Skip to content

fix(storage): resumable, exclusive raw-replay rebuild + status surface - #3641

Merged
Sinity merged 7 commits into
masterfrom
feature/storage/raw-replay-resumable-lease
Aug 3, 2026
Merged

fix(storage): resumable, exclusive raw-replay rebuild + status surface#3641
Sinity merged 7 commits into
masterfrom
feature/storage/raw-replay-resumable-lease

Conversation

@Sinity

@Sinity Sinity commented Aug 3, 2026

Copy link
Copy Markdown
Owner

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-index CLI 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/ActiveWriterLease mutual 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, RebuildLease wrapping the whole pass) built since those notes were written -- but two concrete gaps remained:

  1. next_raw_page's SQL selected 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. This is the concrete shape the bead names: "10,362/17,788 committed... 6,946 false-missing".
  2. There was no consolidated status surface reporting lease ownership, active generation, schema, transaction cursor/delta, or explicit stale-lock recovery guidance from one place.

Solution

  • polylogue/storage/index_generation.py: next_raw_page's SQL now excludes a raw only when EVERY persisted raw_session_memberships 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 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-known DAEMON_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-status renders the above in plain or JSON form.
  • Regenerated docs/plans/topology-target.yaml (new module) and documented the new command in docs/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+fsync durability pattern source.db/user.db writers use, not a SQLite table. I did not migrate this to ops.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

  1. Raw replay acquires the writer lease before its first write and holds it through final parity/activation; competing writers fail visibly, restoring a bypass fails the fixture. SATISFIED (pre-existing + newly proven). _rebuild_index_from_source_owned already wraps its entire body (replay, terminal FTS-parity/readiness stages, and promotion) in with RebuildLease(root):. Added tests/unit/maintenance/test_rebuild_index_lease_lifecycle.py, which monkeypatches repair_session_insights (a terminal stage that runs after replay commits and before promotion) to attempt a concurrent ActiveWriterLease.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.
  2. Killing after a committed batch and resuming processes exactly the uncommitted suffix plus rows acquired after the snapshot; cursor cannot precede batch commit. SATISFIED (pre-existing, verified by reading and by existing test). IndexGenerationStore.checkpoint_transaction is only ever called with the page's results AFTER replay_source has returned/committed; tests/unit/maintenance/test_rebuild_index_deadline.py::test_rebuild_index_deadline_stops_mid_page_and_resumes_without_omission_or_duplication already 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.
  3. Superseded historical revisions are not selected as resume debt, while a genuinely accepted unindexed revision is. SATISFIED (this PR). next_raw_page's superseded-exclusion fix above, with 4 new tests in tests/unit/storage/test_index_generation.py::TestNextRawPageExcludesSupersededResumeDebt covering: 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.
  4. Final resumed output is byte/semantic-parity with a clean rebuild, no active daemon. SATISFIED (pre-existing). The terminal stage sequence (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).
  5. Status reports owner/build/unit/process/archive/schema/generation/heartbeat/cursor/delta/recovery; stale-lock recovery is explicit. SATISFIED for the core fields (this PR): owner (lease holder pid/host/liveness), archive root, schema (index user_version), generation (active generation metadata), heartbeat (transaction updated_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.
  6. The sanitized 10,362/17,788 failure and 6,946 false-missing shape pass; mutation removes lease, cursor atomicity, delta boundary, or authority classification and fails. SATISFIED via targeted regression + manual mutation verification, not an automated mutation-testing harness: (a) reverting the next_raw_page superseded-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 if RebuildLease's scope is narrowed, by construction, since the probe would then observe ActiveWriterLease.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 passed
  • 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 verify --quick -> exit_code: 0 (22/22 steps green, including render all, verify docs-coverage, verify layering)
  • Manual CLI smoke test: invoking polylogue.cli.main with argv=['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_check in tests/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

Sinity and others added 7 commits August 3, 2026 13:10
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>
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@Sinity, you've reached your PR review limit, so we couldn't start this review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1de8d707-8b31-43ae-bbf3-8364c2030771

📥 Commits

Reviewing files that changed from the base of the PR and between 5cea156 and 1f46135.

📒 Files selected for processing (9)
  • docs/design/convergence-simplification-inventory.md
  • docs/plans/topology-target.yaml
  • polylogue/cli/commands/maintenance/__init__.py
  • polylogue/cli/commands/maintenance/_rebuild_index_status.py
  • polylogue/maintenance/rebuild_index.py
  • polylogue/storage/index_generation.py
  • tests/unit/maintenance/test_rebuild_index_lease_lifecycle.py
  • tests/unit/maintenance/test_rebuild_status.py
  • tests/unit/storage/test_index_generation.py

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Sinity
Sinity merged commit b44c2e8 into master Aug 3, 2026
2 of 3 checks passed
@Sinity
Sinity deleted the feature/storage/raw-replay-resumable-lease branch August 3, 2026 11:52
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.

1 participant