2026-08-23 23:12:59 +02:00
|
|
|
|
-- One-time repair of ref_count drift accumulated under the pre-fix
|
|
|
|
|
|
-- copy/delete code paths.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- Why this is atomic with the upgrade rather than a manual admin action
|
|
|
|
|
|
-- ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
-- The two prior migrations on this branch:
|
|
|
|
|
|
-- * `20261016000000_copy_folder_tree_manifest_refcount.sql`
|
|
|
|
|
|
-- (fix the INCREMENT path — copy was bumping the wrong counter for
|
|
|
|
|
|
-- CDC files)
|
|
|
|
|
|
-- * `20261017000000_file_delete_trigger_manifest_aware.sql`
|
|
|
|
|
|
-- (fix the DECREMENT path — trigger was decrementing the wrong
|
|
|
|
|
|
-- counter for CDC files; folder-cascade + trash-empty paths
|
|
|
|
|
|
-- inherited that drift silently)
|
|
|
|
|
|
-- both close the bugs going forward, but production DBs upgrading
|
|
|
|
|
|
-- through this branch may carry accumulated drift from every prior
|
|
|
|
|
|
-- copy → delete cycle a CDC file went through. Under-count is the
|
|
|
|
|
|
-- dangerous direction: the next `dedup_gc` pass would reap a live
|
|
|
|
|
|
-- blob → user-facing 404 → silent data loss.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- Waiting for an operator to open the admin panel and click "Repair
|
|
|
|
|
|
-- ref_counts" is the wrong default for a data-loss-preventing fix.
|
|
|
|
|
|
-- Ed's rule (`[[feedback_no_silent_auto_repair]]`): consistency
|
|
|
|
|
|
-- tenants must default to discovery-only so future bugs surface — but
|
|
|
|
|
|
-- fixing KNOWN pre-existing drift on the upgrade itself is the
|
|
|
|
|
|
-- bounded exception, because at that specific moment the source of
|
|
|
|
|
|
-- drift is known + closed, and there is no upstream mystery to
|
|
|
|
|
|
-- preserve.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- Content-safety guarantees:
|
|
|
|
|
|
-- * Only counter columns change (`storage.chunk_manifests.ref_count`,
|
|
|
|
|
|
-- `storage.blobs.ref_count`). No file rows, no blob rows, no
|
|
|
|
|
|
-- manifest rows, no chunk arrays, no backend files.
|
|
|
|
|
|
-- * The corrective UPDATE sets `stored = actual` where `actual` is
|
|
|
|
|
|
-- computed from the SAME auditor formulas that
|
|
|
|
|
|
-- `manifests_consistency` / `blobs_consistency` use, so this
|
|
|
|
|
|
-- migration and those tenants agree by construction.
|
|
|
|
|
|
-- * Race-safe against concurrent writes (migrations run
|
|
|
|
|
|
-- single-connection at startup before the server serves any
|
|
|
|
|
|
-- traffic; nobody else is writing).
|
|
|
|
|
|
-- * Idempotent — fresh installs and already-clean DBs no-op (both
|
|
|
|
|
|
-- `stored` and `actual` are equal, the `WHERE <>` filters
|
|
|
|
|
|
-- everything out).
|
|
|
|
|
|
--
|
|
|
|
|
|
-- The panel button + `?repair=true` on the trigger endpoints stay for
|
|
|
|
|
|
-- FUTURE drift (regression detector; not for repeat use on this
|
|
|
|
|
|
-- accumulated set).
|
2026-09-02 19:59:59 +02:00
|
|
|
|
--
|
|
|
|
|
|
-- ═══════════════════════════════════════════════════════════════════
|
|
|
|
|
|
-- Performance envelope (rewrite 2026-09-02)
|
|
|
|
|
|
-- ═══════════════════════════════════════════════════════════════════
|
|
|
|
|
|
-- Original implementation used correlated subqueries in both SET and
|
|
|
|
|
|
-- WHERE clauses — PG evaluates each subquery twice per row, and the
|
|
|
|
|
|
-- `b.hash = ANY(m.chunk_hashes)` scan is O(blobs × manifests) without
|
|
|
|
|
|
-- a GIN index. On a production customer with a large storage.blobs +
|
|
|
|
|
|
-- storage.chunk_manifests, this exceeded `statement_timeout` (often
|
|
|
|
|
|
-- 30 s on managed PG configs) and rolled back the whole migration,
|
|
|
|
|
|
-- hard-failing app boot.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- Rewrite computes each count set ONCE via aggregate CTEs, then joins
|
|
|
|
|
|
-- against target rows. Total work is O(files + manifests + blobs +
|
|
|
|
|
|
-- Σ|chunk_hashes|) — linear in data size, not quadratic. Also lifts
|
|
|
|
|
|
-- statement_timeout for THIS migration's transaction so a very large
|
|
|
|
|
|
-- one-time repair can complete on any operator's PG config without
|
|
|
|
|
|
-- them having to intervene.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- Trade-off of `SET LOCAL statement_timeout = 0`: disables the safety
|
|
|
|
|
|
-- net for this migration only (SET LOCAL is transaction-scoped —
|
|
|
|
|
|
-- resets automatically at COMMIT). Justified because (a) work is
|
|
|
|
|
|
-- bounded by table size via the new linear query shape, (b) this is
|
|
|
|
|
|
-- a one-time repair, not a recurring query, (c) app boot is blocked
|
|
|
|
|
|
-- until it completes anyway.
|
|
|
|
|
|
--
|
|
|
|
|
|
-- Measured on a sandbox DB with 303 rows of drift (100 induced + 203
|
|
|
|
|
|
-- pre-existing): 570 ms end-to-end vs. timeout in the original form.
|
|
|
|
|
|
|
|
|
|
|
|
-- Lift the timeout for this migration only. Future migrations inherit
|
|
|
|
|
|
-- the session default again (SET LOCAL resets automatically at COMMIT).
|
|
|
|
|
|
SET LOCAL statement_timeout = 0;
|
2026-08-23 23:12:59 +02:00
|
|
|
|
|
|
|
|
|
|
DO $$
|
|
|
|
|
|
DECLARE
|
|
|
|
|
|
v_m_fixed int;
|
|
|
|
|
|
v_b_fixed int;
|
|
|
|
|
|
BEGIN
|
|
|
|
|
|
-- Manifest counter: `actual` = # files whose blob_hash names this
|
|
|
|
|
|
-- manifest's file_hash. Same formula as
|
|
|
|
|
|
-- `manifests_consistency_service::manifest_page_sql` (via the
|
|
|
|
|
|
-- BlobReferenceRegistry at RefLevel::Manifest) — inline here
|
|
|
|
|
|
-- because migrations can't call Rust.
|
2026-09-02 19:59:59 +02:00
|
|
|
|
--
|
|
|
|
|
|
-- Structure: one GROUP BY over storage.files aggregating counts
|
|
|
|
|
|
-- per blob_hash (single scan), LEFT JOIN against every manifest
|
|
|
|
|
|
-- so zero-file manifests also get actual=0. UPDATE ... FROM
|
|
|
|
|
|
-- walks manifests once, writes only where stored <> actual.
|
|
|
|
|
|
WITH file_counts_by_hash AS (
|
|
|
|
|
|
SELECT blob_hash, COUNT(*)::bigint AS n
|
|
|
|
|
|
FROM storage.files
|
|
|
|
|
|
GROUP BY blob_hash
|
|
|
|
|
|
),
|
|
|
|
|
|
actual_per_manifest AS (
|
|
|
|
|
|
SELECT m.file_hash,
|
|
|
|
|
|
COALESCE(fc.n, 0) AS actual
|
|
|
|
|
|
FROM storage.chunk_manifests m
|
|
|
|
|
|
LEFT JOIN file_counts_by_hash fc ON fc.blob_hash = m.file_hash
|
|
|
|
|
|
)
|
2026-08-23 23:12:59 +02:00
|
|
|
|
UPDATE storage.chunk_manifests m
|
2026-09-02 19:59:59 +02:00
|
|
|
|
SET ref_count = a.actual
|
|
|
|
|
|
FROM actual_per_manifest a
|
|
|
|
|
|
WHERE a.file_hash = m.file_hash
|
|
|
|
|
|
AND m.ref_count <> a.actual;
|
2026-08-23 23:12:59 +02:00
|
|
|
|
GET DIAGNOSTICS v_m_fixed = ROW_COUNT;
|
|
|
|
|
|
|
|
|
|
|
|
-- Blob counter: two-term formula mirroring
|
|
|
|
|
|
-- `blobs_consistency_service.rs:395-408`:
|
|
|
|
|
|
-- (files pointing at this blob AND having NO manifest for their
|
|
|
|
|
|
-- blob_hash — legacy whole-file path)
|
|
|
|
|
|
-- + (manifests including this hash as a chunk in chunk_hashes[])
|
2026-09-02 19:59:59 +02:00
|
|
|
|
--
|
|
|
|
|
|
-- Structure: two aggregate CTEs (one per term), then LEFT JOINed
|
|
|
|
|
|
-- against every blob. `unnest(chunk_hashes)` cost is O(Σ chunk
|
|
|
|
|
|
-- array lengths) — no per-blob scan of chunk_manifests, no GIN
|
|
|
|
|
|
-- index needed.
|
|
|
|
|
|
WITH legacy_file_counts AS (
|
|
|
|
|
|
-- Files whose blob_hash has NO manifest entry — legacy
|
|
|
|
|
|
-- whole-file uploads that pre-date CDC.
|
|
|
|
|
|
SELECT f.blob_hash, COUNT(*)::bigint AS legacy_count
|
|
|
|
|
|
FROM storage.files f
|
|
|
|
|
|
WHERE NOT EXISTS (
|
|
|
|
|
|
SELECT 1 FROM storage.chunk_manifests m
|
|
|
|
|
|
WHERE m.file_hash = f.blob_hash
|
|
|
|
|
|
)
|
|
|
|
|
|
GROUP BY f.blob_hash
|
|
|
|
|
|
),
|
|
|
|
|
|
chunk_usage_counts AS (
|
|
|
|
|
|
-- Chunk-level references — one (manifest, chunk_hash) row
|
|
|
|
|
|
-- via unnest, aggregated per chunk_hash in a single scan of
|
|
|
|
|
|
-- chunk_manifests.
|
|
|
|
|
|
SELECT ch AS hash, COUNT(*)::bigint AS chunk_count
|
|
|
|
|
|
FROM storage.chunk_manifests,
|
|
|
|
|
|
unnest(chunk_hashes) AS ch
|
|
|
|
|
|
GROUP BY ch
|
|
|
|
|
|
),
|
|
|
|
|
|
actual_per_blob AS (
|
|
|
|
|
|
SELECT b.hash,
|
|
|
|
|
|
COALESCE(l.legacy_count, 0) + COALESCE(u.chunk_count, 0) AS actual
|
|
|
|
|
|
FROM storage.blobs b
|
|
|
|
|
|
LEFT JOIN legacy_file_counts l ON l.blob_hash = b.hash
|
|
|
|
|
|
LEFT JOIN chunk_usage_counts u ON u.hash = b.hash
|
|
|
|
|
|
)
|
2026-08-23 23:12:59 +02:00
|
|
|
|
UPDATE storage.blobs b
|
2026-09-02 19:59:59 +02:00
|
|
|
|
SET ref_count = a.actual
|
|
|
|
|
|
FROM actual_per_blob a
|
|
|
|
|
|
WHERE a.hash = b.hash
|
|
|
|
|
|
AND b.ref_count <> a.actual;
|
2026-08-23 23:12:59 +02:00
|
|
|
|
GET DIAGNOSTICS v_b_fixed = ROW_COUNT;
|
|
|
|
|
|
|
|
|
|
|
|
-- Landed in the deploy log so an operator upgrading a huge instance
|
|
|
|
|
|
-- can see the migration did work — silent no-op on fresh installs.
|
|
|
|
|
|
RAISE NOTICE '[refcount_repair] fixed % manifest(s), % blob(s)',
|
|
|
|
|
|
v_m_fixed, v_b_fixed;
|
|
|
|
|
|
END;
|
|
|
|
|
|
$$;
|