flatsql 3.5.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,11 @@ met on Linux. Part V (§37) bounds a reader instance's memory. Part VI (§38)
12
12
  records the memory-safety and race work: the ps-wasm trap, the sanitizers and
13
13
  the first disk-full emergency. Part VII (§39) closes the query gaps T6 found
14
14
  on the host-02 fixture and accepts records stored with their own size
15
- prefix. The design is
15
+ prefix. Part VIII (§40) makes a partition's bookkeeping O(1) or O(log S) per
16
+ commit (the terabyte audit's B4) and adds manifest version 3 (M3). Part IX
17
+ (§41) is TB03: format levels with a ratchet, and paged lane checkpoints that
18
+ lift the 1,170-lane cap (level 3). The
19
+ design is
16
20
  the stack's `docs/architecture/flatsql-partition-store.md` (§22 overrides
17
21
  §0–§21; §22.4 holds the owner rulings). This file records what was built, how
18
22
  to run it, the measured acceptance, and every place the build departs from the
@@ -45,6 +49,7 @@ The wasm artifact is T4; SDN and browser integration are T6/T10.
45
49
  ```
46
50
  <root>/fsql2/
47
51
  STORE MIGRATED registry.fsl registry.fsh
52
+ STORE.tmp only while a format ratchet runs (§41)
48
53
  j/<writer:02x>-{a,b}.fsj commit journal (only with commitJournal)
49
54
  p/<pid:08x>/
50
55
  h.fsh head, A/B 4 KiB slots
@@ -53,7 +58,8 @@ The wasm artifact is T4; SDN and browser integration are T6/T10.
53
58
  r-<seg>.fsr a-<seg>.fsa dense rows / attributes (merged ranges)
54
59
  x-<seg>-<gen:04x>.fsx L1 runs
55
60
  mf-<gen:06x>.fsm segment manifest
56
- l.fsl lane table
61
+ l.fsl lane table (tuples)
62
+ lk-<gen:06x>.fsl level 3: paged lane checkpoint (§41)
57
63
  t/<fid hex8>/
58
64
  h.fsh m-<seg>.fsl type head and log (A10 batches)
59
65
  g-<seg:06x>.fsg arrivals, 24 B entries (A15 segments)
@@ -101,7 +107,9 @@ mutation.
101
107
 
102
108
  ## 4. Open and recovery
103
109
 
104
- 1. `STORE` and `MIGRATED` (refused without the marker).
110
+ 1. `STORE` and `MIGRATED` (refused without the marker). A level above the
111
+ engine's `kFormatMax` is refused before any file is touched; a lower one
112
+ ratchets once the registry is non-empty (§41).
105
113
  2. Registry: frames scanned past the head, truncated at the first bad CRC;
106
114
  incarnation = max seen + 1, recorded durably before any batch.
107
115
  3. Journal replay (A8): both files of every writer id from 0 until the first
@@ -112,6 +120,8 @@ mutation.
112
120
  if replayed), fence index validation, fsync before adoption.
113
121
  5. Partitions: head slot, tail batches across SEAL continuations (same rules),
114
122
  fsync `d`/`m`, A11 intent cleanup (`UNLINK_IF_UNUSED`), DURABLE_CKPT head.
123
+ At level 3 a head that names a LaneRef folds the lane table from it, and
124
+ open removes a lane checkpoint no durable head names (§41).
115
125
  6. Type heads for adopted type tails, written after the partitions are
116
126
  attached (their inline labels come from them).
117
127
 
@@ -650,11 +660,11 @@ and idle-lane sub-cursors (§21 deviation 1).
650
660
 
651
661
  | Path | Contents |
652
662
  |---|---|
653
- | `ps/compaction.h`, `src/ps/compaction.cpp` | Manifest version 2, the compacted rows format, retire items; the compaction pipeline (plan, build, SWAP, apply, abort). |
654
- | `src/ps/reclaim.cpp` | The per-partition file ledger (`disk_bytes`), the RETIRE and UNLINKED records, reader-gated unlinking, meta-segment retirement (A9), open-time cleanup; the same for the type logs (meta log rotation and retirement, retired catalog runs and manifests, the type ledger). |
655
- | `ps/quota.h`, `src/ps/quota.cpp` | The quota planner, the space emergency, the ballast. |
663
+ | `ps/compaction.h`, `src/ps/compaction.cpp` | Manifest versions 2 and 3 (§40), the compacted rows format, retire items; the compaction pipeline (plan, build, SWAP, apply, abort). |
664
+ | `src/ps/reclaim.cpp` | The per-partition file ledger (`disk_bytes`; a hash table, §40), segment lookups by binary search, the RETIRE and UNLINKED records and the RETIRE valve, reader-gated unlinking, meta-segment retirement (A9), open-time cleanup; the same for the type logs (meta log rotation and retirement, retired catalog runs and manifests, the type ledger). |
665
+ | `ps/quota.h`, `src/ps/quota.cpp` | The quota planner, the space emergency, the ballast; the planner's per-segment summary and the compaction candidate index, kept from a change feed (§40). |
656
666
  | `src/ps/sources-t3.cmake` | Registers the T3 sources and tests on the targets `cpp/CMakeLists.txt` defines. |
657
- | `cpp/test/ps/{compaction,orphan,quota}_test.cpp` | T3 tests. `checkPartitionDir` and `checkTypeDir` (fixtures) walk a partition's or a type's directory against the files its head and manifest name. |
667
+ | `cpp/test/ps/{compaction,orphan,quota,bookkeeping_scale}_test.cpp` | T3 tests (the last: §40). `checkPartitionDir` and `checkTypeDir` (fixtures) walk a partition's or a type's directory against the files its head and manifest name. |
658
668
 
659
669
  ## 24. Compaction (§11, A11, minor 1)
660
670
 
@@ -697,7 +707,9 @@ in the manifest; rows' `seg` is rewritten); after a restart, when the
697
707
  partition's dead share is over the ratio and the per-segment counts are
698
708
  unknown, the oldest
699
709
  segment not yet surveyed (a build that would save under 10% is abandoned and
700
- its count remembered). `Engine::swapSegment` requests one (targeted, or the
710
+ its count remembered). The owner keeps these candidates in an index updated
711
+ from a change feed; a look does not walk the segments (§40).
712
+ `Engine::swapSegment` requests one (targeted, or the
701
713
  oldest-generation compactable segment).
702
714
 
703
715
  Kill bound and readers. A type-level reader's visibility is `V_p =
@@ -737,17 +749,20 @@ appended.
737
749
  133 files in 0.31 s at a 0.3 s grace). BUSY (a handle is open) skips to
738
750
  later items and retries after 50 ms; idle
739
751
  reader lanes close cached handles after 5 s. The next batch carries
740
- `UNLINKED` and the shrunken set; `disk_bytes` drops then. A set past 2,048
741
- items unlinks its oldest regardless of the gate (a statement older than
742
- hours of retirements gets the retryable SNAPSHOT_GONE instead of a writer
743
- waiting on it).
752
+ `UNLINKED` and the shrunken set; `disk_bytes` drops then. The set rides one
753
+ ctl record of one batch, so it is held under a soft cap (2,048 items as the
754
+ record encodes them, less with a large lane table): past it the oldest go at
755
+ once, regardless of the gate and the grace (deviation 37). A statement older
756
+ than them gets the retryable SNAPSHOT_GONE; an open handle still makes the
757
+ unlink BUSY.
744
758
  - **Open** unlinks every file of the persisted set (a file a surviving reader
745
759
  instance still holds stays retired), the outputs of an INTENT_COMPACT
746
760
  without SWAP, and r/a files a first merge created without MERGE_DONE, then
747
761
  writes a durable head.
748
762
  - **Meta segments (A9).** A sealed `m-<seg>` whose batches are all merged is
749
- retired by a batch that also re-emits the full lane table (LANE_CKPT) and the
750
- RETIRE set, whose header carries the manifest generation, `merged_through`,
763
+ retired by a batch that also re-emits the full lane table (LANE_CKPT; at
764
+ level 3 past 32 lanes, a lane checkpoint must cover the segment first, §41)
765
+ and the RETIRE set, whose header carries the manifest generation, `merged_through`,
751
766
  `first_live_m_seg` and `next_gen`; `first_live_m_seg` advances when that
752
767
  batch commits. A rebuild of a head whose both slots are torn probes for the
753
768
  first existing meta segment and replays from its first batch.
@@ -1841,3 +1856,525 @@ segments per partition, R type catalog runs):
1841
1856
  (arrivals mode), or the tags' matching instances (collected mode, at most
1842
1857
  262,144).
1843
1858
  - Size-prefixed records: nothing (a 4-byte comparison per record).
