Repository navigation
fix(storage): never collect blobs that retained history still references - #472
Merged
Merged
Conversation
The v4 migration reconstructs blob claims only from current records, and blob GC never consulted pdpp_record_changes. A pre-v4 blob referenced only by a history row was therefore collected during migration or startup while the history row still pointed at it. Blob GC now also skips any blob referenced by retained history. Explicit owner erase paths delete history before calling the collector, so they still free blobs. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
The blob GC's history check scanned every pdpp_record_changes row for each candidate blob, at every writable open. EXPLAIN QUERY PLAN showed `SCAN pdpp_record_changes` inside that correlated subquery. Schema v15 adds an expression index on the blob reference. The GC predicate is built by one helper, so the query and the index expression cannot drift. The CAST gives the expression TEXT affinity: without it the planner does not search the index for the TEXT blob_id comparison. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
…JSON json_extract raises "malformed JSON" on a non-JSON history row. The plain expression index would therefore fail CREATE INDEX in the v15 migration, so the Personal Server would refuse to start, and every later insert of such a row would fail too. The indexed expression and the GC predicate now both guard with json_valid. The outer CAST keeps TEXT affinity; a CASE alone stops the planner from searching the index. A malformed row has no parseable blob reference, so it does not protect a blob. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
…adable A history row whose JSON cannot be parsed may reference any blob, and the startup sweep cannot tell which. While any pdpp_record_changes row holds invalid JSON, startup skips blob collection and logs one warning with the row count. Claim cleanup still runs. Explicit owner erasure calls the collector directly and is not gated. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
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.
Impact
A blob that only a retained history row references can be deleted automatically during migration or startup. The history row keeps its
blob_ref, but the bytes are gone. Affected blobs are pre-v4 history blobs with no current record and no claim.The regression tests show the loss directly: after a writable open,
getBlobMetaandgetBlobBytesreturnundefinedfor the history-referenced blob.Cause
packages/server/src/storage/pdpp-records-sqlite-store.ts:228-232(v4 migration): blob claims are seeded only frompdpp_records, notpdpp_record_changes. Historical blobs get no claim.packages/server/src/storage/pdpp-records-sqlite-store.ts:443-462(sweepStaleBlobClaims): onorigin/mainit ran the collector at startup. The collector deleted any blob with no claim and no live current record. It never consulted history.packages/server/src/storage/pdpp-records-sqlite-store.ts:690:sweepStaleBlobClaimsruns on every writable open, so the deletion happens at startup.Fix
Three changes, in this order:
deleteUnclaimedBlobsalso requires that nopdpp_record_changesrow references the blob. The predicate is built by one helper,noRetainedHistoryReferenceSql, which both deletes use.collectStartupUnclaimedBlobsruns in the startup sweep only. If any history row holds invalid JSON, it skips blob collection and logs one warning with the row count. A row that cannot be parsed may reference any blob, so the sweep cannot prove which blobs are safe. Claim cleanup still runs.eraseInstanceDatastill callsdeleteUnclaimedBlobsdirectly.deleteConnectionandresetInstanceBindingboth reach it. It deletes the erased instance's history first, so the collector can free its blobs.Index (v15)
Schema v15 adds
idx_pdpp_record_changes_blob_refonjson_validguard:json_extractraisesmalformed JSONon a non-JSON history row. Without the guard,CREATE INDEXfails, the store refuses to start, and later inserts of such a row fail too. The guard keeps the index buildable and inserts working.CAST: without it the planner keepsSCANeven with the index present, because the outerpdpp_blobs.blob_idcolumn has TEXT affinity. ACASEalone does not provide the affinity.Raw
EXPLAIN QUERY PLAN, correlated history subquery only:The DELETE statement plans the same way.
Before / after
Tests:
pdpp-history-blob-gc.test.ts(3 cases): a history-only blob survives migration and startup until owner erase.pdpp-history-blob-ref-index.test.ts(4 cases):Results:
origin/main, GC test kept): 3 failed / 3. Example:AssertionError: expected undefined to be '98fcd9c1…'.SqliteError: malformed JSONat open.CASTleaves the planner onSCAN.packages/server/src/storage(28 files): 694 passed / 694.tsc --build packages/server: exit 0.Review focus
CREATE INDEXruns once, at first open after upgrade, and scans all history rows.🤖 Generated with Claude Code