flatsql 3.0.0 → 3.2.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.
@@ -109,14 +109,15 @@ FLATSQL_PS_WASMEDGE=1 npm test -- test/ps-parity-vectors.test.ts # + the C run
109
109
 
110
110
  Machine: the owner's Mac Studio (Darwin 25.3.0, arm64, 28 hardware threads,
111
111
  APFS), Node 25.4.0, shared with other lanes: 1-minute load average 18–25
112
- during these runs. Native builds RelWithDebInfo; wasm builds as §1.
112
+ during these runs (37–41 for the release rerun, §4.2). Native builds
113
+ RelWithDebInfo; wasm builds as §1.
113
114
 
114
115
  | # | Acceptance | Result |
115
116
  |---|---|---|
116
117
  | 1 | Imports exactly WASI p1 + `wasi.thread-spawn` + shared `env.memory` (≤ 32768 pages) + the 7 `env.flatsql_io_*`; exports `wasi_thread_start`; 0 emscripten imports; passes `assertPthreadArtifact` | **Pass.** §1 table; `scripts/check-wasm-imports.mjs`, `test/standalone-wasm-imports.test.ts` (the SDK guard runs in a child process). |
117
118
  | 2 | The T1 and T2 suites, including 1,000 fault crashes, pass under the Node host with distinct OS thread ids ≥ writers + lanes | **Pass.** `scripts/ps-wasm-suite.mjs`: all 53 default-suite tests, each in its own guest process, plus `crash_faults_T1_1 --crash-trials=1000` (1,000 trials over the five modes, 298,923 acked records verified, 41.8 s). `readers_under_saturating_writers_T2_1` (8 writers, 16 lanes) ran 54 guest threads at once on 54 distinct worker (OS) threads. `test/ps-node-threads.test.ts`: the shipped artifact as a writer instance (4 writers) and a reader instance (4 lanes), each running thread on its own worker. `writer_backpressure_flood_T1_5` runs with `--bp-samples=40` (deviation 3). `crash_faults_T1_1_full` (10,000 trials, 64 types, 256 partitions): §4.1. |
118
119
  | 3 | Parity: canonical dumps (by (pid, pseq)) and query results identical native vs Node; deterministic mode byte-identical across native, Node and the SDK WasmEdge C runner (patched 0.16.4) | **Pass.** Canonical dump, 4 writers, 11,313 lines (rows, counters, lane counters and six queries, partition and type level): sha256 `f3b419a2…` natively and under Node, three runs each. Deterministic mode, 122 files: in memory `e7ab8592…` natively, under Node and under the C runner (18.7 s, interpreted); on the host's files `c7e34c55…` natively and under Node. `test/ps-parity-vectors.test.ts`. |
119
- | 4 | A published npm version with provenance; the wasm sha256 in `integrity.json`; `npm ls sql.js` empty; the package root exports only the wasm bindings | See §6 and the release record in §4.2. |
120
+ | 4 | A published npm version with provenance; the wasm sha256 in `integrity.json`; `npm ls sql.js` empty; the package root exports only the wasm bindings | **Pass.** `flatsql@3.0.0`, §4.2 and §6. |
120
121
 
121
122
  Host-level crashes (the overlay, §2): `test/ps-host-fault.test.ts` kills the
122
123
  guest mid-ingest at a random 0.4–2.0 s, crashes the store in the three modes
@@ -140,7 +141,27 @@ journal. A negative control (half a data segment cut off) fails the verifier.
140
141
 
141
142
  ### 4.2 Release
142
143
 
143
- Filled in when the release is published.
144
+ - `flatsql@3.0.0`, published by `npm-publish.yml` from tag `v3.0.0` =
145
+ `79fd694` (run 36486543061) with a signed provenance statement
146
+ (`npm audit signatures`: verified attestation); `gitHead` 79fd694, dist-tag
147
+ `latest`, `dependencies` `{}`.
148
+ - `wasm/flatsql-ps-threads.wasm`: 2,074,707 bytes, sha256
149
+ `572a4bc38345d07ff91c345fc8d81e28873d72efa0fb8864a2edc5242145f9f5`, the same
150
+ in `wasm/integrity.json`, from the Linux arm64 Docker build, the x86_64
151
+ publish runner and the installed tarball.
152
+ - A fresh `npm install flatsql@3.0.0`: `npm ls sql.js` is empty; the root's
153
+ exports equal `flatsql/wasm`'s; `loadFlatSQLPsThreads()` checks the bytes
154
+ against `integrity.json`.
155
+ - At the release sources (`dac8d89` engine), under the Node host: the 55
156
+ default-suite tests and `crash_faults_T1_1 --crash-trials=1000` pass (1,000
157
+ trials, 403,798 acked records verified, 47.3 s; load 37–41). Main CI green
158
+ on `79fd694` (run 36482613908).
159
+ - Two engine defects the Node host found before the release, fixed in the
160
+ sources the artifact builds: the open path dropped the lane checkpoint
161
+ pointer of a head with more than 32 live lanes (`5c7e769`; the 1,000-trial
162
+ crash run, trial 317), and the type owner never merged its L0 directory
163
+ when `mergeL0Blocks` exceeded the directory cap, so labeling, partition
164
+ merges and acks stopped (`dac8d89`).
144
165
 
145
166
  ## 5. Design deviations
146
167
 
