Skip to content

perf(daemon): bound catch-up chunk convergence to the chunk's own input - #4694

Merged
Sinity merged 4 commits into
masterfrom
lane/perf-convergence
Sep 5, 2026
Merged

perf(daemon): bound catch-up chunk convergence to the chunk's own input#4694
Sinity merged 4 commits into
masterfrom
lane/perf-convergence

Conversation

@Sinity

@Sinity Sinity commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Summary

During a chunked catch-up every chunk paid archive-wide convergence costs, so per-chunk convergence grew with the archive. Whole-archive stages now run once per catch-up (on the last chunk), the archive-wide FTS readiness audit is its own deferred stage, hook-paste enrichment reads only the batch's sessions' sidecars, FTS partition inspection uses the block_id index instead of a substr scan, the raw-authority warmer finds its work in two statements (its raw_id collision is fixed by #4693, on which this branch is rebased), the periodic whole-archive FTS sweep waits while catch-up is active, and the convergence ledger now attributes stage check time. A separate defect found on the way: a catch-up with no source file modified in the last hour ingested nothing.

Problem

Rehearsal (fresh daemon, real sources, /realm/tmp/work/rehearsal-4): chunk 1 parse 1.1 s / convergence 5.6 s; chunk 150 parse 23.6 s / convergence 62.9 s for 1.9 MB; average over 150 chunks parse 10.6 s, convergence 20.2 s, both growing with archive size (305 sessions, 45,657 messages). Chunk 149 held the writer 103.9 s while its stage ledger summed to 2.5 s.

Profile (in-process sampler on a scratch daemon over 400 real files; SQL timed against a copy of the rehearsal's 359-session index):

  • enrich_paste_from_hooks after every chunk scanned every sidecar journal in hooks/ (371 files, 1.65 GB): 4.5 s warm, 72.9 s once the page cache was evicted. Untimed (outside the stage ledger); this is the size of chunk 149's unattributed time.
  • fts_invariant_snapshot_sync (five whole-table aggregates over blocks/messages_fts_docsize/identity) ran twice per chunk, from FtsConvergenceOwner._publish_readiness_projection and _record_fts_freshness_after_insights: 0.6 s per call at 90k blocks, 49 % of scratch convergence time (98 s of 200 s).
  • FTS partition inspect/publish selected a session's identity rows with substr(block_id, 1, length(?)+1) = ? || ':': SCAN messages_fts_identity per key, 10.5 ms at 85k rows vs 0.1 ms for the index range.
  • raw_authority_verdict_cache.check_many probed every cohort with two statements (532 cohorts at chunk 150), and its warmup failed 54 times with UNIQUE constraint failed: raw_authority_verdicts.raw_id, re-running the whole-archive check on every following chunk.
  • claude_workflow and delegation_work_evidence re-read every raw artifact / every delegation row per chunk.
  • maintenance.fts_convergence (whole-archive inspect_all) held the writer 1061 s across 40 runs (max 132 s); chunks waited 1977 s on the writer in total.
  • Cold-start defect: _catch_up broke out of its (hot, cold) group loop on the first empty group, so a backlog with no file modified in the last hour was never planned. The after-run reproduced it (0 attempts, 0 cursors, catch_up_complete set after 40 s).

Solution

  • ConvergenceStage.whole_archive; DaemonConverger.converge_batch(whole_archive=False) records such stages SKIPPED (converged, no debt: their staleness is re-derived from content). The watcher runs every catch-up chunk but the last in that scope. Stages flagged: raw_authority_verdict_cache, claude_workflow, delegation_work_evidence, and the new fts_readiness.
  • make_fts_readiness_stage publishes the exact archive-wide FTS audit once per pass, after fts and derived; partition-scoped FTS passes and insight rebuilds no longer publish it. _periodic_convergence_check skips the whole-archive FTS sweep while watcher.catch_up_active.
  • enrich_paste_from_hooks(db, session_ids=...) reads only *-<native_id>.jsonl for the batch's sessions (session_ids_by_path on append, changed_session_ids on full ingest).
  • FTS derivation: [key || ':', key || ';') range on the block_id UNIQUE index in inspect (excess, duplicates) and publish.
  • find_raw_authority_verdict_cache_work: one pass over raw_sessions and one over raw_authority_verdicts (master's fix(storage): a re-keyed raw replaces its stale verdict-cache row #4693 writer, which removes a re-keyed raw's stale row, is kept as is).
  • Converger ledger: <stage>.check and hook_paste_enrichment timings.
  • Watcher: an empty priority group is skipped; only a stop request ends the loop.

Verification

  • .venv/bin/python -m devtools verify --quick -> all 13 gates ok (format, lint, mypy, generated-surfaces, layering, patterns, doc-commands, schema-manifest, oracle-integrity, testmon-selection, consumer-reachability, timestamp-doctrine, schema-privacy).
  • .venv/bin/python -m devtools test tests/unit/daemon/test_catch_up_chunk_cost.py tests/unit/daemon/test_daemon_convergence.py tests/unit/sources/test_hook_paste_enrichment.py tests/unit/storage/test_fts_derivation.py tests/unit/storage/test_raw_authority_verdict_cache.py tests/unit/daemon/test_convergence_stages.py tests/unit/daemon/test_fts_readiness_fallback.py tests/unit/daemon/test_fts_identity_convergence.py tests/unit/sources/test_live_batch_convergence.py tests/unit/pipeline/test_ingest_batch_fts_repair.py tests/unit/sources/test_live_catchup_planning.py tests/unit/sources/test_live_watcher.py tests/unit/sources/test_live_hot_session_convergence.py tests/unit/daemon/test_daemon_cli.py -> 1 failed, 378 passed in 252.37s on head 1a0ac23 (pueue 1655, .cache/verify/pytest-slot-1672251.log); the one failure, test_daemon_cli.py::test_spool_pending_check_ignores_terminal_cursor_states, is inherited at base 754553b and fixed by test: follow the merged runner rename, corpus width, and read-only cursor probe #4695 (the test lambda lacked **_kwargs)
  • New tests: test_chunk_convergence_cost_does_not_grow_with_archive_size (real live-ingest route, archives of 2 and 14 sessions, one 2-file chunk: product SQL statements inside _converge_paths equal, 2 sidecars read, 0 snapshot calls, no deferred stage in the ledger; reverting any deferral adds two statements per cohort / one per artifact row), test_final_catch_up_chunk_runs_the_whole_archive_stages, converger chunk-scope and check-ledger, sidecar scoping, FTS EXPLAIN QUERY PLAN has no SCAN of the identity ledger, raw-authority rebinding and two-statement work finder, cold-backlog catch-up, last-chunk whole_archive_convergence flag.

Scratch run (same host, same 400 files / 325 MB from ~/.claude/projects and ~/.codex/sessions, 100 catch-up chunks, polylogued run with an in-process sampler):

before (dfb1322) after
convergence total 200.4 s 64.1 s
convergence per chunk, by 20-chunk window 1.61 / 1.89 / 2.14 / 2.60 / 1.78 s 0.71 / 0.56 / 0.70 / 0.69 / 0.54 s
fts_invariant_snapshot_sync inside the chunk path 62 s 0 s (once, on the last chunk)
raw-authority warm inside the chunk path 40 s 0 s
periodic whole-archive FTS sweeps during catch-up 16 0
parse total 406.0 s 534.0 s (three chunks at 79 / 57 / 39 s in plain messages/blocks inserts; a full-corpus pytest ran on the host during the after run and was OOM-killed, pueue 1636 exit 137)
end to end 0.54 MB/s 0.55 MB/s (0.77 MB/s without the three contended chunks)

Per-chunk convergence no longer grows with the archive. End-to-end throughput on this scratch is parse-bound (0.61–0.80 MB/s parse-only), so the 2x-of-parse criterion from polylogue-t4iy5.11.1 still needs the rehearsal driver on a quiet host for its operational proof.

Residuals

  • The medium health tier (_check_fts_readiness_medium) recomputes the exact archive-wide FTS audit on every status payload: 51 s of the after-run's profile, all from /api/status and the status collector. The rehearsal driver probes status every 60 s; at 5M blocks each probe would take tens of seconds. It should read the freshness ledger the fts_readiness stage publishes.
  • Index write cost is per-session CPU (parser-source fingerprinting is 22 % of index_parsed_write; messages/blocks inserts with 18 secondary indexes) plus page-cache-miss stalls under host memory pressure; no whole-archive statement was found on the write path.
  • claude_workflow and delegation_work_evidence still rebuild from the whole archive whenever they run (once per catch-up now, then per live batch).
  • If a catch-up stops before its last chunk, the deferred stages run on the next live batch or catch-up rather than immediately.

Beads: polylogue-t4iy5.11.1, polylogue-623q

🤖 Generated with Claude Code

https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid

@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Team

Run ID: cdbe5915-827f-4cb1-ba38-94a3c38a26e7


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.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-05T18:21:18.896198Z 1a0ac23 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d9ae9e0393

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +697 to +699
if chunk_index != len(chunks):
ingest_kwargs["whole_archive_convergence"] = False
metrics = await self._ingest_files(chunk_paths, **ingest_kwargs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Carry Claude triggers into the final whole-archive pass

In the catch-up path, a backlog with one changed Claude Workflow artifact and more than one chunk of Codex files can place the Claude artifact in an early round-robin chunk and only Codex paths in the last chunk. This marks the Claude stage SKIPPED without debt in the early chunk, while the final pass receives only its own paths; make_claude_workflow_stage.check_many() then finds no relevant Claude candidate and never rebuilds the archive-wide workflow graph. Preserve relevant triggers from prior chunks or run the final whole-archive stages independently of the last chunk's path set.

AGENTS.md reference: AGENTS.md:L109-L115

Useful? React with 👍 / 👎.

Comment on lines 1779 to +1781
from polylogue.sources.live.hook_paste_enrichment import enrich_paste_from_hooks

enrich_paste_from_hooks(self._cursor._db_path)
enrich_paste_from_hooks(self._cursor._db_path, session_ids=tuple(dict.fromkeys(session_ids)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retain late hook events until their session is enriched

When a UserPromptSubmit journal entry arrives after session A's transcript batch, and the next live batch changes only session B, this call scans only B's journal. No hook-event route invokes this enrichment directly, so if A receives no later transcript update its message remains observably has_paste = 0 indefinitely; the previous archive-wide scan would process A during B's batch. Track pending hook sessions or otherwise consume the durable hook evidence without restricting it solely to the current ingest's session IDs.

AGENTS.md reference: AGENTS.md:L109-L115

Useful? React with 👍 / 👎.

Comment on lines 1164 to +1168
"watcher.live_ingest.full_convergence",
self._converge_paths,
full_result.succeeded,
whole_archive=whole_archive_convergence,
session_ids=full_result.changed_session_ids,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Run whole-archive stages only once after the final batch

When the final catch-up chunk contains changed files from multiple full-ingest source groups, or an append group plus full-ingest groups, ingest_files() invokes _converge_paths() separately for each group and forwards whole_archive_convergence=True every time. Consequently the exact FTS audit, delegation projection, and raw-authority scan can each traverse the entire archive several times in the nominally final chunk, so its cost is multiplied by the number of internal groups rather than being the promised single pass. Hoist the whole-archive pass after all groups or otherwise permit it only once.

AGENTS.md reference: AGENTS.md:L109-L113

Useful? React with 👍 / 👎.

Comment on lines 453 to +456
state.stages[stage_name] = StageState.FAILED
state.error_count += 1
else:
_record_stage_times(batch_stage_times, f"{stage_name}.check", time.perf_counter() - t_check, {})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Account for every batch check invocation

For a batch stage such as raw_authority_verdict_cache, returning False while bounded work remains causes check_many() to run a second time during the post-execution recheck, but this records only the initial probe; the same timing is also omitted when the initial probe raises because the recorder is confined to the else branch. On a large archive either omitted scan can dominate convergence time, so the returned <stage>.check ledger does not provide the fully attributed cost promised by converge_batch(). Time each check invocation, including rechecks and exceptional exits.

Useful? React with 👍 / 👎.

Sinity and others added 4 commits September 5, 2026 20:10
Partition inspection and publish selected a session's identity-ledger rows
with substr(block_id, 1, length(key) + 1) = key || ':', which plans as a
full scan of messages_fts_identity for every key on every pass. block_id is
session_id || ':' || ..., so the half-open range [key || ':', key || ';')
selects exactly the same rows through the block_id UNIQUE index (';' is the
code point after ':'). Measured on a 359-session archive: 10.5 ms -> 0.1 ms
per key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid
find_raw_authority_verdict_cache_work probed every logical_source_key with
two point queries, so each daemon convergence check cost O(cohorts)
statements. It now reads the cohort rows and the cached fingerprints in one
pass each and applies the same staleness rule per key.

write_raw_authority_verdict_cache deleted only the cohort's own rows; a raw
already cached under a previous logical_source_key collided with the raw_id
primary key (54 warmup failures in one rehearsal, each re-running the
whole-archive check on the next chunk). Stale rows for the cohort's raw_ids
are removed before the insert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid
The catch-up loop broke out of its priority groups on the first empty
group, so a backlog with no file modified in the last hour was never
planned: a fresh daemon over a quiet source tree logged "watching" and
ingested nothing. An empty group is skipped; only a stop request ends the
loop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid
… input

Per-chunk convergence paid archive-wide costs on every chunk of a catch-up:
the exact FTS readiness audit (whole-table aggregates, run twice per chunk:
after FTS repair and after insights), the raw-authority verdict warmer
(one probe per cohort), the Claude workflow and delegation graphs (rebuilt
from every raw artifact), a scan of every hook sidecar journal (1.65 GB on
the rehearsal spool: 4.5 s warm, 73 s once the page cache was evicted), and
the periodic whole-archive FTS sweep interleaving with chunks. Stage check
time was outside the ledger, so a 102 s chunk showed 2.5 s of stages.

- ConvergenceStage.whole_archive marks stages whose work is a function of
  the archive; converge_batch(whole_archive=False) records them SKIPPED.
  The watcher runs every catch-up chunk but the last in that scope, so
  the archive-wide stages run once per catch-up.
- The readiness audit is its own whole-archive stage (fts_readiness), run
  after fts and derived; partition-scoped FTS passes and insight rebuilds
  no longer publish it. The periodic whole-archive FTS sweep waits while
  a chunked catch-up is active.
- Hook paste enrichment reads only the batch's sessions' sidecar journals
  (<provider>-<native_id>.jsonl).
- The convergence ledger charges <stage>.check and hook_paste_enrichment.

Scratch run, 400 files / 325 MB, 100 chunks, same host: convergence
200.4 s -> see PR body for the after figures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid
@Sinity
Sinity force-pushed the lane/perf-convergence branch from d9ae9e0 to 1a0ac23 Compare September 5, 2026 18:14

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1a0ac2368f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +2516 to 2518
# The rebuild commits its own rows; the exact archive-wide FTS audit is
# published once per whole-archive pass by ``make_fts_readiness_stage``.
conn.commit()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh FTS readiness after derived-only debt retries

When a hot session's stage-specific derived debt later retries, _drain_convergence_debt_once() constructs a converger containing only make_derived_stage, so the new fts_readiness stage never runs. This rebuild can change session_work_events and its trigger-maintained FTS rows while leaving the previously recorded exact counters/readiness unchanged; during an active catch-up the immediately following periodic FTS audit is explicitly suppressed, so status and health surfaces can report the old snapshot until a later sweep. Keep the audit on this standalone derived route or include fts_readiness after a successful derived debt retry.

AGENTS.md reference: AGENTS.md:L111-L115

Useful? React with 👍 / 👎.

Comment on lines +87 to 90
if result.outcome is FtsOutcome.DONE and partition_keys is None:
self._publish_readiness_projection(conn)
state = {
FtsOutcome.DONE: FtsOwnerState.READY_EXACT,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid reporting partition-only FTS repair as archive exact

With partition_keys=("session-a",) and an unrelated stale partition still present, this branch correctly skips the archive-wide readiness projection, but the subsequent mapping still returns FtsOwnerState.READY_EXACT with exact=True. Consequently callers of FtsConvergenceOwner.run_once_sync() observe an exact-ready archive even though only the requested partition was inspected and repaired. Return a distinct partition-complete result, or otherwise keep the archive-level ready/exact fields false while allowing make_fts_stage() to recognize successful partition convergence.

Useful? React with 👍 / 👎.

@Sinity
Sinity merged commit e38f8a9 into master Sep 5, 2026
3 of 4 checks passed
@Sinity
Sinity deleted the lane/perf-convergence branch September 5, 2026 18:27
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