✨ 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).
This commit is contained in:
Álvaro Tejero Cantero 2026-08-13 06:55:54 +02:00
parent bc22388776
commit b043fcc76a
No known key found for this signature in database
3 changed files with 28 additions and 11 deletions

View File

@ -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?

View File

@ -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

View File

@ -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])))