Skip to content

Commit 9afaa5b

Browse files
committed
feat(cnf): snapshot image + tail-fold reload + incremental boot (thread 019f100f-7fff)
Head-materialization made incremental: the mislabeled "single-writer" bottleneck was O(history) head re-fold on boot AND a whole-log re-migrate on EVERY out-of-band append. Now: - write-snapshot!: thin wrapper over dump-log! — writes a v2 image the existing replay consumes, plus @snapshot:<seq> claims (covers_through/byte_offset/ claim_count/image_path/snapshot_hash/of_view @view:main) and an O(1) boot sidecar. - read-log-tail: UTF-8 byte-seek + :tx filter (anchored on :tx, byte_offset a hint). - apply-tail!: per-key keyed-latest delta apply (single supersedes the cell; multi adds the edge iff absent; retract supersedes). Correct because fram.fold is keyed-latest-by-:tx, so a tail key's max-:tx line dominates every snapshot-era line for that key — per-key tail fold over the untouched snapshot == a full fold. - boot-flat!: nearest snapshot image + tail-apply (cold whole-migrate fallback on no/torn image). maybe-reload!: tail-fold the new lines onto an O(1) structural- share clone, then atomic swap — O(delta), atomicity preserved for lock-free readers. - snapshot-reconcile gate: live store == from-scratch whole migrate (name-triples). - :snapshot / :snapshot-reconcile / :built-through ops. The writer stays single + serialized (id allocation is its one irreducible job); no multi-master path. tests/cnf_snapshot_test 14/14 incl. a compaction proof (drop all flat lines <= covers_through; boot still reconstructs the head from the image).
1 parent c6bd4f0 commit 9afaa5b

2 files changed

Lines changed: 302 additions & 12 deletions

File tree

cnf_coord_daemon.clj

Lines changed: 219 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,6 +1133,9 @@
11331133
:version (current-seq @co)}))))))
11341134

11351135
(declare maybe-reload!)
1136+
;; thread 019f100f-7fff: snapshot/tail-fold/as-of/incremental-aggregate surface,
1137+
;; defined below migrate-flat->co (so they can call it) but referenced in handle.
1138+
(declare write-snapshot! snapshot-reconcile materialize-as-of register-agg! agg-report sweep-snapshots! built-through)
11361139

11371140
(defn handle [req]
11381141
;; (#14 socket EXPOSURE) :edit-min runs OUTSIDE the outer dlock. do-edit-min's compute
@@ -1195,6 +1198,12 @@
11951198
:inc-triples (count (:triples (:idx inc))) :fresh-triples (count (:triples fidx))
11961199
:version (current-seq @co)})
11971200
:status {:version (current-seq @co) :claims (count (c/current-claims (:store @co))) :log (or @flat-log (:log @co))}
1201+
;; thread 019f100f-7fff — snapshot/compaction surface:
1202+
;; :snapshot writes a checkpoint (dump-log! image + @snapshot:<seq> claims);
1203+
;; :snapshot-reconcile is the gate (live store == from-scratch whole migrate).
1204+
:snapshot (if @flat-log (write-snapshot! @co @flat-log) {:error "snapshot needs flat-log (drop-in) mode"})
1205+
:snapshot-reconcile (snapshot-reconcile)
1206+
:built-through {:built-through @built-through :version (current-seq @co)}
11981207
;; warm scope-correct callers of a binding, served from refers_to materialized
11991208
;; over `co` (version-cached). ensure-refers! whole-corpus re-resolves only when
12001209
;; the code version moved (the correct first cut); the reverse lookup is then a
@@ -1496,35 +1505,233 @@
14961505
;; daemon's append-flat!; the reified store must NOT dump v2 :k-records into it.
14971506
{:store st :log nil :lock (Object.)}))
14981507

