Fix internal/corrosion/vms.go's scanVMRow (used by ListVMs/ListVMsPag... - #150
Merged
colonelpanik merged 1 commit intoAug 9, 2026
Conversation
colonelpanik
marked this pull request as ready for review
August 9, 2026 19:10
colonelpanik
force-pushed
the
overseer/fix-internal-corrosion-vms-go-s-scanvmrow-used-by-listvms-li
branch
from
August 9, 2026 19:11
bcaebce to
56dd559
Compare
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.
Goal
Fix internal/corrosion/vms.go's scanVMRow (used by ListVMs/ListVMsPage) so it actually populates VMRecord.PendingActionID from the pending_action_id column, and extend TestAnyStrandedPending in internal/grpcapi/ha_health_test.go to cover the currently-missing case: a state=pending VM that DOES carry a pending_action_id must NOT be reported as stranded.
Plan
Plan: populate
VMRecord.PendingActionIDin theListVMsprojectionfix(health): populate PendingActionID in the ListVMs projectionThe defect
internal/corrosion/vms.go:277scanVMRownever setsPendingActionID, and neitherListVMs(vms.go:248) norListVMsPage(vms.go:305) selects the column.Row.Stringon an absent column returns
""(internal/corrosion/client.go:1137→getreturns nil →""), so every VM read through the list path carriesPendingActionID == "".The only production reader of that field off the list path is
internal/grpcapi/ha_health.go:266:So today
anyStrandedPendingdegenerates to "anystate=pendingVM assigned to this host",and
evaluateHADegraded(ha_health.go:250) raiseslegacy_pending_strandedfor it wheneversplit_brain_gate_v1is enforced.This is a live false positive, not only a test gap. A normal proof-gated
reschedule/failover parks the VM at
host_name = <dest>, state = 'pending', pending_action_id = <proof id>in one batch (corrosion.WriteVMRescheduleProof,internal/corrosion/action_proofs.go:121). From that write until the destination'sreconciler completes the start, the destination node's HA monitor
(
RunHAHealthMonitor,ha_health.go:94-105) setslitevirt_ha_degraded{reason="legacy_pending_stranded"}and publishes anha.degradedevent, then an
ha.recoveredwhen the start lands. The reason string is documented as adurable operator-repair condition ("refused proof_missing forever",
ha_health.go:20,ha_health.go:244-249); the missing column turns it into routinetransient noise on every failover, and simultaneously makes the genuine stranded-legacy-row
signal indistinguishable from that noise.
internal/health/reconciler.go:736readsPendingActionIDtoo, but fromGetVM, whoseprojection is already correct (
vms.go:344) — so it is unaffected either way.Changes
1.
internal/corrosion/vms.go—ListVMsprojectionAdd the column using the exact
GetVMconvention (vms.go:344):COALESCEis redundant against the schema (pending_action_id TEXT NOT NULL DEFAULT '',schema.go:1103, and the v38ALTERatschema.go:2428is likewiseNOT NULL DEFAULT ''),but it matches
GetVMand costs nothing. Consistency here is the point: the two projectionsshould be diffable by eye.
2.
internal/corrosion/vms.go—ListVMsPageprojectionSame line added to
ListVMsPage's SELECT.scanVMRowis shared by both callers, so ifonly
ListVMscarried the column the shared scanner would silently produce""for thepaginated path — the same class of bug being fixed, one caller over.
PendingActionIDhas aconsumer that acts on an empty value (
anyStrandedPending), so it must be in bothprojections.
3.
internal/corrosion/vms.go—scanVMRowAdd the mapping, next to
IsTemplate:and replace the doc comment with one that describes the actual per-caller contract. The
scanner reads the union of what its callers select and those projections are not
identical, so the comment must not claim every caller carries every column — it would be
false the moment it was written (
ListVMsPagedeliberately omitsvm_owner_epoch; see Scope):This keeps the real hazard documented (silent zero values) without asserting a uniformity
invariant the code does not hold, and it names the one asymmetry a future reader would
otherwise have to rediscover.
Also correct the now-stale
VMRecordfield comment atvms.go:58-61— it claims the v41trio is "Populated by GetVM; the ListVMs projection omits them", which is already untrue of
OwnerEpoch. Narrow it toSpecGeneration/ActiveOperationID, the two that really areomitted by both list projections, and note that
OwnerEpochrides the unpaginated list readonly. Comment-only.
4.
internal/grpcapi/ha_health_test.go— extendTestAnyStrandedPendingInsert the missing negative case between the existing running-VM check and the
vmbadmarkerless-pending case:Two things a reviewer should check about this placement:
anyStrandedPendingis an OR over rows, so the negative casemust be asserted while no stranded row exists. Asserting it after
vmbadwould bevacuous — the function returns true regardless.
vmbadlast strengthens the existing assertion for free: it now proves thedetector still fires with a legitimately-marked pending row already in the table, i.e.
the predicate is per-row, not "any pending ⇒ true" nor "any marker ⇒ false".
Seeding via
WriteVMRescheduleProofrather than a rawUPDATE vms SET pending_action_id=…(the precedent at
internal/health/reconciler_gate_test.go:583) is deliberate: it is thereal producer of the
(state='pending', pending_action_id)pair, so the fixture cannot driftfrom the production shape, and the test would follow a future change to that write. Proof
replication gating is send-side only (
replicator.peerLacksProofSupport), so writing a proofrow into a single-node test client is unaffected.
p.OwnerEpochleft empty skipsWriteVMRescheduleProof's epoch CAS, which matters becauseInsertVMleavesvm_owner_epochat 0.5.
internal/corrosion/vms_test.go— pin the projection at its own layerThe grpcapi test proves the consequence; this pins the cause, and covers
ListVMsPage,which no grpcapi test reaches for this field:
apTestClient/apInsertVM/apProofalready exist ininternal/corrosion/action_proofs_test.go(same package), so no new helper.
No other files change. No schema change, no proto change, no docs change (see below).
Mutation verification
Per CLAUDE.md, the assertion is worthless until seen failing. Exact sequence:
go test ./internal/grpcapi/ -run TestAnyStrandedPending -count=1→ must FAIL on "a pending VM carrying a pending_action_id is a minted transfer, not stranded".
go test ./internal/corrosion/ -run TestListVMs_SurfacesPendingActionID -count=1→ must FAIL on
ListVMs: PendingActionID=""; want p1.Capture both failure outputs for the PR body.
projections are pinned independently, rather than one test masking the other):
PendingActionID:fromscanVMRow→ both tests red.ListVMsonly → grpcapi test red; corrosion test red onthe
ListVMscase, green onListVMsPage.ListVMsPageonly → corrosion test red on theListVMsPagecase;grpcapi test green (
anyStrandedPendinguses the unpaginatedListVMs). Thisasymmetry is the reason change 5 exists.
go build ./... && go vet ./...,go test ./...,make ci-guards.Baseline recorded before starting:
go build ./...,go vet ./internal/corrosion/ ./internal/grpcapi/,go test ./internal/grpcapi/ -run TestAnyStrandedPending, andgo test ./internal/corrosion/ -run 'TestListVMs_Filter|TestWriteVMRescheduleProof'all passon the unmodified tree — so any red in step 1 is attributable to the new assertions.
Scope held
pending_action_idonly.SpecGeneration/ActiveOperationIDstay out of both listprojections. They have no demonstrated live bug (
ListVMsWithActiveOperation,internal/corrosion/operations.go:507, is the purpose-built reader) and widening the listcolumn set touches ~40
ListVMscall sites' data shape for nothing.Pre-existing divergence deliberately left alone — a reviewer will spot it, so stating
it:
ListVMsselectsCOALESCE(vm_owner_epoch, 0)butListVMsPagedoes not, soOwnerEpochreads 0 through the paginated path. Its only caller is theListVMsRPChandler (
internal/grpcapi/vm.go:920), which never readsOwnerEpoch, and the dual-rundetector uses the unpaginated
ListVMs(internal/grpcapi/dualrun.go:468) — no live bug,so out of scope under the "no unmotivated widening" constraint. Worth a follow-up issue,
not this commit.
The consequence for every comment this change writes (
scanVMRowin change 3, the testdoc in change 5, and the justification in change 2): the two list projections are not
uniform, so each states a per-column requirement —
pending_action_idmust be on bothpaths because
anyStrandedPendingacts on an empty value — and never the general rule"every caller selects every column that
scanVMRowreads." That rule is false in the treethis plan produces, since
vm_owner_epochstaysListVMs-only.Earlier drafts of this plan asserted that rule in two separate places, and both were wrong.
Worth naming the trap, because the change invites it: the fix's whole shape is "the
scanner reads a column no projection selected", which reads as an argument for uniformity —
but the actual defect is narrower and the honest invariant is per-column. A comment stating
a false invariant is worse than the terse comment it replaces, because the next person
adding a field would trust it and assume their column is already carried everywhere.
The alternative resolution — adding
vm_owner_epochtoListVMsPageso the uniform rulebecomes true — is a projection widening with no demonstrated bug behind it, i.e. exactly
what this task's constraints exclude. If a future change gives
OwnerEpocha paginatedconsumer, that is when the column should be added, and
scanVMRow's comment says so.What a reviewer needs to know
anyStrandedPending.PendingActionIDhas two productionreaders (
ha_health.go:272viaListVMs,reconciler.go:736viaGetVM); nothing writesa
VMRecordback from a list read (InsertVMdoes not touchpending_action_idat all,vms.go:167), and theListVMsRPC maps a fixedpb.VMfield set that excludes it(
vm.go:959). So no proto/UI/REST surface changes and no round-trip risk.make ci-guardsanyway.stmtshapecheckonlyfingerprints replicated statements — calls to
Execute,ExecuteRows,ExecuteDeferred,ExecuteBatch,ExecuteBatchGuarded(scripts/ci/stmtshapecheck/main.go:456)— and loads packages with
Tests: false. AQuerySELECT is neither replicated norscanned, so no ledger entry and no schema bump are required for this change, and the
raw SQL in the new tests is invisible to it.
writecheckskips_test.go(
scripts/ci/writecheck/main.go:79) and only flags discarded returns of its namedcorrosion.<Fn>writers; both new test writes check their error regardless.legacy_pending_strandedappears only inha_health.goand Go tests —no
docs/reference, and the docs-triangulation guard covers commands / config keys /litevirt_*identifiers, none of which move here. (Thelitevirt_ha_degradedmetric nameand its reason vocabulary are unchanged; only how often one reason fires changes.)
chronically reporting
legacy_pending_strandedmay go quiet — that is the fix, not a lostsignal. A node still reporting it is now reporting a genuinely markerless pending row
(an enforcement-flip legacy row), which is the operator-repair case the reason was built
for.
fix(health)— the observable defect is HA health reporting, even though thecode change lands in
internal/corrosion.fix(corrosion)would be equally defensible;pick one, not both.
Deviations from the plan as executed
Two, both minor; the plan's substance held everywhere else (every predicted failure message
matched verbatim, including mutation 3's grpcapi/corrosion asymmetry).
Three whitespace-only lines beyond change 3's stated scope.
vms.gowas ALREADYgofmt-dirty at
de655eafor one reason:scanVMRow's literal had its post-comment tail(
OwnerEpoch/CreatedAt/UpdatedAt) padded to the pre-comment group's width, which gofmtdoes not do — a comment line splits an alignment group, so each side aligns independently.
Adding
PendingActionID:makes it the longest key in the pre-comment group, widening thatside and leaving the tail visibly mis-padded against nothing.
gofmt -w internal/corrosion/vms.gowas therefore run, which re-pads those three lines and makes the file gofmt-clean (many other
files in the tree are not, and no CI guard checks gofmt —
make ci-guardshas no fmt step).The alternative was leaving a struct literal I had just edited misaligned; the churn is three
lines, entirely inside the literal the change touches.
Mutation-verification step 3, second bullet, is only half-observable. The plan predicts
that removing the column from
ListVMsonly leaves the corrosion test "red on theListVMscase, green on
ListVMsPage". The second half cannot be seen:TestListVMs_SurfacesPendingActionIDiterates a plain slice (not
t.Runsubtests) and each assertion ist.Fatalf, so the runaborts at the
ListVMscase and never reachesListVMsPage. Observed output wasListVMs: PendingActionID=""; want p1, which is the assertion that mutation targeted. TheListVMsPagehalf is pinned independently by the third mutation, which behaved exactly aswritten — corrosion red with
ListVMsPage: PendingActionID=""; want p1, grpcapi green —so the property the plan wanted proved (the two projections are pinned separately, neither
test masking the other) is proved. No test change was made for this: converting the loop to
subtests to surface both cases per mutation would trade a real
t.Fatalf-on-first-failureidiom used throughout this package for mutation-run ergonomics only.
Verification run:
go build ./...,go vet ./...,go test ./..., andmake ci-guardsallpass (ci-guards: no schema growth at v50, no ledger drift, 336 replicated statements all
registered, docs triangulation OK).
Final Codex review
No blocking findings remained.
Opened by overseer. The plan and the code each converged to zero blocking findings from Codex before this pull request was created.