From 3f56b5b96a648ba5a25341dd906237861d18d31c Mon Sep 17 00:00:00 2001 From: Christian Weilbach Date: Fri, 24 Jul 2026 18:02:30 -0700 Subject: [PATCH] feat(konserve): back an index with konserve, branch by manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Konserve becomes the source of truth for a scriptum index and the local directory becomes a derived cache that can be deleted at any time. This is proximum's dual-storage model applied to Lucene, and Lucene fits it better than vectors do: segment files are write-once, so a cached file is valid forever and never needs invalidating. The substantive change is where a branch LIVES. The path-based design encodes a branch as `branches//` and enumerates branches with newDirectoryStream, which makes the filesystem the branch registry — the role konserve is supposed to hold. Here a branch is a manifest {lucene-filename -> content-address} in the store, and each segment is a blob keyed by its content hash. Three properties follow, each with a test: - forking copies a manifest; no bytes move - branches sharing a segment share ONE blob, and locally one INODE, because each branch view is hard links into a content-addressed pool — so a shared segment costs disk and page cache once - merging is branch-local: it writes new blobs under new addresses and leaves the old ones for whoever still references them That last property is why BranchAwareMergePolicy existed; the second is what BranchedDirectory's base/overlay composition was for; and reachability from the live manifests replaces BranchDeletionPolicy's ref-counting. All three can go — a follow-up, since removing them is a breaking API change that deserves its own review. Concurrency needs no lock of our own. Lucene's write.lock lives in the per-branch view, which IS scriptum's contract: a second writer on one branch fails loudly, writers on different branches proceed in parallel. Two regressions are pinned because both were live in earlier drafts. A cache keyed only by filename let Lucene see another branch's files and CONTINUE its index — branch B's durable manifest ended up containing branch A's segments. And caching the manifest at construction left openIfChanged permanently blind, which is precisely what a remote reader polling a shared store depends on; listAll re-reads it now, so a reader polls a small pointer and never re-reads immutable segment data. sync is wrapped in konserve's gc-guard: it is exactly a values-then-pointer sequence, and a collection landing between the blobs and the manifest would sweep what the manifest is about to reference. Needs konserve with gc-guard — unreleased, hence the :local alias. gc! blocks on the sweep rather than returning its channel; returning it unconsumed let a caller observe the store before the sweep had run. Collection is eventual: stamps are millisecond-granular and the sweep spares ties, and an explicit cutoff can only ever hold a collection back, never hurry it, because the sweep clamps to min(cutoff, safe-point). --- deps.edn | 15 +- src/clojure/scriptum/konserve.clj | 303 ++++++++++++++++++++++++++++++ test/scriptum/konserve_test.clj | 234 +++++++++++++++++++++++ 3 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 src/clojure/scriptum/konserve.clj create mode 100644 test/scriptum/konserve_test.clj diff --git a/deps.edn b/deps.edn index dca3a6b..29f7eed 100644 --- a/deps.edn +++ b/deps.edn @@ -8,10 +8,21 @@ org.apache.lucene/lucene-queryparser {:mvn/version "10.3.2"} com.fasterxml.jackson.core/jackson-core {:mvn/version "2.17.2"} com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.17.2"} - org.replikativ/yggdrasil {:mvn/version "0.2.14"}} + org.replikativ/yggdrasil {:mvn/version "0.2.14"} + ;; Direct now rather than transitive: konserve is the source of truth for + ;; an index (scriptum.konserve), and hasch supplies the content addresses + ;; that make branches share blobs. + org.replikativ/konserve {:mvn/version "0.9.350"} + org.replikativ/hasch {:mvn/version "0.3.96"}} :aliases - {:build + {;; Sibling checkouts, for co-development across the replikativ stack. + ;; scriptum.konserve needs konserve.gc-guard, which is unreleased — use this + ;; alias until a konserve carrying it is on Maven. + :local + {:override-deps {org.replikativ/konserve {:local/root "../konserve"}}} + + :build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.6"} slipset/deps-deploy {:mvn/version "0.2.0"}} :ns-default build} diff --git a/src/clojure/scriptum/konserve.clj b/src/clojure/scriptum/konserve.clj new file mode 100644 index 0000000..3691914 --- /dev/null +++ b/src/clojure/scriptum/konserve.clj @@ -0,0 +1,303 @@ +(ns scriptum.konserve + "Konserve-backed storage for scriptum indices. + + Konserve is the source of truth; a local directory is a derived cache that + may be deleted at any time. This is proximum's dual-storage model + (`proximum.vectors`) applied to Lucene, and Lucene fits it better than + vectors do: segment files are WRITE-ONCE, so a cached file is valid forever + and never needs invalidating. + + BRANCH IDENTITY LIVES IN A MANIFEST, NOT IN A DIRECTORY TREE. The + path-based design encodes a branch as `branches//` and enumerates + branches with `newDirectoryStream`, which makes the filesystem the branch + registry — the role konserve is supposed to hold. Here a branch is + + [:scriptum :manifest ] -> {lucene-filename -> content-address} + + and each referenced blob is + + [:scriptum :blob
] -> the bytes + + Three consequences, each verified by the tests in this namespace: + + 1. Forking is copying a manifest. No bytes move. + 2. Segments shared between branches are ONE blob, because the address is the + content hash. Locally they are one INODE too (the per-branch view is made + of hard links into a content-addressed pool), so shared segments occupy + memory once when mmap'd. + 3. Merging is branch-local. A merge writes new blobs under new addresses and + leaves the old ones for whoever still references them. + + That third point removes the reason `BranchAwareMergePolicy` existed, the + second removes `BranchedDirectory`'s base/overlay composition, and reachability + from the set of live manifests removes `BranchDeletionPolicy`'s ref-counting. + + CONCURRENCY follows konserve's contract: one writer per branch in one runtime, + readers unconstrained. Per-branch view directories give that for free — Lucene's + own `write.lock` lives in the view, so a second writer on the same branch fails + loudly with LockObtainFailedException while writers on different branches + proceed in parallel." + (:require [clojure.java.io :as io] + [clojure.core.async :refer [path ^java.nio.file.Path [^String s] + (Paths/get s (make-array String 0))) + +(defn manifest-key [branch] [:scriptum :manifest branch]) +(defn blob-key [address] [:scriptum :blob address]) + +(defn read-manifest + "The branch's `{lucene-filename -> address}` map, or `{}` when it has none." + [store branch] + (or (k/get store (manifest-key branch) nil {:sync? true}) {})) + +(defn branches + "Every branch that has a manifest in `store`." + [store] + (into #{} + (comp (map :key) + (filter #(and (vector? %) (= [:scriptum :manifest] (subvec % 0 2)))) + (map #(nth % 2))) + (k/keys store {:sync? true}))) + +;; ============================================================================= +;; Local cache: a content-addressed pool + per-branch hard-link views +;; ============================================================================= + +(defn- pool-file ^java.io.File [cache address] + (io/file cache "pool" (str address))) + +(defn- view-file ^java.io.File [cache branch name] + (io/file cache branch name)) + +(defn- slurp-bytes ^bytes [^java.io.File f] + (let [bs (byte-array (.length f))] + (with-open [in (io/input-stream f)] (.readNBytes in bs 0 (alength bs))) + bs)) + +(defn- spit-bytes! + "Write `bs` to `f` via a temp file + rename, so an interrupted materialization + cannot leave a truncated file that a later run mistakes for a cache hit." + [^java.io.File f ^bytes bs] + (io/make-parents f) + (let [tmp (io/file (.getParentFile f) (str "." (.getName f) ".tmp"))] + (with-open [out (io/output-stream tmp)] (.write out bs)) + (.renameTo tmp f))) + +(defn- ensure-pooled! + "The bytes for `address`, present in the local pool." + ^java.io.File [store cache address] + (let [pf (pool-file cache address)] + (when-not (.exists pf) + (if-let [bs (k/bget store (blob-key address) + (fn [{is :input-stream}] + (when is + (let [bos (java.io.ByteArrayOutputStream.)] + (io/copy is bos) + (.toByteArray bos)))) + {:sync? true})] + (spit-bytes! pf bs) + (throw (ex-info "scriptum: blob referenced by a manifest is missing from the store" + {:address address :cache cache})))) + pf)) + +(defn- link-into-view! + "Hard-link the pooled blob into `branch`'s view under its Lucene name. + + A hard link rather than a copy: branches that share a segment then share one + inode, so the bytes sit on disk once and mmap'd pages are shared between + branches instead of duplicated." + [store cache branch name address] + (let [pf (ensure-pooled! store cache address) + vf (view-file cache branch name)] + (when-not (.exists vf) + (io/make-parents vf) + (Files/createLink (->path (.getPath vf)) (->path (.getPath pf)))) + vf)) + +(defn- pool! + "Fold a freshly written view file into the pool under `address`, so a later + fork of this branch shares its inode instead of re-materializing." + [cache branch name address] + (let [pf (pool-file cache address)] + (when-not (.exists pf) + (io/make-parents pf) + (Files/createLink (->path (.getPath pf)) + (->path (.getPath (view-file cache branch name))))))) + +;; ============================================================================= +;; The Directory +;; ============================================================================= + +(defn konserve-directory + "A Lucene `Directory` for `branch`, durable in `store`, read through an + mmap'd local cache under `cache`. + + `store-id` identifies the PHYSICAL konserve store for `konserve.gc-guard`; + every writer sharing that store must use the same value, or a collection will + not see this index's in-flight writes. Omitting it disables the guard, which + is only safe on a store that is never collected." + (^Directory [store cache branch] (konserve-directory store cache branch nil)) + (^Directory [store ^String cache ^String branch store-id] + (.mkdirs (io/file cache branch)) + (let [live (MMapDirectory/open (->path (str cache "/" branch))) + manifest (atom (read-manifest store branch)) + ;; Files created through this Directory but not yet synced. Tracked + ;; explicitly because the local cache is NOT authoritative — it can + ;; hold files from another branch sharing the pool, and Lucene must + ;; never see those. The manifest defines what the index contains. + session (atom #{})] + (doseq [[n address] @manifest] (link-into-view! store cache branch n address)) + (proxy [Directory] [] + (listAll [] + ;; Re-read rather than serving the cached manifest: this is what + ;; DirectoryReader.openIfChanged consults, so a stale manifest leaves a + ;; long-lived reader permanently blind to new commits. Cheap and right + ;; for remote stores too — the manifest is a small mutable pointer, so + ;; a reader polls the pointer and never re-reads immutable segment data. + (let [m (read-manifest store branch)] + (reset! manifest m) + (into-array String (sort (into (set (keys m)) @session))))) + + (fileLength [name] + (when-let [a (get @manifest name)] (link-into-view! store cache branch name a)) + (.fileLength live name)) + + (createOutput [name context] + ;; A name this branch writes must not resolve to a stale local file + ;; left behind by another branch sharing the cache root. + (when (.exists (view-file cache branch name)) (.deleteFile live name)) + (swap! session conj name) + (.createOutput live name context)) + + (createTempOutput [prefix suffix context] + (let [out (.createTempOutput live prefix suffix context)] + (swap! session conj (.getName out)) + out)) + + (sync [names] + ;; The durability hook: Lucene syncs before it commits, so this is where + ;; write-once files become durable and shareable. + ;; + ;; Guarded, because it is precisely a values-then-pointer sequence — the + ;; blobs go in first and only the manifest write makes them reachable. + ;; A collection landing in between would sweep blobs the manifest is + ;; about to reference. See konserve.gc-guard. + (.sync live names) + (let [write! (fn [] + (let [m (reduce (fn [m ^String n] + (if (contains? m n) + m + (let [bs (slurp-bytes (view-file cache branch n)) + address (hasch/uuid bs)] + (k/bassoc store (blob-key address) bs {:sync? true}) + (pool! cache branch n address) + (assoc m n address)))) + @manifest names)] + (k/assoc store (manifest-key branch) m {:sync? true}) + (reset! manifest m)))] + (if store-id + (guard/with-unreferenced-writes store-id (write!)) + (write!)))) + + (syncMetaData [] nil) + + (rename [source dest] + (.rename live source dest) + (when-let [a (get @manifest source)] + (let [m (-> @manifest (dissoc source) (assoc dest a))] + (k/assoc store (manifest-key branch) m {:sync? true}) + (reset! manifest m)))) + + (deleteFile [name] + (when (.exists (view-file cache branch name)) (.deleteFile live name)) + (swap! session disj name) + ;; Drop the reference only. The blob stays until a GC finds it + ;; unreachable from EVERY manifest, so a branch or a reader still + ;; holding an older manifest keeps working. + (when (contains? @manifest name) + (let [m (dissoc @manifest name)] + (k/assoc store (manifest-key branch) m {:sync? true}) + (reset! manifest m)))) + + (openInput [name context] + (when-let [a (get @manifest name)] (link-into-view! store cache branch name a)) + (.openInput live name context)) + + ;; Lucene's own lock, in the per-branch view: one writer per branch, and + ;; writers on different branches do not see each other. Exactly scriptum's + ;; contract, with no lock of our own. + (obtainLock [name] (.obtainLock live name)) + (close [] (.close live)) + (getPendingDeletions [] (.getPendingDeletions live)))))) + +;; ============================================================================= +;; Branch operations +;; ============================================================================= + +(defn fork! + "Branch `from` as `to`: copy the manifest. O(1) — no segment bytes move, and + the two branches share every blob they have in common." + [store from to] + (when (contains? (branches store) to) + (throw (ex-info "scriptum: branch already exists" {:branch to}))) + (let [m (read-manifest store from)] + (k/assoc store (manifest-key to) m {:sync? true}) + m)) + +(defn delete-branch! + "Forget `branch`. Blobs it referenced survive until `gc!` finds them + unreachable from every remaining manifest." + [store branch] + (k/dissoc store (manifest-key branch) {:sync? true}) + nil) + +(defn reachable-addresses + "Every blob address referenced by any branch — the GC root set." + [store] + (into #{} (mapcat #(vals (read-manifest store %))) (branches store))) + +(defn gc! + "Collect blobs no branch references any more. + + Reachability from the live manifests IS the root set, which is why no + ref-counting deletion policy is needed. `store-id` must be the one writers + pass to `konserve-directory`, so the sweep can see their in-flight writes. + + Collection is EVENTUAL, not immediate. Write stamps have millisecond + granularity and the sweep spares ties, so a blob written in the same + millisecond as the call survives to the next cycle. + + `cutoff` defaults to now and can only make a collection MORE conservative: + the sweep takes `min(cutoff, safe-point)`, and the safe point never runs + ahead of now, so passing a later instant cannot force an earlier collection. + Pass one to hold back a collection (\"nothing newer than X\"), not to hurry it. + + Blocking, returning the set of collected keys — see below. + + NOTE for shared stores: this collects blobs that no CURRENT manifest names. + A reader on another machine pinned to an older manifest can still be holding + one. Readers on a shared store therefore need a root of their own before this + is safe to run there. + + `konserve.gc/sweep!` is async-only, so the channel has to be taken from here: + returning it unconsumed would let a caller observe the store before the sweep + had run." + ([store store-id] (gc! store store-id (ku/now))) + ([store store-id cutoff] + (let [keep (into #{} (map blob-key) (reachable-addresses store)) + manifests (into #{} (map manifest-key) (branches store)) + result ( r1 .close)))) + (finally (.close r0)))))))) + +(deftest one-writer-per-branch-many-branches-in-parallel + (testing "scriptum's concurrency contract, from the per-branch view directory: + a second writer on a branch fails LOUDLY, while another branch's + writer proceeds" + (let [s (store)] + (with-open [d (sk/konserve-directory s (cache) "main")] + (add-doc! d "seed")) + (sk/fork! s "main" "feature") + (with-open [m1 (sk/konserve-directory s (cache) "main") + m2 (sk/konserve-directory s (cache) "main") + f (sk/konserve-directory s (cache) "feature")] + (with-open [_w1 (IndexWriter. m1 (IndexWriterConfig. (StandardAnalyzer.)))] + (is (thrown? LockObtainFailedException + (IndexWriter. m2 (IndexWriterConfig. (StandardAnalyzer.)))) + "a second writer on the SAME branch must fail loudly") + (with-open [_wf (IndexWriter. f (IndexWriterConfig. (StandardAnalyzer.)))] + (is true "a writer on a DIFFERENT branch proceeds in parallel"))))))) + +(deftest gc-collects-only-what-no-branch-references + (testing "reachability from the live manifests is the root set — which is why + no ref-counting deletion policy is needed" + (let [s (store) + sid (random-uuid)] + (with-open [d (sk/konserve-directory s (cache) "main" sid)] + (add-doc! d "keep me")) + (sk/fork! s "main" "doomed") + (with-open [d (sk/konserve-directory s (cache) "doomed" sid)] + (add-doc! d "only in doomed")) + (let [doomed-only (remove (set (vals (sk/read-manifest s "main"))) + (vals (sk/read-manifest s "doomed")))] + (is (seq doomed-only)) + (sk/delete-branch! s "doomed") + (let-the-millisecond-turn-over!) + (sk/gc! s sid) + (is (= #{"main"} (sk/branches s))) + (doseq [a doomed-only] + ;; k/exists?, not k/get: reading a binary value as EDN yields a + ;; misread byte rather than nil, so k/get cannot answer presence. + (is (not (k/exists? s (sk/blob-key a) {:sync? true})) + "a blob no branch references must be collected")) + (with-open [d (sk/konserve-directory s (cache) "main")] + (is (= #{"keep me"} (bodies d)) + "and main must be entirely intact")))))) + +(deftest gc-spares-blobs-whose-manifest-has-not-landed + (testing "the values-then-pointer race: a sweep running while a sync is in + flight must not collect the blobs that sync is about to reference" + (let [s (store) + sid (random-uuid)] + (with-open [d (sk/konserve-directory s (cache) "main" sid)] + (add-doc! d "seed")) + ;; Stand in for a sync in progress: blobs written, manifest not yet updated. + (let [orphan (random-uuid)] + (guard/with-unreferenced-writes sid + (k/bassoc s (sk/blob-key orphan) (byte-array [1 2 3]) {:sync? true}) + (let-the-millisecond-turn-over!) + (sk/gc! s sid) + (is (k/exists? s (sk/blob-key orphan) {:sync? true}) + "an unreferenced-but-in-flight blob must survive the sweep")) + ;; Sequence closed and it never became reachable, so now it is collectable. + (let-the-millisecond-turn-over!) + (sk/gc! s sid) + (is (not (k/exists? s (sk/blob-key orphan) {:sync? true})) + "once the sequence closes, a genuinely unreachable blob is collected")))))