1859
+
1860
+ # Part VIII: writer bookkeeping at scale
1861
+
1862
+ ## 40. Per-partition bookkeeping at scale (TB audit B4, M3)
1863
+
1864
+ The terabyte audit (stack `docs/architecture/flatsql-ps-terabyte.md`, B4)
1865
+ found a partition's bookkeeping growing as O(S²) in its segment count S. A
1866
+ single dominant partition (the GP archive import, a hot-split partition)
1867
+ reaches S = 1,000 at about 170 GB and S = 6,000 at about 1 TB. With 128 KiB
1868
+ seals, the rate fell from 116,829 rec/s at S = 235 to 465 rec/s at
1869
+ S = 5,188. Commit rounds reached a p99 of 100.7 ms and maintenance steps
1870
+ 100.7 ms. The causes:
1871
+
1872
+ - The ledger (§26) was a vector searched linearly.
1873
+ - `partitionPublishSummary` made three ledger scans per sealed segment on
1874
+ every commit that carried a MERGE_DONE, SEAL, SWAP, RETIRE or UNLINKED.
1875
+ - The compaction look walked every segment every 50 ms, with the same three
1876
+ scans and an L0 scan per segment.
1877
+ - Segment lookups were linear.
1878
+ - Open rebuilt the ledger in O(S²).
1879
+ - A MERGE_DONE and a SWAP recomputed the accelerator bytes by walking every
1880
+ run.
1881
+ - A split partition re-signed its run set on every commit by walking every
1882
+ run, and its helpers matched their previous view run by run in O(runs²).
1883
+
1884
+ What the owner keeps now (`PartitionLedger`, `ps/quota.h`):
1885
+
1886
+ - **The ledger** is an open-addressing table keyed by `(letter, seg, gen)`,
1887
+ with the same matching rules as before: manifests by generation, `d r a m`
1888
+ by segment, the rest by both. The byte total changes with every set and
1889
+ drop. A lookup, change or drop costs O(1).
1890
+ - **A change feed.** A ledger change to a segment's files, a new entry, a
1891
+ kill count, a survey and every input of a SWAP name the segments
1892
+ concerned. The planner's summary and the compaction candidate index update
1893
+ only those segments. The summary does this on the commits that publish
1894
+ it, the index on each look. Open rebuilds both in one walk.
1895
+ - **The compaction candidate index** applies §24's rules unchanged:
1896
+ - segments past the dead ratio, ordered by share (largest first, lowest
1897
+ id among equals);
1898
+ - starts of adjacent small pairs, where the first run of small segments
1899
+ begins at the lowest pair and grows within the coalescing limits;
1900
+ - segments not surveyed since a restart.
1901
+ A look costs O(log S), plus the output's neighbours.
1902
+ - **Segment lookups** binary-search the ascending entry list. Entries are
1903
+ created in order, and a pseq search steps over entries that hold no rows
1904
+ yet. Open walks the unmerged sealed segments alongside the entries.
1905
+ - **Accelerator bytes** change by the runs a MERGE_DONE or SWAP adds and
1906
+ removes.
1907
+ - **The split run set** is signed by the manifest generation, since every
1908
+ change to it commits a new one. A republish for new L0 blocks shares the
1909
+ run list instead of copying it. Helpers find reusable runs by
1910
+ `(seg, gen)`.
1911
+
1912
+ Stage-1 helpers (§32) no longer race the owner or producers (ThreadSanitizer):
1913
+ - A slot's `pos`, `len` and `kind` are relaxed atomics. The owner may take a
1914
+ CLAIMED slot and republish it for the next ordinal while the helper still
1915
+ reads them.
1916
+ - A helper copies an entry with `ringReadShared` (relaxed 8-byte word
1917
+ loads), never through a pointer into the ring. After a steal, the owner may
1918
+ consume and release the entry, and a producer may rewrite its bytes while
1919
+ the helper copies.
1920
+ - `ringWrite` stores relaxed words and edge bytes, which natively cost what
1921
+ plain stores do; the wasm hosts write slabs themselves.
1922
+ - In both cases the helper's CLAIMED to WRITING step fails and its result is
1923
+ dropped, as before. What it parsed is its own copy.
1924
+ - Slabs must be whole 8-byte words.
1925
+
1926
+ Deviations:
1927
+
1928
+ 37. **The RETIRE set is held under what one batch carries** (A12 said: past
1929
+ 2,048 items, the oldest go regardless of the gate once a grace has
1930
+ passed). The set rides one ctl record, whose length is a u16, inside the
1931
+ batch's 64 KiB ctl buffer, alongside UNLINKED (up to 1,024 items) and the
1932
+ lane table. A set that does not fit refuses every batch of the
1933
+ partition. A busy partition retires a manifest and folded runs per
1934
+ merge, and a coalescing SWAP retires a dozen files per input, so within
1935
+ the 60 s grace its set outgrew a batch. Ingest then stopped for the rest
1936
+ of the grace. The audit harness showed 843 rec/s for 50,000 records at a
1937
+ time, about every minute. The valve counts the set as its record
1938
+ encodes it (consecutive manifest generations collapse). The soft cap is
1939
+ 2,048 items, or less when a large lane table leaves less room. Past it,
1940
+ the oldest items are unlinked at once, without waiting for the reader
1941
+ gate or the grace:
1942
+ - A statement older than those items gets the retryable SNAPSHOT_GONE.
1943
+ - A file held open is BUSY and waits as before.
1944
+ - A meta segment still waits for a durable head (A9).
1945
+ - Crash safety never depended on the grace. The batch that retired a
1946
+ file is durable before its retirement counts, and open replays that
1947
+ batch.
1948
+ - While a compaction in flight pins the newest items and the set is past
1949
+ halfway from the soft cap to the hard cap, merges wait. Merges are the
1950
+ steady source of retirements, so the SWAP or abort can land first.
1951
+ 38. **Manifest version 3 (M3)** carries the segment count as a u32 in the
1952
+ header's last word (the u16 then reads 0xFFFF). It is written only when
1953
+ a partition's list outgrows the u16, so an engine that predates it still
1954
+ reads every store it can represent, and refuses a version 3 manifest
1955
+ instead of misreading it. Every version now decodes strictly: the
1956
+ records must end exactly at the CRC trailer. Before, a version 2
1957
+ manifest over 65,535 segments wrapped its count, passed the CRC and lost
1958
+ the rest.
1959
+
1960
+ Measured on the Mac (Darwin 25.3.0, arm64, 28 hardware threads, shared) with
1961
+ the in-memory fault host. One partition, 128 KiB seals, coalescing off (so S
1962
+ is the seal count), 400 B pad records, lockStats on:
1963
+ `bookkeeping_scale_S5k`. The base column is 0df6ef4 (its writer is
1964
+ 14a3075's) with the same test. Each row is the 50,000-record window that ends
1965
+ at that S.
1966
+
1967
+ | | S = 235, base | S = 235 | S = 5,188, base | S = 5,188 |
1968
+ |---|---|---|---|---|
1969
+ | commit round p99 | 0.328 ms | 0.229 ms | 100.7 ms | 0.115 ms |
1970
+ | maintenance step p99 | 0.082 ms | 0.049 ms | 100.7 ms | 0.328 ms |
1971
+ | summary refresh p99 (per commit that publishes it) | | 0.016 ms | | 0.004 ms |
1972
+ | candidate look p99 | | 0.033 ms | | 0.012 ms |
1973
+ | open (half, full: S = 2,594, 5,188) | | | 45.7 ms, 170 ms | 4.0 ms, 4.5 ms |
1974
+ | rec/s | 116,829 | 99,796 | 465 | 5,386 |
1975
+
1976
+ Load was 39–60 for the base run and 22–30 for the new one. The same test on
1977
+ this change over d566293, at load 26–37: commit p99 0.393 → 0.229 ms,
1978
+ maintenance p99 0.066 → 0.786 ms, summary refresh 0.012 → 0.020 ms,
1979
+ candidate look 0.049 → 0.016 ms, open 6.0 ms at S = 5,188, rate 77,556 →
1980
+ 3,363 rec/s.
1981
+
1982
+ Over the same 200,000 records (S = 0 to 940), the process used 5.9–6.9 s of
1983
+ CPU against 9.2–10.3 s on 14a3075 (six alternating runs). Near S = 235 the new
1984
+ build's wall rate was lower than base's by about 7% in alternating runs
1985
+ (82,405 against 94,237 rec/s, load 30–37), while at S ≈ 11 the two were equal.
1986
+ With shorter commit rounds, the writer commits smaller batches (about 940
1987
+ commits per 50,000 records against 700) and waits idle for the test's single
1988
+ producer. The time is lost in the handshake, not in work.
1989
+
1990
+ On ext4, in Docker's Linux VM on the same Mac, the audit's harness
1991
+ (`tb_segments_scaling`: fsync on, one partition, 128 KiB seals, 550,000
1992
+ records) was bound by the VM's shared virtual disk. Other lanes' containers
1993
+ were using it too, so both builds ran near 500 rec/s at S = 235:
1994
+
1995
+ | build | S = 235 | S = 1,886 | S = 2,594 | reopen at S = 2,594 | whole run |
1996
+ |---|---|---|---|---|---|
1997
+ | 14a3075 | 475 rec/s | 211 rec/s | 356 rec/s | 21.0 s | 31.3 min |
1998
+ | this build | 522 rec/s | 795 rec/s | 781 rec/s | 3.6 s | 19.0 min |
1999
+
2000
+ `bookkeeping_scale_S5k` itself on the same ext4 volume (`--dir`) is disk
2001
+ bound: commit rounds wait on fsync (p99 336 ms at S = 235, 470 ms at
2002
+ S = 5,188). The bookkeeping in them stays flat, though: the summary refresh
2003
+ p99 is 0.041 → 0.033 ms and the candidate look p99 0.066 → 0.029 ms, and
2004
+ open at S = 5,188 takes 134 ms. With `--dir` the test reports commit and
2005
+ maintenance p99 without gating them.
2006
+
2007
+ The rate that remains decays with S because of B5: every record's CID dedupe
2008
+ probes the bloom of every L1 run of every segment (`committedPostings`,
2009
+ `partition_log.cpp`). `sample` at S ≈ 4,900 puts 94% of the writer's samples
2010
+ in `cidState` and about 1% in the paths this section changed: maintenance,
2011
+ commit rounds and reclamation together account for 184 of 12,272 samples.
2012
+ Per-partition levelled lookup indexes (B5) are the terabyte design's.
2013
+
2014
+ What still costs O(S), and where it belongs:
2015
+ - A merge snapshots every segment into the manifest it rewrites (M3's delta
2016
+ or two-level manifest). The maintenance p99 above (0.05 → 0.33 ms) is
2017
+ that.
2018
+ - A SWAP writes a whole manifest and rebuilds the entry list.
2019
+ - A split partition's helpers reopen every run file per run set (B2's
2020
+ handle LRU).
2021
+
2022
+ Tests (`cpp/test/ps/bookkeeping_scale_test.cpp`):
2023
+ - `book_manifest_v3_codec`: exact round trips at 0, 1, 65,535, 65,536 and
2024
+ 70,001 segments; a wrapped v2 count, a stray byte, a bad v3 header and
2025
+ version 4 refused; version 1 read.
2026
+ - `book_retire_valve_keeps_ingest_flowing`: 60,000 records at the default
2027
+ 60 s grace. 16,767 files retired, 14,299 unlinked by the valve, the
2028
+ encoded set peaked at 2,113, 0 batches refused. With the valve off, the
2029
+ same test refused 4,397 batches and one 5,000-record chunk took 59.5 s.
2030
+ - The valve test also reports the writer's memory, and none of it grows
2031
+ with records. From 25,000 to 60,000 records:
2032
+ - slab pool: 3.9 → 4.1 MB;
2033
+ - ledger: 2,271 → 2,398 files (67 → 69 KB);
2034
+ - RETIRE set: 2,145 → 2,217 items (160 KB);
2035
+ - segment list: 54 → 68 entries (12 → 15 KB).
2036
+ The process does grow: 1.47 GB at 25,000 records and 3.55 GB at 60,000
2037
+ (3.4 GiB of wasm's 4 GiB). That is the in-memory test host, which keeps
2038
+ every file it ever wrote (7,212 files, 1.45 GB; then 17,281 files,
2039
+ 3.52 GB) so that data calls need no lock. The rest of the heap
2040
+ (13 → 25 MiB) grows by the host's 231 B per file. On wasm the test runs
2041
+ 25,000 records.
2042
+ - `book_equivalence_all_commit_kinds`: runs with `gBookCheck`, which checks
2043
+ every ledger operation against the linear ledger it replaced, and every
2044
+ segment lookup, summary refresh, candidate pick and split run-set
2045
+ signature against a walk. A mismatch aborts. The run covers seals,
2046
+ merges, SWAPs (dead-ratio, coalescing, requested, quota evictions),
2047
+ RETIRE and UNLINKED, meta-segment retirement, a hot split, a clean
2048
+ reopen and a kill -9.
2049
+ - `book_crash_every_op` (every 37th mutating call) and `_full` (every one:
2050
+ 9,416 crash points over seals, merges, SWAPs, retirement, unlinking and
2051
+ A9, all five crash modes, 0 failures): after each reopen, the rebuilt
2052
+ ledger names exactly the files on disk at `disk_bytes`, counters equal a
2053
+ recount, and the engine carries on under `gBookCheck`.
2054
+ - `book_check_fast_suite` (slow): every fast test again with `gBookCheck`
2055
+ (88 of 88 passed).
2056
+ - `bookkeeping_scale_S5k` (slow): the table above. It gates commit and
2057
+ maintenance p99 under 10 ms at S ≥ 5,000, and the summary refresh and
2058
+ candidate look within 2× (+50 µs) of their S = 235 cost. On the base
2059
+ commit it fails both p99 gates.
2060
+
2061
+ Sanitizers, on this build over d566293 (macOS, clang):
2062
+ - ThreadSanitizer reports nothing on eleven tests: the four book tests,
2063
+ `quota_arrival_order_heads_spared_T3_3`,
2064
+ `compaction_cat_supersede_plateau_T3_2`,
2065
+ `orphan_crash_points_during_compaction_T3_4`, and the four fast hot-split
2066
+ tests.
2067
+ - AddressSanitizer with UBSan reports nothing on the book tests,
2068
+ `compaction_removes_dead_rows_keeps_live`,
2069
+ `quota_arrival_order_heads_spared_T3_3`, the orphan harness,
2070
+ `hot_split_equals_unsplit_reference_T3_5` and `crash_faults_T1_1`. It had
2071
+ found one defect in this section, fixed: `std::min` bound a reference to a
2072
+ packed counter.
2073
+ - The stage-1 race, before and after, on Docker linux/arm64 with gcc 13 and
2074
+ on macOS:
2075
+ - 14a3075 reports it in 3 of 5 hot-split runs (`hot_split_throughput_T3_5`,
2076
+ and `hot_split_rebalance_stalled_helpers_A26` at 12 s, twice);
2077
+ - this change reports it in none of 6 runs.
2078
+ - A two-thread program (`ringWrite` against a helper's read of the same
2079
+ bytes) shows it on Linux with a plain reader, and still shows it with
2080
+ atomic loads against a plain `memcpy` write. It is silent only once both
2081
+ sides are atomic.
2082
+ - Under ASan the equivalence test needs more than its default 60 s to settle
2083
+ (it passes with `--eq-settle-s=600`).
2084
+
2085
+ Mutations the checks caught:
2086
+ - a missing kill touch;
2087
+ - a SWAP input not touched;
2088
+ - a ledger update dropped;
2089
+ - the valve off.
2090
+
2091
+ # Part IX: format levels and lane checkpoints
2092
+
2093
+ ## 41. Lanes past 1,170 and format levels (TB03)
2094
+
2095
+ The terabyte design (stack `docs/architecture/flatsql-ps-terabyte.md` §2.9 K0,
2096
+ §3, §10 TB03) found that a partition stops committing past 1,170 live lanes.
2097
+ Past 32, every batch that changed a lane carried the whole live table (56 B a
2098
+ lane) in one LANE_CKPT ctl record. A u16 bounds that record, so a table past
2099
+ (65,535 − 4) / 56 = 1,170 lanes refused the batch, and every retry refused it
2100
+ too. SDN mints a lane per fetch, so a 3-hourly source whose history stays
2101
+ live crosses the cap in about 146 days. Below the cap each such batch still
2102
+ rewrote about 65 KB near 1,170 lanes. The RETIRE encoder also reserved room
2103
+ for every lane ever loaded.
2104
+
2105
+ Measured before the change on 2026-09-30:
2106
+ - host-02's legacy store (read-only, `sdn_record_source_summary`): the
2107
+ largest partition is MPE.fbs, celestrak-gp, at 66 live lanes. That is 5.6%
2108
+ of the cap. It grows about 4.3 lanes a day, so it would reach 1,170 in
2109
+ about 257 days. The next largest are RFB.fbs at 11 and OMM.fbs at 3.
2110
+ - The host-02 fixture migrated with SDN's store-migrate (4,364,873 records):
2111
+ OMM has 53 lanes, MPE 52, CAT 2 and IQC 1.
2112
+
2113
+ ### Format levels
2114
+
2115
+ `cpp/include/flatsql/ps/format_level.h` holds `kFormatMin` 2, `kFormatMax`
2116
+ 3 and `kLevelLaneCkpt` 3. `STORE.format` is the store's level.
2117
+ `HeadPrefix.format` and MIGRATED stay 2, because SDN's Go head reader checks
2118
+ them.
2119
+
2120
+ - **Accepted levels.** An engine opens `kFormatMin ≤ format ≤ kFormatMax`.
2121
+ A valid STORE above that is refused ("STORE format N is newer than this
2122
+ engine") before any file is created or written. So is a torn STORE whose
2123
+ STORE.tmp (below) is above it. An intact STORE the engine opens is never
2124
+ refused because of a higher STORE.tmp: that ratchet never reached step 2,
2125
+ so nothing of its level exists, and the leftover is removed. Readers apply
2126
+ the same rules: an intact STORE decides alone, and only a torn one reads
2127
+ through STORE.tmp.
2128
+ - **writeFormat** (writer TLV 31, `EngineConfig::writeFormat`; 0 is
2129
+ `kFormatMax`). A fresh store is created at it. At open, STORE ratchets up
2130
+ to it, but only once `registry.fsl` is non-empty: an empty store can still
2131
+ be recreated by an older engine. Nothing of a level is written before the
2132
+ ratchet to it is durable. A host can pin a lower level for a staged
2133
+ rollout, and a pinned host keeps its level: a STORE.tmp above the pin is
2134
+ removed, not finished. A store never ratchets down.
2135
+ - **A full device.** When STORE.tmp cannot be created, written or synced
2136
+ (step 1), the ratchet does not start: the leftover is removed, the store
2137
+ opens at its level exactly as the level-2 open would, and the next open
2138
+ tries again. A ratcheting engine on a full disk therefore never keeps a
2139
+ host down that the older engine would start.
2140
+ - **The ratchet.** The I/O ABI has no rename (deviation 39), so the ratchet
2141
+ takes three steps:
2142
+ 1. STORE.tmp (the new STORE) is written whole and synced; its directory
2143
+ entry is durable from creation.
2144
+ 2. STORE is rewritten in place (64 bytes, one sector) and synced.
2145
+ 3. STORE.tmp is unlinked.
2146
+
2147
+ A STORE.tmp is this store's when every field but the format equals
2148
+ STORE's. STORE is written once, and only the ratchet rewrites it, changing
2149
+ only its format, so a STORE.tmp an older engine left beside the store it
2150
+ went on writing still describes that store. An open finishes the ratchet
2151
+ from STORE.tmp when STORE is torn (STORE.tmp is the one valid record), or
2152
+ when STORE is intact and the open writes that level. Otherwise STORE.tmp is
2153
+ removed and STORE stands. Level-3 files exist only after step 2.
2154
+
2155
+ The released 3.5.1 engine reads STORE only, run natively on each crash
2156
+ state:
2157
+
2158
+ | State | 3.5.1 | Files |
2159
+ |---|---|---|
2160
+ | STORE 2, STORE.tmp 3 (step 1 done) | opens, writes level-2 data | changed |
2161
+ | STORE torn, STORE.tmp 3 (inside step 2) | "STORE is corrupt" | unchanged |
2162
+ | STORE 3, STORE.tmp 3 (step 2 done) | "STORE is corrupt" | unchanged |
2163
+ | STORE 3 (finished) | "STORE is corrupt" | unchanged |
2164
+
2165
+ A level-3 open afterwards recovered all four at level 3, with the lanes
2166
+ 3.5.1's own reopen reports. In the first state, 3.5.1 writes level-2 data
2167
+ into a level-2 store, which level 3 reads and converts.
2168
+ - **Rollback.** The ratchet is durable before the open reads a partition,
2169
+ so a first level-3 open that then fails for another reason (a tail scan,
2170
+ a manifest) also leaves the store at level 3, which 3.5.1 refuses. Rolling
2171
+ back after the first start restores a snapshot of the store taken before
2172
+ it, or re-syncs. SDN forwards TLV 31 as `SDN_F2_WRITE_FORMAT` for a host
2173
+ held at level 2.
2174
+ - **Stats** (appended, entries 36–40): the store's level, `kFormatMax`, the
2175
+ level this open raised STORE from (0: none; 1: STORE was torn inside a
2176
+ ratchet this open finished, so its old level is unknown), lane
2177
+ checkpoints cut, and their bytes.
2178
+
2179
+ At level 3, manifest version 3 is the written version. Below level 3, v3 is
2180
+ written only past 65,535 segments (§40, deviation 38).
2181
+
2182
+ ### Lanes at level 3
2183
+
2184
+ - **A batch carries only the lanes it changed.** That is its lane-delta
2185
+ section: one 48 B LaneDelta per changed lane, counted by a u32 (deviation
2186
+ 40: this section is the design's `LANE_PAGE`, so no new ctl kind was
2187
+ needed). The per-batch delta table is now indexed by lane id and has no
2188
+ cap. It was 256, so a kill of a record with more than 256 tag instances
2189
+ failed its batch for good.
2190
+ - **Up to 32 live lanes**, the head holds the table inline, as at level 2.
2191
+ - **The batch that takes a partition past 32** writes the table once as a
2192
+ LANE_CKPT. In inline mode, staging stops before an entry once the batch
2193
+ holds 64 lane deltas. An entry makes at most one lane live (a PUT or a
2194
+ re-tag), so that table holds at most the live lanes plus 65, 97 lanes.
2195
+ - **From that batch on, the head names a LaneRef** (`nLanes` 0xFFFE,
2196
+ 48 bytes after the L0 directory): a base plus the batch position its
2197
+ deltas replay from. The base is `lk-<gen>.fsl`, or the crossing
2198
+ LANE_CKPT. The table is the base, plus the deltas of every batch from the
2199
+ replay offset up to the head's `(mSeg, mEnd)`. Batches are walked by
2200
+ their headers, and a SEAL moves to segment + 1.
2201
+ - **`lk-<gen:06x>.fsl`** is a 4 KiB header page (pid, gen, lane count,
2202
+ `mintedThrough`, the cut position, CRC). Then come pages of up to 73
2203
+ counters sorted by lane id, each page with a CRC. Count-0 lanes are
2204
+ dropped. There is no size cap.
2205
+ - **Cuts.** A checkpoint is cut:
2206
+ - after the crossing;
2207
+ - when A9 wants to retire a meta segment at or above the ref's floor (its
2208
+ replay segment, and a LANE_CKPT base's segment);
2209
+ - once a fold would walk `laneCkptBatches` batches with lane deltas
2210
+ (writer TLV 32, default 128), or four times that many batches in all.
2211
+ A fold reads every batch it walks, and every `flatsql_lanes` statement
2212
+ and every open folds, so this bounds their work. Batches without lane
2213
+ deltas count a quarter, so the RETIRE and UNLINKED batches a cut itself
2214
+ causes never make the next cut due.
2215
+
2216
+ The owner copies its live counters at a batch boundary (O(live lanes),
2217
+ once per cut), and a maintenance helper writes and syncs the file
2218
+ (deviation 41). The encoded file counts in the writer's committed memory
2219
+ until it is written.
2220
+ - **Crash protocol**, as built:
2221
+ 1. The helper writes `lk-<g>` and syncs it. One cut is in flight per
2222
+ partition, so g is always the ref's gen + 1.
2223
+ 2. The owner installs the ref and writes a DURABLE_CKPT head naming it.
2224
+ 3. Only once a durable head names it does a RETIRE list `lk-<g−1>`
2225
+ (letter `L`), and A9 retire a meta segment below the new floor.
2226
+ 4. Open unlinks `lk-<gen + 1>`, a checkpoint no durable head names. An
2227
+ `lk-<gen − 1>` whose RETIRE never committed goes after the open's
2228
+ durable head.
2229
+
2230
+ No cut starts while a RETIRE that lists a replaced checkpoint has not
2231
+ committed. Until it commits only the head names what open cleans up, so
2232
+ the files on disk stay within `lk-<gen − 1>` to `lk-<gen + 1>`. Without
2233
+ that wait, meta writes failing ENOSPC let the next cut install and a crash
2234
+ left `lk-<gen − 2>` named by nothing, for good. Open also probes
2235
+ `lk-<gen − 2>`.
2236
+ - **Torn heads.** Every batch that carries a RETIRE also carries the LaneRef
2237
+ in force (ctl kind 13, LANE_REF). A torn-head rebuild from a later meta
2238
+ segment folds the table from the last one.
2239
+ - **The RETIRE valve** reserves only what the batch's finalize step adds:
2240
+ the LANE_REF in ref mode; in inline mode the table a crossing can write,
2241
+ the live lanes plus 65; and the whole table at level 2, as before. With 32
2242
+ or fewer live lanes the soft cap stays at 2,048 items, as at level 2.
2243
+ - **Readers** fold the same way (`LaneStore::laneCounters`) up to their
2244
+ snapshot's head. A file retired since then answers SNAPSHOT_GONE.
2245
+ - **A level-2 head at a level-3 open.** A head that names a LANE_CKPT
2246
+ becomes a LaneRef with that record as its base and the head position as
2247
+ its replay offset. This holds because level 2 wrote a LANE_CKPT with every
2248
+ batch that changed a lane past 32. A first checkpoint follows.
2249
+ - **In memory**, lanes are found by id through an index. It was a linear
2250
+ search per delta at publish, replay and RECONCILE. Replay, readers and
2251
+ checkpoints apply a delta by one rule (`laneApplyDelta`). A lane at count
2252
+ 0 carries no history: checkpoints and head tables drop it, so a delta that
2253
+ makes it live again starts its counters afresh (`first_seen`, `max_pseq`,
2254
+ `updated`). The writer's memory, a fold from any base and a replay then
2255
+ agree whether or not a checkpoint dropped the lane. Before, a lane revived
2256
+ after a checkpoint dropped it read its old `first_seen` until the next cut
2257
+ and the new one after it, with no write in between.
2258
+ - **A failed batch that minted lanes** rolls `nextLaneId` back to the first
2259
+ id it minted. The table is not in id order when l.fsl lost frames the
2260
+ head still counts, and the old rule, the last lane's id + 1, could fall
2261
+ below a live id.
2262
+ - **A LaneRef head in a store below level 3** (a partial snapshot restore)
2263
+ quarantines that partition. It used to fail the whole open.
2264
+
2265
+ ### Live-only candidate caps (N2)
2266
+
2267
+ Staging gathered at most a fixed number of postings per lookup and then
2268
+ dropped the dead ones. A key with more dead postings than the cap hid its
2269
+ live row:
2270
+
2271
+ | Lookup | Old cap |
2272
+ |---|---|
2273
+ | supersede candidates | 8 |
2274
+ | CID candidates | 64 |
2275
+ | tag instances of one tuple | 64 |
2276
+ | live instances of a PUT | 256 |
2277
+ | licence and control supersedes | 8 |
2278
+
2279
+ Only live rows count now. A key holds at most one live row of each kind:
2280
+ every PUT kills the live PUTs of its supersede keys whose CID differs, every
2281
+ control record every live row of its key, every licence the live licences
2282
+ of its key, and a CID is stored once per partition. A live control record
2283
+ is also older than any live PUT beside it. So a supersede or licence lookup
2284
+ ends at the key's newest live row, and a control record's at the live
2285
+ control record.
2286
+
2287
+ The walk goes newest first through the postings' sources. The unmerged L0
2288
+ blocks and each segment's L1 runs hold disjoint, ascending pseq ranges,
2289
+ with every run below every block, so the sources are read from the newest,
2290
+ each one's postings sorted descending, and the walk stops at the end
2291
+ without reading older sources. Should the ranges not be in that order, all
2292
+ postings are gathered and sorted instead. A PUT of a hot key reads its
2293
+ newest version, not every dead version compaction has not dropped yet.
2294
+ CID and tag-instance lookups gather every posting and stop at the first
2295
+ live one. The lists grow outside the hot-path count.
2296
+
2297
+ ### Tests
2298
+
2299
+ The tests are in `cpp/test/ps/lane_cap_test.cpp`. The Mac is an M3 Ultra
2300
+ with 28 cores, APFS, and the in-memory fault host, shared with other lanes
2301
+ (1-minute load 9–20 during the runs). Every crash sweep and trial checks,
2302
+ after each reopen, the lane table on disk against a recount from rows
2303
+ (count and bytes), and against the engine's own table in every counter
2304
+ field, for every live lane.
2305
+
2306
+ | Test | Result |
2307
+ |---|---|
2308
+ | `lanecap_5000_live_lanes` | 5,000 lanes, then 500 re-tags into new lanes and 200 kills, all acked. The engine, `flatsql_lanes`, the on-disk fold and a recount from rows all agree (5,272 lanes); again after a reopen and 100 more lanes |
2309
+ | `lanecap_crash_trials_full` | 1,000 trials, 2 writers, helper-written checkpoints, file and journal mode, 114 s. Each crash lands after at least 5,000 live lanes: 160 left an unnamed checkpoint and 362 a replaced one pending. After every reopen the table equals a recount from rows and the engine's table, and no `lk` orphan is left. 0 failures |
2310
+ | `lanecap_ckpt_crash_every_op_full` | A crash at each of 2,912 mutating I/O calls of a workload that crosses 32 lanes, cuts 30 checkpoints, seals, and retires meta segments and replaced checkpoints. All five crash modes. Before open: 965 crashes left an unnamed checkpoint and 1,241 a replaced one pending. After open: lanes equal a recount and the engine's table, and the directory holds exactly the checkpoint the head names. 0 failures |
2311
+ | `lanecap_2000_lanes_retire_bearing` | 2,000 lanes with seals, merges, a coalescing SWAP, 97 files retired, 5 meta segments retired, 8 checkpoints: 0 batches refused, every lane correct |
2312
+ | `lanecap_batch_lane_bytes_proportional` | A batch that changes one lane writes 56 B of lane records at level 3, with 66 live lanes (host-02's largest partition) or 5,000. At level 2 the same batch writes 3,760 B at 66 lanes and 61,664 B at 1,100. At level 2, 1,180 lanes never ack |
2313
+ | `lanecap_no_cut_while_lk_retire_pending` | Meta writes fail ENOSPC for 300 steps: no cut starts while a RETIRE naming a replaced checkpoint waits (355 steps waited), and a crash then leaves exactly the named checkpoint after open. With the wait removed, 82 steps had a cut in flight |
2314
+ | `lanecap_install_head_nospace` | The head write naming a newly installed checkpoint fails ENOSPC (40 installs), then a crash in each of the five modes at four points after: every reopen agrees with the recount and the engine and keeps only the named checkpoint. 0 failures of 20 trials. Removing the replaced checkpoint's wait for a durable head is not caught here: this workload's checkpoint heads follow in the same step, and every RETIRE batch also carries the LaneRef in force |
2315
+ | `lanecap_fold_walk_bounded` | 1,500 one-lane batches on 100 lanes at the default cadence: a fold walks at most 128 batches (13 checkpoints); `flatsql_lanes` 0.3–1.0 ms |
2316
+ | `lanecap_revived_lane_counters_stable` | A lane killed to 0, dropped by a checkpoint and written again reads the same `first_seen` (the revival's) before the next cut, after it and after a reopen. Without the restart rule it flipped at the cut |
2317
+ | `lanecap_rollback_keeps_lane_ids_unique` | l.fsl loses 6 frames (the table is out of id order), a batch that minted a lane fails ENOSPC and retries: every lane id unique, the new lane's id past every live one. The old rule fails it |
2318
+ | `tb_supersede_candidate_cap`, `_autocompact` | 1 live version; 381 of 381 live. The old cap gives 12, and 392 of 381 |
2319
+ | `tb_supersede_hot_key_cost` | A CAT key with 400 versions among 19 static objects each, default compaction: its PUT costs 1.8x a new key's (618 vs 340 µs p50); 2.3x at 800 versions. Checking every dead version (the review's measurement: 12.8x at 800) fails it at 7.3x. What remains is the kill's TAG_OF prefix scan across every run, the same as 3.5.1's (TB12's prefix bloom) |
2320
+ | `lanecap_kill_record_with_300_instances` | A kill takes all 300 instances and lanes in one batch |
2321
+ | `lanecap_cid_candidates_past_64` | The 80th copy of a CID dedupes |
2322
+ | `lanecap_torn_heads_rebuild_from_lane_ref` | Both head slots torn: the table is rebuilt from LANE_REF |
2323
+ | `format_older_engine_refuses_ratcheted_store` | An engine built with `kFormatMax` 2 refuses a level-3 store, and this engine refuses a level-4 STORE; neither creates or changes a file |
2324
+ | `format_empty_registry_never_ratchets` | Opened three times, the empty level-2 store stays at 2; with a registration, the next open ratchets to 3 |
2325
+ | `format_ratchet_crash_every_io_call` | A crash at each of the ratcheting open's 13 mutating calls, in all five modes (65 trials). An engine built with `kFormatMax` 2 (this code with `testFormatMax` 2, not the released 3.5.1: see the table above) then opened the store 31 times and refused it 34 times, creating nothing when it refused. This engine then opens at level 3 with every record |
2326
+ | `format_ratchet_leftover_tmp_rules` | A STORE.tmp at 3 beside an intact STORE at 2: removed by a host pinned at 2 and by an engine whose `kFormatMax` is 2, which open at 2; finished by this engine (stats entry 38 says 2). One that differs from STORE in more than the format: removed. Beside a torn STORE: refused by the `kFormatMax`-2 engine with no file changed, finished by this one even pinned (entry 38 says 1) |
2327
+ | `format_ratchet_waits_on_a_full_device` | No room for STORE.tmp: the store opens at 2, as the level-2 open does, and the next open with room raises it |
2328
+ | `format_reader_store_rules` | A reader refuses a STORE at 4 beside a STORE.tmp at 3, reads a torn STORE through a STORE.tmp at 3, and refuses one through a STORE.tmp at 4 |
2329
+ | `format_fresh_store_at_write_format`, `format_manifest_v3_from_level_3`, `format_manifest_v3_on_swap` | Fresh stores at writeFormat. Manifests v3 from level 3 and v2 at level 2, from a merge and from a compaction SWAP |
2330
+
2331
+ Mutations the checks catch: the old supersede cap (12 live, and 392 of
2332
+ 381); checking every dead version (7.3x); no orphan cleanup at open; a cut
2333
+ while a replaced checkpoint's RETIRE waits; no restart of a revived lane;
2334
+ the old rollback rule; the old STORE.tmp rules in the writer and in
2335
+ readers; a ratchet that fails the open on a full device; the old cadence
2336
+ default.
2337
+
2338
+ The native suite (118 fast tests), `crash_faults_T1_1_full` (10,000
2339
+ trials), `orphan_crash_points_during_compaction_T3_4_full` (2,501 trials),
2340
+ `fanout_randomized_vs_bruteforce_T2_2_full` (T2 #2) and
2341
+ `book_crash_every_op_full` (9,389 crash points) pass, 0 failures. In wasm32
2342
+ (wasi-sdk 30, the Node host, `scripts/ps-wasm-suite.mjs`) all 118 fast
2343
+ tests and `crash_faults_T1_1` with 1,000 trials pass. ThreadSanitizer
2344
+ reports nothing on the threaded `lanecap_` and `format_` tests, the
2345
+ supersede autocompaction test and the SWAP manifest test.
2346
+
2347
+ Deviations:
2348
+
2349
+ 39. **The ratchet uses STORE.tmp and an in-place rewrite** (the design says
2350
+ an atomic rename). The host I/O contract has seven imports and no
2351
+ rename, and adding one would change every host. The three steps above
2352
+ leave the old STORE, the new one, or a torn one that STORE.tmp repairs.
2353
+ An engine that predates levels never misreads a ratcheted store. SDN
2354
+ reads a torn STORE through STORE.tmp too (its `Migrated` and the update
2355
+ guard), so neither the daemon's start nor the Apply/Rollback guard
2356
+ stops short of the engine's repair.
2357
+ 40. **No LANE_PAGE ctl kind.** The batch's lane-delta section already holds
2358
+ exactly the changed lanes, and a u32 counts it.
2359
+ 41. **The owner cuts the checkpoint from its in-memory table** (the design:
2360
+ a helper streams the previous checkpoint plus the deltas since). The
2361
+ writer holds every lane's counters until TB15b removes them. The copy
2362
+ costs O(live lanes) once per cut (the review measured 0.055 ms at 5,000
2363
+ lanes, 1.5 ms at 100,000 and 14 ms at 1,000,000), and the helper still
2364
+ writes and syncs the file; the encoded bytes count in the writer's
2365
+ committed memory until then. The copy equals a fold of the checkpoint
2366
+ it replaces plus the deltas since because a lane at count 0 restarts
2367
+ (above). TB15b switches the source when the writer stops holding the
2368
+ table, before a partition can hold 10^5 lanes.
2369
+
2370
+ Not built here:
2371
+ - A reader cache of folded tables. A reader folds at most 128 batches with
2372
+ lane deltas (512 in all) per partition per `flatsql_lanes` statement.
2373
+ - `flatsql_lanes` still matches lanes to tuples in O(lanes²)
2374
+ (`vtab_meta.cpp` `fillLanes`, the fan-out estimate): 15 ms per statement
2375
+ at 5,000 lanes, 0.1 ms at 100. That is TB15a's (pushdown and a hash from
2376
+ lane id to tuple); today's largest partition holds 66 lanes.
2377
+ - The LANE kind, the lane LRU and `flatsql_lanes` pushdown (TB15a, TB15b).
2378
+ - A kill's TAG_OF prefix scan still reads every run of the partition (the
2379
+ cost left in `tb_supersede_hot_key_cost`); TB12's prefix bloom removes
2380
+ it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flatsql",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "description": "SQL queries over raw FlatBuffer storage: the SQLite-backed wasm engine and the multithreaded partition store (flatsql-ps-threads.wasm)",
5
5
  "type": "module",
6
6
  "main": "wasm/index.js",
Binary file
@@ -5,9 +5,9 @@
5
5
  "size": 2246620,
6
6
  "files": {
7
7
  "flatsql-ps-threads.wasm": {
8
- "sha256": "787adbcccf52a9e9fe2767d6679b6f2d94a4ead41985bf2a7bb0855f821abc72",
9
- "sri": "sha384-qR37NE4y3sP60hLFJEmxsYz76i2vpTZf9ydfSO0dX1MSD15HKEgEIm6UnkGYS8lo",
10
- "size": 2484927
8
+ "sha256": "87ea0c727eb3c0b889a2d3fb41f8dac631ec2af07f521fe9526556d094d2e119",
9
+ "sri": "sha384-RUEUl560TtNBLtx6OauGyCwNdLJNbYfXPDEKVgV1M5au/stPrlxS+r9hlWxNprWI",
10
+ "size": 2629364
11
11
  },
12
12
  "flatsql-wasi-noeh.wasm": {
13
13
  "sha256": "8f11fd49ee2e6961b9c1c22dd891d5a6645d0faf481f815408c0f1f5d3a385ab",