fix(storage): resumable, exclusive raw-replay rebuild + status surface - #3641
Conversation
Problem: IndexGenerationStore.next_raw_page (the raw-replay rebuild's paged keyset cursor, used by both the offline `rebuild-index` CLI and the daemon's own bulk-rebuild loop) scheduled every row in `raw_sessions` unconditionally, including raws whose `raw_session_memberships.decision` is already durably `superseded_equivalent`/`superseded_prefix`. Those raws never gain their own `index.sessions` row (only their cohort's accepted head does), so every rebuild pass re-scheduled and re-parsed them for nothing -- a live archive carried 6,946 such rows. Solution: next_raw_page's SQL now excludes a raw only when EVERY persisted membership row for it is superseded, reusing the closed `MembershipDecision` vocabulary `classify_membership_revisions` already writes back to durable source.db (no rebuild-local classifier). A raw with no membership row (never censused) or with at least one non-superseded row remains eligible, so a genuinely accepted-but- unindexed or still-pending raw is never dropped. The filter lives inside the same query as the keyset cursor, so pagination correctness is unaffected. Verification: devtools test tests/unit/storage/test_index_generation.py -k NextRawPage -> 4 passed. Confirmed red-first: reverting the index_generation.py change while keeping the new tests fails 2 of 4 (test_fully_superseded_raw_is_excluded_from_the_page, test_exclusion_survives_the_keyset_cursor_across_pages). Ref polylogue-b5l.1 Co-Authored-By: Claude <noreply@anthropic.com>
Problem: polylogue-b5l.1 AC1 requires the raw-replay rebuild (rebuild_index_from_source, the ops reset --index && polylogued run path) to hold the archive-root writer lease from its first write through final parity/activation -- not merely check it once at entry, which is exactly the narrow point-in-time-check shape the 2026-07-10 competing-daemon incident exhibited. PR #2872 proved this property for the clone-forward fast-forward path only; the raw-replay path had no equivalent regression test even though `with RebuildLease(root):` already wraps the whole pass body. Solution: a new regression test monkeypatches polylogue.storage.repair.repair_session_insights -- a terminal stage that runs after replay has committed rows and before FTS parity/readiness/promotion -- to attempt a concurrent ActiveWriterLease.acquire() from inside the pass, and asserts it fails. This proves the lease is held deep inside the pass, not just at the top, and that it is released again once the pass returns. Verification: devtools test tests/unit/maintenance/test_rebuild_index_lease_lifecycle.py -> 1 passed. Ref polylogue-b5l.1 Co-Authored-By: Claude <noreply@anthropic.com>
Problem: polylogue-b5l.1 AC5 requires the raw-replay rebuild to expose lease ownership for status/recovery surfaces, and to make stale-lock recovery explicit. There was no way to inspect the lease (held/holder/liveness) without either blocking or, worse, risking a reclaim as a side effect of merely looking. Solution: RebuildLeaseStatus + rebuild_lease_status(archive_root) in polylogue/storage/index_generation.py. It attempts a non-blocking flock: success means nothing holds the lease (released immediately after the probe); failure means it is genuinely held, and the lock file's recorded pid/host are reported alongside a liveness check against that pid, with `stale=True` when the recorded holder is provably dead (the same condition RebuildLease.__enter__ already reclaims on next acquisition). Never disturbs a real holder and never blocks. Verification: devtools test tests/unit/storage/test_index_generation.py -k RebuildLeaseStatus -> 6 passed. Ref polylogue-b5l.1 Co-Authored-By: Claude <noreply@anthropic.com>
…face Problem: polylogue-b5l.1 AC5 requires the raw-replay rebuild to report owner/build/archive/schema/generation/heartbeat/cursor/delta/recovery from one place, with explicit stale-lock recovery guidance. Previously an operator had to hand-cross-reference .index-rebuild.lock, .index-active-pointer, and a transaction JSON file under .index-rebuild-transactions/ separately, with no delta/recovery synthesis at all. Solution: rebuild_status(archive_root, operation_id=None, include_daemon_bulk_rebuild=True) in polylogue/maintenance/rebuild_index.py assembles: the read-only lease probe (RebuildLeaseStatus from the prior commit), the active generation's metadata, the active index's schema (PRAGMA user_version), the resumable transaction (defaulting to the daemon's well-known DAEMON_BULK_REBUILD_OPERATION_ID when no operation_id is given -- the ops reset --index && polylogued run case never has an explicit one to pass), a source-snapshot delta comparison, and a recovery message list covering a stale (dead-pid) lease, a failed transaction, and a drifted source snapshot. Entirely read-only: never acquires RebuildLease, never mutates a transaction or generation. Verification: devtools test tests/unit/maintenance/test_rebuild_status.py -> 6 passed. Ref polylogue-b5l.1 Co-Authored-By: Claude <noreply@anthropic.com>
Problem: polylogue-b5l.1 AC5 requires an operator/agent-facing surface for the consolidated rebuild_status payload (lease, generation, schema, cursor/delta, recovery), not just a library function nothing calls. Solution: `polylogue ops maintenance rebuild-index-status` renders polylogue.maintenance.rebuild_index.rebuild_status in plain or JSON form. Read-only; --operation-id selects which transaction to report, defaulting to the daemon's well-known bulk-rebuild operation id. Regenerated docs/plans/topology-target.yaml (devtools render topology-projection) for the new module. Verification: manual smoke test via `python3 -c "... from polylogue.cli import main; main()"` with POLYLOGUE_ARCHIVE_ROOT set to a scratch dir, both plain and --output-format json, confirms correct output. devtools render all --check -> exit 0 (grepped for "out of sync": none). devtools test tests/unit/cli/ -k maintenance -> 159 passed, 1 failed (test_verify_archive_cli_json_reports_every_registered_check, in tests/unit/cli/test_maintenance_verify_archive_cli.py -- untouched by this diff, unrelated to rebuild-index status; last touched by PR #3529 which predates and is unrelated to this change). Ref polylogue-b5l.1 Co-Authored-By: Claude <noreply@anthropic.com>
Problem: devtools verify --quick's mypy step failed on the two new test files added for polylogue-b5l.1 -- indexing an object-typed dict value without a narrowing isinstance check, and a monkeypatched repair_session_insights replacement typed with a bare *args/**kwargs signature that mypy correctly rejected against the real function's concrete keyword-only signature. Solution: isinstance-narrow status["lease"] before indexing it (same pattern already used elsewhere in this file); give probing_repair_session_insights the exact same signature as repair_session_insights (config, dry_run, then the five keyword-only params) instead of *args/**kwargs. Verification: python3 -m mypy tests/unit/maintenance/test_rebuild_status.py tests/unit/maintenance/test_rebuild_index_lease_lifecycle.py polylogue/maintenance/rebuild_index.py polylogue/storage/index_generation.py polylogue/cli/commands/maintenance/_rebuild_index_status.py -> Success: no issues found in 5 source files. devtools test tests/unit/maintenance/ test_rebuild_status.py tests/unit/maintenance/test_rebuild_index_lease_lifecycle.py tests/unit/storage/test_index_generation.py -> 41 passed. Ref polylogue-b5l.1 Co-Authored-By: Claude <noreply@anthropic.com>
Problem: devtools verify docs-coverage failed after adding `ops maintenance rebuild-index-status` -- every public CLI command must be reachable (named verbatim) from README.md or docs/**/*.md, and the new command had zero doc footprint. Solution: document it alongside the existing `rebuild-index` inventory entry in docs/design/convergence-simplification-inventory.md, since that is where operational tooling for this command family is already inventoried. Verification: devtools verify docs-coverage -> "every public CLI command, MCP tool, config key, and stable route is reachable". Ref polylogue-b5l.1 Co-Authored-By: Claude <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 26 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (9)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary
Closes the concrete, unimplemented residual of polylogue-b5l.1 (make raw-replay rebuild exactly resumable and visibly exclusive): the paged keyset cursor that drives both the offline
rebuild-indexCLI and the daemon's own bulk-rebuild loop now excludes durably-superseded revisions from resume debt, and a new read-only status surface consolidates lease/generation/schema/cursor/delta/recovery reporting that previously required hand-cross-referencing several files.Problem
PR #2872 already proved
RebuildLease/ActiveWriterLeasemutual exclusion for the clone-forward fast-forward path only (devtools/archive_schema_fast_forward.py), explicitly leaving the raw-replay rebuild path (polylogue ops reset --index && polylogued run,polylogue/maintenance/rebuild_index.py) as this bead's residual scope. Reading the current source (not assuming from the bead's older notes) showed the raw-replay path already had substantial resumable-transaction machinery (IndexGenerationStore.next_raw_page/checkpoint_transaction,RebuildLeasewrapping the whole pass) built since those notes were written -- but two concrete gaps remained:next_raw_page's SQL selected every row inraw_sessionsunconditionally, including raws whoseraw_session_memberships.decisionis already durablysuperseded_equivalent/superseded_prefix. Those raws never gain their ownindex.sessionsrow (only their cohort's accepted head does), so every rebuild pass re-scheduled and re-parsed them for nothing. This is the concrete shape the bead names: "10,362/17,788 committed... 6,946 false-missing".Solution
polylogue/storage/index_generation.py:next_raw_page's SQL now excludes a raw only when EVERY persistedraw_session_membershipsrow for it is superseded, reusing the closedMembershipDecisionvocabularyclassify_membership_revisionsalready writes back to durablesource.db-- no rebuild-local classifier. A raw with no membership row (never censused) or at least one non-superseded row remains eligible, so a genuinely accepted-but-unindexed or still-pending raw is never dropped. The filter lives inside the same query as the keyset cursor, so pagination correctness is unaffected.polylogue/storage/index_generation.py:RebuildLeaseStatus/rebuild_lease_status()-- a read-only, non-blocking probe of the lease (held/holder pid+host/liveness/staleness) that never disturbs a genuine holder.polylogue/maintenance/rebuild_index.py:rebuild_status()assembles the lease probe, active generation, active index schema version (PRAGMA user_version), the resumable transaction (defaulting to the daemon's well-knownDAEMON_BULK_REBUILD_OPERATION_ID), a source-snapshot delta, and recovery guidance (stale lease, failed transaction, drifted source snapshot).polylogue/cli/commands/maintenance/_rebuild_index_status.py:polylogue ops maintenance rebuild-index-statusrenders the above in plain or JSON form.docs/plans/topology-target.yaml(new module) and documented the new command indocs/design/convergence-simplification-inventory.md(docs-coverage gate).Alternative rejected: the bead's design note suggested persisting the source-snapshot vector/cursor/delta boundary "in ops.db". The existing (already-landed, pre-dating this PR) transaction machinery persists this in JSON files under
.index-rebuild-transactions/with the same tmp+os.replace+fsyncdurability patternsource.db/user.dbwriters use, not a SQLite table. I did not migrate this toops.db; it already satisfies the durability/atomicity intent (survives a kill, atomic commit, no partial state), and moving it would be a large, orthogonal storage-location change with no correctness benefit. Noting here rather than silently reframing the AC.Acceptance criteria matrix
_rebuild_index_from_source_ownedalready wraps its entire body (replay, terminal FTS-parity/readiness stages, and promotion) inwith RebuildLease(root):. Addedtests/unit/maintenance/test_rebuild_index_lease_lifecycle.py, which monkeypatchesrepair_session_insights(a terminal stage that runs after replay commits and before promotion) to attempt a concurrentActiveWriterLease.acquire()mid-pass and asserts it fails, proving the lease is held deep inside the pass, not just checked at entry (the exact narrow-check shape the 2026-07-10 incident exhibited), and that it releases cleanly afterward.IndexGenerationStore.checkpoint_transactionis only ever called with the page's results AFTERreplay_sourcehas returned/committed;tests/unit/maintenance/test_rebuild_index_deadline.py::test_rebuild_index_deadline_stops_mid_page_and_resumes_without_omission_or_duplicationalready proves an interrupted pass leaves the cursor unadvanced (processed_raw_count == 0) and a resumed pass completes with the exact final count (no omission or duplication). I did not duplicate this coverage; I verified it and it remains green.next_raw_page's superseded-exclusion fix above, with 4 new tests intests/unit/storage/test_index_generation.py::TestNextRawPageExcludesSupersededResumeDebtcovering: a fully-superseded raw excluded, a never-censused raw still eligible, a raw superseded in one cohort but pending in another still eligible, and the exclusion holding across the keyset cursor's page boundary.repair_session_insights-> bulk-build derived-store repopulate ->verify_archive(checks=["fts-parity"])->archive_readiness_status) runs identically whether a pass is the first or a resumed one, and raises before promotion on any parity/readiness failure; unaffected by this PR's changes (verified by running the existing suite green, not re-derived from scratch).user_version), generation (active generation metadata), heartbeat (transactionupdated_at_ms), cursor (last_raw_id/processed_raw_count/processed_blob_bytes), delta (source-snapshot match), and recovery (explicit stale-lease / failed-transaction / drifted-snapshot messages). DEFERRED: "build" (executable/package version) and "unit"/"process" (systemd unit name, live process introspection beyond the lease-holder pid already reported) are not wired -- they would require either a live systemd/process query (hard to test meaningfully in a unit-test harness and orthogonal to the lease-correctness property this bead is about) or an arbitrary constant. Flagging as explicitly out of scope rather than silently omitting.next_raw_pagesuperseded-exclusion change while keeping the new tests fails 2 of 4 (test_fully_superseded_raw_is_excluded_from_the_page,test_exclusion_survives_the_keyset_cursor_across_pages); verified directly during development via a revert-and-rerun cycle; (b) the lease-lifecycle test fails ifRebuildLease's scope is narrowed, by construction, since the probe would then observeActiveWriterLease.acquire()succeeding; (c)test_rebuild_index_deadline_stops_mid_page_and_resumes_without_omission_or_duplication's own docstring names its mutation (reverting the pre-hoc deadline check, or advancing the cursor on interrupt) and I confirmed it stays green against current source.Verification
devtools test tests/unit/storage/test_index_generation.py tests/unit/maintenance/test_rebuild_status.py tests/unit/maintenance/test_rebuild_index_lease_lifecycle.py tests/unit/maintenance/test_rebuild_index_selection.py tests/unit/maintenance/test_rebuild_index_deadline.py tests/unit/maintenance/test_rebuild_index_bulk_build.py tests/unit/maintenance/test_rebuild_index_ownership.py tests/unit/maintenance/test_rebuild_index_phase_timing.py->56 passedpython3 -m mypy tests/unit/maintenance/test_rebuild_status.py tests/unit/maintenance/test_rebuild_index_lease_lifecycle.py polylogue/maintenance/rebuild_index.py polylogue/storage/index_generation.py polylogue/cli/commands/maintenance/_rebuild_index_status.py->Success: no issues found in 5 source filesdevtools verify --quick->exit_code: 0(22/22 steps green, includingrender all,verify docs-coverage,verify layering)polylogue.cli.mainwithargv=['polylogue','ops','maintenance','rebuild-index-status']against a scratch archive root, both plain and--output-format json, confirmed correct rendering.devtools test tests/unit/cli/ -k maintenance-> 159 passed, 1 pre-existing failure (test_verify_archive_cli_json_reports_every_registered_checkintests/unit/cli/test_maintenance_verify_archive_cli.py) unrelated to this diff -- last touched by PR fix(cli): print archive-root provenance on every ops maintenance command #3529, concerns an unrelated registered-check inventory drift.Ref polylogue-b5l.1