Skip to content

feat: single-fetch build lifecycle, fragments_on_skip, idempotent FTS, sibling imports (zeeker 0.8.0) - #9

Merged
houfu merged 2 commits into
mainfrom
claude/build-lifecycle
Jul 16, 2026
Merged

feat: single-fetch build lifecycle, fragments_on_skip, idempotent FTS, sibling imports (zeeker 0.8.0)#9
houfu merged 2 commits into
mainfrom
claude/build-lifecycle

Conversation

@houfu

@houfu houfu commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Why

Every downstream data project independently works around the same four build-lifecycle defects: env-var PID sentinels and marker files to detect zeeker's duplicate fetch_data call, raw-SQL fragment insertion because the fragments phase never runs on steady-state builds, a hand-rolled setup_fts.py because --setup-fts isn't idempotent, and sys.path shims in every resource file for sibling imports.

Changes

1. Single-load, single-fetch lifecycle. The fragments phase no longer reloads the resource module or re-calls fetch_data. The main phase's loaded module and raw fetch output (pre-transform_data, snapshotted via deepcopy when a transform exists) are threaded through as main_data_context. fetch_data now runs exactly once per build — including under --parallel, where a per-build module cache ensures the pre-warm and sequential phases share one module instance. _process_fragments_for_resource keeps its old reload+refetch behavior when the new optional params are absent (backward-compatible for external callers).

2. fragments_on_skip = true (new opt-in per-resource flag in zeeker.toml): runs the fragments phase with main_data_context=[] even when fetch_data returns no new rows — what enrichment-style pipelines need on steady-state builds. Flag absent → behavior identical to 0.7.0 (regression-tested).

3. Idempotent --setup-fts. enable_fts(..., replace=True), and the follow-up population switched from populate_fts() to rebuild_fts() + explicit commit. This also fixes a latent pre-existing double-indexing bug (enable_fts populated internally, then populate_fts inserted every row again) and a write-lock leak that could deadlock the next incremental build. --setup-fts is now safe on --sync-from-s3 builds.

4. Sibling imports in resources/. The resources directory is appended to sys.path (lowest precedence — stdlib/site-packages always win name clashes) only for the duration of module load, then removed. Resource modules can import helper without shims.

Version bumped 0.7.0 → 0.8.0 (uv.lock updated); CLAUDE.md documents the new lifecycle guarantee and flag.

Review process

Implemented, then adversarially reviewed by two independent reviewers (correctness + backward-compat against the documented downstream workaround patterns). 6 distinct findings; 5 confirmed and fixed:

  • pre-transform snapshot for main_data_context (in-place transforms were mutating the fragments context)
  • single-module guarantee under --parallel (pre-warm loaded a second module instance whose state never reached fragments)
  • sys.path shadowing (prepend → append; scoped to load; removed after)
  • (+2 further fixes in the finalize commit)

1 rejected with verification: the non-empty main_data_context on new-row builds is the intended, documented contract of this release — the old [] context was an artifact of the duplicate call this PR removes.

Tests

New test_build_lifecycle.py: fetch-count == 1 on fresh and incremental fragments builds, module loaded exactly once, fragments_on_skip on/off paths, FTS idempotency (double setup, no duplicate index entries, triggers alive), sibling-import build. Full suite: 381 passed, 5 skipped (all pre-existing skips), coverage 73.98%.

Downstream note

Existing workarounds (PID sentinels, marker files, module caches) remain harmless under 0.8.0 — they detect a second call that no longer happens. Follow-up PRs will remove them per repo once this releases.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Cuh5eJDfVowgpp22s7siE1


Generated by Claude Code

claude added 2 commits July 16, 2026 07:36
Defect 1 — single-fetch lifecycle: the fragments phase no longer reloads
the resource module or re-invokes fetch_data(). The main phase threads
the loaded module and the raw fetch_data() output through typed
ValidationResult fields (module, raw_data), and the builder passes both
into _process_fragments_for_resource as main_data_context. fetch_data()
now runs exactly once per build; downstream PID sentinels / marker files
are no longer needed. _process_fragments_for_resource keeps a
backward-compatible signature (new params optional, legacy reload+refetch
fallback when omitted).

