Files
Oxicloud/migrations/20261017000002_repair_existing_refcount_drift.sql
T
Edouard Vanbelle e42c9c7e8b fix(migrations): linear-time refcount-repair with lifted statement_timeout
Original correlated-subquery form was O(files × manifests) and
  O(blobs × manifests) — hit statement_timeout on a large production
  customer's DB and hard-failed app boot (migration rolls back → sqlx
  marks failed → next start also fails; only recovery was bumping the
  role-level timeout manually before restart).

  Rewrite:
    * SET LOCAL statement_timeout = 0 (tx-scoped, auto-reset at COMMIT)
      — lifts the safety net for THIS migration only, so operators
      with restrictive session defaults can complete the one-time
      repair without intervention.
    * Both UPDATEs replaced with WITH ... UPDATE ... FROM CTE + LEFT
      JOIN patterns — single scans per source table, linear total work.
    * unnest(chunk_hashes) replaces b.hash = ANY(...) — cost is
      O(Σ chunk-array lengths), not O(blobs × manifests). No GIN
      index needed.

  Measured on sandbox with 303 rows of drift: 570 ms in the original
  form. New form on 200 rows drift, cache-warm: 15 ms. Second run on
  clean data: 10 ms no-op — idempotency preserved.

  Content semantics unchanged — same auditor formulas, same idempotence
  guarantee, same content-safety guarantees; only algorithmic
  complexity + statement_timeout scope changed.
2026-09-02 19:59:59 +02:00

163 lines
7.7 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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).
--
-- ═══════════════════════════════════════════════════════════════════
-- 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;
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.
--
-- 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
)
UPDATE storage.chunk_manifests m
SET ref_count = a.actual
FROM actual_per_manifest a
WHERE a.file_hash = m.file_hash
AND m.ref_count <> a.actual;
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[])
--
-- 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
)
UPDATE storage.blobs b
SET ref_count = a.actual
FROM actual_per_blob a
WHERE a.hash = b.hash
AND b.ref_count <> a.actual;
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;
$$;