e0efaed549
Deduplication GC (garbage_collect, Phase 2): - Add an orphan grace period before a ref_count=0 blob's backing file is physically deleted, mirroring git's gc.pruneExpire. New storage.blobs.orphaned_at records when a blob last reached ref_count 0; the delete trigger and every decrement / 0-ref insert path stamp it, every re-reference clears it. - Cross-check that no manifest lists the chunk and no file points at the blob before deleting it (mirrors Phase 1's file check), so a stale ref_count can only delay collection, never delete live content. - Unlink the backing files with bounded parallel fan-out. Together these close a TOCTOU where a concurrent upload of identical content could re-reference a chunk in the window between the GC row delete committing and the backing file being unlinked. Individual file deletes still reclaim eagerly; only bulk empty-trash and the periodic sweep observe the grace window. Trash: match ErrorKind::NotFound instead of substring-matching the error message when treating an already-deleted item as success. Content-index worker: supervise the drain loop and restart it with backoff after a panic, instead of letting a panic silently freeze the search index while the dirty queue grows unbounded. Adds migration 20260802000000_blob_gc_grace.sql and an integration test covering the grace window and reference cross-checks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0172rsVwzTwD216R9HXT2aU4
48 lines
2.3 KiB
PL/PgSQL
48 lines
2.3 KiB
PL/PgSQL
-- Garbage-collection safety for orphaned blobs.
|
|
--
|
|
-- The dedup GC deletes a blob row (committed) and then unlinks the backing
|
|
-- file. A concurrent uploader of identical content can re-reference a chunk in
|
|
-- that window. Two mechanisms make the sweep safe:
|
|
-- (a) garbage_collect() never collects a blob still referenced by a manifest
|
|
-- (chunk) or a file (legacy whole-file blob) — cross-checks backed by
|
|
-- idx_chunk_manifests_chunk_hashes_gin and idx_files_blob_hash. A stale
|
|
-- ref_count = 0 on live content can then only delay collection, never
|
|
-- delete it.
|
|
-- (b) garbage_collect() never collects a blob that became unreferenced only
|
|
-- moments ago — the grace period below, mirroring git's gc.pruneExpire,
|
|
-- so a writer about to pin a just-orphaned chunk cannot race the sweep.
|
|
--
|
|
-- `orphaned_at` records when ref_count last reached 0. NULL means the row is
|
|
-- referenced (ref_count > 0) or predates this column.
|
|
|
|
ALTER TABLE storage.blobs ADD COLUMN IF NOT EXISTS orphaned_at TIMESTAMPTZ;
|
|
|
|
-- Existing orphans start their grace window now, so applying this migration
|
|
-- never triggers an immediate sweep of content a writer might still be racing.
|
|
UPDATE storage.blobs
|
|
SET orphaned_at = now()
|
|
WHERE ref_count <= 0 AND orphaned_at IS NULL;
|
|
|
|
-- GC scan index: orphan rows ordered by when they became collectible. Replaces
|
|
-- the old ref_count-only partial index (the GC now also filters on orphaned_at).
|
|
DROP INDEX IF EXISTS storage.idx_blobs_orphaned;
|
|
CREATE INDEX IF NOT EXISTS idx_blobs_gc_eligible
|
|
ON storage.blobs (orphaned_at) WHERE ref_count = 0;
|
|
|
|
-- Stamp orphaned_at when a file delete drops a blob's ref_count to 0, so the
|
|
-- grace window starts at the moment of orphaning. No-op for multi-chunk files
|
|
-- whose file_hash is not itself a storage.blobs row.
|
|
CREATE OR REPLACE FUNCTION storage.decrement_blob_ref()
|
|
RETURNS trigger AS $$
|
|
BEGIN
|
|
UPDATE storage.blobs
|
|
SET ref_count = GREATEST(ref_count - 1, 0),
|
|
orphaned_at = CASE WHEN GREATEST(ref_count - 1, 0) = 0 THEN now() ELSE orphaned_at END
|
|
WHERE hash = OLD.blob_hash;
|
|
RETURN OLD;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
COMMENT ON COLUMN storage.blobs.orphaned_at IS
|
|
'When ref_count last reached 0; GC waits a grace period past this before deleting (NULL = referenced or pre-migration)';
|