fix(core): enumerate wisps via gc bd query, not gc bd list - #12
Open
benw5483 wants to merge 4 commits into
Open
Conversation
The wisp-compact order has been a silent no-op. It enumerated with `gc bd list --json --all -n 0` and kept `select(.ephemeral == true)`, but `bd list` excludes ephemeral beads outright and omits the `ephemeral` field from its projection, so the filter matched zero of 33,322 live wisps. The sweep reaped nothing and exited 0, which reads as a healthy run: the controller logs an exec order's output only on a non-zero exit, so the summary line never reached the log either. Enumeration now runs through `gc bd query ephemeral=true --all --limit 0`, the only listing that returns wisps, and it carries the labels the TTL policy needs. Throughput is fixed alongside it, because a working enumeration surfaces a backlog the old shape could not have drained. Classification collapses from five jq processes and up to three date processes per bead into a single jq pass, deletions batch through `--from-file` instead of one `gc bd` call per bead, and each run is bounded by a wall-clock budget so a backlog larger than one sweep drains across sweeps instead of dying at the 300s deadline. Promotions take the first half of the budget so a large delete backlog cannot starve stuck detection. Failures are loud now, matching the sibling scripts' gastownhall#4543 convention. An enumeration failure, an empty result, and an unactioned wisp each exit non-zero. The `|| exit 0` this replaces is what let two successive faults (a bd schema skew, then the enumeration gap) both present as healthy. Retention semantics are unchanged: same TTLs, same last-label-wins resolution, same promote-vs-delete split. Deletion stays closed-only, verified against a live snapshot where all 627 open ephemerals were spared. Generated by the operator's software factory. City: factory-main · Agent: local-core.builder-1 On behalf of: @benw5483 Co-Authored-By: <operator-factory-bot> <factory-bot@operator-domain.invalid>
…e query Two follow-ups to the enumeration switch, both surfaced by the behavioral suite in examples/gastown that exercises this script against a stubbed bd. The classification pass now drops an explicitly non-ephemeral row. It tests `!= false` rather than `== true` on purpose: the query already filters server-side, so this only catches a stray, and `!= false` keeps a bead whose `ephemeral` field is absent. `== true` is what silently dropped every row when `bd list` stopped emitting the field, and reintroducing it here would rebuild the same trap one layer down. The empty-enumeration guard now tests the raw row count instead of the actionable totals. A sweep that finds every wisp still inside its TTL has nothing to do and should stay quiet; only a sweep that cannot see wisps at all is broken. The previous form made the quiet-and-correct case shout. The suite's bd stub moves to `query`, treats `bd list` as a hard error so the old enumeration cannot come back unnoticed, and expands a batched `delete --from-file` into one logged line per id, which leaves every existing retention assertion reading exactly as it did before. The Z-timestamp test keeps its property, that a trailing-Z stamp is read as UTC and not in $TZ, and now pins it through the jq pass while asserting the per-bead date fan-out does not return. Generated by the operator's software factory. City: factory-main · Agent: local-core.builder-1 On behalf of: @benw5483 Co-Authored-By: <operator-factory-bot> <factory-bot@operator-domain.invalid>
…carries
Two contract assertions passed against a script whose guard had been
deleted, so they could not have failed for the reason they existed.
The deletion-scope check looked for `$b.status != "closed"`. That
substring occurs twice: once in the elif that routes an open past-TTL
wisp to PROMOTE, and once in the reason ternary just below it, which
only picks the wording of the promotion comment. Remove the elif
disjunct and the ternary keeps the substring present, so the assertion
stays green while every open past-TTL wisp moves from promoted to
deleted. That is the one scope widening this order must never make.
Pinning `or ($b.status != "closed") then` matches the routing branch
alone, and the ternary cannot satisfy it.
The empty-set opt-out check looked for the bare knob name
GC_WISP_COMPACT_ALLOW_EMPTY. Comments are stripped before matching, but
string literals are not, and the operator-facing error message names the
knob to tell the reader how to set it. Deleting the whole guard left
that message behind and the assertion still passed. Pinning the
expansion form `${GC_WISP_COMPACT_ALLOW_EMPTY:-` matches only the guard.
The budget knob moves to the same form for consistency; that one was
already non-vacuous, since its only uncommented occurrence is the
expansion itself.
Verified by injecting each defect separately against an otherwise
pristine tree, restoring between runs: every injection turns the test
red with the intended message, and the two former assertions stay green
on the same injected scripts.
Generated by the operator's software factory.
City: factory-main · Agent: local-core.builder-1
On behalf of: @benw5483
Co-Authored-By: <operator-factory-bot> <factory-bot@operator-domain.invalid>
…ssage alone Both loud-fail assertions matched their error string and stopped there. The string is not the load-bearing half. The controller logs an exec order's output only on a non-zero exit, so a message printed behind `exit 0` goes to a stream nobody reads, and the sweep this order exists to make loud is silent again with every message still in place. Flipping either `exit 1` to `exit 0` is the single likeliest edit anyone makes if this order is ever called noisy, and both assertions stayed green through it. So did deleting either exit outright. Each assertion now pins the message and the non-zero exit beneath it as one regexp, requiring the exit on the next non-blank line. That is deliberately strict: inserting a line between them turns the test red, which is the right failure mode for a guard whose defect is silence. The `"$FAILED" -gt 0` check stays alongside, because it pins the gate condition the regexp does not describe. This mattered more than the usual vacuity finding because no behavioral test covers any failure path. All nine examples/gastown wisp tests assert the happy path, so unlike the deletion-scope case there was no second suite quietly holding this property up. Verified by injecting four defects separately against an otherwise pristine tree, restoring between runs, with bash -n clean each time: dropping or zeroing either exit turns the test red naming that site. An untouched tree stays green, so the assertion does not simply refuse everything, and the two assertions pinned in the previous commit stay red on their own injections. Generated by the operator's software factory. City: factory-main · Agent: local-core.builder-1 On behalf of: @benw5483 Co-Authored-By: <operator-factory-bot> <factory-bot@operator-domain.invalid>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
wisp-compactenumerated withgc bd list --json --all -n 0and keptselect(.ephemeral == true). Butbd listexcludes ephemeral beads outright and doesn't even carry theephemeralfield in its projection, so that filter matched zero of 33,322 live wisps. The sweep reaped nothing, and exited 0 while it did.gc bd query ephemeral=true --all --limit 0, the only listing that returns wisps, and it carries the labels the TTL policy reads.Why the old shape was invisible
Two things had to line up for this to stay hidden, and they did. The script swallowed enumeration trouble with
|| exit 0, andcmd/gc/order_dispatch.gologs an exec order's output only on a non-zero exit. So a run that saw nothing printed nothing, logged nothing, and still recorded a perfectly normal hourly execution ingc order history.That isn't hypothetical. On the city where this turned up, the order ran hourly for two solid days without emitting a single line, while
gc bd query ephemeral=truewas returning 33,322 wisps the whole time, 26,619 of them closed and past TTL and therefore already due for deletion. Before that it failed loudly instead. It was 2,382context deadline exceededlines deep, and nobody noticed the changeover, which is rather the point: the quiet state looks exactly like success.This is an upstream defect, not a stale materialization
Worth stating plainly. The shape of this one invites exactly the opposite conclusion, and it would be an easy call to get wrong, so here is the evidence.
maincarries the sameselect(.ephemeral == true)filter againstgc bd listatwisp-compact.sh:27-28. The only difference from the older materialization found in the field is barebdversusgc bd, which doesn't touch the filter at all, so rebuilding and reinstalling would just have swapped one silent no-op for another. I measured it againstmain's own enumeration, and it's the same story:gc bd list --json --all -n 0returns 4,459 records, not one of which carries anephemeralkey, and not one of which is a wisp.What is unchanged
Nothing here is new behavior. Retention semantics don't move at all: same TTLs, same last-label-wins resolution across the label list, same promote-versus-delete split, same comment text on promotion. Deletion stays closed-only, so a non-closed wisp past TTL still gets promoted for stuck detection rather than removed.
Throughput
The old loop spawned five
jqprocesses and up to threedateprocesses per bead, then onegc bd deleteper bead, and each of thosegc bdinvocations costs roughly a second of process startup on the host I measured, no matter how little work it is actually being asked to do. That adds up fast. Now classification is a singlejqpass, deletions batch through--from-file, and each run stops at a wall-clock budget. A backlog bigger than one sweep just drains across several. Promotions take the first half of the budget so a large delete backlog cannot starve stuck detection.Three knobs, all optional:
GC_WISP_COMPACT_BUDGET(default 240s),GC_WISP_COMPACT_DELETE_CHUNK(default 500),GC_WISP_COMPACT_ALLOW_EMPTY.Known limitation, deferred out of this PR
The enumeration captures with
2>&1, so stderr from an otherwise successful query would land inside the variable that has to hold pure JSON. The run would then die in the classifier with a jq parse error, which bills the enumeration's problem to the wrong stage.It doesn't fire today. The live query returns rc=0 with exactly zero bytes on stderr across 33,451 beads, and if it ever did fire it'd fail in the safe direction, exiting non-zero before anything is promoted or deleted. The fix is two lines and it's deliberately not here. The review rounds budgeted for this change are spent, and this is the live reclaim path that deletes on the order of 26,600 rows a run, so it's tracked separately rather than slipped in without a round covering it.
Test plan
go test ./internal/bootstrap/packs/core/passes, including the newTestWispCompactOrderandTestWispCompactScriptContract.go test ./internal/testpolicy/resourcecensus/ -run TestRepositoryLedgerMatchesCensusAndDocumentationpasses. The new test adds no subprocess call site; it asserts on the embedded script's text.Vacuity control on the fix: the pre-change script, driven against the real
gc bd listprojection through a stub, makes one call, deletes nothing, prints nothing, exits 0. The reproduction of the silent no-op.Behavior control on the fix: the post-change script against a live 33,322-bead snapshot classifies 569 promote, 26,619 delete, 6,134 within TTL, matching an independently written classifier over the same snapshot.
Scope control: zero of the snapshot's 627 open ephemerals appear in the delete set, and every deleted id is closed.
Loud-fail controls, each injected separately: enumeration failure exits 1; an empty result exits 1;
GC_WISP_COMPACT_ALLOW_EMPTY=1returns that same empty case to a silent exit 0; a failing batch delete exits 1 after one log line rather than one per chunk; a zero budget does no work, reports the remainder, and exits 0.Vacuity control on the new test, restated. An earlier version of this bullet cited seven injected defects in a way that read as a completeness claim. It wasn't one. Eleven defects have now been injected one at a time, each reverting a single property the test claims to pin, and each turns the test red while unmodified source stays green. The count grew because review found three assertions that passed against a script whose guard had already been deleted. Two of them named a knob or a field that the operator-facing error text also carries, so deleting the guard left the name sitting there. The third pinned the loud-fail message but not the non-zero exit that message depends on, and since the controller logs an exec order's output only on a non-zero exit, flipping
exit 1toexit 0left the test green and the sweep silent again. All three are now pinned to a construct only the guard itself can produce. Read the count as the set that's been checked, not as proof that nothing unpinned remains.Baseline-differenced against
./cmd/gc/. Running theTestEmbed|TestBuiltinPacks|TestCityRuntime|TestControllerselector on this branch leaves one failure,TestControllerStateOpenRigStoreExecProjectsRigTarget, which is the macOS/private/varversus/varfamily. The same selector on a pristinegit archiveof the base leaves six failures including that one, so the set difference of mine minus baseline is empty.Not run: the rest of
./cmd/gc/. The diff touches one embedded shell asset plus one test file ininternal/bootstrap/packs/core, and thecmd/gctests that namewisp-compactassert the order is registered and listed, which the unchangedwisp-compact.tomlstill satisfies.