@@ -4,13 +4,16 @@ The engine of the partition store program. Part I (§1–§11) is T1: append-onl
4
4
  partition logs, one writer per partition on a pinned pool, durable acks,
5
5
  per-commit indexes, type owners, and durable-tail open. Part II (§12–§21) is
6
6
  T2: reader instances and lanes, the snapshot protocol, the SQL surface, the
7
- fan-out merge, admission and results. The design is the stack's
8
- `docs/architecture/flatsql-partition-store.md` (§22 overrides §0–§21; §22.4
9
- holds the owner rulings). This file records what was built, how to run it, the
10
- measured acceptance, and every place the build departs from the design.
7
+ fan-out merge, admission and results. Part III (§23–§30) is T3: compaction,
8
+ reclamation, meta-segment retirement, disk accounting and quota. Part IV
9
+ (§31–) is T3b: store-migrate gseqs, the entry-size setting, hot-partition
10
+ splitting and arrivals compaction. The design is
11
+ the stack's `docs/architecture/flatsql-partition-store.md` (§22 overrides
12
+ §0–§21; §22.4 holds the owner rulings). This file records what was built, how
13
+ to run it, the measured acceptance, and every place the build departs from the
14
+ design.
11
15
 
12
- Compaction, splits and quota are T3; the wasm artifact is T4; SDN and browser
13
- integration are T6/T10.
16
+ The wasm artifact is T4; SDN and browser integration are T6/T10.
14
17
 
15
18
  ## 1. Code map
16
19
 
@@ -633,3 +636,445 @@ engine. If the bound fails there, the next steps are the design's own levers:
633
636
  fewer writer threads than cores (design §5.1: `clamp(cores−2, 1, 16)`), interactive
634
637
  lanes at a higher priority than writers (design A28 gives nice −5 only on ≤ 2 vCPU),
635
638
  and idle-lane sub-cursors (§21 deviation 1).
