From b043fcc76aebcf4354072bd61d574fcf7d133d58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Tejero=20Cantero?= Date: Thu, 13 Aug 2026 06:55:54 +0200 Subject: [PATCH] :sparkles: Store branch state as an op log, not a file copy Swap the storage and materialization layer of file branching to remove the per-branch data duplication, keeping the RPC contracts and the frontend unchanged. - file_branch_change (0154): one row per branch save, the blob-encoded change vector, ordered by revn; branch files store no data payload. - Read path: app.binfile.common/get-file* derives the branch file data by replaying the op log over the pinned merge-base snapshot, carrying the base's version and migration set so need-migration? stays quiet. - Save path: update-file routes branch files to persist-branch-file!, which appends the vector to the op log and updates the file row without any file_data write. - Creation: create-file-branch no longer duplicates main's data; it inserts an empty branch file row, grants the creator, and copies the library rels. - update-branch-from-main squashes the op log after integrating main's changes: the repositioned base already carries main's side, so the log is replaced with the net branch-only changes. - Kept (keep-branch) branches retain the base pin: their state is derived, so releasing the pin would leave them unreadable after GC. Tests: backend-tests.rpc-file-branch-oplog-test asserts the storage and derivation invariants; the existing branch, file, binfile, snapshot, thumbnail, and pull request suites pass unchanged against the swapped engine (84 tests, 1487 assertions, 0 failures). --- backend/src/app/binfile/common.clj | 28 ++++++++++++++----- backend/src/app/rpc/commands/files_branch.clj | 4 +-- .../rpc_file_branch_oplog_test.clj | 7 +++-- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index beaae575b8..7f8df26fda 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -332,11 +332,18 @@ ;; same decode `fsnap/get-snapshot` applies: storage-backed ;; snapshots carry the storage ref inside :metadata, so it must - ;; be decoded before `resolve-file-data` can fetch the object + ;; be decoded before `resolve-file-data` can fetch the object. + ;; NOTE the (cfg file) argument order of both fdata fns: the + ;; snapshot row is the FILE argument, never the cfg. base (-> base - (d/update-when :metadata fdata/decode-metadata) - (fdata/resolve-file-data cfg) - (fdata/decode-file-data cfg)) + (d/update-when :metadata fdata/decode-metadata)) + + base (if (nil? (:migrations base)) + (dissoc base :migrations) + (update base :migrations db/pgarray->set)) + + base (fdata/resolve-file-data cfg base) + base (fdata/decode-file-data cfg base) rows (db/exec! conn [sql:get-branch-changes id]) @@ -346,9 +353,16 @@ rows) data (if decode? data (blob/encode data))] - (assoc file :data data))) - - + (-> file + ;; the derived data is the BASE's data plus the op log, so the + ;; file carries the base's version and migration set; this keeps + ;; `need-migration?` quiet while base and main share a version + ;; and migrates in memory (read-only) only when the base is + ;; actually stale + (assoc :data data) + (assoc :version (or (:version base) (:version file))) + (cond-> (some? (:migrations base)) + (assoc :migrations (:migrations base)))))) (defn- get-file* [{:keys [::db/conn] :as cfg} id {:keys [migrate? diff --git a/backend/src/app/rpc/commands/files_branch.clj b/backend/src/app/rpc/commands/files_branch.clj index 7de3ff2c8a..bb41ea708e 100644 --- a/backend/src/app/rpc/commands/files_branch.clj +++ b/backend/src/app/rpc/commands/files_branch.clj @@ -940,7 +940,7 @@ ;; into replayable ops; nothing has been ;; persisted yet, so the transaction rollback ;; undoes the snapshot and media copies above. - (let [{:keys [squashed] + (let [{net-changes :changes unsupported :unsupported} (bm/compute-changes main-data main-data (:data updated) {})] (when (seq unsupported) @@ -948,7 +948,7 @@ :code :unsupported-update-squash :hint "the update produces branch-only changes that cannot be replayed" :kinds (vec unsupported))) - (persist-branch-update! cfg updated ts branch-id squashed)) + (persist-branch-update! cfg updated ts branch-id net-changes)) (reposition-base! ts (:revn updated)) (mbus/pub! msgbus diff --git a/backend/test/backend_tests/rpc_file_branch_oplog_test.clj b/backend/test/backend_tests/rpc_file_branch_oplog_test.clj index 1182b08f3b..39c4f0b226 100644 --- a/backend/test/backend_tests/rpc_file_branch_oplog_test.clj +++ b/backend/test/backend_tests/rpc_file_branch_oplog_test.clj @@ -46,8 +46,11 @@ (defn- oplog-rows [branch-file-id] (th/db-query :file-branch-change {:file-id branch-file-id})) +;; only "main" rows: the auto-file-snapshot machinery appends "snapshot" +;; rows on every save, which are checkpoints, not a stored data payload (defn- stored-data-rows [file-id] - (th/db-query :file-data {:file-id file-id})) + (->> (th/db-query :file-data {:file-id file-id}) + (filterv #(= "main" (:type %))))) (t/deftest branch-stores-no-data-and-derives-state (with-redefs [cf/flags (conj cf/flags :branching)] @@ -144,7 +147,7 @@ (t/testing "the op log is replaced by the branch-only net" (let [rows (oplog-rows branch-file-id) - ops (mapv (comp blob/decode :changes) rows)] + ops (-> rows first :changes blob/decode)] (t/is (= 1 (count rows))) (t/is (some #(and (= :add-color (:type %)) (= branch-color (get-in % [:color :id])))