Skip to content

fix: readiness check, no-op graph publish, NUL-stranded runs, and an invalidated test - #5

Merged
BeLazy167 merged 7 commits into
mainfrom
oss/readyz-and-noop-publish
Sep 4, 2026
Merged

fix: readiness check, no-op graph publish, NUL-stranded runs, and an invalidated test#5
BeLazy167 merged 7 commits into
mainfrom
oss/readyz-and-noop-publish

Conversation

@BeLazy167

@BeLazy167 BeLazy167 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

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-db Postgres volume filled and Postgres crash-looped every ~2 seconds for three days:

PANIC: could not write to file "pg_logical/replorigin_checkpoint.tmp": No space left on device
LOG: checkpointer process was terminated by signal 6: Aborted

93% of the database was one table — graph._sync_log, the pgGraph CDC log, at 10.2M rows / 5.9 GB.

1. /readyz health check (backend/fly.toml)

/healthz returns 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".

/readyz already existed, pings the pool (server.go:308), and correctly returned 503 throughout. Nothing was watching it. Added as a second check; /healthz keeps liveness semantics.

2. Skip no-op graph publish swaps (internal/graph/fullindex.go, migration 087)

publishGraphGeneration deletes every code_node for a repo and re-inserts the staged snapshot in one transaction. That is deliberate — fullindex.go:553 calls 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:

Logged inserts Live rows Whole-graph rewrites
code_nodes 2,279,287 210,623 10.8
code_edges 2,841,573 264,365 10.7

Migration 087 adds graph_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 extracted finishGraphGenerationPublish.

Narrow by design: ConfirmGraphDefaultHead already 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 to code_nodes/code_edges since that publish, because the PR indexer mutates both between full publishes.

3. NUL in suppression keys stranding runs forever (internal/pipeline/suppression.go)

suppressionKey joined path/line/body with NUL. Those are map keys on PipelineRun.SuppressedKeys, and persistState marshals the run into the jsonb pipeline_states.payload column. jsonb cannot represent a NUL:

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 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 synthesizing for 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 empty pipeline_states.error column. 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 synthesizingposting on its own within seconds, with no intervention.

4. Mirror ownership test that migration 086 invalidated

TestMirrorWorkerDeleteKeepsMemoryOwnedByDuplicatePattern built its setup from two co-existing patterns sharing one memory_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 CreatePattern raw-inserts, so it fails with duplicate key value violates unique constraint; where CreatePattern upserts, it silently returns the first row instead. Same dead end, different symptom — the comment covers both.

Now covers three branches of patternMirrorDeleteAuthorized:

Test Rows Branch
…OwnedByDuplicatePattern two rows, memory_doc_id set, custom_id NULL explicitlyOwned COALESCE
…OwnedByLivePattern live row + superseded row's late delete explicitlyOwned, sequential
…OwnedByLegacyDuplicatePattern (pre-existing) two rows, both columns NULL 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.

Note for self-hosters running pgGraph

The root cause of the disk exhaustion was not in this code. internal/store/graph_index.go:826 documents that the projection "is registered with sync_mode = 'manual'", and RebuildCodeGraphProjection correctly calls graph.build() after each index. But graph.sync_mode was never set on the database, so pgGraph used its default of trigger, 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:

ALTER DATABASE <db> SET graph.sync_mode TO manual;

Without it, graph._sync_log grows 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 lowest applied_sync_id across 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 ./...:

2875 passed, 0 failed

All three ownership tests verified by mutation — stubbing patternMirrorDeleteAuthorized to always authorise fails all three. TestSuppressionKeyIsRepresentableInJSONB verified by reverting the separator to NUL. The separator guard rejects printable characters and whitespace controls alike (|, A, \n all rejected).

fly config validate passes.

BeLazy167 and others added 3 commits September 3, 2026 21:15
/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
@BeLazy167 BeLazy167 changed the title fix: readiness health check + skip no-op graph publish swaps fix: readiness check, no-op graph publish, and NUL-poisoned pipeline runs Sep 4, 2026
@BeLazy167

Copy link
Copy Markdown
Owner Author

Added a third fix to this branch: NUL in suppression keys stranding pipeline runs forever (4d37f04).

suppressionKey joined path/line/body with NUL. Those 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 rejects the whole upsert with SQLSTATE 22P05.

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 synthesizing for up to 13 days, while the recovery sweep reported 8 ms scans with error_present: false and an empty pipeline_states.error column.

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.
@BeLazy167 BeLazy167 changed the title fix: readiness check, no-op graph publish, and NUL-poisoned pipeline runs fix: readiness check, no-op graph publish, NUL-stranded runs, and an invalidated test Sep 4, 2026
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.
@BeLazy167
BeLazy167 merged commit 742287f into main Sep 4, 2026
4 checks passed
@BeLazy167
BeLazy167 deleted the oss/readyz-and-noop-publish branch September 4, 2026 07:16
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