639
+
640
+ # Part III: compaction, reclamation and quota (T3)
641
+
642
+ ## 23. Code map
643
+
644
+ | Path | Contents |
645
+ |---|---|
646
+ | `ps/compaction.h`, `src/ps/compaction.cpp` | Manifest version 2, the compacted rows format, retire items; the compaction pipeline (plan, build, SWAP, apply, abort). |
647
+ | `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). |
648
+ | `ps/quota.h`, `src/ps/quota.cpp` | The quota planner, the space emergency, the ballast. |
649
+ | `src/ps/sources-t3.cmake` | Registers the T3 sources and tests on the targets `cpp/CMakeLists.txt` defines. |
650
+ | `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. |
651
+
652
+ ## 24. Compaction (§11, A11, minor 1)
653
+
654
+ A compaction rewrites a run of sealed, fully merged segments `[seg, segEnd]`
655
+ into one new file set, `c-<seg>-<gen>.{fsd,fsr,fsa}` plus
656
+ `x-<seg>-<gen>.fsx` and `mf-<gen>.fsm`:
657
+
658
+ 1. The owner plans. `INTENT_COMPACT{seg, segEnd, gen}` rides the next batch; no
659
+ output exists before it is durable. The head carries the intent.
660
+ 2. A compaction thread builds. It reads the DEAD and TAG_DEAD postings over the
661
+ inputs' pseq range from every run and L0 block at or after the inputs, keeps
662
+ a killer only when it is at or below the plan's kill bound (the type owner's
663
+ `labeled_through`), and decides per row:
664
+ - PUT, LICENCE and CTL rows with a killer go;
665
+ - RETAG rows go when their instance is TAG_DEAD, or their PUT is dead or gone;
666
+ - TOMB, CTL_TOMB and TAG_TOMB rows go only when an earlier SWAP removed their
667
+ target (minor 1): outside the inputs, or VOID within them.
668
+
669
+ It copies the surviving frames, attributes and rows in 4 MiB slices, filters
670
+ the inputs' L1 runs down to the surviving rows' postings (every partition
671
+ posting's value is the pseq of the row that emitted it), fsyncs everything,
672
+ and re-checks the owner word and an abort flag before every mutation (A26).
673
+ 3. The owner, once no merge is in flight, writes the manifest (its fsync joins
674
+ the next round's first sync phase) and queues `SWAP{seg, gen, oldCgen,
675
+ segEnd}`; the SWAP, the RETIRE set and the counters' adjustment ride one
676
+ batch. Publish attaches the new files.
677
+
678
+ Rows keep their pseqs. `c-*.fsr` holds the surviving rows (dense by rank) and a
679
+ presence directory (a bit per pseq, a rank per 512); a pseq the directory
680
+ lacks reads as a `VOID` row (kind 8, never stored, never live). A segment with
681
+ no survivor is `EMPTY` in the manifest and has no files.
682
+
683
+ Candidates (maintenance, every 50 ms per warm partition, and at once after a
684
+ SWAP): the sealed segment with the largest dead share at or over
685
+ `compactDeadRatio` (15%; dead frame bytes counted as kills commit), joined by
686
+ its adjacent segments past the ratio too (one output per wave, within the
687
+ coalescing limits); adjacent sealed segments under 8 MiB, coalesced up to 16
688
+ at a time and 64 MiB of output into one segment named by the first (`lastSeg`
689
+ in the manifest; rows' `seg` is rewritten); after a restart, when the
690
+ partition's dead share is over the ratio and the per-segment counts are
691
+ unknown, the oldest
692
+ segment not yet surveyed (a build that would save under 10% is abandoned and
693
+ its count remembered). `Engine::swapSegment` requests one (targeted, or the
694
+ oldest-generation compactable segment).
695
+
696
+ Kill bound and readers. A type-level reader's visibility is `V_p =
697
+ min(pseq_hi, labeled_through[p])`. A compacted segment records `killThrough`
698
+ (the largest killer behind a removed row, the inputs' own, and the bound of
699
+ the SWAP that removed a dropped tombstone's target) and `prevGen` (the manifest
700
+ before its SWAP). A type-level snapshot whose `V_p` is below a segment's
701
+ `killThrough` reads that segment through `prevGen`, repeatedly for a segment
702
+ compacted more than once; the reader gate keeps those files for any statement
703
+ older than the SWAP. Partition-level readers see `pseq_hi`, which is never
704
+ below a SWAP's bound.
705
+
706
+ Counters: `total_count`, `total_bytes` and `tomb_count` count the rows the
707
+ files hold (a SWAP subtracts what it removed), so they equal a recount;
708
+ `min_epoch`, `max_epoch` and `latest_arrival` stay bounds over everything ever
709
+ appended.
710
+
711
+ ## 25. Reclamation (A12) and meta segments (A9)
712
+
713
+ - **Retire.** A MERGE_DONE retires the folded runs and the manifest it
714
+ replaces; a SWAP retires the inputs' files, their runs and the manifest. The
715
+ whole outstanding set is a `RETIRE` ctl record in the same batch (the head
716
+ points at it: `retireSeg/retireOff/retireN`); consecutive manifests collapse
717
+ into one range item.
718
+ - **Unlink.** Maintenance unlinks a retired file with `UNLINK_IF_UNUSED` once
719
+ the reader gate (the start of the oldest running reader statement) is past
720
+ its retirement time, checked twice a grace apart (60 s by
721
+ default). The retirement time is that of the first head write after the
722
+ batch commits (the head that stops naming the file): a statement that
723
+ starts earlier may read the previous head. In journal mode also once every
724
+ journal record older than the
725
+ retirement is checkpointed; a meta segment once a DURABLE_CKPT head names a
726
+ later `first_live_m_seg` (a quiet partition writes one; later items do not
727
+ wait for it, and a partition with retired files never goes cold). Each pass
728
+ takes the first check of every file past the gate, so a backlog goes about
729
+ one grace after its statement ends (`reclaim_backlog_goes_in_one_grace`:
730
+ 133 files in 0.31 s at a 0.3 s grace). BUSY (a handle is open) skips to
731
+ later items and retries after 50 ms; idle
732
+ reader lanes close cached handles after 5 s. The next batch carries
733
+ `UNLINKED` and the shrunken set; `disk_bytes` drops then. A set past 2,048
734
+ items unlinks its oldest regardless of the gate (a statement older than
735
+ hours of retirements gets the retryable SNAPSHOT_GONE instead of a writer
736
+ waiting on it).
737
+ - **Open** unlinks every file of the persisted set (a file a surviving reader
738
+ instance still holds stays retired), the outputs of an INTENT_COMPACT
739
+ without SWAP, and r/a files a first merge created without MERGE_DONE, then
740
+ writes a durable head.
741
+ - **Meta segments (A9).** A sealed `m-<seg>` whose batches are all merged is
742
+ retired by a batch that also re-emits the full lane table (LANE_CKPT) and the
743
+ RETIRE set, whose header carries the manifest generation, `merged_through`,
744
+ `first_live_m_seg` and `next_gen`; `first_live_m_seg` advances when that
745
+ batch commits. A rebuild of a head whose both slots are torn probes for the
746
+ first existing meta segment and replays from its first batch.
747
+ - **Type logs.** A type's MERGE_DONE retires the catalog runs it folds and
748
+ the manifest it replaces. The new manifest persists the whole outstanding
749
+ set (these and any still waiting) in an appendix after its CRC trailer,
750
+ `[magic FSTR][n][n x RetireItem][crc][pad]`, which readers that predate it
751
+ never read. Unlinking follows the partition rules (reader gate twice a
752
+ grace apart, journal safe time, BUSY retried after 50 ms). Open unlinks the
753
+ appendix's files, the outputs of a merge a crash cut short (it sweeps the
754
+ generations after the manifest's to `next_gen + 64`, and 16 past the last
755
+ leftover it finds, since a head's `next_gen` can lag), and anything below
756
+ `first_live_m_seg`. A failed merge unlinks its outputs at once (durably;
757
+ one that cannot go yet is retired like the rest) and retries with the same
758
+ generation. At stop a merge in flight closes its handles; an unfinished
759
+ build's outputs go, a finished one's are left to open (a MERGE_DONE already
760
+ in the log may name them).
761
+ - **Type meta segments.** The type meta log starts `m-<seg+1>` once it passes
762
+ `typeMetaSegBytes` (64 MiB): the sealed segment is cut at the end of its
763
+ last batch and synced, the new one created, and a durable head names it.
764
+ Open follows the batch chain from a segment's end into the next one, so a
765
+ stale head slot loses nothing. With more than 128 partitions the new
766
+ segment's first batch is a FULL_LABELS checkpoint, and readers read label
767
+ deltas across segments. The oldest sealed segment retires once no unmerged
768
+ L0 block, merge input or label checkpoint lives in it: `first_live_m_seg`
769
+ (a type head field) is written to both head slots and synced, then the file
770
+ waits for readers like any retired file.
771
+ - **Dead copies leave the catalog (A15, catalog half).** A type merge drops the
772
+ CID, LABEL and REPEAT entries of every copy killed within its inputs whose
773
+ first posting (FIRST or REPEAT) is also among them: a copy never leaves
774
+ DEAD, and no older run holds any of its postings. A full fold drops every
775
+ dead copy. GONE and REHOME entries stay: they are keyed by gseq for
776
+ arrivals order, and arrivals are not compacted yet (§30).
777
+ - **Reader gate in production.** The writer is its own wasm instance: the host
778
+ reads every reader instance's lane announcements
779
+ (`FlatsqlPsReaderLayout.laneAnnounce`) and passes the minimum through
780
+ `flatsql_ps_reader_gate` (T6 wires it). Natively, `Engine::setReaderGate`.
781
+
782
+ ## 26. Disk bytes (§13)
783
+
784
+ `disk_bytes` is the exact size of the files a partition names: a ledger of the
785
+ stable files (sealed segments' d and m, r/a, c-*, runs, manifests, the next
786
+ segment's pre-created files, retired files until UNLINKED) plus the extents of
787
+ the files still growing (h, l, the active d and m, zero-fill included). Open
788
+ rebuilds it from the head, the manifest and file sizes; the owner maintains it
789
+ on every append, merge, seal, SWAP and unlink. After every reopen in the crash
790
+ tests, a walk of each partition directory finds exactly the named files and a
791
+ total equal to `disk_bytes`. Head files are written as whole slots until they
792
+ hold both, so a head write never grows the file again.
793
+
794
+ A type's disk bytes are kept the same way: its head, meta segments from
795
+ `first_live_m_seg`, arrivals segments and fence, catalog runs, manifest, a
796
+ built merge's outputs until MERGE_DONE, and retired files until they are
797
+ unlinked, maintained on every write, seal, rotation, merge and unlink and
798
+ rebuilt at open from file sizes. Type configs (`s-*.fsc`) are registration
799
+ and not counted. The crash tests check the type directory the same way.
800
+
801
+ ## 27. Quota and the full disk (§13, A13, §22.4-3)
802
+
803
+ - **Planner** (writer 0's maintenance, every 100 ms): usage = Σ partition
804
+ `disk_bytes` + Σ type log bytes, less retired files waiting only for
805
+ readers. Over the cap
806
+ (`EngineConfig::quotaBytes`, `Engine::setQuota`, `flatsql_ps_set_quota`), it
807
+ evicts whole sealed segments in arrival order across partitions (their
808
+ `minArrival` zone) down to 0.85 of the cap: a `TOMB_RANGE{seg, all}` on the
809
+ owner (bounded 512-row steps; supersede-lane heads and control kinds are
810
+ spared; each step also stops after 4 ms of CPU), then, once the tombstones
811
+ are durable, a compaction of the segment. One wave at a time.
812
+ - **ENOSPC.** A commit that fails NOSPACE starts an emergency: record entries
813
+ stop being consumed (producers see zero credits; nothing is acked), the
814
+ ballast file (`fsql2/ballast`, `ballastBytes`) is released, and waves evict one
815
+ segment at a time down to 0.85 of the usage at the failure, and at least the
816
+ ballast's size, but never more than half the store in one episode. Once
817
+ usage is under that cap the ballast is recreated and ingest resumes. NOSPACE
818
+ in a merge, a compaction, a head write or a segment creation is transient;
819
+ a merge that failed retries after 100 ms (a type merge with the same
820
+ generation when it left nothing behind). A partition whose L0 directory is
821
+ full stages no rows at all (entries, mailbox kills, TOMB_RANGE steps) until
822
+ a merge frees a slot.
823
+
824
+ ## 28. Acceptance (§18 T3 as amended)
825
+
826
+ Machines: **Mac** = the owner's Mac Studio (Darwin 25.3.0, arm64, 28 hardware
827
+ threads), shared with other lanes (1-minute load recorded per run). **Mac mem**
828
+ runs use the in-memory fault host (no fsync). Numbers come from `MEASURED`
829
+ lines of `flatsql_ps_test` (RelWithDebInfo) on the task-branch build landed on
830
+ 2026-09-28, run one after another at load 28–38.
831
+
832
+ | # | Acceptance | Result | Where |
833
+ |---|---|---|---|
834
+ | 1 | A partition with 50% dead bytes compacts to ≤ 55% of its prior disk bytes; owner commit p99 and interactive lane p99 ≤ 1.5× baseline during compaction; 1,000 pre-SWAP statements complete correctly | **Size and statements pass; the latency ratio is not met on the Mac (needs a quiet Linux-8 box).** Mac mem, load 31–35: 4 partitions × 20,000 OMM records, half killed: 106.4 MB → 51.9 MB (0.488×); 1,003 bulk statements that started before a SWAP returned exactly the live rows with their bytes, 0 errors (2,177 SWAPs). Latency over the whole SWAP period (the compaction plus the re-compactions that produced those statements; 16,093 samples): ack p99 0.133 → 18.5 ms, interactive lane p99 0.339 → 3.75 ms, commit round p99 1.05 ms. | `compaction_half_dead_T3_1_full` |
835
+ | 2 | CAT supersede, 20% changed per cycle, 100 cycles: disk plateaus ≤ 1.3× live bytes; A9: `m` bytes ≤ one active plus one sealed segment | **Pass with the 15% trigger (deviation 24), "live bytes" read as the live set's compacted footprint.** Mac mem, load 31–33: 20,000 CAT objects, 100 cycles (each updates a random 20%), the store measured at rest after each cycle: disk peaked at 1.120× the footprint of the same live set fully compacted (19.5 MB); 346 compactions, 520 meta segments retired, 5,291 files unlinked; at most 1 meta segment on disk (A9). With §11's 25% trigger it peaks at 1.31–1.38× (six runs). Against the records' own bytes (Σ len − 4 of live PUTs, 4.9 MB) the disk is 4.5×: a fully compacted CAT record costs about 4× its frame in rows, attributes and postings, which no compaction removes | `compaction_cat_supersede_plateau_T3_2_full` |
836
+ | 3 | Quota at 80% of current bytes: oldest records evicted first; no supersede-lane head evicted; disk ≤ cap within 3 passes; each eviction step ≤ 10 ms | **Pass (arrival order, §22.4-3).** Mac mem, load 33–35: the store with its type logs 14.1 MB, cap 11.3 MB: under the cap after 1 pass (14 segments), 0 of 256 CAT heads evicted, in each partition the evicted records are exactly its oldest (0 holes); step max 3.4 ms over 15 steps. Across partitions eviction is by whole segment, so a survivor can be older than an evicted record of another partition (288 of 6,367 records here). The 10 ms bound is enforced on a quiet box (the wasm suite at load 41 measured past it). | `quota_arrival_order_heads_spared_T3_3` |
837
+ | 4 | 1,000 crash points during compaction: exactly one of the old or new set is live, 0 orphans, 0 acked records lost; A12: crash points inside the grace window, Σ on-disk sizes = Σ `disk_bytes` after each reopen | **Pass.** Mac mem, load 28–38: 3,224 trials, 1,000 with a compaction planned but not applied at the crash, 1,744 with retired files waiting; all five crash modes (664 drop-all, 637 drop-subset, 658 torn, 623 reordered, 642 kill -9); 41,825 compactions, 291,864 files unlinked, 24,719 meta segments retired (partitions and the type's), 723,969 dead catalog entries folded out; after every reopen each partition directory and the type directory held exactly the named files with sizes equal to the engine's disk bytes, every acked live record live with its bytes, every acked kill dead, counters equal to a recount; once labels caught up, `first_live_count` equal to the live records and 16 catalog lookups right. 507 s. The T1 crash harness (merges, A9, reclamation; compaction off: its oracle keeps dead bytes) checks the partition directories after every reopen too; the wasm build runs it ×1000. | `orphan_crash_points_during_compaction_T3_4_full`, `crash_faults_T1_1` |
838
+ | 5 | Hot split at 4× single-writer capacity with 4 helpers | **Not built** (§30). | – |
839
+ | 6 | Fill the disk to ENOSPC under ingest: ingest resumes with 0 acked losses and no operator action | **Pass.** Mac mem, load 34–35, a 16 MiB device, 1 MiB ballast, two OMM producers for 4 s: 17 episodes, the ballast released 17 times and restored 16, 41,112 records acked after a recovery, 6,318 alive at the end; in each partition the surviving acked records are exactly its newest (0 holes); after a reopen (before the engine starts) the partition and type directories hold exactly the named files at the engine's disk bytes. Before type-log reclamation the type logs filled 16 of the device's 17 MB and the run ended in the emergency with no survivor. | `quota_disk_full_resumes_without_operator_T3_6` |
840
+
841
+ §22 amendments naming T3:
842
+
843
+ | Amendment | Status |
844
+ |---|---|
845
+ | A9 meta-segment retirement | Built (§25). |
846
+ | A11 intents | INTENT_COMPACT built; merge and compaction intents coexist in one batch. |
847
+ | A12 reclamation lifecycle | Built (§25): RETIRE/UNLINKED, reader gate twice a grace apart, open replays the set. |
848
+ | A13 ENOSPC, ballast, arrival order | Built with deviations 12–13. |
849
+ | A15 arrivals compaction | **Half built**: dead catalog copies are folded out of the type's runs and the type logs are reclaimed and counted (§25–§27); arrivals entries and GONE postings of dead gseqs stay (§30). |
850
+ | A26 stage-1 prep states | Not built (with #5). |
851
+
852
+ ## 29. Design deviations (T3)
853
+
854
+ 1. **Compaction builds on its own threads** (§11: the owner in 4 MiB / 10 ms
855
+ slices; helpers only for split partitions). As T1 deviation 1 did for
856
+ merges: a build on the writer stalls every partition it owns. The builder
857
+ re-checks the owner word and an abort flag before every mutation, and a
858
+ HANDOFF waits for it to stop (one slice).
859
+ 2. **The owner writes the manifest at SWAP time**, synced in the next round's
860
+ first phase, instead of the builder: merges of the active segment continue
861
+ while a build runs; only the SWAP waits for no merge in flight.
862
+ 3. **Kill bound and `prevGen` fallback** (not in the design): a row is removed
863
+ only when its killer is labeled, and a type-level snapshot below a
864
+ segment's bound reads the previous file set. Without it, a type-level
865
+ statement whose type snapshot predates a death could miss a live copy
866
+ (A14).
867
+ 4. **Counters are physical** (§24): recounts equal them after compaction.
868
+ 5. **Coalescing** adjacent small segments (§11 "adjacent sealed segments under
869
+ 8 MiB each") into one output named by the first segment.
870
+ 6. **Minor 1 extended**: a tombstone whose target is VOID in the same inputs
871
+ (an earlier SWAP of that segment removed it) goes too. Without it the
872
+ supersede tombstones of a partition compacted in one piece never went
873
+ (measured: the CAT plateau grew by 77 KB a cycle).
874
+ 7. **Tombstone-only segments seal by age** (T1 sealed only segments with
875
+ frames), so a partition that stops ingesting can drop its tombstones.
876
+ 8. **Reader gate by statement start time**, not by announced manifest
877
+ generations (A12 text): T2 built announcements as start times
878
+ (`oldestActiveStart`); the host passes the minimum
879
+ (`flatsql_ps_reader_gate`).
880
+ 9. **RETIRE is the whole set** per changing batch, not incremental records, so
881
+ open reads one record (head pointer) plus the tail.
882
+ 10. **UNLINK_IF_UNUSED BUSY skips** to later items (A12: "retry later");
883
+ reader lanes close idle handles after 5 s.
884
+ 11. **Disk bytes include zero-fill and pre-created files** (the file sizes);
885
+ open stats the files it names.
886
+ 12. **ENOSPC evicts with tombstones, one segment per wave** (A13: "first unlink
887
+ whole eligible sealed segments, oldest first ... needs no new space").
888
+ Dropping a segment without per-record tombstones would leave the type
889
+ owner unaware of the deaths (A14: FIRST copies must be labeled DEAD and
890
+ REPEATs promoted), and those catalog updates need space too. The ballast
891
+ must hold one segment's tombstones: about 128 B of row plus postings per
892
+ record, twice until the tombstones' own segment is compacted.
893
+ 13. **Ballast is off in the engine default**; hosts set it (servers 256 MiB,
894
+ browsers one seal plus one commit), so tests and benchmarks are unaffected.
895
+ 14. **Quota counts partitions and type logs**, not the registry (a few KB)
896
+ or type configs (registration).
897
+ 15. **A rebalance of a partition already handed off is ignored** (a T1 race
898
+ the compaction timing exposed: a second release before the target adopted
899
+ could leave two owners).
900
+ 16. **RecordAttr bytes**: `buildRecordAttr` created its strings inside one
901
+ argument list, whose evaluation order C++ leaves unspecified; GCC on x86_64
902
+ built different bytes from clang. Each string is now created in field order;
903
+ `format_record_attr_golden_bytes` checks `vectors/record_attr.hex`
904
+ (22.3a-6).
905
+ 17. **T3 #2 "live bytes" is the live set's compacted footprint**: the same
906
+ records fully compacted, rows, attributes and postings included. Every
907
+ record carries about four times its frame in index structure, so a ratio
908
+ to frame bytes alone would measure the index, not garbage; both ratios are
909
+ reported (§28).
910
+ 18. **Type logs persist their retired set in the manifest** (not in ctl
911
+ records): a type has no ctl-record log, and the manifest is the one file a
912
+ MERGE_DONE names. The appendix sits after the CRC trailer, so readers and
913
+ opens that predate it parse the manifest unchanged.
914
+ 19. **A type meta segment retires with two synced head writes** instead of a
915
+ LANE_CKPT batch and a later DURABLE_CKPT head (A9): a type head has no
916
+ lane table, and after the second write neither slot names the segment,
917
+ whichever one a crash leaves torn.
918
+ 20. **A compaction plan pins its files**: anything retired after the plan was
919
+ made stays until the SWAP or the abort (a build reads the runs and meta
920
+ segments current at planning).
921
+ 21. **A failed build never quarantines**: the inputs are untouched, the
922
+ partition carries on, and requests end with an error (NOSPACE starts the
923
+ emergency). A quarantined partition ends its compaction requests at once.
924
+ 22. **TOMB_RANGE steps are also bounded in time** (4 ms of CPU, besides 512
925
+ rows): a kill looks up the row's tag instances in every run, so rows cost
926
+ more as a partition grows, and more under wasm (T3 #3: ≤ 10 ms a step).
927
+ 23. **Dead catalog copies are dropped by merges** (A15 says "type-log
928
+ compaction"): the type merge already reads every entry it could drop, so
929
+ no separate pass rewrites the runs; a copy goes once its first posting and
930
+ its DEAD posting meet in one merge (at the latest, the next full fold).
931
+ 24. **The dead-share trigger is 15%, not 25%** (§11). At rest a segment can
932
+ hold up to the trigger's share of dead rows, and a dead row costs as much
933
+ as a live one in rows, attributes and postings, so a store at rest sits
934
+ at up to 1/(1 − trigger) of its compacted footprint: 1.33× at 25%, above
935
+ T3 #2's 1.3. Measured on the T3 #2 run (20,000 CAT objects, 20% updated
936
+ per cycle, 100 cycles; Mac mem, load 6–26): 25%: 1.31–1.38 over six
937
+ runs; 20%: 1.35, 1.38; 15%: 1.115, 1.120; 10%: 1.19, with about the
938
+ same number of compactions (334–361). The price is write
939
+ amplification: a compaction at 15% rewrites 5.7 bytes per byte it
940
+ reclaims (3 at 25%). `EngineConfig::compactDeadRatio` sets it.
941
+ 25. **A compaction wave is one output**: segments next to the chosen one that
942
+ are past the trigger too join it, and the next candidate is looked for
943
+ right after a SWAP (not 50 ms later).
944
+
945
+ ## 30. Not built yet; running the T3 tests
946
+
947
+ Remaining T3 scope:
948
+
949
+ - **#5 hot split** with stage-1 helpers and sealed-segment re-homing (§12), and
950
+ A26's ring prep states (FREE → CLAIMED(epoch) → PREPARED). Sealed segments'
951
+ merges and compactions already build off the owner (deviation 1 here, T1
952
+ deviation 1); what is missing is stage-1 (verify, sha256, key extraction
953
+ claimed in ranges by idle writers) and its trigger. Its acceptance (≥ 2.5×
954
+ the unsplit rate with 4 helpers) is a throughput ratio that needs a quiet
955
+ many-core box to mean anything.
956
+ - **A15 arrivals compaction**: arrivals entries (24 B) and the GONE posting
957
+ of every dead gseq stay, about 40 B per record ever ingested; the quota
958
+ counts them but nothing shrinks them (the type-log test: 2.4 MB of catalog
959
+ runs, mostly GONE, and 2.5 MB of arrivals after 104,100 records with 835
960
+ alive). The type-log half (catalog runs, manifests, meta segments, dead
961
+ catalog copies) is built (§25). The arrivals half must drop an entry and its
962
+ GONE posting in one commit, or offset paging (live = entries − GONE, T2)
963
+ miscounts: the plan is to rewrite sealed arrivals segments inside the type
964
+ merge that drops their GONE postings, named by the merge's generation in
965
+ its manifest.
966
+ - **#1 latency ratio** on a quiet Linux-8 box.
967
+
968
+ ```
969
+ cpp/build/flatsql_ps_test --test=compaction_ # basic, T3 #1 and #2 (short forms)
970
+ cpp/build/flatsql_ps_test --test=compaction_half_dead_T3_1_full [--dir=<dir>]
971
+ cpp/build/flatsql_ps_test --test=compaction_cat_supersede_plateau_T3_2_full
972
+ cpp/build/flatsql_ps_test --test=orphan_crash_points_during_compaction_T3_4_full # 1,000 crash points
973
+ cpp/build/flatsql_ps_test --test=quota_ # T3 #3, #6
974
+ cpp/build/flatsql_ps_test --test=type_logs_reclaimed_under_readers
975
+ ```
976
+
977
+ **T3 #1 on a quiet Linux-8 box** (8 cores, ext4, nothing else running), from a
978
+ checkout at the landed commit; the test enforces the 1.5× bounds on such a box:
979
+
980
+ ```
981
+ cpp/build/flatsql_ps_test --test=compaction_half_dead_T3_1_full --dir=<ext4 dir>
982
+ ```
983
+
984
+ # Part IV: T3b
985
+
986
+ ## 31. store-migrate gseqs, the entry-size setting, tag conditions
987
+
988
+ store-migrate (T6, design §16.1-5) gives every migrated FIRST copy gseq = its
989
+ legacy `sdn_record_index.rowid`, so per-schema MaxRowID and the FTS rowids
990
+ carry over.
991
+
992
+ - **Input.** `RecordAttr.migrated_gseq` (`ulong`, field 6, appended to
993
+ `schemas/flatsql_attr.fbs`; absent = 0, so older attributes are
994
+ unchanged byte for byte). `buildRecordAttr(..., migratedGseq)` writes it.
995
+ - **Row.** A new PUT whose attribute carries one gets row flag
996
+ `MIGRATED_GSEQ` (0x80). The gseq stays in the attribute (in the same meta
997
+ batch, `ATTR_IN_M`): the RecRow has no spare u64 (offsets 108–127 hold
998
+ `supersedeHash`, `tagHash` and `aux`), and the attribute is as durable as
999
+ the row. Deviation from the T6 proposal "(b) the RecRow reserved u64 at
1000
+ offset 116", which T1's layout had already given to `tagHash`.
1001
+ - **Labeling.** The type owner reads the flagged rows' attributes in windows
1002
+ over the chunk it labels (one read per chunk of up to 4,096 rows when the
1003
+ attributes fit in 1 MiB). A flagged copy labeled FIRST keeps its gseq when it
1004
+ is above the type's committed `gseq_hi` and no arrival of the same type
1005
+ commit holds it; the engine's gseq counter is then raised to at least gseq
1006
+ + 1. Otherwise it gets an allocated gseq and counts a fallback; a flagged
1007
+ copy labeled REPEAT counts a fallback too. A type commit's new arrivals are
1008
+ sorted by gseq before they are appended, so arrivals stay strictly
1009
+ increasing whatever order partitions are labeled in.
1010
+ - **Counters.** `EngineStats::migratedGseqs` and `migratedGseqFallbacks`,
1011
+ appended to `flatsql_ps_stats` (entries 24 and 25). store-migrate's
1012
+ verification (§16.1-7) needs fallbacks = 0; they are counted per labeling
1013
+ decision, so a type commit that fails and is relabeled counts again.
1014
+ - **Crash.** The gseq is read from durable rows and attributes, so a crash
1015
+ that loses the type commit relabels the same copies with the same gseqs.
1016
+ An allocated fallback gseq that was never published can be reassigned
1017
+ (as for every allocated gseq).
1018
+ - **What T6 must do** (its (d)): feed each type's FIRST copies in rowid
1019
+ order and wait for `labeled_through` before crossing partitions, and run
1020
+ with fallbacks = 0. gseqs are unique per type; across types a fallback could
1021
+ reuse a legacy rowid another type migrates later, which is one more reason
1022
+ a fallback fails the migration.
1023
+ - **Entry size.** Writer TLV 19 = `maxEntryBytes` (u64; default 1 MiB +
1024
+ 4 KiB, clamped to [64 KiB, 1 GiB]): the largest ring entry (header,
1025
+ RecordAttr and frame). Each writer's arena grows to at least 4 ×
1026
+ (maxEntryBytes + 4 KiB), so the frames half holds two entries. Frames
1027
+ larger than a segment (A27 jumbo segments) are still not built.
1028
+
1029
+ Tests: `type_migrated_gseq_preserved_fallback_and_stats` (kept, fallback
1030
+ below `gseq_hi`, REPEAT copy, ordinary allocation continues above, flags on
1031
+ disk, reopen), `type_migrated_gseq_one_commit_sorted_and_crash_reopen` (two
1032
+ partitions' interleaved gseqs in one type commit come out sorted; a crash
1033
+ before and after the type commit gives the same arrivals),
1034
+ `writer_max_entry_bytes_configurable` (a 9 MiB frame refused by default,
1035
+ stored and read back at 10 MiB), `writer_capi_tlv19_max_entry_and_stats`
1036
+ (tag 19 through `flatsql_ps_init`, the 26-entry stats array).
1037
+
1038
+ ### 31.1 Tag conditions match any live tag instance (A2)
1039
+
1040
+ `_provider`, `_batch`, `_peer_id`, `_source` and `_source_name` conditions
1041
+ have the legacy tag table's ANY-row semantics (`indexedRecordWindowSQL`: a
1042
+ CID matches when one of its tag rows satisfies every requested condition).
1043
+ In format 2 a record's tag instances are its PUT's own tag and its RETAG rows
1044
+ (A2). As T2 built it, the vtab scanned tag postings (which name every
1045
+ instance, RETAGs included) but left each constraint for SQLite to re-check
1046
+ against the projected column, which shows only the PUT's own tag, so every
1047
+ record matched through a RETAG was dropped (T6 #7 on a migrated copy: a
1048
+ provider window of 200 read as 100; a source + batch window lost the
1049
+ re-tagged records).
1050
+
1051
+ Now `xBestIndex` consumes every tag condition (`omit = 1`) and the vtab
1052
+ evaluates them all on one instance:
1053
+
1054
+ - A tag or source posting scan (the plan's key) checks the remaining
1055
+ conditions on the instance the posting names (its RecordAttr), then emits
1056
+ the instance's record. A single condition that is the scanned key needs no
1057
+ attribute read unless the key was capped (over 512 bytes).
1058
+ - Any other plan (CID, column index, epoch, arrivals order, full scan)
1059
+ keeps a record when one of its live tag instances matches every condition
1060
+ (`RowFilter::hasLiveTag`, the TAG_OF postings), as the alias table's source
1061
+ filter already did.
1062
+ - An untagged record matches no tag condition (as the index-driven plans
1063
+ already behaved, T2 deviation 8), and conditions that contradict each other
1064
+ (two sources) return no rows.
1065
+ - `_source = '<TYPE>@<name>'` is compared as the column projects it: the type
1066
+ prefix is case-sensitive (a lower-case prefix returned no rows before
1067
+ either, after SQLite's re-check).
1068
+ - `_peer_id` conditions match the tag's producer peer (the TAG_PEER postings
1069
+ the T2 plan scanned); the `_peer_id` column still projects the storing
1070
+ call's `RecordAttr.peer_id`. Before, SQLite compared that BLOB column with
1071
+ the argument, so a text argument never matched.
1072
+ - `<TYPE>_current` groups first and filters after, as SQL says: its tag
1073
+ conditions stay SQLite's (the current version's own tag).
1074
+ - Projected columns are unchanged: they show the PUT's own tag.
1075
+
1076
+ Test: `lane_tag_conditions_match_any_instance_A2` (two partitions, PUT tags,
1077
+ RETAGs and REPEAT copies; eleven condition sets at type and partition level
1078
+ against an ANY-instance oracle, the full `_source` form, the alias, an
1079
+ epoch-ordered window, column and CID plans). With `omit = 0` restored it
1080
+ fails 18 checks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flatsql",
3
- "version": "3.0.0",
3
+ "version": "3.2.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": 2139350,
6
6
  "files": {
7
7
  "flatsql-ps-threads.wasm": {
8
- "sha256": "572a4bc38345d07ff91c345fc8d81e28873d72efa0fb8864a2edc5242145f9f5",
9
- "sri": "sha384-ewHpK0cJ1AAFg2xqLIWtGWyAE9W0A+4Vq6THCJ2WJpPcKxHqabdf7bbxt9Ngf7oZ",
10
- "size": 2074707
8
+ "sha256": "a90d9488187e527bccd3f02ce3f68be6443690bc1a2ee6c6786c64c907376e36",
9
+ "sri": "sha384-zzvS8qbCe33AJCklxaZX4xr1+QajJAaz0G0FuBv9WyEXh2rSKAz9+Rq4o2XksFaF",
10
+ "size": 2261085
11
11
  },
12
12
  "flatsql-wasi-noeh.wasm": {
13
13
  "sha256": "fd744d4c502310669bc74ab555a36de1692030a0fa6e22fdb1df744809eeacbe",