Repository navigation
fix(repocontext): delete cleared grain state instead of retaining a dead row - #3312
Merged
NSTA1 merged 1 commit intoSep 20, 2026
Conversation
…ead row Nothing in this host removed a row from OrleansStorage. ClearStorageKey nulls PayloadBinary and keeps the row, the embedded schema defined no grain-state delete at all, and AdoNetGrainStorageOptions.DeleteStateOnClear sat at the Orleans default of false and was referenced nowhere. Measured on the deployed container, 29.8 percent of rows were dead and accruing at roughly 3,000 a day. What makes that unbounded is key shape rather than grain type. A stable key is rewritten in place, so it costs at most one dead row. A generational key - the per-leaf generation counter behind leaf-snapshot-segment, the per-cursor GUID behind snapshot-baseline and lattice-cursor - is never revisited, so every generation leaves a permanent row. The SQLite script now defines a DeleteStorageKey query and the SQLite and Azure Table branches enable the option. The two are one change: Orleans resolves the query key during AdoNetGrainStorage.Init and fails silo startup when the option is set and the key is absent. No migration is needed, because SqliteSchemaInitializer reapplies the script INSERT OR REPLACE on every start. The query reports its version as a scalar rather than using upstream's DELETE ... RETURNING, which emits one row per deleted row and would throw out of Orleans' SingleOrDefault() against a grain holding a duplicate row. Postgres deliberately stays at false: this host ships no Postgres schema, so enabling the option could turn an operator-provisioned catalogue lacking the query into a container that will not boot. This bounds row count, index depth and scan cost. It reclaims no disk - a nulled row already held no payload bytes - and does not remove rows already tombstoned. Refs #3307
NSTA1
force-pushed
the
fix/epic/repocontext-reliability-4-grain-row-tombstones
branch
from
September 20, 2026 21:25
d71ec9a to
0e61afd
Compare
NSTA1
deleted the
fix/epic/repocontext-reliability-4-grain-row-tombstones
branch
September 20, 2026 21:47
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.
What this fixes
Nothing in the repocontext host ever removed a row from
OrleansStorage.OrleansQuerycatalogue contains no grain-state delete at all.ClearStorageKeyis anUPDATEthat nullsPayloadBinary, stampsModifiedOnand incrementsVersion, keeping the row. The onlyDELETE FROM OrleansStorage...anywhere in the schema targets the tempOrleansStorageWriteStatebookkeeping table insideWriteToStorageKey.AdoNetGrainStorageOptions.DeleteStateOnClearsat at the Orleans default offalseand was referenced nowhere in the repository.Measured on the deployed container, 29.8% of
OrleansStoragerows (18,810 of 63,092) were dead, accruing at roughly 3,000 a day.The mechanism that makes this unbounded is key shape, not grain type. A grain keyed stably rewrites one row in place, so clear-without-delete costs it at most one dead row -
leaf-snapshotsits at Version 30-66 for exactly that reason. A grain keyed generationally never revisits a key, so every generation it has ever taken leaves a permanent row:leaf-snapshot-segmentkeys on a per-leaf generation counter and all 19 generations coexist in the live database, with generations 10, 11 and 15-19 holding zero live bytes.What this does NOT fix, stated so nothing here is read as claiming it
This does not reclaim disk and the database will not shrink. The file is 97.7% live payload (
page_size4096 xpage_count1,283,605 = 5,257,646,080 bytes againstSUM(LENGTH(PayloadBinary))= 5,136,292,251), andfreelist_countis 1,990 pages, i.e. 8.1 MB. A cleared row holds zero payload bytes by construction, so the tombstones cost fixed columns plus an index entry - order 250 MB/year. The gigabytes are liveleaf-snapshotdata and are unaffected by this change.What it does bound is row count (about 18x table inflation a year),
IX_OrleansStoragedepth and scan cost, and the legibility of the store - at present you cannot tell a live grain from a dead one.It also does not remove the rows already tombstoned. A stable-key row is reclaimed on its next rewrite; a generational-key row never is. A one-time scrub is a bulk delete against a live 4.9 GB database with a different risk profile and belongs in its own issue.
The change
Two coupled edits that must land together.
apps/repocontext/Persistence/Sqlite/SQLite-Persistence.sqlgains aDeleteStorageKeyquery.apps/repocontext/Hosting/DurabilitySelector.csenablesDeleteStateOnClearon the SQLite and Azure Table branches.They are coupled because Orleans resolves the query key literal
DeleteStorageKeyduringAdoNetGrainStorage.Init- it is one of the four keys selected by the publicAdoNetGrainStorage.DefaultInitializationQueryconstant - and throws when the option is set and the key is absent. That fails silo startup, not the first clear, so enabling the option alone would produce a container that does not boot.ClearStorageKeyis retained because Orleans resolves it unconditionally either way.No migration step is needed.
SqliteSchemaInitializerreapplies the embedded script on every start and every query isINSERT OR REPLACE, so an existing database picks the new key up on the next deploy.Why not upstream's query text
Orleans' PostgreSQL form is
DELETE ... RETURNING Version + 1, which emits one row per deleted row.IX_OrleansStorageis deliberately non-unique, so against a grain identity that had acquired a duplicate row that form hands Orleans two rows and itsSingleOrDefault()throwsInvalidOperationException("Sequence contains more than one element")- reintroducing precisely the wedge #3116 removed from this file. The new query computes the version as a scalar, matching the local shape already used byWriteToStorageKeyandClearStorageKey, and returns exactly one row however many were deleted.Decision on the three storage branches
DeleteStateOnCleartruetruefalse, deliberatelyPostgreSQL-Persistence.sqlwith its 3.6.0 migration in dotnet/orleans#11247, so presence is both version- and provisioning-path-dependent. The reason and the opt-in recipe are recorded in a comment beside the arm, and pinned by a test.The two leads from the issue
Both are explained, and both turn out to be the same mechanism as the headline defect rather than separate defects.
snapshot-baseline, 100% dead (3,776/3,776), accruing ~1,100/day. Not a second defect.SnapshotLeafGrain.BuildBaselineKeyis{treeId}/{shardIndex}/{baselineToken:N}- a per-cursor GUID, so the key is generational in the same sense asleaf-snapshot-segment. A baseline is written lazily only when a snapshot scan must survive past its first page, and is cleared on cursor close (LatticeCursorGrain.Snapshot.cs) or by the sliding-TTL leak guard. Every baseline is therefore ephemeral by design and every key is used once. 100% dead is the expected census signature for such a population under clear-without-delete, not evidence of a leak: a live row would only appear if a scan happened to be mid-page at the sampling instant. The name is misleading - it is a per-scan baseline, not a durable one. Fixed entirely by this change.lattice-cursor, 100% dead (80/80) at Version 3. The same mechanism.LatticeCursorGrainis per-cursor, keyedrepo-context-memory/<guid>, and callsClearStateAsync()on close. Version 3 is consistent with an open/advance/close lifecycle. No connection to #3300 was established and none is asserted here. The 100% dead census is fully accounted for by ephemeral keys plus clear-without-delete, so it is not evidence either way about whether that tree checkpoints.Regression review
The one real interaction is WAL GC blocking-pin classification. A cleared leaf currently reads back as
RecordExists == truewith a default state and an emptyTreeId, classifying asOrphaned; with the row gone it reads as no record and classifies asNoDurableState. The orphan-retirement sweep inLatticeWalGcSchedulerhandles both in the same case block, so the repair added by #3297 is unaffected and only the metric label moves.IndexedPersistentStateenrolment is gated onRecordExistsand becomes strictly more correct, since a dead grain stops projecting a default-state index entry. Clear-then-write within an activation stays correct: the ETag is now null, so the write takes the insert path rather than the update path, which is what the round-trip test pins.Tests
test/lattice.api.mcp.repocontext/Host/SqliteGrainStorageQueryTests.csdrives the real embedded query text against a real SQLite file:A_delete_removes_the_row_entirelyassertsCOUNT(*) == 0. This is deliberately the row-count assertion rather than "a read returns empty" - the latter is the weaker claim that current clear-without-delete behaviour already satisfies, so it could not distinguish the fix from the bug.A_clear_leaves_a_tombstone_row_behind_which_is_the_behaviour_delete_replacespins the contrast that gives the test above its meaning, so the two queries cannot silently converge.SqliteDeleteOnClearContractTests.csis the coupling guard: it executes Orleans' ownAdoNetGrainStorage.DefaultInitializationQueryverbatim against an initialised database and checks the resulting key set against the option value the real wiring produces. It reddens if the query is renamed or dropped, if the option is enabled on a branch whose schema lacks the key, or if Orleans changes which keys it demands. It carries a negative control so the assertion cannot go vacuous.DurabilitySelectorWiringTests.cspinsDeleteStateOnClearastrueon the SQLite arm andfalseon the Postgres arm, with the reason in the assertion message.Follow-up worth its own issue
Purging the ~18,810 rows already tombstoned. Not done here: it is a bulk delete against a live 4.9 GB database, it is not in this issue's acceptance criteria, and it would still reclaim no disk.
Refs #3307