|
1133 | 1133 | :version (current-seq @co)})))))) |
1134 | 1134 |
|
1135 | 1135 | (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) |
1136 | 1139 |
|
1137 | 1140 | (defn handle [req] |
1138 | 1141 | ;; (#14 socket EXPOSURE) :edit-min runs OUTSIDE the outer dlock. do-edit-min's compute |
|
1195 | 1198 | :inc-triples (count (:triples (:idx inc))) :fresh-triples (count (:triples fidx)) |
1196 | 1199 | :version (current-seq @co)}) |
1197 | 1200 | :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)} |
1198 | 1207 | ;; warm scope-correct callers of a binding, served from refers_to materialized |
1199 | 1208 | ;; over `co` (version-cached). ensure-refers! whole-corpus re-resolves only when |
1200 | 1209 | ;; the code version moved (the correct first cut); the reverse lookup is then a |
|
1496 | 1505 | ;; daemon's append-flat!; the reified store must NOT dump v2 :k-records into it. |
1497 | 1506 | {:store st :log nil :lock (Object.)})) |
1498 | 1507 |
|
| 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 | + |
1499 | 1657 | (defn boot-flat! [flat] |
1500 | 1658 | (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))))) |
1502 | 1667 | (seed-name-seq! (:store @co)) ; Build A: seed the serialized name allocator above the global max |
1503 | 1668 | (reset! flat-log flat) |
1504 | 1669 | (reset! flat-mtime (stamp flat)) |
| 1670 | + (reset! flat-bytes (.length (java.io.File. (str flat)))) |
1505 | 1671 | (reset! cache {:index nil :version -1}) |
1506 | 1672 | (reset-refers-state!) ; S3.3: derived refers_to belong to the OLD store |
1507 | 1673 | (index!) @co) |
1508 | 1674 |
|
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. |
1518 | 1683 | (defn maybe-reload! [] |
1519 | 1684 | (when (and @flat-canonical? @flat-log) |
1520 | 1685 | (let [st (stamp @flat-log)] |
1521 | 1686 | (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))))) |
1523 | 1697 | (reset! flat-mtime st) |
| 1698 | + (reset! flat-bytes (.length (java.io.File. (str @flat-log)))) |
1524 | 1699 | (reset! cache {:index nil :version -1}) |
1525 | | - (reset-refers-state!) ; S3.3: external rebuild discards in-memory refers_to |
| 1700 | + (reset-refers-state!) |
1526 | 1701 | (index!))))) |
1527 | 1702 |
|
| 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 | + |
1528 | 1735 | (defn serve-flat-daemon [port flat] |
1529 | 1736 | (boot-flat! flat) |
1530 | 1737 | (println (str "reified coordinator (drop-in over flat log): " |
|
0 commit comments