1508+
;; ===========================================================================
1509+
;; SNAPSHOT / TAIL-FOLD / AS-OF / INCREMENTAL AGGREGATES (thread 019f100f-7fff)
1510+
;; ---------------------------------------------------------------------------
1511+
;; The flat fold (fram.fold) is KEYED-LATEST-BY-:tx: a single-valued predicate keys
1512+
;; on (l,p) [an LWW cell]; a multi on (l,p,r) [the edge]; per key the MAX-:tx line
1513+
;; wins and a `retract` whose :tx dominates drops the key. So for ANY key first
1514+
;; touched in a tail T past a snapshot at seq N, every tail line has :tx > N >= every
1515+
;; snapshot-era line for that key — the tail's max-:tx line for the key dominates the
1516+
;; whole history. THEREFORE: per-key keyed-latest over T, applied onto the snapshot
1517+
;; store (whose keys T is silent on are left untouched), reconstructs EXACTLY a full
1518+
;; fold of the whole log. That is the whole correctness argument for incremental boot
1519+
;; + tail-fold reload + as-of; it is GATED at runtime by snapshot-reconcile (diff the
1520+
;; incremental store's live name-triples against a from-scratch whole-migrate).
1521+
;; This is the fix for the mislabeled "single-writer" bottleneck: the writer's one
1522+
;; job (id allocation) STAYS single + serialized; what was O(history) — boot re-fold
1523+
;; and the whole-log re-migrate on EVERY out-of-band append — becomes O(delta).
1524+
;; ===========================================================================
1525+
1526+
;; the highest flat :tx the live store reflects, and the flat-log byte length at that
1527+
;; point. flat-bytes is a SEEK HINT for the tail read; correctness is the :tx filter
1528+
;; (risk guard: "anchor boot on :tx (monotonic), not byte_offset").
1529+
(def built-through (atom 0))
1530+
(def flat-bytes (atom 0))
1531+
1532+
(defn- snap-dir [flat] (str flat ".snapshots"))
1533+
(defn- snap-image [flat seq] (str (snap-dir flat) "/snap-" seq ".v2log"))
1534+
(defn- sidecar-path [flat] (str flat ".snap")) ; latest-snapshot pointer (O(1) boot hint)
1535+
1536+
(defn- sha256-file [path]
1537+
(let [md (java.security.MessageDigest/getInstance "SHA-256")
1538+
buf (byte-array 65536)]
1539+
(with-open [is (java.io.FileInputStream. (str path))]
1540+
(loop [] (let [n (.read is buf)] (when (pos? n) (.update md buf 0 n) (recur)))))
1541+
(apply str (map #(format "%02x" %) (.digest md)))))
1542+
1543+
(defn- read-sidecar [flat]
1544+
(let [f (java.io.File. (sidecar-path flat))]
1545+
(when (.exists f)
1546+
(try (edn/read-string (slurp f)) (catch Exception _ nil)))))
1547+
(defn- write-sidecar! [flat m] (spit (sidecar-path flat) (pr-str m)))
1548+
1549+
(defn- skip-fully! [^java.io.InputStream is ^long n]
1550+
(loop [left n] (when (pos? left) (let [s (.skip is left)] (if (pos? s) (recur (- left s)) nil)))))
1551+
1552+
;; flat-log lines (raw maps) with :tx > from-tx, read from byte `from-byte` forward.
1553+
;; UTF-8 decode (claim values carry unicode — so NOT RandomAccessFile.readLine, which
1554+
;; is ISO-8859-1 and corrupts multibyte). from-byte is a seek hint: too-low only costs
1555+
;; extra parsing, a torn first line is dropped, and the :tx filter is the real boundary.
1556+
(defn- read-log-tail [path from-byte from-tx]
1557+
(let [f (java.io.File. (str path))]
1558+
(if-not (and (.exists f) (pos? (.length f)))
1559+
[]
1560+
(let [len (.length f) start (long (max 0 (min (long (or from-byte 0)) len)))]
1561+
(with-open [is (java.io.FileInputStream. f)]
1562+
(skip-fully! is start)
1563+
(let [rdr (java.io.BufferedReader. (java.io.InputStreamReader. is "UTF-8"))]
1564+
(loop [acc (transient [])]
1565+
(let [line (.readLine rdr)]
1566+
(if (nil? line)
1567+
(persistent! acc)
1568+
(let [m (try (let [x (edn/read-string line)]
1569+
(when (and (:l x) (:p x) (:r x) (int? (:tx x))
1570+
(> (long (:tx x)) (long from-tx))) x))
1571+
(catch Exception _ nil))]
1572+
(recur (if m (conj! acc m) acc))))))))))))
1573+
1574+
(defn- ref-str?* [x] (and (string? x) (ref-shape? x)))
1575+
1576+
;; keyed-latest over flat lines, mirroring fram.fold/key-of (single -> (l,p); multi ->
1577+
;; (l,p,r)); the latest by :tx wins and its :op is carried so a dominating retract
1578+
;; removes the key. Re-derived here because fram.fold/keyed-latest is private AND drops
1579+
;; retracts (we need them to supersede a snapshot-era claim).
1580+
(defn- tail-keyed-latest [lines]
1581+
(reduce (fn [m a]
1582+
(let [k (if (ck/single? (:p a)) [(:l a) (:p a)] [(:l a) (:p a) (:r a)])
1583+
prev (get m k)]
1584+
(if (and prev (>= (long (:tx prev)) (long (:tx a)))) m (assoc m k a))))
1585+
{} lines))
1586+
1587+
;; apply a flat-log TAIL (lines past from-tx) onto co's store as per-key group-
1588+
;; reconciled deltas — the O(delta) materialization step shared by boot / reload /
1589+
;; as-of. New predicates are declared (existing cardinality untouched). Then per
1590+
;; keyed-latest key: assert/link (single supersedes the cell via s/assert!/s/link!'s
1591+
;; replace!; multi adds the edge IFF not already live — idempotent), or retract
1592+
;; (mark the matching live claim cnf-superseded). One migrate-style tx; the seq space
1593+
;; is advanced to the tail's max :tx so :version stays == the flat fold version.
1594+
;; Mutates (:store co) in place — callers that need atomicity vs lock-free readers
1595+
;; clone the store first (see maybe-reload!). Returns co.
1596+
(defn- apply-tail! [co lines]
1597+
(let [st (:store co)
1598+
valid (filterv #(and (:l %) (:p %) (:r %) (int? (:tx %)) (not (schema-preds (:p %)))) lines)]
1599+
(when (seq valid)
1600+
(let [tx (c/begin-tx! st "tail")
1601+
by-pred (group-by :p valid)
1602+
memo (atom {})
1603+
sub! (fn [sid] (or (get @memo sid)
1604+
(let [id (or (s/resolve-name st sid)
1605+
(let [e (c/entity! st)] (s/name! st e sid tx) e))]
1606+
(swap! memo assoc sid id) id)))]
1607+
;; declare predicates new to the store; keep any existing cardinality claim
1608+
(doseq [p (keys by-pred)]
1609+
(when (empty? (c/by-lp st (c/value! st p) (c/value-id st "cardinality")))
1610+
(s/def-predicate! st p (if (ck/single? p) "single" "multi")
1611+
(if (some ref-str?* (map :r (get by-pred p))) "ref" "literal") tx)))
1612+
(doseq [[_ a] (tail-keyed-latest valid)]
1613+
(let [p (:p a) r (:r a) single? (ck/single? p)
1614+
su (sub! (:l a)) pid (c/value! st p)
1615+
live (c/by-lp st su pid)] ; already live-only
1616+
(if (= "retract" (:op a))
1617+
(let [sup (c/value! st "cnf-supersedes")
1618+
rid (when (ref-str?* r) (s/resolve-name st r))
1619+
victims (if single? live
1620+
(filter #(let [cr (:r (c/claim-of st %))]
1621+
(if (ref-str?* r) (= rid cr) (= (c/value-id st r) cr))) live))]
1622+
(doseq [old victims] (c/claim! st old sup old tx)))
1623+
(let [exists? (some #(let [cr (:r (c/claim-of st %))]
1624+
(if (ref-str?* r) (= (s/resolve-name st r) cr) (= (c/value-id st r) cr))) live)]
1625+
(when-not (and (not single?) exists?)
1626+
(if (ref-str?* r) (s/link! st su p (sub! r) tx) (s/assert! st su p r tx)))))))
1627+
(let [tmax (reduce max 0 (map :tx valid))]
1628+
(swap! st assoc :next-seq tmax)
1629+
(swap! st update :txs assoc tx {:seq tmax :agent "tail"}))))
1630+
co))
1631+
1632+
;; live name-triples (store-independent: names + literals, not entity ids) — the
1633+
;; substrate for the reconcile gate, which compares an incrementally-built store to a
1634+
;; from-scratch whole-migrate of the same flat log (they MUST be set-equal).
1635+
(defn- live-name-triples [co] (set (reified->claims co)))
1636+
(defn snapshot-reconcile
1637+
"Gate: does the live (incrementally-materialized) store equal a from-scratch whole
1638+
migrate of the flat log? {:ok bool :inc n :fresh n}. Hot-path-free (test/admin)."
1639+
([] (snapshot-reconcile @co @flat-log))
1640+
([co flat]
1641+
(let [fresh (migrate-flat->co flat)]
1642+
{:ok (= (live-name-triples co) (live-name-triples fresh))
1643+
:inc (count (reified->claims co)) :fresh (count (reified->claims fresh))})))
1644+
1645+
;; replay the nearest snapshot image, then tail-apply the flat lines past it — the
1646+
;; incremental boot. nil if no usable snapshot (caller falls back to whole migrate).
1647+
(defn- incremental-boot [snap flat]
1648+
(let [img (java.io.File. (str (:image snap)))]
1649+
(when (and (.exists img) (pos? (.length img))
1650+
;; hash gate: a torn/edited image is rejected (fall back to whole migrate)
1651+
(or (nil? (:hash snap)) (= (:hash snap) (try (sha256-file (:image snap)) (catch Exception _ nil)))))
1652+
(let [base {:store (replay (:image snap)) :log nil :lock (Object.)}
1653+
tail (read-log-tail flat (:byte_offset snap) (:seq snap))]
1654+
(apply-tail! base tail)
1655+
{:co base :through (reduce max (long (:seq snap)) (map :tx tail))}))))
1656+
14991657
(defn boot-flat! [flat]
15001658
(reset! flat-canonical? true)
1501-
(reset! co (migrate-flat->co flat))
1659+
(let [snap (read-sidecar flat)
1660+
ib (when snap (try (incremental-boot snap flat) (catch Throwable _ nil)))]
1661+
(if ib
1662+
(do (reset! co (:co ib)) (reset! built-through (:through ib)))
1663+
;; cold path: no snapshot / torn image -> the proven whole-log migrate
1664+
(let [c0 (migrate-flat->co flat)]
1665+
(reset! co c0)
1666+
(reset! built-through (or (:next-seq @(:store c0)) 0)))))
15021667
(seed-name-seq! (:store @co)) ; Build A: seed the serialized name allocator above the global max
15031668
(reset! flat-log flat)
15041669
(reset! flat-mtime (stamp flat))
1670+
(reset! flat-bytes (.length (java.io.File. (str flat))))
15051671
(reset! cache {:index nil :version -1})
15061672
(reset-refers-state!) ; S3.3: derived refers_to belong to the OLD store
15071673
(index!) @co)
15081674

1509-
;; absorb external edits (capture/import/set append to the flat log out-of-band).
1510-
;; HOT-PATH cost (finding #4): the per-request work is ONLY a cheap (stamp ...)
1511-
;; (one File.lastModified + File.length stat) compared against the last-seen
1512-
;; stamp. The O(n) migrate-flat->co rebuild runs ONLY when that stamp actually
1513-
;; changed — never on an unchanged log — so a stream of pure reads/writes with no
1514-
;; external append pays a stat, not a rebuild. We stat once and reuse it to set
1515-
;; flat-mtime, so a fresh stat is not taken twice per reload. (Reload stays under
1516-
;; dlock by necessity: swapping `co`/`cache` atomically against concurrent
1517-
;; writers is what keeps the live view and the OCC base versions coherent.)
1675+
;; absorb external edits (capture/import append to the flat log out-of-band). The
1676+
;; per-request cost is a (stamp ...) stat; on a change we now TAIL-FOLD only the new
1677+
;; lines (:tx > built-through, read from the last byte length) onto a STRUCTURAL-SHARE
1678+
;; CLONE of the live store — O(delta), not the old O(history) whole re-migrate. The
1679+
;; clone (atom over the immutable store value) is O(1) and keeps the swap atomic, so a
1680+
;; lock-free reader sees the old OR the new store, never a half-applied tail. If the
1681+
;; mtime moved but no new :tx appeared (a compaction/rewrite that renumbered or shrank
1682+
;; the log), we fall back to the whole migrate — correctness floor.
15181683
(defn maybe-reload! []
15191684
(when (and @flat-canonical? @flat-log)
15201685
(let [st (stamp @flat-log)]
15211686
(when (not= st @flat-mtime)
1522-
(reset! co (migrate-flat->co @flat-log))
1687+
(let [tail (read-log-tail @flat-log @flat-bytes @built-through)]
1688+
(if (seq tail)
1689+
(let [clone {:store (atom @(:store @co)) :log nil :lock (Object.)}]
1690+
(apply-tail! clone tail)
1691+
(reset! co clone)
1692+
(reset! built-through (reduce max (long @built-through) (map :tx tail))))
1693+
;; mtime moved but no new tx -> log was rewritten/compacted: whole migrate
1694+
(let [c0 (migrate-flat->co @flat-log)]
1695+
(reset! co c0)
1696+
(reset! built-through (or (:next-seq @(:store c0)) 0)))))
15231697
(reset! flat-mtime st)
1698+
(reset! flat-bytes (.length (java.io.File. (str @flat-log))))
15241699
(reset! cache {:index nil :version -1})
1525-
(reset-refers-state!) ; S3.3: external rebuild discards in-memory refers_to
1700+
(reset-refers-state!)
15261701
(index!)))))
15271702

1703+
;; ---- snapshot WRITER: a thin wrapper over dump-log! + @snapshot:<seq> claims ------
1704+
;; dump-log! writes the live store as a v2 image the EXISTING replay consumes (reuse,
1705+
;; not new fold code). covers_through = the live seq at dump time; byte_offset = the
1706+
;; flat-log length at dump time (= tail start). The metadata becomes CLAIMS so "latest
1707+
;; snapshot" / "which covers seq N" / "GC candidates" are queries; a tiny sidecar
1708+
;; mirrors the latest pointer for O(1) boot discovery (claims are the source of truth).
1709+
;; Snapshot is view-relative: of_view @view:main. The @snapshot:<seq> claims land in
1710+
;; the tail (tx > covers_through) and are harmlessly re-applied on the next boot.
1711+
(defn write-snapshot! [co flat]
1712+
(locking dlock
1713+
(let [st (:store co)
1714+
sq (current-seq co)
1715+
_ (.mkdirs (java.io.File. (snap-dir flat)))
1716+
image (snap-image flat sq)
1717+
byteoff (.length (java.io.File. (str flat)))
1718+
_ (dump-log! st image)
1719+
h (sha256-file image)
1720+
ccount (count (c/current-claims st))
1721+
subj (str "@snapshot:" sq)]
1722+
(doseq [[p v] [["covers_through" (str sq)] ["byte_offset" (str byteoff)]
1723+
["claim_count" (str ccount)] ["image_path" image]
1724+
["snapshot_hash" h] ["of_view" "@view:main"]]]
1725+
(do-assert subj p v nil))
1726+
(write-sidecar! flat {:seq sq :image image :byte_offset byteoff :claim_count ccount :hash h})
1727+
;; the snapshot's own @snapshot:<seq> claims were appended inline (do-assert),
1728+
;; so the LIVE store already reflects them: advance built-through past them and
1729+
;; re-stamp, so a following reload tail-reads only genuinely-new appends.
1730+
(reset! built-through (current-seq co))
1731+
(reset! flat-mtime (stamp flat))
1732+
(reset! flat-bytes (.length (java.io.File. (str flat))))
1733+
{:ok sq :image image :byte_offset byteoff :claim_count ccount :hash h})))
1734+
15281735
(defn serve-flat-daemon [port flat]
15291736
(boot-flat! flat)
15301737
(println (str "reified coordinator (drop-in over flat log): "

0 commit comments

Comments
 (0)