fix: readiness check, no-op graph publish, NUL-stranded runs, and an invalidated test - #5
Conversation
/healthz returns a constant 200 and never touches the store. It was the only Fly check, so argus-db filling its volume and crash-looping for three days still reported the app healthy while every data request failed. Add a second check on /readyz, which pings the store pool. Longer grace period covers pool open lagging the listener on boot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJehDM4aXz1AkJrTaX2mSL
Publishing a full generation deletes every code_node for the repo and re-inserts the staged snapshot. That swap is the visibility boundary, so it cannot be incremental. But when the staged generation reproduces the live graph exactly, it rewrites every row to arrive where it already was. Between Aug 12 and Sep 3 that churn wrote 10.2M rows into the pggraph CDC log (graph._sync_log, 5.9GB) and filled the argus-db volume, crash-looping Postgres for three days. Fingerprint the staged payloads, compare against the published generation's hash, and skip the swap on a match. Bookkeeping still advances via the extracted finishGraphGenerationPublish, so the repo records the new head and clears its refresh request. The skip also requires that nothing wrote to code_nodes or code_edges since that publish: the PR indexer mutates both between full publishes, and a swap removes the rows it added. Every uncertain case falls through to the swap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJehDM4aXz1AkJrTaX2mSL
suppressionKey joined path/line/body with NUL. Those strings are map keys on PipelineRun.SuppressedKeys, and persistState marshals the run into the jsonb pipeline_states.payload column. jsonb cannot represent a NUL, so Postgres rejected the whole upsert: failed to recover run ... error="persisting state: upserting pipeline_states: ERROR: unsupported Unicode escape sequence (SQLSTATE 22P05)" The payload is how a run resumes, so a run that hits this can never persist and never recover. Recovery claims it, fails identically, and damps it for 30 minutes -- forever. Six production runs sat stranded in synthesizing between 2026-08-21 and 2026-09-02, and the sweep looked healthy the whole time: 8ms scans, error_present false, and an empty pipeline_states.error column. Switch the separator to U+001F (Unit Separator). It keeps the property NUL was chosen for -- a control character no path or review body carries -- and jsonb represents it fine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJehDM4aXz1AkJrTaX2mSL
|
Added a third fix to this branch: NUL in suppression keys stranding pipeline runs forever (
Because that payload is how a run resumes, an affected run can never persist and never recover — the sweep claims it, fails identically, damps it 30 minutes, and repeats forever. Six production runs sat stranded in Separator moved to U+001F, which keeps the "no path or review body contains this" property and is representable in jsonb. Regression test verified to fail when the separator is reverted to NUL. Full analysis in the corresponding private PR. |
The adversarial gate on #287 found TestSuppressionKeySeparatorStaysUnambiguous
did not enforce what its comment promised. It scanned
strings.ContainsAny(sep, "abc...0123/.-_ "), which lets through every
uppercase letter and every punctuation mark outside that short list.
Reproduced: swapping the separator to "|" or "A" kept the test green.
Both appear constantly in review bodies -- "|" in every markdown table --
and either makes suppressionKey ambiguous:
suppressionKey("a", 1, "b|2|c") == suppressionKey("a|1|b", 2, "c")
== "a|1|b|2|c"
so one dismissal suppresses an unrelated live finding and pattern-learning
silently skips it.
Assert the actual property: a single C0 control byte other than NUL. NUL
breaks jsonb (22P05); anything >= 0x20 is printable and can appear in a
path or a body. Verified the new assertion rejects "|" (0x7c) and accepts
U+001F.
The gate's re-run flagged that the C0-control assertion still accepts
tab, LF and CR. LF is not an edge case here -- review bodies are
multi-line markdown, so it is the ordinary case -- and it collides
exactly like a printable separator:
suppressionKey("a.go", 1, "b\n2\nc") == suppressionKey("a.go\n1\nb", 2, "c")
Reject 0x09/0x0a/0x0d explicitly, and assert the property directly
against a body shaped like a real finding (markdown table, blank lines,
an indented line, CRLF) rather than trusting the byte class to imply it.
Verified the assertion rejects "\n" (0x0a) and still accepts U+001F.
TestMirrorWorkerDeleteKeepsMemoryOwnedByDuplicatePattern built its setup
from two co-existing patterns sharing one memory_custom_id. Migration 086
added patterns_installation_memory_custom_uniq over (installation_id,
memory_custom_id), so that cannot produce a second row and the test
deleted the only owner, then asserted a memory nothing owned would
survive.
Verified against PG 19beta2 on this tree: the second create fails with
"duplicate key value violates unique constraint". (Where CreatePattern
upserts instead of raw-inserting, it silently returns the first row --
same dead end, different symptom. The comment covers both.)
The file now covers three branches of patternMirrorDeleteAuthorized:
DuplicatePattern two rows, memory_doc_id set, custom_id NULL
-> explicitlyOwned COALESCE
LivePattern live row + a superseded row's late delete
-> explicitlyOwned, sequential
LegacyDuplicatePattern two rows, both columns NULL (pre-existing)
-> legacyOwner derivation
086's index is PARTIAL (WHERE memory_custom_id IS NOT NULL), so legacy
memory_doc_id rows are unconstrained and the duplicate-owner case is
still reachable -- which is why the first branch is restored rather than
dropped.
All three verified by mutation: stubbing patternMirrorDeleteAuthorized to
always authorise fails all three. Full suite 2875 passed.
Migration 087 adds graph_index_generations.content_hash, and sqlc derives internal/store/db/models.go from the migration schema, so the committed output drifted and `make sqlc-check` failed the build job: diff --git a/backend/internal/store/db/models.go make: *** [Makefile:39: sqlc-check] Error 1 Regenerated with sqlc v1.30.0 (the version CI pins). The change is one field, ContentHash, on GraphIndexGeneration. Any PR adding a store migration needs this same regeneration.
Fixes from a production incident on 2026-09-03, ported to the open-source tree. All four are merged and deployed in the private tree (#284–#287); this brings the public repo in line.
What happened
The
argus-dbPostgres volume filled and Postgres crash-looped every ~2 seconds for three days:93% of the database was one table —
graph._sync_log, the pgGraph CDC log, at 10.2M rows / 5.9 GB.1.
/readyzhealth check (backend/fly.toml)/healthzreturns a hardcoded{"status":"ok"}and never touches the store (internal/api/server.go:302). It was the only check wired into[checks], so the platform reported the app healthy for three days while every data request failed. The static Next.js shell served 200s on top, which is why it surfaced as "the site loads but nothing works"./readyzalready existed, pings the pool (server.go:308), and correctly returned 503 throughout. Nothing was watching it. Added as a second check;/healthzkeeps liveness semantics.2. Skip no-op graph publish swaps (
internal/graph/fullindex.go, migration 087)publishGraphGenerationdeletes everycode_nodefor a repo and re-inserts the staged snapshot in one transaction. That is deliberate —fullindex.go:553calls it "the visibility boundary" — and this keeps it. What it need not do is run when the staged generation reproduces the graph already live. Measured over 22 days:code_nodescode_edgesMigration
087addsgraph_index_generations.content_hash; the swap is skipped when the staged payloads hash to the published generation. Bookkeeping still runs on both paths via the extractedfinishGraphGenerationPublish.Narrow by design:
ConfirmGraphDefaultHeadalready short-circuits an unchanged head, so this only fires when the head moved but no indexed symbol changed. The skip also requires that nothing wrote tocode_nodes/code_edgessince that publish, because the PR indexer mutates both between full publishes.3. NUL in suppression keys stranding runs forever (
internal/pipeline/suppression.go)suppressionKeyjoined path/line/body with NUL. Those are map keys onPipelineRun.SuppressedKeys, andpersistStatemarshals the run into the jsonbpipeline_states.payloadcolumn. jsonb cannot represent a NUL:The payload is how a run resumes, so an affected run could never persist and never recover — the sweep claimed it, failed identically, damped it 30 minutes, and repeated. Seven production runs stranded in
synthesizingfor up to 13 days.It presented as a healthy system throughout: 8 ms recovery scans reporting
error_present: false(correct — the runs were inside their backoff), and an emptypipeline_states.errorcolumn. The reason existed only in a log line emitted once per 30 minutes per run.Separator moved to U+001F, which keeps the "no path or review body contains this" property and is representable in jsonb.
Confirmed in production: after deploying this fix, a stranded run moved
synthesizing→postingon its own within seconds, with no intervention.4. Mirror ownership test that migration 086 invalidated
TestMirrorWorkerDeleteKeepsMemoryOwnedByDuplicatePatternbuilt its setup from two co-existing patterns sharing onememory_custom_id. Migration 086's unique index makes that impossible, so the test deleted the only owner and asserted a memory nothing owned would survive — it could never pass. A test left behind by a schema change, not a product defect.Note this tree's
CreatePatternraw-inserts, so it fails withduplicate key value violates unique constraint; whereCreatePatternupserts, it silently returns the first row instead. Same dead end, different symptom — the comment covers both.Now covers three branches of
patternMirrorDeleteAuthorized:…OwnedByDuplicatePatternmemory_doc_idset,custom_idNULLexplicitlyOwnedCOALESCE…OwnedByLivePatternexplicitlyOwned, sequential…OwnedByLegacyDuplicatePattern(pre-existing)legacyOwnerderivation086's index is partial (
WHERE memory_custom_id IS NOT NULL), so legacymemory_doc_idrows are unconstrained and the duplicate-owner case is still reachable — which is why the first branch is restored rather than dropped.Note for self-hosters running pgGraph
The root cause of the disk exhaustion was not in this code.
internal/store/graph_index.go:826documents that the projection "is registered with sync_mode = 'manual'", andRebuildCodeGraphProjectioncorrectly callsgraph.build()after each index. Butgraph.sync_modewas never set on the database, so pgGraph used its default oftrigger, installed 8 CDC triggers, and logged every graph write forever with no consumer draining it.If you run Argus against Postgres with the pgGraph extension, set it explicitly:
Without it,
graph._sync_loggrows unbounded — pgGraph will not prune a log no consumer has read. Per its docs the prune floor is the minimum of the projection watermark and the lowestapplied_sync_idacross backend heartbeats, so with no consumer no floor is ever safe.Verification
Full suite against the CI Postgres image (PG 19beta2 + pinned pgvector),
go test -race -count=1 ./...:All three ownership tests verified by mutation — stubbing
patternMirrorDeleteAuthorizedto always authorise fails all three.TestSuppressionKeyIsRepresentableInJSONBverified by reverting the separator to NUL. The separator guard rejects printable characters and whitespace controls alike (|,A,\nall rejected).fly config validatepasses.