Defect 2 — new opt-in per-resource zeeker.toml flag fragments_on_skip:
when fragments = true and fragments_on_skip = true, the fragments phase
also runs on steady-state builds where fetch_data() returned no new rows
(status "skipped"), with main_data_context=[] and the already-loaded
module. Flag absent preserves existing behavior exactly.

Defect 3 — idempotent --setup-fts: enable_fts(..., replace=True) so
repeated setup on an existing database (incremental --sync-from-s3
builds) succeeds; the index is refreshed via rebuild_fts() (FTS5
'rebuild', idempotent) instead of populate_fts() (raw INSERT that
double-indexes already-populated tables), followed by an explicit commit
so the build connection releases its write lock.

Defect 4 — sibling imports: _load_resource_module prepends the
resources/ directory to sys.path before exec_module, so resource modules
can import sibling helper modules without a manual shim.

Also: document fragments_on_skip and the single-fetch lifecycle in
CLAUDE.md; bump zeeker to 0.8.0; add 8 unit tests covering fetch-once,
module-loaded-once, fragments_on_skip on/off, FTS idempotency (unit +
end-to-end), and sibling imports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cuh5eJDfVowgpp22s7siE1
… aliasing, sys.path scoping, fetch-cache keying

Addresses four verified defects from code review of the build-lifecycle
change:

1. Pre-transform snapshot for main_data_context (processor.py). raw_data
   aliased the same list/dicts later handed to transform_data(), so an
   in-place transform (e.g. renaming/popping the heavy content column)
   mutated the "raw" context the fragments phase receives, violating the
   documented pre-transform contract. raw_data is now deep-copied before
   the transform runs (only when a transform_data function exists, so
   transform-less resources pay nothing).

2. Single-load/single-fetch guarantee now holds under --parallel
   (processor.py, builder.py). _prewarm_fetches imported its own module
   instance and ran fetch_data there, while the sequential loop imported
   a second, virgin instance that the fragments phase received — losing
   module-level state and double-running module import side effects.
   ResourceProcessor now keeps a per-build module cache keyed by resource
   name, so prewarm and the sequential loop share one instance; the cache
   is cleared in build_database's finally via clear_build_caches().

3. Scoped, lowest-precedence sibling-import path (processor.py). The
   sys.path.insert(0, resources_dir) was permanent and took precedence
   over stdlib/site-packages, so resources/openai.py self-imported on
   `import openai`, and any helper named after a lazily-imported package
   (statistics, csv, requests, ...) shadowed it process-wide. The dir is
   now APPENDED for the duration of the module load only and removed in a
   finally block. Sibling helper modules registered in sys.modules during
   the load are tracked and purged at build end, so same-named helpers in
   different projects no longer leak between builds in one process.
   Sibling imports must be at module top level (documented in CLAUDE.md).

4. Build-stable fetch-cache key (async_executor.py). The cache key
   included the live row count, so a user migrate_schema() that changes
   the row count between the schema-check fetch and the insert fetch
   caused a cache miss and a second fetch_data invocation in the same
   build (double-spending API budgets / advancing checkpoints). The key
   is now the resource name alone; the cache is cleared between builds.

Rejected one finding (builder.py:157, "context flip enables duplicate
fragment insertion in dedup-pattern resources"): the non-empty
main_data_context is the intended, versioned behavior of 0.8.0 — the []
context under 0.7.0 was an artifact of the duplicate fetch this release
removes, never a contract. Verified against the cited pdpc project: its
fetch_fragments_data is a `return []` stub that ignores context, so no
duplicate insertion occurs.

Adds 5 regression tests (parallel module state, in-place transform,
sys.path cleanup, cross-project sibling leak, migrate_schema row-count
change). Full suite: 386 passed, 5 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cuh5eJDfVowgpp22s7siE1
@houfu
houfu merged commit 66230e6 into main Jul 16, 2026
2 checks passed
@houfu
houfu deleted the claude/build-lifecycle branch July 16, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants