Files
Oxicloud/migrations/20261019000000_copy_file_satellites.sql
T
Edouard Vanbelle 9f8ec141f3 feat(storage): single-source the copy fan-out via copy_file_satellites
Step 8 of docs/plan/derived-blobs.md. "What follows a file on copy" was
written twice — the copy_file CTE and storage.copy_folder_tree — and had
already drifted: the tree path bumped storage.blobs only, missing
manifests, which was silent data loss on any multi-chunk file. Fixing it
meant writing the same logic a second time. Step 9 adds a file-keyed
satellite table, which would mean a third and fourth.

Two SQL functions:

  storage.add_blob_references(TEXT[]) — the manifest-first reference
  contract for SQL callers, returning hashes that matched no registry
  row. Set-based so the tree path keeps its single-statement cost; a
  per-row helper would have made a 10k-file copy 10k calls.

  storage.copy_file_satellites(UUID[], UUID[]) — dead properties plus
  the blob reference. The body is the copy-semantics declaration: what
  is absent (comments, favorites, content-keyed derived rows) is listed
  with its reason, so the taxonomy is executable rather than documented
  elsewhere and drifting.

Both copy paths now call it. The single-file path becomes a real
transaction, which also fixes the reference being best-effort: a failed
add_reference used to log a warning and leave a copy holding no
reference at all — the exact shape that gets its content reaped. It
cannot be a CTE arm, because data-modifying CTEs share one snapshot and
the function must read the row the INSERT just wrote.

Verified against a scratch PG with all migrations applied: multi-chunk
manifest 1→2, single-chunk alias bumped at manifest level only (the
NOT EXISTS guard), chunks behind a manifest untouched, dead properties
duplicated, length mismatch rejected, repeats counted.

tests/api/derived_blob_copy.hurl covers it end-to-end and answers the
question the copy raises: content_derived_blobs is NOT copied. A copy
carries the same blob_hash, so it resolves the same derived row — the
test asserts byte-identical thumbnails from both copy paths, then
deletes the original, runs GC, and requires both copies to still serve.
That last step only passes if the references are real.
2026-08-30 13:41:04 +02:00

333 lines
14 KiB
PL/PgSQL

-- Step 8 of `docs/plan/derived-blobs.md` — single-source the copy fan-out.
--
-- "What follows a file when the file is copied" was written twice: once in
-- the `copy_file` CTE (Rust, `file_blob_write_repository.rs`) and once in
-- `storage.copy_folder_tree`. They had already drifted — the tree path
-- bumped `storage.blobs` only, missing manifests entirely, which was silent
-- data loss on a multi-chunk file (fixed in `20261016000000`, and the fix
-- had to be written a second time rather than in one place).
--
-- The plan adds file-keyed satellite tables (`file_attached_blobs`, step 9).
-- Adding them against two copy sites means writing the same cascade a third
-- and fourth time, into sites that have already proven they drift. So the
-- fan-out gets exactly one home first.
--
-- Two functions land here:
--
-- * `storage.add_blob_references(TEXT[])` — the manifest-first reference
-- contract, expressed once for SQL callers. `DedupService::add_reference`
-- is the Rust twin; they must change together, which is why the shared
-- contract is spelled out in both doc comments.
--
-- * `storage.copy_file_satellites(UUID[], UUID[])` — everything that
-- follows a file on copy. The body IS the copy-semantics declaration:
-- what is absent is a documented decision (see the trailing comments),
-- not an omission someone has to notice.
--
-- Set-based rather than per-row on purpose. A per-row helper would have made
-- a 10k-file folder copy 10k function calls; taking arrays keeps the tree
-- path's single-statement cost while still having one implementation. The
-- single-file path passes one-element arrays.
-- ── The reference contract, for SQL callers ──────────────────────────────
--
-- Increment the reference count for each hash in `p_hashes`, counting
-- repeats (pass the hash once per referencing row). Returns the hashes that
-- matched NEITHER table, so callers can decide how loud to be — a copy
-- inherits a pre-existing breakage and should warn, whereas an ingest
-- referencing a nonexistent blob is a hard error.
--
-- MANIFEST FIRST, `storage.blobs` only as fallback. The order is the whole
-- point: a CDC file's `blob_hash` names a manifest
-- (`chunk_manifests.file_hash`), not a chunk, so bumping `storage.blobs`
-- first would match nothing for a multi-chunk file and take no reference at
-- all.
--
-- The `NOT EXISTS (bumped)` guard on the blobs branch is load-bearing. For a
-- SINGLE-chunk file the whole-file hash EQUALS its lone chunk's hash (both
-- are BLAKE3 over the same bytes), so without the guard one reference would
-- be counted at both levels — turning an under-count into an over-count.
--
-- Mirrors `DedupService::add_reference`, including the asymmetry on
-- `orphaned_at`: only `storage.blobs` carries that column, so only the blobs
-- branch clears it. A chunk resurrected inside its GC grace window must lose
-- its orphan stamp or `dedup_gc` reaps live content.
CREATE OR REPLACE FUNCTION storage.add_blob_references(p_hashes TEXT[])
RETURNS TEXT[] AS $$
DECLARE
v_unmatched TEXT[];
BEGIN
IF p_hashes IS NULL OR cardinality(p_hashes) = 0 THEN
RETURN ARRAY[]::TEXT[];
END IF;
WITH hc AS (
SELECT h AS blob_hash, COUNT(*)::int AS cnt
FROM unnest(p_hashes) AS h
WHERE h IS NOT NULL
GROUP BY h
),
bumped_manifests AS (
UPDATE storage.chunk_manifests m
SET ref_count = m.ref_count + hc.cnt
FROM hc
WHERE m.file_hash = hc.blob_hash
RETURNING m.file_hash
),
bumped_blobs AS (
UPDATE storage.blobs b
SET ref_count = b.ref_count + hc.cnt,
orphaned_at = NULL
FROM hc
WHERE b.hash = hc.blob_hash
AND NOT EXISTS (
SELECT 1 FROM bumped_manifests WHERE file_hash = hc.blob_hash
)
RETURNING b.hash
)
SELECT COALESCE(array_agg(hc.blob_hash), ARRAY[]::TEXT[])
INTO v_unmatched
FROM hc
WHERE NOT EXISTS (SELECT 1 FROM bumped_manifests WHERE file_hash = hc.blob_hash)
AND NOT EXISTS (SELECT 1 FROM bumped_blobs WHERE hash = hc.blob_hash);
RETURN v_unmatched;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION storage.add_blob_references(TEXT[]) IS
'Manifest-first blob reference increment for SQL callers. Returns hashes '
'that matched no registry row. Rust twin: DedupService::add_reference — '
'change both together.';
-- ── What follows a file on copy ──────────────────────────────────────────
--
-- `p_old_ids[i]` is copied to `p_new_ids[i]`; the new `storage.files` rows
-- must already be inserted and visible (both callers insert in an earlier
-- statement of the same transaction).
--
-- Every satellite of a copied file belongs in this body. What is NOT here is
-- listed at the bottom, with the reason — the taxonomy is executable rather
-- than living in a document that drifts from the code.
CREATE OR REPLACE FUNCTION storage.copy_file_satellites(
p_old_ids UUID[],
p_new_ids UUID[]
) RETURNS void AS $$
DECLARE
v_unmatched TEXT[];
BEGIN
IF p_old_ids IS NULL OR cardinality(p_old_ids) = 0 THEN
RETURN;
END IF;
IF p_new_ids IS NULL OR cardinality(p_old_ids) <> cardinality(p_new_ids) THEN
-- Positional correspondence is the whole interface; a length
-- mismatch would silently attach satellites to the wrong file.
RAISE EXCEPTION
'copy_file_satellites: id arrays must correspond positionally (% old vs % new)',
cardinality(p_old_ids), COALESCE(cardinality(p_new_ids), 0);
END IF;
-- 1. WebDAV dead properties. RFC 4918 §8.8 requires COPY to duplicate
-- them: properties describe the resource, and the copy is a resource.
INSERT INTO storage.webdav_dead_properties
(file_id, namespace, local_name, value)
SELECT m.new_id, dp.namespace, dp.local_name, dp.value
FROM unnest(p_old_ids, p_new_ids) AS m(old_id, new_id)
JOIN storage.webdav_dead_properties dp ON dp.file_id = m.old_id;
-- 2. A reference on the copied content, so deleting the original cannot
-- reap bytes the copy still needs. Read from the NEW rows rather than
-- the old ones: that is what makes an unreferenceable copy impossible
-- to create, since a row that failed to insert contributes nothing.
SELECT storage.add_blob_references(array_agg(f.blob_hash))
INTO v_unmatched
FROM unnest(p_new_ids) AS n(id)
JOIN storage.files f ON f.id = n.id
WHERE NOT f.is_trashed;
IF v_unmatched IS NOT NULL AND cardinality(v_unmatched) > 0 THEN
-- Warn, do not abort. A missing registry row means the SOURCE file
-- was already broken; the copy merely inherits it. Failing here
-- would abort an entire folder copy over one pre-existing fault,
-- which is worse than completing it and reporting. The blob-level
-- audit jobs are what surface the underlying breakage.
RAISE WARNING
'copy_file_satellites: % copied file(s) reference a blob with no registry row (first: %); source was already broken',
cardinality(v_unmatched), v_unmatched[1];
END IF;
-- ── Deliberately absent ──────────────────────────────────────────────
--
-- storage.comments (future): NOT copied. A copy is a new artifact; the
-- discussion belongs to the original.
--
-- storage.file_attached_blobs (step 9): WILL be copied here, with a
-- reference taken per attached blob_hash via add_blob_references.
--
-- content_derived_blobs, blob_extracted_text, faces.faces: content-keyed.
-- The copy shares the source's hash, so it already sees them — copying
-- would duplicate rows that are keyed on the very thing being shared.
--
-- storage.favorites, recent_items, shares: properties of the ORIGINAL's
-- relationship to users, not of its content.
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION storage.copy_file_satellites(UUID[], UUID[]) IS
'Single source of truth for what follows a file on copy. Both copy paths '
'(single-file and copy_folder_tree) call it. Adding a file-keyed satellite '
'table means editing this function, and only this function.';
-- ── Route copy_folder_tree through it ────────────────────────────────────
--
-- Only two blocks change versus `20261016000000`: the inline reference bump
-- and the per-file dead-property INSERT are both replaced by one
-- `copy_file_satellites` call. The folder dead-property INSERT stays inline
-- — folders are not files and have no satellite fan-out to share.
CREATE OR REPLACE FUNCTION storage.copy_folder_tree(
p_source_id UUID,
p_target_parent_id UUID, -- NULL = copy to root (keeps source drive)
p_dest_name TEXT DEFAULT NULL -- NULL = keep source folder name
) RETURNS TABLE(new_root_id TEXT, folders_copied BIGINT, files_copied BIGINT) AS $$
DECLARE
v_root_lpath ltree;
v_root_depth INT;
v_max_depth INT;
v_level INT;
v_folders BIGINT := 0;
v_files BIGINT := 0;
v_inserted BIGINT;
v_new_root UUID;
v_dest_drive_id UUID;
BEGIN
-- Validate source exists.
SELECT fo.lpath, nlevel(fo.lpath)
INTO v_root_lpath, v_root_depth
FROM storage.folders fo
WHERE fo.id = p_source_id AND NOT fo.is_trashed;
IF v_root_lpath IS NULL THEN
RAISE EXCEPTION 'Source folder not found: %', p_source_id
USING ERRCODE = 'P0002'; -- no_data_found
END IF;
-- Resolve destination drive_id once up front (cross-drive copy path).
IF p_target_parent_id IS NULL THEN
SELECT fo.drive_id INTO v_dest_drive_id
FROM storage.folders fo
WHERE fo.id = p_source_id;
ELSE
SELECT fo.drive_id INTO v_dest_drive_id
FROM storage.folders fo
WHERE fo.id = p_target_parent_id AND NOT fo.is_trashed;
IF v_dest_drive_id IS NULL THEN
RAISE EXCEPTION 'Target parent folder not found: %', p_target_parent_id
USING ERRCODE = 'P0002';
END IF;
END IF;
-- Temp mapping: every folder in the subtree → new UUID.
CREATE TEMP TABLE IF NOT EXISTS _copy_map(
old_id UUID PRIMARY KEY,
new_id UUID NOT NULL DEFAULT gen_random_uuid()
) ON COMMIT DROP;
TRUNCATE _copy_map;
INSERT INTO _copy_map(old_id)
SELECT fo.id
FROM storage.folders fo
WHERE NOT fo.is_trashed
AND fo.lpath <@ v_root_lpath;
SELECT cm.new_id INTO v_new_root
FROM _copy_map cm WHERE cm.old_id = p_source_id;
SELECT MAX(nlevel(fo.lpath))
INTO v_max_depth
FROM storage.folders fo
JOIN _copy_map cm ON fo.id = cm.old_id;
-- ── Insert folders level by level ──
-- Post-D7: `user_id` intentionally omitted from the column list so
-- copied rows leave the (now-nullable) column NULL. Provenance is
-- carried by `created_by` / `updated_by` (§14 columns) — preserved
-- from source so authorship survives the copy.
FOR v_level IN v_root_depth .. v_max_depth LOOP
INSERT INTO storage.folders(
id, name, parent_id,
drive_id, created_by, updated_by
)
SELECT cm.new_id,
CASE WHEN fo.id = p_source_id AND p_dest_name IS NOT NULL
THEN p_dest_name ELSE fo.name END,
CASE WHEN fo.id = p_source_id THEN p_target_parent_id
ELSE pm.new_id END,
v_dest_drive_id,
fo.created_by,
fo.updated_by
FROM storage.folders fo
JOIN _copy_map cm ON fo.id = cm.old_id
LEFT JOIN _copy_map pm ON fo.parent_id = pm.old_id
WHERE NOT fo.is_trashed
AND nlevel(fo.lpath) = v_level;
GET DIAGNOSTICS v_inserted = ROW_COUNT;
v_folders := v_folders + v_inserted;
END LOOP;
-- Temp mapping for files src→dst (dst ids pre-allocated so we can hand
-- both sides to copy_file_satellites below).
CREATE TEMP TABLE IF NOT EXISTS _copy_file_map(
old_id UUID PRIMARY KEY,
new_id UUID NOT NULL DEFAULT gen_random_uuid()
) ON COMMIT DROP;
TRUNCATE _copy_file_map;
INSERT INTO _copy_file_map(old_id)
SELECT f.id
FROM storage.files f
JOIN _copy_map cm ON f.folder_id = cm.old_id
WHERE NOT f.is_trashed;
-- ── Batch copy all files (zero-copy: same blob_hash) ──
-- Post-D7: `user_id` omitted. Provenance via `created_by`/`updated_by`.
INSERT INTO storage.files(
id, name, folder_id, blob_hash, size, mime_type,
media_sort_date, drive_id, created_by, updated_by
)
SELECT fm.new_id, f.name, cm.new_id, f.blob_hash, f.size,
f.mime_type, f.media_sort_date, v_dest_drive_id, f.created_by,
f.updated_by
FROM storage.files f
JOIN _copy_map cm ON f.folder_id = cm.old_id
JOIN _copy_file_map fm ON fm.old_id = f.id
WHERE NOT f.is_trashed;
GET DIAGNOSTICS v_files = ROW_COUNT;
-- Everything that follows a file on copy — blob references and dead
-- properties — in one call, shared with the single-file copy path.
--
-- Both aggregates order by `old_id`, which is what makes the two arrays
-- correspond positionally; `array_agg` without a matching ORDER BY would
-- be free to pair a file with another file's satellites.
IF v_files > 0 THEN
PERFORM storage.copy_file_satellites(
(SELECT array_agg(old_id ORDER BY old_id) FROM _copy_file_map),
(SELECT array_agg(new_id ORDER BY old_id) FROM _copy_file_map)
);
END IF;
-- Folder dead properties. Files are handled inside copy_file_satellites;
-- folders have no other satellites, so this stays here.
INSERT INTO storage.webdav_dead_properties
(folder_id, namespace, local_name, value)
SELECT cm.new_id, dp.namespace, dp.local_name, dp.value
FROM storage.webdav_dead_properties dp
JOIN _copy_map cm ON dp.folder_id = cm.old_id;
RETURN QUERY SELECT v_new_root::text, v_folders, v_files;
END;
$$ LANGUAGE plpgsql;