#Scope
This page specifies store format 5 of FlatSQL's partition store: a feed's bytes on disk, what an acknowledgment promises, how a feed is rebuilt from its own stream, and how a feed maps to IPLD (per-record CIDs, the feed root DAG and CARv2 backups). The node's side of IPFS (filestore registration, providing and pin policy) is summarized in §11.
#0Principles
- The stream is the only authority. Each feed is one linear stream of
[u32 LE size][FlatBuffer]frames, appended in order. Everything needed to rebuild the feed is in those frames: the records, their metadata and the checkpointed state. Everything else is a cache derived from the stream: the SQLite index, the type index, the views, the IPLD link nodes and the CARs. Any of it can be deleted and rebuilt. - Records are verbatim. A record frame holds exactly the bytes that arrived. No envelope, header or trailer is ever put around a record. Metadata goes in sibling frames.
- One record is one contiguous byte range. The raw-leaf CIDv1 (sha2-256) of that range is the record's IPFS block. Ranges are never changed while a filestore entry can name them.
- Every frame is a standard FlatBuffer. Each frame is identified by its 4-byte file identifier. The non-record frames are SDS tables (§3). A stock FlatBuffers reader walks the file from byte 0 and dispatches on the identifier. It needs no FlatSQL code.
- One format. A store in an earlier format is refused with
P4_E_FORMAT, and a node rebuilds it by re-syncing its feeds. - Each value lives in exactly one place. A value that can be re-derived from the record bytes is not stored again: the epoch and the object key are re-extracted on rebuild.
$FMBholds only what the bytes cannot give. Between compactions, checkpoint segments are derived snapshots that can be discarded: the authoritative data is the records plus their$FMBframes. A compaction drops the$FMBs of dead rows. After that, the state those rows left behind (instance and token totals and times, dictionary ids) lives only in the new generation's segments, which are then authoritative for it.
#1Files
<data>/fsql4/
STORE 64 B, magic FSQ4, format 5 (any other format: P4_E_FORMAT)
T/<TYPE>.spec registered spec TLV
T/<TYPE>.idx type index: CACHE (feed and token registries, rebuilt from the streams)
T/<TYPE>.fts full text: CACHE
P/<TYPE>/<feed>[.<gen>].fsdata THE FEED: the linear frame stream (§2)
P/<TYPE>/<feed>.db SQLite index: CACHE (optional per workload, §7)- Only the
.fsdatastream is durable state. The SQLite index, the type index and the full-text index are caches. - A feed's instances, token counts, ingest identities, moves and counters are derived state, folded from the stream.
- A feed has one stream file per generation. Compaction writes generation
g+1and retiresg(§10).
#2The stream
#2.1Frame bytes
frame at offset F:
F+0 u32 LE N size of the FlatBuffer; no flag bits, N >= 8
F+4 N bytes FlatBuffer [u32 root uoffset][4-byte file identifier][...]
next frame at F + 4 + N; the file ends exactly at a frame end.F; its range, the bytes a CID names, is [F+4, F+4+N). The next frame starts at F+4+N.- There is no file header, magic, padding, CRC or trailer.
- A frame's offset is
F, the offset of its size prefix. - Its range is
[F+4, F+4+N), the FlatBuffer bytes only. - The identifier at
F+8..F+12(bytes 4..8 of the FlatBuffer) names the kind:
| Kind | Identifier | Written when | Holds |
|---|---|---|---|
| record | the type's ($OMM, $WXF, ...) or a sealed envelope | a new record or new bytes enter the feed | the record bytes exactly as they arrived |
| metadata | $FMB | once per feed per commit round, after that round's record frames; also alone for removals | the round's rows, dictionary additions and removals (§3.1) |
| checkpoint | $FCK | at stream creation (genesis), then on the cadence in §6 | the feed's identity, counters and live segment list (§3.2) |
| segment | $FSG | at checkpoints and in LSM merges | an immutable sorted state segment (§3.3) |
- Reserved identifiers. A type cannot be registered with the identifier
FMB,FCKorFSG. A record of another type is refused if it carries one of them (P4_REJ_FID), so no record frame can pose as a metadata frame. - The commit chain. Every non-record frame carries
SELF_OFFSET(its ownF) andPREVIOUS(theFof the non-record frame before it). Genesis hasPREVIOUS = 2^64-1. Recovery walks this chain (§9). It never classifies a frame by its identifier alone. - Alignment. Frames are back to back, so a FlatBuffer may start at 4 mod 8.
- Record frames are read in place.
- Metadata, checkpoint and segment frames are read only at open, recovery, rebuild and DAG or CAR export. They are copied into an 8-aligned buffer before verification. No padding frames are written.
#2.2Record frames
- Bytes.
Nbytes, verbatim. A sealed record's frame is its sealed envelope (SDF1/SDFN). - The record CID. The record CID is SDN's key and the stored CID. For an unsealed record it is
CIDv1(raw 0x55, sha2-256(range)): textbafkrei..., binary01 55 12 20 || digest.- The ingest check
kVerifyCid(on by default) computes this digest and refuses a mismatch (P4_REJ_CID). So the stored CID is the range's raw-leaf CID, with no extra hashing. - A type configured without
kVerifyCidhas the digest computed at write. A store never holds a record CID that differs from its range, except for sealed records.
- The ingest check
- Sealed records. The record CID is the plaintext's (SDN's key). The leaf CID is the ciphertext range's sha2-256. The metadata frame carries both (§3.1), and IPFS sees only the leaf CID. Encrypted feeds are addressed by ciphertext.
- Records over 1 MiB (
N > 1,048,576) become a UnixFS file DAG (§4.2). The record CID stays the raw sha2-256 of the whole range (SDN's key). IPFS sees the DAG root and its 1 MiB raw leaves. - Shared frames. A record's copies and instances share one frame when the bytes are equal. A row that points at an earlier frame adds no record frame.
#2.3What a stock reader sees
- A loop from byte 0 reads
N, slices[F+4, F+4+N), reads the identifier and verifies with that root type. - The SDS readers for
$FMB,$FCKand$FSG(§3) decode the metadata. - To list a feed's live records with no FlatSQL code, follow the newest checkpoint's segments, or replay the metadata frames (§9).
#3Metadata, checkpoint and segment tables
The three non-record kinds are SDS standards in the FlatSQL family, beside FSB and FSO: FMB (batch metadata), FCK (checkpoint) and FSG (segment), as of SDS 1.256.0. The schemas below are those standards. No earlier SDS table fits these roles:
- FSB is opaque by contract: a reader must not interpret its data.
- PNM announces a single object.
- DPM is a signed publication manifest, with no per-record offset, sequence or dictionary.
- REC wraps a record, which principle 2 forbids.
Conventions:
- Fixed-width per-row data is in structs (zero-copy vectors).
- Digests are
[ubyte]vectors with a 32-byte stride, parallel to the struct vector. Fixed arrays in structs are avoided because some SDS languages lack them. - Ids are feed-scoped, except
TOKENids, which are type-scoped. 0means "none" for every*_ID.EPOCH = INT64_MINmeans no epoch.- Offsets are u64 and local to the stream. They are never trusted across copies (§8).
#3.1$FMB: batch metadata (one per feed per commit round)
// FMB: FlatSQL batch metadata. Sibling of the record frames it commits; never holds record bytes.
enum fmbDictKind : ubyte { TOKEN, NODE, BATCH, CONTENT_KEY, URL, OBJECT_KEY }
enum fmbRemoveReason : ubyte { DELETE, SUPERSEDE, CAT_SUPERSEDE, QUOTA, RETENTION, MOVED_OUT }
/// One row = one delivery of one record to this feed (rid = SEQ << 16 | ROW_J). 40 bytes.
/// No epoch or object key: both are re-extracted from the record bytes (principle 6).
struct FMBRow {
OFFSET:ulong; // F of the record frame (this round's, or an earlier shared frame)
SEQ:ulong; // the record's seq in the type's seq space
SOURCE_TS:long; // the copy's source timestamp: a PUT field (tag 53 entry), not in the bytes
LENGTH:uint; // N of the record frame
GROUP:ushort; // index into GROUPS
ROW_J:ushort; // j of the rid
FLAGS:uint; // bit0 NEW_FRAME (frame written this round), bit1 SEALED, bit2 LARGE,
// bit3 MOVED_IN (local -> feed, seq kept), bit4 COMPACTED (re-emitted by a compaction),
// bit5 COPY, bit6 RETAG, others 0
RESERVED:uint; // 0
}
/// What a call's rows share. 24 bytes.
struct FMBGroup {
DELIVERED_AT:long; // when this feed delivered the rows
NODE_ID:uint; // NODE (producer, peer)
BATCH_ID:uint; // BATCH (batch, producer peer, producer key)
CONTENT_KEY_ID:uint;
URL_ID:uint;
}
struct FMBRemove { SEQ:ulong; ROW_J:uint; REASON:fmbRemoveReason; } // ROW_J 0xFFFFFFFF: every row of SEQ
struct FMBUrl { SEQ:ulong; ROW_J:uint; URL_ID:uint; } // a duplicate delivery moving a row's url
table FMBEntry { KIND:fmbDictKind; ID:uint; TEXT:[string]; } // TOKEN [token, peer]; NODE [producer, peer];
// BATCH [batch, ppeer, pkey]; others [value]
table FMBSignature { ROW:uint; BYTES:[ubyte]; } // the row's signature
table FMBSealed { ROW:uint; EPOCH:long = -9223372036854775808; // ciphertext cannot be re-extracted, so a sealed
OBJECT_KEY_ID:uint; LEAF_DIGEST:[ubyte]; // row's epoch and key live here only; LEAF_DIGEST:
COLUMNS:[ubyte]; } // ciphertext sha2-256; COLUMNS: its extracted values
table FMBIdentity { ROW:uint; HASH:[ubyte]; } // IQC ingest identity (32 B)
table FMBLarge { ROW:uint; ROOT_CID:[ubyte]; ROOT_NODE:[ubyte]; LEAF_DIGESTS:[ubyte]; } // §4.2
table FMB {
SELF_OFFSET:ulong;
PREVIOUS:ulong; // F of the previous non-record frame
FEED_ID:uint;
COMMITTED_AT:long; // the round's clock (restamps instances on removals)
TYPE_SEQ_HIGH:ulong; // the type's highest seq issued so far (seqs never go back)
ROWS:[FMBRow]; // in plan order; at most 65,536 per frame (a round splits beyond)
DIGESTS:[ubyte]; // 32 B per NEW_FRAME row, in row order: the record CID's digest
// (a shared-frame row takes the digest of the frame it names)
GROUPS:[FMBGroup];
DICTIONARY:[FMBEntry]; // entries first used by this round (deltas)
REMOVALS:[FMBRemove];
URLS:[FMBUrl];
SIGNATURES:[FMBSignature];
SEALED:[FMBSealed];
IDENTITIES:[FMBIdentity];
LARGE:[FMBLarge];
}
root_type FMB;
file_identifier "$FMB";What the vectors hold:
ROWSandGROUPShold every column of a row.DICTIONARYholds the node, batch, content-key, url and token strings that the round uses first.OBJECT_KEYentries are written only for sealed rows; their oneTEXTis the key typed as the extractor gives it:i+ decimal for an integer key,s+ the text for a string key. Other rows' object keys come from their bytes, with ids given in order of first appearance in the stream, so a rebuild gives the same ids.- Why the digest stays. It is the commit's integrity check (§9) and the record's key. Without it, recovery could not tell a torn record frame from a good one, and a rebuild would have to re-hash every byte.
IDENTITIESholds the rows' ingest identities.- A move is
MOVED_INrows in the feed plus aMOVED_OUTremoval in the local feed.
The instances, the token counts and the counters are folds over the rows.
Every change is an appended frame; nothing is changed in place:
- DELETE, SUPERSEDE, CAT supersede, QUOTA and retention. An
$FMBwithREMOVALSand no rows. - A duplicate delivery's url.
URLS. - A move. The feed's
$FMBwithMOVED_INrows first, then the local feed's$FMBwithMOVED_OUT. Recovery finishes a move that has only the first half. - A copy, retag or new instance. Rows that point at the existing frame.
#3.2$FCK: checkpoint (small; the state lives in segments)
enum fckSegmentKind : ubyte { // the FSG kinds, §3.3
DICTIONARY, LATEST, INSTANCES, TOKENS, TOMBSTONES,
ROWS_BY_SEQ, ROWS_BY_OBJECT_EPOCH, ROWS_BY_CID, ROWS_BY_W, ROWS_BY_AT, // the row orders, §7
COLUMN = 64, ZONE_MAP = 65, ROLLUP = 66 // reserved: analytics, 64..127
}
struct FCKCounters { // the feed's counters
ROWS:ulong; RECORDS:ulong; BYTES:ulong; RECORDS_NO_EPOCH:ulong;
COPIES:ulong; COPY_BYTES:ulong; LIVE_FRAME_BYTES:ulong;
MIN_SEQ:ulong; MAX_SEQ:ulong; MIN_EPOCH:long; MAX_EPOCH:long;
MIN_TS:long; MAX_TS:long; MIN_AT:long; MAX_AT:long;
}
struct FCKSegment { // one live FSG frame. 48 bytes
OFFSET:ulong; LENGTH:uint; KIND:fckSegmentKind; LEVEL:ubyte; RESERVED:ushort;
MIN_SEQ:ulong; MAX_SEQ:ulong; COVERS_THROUGH:ulong; ENTRIES:ulong;
}
table FCK {
SELF_OFFSET:ulong;
PREVIOUS:ulong;
FEED_ID:uint;
TYPE:string; PROVIDER:string; SOURCE:string; // the feed's identity: the type index is rebuilt from these
GENERATION:uint;
COMMITTED_AT:long;
APPLIED_THROUGH:ulong; // end (F+4+N) of the last FMB folded into the segments
TYPE_SEQ_HIGH:ulong;
COUNTERS:FCKCounters;
SEGMENTS:[FCKSegment];
SEGMENT_DIGESTS:[ubyte]; // 32 B per segment: its raw-leaf CID digest
COMMITS_ROOT_CID:[ubyte]; // the commits tree (§4.3) as of APPLIED_THROUGH
PREVIOUS_FEED_ROOT_CID:[ubyte];
RECORD_BYTES:ulong; // each record's smallest copy, summed; 0 when COUNTERS.ROWS is 0
MIN_W:long; MAX_W:long; // w bounds over the rows; 0 when COUNTERS.ROWS is 0 (read as none)
}
root_type FCK;
file_identifier "$FCK";- Genesis. A new stream's first frame is an
$FCKwith zero counters and no segments. A stream names its own feed, and the type index is a cache. - Latest per object. Kept in the
LATESTsegments, not in the$FCKitself, so the checkpoint stays small. Each segment is sorted by object id, and the newest entry wins across segments.
#3.3$FSG: immutable state segment
struct FSGPair { // the one sorted entry shape. 32 bytes
KEY_HI:ulong; KEY_LO:ulong;
RID:ulong; // SEQ << 16 | ROW_J
ROW_REF:ulong; // (F of the FMB holding the row) << 16 | row index in it
}
struct FSGLatest { OBJECT_KEY_ID:uint; RESERVED:uint; EPOCH:long; RID:ulong; ROW_REF:ulong; } // 32 B
struct FSGInstance { // one instance row. 88 B
BATCH_ID:uint; CONTENT_KEY_ID:uint; RECORDS:ulong; BYTES:ulong; MIN_W:long; MAX_W:long;
MIN_SEQ:ulong; MAX_SEQ:ulong; FIRST:long; UPDATED:long; MAX_AT:long; MAX_TS:long;
}
table FSG {
SELF_OFFSET:ulong;
PREVIOUS:ulong;
FEED_ID:uint;
KIND:fckSegmentKind; // which vector is filled
LEVEL:ubyte;
MIN_SEQ:ulong; MAX_SEQ:ulong;
COVERS_THROUGH:ulong;
REPLACES:[ulong]; // F of the segments an LSM merge replaced
ENTRIES:[FMBEntry]; // DICTIONARY, sorted by (KIND, ID)
LATEST:[FSGLatest]; // LATEST, sorted by OBJECT_KEY_ID
INSTANCES:[FSGInstance]; // INSTANCES, sorted by (BATCH_ID, CONTENT_KEY_ID)
TOKENS:[FMBEntry]; // TOKENS: per-producer counts as TEXT [token, n, bytes, mints, maxts, maxseq]
PAIRS:[FSGPair]; // TOMBSTONES (KEY_HI = rid) and ROWS_BY_*, sorted by (KEY_HI, KEY_LO, RID)
COLUMN:[ubyte]; // reserved (analytics)
INSTANCE_URL_IDS:[uint]; // parallel to INSTANCES, each instance's latest url (a URL dictionary id; 0 = none)
}
root_type FSG;
file_identifier "$FSG";How the row orders fill PAIRS. Here w is the record's epoch, or its source timestamp when it has no epoch; at is the delivery time; cp is the first 8 bytes of the CID key.
| Kind | KEY_HI | KEY_LO |
|---|---|---|
ROWS_BY_SEQ | rid | 0 |
ROWS_BY_OBJECT_EPOCH | object id | epoch XOR 2^63 |
ROWS_BY_CID | the 8-byte key prefix (cp) | the next 8 bytes |
ROWS_BY_W | w XOR 2^63 | 0 |
ROWS_BY_AT | at XOR 2^63 | 0 |
ROW_REFreaches every other column of the row in its$FMBwith one read.- The row-order kinds (
ROWS_BY_*) serve the segment index (§7). A store that keeps the SQLite index writes only the kinds throughTOMBSTONES.
#4IPFS mapping
#4.1Per-record blocks
- For a record of
N <= 1 MiB, the IPFS block is its range, with CIDCIDv1(raw, sha2-256). This equals the record CID (unsealed) or the leaf CID (sealed). - The node registers
(path of the generation file, F+4, N, CID)in Kubo's filestore (nocopy). The bytes stay where they are. - FlatSQL exposes these tuples through a read op. It answers RB1 rows
(gen path, offset, length, cid, kind, unit)for a feed and a commit range, and the node pulls them per acknowledged round (§5). - Non-record frames (
$FMB,$FCK,$FSG) are raw leaves the same way. The DAG links them in situ too.
#4.2Records over 1 MiB
- Leaves. Leaf
iis the range[F+4 + i*2^20, F+4 + min(N, (i+1)*2^20)), as a raw block. - Root. A dag-pb UnixFS
Filenode:- links unnamed,
Tsize= leaf size, in order; Data= UnixFS{Type File, filesize N, blocksizes};- balanced and one level deep (the write pool caps a frame below 8 MiB, so a record has at most 8 leaves; a level is added only past 174 leaves).
- links unnamed,
- The root CID equals what
ipfs add --cid-version=1 --raw-leaves --chunker=size-1048576gives for the same bytes. That is the interoperability check. FMBLargekeeps the root CID, the root node's bytes (about 410 B for 8 leaves) and the leaf digests. The DAG and the CAR need no re-hash, and the node putsROOT_NODEin its blockstore.- The whole-range raw CID (SDN's key) is never registered or provided: bitswap cannot move a block that large. The
ROWentry maps the two CIDs to each other.
#4.3The feed root DAG
All link nodes are dag-pb, CIDv1, sha2-256. FlatSQL computes them as a pure function of the frames: they are never FlatSQL disk state. The node keeps them in its blockstore when exposure is on, and the CAR export emits them.
feed root dag-pb UnixFS Directory, links sorted by name
├── "checkpoint" -> the newest $FCK frame raw leaf, in situ
├── "commits" -> commits tree dag-pb, no Data; fanout <= 1,024
│ └── ... -> commit unit (one per FMB, stream order) dag-pb, no Data, links in stream order:
│ NEW record frames (raw leaf | §4.2 root), then the $FMB frame (raw leaf)
├── "segments" -> node linking each live $FSG frame (raw leaf, in situ), in FCK.SEGMENTS order
└── "columns" -> reserved for analytics column segments (absent)- Liveness.
- A commit unit is linked while at least one row it added is live, or while it removes a row of a linked unit.
- Its record links are only the live new frames.
- A recursive pin of the feed root is the feed's live state, and nothing dead.
- When the root changes.
- The root is recomputed at each checkpoint (§6): the new units, the commits tree's right spine (O(depth) nodes), the segments node and the root.
- Retention or a delete rewrites the touched units and their spine.
- Compaction rewrites the whole tree.
- Pulling a change. Every node of the DAG carries the commit that last rewrote it, and no block below a node is newer than the node.
RANGESfrom a commit therefore walks only the nodes rewritten at or after it: the new units, the rewritten units and their spine.FEED_ROOTcomputes only the root. Neither walks the feed (§12 has the cost). - One forward cache. The derived DAG is built forward only. A commits root asked for an earlier commit point is the one recorded when a build stopped there (the last 4,096 points are kept); the feed is never replayed for it.
- What
PREVIOUS_FEED_ROOT_CIDgives. Inside the$FCKit is just bytes: a raw leaf has no links. History is reachable on purpose but never pinned by the current root. - Announcing. Only feed roots (and, if the node chooses, segment roots) are announced. Records are fetched by CID over bitswap from root holders.
#5Acknowledgment and durability
Rule. For one call, the writer:
- appends its record frames and then its feed's
$FMBto each touched stream; - syncs each touched stream once (in parallel when several);
- commits the SQLite index without a device sync (WAL,
synchronous=NORMAL), for read-after-write; - publishes and acknowledges.
What an acknowledgment promises. The record bytes and every column of their rows are durable in the stream. The index may be behind after a power loss, and recovery (§9) restores it from the $FMB frames.
Sync cost. A call pays one sync per touched stream. Syncing the index as well would make it two, so a single-feed call drops from 2 syncs to 1. A call that also creates a feed pays once for the feed: the new file, its genesis $FCK and a directory sync. A new token adds no sync, because it lives in the $FMB dictionary.
SQLite never syncs on the acknowledgment path. The index files open through a VFS shim (flatsql_p4_cache) whose xSync is a no-op outside WAL checkpoints and connection close, so a WAL restart's header sync is skipped too. A power loss can therefore drop index commits, but never acknowledged data. Checkpoints keep SQLite's own sync order.
Split rounds. A round whose feed rows exceed 65,536 writes several $FMBs. The acknowledgment follows the sync of all of them. Recovery may apply a durable prefix of an unacknowledged round; such records were never acknowledged and are kept, as any durable but unacknowledged write is.
Order of persistence. The index commit is written only after its stream sync returned. A persisted index commit therefore never names bytes the stream lost. The index's mark is the end of the last $FMB it applied.
Background work keeps its own syncs off the acknowledgment path:
- WAL checkpoints (they sync);
$FCKand$FSGpreparation (a background thread, §6) and frames (synced with the next round, or alone when idle);- merges of staged rows.
Measured: a checkpoint preparing does not slow the acknowledgment. PUT acknowledgment latency, p50/p99:
| p50 | p99 | |
|---|---|---|
| No checkpoint preparing | 8.80 ms | 13.95 ms |
| A checkpoint preparing | 9.04 ms | 10.44 ms |
Installing the prepared checkpoint takes about 73 ms, once, on the round where the appender installs it.
Basis: FlatSQL 3.10.0's replay gate (REPLAY_CHECKPOINT_PREPARE, native):
- the gate fixture, a 1 GB WXF sea feed and 3 seed days of OMM (182,508 records);
- 32 PUTs timed with no checkpoint preparing, and 32 rounds of 256 PUTs timed while one prepares;
- one run on a 28-core Apple M-series Mac Studio, with a 1-minute load average of 23.7 (peak 30.4) from other work.
Registration follows the acknowledgment:
- The node pulls
(range, CID)tuples only for acknowledged rounds, so a filestore entry never names unacknowledged bytes. - The node syncs nothing extra. A crash that loses registrations is repaired by re-pulling from the node's last registered commit.
#6Checkpoints and segments
Cadence. A checkpoint is written when the first of these holds:
max(64 MiB, 4 x live segment bytes)of frames have been appended since the last one;- 65,536 rows have been appended since the last one;
- the feed has had commits but has been idle for 30 s;
- at close, at the end of a compaction, or before an export.
Writing one.
- Fold the rows since
APPLIED_THROUGHinto new level-0 segments, one per changed kind: dictionary entries, latest-per-object, instances, tokens, tombstones, and the row orders where enabled (§7). - Append the segments, then the
$FCKthat lists every live segment.
Off the PUT path.
- Preparation (step 1) runs on a background thread. It folds a due feed from one SQLite read snapshot and the stream prefix that snapshot names.
- The feed's appender installs the prepared plan as a short step at its next round boundary (or when idle), appending the segments and the
$FCK. No PUT waits for a fold. A plan whose base changed is dropped, and the feed is prepared again while it stays due. APPLIED_THROUGHis the prepare point: the end of the last$FMBin that snapshot. Rounds acknowledged while the plan was prepared sit betweenAPPLIED_THROUGHand the$FCK.- Open and recovery fold every
$FMBafterAPPLIED_THROUGH, including those before the$FCK.
LSM.
- When a kind has 4 segments at a level, they merge into one at the next level (newest wins; tombstones drop the rows they name).
REPLACESnames the inputs, which become dead frames.- Row-order segments merge only with neighbors in seq order, and a merge's output stays at most 1/8 of the feed's live rows. Retention by arrival therefore drops whole segments.
Open. Open reads the newest $FCK, its segments and the tail $FMBs after APPLIED_THROUGH. No SQLite file is needed for the counters, dictionary, instances, tokens or latest-per-object.
#7Index choice
- The SQLite index (
<feed>.db) is the default. It is a cache rebuilt from the$FMBchain (§9). FSGrow-order segments replace it for a workload only when every replay workload on the 15 GB fixture runs within about 10% of SQLite, native and AOT.- Reads go through the same reader and virtual-table interface over "segments + tail", where the tail is the in-memory view of the rows after the newest checkpoint.
#8Backups and archives: CARv2
A backup of a feed is the CARv2 of its feed root. A store or type backup is the CAR of a dag-pb UnixFS Directory <TYPE>/<feed>, whose links are the feed roots.
Bytes.
- Pragma
0a a1 67 76 65 72 73 69 6f 6e 02(11 B). - CARv2 header (40 B): characteristics 0, data offset 51, data size, index offset.
- CARv1 payload:
- the dag-cbor header
{roots: [root], version: 1}; - sections
varint(len(cid)+len(data)) | cid | datain deterministic DFS pre-order from the root, each block once at its first occurrence; - in order: the root, "checkpoint", "commits" (interior node, unit node, its leaves in stream order; a large record's root, then its leaves), "segments" and its leaves.
- the dag-cbor header
MultihashIndexSortedindex (0x0401), as go-car writes it.
The same state always gives the same CAR bytes. There is no custom container.
Restore.
ipfs dag importworks as is.- FlatSQL restore walks the CAR in order. For each unit it appends the new record leaves as frames, then applies the unit's
$FMBthrough the writer in replay mode. Seqs, timestamps, delivery times, nodes, batches, urls, signatures and identities are kept. OnlyOFFSETis local, so it is remapped and written into a fresh$FMB. - This is the same apply code as crash recovery and rebuild (§9): one path.
- Pass condition: every replay answer hashes equal to the source's.
Backups are tables themselves.
- A restored backup is a feed and serves as a table.
- A backup set is a feed-root DAG of the same shape, so it can be pinned, announced, backed up and restored like any feed: recursive storage.
- The backup catalog is a feed of
$DPMrecords, one record per CAR. The node writes them, because a DPM is signed by its provider. The catalog feed is itself backed up the same way.DATASET_IDis<TYPE>/<provider>@<source>.UPDATE_IDis the feed root CID's text.PROVIDER_PEER_ID,PUBLISH_TIMESTAMPandPROVIDER_SIGNATUREcome from the node.- Each CAR is one
ASSETSentry,TRANSPORT_KIND = CONTENT_ADDRESS:CIDandDATA_ROOTare the feed root,BYTE_LENGTHis the CAR's size andFILE_NAMEis the CAR's name. - Two additive DPM fields, in the same SDS release as FMB, FCK and FSG (1.256.0), carry the rest:
publicationAssetKind.CAR_ARCHIVE;DPMAsset.THROUGH_SEQ:ulong, the highest seq the archive covers (its as-of point).
#9Crash recovery from the stream tail
The commit point. A commit point is a verified non-record frame on the commit chain. An $FMB verifies when all of these hold:
- the FlatBuffers Verifier passes on its aligned copy;
SELF_OFFSET= itsF;PREVIOUS= the last commit point'sF;- every
NEW_FRAMErow names a whole frame between the previous commit point and it; - each such frame's sha2-256 equals the row's digest (or
LEAF_DIGEST, sealed).
An $FCK or $FSG verifies on the first three. The digest check makes recovery safe even when a crash persisted later pages before earlier ones.
Open, per feed:
- Start.
- With an index: start at its
mark. A stream shorter than its mark lost acknowledged frames, and the feed is quarantined (P4_E_CORRUPT, named). - Without one: find the newest
$FCKby the tail search below and load its segments.
- With an index: start at its
- Walk forward from the start, collecting record frames until a candidate commit point. If the candidate verifies, apply it with its seqs and metadata. Rows get no fresh seqs, and provenance is never blank.
- Cut the tail. At EOF, or at a torn or unverifiable frame, cut the stream back to the end of the last commit point. Those bytes were never acknowledged and never registered.
- Finish moves. A
MOVED_INrow in a feed whoseMOVED_OUTis missing in the local feed gets its removal$FMBappended to the local feed. - Rebuild caches as needed:
- the views from the index's staged rows;
- a missing or damaged
.dbfrom the$FMBchain; - a missing
.idxfrom the genesis frames.
Tail search (no index).
- Read the stream backward in 1 MiB chunks.
- A candidate is a position
pwith"$FCK"atp, givingF = p - 8. - Accept it only if all of these hold: it verifies,
SELF_OFFSET = F, itsPREVIOUSnames a frame that ends exactly atFand verifies as a commit point, and the forward walk from it reaches EOF or a torn tail. - The checkpoint cadence bounds the search to
max(64 MiB, 4 x live segment bytes)plus one round. On any doubt, the search falls back to a forward walk of the size prefixes, which is a sequential read of the file.
Rebuilding the SQLite index without its file.
- Walk the stream forward in one sequential read. Apply each
$FMBin one bulk transaction, with the secondary indexes built after. - The epoch and object key of each unsealed row are re-extracted from its record frame, which is already in the read buffer (principle 6).
- Nothing is re-hashed except the tail after the newest checkpoint.
- Open without the SQLite index needs none of this: the newest
$FCKand its segments already hold the epoch and object orders.
#10Retention and compaction (pins and filestore first)
Retention, QUOTA_GC, DELETE, SUPERSEDE:
- Append the removal
$FMBand sync. The decision is now durable. - Remove the index rows. Reads stop answering them.
- Publish the drop list (CID, unit) and the next feed root at the next checkpoint (or at once when the feed is exposed).
- The node does
pin update old-root -> new-root, removes the dropped CIDs' filestore entries (reference-counted across feeds, §11) and callsp4 release(feed, through-commit). - Only released bytes may physically go (step 2 of a compaction). A feed that is not exposed is released implicitly.
The stream is append-only, so no byte under a live filestore entry is ever changed. Retention by arrival drops whole FSG segments (§6).
Compaction (a stream more than half dead, with at least 1 MiB dead):
- Write generation
g+1: the genesis$FCK, then the live record frames in stream order in chunks of at most 65,536 rows, each chunk followed by its consolidated$FMB(rowsCOMPACTED, new offsets), then fresh segments and the final$FCK. Then sync. - Repoint the index (one transaction) and publish the moved ranges and the new root.
- The node re-registers the same CIDs at their new
(path, offset)and pin-updates to the new root. Record CIDs do not change, because records are verbatim. Then it releases generationg:release(feed, g)releases all of the immutable generationgwhatever through-commit it names, andrelease(feed, through)ong+1releasesgoncethroughreaches the compaction's final$FCK. - Unlink
gafter the release and after the 30 s reader window.
A node that never releases blocks compaction, and the store reports it as a stat. It never loses data.
#11The node's side
The node, not FlatSQL, runs the IPFS side of a feed:
- Filestore. Kubo filestore (nocopy) in the in-process Kubo, with the store directory inside the filestore root. Registration goes through
filestore.FileManager.PutwithPosInfo{FullPath, Offset}.ipfs addis not used: it re-chunks. - Registration per batch. The node pulls the acknowledged rounds' tuples, puts them in the filestore in one datastore batch, and keeps a per-feed "registered through" commit.
- Reference counts. Filestore entries are keyed by multihash. A record held by two feeds has one entry: the node counts holders, and on a drop re-points the entry to a remaining holder, never deleting it.
- Roots only. The node announces feed roots (and optionally segment roots). It never provides record CIDs.
- Per-feed policy, off by default. Restricted feeds (UDL, and anything not on
ipfs_schemas) are never registered, provided or served. Encrypted feeds are addressed by ciphertext. - Unpin on retention and compaction, per §10, then
release. - Backup catalog. The node writes one signed
$DPMper CAR into the catalog feed (§8).
#12Costs and measurements
M = measured on a Mac Studio, native. E = estimated. The gate fixture holds two feeds: an OMM feed of 623,462 rows (a 204.7 MB stream of 328 B records) and a WXF sea feed of 1,720,179 rows (15.0 GB of 8.7 KB records).
| Property | How | Cost |
|---|---|---|
The disk holds a linear [u32 size][FlatBuffer] stream, appended in order, that any FlatBuffers reader opens | §2: one stream per feed generation; four frame kinds told apart by identifier; no header, padding or trailer | metadata frames are copied to an aligned buffer when read (open and rebuild only): negligible |
| A table rebuilds from FlatBuffers alone: sibling per-record metadata frames, never an envelope, plus periodic checkpoints | §3.1 $FMB per feed per round (every row column, the dictionaries, removals); §3.2–3.3 $FCK and $FSG; SQLite, the type index and the views are caches | $FMB = 72 B/row (40 B struct + 32 B digest) + 24 B/call + dictionary deltas. Gate fixture (M sizes): OMM 623,462 rows → 44.9 MB = +22% on a 204.7 MB stream (328 B records); WXF sea 1,720,179 rows → 123.9 MB = +0.8% on 15.0 GB (8.7 KB records), plus about 11 MB (E) for its 191,131 batch strings. Checkpoint segments: at most 25% of appended bytes by the cadence rule, typically about 1–2% (E); LSM write amplification about log4(segments) (E) |
| Records are pinned in situ: each record one contiguous range, raw-leaf CIDv1 (sha2-256) = record CID, Kubo filestore nocopy | §2.2, §4.1: the stored CID is already the range's sha2-256 (kVerifyCid, on by default); the node registers (path, off, len, CID) | write path: 0 extra hashing (the digest exists); 72 B/row in $FMB (above). Kubo side (E): about 150–200 B per filestore entry in its datastore, about 50–60% of OMM's stream bytes and about 2% of WXF's, so exposure is opt-in per feed |
| Records over 1 MiB are a DAG of raw-leaf sub-ranges | §4.2: 1 MiB leaves; UnixFS root byte-equal to ipfs add --raw-leaves --chunker=size-1048576 | about 410 B root node + 32 B per leaf in FMBLarge; no fixture record exceeds 1 MiB (M: max 8,720 B) |
| Backups and archives are IPLD CARv2 (record blocks, index segments, checkpoint root DAG), and a backup is itself a table | §8: deterministic CARv2 of the feed root; restore uses the same apply path as recovery; a restored CAR is a feed | CAR overhead (E): about 40 B per block section + 40 B per index entry: OMM about 1.25x its live stream, WXF about 1.01x. dag-pb links about 44 B per record (in the node's blockstore, not FlatSQL's) |
| Roots are announced, not records; IPFS is per feed and off by default; restricted feeds are never registered or served | §4.3, §11: FlatSQL computes roots and range tuples for every feed (cheap, and needed for backups); registration, providing and serving are the node's per-feed policy | DHT load: one provider record per feed root per checkpoint, instead of one per record |
| FlatBuffer index segments may replace SQLite per workload, within about 10% | §3.3, §6, §7: FSGPair sorted vectors, LSM, whole-segment retention, behind the same reader interface | 32 B per row per order (5 orders = 160 B/row), against SQLite's 193 B/row for OMM (M: 120.7 MB / 623,462) and 180 B/row for WXF (M: 309.8 MB / 1,720,179) |
| Room for analytics column segments | fckSegmentKind 64–127 and FSG.COLUMN reserved; root link "columns" reserved | none |
| One format | earlier formats are refused (P4_E_FORMAT); nodes rebuild by re-sync | one re-sync per node |
| Acknowledgment and durability | §5: one stream sync per touched stream; index commit unsynced (WAL NORMAL) | M: 4.79 ms per sync (F_FULLFSYNC; gate: 16 syncs = 76.66 ms). Gate wxf_lead 746 ms with 128 syncs (613 ms) → 64 syncs, about −307 ms (−41%); gp_ingest 476 ms with 16 → 8, about −38 ms (−8%) (E from M) |
| Crash recovery from the tail | §9: commit chain + digest-checked $FMBs; tail search within about 64 MiB | open without the index (E): newest $FCK + segments + tail of at most 64 MiB, about 1 s. Rebuilding the 15 GB fixture's SQLite index from the stream (E): one sequential read of 15.2 GB, about 1 µs/row of re-extraction and a bulk insert of 2.34M rows, about 15–30 s |
| Reads through IPFS or direct | not a format change | (E) direct warm pread 5–20 µs/record; through the local filestore +30–100 µs/block (datastore lookup, pread, Kubo's re-hash); remote = network round trip + that |
| The node pulls per acknowledged round, so a pull costs what changed, not the feed | §4.3: RANGES from a commit walks only the nodes rewritten since; FEED_ROOT computes only the root; an earlier commits root comes from the forward cache | M (the gate's WXF sea feed, 110,915 blocks, against a 92-block feed of the same lane shape; medians of 7 rounds): FEED_ROOT 0.109 / 0.064 ms; RANGES from the prior commit 0.089 / 0.038 ms (14 blocks); earlier commits root 4 / 3 µs. The whole-closure RANGES on the sea feed costs 68.6 ms |
#Measured in the implementation
Measured as each part of the format was built (FlatSQL 3.8.0 to 3.10.1): the replay gate's native medians against store format 4 (FlatSQL 3.7.2) as the baseline, and the 15 GB replay fixture.
| Property | Measured |
|---|---|
| One stream sync per acknowledgment | wxf_lead 799 → 398 ms; gp_ingest 531 → 478 ms |
$FMB size | 76 B per row |
| Crash recovery | kill -9 in the middle of a lead (3 runs) and rebuilds with every .db deleted answer exactly |
| Open and rebuild with every index file deleted (15 GB fixture) | checkpoint open 2.6 s; row-index rebuild 18.5 s; every answer equal |
| Checkpoint segments | 4.8% of an OMM stream (328 B records) and 0.7% of a WXF stream; checkpoints under 0.01% |
| Replay with checkpoints and segments | wxf_lead 826 → 431 ms; explorer warm 87.8 → 9.4 ms; sync_window warm 142 → 78 ms; latest warm 112 → 87 ms |
| Feed root DAG resident state | 88.9 B per row |
| Records over 1 MiB | root CIDs byte-equal to Kubo's ipfs add --cid-version=1 --raw-leaves --chunker=size-1048576 |
| CARv2 export | ipfs dag import takes the CAR byte for byte |
| A compacted feed with its index deleted | rebuilds exactly, including urls that only dead rows wrote |
| Acknowledgment while a checkpoint prepares | p50/p99 9.04/10.44 ms, against 8.80/13.95 ms with none; the install step about 73 ms on one round (§5) |
| Pulling one round from a 110,915-block feed | RANGES 0.089 ms and FEED_ROOT 0.109 ms; the whole-closure walk 68.6 ms (§12) |
Source: cpp/src/p4/FORMAT.md in the FlatSQL repository, commit d57ca6c92e. The schemas are the SDS standards FMB, FCK and FSG (SDS 1.256.0).