Storage review follow-ups: boot-only prune, prune-then-persist, cap + persistence tests, named columns #44
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/storage-review-nits"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #43 — all six follow-ups from the approving review on #38.
What
1. The 8 MB cap now has a test pinning the UTF-8 measurement (
storage_test.ts). 5 Mi"ä"characters: ~10 MiB in UTF-8 (over the cap), ~5 M UTF-16 code units (under it) — so a check rewritten against string.lengthfails the test, not just a removed check.2. The poll-path prune runs only on the boot-seeded map (
mod.ts). AseededFromStorageflag is set when rehydration seeds a non-empty map and consumed by the first full, non-empty listing — the same predicate that gateshaveFullListing, so an empty first poll (an engine still coming up; reachable only in the bare-process dev shape, since the containerised deployment always lists OpsDeck's own container) neither wipes the seeded map nor unlocks the scoped-refresh prune. A poll landing inside an operator's owncompose down/up -dwindow can no longer drop live verdicts. Tests pin the trade-off from both sides: a poll whose listing is missing exactly the downed project keeps the verdict, and an empty full listing defers instead of wiping — in the boot prune and in the flag.3. A pruning poll persists the pruned map (new
persistUpdateshelper, shared with the sweep). Before, only sweeps persisted, so a host whose registry sweeps keep failing re-seeded the same dead verdicts on every restart. Persist stays after publish on every path. New test: a poll that prunes a ghost writes a map without it.4. The boot comment no longer overstates — it now states the condition its "corrects within one poll" argument depends on:
ctx.scheduler.everygives an immediate first tick only at intervals ≤ 5 min; a largerINTERVAL_MSbecomes a real schedule whosefirstRun: "immediate"does not fire when the schedule row already exists.5. Sweep persistence is tested end-to-end (
mod_test.ts). Whatstorage.setwas called with is captured and fed into a secondregister()— the sweep's set key and boot's get key are checked against each other, so a typo in either"updates"literal now fails a test instead of leaving the feature dead with a green suite.6. The
module_kvinsert names its columns (storage.ts, and the corrupt-row fixture instorage_test.ts), so a widening migration 11 fails the statement at parse instead of silently shifting the positional binding.Boot-only pruning narrows the down/up hole to one shot; it does not remove the class. A stack an operator left
downacross an OpsDeck restart is absent from the first full listing, so that one prune drops its verdicts and persists the loss until the next sweep — and a stack removed out of band while OpsDeck runs keeps a phantom verdict for up toCHECK_INTERVAL_MS, healed sooner only by the action path. Both directions are stated in the module comment; that trade is the one #43 asked for. The listing capability remains #39.Tests
mod_test.tsgrew from 1 to 8 tests (shared engine/storage mocks extracted, env dance deduped intorealDataTest). Every guard clause is mutation-tested: the eight mod-side mutations from both review rounds were each re-run against the current head and each kills at least one test.deno fmt --check,deno lint,deno task checkclean. Server + module suites green except the 6 pre-existing Windows-path failures (#20). Reviewed by cavecrew-reviewer: no findings.🤖 Generated with Claude Code
Re-reviewed at
6ecb0a3in a detached worktree, checking the claims rather than the description.Verified independently
seededFromStorageguard (prune every poll) → "a live verdict survives a poll that lands inside compose down/up" fails; dropif (prunedGhosts) await persistUpdates()→ the persistence test fails; typo the"updates"set key → the sweep/boot key test fails;new TextEncoder().encode(json).length→json.length→ the UTF-8 cap test fails. Four mutations, four kills. Items 1, 2, 3 and 5 are genuinely pinned.packages/server/src/metrics/schema.ts:206) in order —module, key, value, updated_at_ms.INTERVAL_AS_SCHEDULE_MSis 5 min (host.ts:171), andfirstRun: "immediate"fires only insideif (!existing)(tasks/service.ts:1334-1337) with the declaration atcatchUp: "skip"."ä"is 10 MiB in UTF-8 (over the 8 MiB cap) and 5,242,880 UTF-16 code units (under it), andJSON.stringifydoes not escape non-ASCII, so the value really does reach the encoder as two bytes per character.deno fmt --check,deno lint,deno task checkclean. Fulldeno task teston Linux: 472 passed, 0 failed (the description's 6 Windows-path failures do not appear here; the 2 failures I hit first were my own gpg signing config inexternal_modules_test.ts, not this branch).What I don't buy
One blocking finding inline at
mod.ts:278, with its follow-on atmod.ts:324. Short version: the prune this PR reduces to a single shot is not gated on a full listing, and a scoped refresh from any action'sfinallycan consume it before the first poll — reproduced against this head, verdicts for every other project dropped from memory and written back tomodule_kv.Separately, the description's "the down/up hole item 2 describes for per-poll pruning is gone by construction now" is too strong. The hole is narrowed to one prune, not removed by construction: that prune still keys off "absent from the current container listing", so an operator whose stack is
downacross an OpsDeck restart loses live verdicts exactly as item 2 described — and now the loss is persisted, bounded by the same next-sweep the item objected to. Worth saying plainly in the comment instead of claiming the class is gone.Also worth stating out loud somewhere durable: the accepted cost of boot-only pruning is that a stack removed out of band while OpsDeck runs keeps a phantom verdict for up to
CHECK_INTERVAL_MSinstead of one poll. Only the action path heals it (refreshUpdates(project)). That is the trade issue #43 asked for, so it is fine — but the module comment currently argues only the other direction.Nit, no action needed: the test-4 comment "the updates tick polls stacks first (nothing listed yet)" — the poll does list
webContainer; the condition is!state.updatedAt.Everything else here is good work, and the mutation results say the test suite will now actually defend it. Changes requested for the one guard.
@ -247,2 +276,3 @@// `state.stacks` is the full set even on a scoped refresh// (mergeScopedStacks merges into it), so pruning here is safe.state.updates = pruneUpdates(state.updates, state.stacks);if (seededFromStorage) {Blocking: this guard is missing
&& !project.refreshStacks(project)is reachable before the first full poll. EverycomposeActionre-reads its own project in afinally(actions.ts:133), including when the run threw, andstack-lifecyclecalls it directly (actions.ts:181). At that pointstate.stacksis[],mergeScopedStacksreturns only the refreshed project (merge.ts:20filterslistedbyproject), and the one prune this PR still allows runs against that partial set.Reproduced against this head, with no tick fired — i.e. the window before the first
stackspoll:postgres:16is gone from memory and frommodule_kv, restored only by the next sweep — up toCHECK_INTERVAL_MSaway, or never on exactly the host whose sweeps keep failing that item 3 is written for.The comment two lines up ("
state.stacksis the full set even on a scoped refresh (mergeScopedStacks merges into it), so pruning here is safe") is only true once a full poll has run. This PR moves the prune into the window where it hasn't, and keeps the sentence.How wide is the window? At the default
INTERVAL_MSit is one microtask plus onelistStacks, so small. Above 5 min it is the case this PR's own new comment documents: the schedule row already exists,firstRun: "immediate"does not fire, and the window is up to a whole interval — long enough for an operator to click something.To be fair to the diff: the wipe itself predates it (the old code pruned on the same scoped call, and
refreshUpdates(project)persists the result either way). What is new is that this is now the module's only prune and it is aimed squarely at the boot window.Fix:
if (seededFromStorage && !project). A scoped call then leaves the flag set for the first full poll — the listing the prune was actually reasoned about.@ -286,0 +321,4 @@// A pruned ghost must not outlive this boot in storage: only sweeps// persist otherwise, so on a host whose sweeps keep failing the row// would re-seed the same dead verdicts on every restart, forever.if (prunedGhosts) await persistUpdates();Related, non-blocking: this line is what makes a bad boot prune durable.
The prune trusts whatever the first listing returns, and it can return less than the truth in two ways: the scoped call above, and an engine that answers the first poll with an empty or partial list (a stack the operator left
downacross the restart, or a daemon still coming up). Verdicts drop, and this line writes the drop back tomodule_kv— so the loss survives the very restart the persistence exists for. That is the mirror image of the ghost this PR removes, and it is not covered by a test.Cheapest guard is on the prune, not here:
if (seededFromStorage && !project && state.stacks.length > 0). An empty listing at boot is far more often a hiccup than a host with nothing on it, and a host with genuinely nothing running loses nothing it will miss.The stricter version, if it is worth it: key the prune off the engine event window rather than absence from the listing —
eventsByNameis already fetched at the top ofrefreshStacksand carries the destroy events, which is the signal "this stack is gone" as opposed to "this stack is not listed right now".Addressed in
7ab05fa.The blocking guard — taken, and it turned out not to be enough on its own. Re-running your repro with only
seededFromStorage && !projectin place still wipespostgres:16from memory andmodule_kv: the samefinallythat runs the scopedrefreshStacksfollows it withrefreshUpdates("web"), which prunes the merged map against the same partialstate.stacksand persists unconditionally. And the sweep tick guarded on!state.updatedAt, which the scoped refresh sets — so a sweep firing before any full poll would rebuild the verdict map from a one-project listing (thecheckedmap is built fromstate.stackstoo) and wipe through a third door.All three paths are the same defect — treating
state.stacksas the whole host before a full listing has landed — so the fix is one flag,haveFullListing, set by a fullrefreshStacks:seededFromStorage && !project && state.stacks.length > 0(your suggested guard, empty-listing clause included);seededFromStorage— the one shot stays armed for the listing it was reasoned about;!haveFullListinginstead of!state.updatedAt.The description overclaim and the missing other-direction trade — the prune comment now says both plainly: boot-only pruning narrows the down-across-restart hole to one shot rather than removing the class (and the loss is persisted until the next sweep), and a stack removed out of band while OpsDeck runs keeps its phantom verdict for up to
CHECK_INTERVAL_MS, healed sooner only by the action path.The test-4 comment nit — fixed; it now describes the actual condition.
Tests.
ctxFornow capturesctx.tasks.defined actions, so the new tests drive the realcomposeActionfinally(the thrownunknown-stackrun included) rather than calling the refresh helpers directly. Three new tests: a scoped action refresh before the first poll neither wipes another project's verdicts (memory or storage) nor consumes the boot prune; a sweep firing after only a scoped refresh polls the full host first; an empty first listing defers the prune to the next non-empty full poll. Mutation check on this head: dropping!project/the length guard, always-pruning inrefreshUpdates, reverting the tick guard to!state.updatedAt, and clearingseededFromStorageunconditionally each fail at least one test.deno fmt --check,deno lint,deno task checkclean; server + docker suites 325 passed (322 + the 3 new), same 6 pre-existing Windows-path failures (#20). Reviewed by cavecrew-reviewer: no findings.🤖 Generated with Claude Code
Re-reviewed at
7ab05fain a detached worktree, mutation-testing every claim rather than reading the follow-up comment.Verified independently
The
haveFullListingwork is right, and the follow-up comment's mutation list checks out. Each mutation, and the test that actually kills it:!state.updatedAtrefreshUpdatesseededFromStorageunconditionally!projectfrom the boot guardstate.stacks.length > 0if (prunedGhosts) await persistUpdates()TextEncoder().encode(json).length→json.lengthSeven mutations, seven kills — one short of the set, see below.
engineFetchignores the compose-project filter, sorefreshStacks("web")receives both containers. It still produces a genuinely partialstate.stacks, becausemergeScopedStacksfilterslistedby project itself (merge.ts:20). Confirmed separately with a filter-aware mock — same result.(module, key, value, updated_at_ms)matches migration 10 (metrics/schema.ts:206-212) in order, andmodule_kvhas no other insert site (storage.ts+ the updated fixture).intervalMs <= INTERVAL_AS_SCHEDULE_MS= 5 min inclusive (host.ts:409,171), plain timers get their immediate tick fromqueueMicrotask(tick)(scheduler.ts:40), andfirstRun: "immediate"only fires insideif (!existing)(tasks/service.ts:1334-1337) with the declaration atcatchUp: "skip"— so after a restart the >5 min case really does wait a full interval.CHECK_INTERVAL_MSphantom for an out-of-band removal. That was my last review's second point; taken.deno fmt --check,deno lint,deno task checkclean. Full suite 475 passed, 0 failed, and deterministic: docker + storage tests identical across 8 consecutive runs. (One earlier full run reported a single failure I could not reproduce in four subsequent runs and could not pin to a test; not attributable to this branch.)What I don't buy
1. Blocking — the single mutation that survives is the one this PR is named for. Remove
seededFromStorage &&from the boot-prune guard, so it prunes on every full poll — the exact behaviour issue #43 item 2 objected to and this PR's headline change — and the suite is still fully green:475 passed | 0 failed. The test the file's own header nominates as the pin ("ONLY the first … (third test)") empties the entire container listing, so it'sstate.stacks.length > 0that blocks the prune, not the boot-only flag. Note what is missing from the mutation list in your comment: dropping!project, dropping the length clause, always-pruning, clearing the flag unconditionally — but not dropping the flag from the guard itself. Two tokens fix the test; exact patch and both-directions verification inline atmod_test.ts:257.That matters more than usual here because items 1 and 5 of the issue are explicitly "the check can be silently reintroduced" findings. Shipping the fix for those alongside an unpinned headline guard is the same failure one level up.
2.
haveFullListingis set by an empty full listing, which reopens the wipe. The prune comment argues an empty listing is untrustworthy; forty lines earlier the flag trusts it anyway, and the flag is what gates therefreshUpdatesprune. Empty first poll → next scoped action refresh prunes against a one-projectstate.stacksand persists it; I reproducedpostgres:16disappearing from both memory and themodule_kvwrite on this head. Narrow — it needs a listing that can actually be empty, i.e. OpsDeck as a bare process rather than a container — but it is the same defect class through the one door left open, and it is one clause. Repro and patch inline atmod.ts:252.Nit, no action: the description still says "
mod_test.tsgrew from 1 to 4 tests"; it's 7. The follow-up comment covers it.The mechanism is right and the guards are well argued — this is one test line and one predicate away from an approve.
@ -218,6 +249,7 @@ export default async function register(ctx: ModuleContext): Promise<void> {? mergeScopedStacks(state.stacks, listed, project): listed;state.updatedAt = Date.now();if (!project) haveFullListing = true;haveFullListingis set by any full listing, empty ones included — and 47lines below, the prune comment argues an empty listing must not be trusted
("far more often an engine still coming up"). Both cannot be right, and the
flag is the one that unlocks the wipe: after an empty first poll, the next
scoped action refresh prunes the merged map against a one-project
state.stacksatmod.ts:391and persists it.Reproduced on this head (probe test, filter-aware engine mock so the scoped
listing really is scoped; storage seeded with
nginx:1.26+postgres:16):postgres:16is gone fromstate.updatesand from what went intomodule_kv— the same defecthaveFullListingwas added to close, reachedthrough the door the code itself calls untrustworthy.
Reachability is narrow and I'll say so: in the containerised deployment the
listing can never be empty (self is in it), so this needs OpsDeck running as
a bare process against the socket — the documented local-dev shape — on a
host whose stacks are not up yet. The fix is one clause:
Same predicate the prune already uses, so the two stop disagreeing. The cost
is a sweep tick re-polling on a genuinely empty host, which is free. If you'd
rather keep the flag as-is, then the honest alternative is to drop the
state.stacks.length > 0clause and its test as well — but not one withoutthe other, and I'd take the guard.
@ -163,0 +254,4 @@);await register(ctx);await ticks.get("stacks")!(); // boot prune: nginx runs, verdict keptcontainers = []; // the down/up windowBlocking: this test does not pin the thing the PR is named for.
The file header names this test as the pin for boot-only pruning ("ONLY the
first ... (third test)"). It isn't. Delete
seededFromStorage &&from theguard at
mod.ts:299— i.e. prune on every full poll, exactly thebehaviour item 2 exists to prevent — and the entire suite stays green:
Because the window here empties the whole listing,
state.stacks.length > 0is what blocks the prune, not
seededFromStorage. And an empty full listingis not what a
compose downlooks like:listStacksusesall=truewith noself-filter (
engine.ts:66), so OpsDeck's own container is always in thelist — that is why
/stackscomputesselfStackfromSELF_IDat all. Therealistic shape is one project missing.
Two tokens:
Verified both directions on this head: passes as written on
7ab05fa, andwith
seededFromStorage &&removed it fails witha poll during compose down dropped a live verdict.dbContainerand itslocal digest are already in the fixtures, so nothing else moves.
Both findings taken as prescribed, in
7d260da.1. The compose-down test now pins the flag, not the length clause. Your two-token patch, verbatim: the fixture starts at
[webContainer, dbContainer]and the window iscontainers = [dbContainer]— one project missing, which per yourall=true/SELF_IDpoint is what acompose downactually looks like. The second poll's listing is full and non-empty, so onlyseededFromStoragestands between it and the prune. Re-ran your surviving mutation: withseededFromStorage &&removed from the guard, the test fails witha poll during compose down dropped a live verdict; restored, it passes. The test's comment now states why the listing must stay non-empty.2.
haveFullListingnow requires a non-empty listing. Your clause, verbatim:if (!project && listed.length > 0) haveFullListing = true;— the same predicate the boot prune uses, so the two stop disagreeing. Took the guard over dropping the length clause, as you would. Your PROBE2 repro is now a regression test, "an empty full listing does not unlock the scoped-refresh prune": storage seeded withnginx:1.26+postgres:16, empty first full poll, host catches up,stack-lifecycle{project:"web"}fires itsfinallyrefresh pair — assertspostgres:16survives in memory and in themodule_kvwrite. Reverting the clause fails it. The comment at the flag's set site names the bare-process reachability, mirroring your note.Mutation matrix re-run against this head — all six mod-side mutations from your table plus the two new ones, each killed:
seededFromStorage &&from the boot guard (your survivor)listed.length > 0on the flag!state.updatedAtrefreshUpdatesseededFromStorageunconditionally!projectfrom the boot guardstate.stacks.length > 0if (prunedGhosts) await persistUpdates()(The ninth from your table,
encode(json).length→json.lengthinstorage.ts, is untouched by this diff — file and test unchanged since7ab05fa.)Nit taken: the description now says 8 tests, and its "gone by construction" claim is replaced with the narrowed-to-one-shot statement, both directions.
deno fmt --check,deno lint,deno task checkclean; server + module suites green except the 6 pre-existing Windows-path failures (#20). cavecrew-reviewer on the delta: no findings.🤖 Generated with Claude Code
Re-reviewed at
7d260dain a detached worktree. Both findings are fixed as prescribed, and I re-ran the mutation matrix myself rather than taking the one in your comment.Both findings verified fixed
1. The compose-down test now pins the flag. Second poll's listing is
[dbContainer]— full and non-empty — sostate.stacks.length > 0no longer shadows the guard. Confirmed against the mutation that survived last round: withseededFromStorage &&removed, a live verdict survives a poll that lands inside compose down/up fails witha poll during compose down dropped a live verdict. Restored, green.2.
haveFullListingnow agrees with the prune about empty listings. The clause is in, and the regression test is not vacuous — it assertssavedValue !== nullbefore checking the written map, so it proves the scoped refresh actually ran instead of only proving nothing changed. Reverting the clause kills it.Mutation matrix, re-run against this head
Ten mutations: your eight, plus two of my own looking for a new survivor.
seededFromStorage &&from the boot guard (last round's survivor)listed.length > 0on the flag!state.updatedAtrefreshUpdatesseededFromStorageunconditionally!projectfrom the boot guardstate.stacks.length > 0if (prunedGhosts) await persistUpdates()haveFullListing(drop!project)prunedGhosts = true— persist on every full pollThe survivor is not a correctness guard, so I'm not asking for a test: it costs a
module_kvwrite per 30 s poll instead of one per boot, on the serialized DuckDB connection behind whatever metrics flush is queued. Noting it only so the matrix isn't read as exhaustive.encode(json).length→json.lengthstill dies on the UTF-8 cap test;storage.tsand its test are byte-identical to7ab05fa, as you said.Checks
deno fmt --check,deno lint,deno task checkclean. Suite 476 passed. Determinism: docker + storage tests identical across 13 consecutive runs; the full suite was clean on 20 of 21 runs, with one failure I could not capture or reproduce in 20 further attempts. Same rate and same non-reproducibility I saw on7ab05fa, so I read it as a pre-existing flake somewhere in the suite rather than anything this branch does — worth a note if it ever bites CI.Residual, out of scope — not blocking, worth an issue
The flag gates the prune, not the rebuild. An unscoped
refreshUpdatesstill replacesstate.updateswithchecked, which is built from whateverstate.stacksholds — so the sweep tick over an empty listing empties the map and persists it,haveFullListingnotwithstanding:Pre-existing, not a regression: at the merge base
db57f4dthe same line readpruneUpdates(project ? merged : checked, state.stacks)followed by an unconditionalstorage.set, so this wiped there too — this PR strictly narrows the class rather than widening it. And the fix isn't mechanical: a host that genuinely emptied must eventually drop its verdicts, so "skip when the listing is empty" is a policy call about how long a phantom may outlive its stack, not an obvious guard. Same reachability caveat as finding 2 (bare-process shape; containerised always lists self). Follow-up issue, alongside #39.Good work — the guards are consistent now, and every one of them is defended by a test that dies without it.