fix(pipeline): preserve concurrent manage_adr write across reindex swap - #2315
0xb007ab1e wants to merge 1 commit into
Conversation
|
Thanks for opening this — it has been seen, and it is queued. This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence. Current review status: working through a backlog. What that means for this PR, concretely:
Things that will genuinely speed it up whenever review does happen:
If this fixes a bug, a reproduction we can run is worth more than a description of the symptom. Thanks for contributing, and sorry in advance for the wait. |
DeusData
left a comment
There was a problem hiding this comment.
Thank you for #2314 and #2315. Silently losing an ADR is exactly the kind of bug nobody notices until the decision record is gone, and your root-cause write-up (a shared project_summaries row, early capture and late swap, in-process guards only) is precise. We appreciate the honest risk section too.
We'd like to go with the direction you named as Alternative 1: a separate per-project ADR store that a reindex never replaces. Here is why we prefer it over the lock:
- It removes the race instead of narrowing it. When the ADR doesn't live in the file a reindex regenerates, there is no capture, re-read or re-apply to keep in step, and no lock to coordinate across processes.
- The lock as written conflicts with two of our standing rules. We don't put sleeps or polling waits into the code, and we don't let a timeout decide an outcome. The 10 ms retry loop, with its 30 s fail-open, does both: a slow peer silently brings the data loss back.
- The re-apply happens after the swap. For a moment the published DB shows the stale ADR, and if the re-apply write fails, the rescued ADR is lost with only a log line. An ADR deleted during the reindex would also come back from the early capture.
What we'd look for in the reworked PR:
- ADRs stored in their own file beside the project DB (your
<project>.adr.dbnaming is fine), read and written bymanage_adr, and never touched by index publication. - A one-time migration: an existing ADR in
project_summariesmoves to the new store the first time it is read or written, and the old row is no longer used as the source of truth. Existing indexes must keep their ADRs without a reindex. delete_projectremoves the ADR store with the project, or keeps it deliberately and says so. Please pick one and document it.- Tests: an ADR written between the start of a reindex and its publish survives (your hook-based test adapts directly); an ADR created before upgrading is migrated and still readable after a reindex; delete behaves as documented.
- The early-capture and re-apply code in the pipeline can then go away, which makes publication simpler.
If you'd rather not take on the larger change, tell us and we will pick it up ourselves, crediting your analysis and test. Either way, thank you. This was a careful find.
9527694 to
c27a535
Compare
|
Reworked to Alternative 1 as you asked — thanks for the precise review; agreed on removing the race rather than narrowing it, and on the no-sleep/no-timeout rules.
Tests (file-based): an ADR seeded in the legacy Built clean under |
DeusData
left a comment
There was a problem hiding this comment.
Thank you, @0xb007ab1e, for such a fast and thorough rework. You took the structural fix from your own "Alternatives" section, and the result is much simpler: publication no longer writes ADRs at all, the capture and re-apply machinery and its fail-capture hook are gone, and BEGIN IMMEDIATE now runs on the connection it is meant to protect. That is exactly the direction we hoped for.
A few things before we can merge, each with the reason:
- Migrate before a rebuild can destroy the legacy row. Migration currently runs lazily, on the first ADR read or write. But the format-change route (
pipeline.caround 1749-1758) and the forced-full route (around 1770-1775) unlink the old generation, and a full rebuild publishes a freshproject_summaries. On an upgrade whose first action is a full reindex (a format bump, a forced full, or the watcher's startup reindex), the ADR is therefore lost before anything copies it. Could you migrate the legacy row into the sidecar at the start of routing, whenever an existing generation is present and before any unlink or publish, and preserve and abort if that fails? A done-marker in the sidecar (for example anadr_metarow) would make the migration truly one-time. Today it re-runs on every new connection and could bring back a deleted ADR from a delta-cloned legacy row. - Lock behaviour of the sidecar. Please open it the way the graph store is opened, with
PRAGMA busy_timeout = 10000andjournal_mode = WAL(the baresqlite3_openinstore.caround 9694). Without them, a second process's write fails immediately withSQLITE_BUSY, and a read during a commit reports "no ADR". That is precisely the cross-process case this PR exists for. - Reads must not write. Query-only stores also carry
db_path, somanage_adr get,index_repository'sadr_presentandGET /api/adrcurrently create<db>.adr.db. On a read-only cache, a legacy ADR that used to be readable now errors out. For reads, please open the sidecarSQLITE_OPEN_READONLYif it exists, and otherwise read the legacy row from the graph DB without migrating. - Path helper. Please build the sidecar path through
cbm_path_for_file_apiwith a 4096-byte buffer, likestore_open_internal. On Windows that adds the long-path\\?\prefix; without it, a long cache path opens the graph but not the ADR. - Memory-core gate.
store.cgoes from 358 to 359 raw allocator sites against a baseline of 358, somake -f Makefile.cbm lint-memory-corewill fail.adr_db_pathis written but never read, so dropping the field and itsfreefixes it. Lowering the baselines forpipeline.c(to 129) andpipeline_incremental.c(to 134) in the same change is welcome. - The ADR should travel with the exported artifact. The team-shared
.codebase-memory/graph.db.zstused to carry the ADR inproject_summaries, so a teammate's first index started with the team's decisions. With the sidecar that silently stops. Please carry it across: write the sidecar's ADR into the exported DB, and restore it into the sidecar on import when no local ADR exists. - Tests. Please add:
- (a) an ADR written from a pipeline test hook between the start of the reindex and publication survives, on both the full and the delta route;
- (b) the upgrade order: seed a legacy row, run a forced-full or format-change reindex with no ADR read first, then read the ADR;
- (c)
delete_projectthrough the MCP handler removes<db>.adr.dband its-wal/-shm/-journal, and a re-indexed project of the same name starts with no ADR; - (d) the artifact round trip keeps the ADR.
- Docs and description. Please mention in the
delete_projecttool description and in the README that deleting a project also deletes its ADR, and update the PR description to the sidecar design. It still describes the lock andcompat_fs.c.
A smaller note, not a blocker: the sidecar name ends in .db, so the CLI's cbm_list_indexes / count_db_indexes count it as an index. A one-line exclusion there would help.
Thanks again. The hard part is done, and this is close.
c27a535 to
2d2c0fd
Compare
|
Round 2 pushed — thank you for the precise, itemized review. All eight plus the minor:
Verified locally: |
DeusData
left a comment
There was a problem hiding this comment.
Thank you for round 2, @0xb007ab1e. This is a careful, thorough rework:
- Moving ADRs into the per-project sidecar removes the race rather than narrowing it, and deleting the capture and re-apply machinery made publication noticeably simpler.
- Reusing
stage_root_lengthfor the staging-to-final derivation was the right call. It recognises only the exact minted.stage.XXXXXXbasename, so a real path containing.stage.is safe. - You brought the memory-core baselines down in
pipeline.candpipeline_incremental.cwhile keepingstore.cat 358. Appreciated.
Items 2, 3, 4, 5, 7c, 7d and 8 are done. A few small things before we merge:
- The migrate-before-delete step fails open (
pipeline.caround 1728-1731).cbm_store_open_path_queryreturns NULL for open failures as well as for a missing file, and on this route the final generation is known to exist. Soif (!mig) return true;can let a rebuild publish over a legacy ADR it never moved. Suggested:if (!mig) return !cbm_file_exists(final_path);. - A deleted ADR can come back through the artifact. After migration, the legacy
project_summariesrow stays in the graph DB, and incremental and closure-repair clones keep carrying it.VACUUM INTOcopies it into the snapshot. When the sidecar has no ADR, nothing is injected, the stale row ships, and a teammate's import restores it. Suggested: insnapshot_inject_adr, alwaysDELETE FROM project_summariesfirst, then insert only when an ADR exists. - Make the one-time migration atomic (
store.caround 9673-9768). Please wrap the marker check, the legacy copy and the marker insert in oneBEGIN IMMEDIATE … COMMIT. Otherwise two processes migrating at once can re-copy a row the other just deleted. It would also help ifcbm_store_adr_getfell back to the legacy graph row when the sidecar exists but has nomigratedmarker, so a half-created sidecar never hides an ADR. - Tests.
- Please add the delta / closure-repair variant of the ADR-written-mid-reindex test.
- Pin the route in both new pipeline tests with
cbm_pipeline_incremental_test_last_route()(FORCED_FULL, and CLOSURE_REPAIR for the delta one), so they stay bound to their routes if routing changes. - The preserve-and-abort path on migration failure lost its test when the old hook went away. Putting a directory at
<db>.adr.dbshould make it fail deterministically with no hook: assertCBM_PIPELINE_ABORT_PRESERVE_DBand that the old generation is intact.
Nits, take them or leave them:
- Give the read-only sidecar handle the same 10 s busy timeout.
adr_sidecar_pathshould compare(size_t)n >= raw_szrather than against an(int)cast.- In
delete_project, remove the sidecar only once the graph DB unlink has succeeded, and sizeadr_pathlikepathplus the suffix. is_project_db_file(mcp.c) and the cross-repo cache scan could skip*.adr.dbthe way the CLI listing now does.- In the PR body, "migrated on first access" now means "on first write or before the next rebuild".
Thanks again. This is close, and the design is exactly where we wanted it.
Reworks the ADR-loss-across-reindex fix to Alternative 1 (per the PR review):
remove the race rather than narrow it. ADRs live in a per-project sidecar SQLite
DB ("<graph_db>.adr.db"), separate from the graph DB a reindex rebuilds or
deletes, so there is no capture, re-read, re-apply, or lock — and no sleeps,
polling, or timeout deciding an outcome.
store.c:
- ADR writes (store/delete/update_sections) use a cached read-write handle opened
SQLITE_OPEN_READWRITE|CREATE and configured like the graph store (PRAGMA
busy_timeout=10000 + journal_mode=WAL) so cross-process access waits rather
than failing SQLITE_BUSY. A :memory: store keeps ADRs in the graph DB.
- Reads never write: cbm_store_adr_get uses the cached RW handle if open, else
opens the sidecar SQLITE_OPEN_READONLY (with the same busy timeout) when the
file exists, else reads the legacy graph row without creating anything. When
the sidecar exists but is NOT marked migrated (half-created), it falls back to
the legacy graph row so a partial sidecar never hides an ADR.
- One-time migration is atomic: adr_copy_legacy_once wraps the marker check,
legacy copy, and marker insert in a single BEGIN IMMEDIATE...COMMIT
(re-checking the marker under the lock; ROLLBACK->ERR on failure). New public
cbm_store_adr_migrate_once. Sidecar path via cbm_path_for_file_api (Windows
long-path). Dropped the write-only adr_db_path field.
pipeline.c: migrate a legacy ADR into the sidecar BEFORE the format-change and
forced-full routes unlink the old generation; preserve-and-abort on failure, and
do NOT fail open — a query-open returning NULL preserves unless the final DB
genuinely does not exist. Migration targets the FINAL path (staging suffix
stripped). The early-capture/re-apply machinery and its test hook are gone.
artifact.c: the exported artifact carries the ADR — snapshot_inject_adr always
DELETEs project_summaries first, then injects only when an ADR is present, so a
deleted ADR never resurrects through a lingering legacy row; import restores the
ADR into the destination sidecar via cbm_store_adr_migrate_once (INSERT OR
IGNORE — never clobbers a local ADR).
mcp.c: delete_project removes "<db>.adr.db" (+ -wal/-shm/-journal) only after the
graph unlink succeeds; is_project_db_file skips *.adr.db. cli.c: exclude
*.adr.db from index listing/counting. README + memory-core baselines updated.
Tests (file-based): ADR written mid-reindex survives on BOTH the full and delta
routes (route-pinned FORCED_FULL / CLOSURE_REPAIR); a legacy row migrates before
a forced-full/format reindex with no prior read; migration failure (a directory
at <db>.adr.db) preserves-and-aborts with the old generation intact;
delete_project via the MCP handler removes the sidecar + journals; the artifact
export->import round trip keeps the ADR; a deleted ADR does not resurrect on
import; an import does not clobber a local ADR; a half-created sidecar falls back
to the legacy row.
Closes DeusData#516
Signed-off-by: 0xb007ab1e <134006168+0xb007ab1e@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2d2c0fd to
a1ac807
Compare
|
Round 3 pushed — thank you again for the depth here. Correctness
Tests
Nits — all applied: the read-only handle gets the 10 s busy timeout; Verified locally: If any other maintainers or contributors want to weigh in on the sidecar approach, the migration ordering, or edges I might have missed, I'd welcome the input — happy to adjust. |
What
Fixes ADR loss across a reindex by moving ADRs into a per-project sidecar DB (
<graph_db>.adr.db) that index publication never touches — the structural fix (Alternative 1) from the original discussion, not the lock. Closes #516.Why the sidecar (not the lock)
When the ADR doesn't live in the file a reindex regenerates, there is no capture, re-read, or re-apply to keep in step, and no lock to coordinate across processes — the race is removed, not narrowed. No sleeps, polling, or timeout deciding an outcome.
Design
sqlite3_open_v2(RW|CREATE)+busy_timeout=10000+WAL, matching the graph store). Reads never write: use the cached RW handle if open, else openREADONLYwhen the file exists, else read the legacy graph row without creating anything. A:memory:store keeps ADRs in the graph DB (never file-swapped). One-time legacy migration guarded by anadr_metamarker (cbm_store_adr_migrate_once); sidecar path viacbm_path_for_file_api.graph.db.zstcarries the ADR: inject the sidecar's ADR into the snapshot before compression; restore it into the sidecar on import.delete_projectremoves the sidecar (documented in the tool + README);*.adr.dbexcluded from index listing/counting.Tests
File-based: ADR written mid-reindex survives; a legacy row migrates before a forced-full/format reindex with no prior read;
delete_project(via the MCP handler) removes the sidecar and a same-name re-index has no ADR; artifact export→import round trip keeps the ADR.Verification (local)
-Werrorclean;lint-memory-corepasses (store.c358,pipeline.c131,pipeline_incremental.c134);pipeline289;store_arch mcp mcp_mutation_guard index_format artifact475 (+4 skipped). Only clang-format-19 available locally (CI pins 20);git clang-formatshows the changed lines clean.Compatibility
Existing on-disk indexes keep their ADR with no reindex (migrated on first write or before the next rebuild. No config or API breakage;
cbm_store_adr_*signatures unchanged (plus one newcbm_store_adr_migrate_once).