Convencions: els enters de capçalera són little-endian; les claus del B-tree fan servir una codificació memcomparable big-endian (de manera que l’ordre de bytes coincideix amb l’ordre de claus). L’extensió de fitxer recomanada és .arkeion, però no és obligatòria — el motor obre qualsevol camí, sense configuració.
Disposició global
Una base de dades és un sol fitxer de pàgines fixes de 4096 bytes. Només tres regions són especials; tota la resta s’afegeix al final i no se sobreescriu mai.
page 0 File header (immutable after creation)
page 1 Meta slot A (alternating rewrite, LMDB-style)
page 2 Meta slot B
page 3… Append-only zone: data pages and commit pages
Pàgina en disc — 4096 bytes
PAGE_SIZE = 4096 coincideix amb una pàgina d’SO i un sector lògic habituals, cosa que minimitza les escriptures esquinçades i alinea la unitat de xifratge. PageId = u64, i l’offset en bytes d’una pàgina és simplement id × 4096.
Cada pàgina té la mateixa disposició de tres bandes, xifrada o no — la reserva criptogràfica (28 B, 0.7%) es paga sempre, a canvi d’un B-tree completament agnòstic respecte del xifratge.
0..12 nonce encrypted: 96-bit GCM counter · plaintext: zeros
12..28 tag encrypted: GCM tag, AAD = page_id · plaintext: SHA-256(LE(page_id) ‖ body)[0..16]
28..4096 body encrypted: ciphertext of the body · plaintext: the body as-is
El tag lliga el contingut al seu page_id: una pàgina reubicada a un altre offset es detecta com a corrupció, no s’accepta com a dades vàlides al lloc equivocat. Sense xifratge, el nonce ha de ser zero (es comprova). La integritat es verifica sempre en llegir, en tots dos modes. Amb el xifratge activat, totes les pàgines es xifren excepte la capçalera i els slots meta — i aquests no contenen dades d’usuari, només magics, punters i versions (ni tan sols els noms de taula queden en text clar).
Identificació de pàgines
Les pàgines de dades porten el tipus al primer byte del cos:
| Tipus | Valor | Contingut |
|---|---|---|
| Fulla de B-tree | 0x01 | Cel·les clau→valor |
| Node intern de B-tree | 0x02 | Cel·les clau→fill |
| Overflow | 0x03 | Continuació de valors grans |
Les pàgines estructurals s’identifiquen per un magic de 8 bytes a body[0..8] — no hi pot haver col·lisió, perquè cap byte de tipus de dades no és igual a 0x41 ('A'):
| Pàgina | Magic | Posició |
|---|---|---|
| Capçalera | "ARKEION1" |
pàgina 0 |
| Slot meta | "ARKMETA1" |
pàgines 1–2 |
| Commit | "ARKCMT01" |
zona d’append |
Capçalera del fitxer — cos de la pàgina 0
0..8 magic "ARKEION1"
8..12 format_version u32 = 1
12..16 page_size u32 = 4096
16..20 flags u32 bit0 = encryption enabled
20..36 file_id [16] random at creation (seed of the genesis chain)
36..52 kdf_salt [16] reserved (raw 32-byte key today, no KDF)
52.. reserved zeros
Slot meta — cos de les pàgines 1 i 2
0..8 magic "ARKMETA1"
8..16 version u64 version of the last commit reflected
16..24 last_commit_page u64
24..32 n_pages u64 file length (in pages) at that commit
L’escriptor alterna A/B. En obrir, el motor agafa el slot vàlid (integritat del tag) amb la version més alta, i després fa un escaneig cap endavant des de n_pages per si han aterrat commits després de l’última actualització del slot — l’actualització del slot és lazy, mantinguda deliberadament fora del camí crític de durabilitat.
Pàgina de commit — cos, tipus 0x04
0..8 magic "ARKCMT01"
8..12 flags u32 bit0 = compaction checkpoint (vacuum)
12..16 reserved u32
16..24 version u64 global monotonic, starts at 1
24..32 parent_page u64 parent commit ON THE BRANCH (0 = genesis)
32..40 prev_page u64 previous commit GLOBALLY (0 = genesis)
40..48 timestamp_ms u64 wall clock, informational (version is authoritative)
48..56 data_root u64 root of the branch's data tree
56..64 meta_root u64 root of the global meta tree
64..72 nonce_counter u64 next GCM counter after this commit
72..80 pages_written u64 pages added by this commit
80..144 branch [64] UTF-8 branch name, zero-padded (max 64 B)
144..176 content_hash [32] SHA-256 of the PLAINTEXT bodies written by the commit, in order
176..208 prev_chain [32] chain_hash of the previous global commit
208..240 chain_hash [32] see below
240.. reserved zeros
Cada commit tanca una hash chain sobre l’ordre global de tots els commits, a través de totes les branques:
chain_hash = SHA-256( prev_chain ‖ content_hash ‖ LE(version) ‖ LE(timestamp_ms)
‖ LE(data_root) ‖ LE(meta_root) ‖ branch )
genesis: prev_chain = SHA-256( "ARKEION1" ‖ file_id )
Manipular qualsevol escriptura passada, en qualsevol branca, trenca la cadena d’aquell punt en endavant. Un commit amb flags.checkpoint = 1 l’escriu vacuum: el seu prev_chain porta el chain_hash de cap de la història truncada, de manera que la cadena es manté contínua i verificable encara que les pàgines antigues ja no hi siguin.
Pàgines del B-tree
leaf (0x01): [type u8][flags u8][ncells u16] · [plen varint][prefix]? · cells · pointers
flags bit0 = COMPRESSED PREFIX: a [plen varint][prefix] shared by every key
follows the header, and each cell stores only the SUFFIX key[plen..];
full key = prefix ++ suffix. Without the bit: empty prefix, cell = whole key.
cell: [cell-flags u8][klen|slen varint][key|suffix][vlen varint][val]
cell-flags bit0 = overflow: [...][total len varint][first overflow u64]
(a value pushing the cell past 1280 B goes to overflow; key max 1024 B)
internal (0x02): [type][flags][ncells u16][rightmost u64] · cells: [klen varint][key][child u64]
cell.key = exclusive upper bound of its child
overflow (0x03): [type][flags][len u16][next u64][bytes…]
Cada node acaba amb un array de punters de cel·la — un u16 LE per cel·la, que creix des del final del node cap al contingut — de manera que el descens, get i els escanejos fan cerca binària dins de pàgina sense materialitzar el node, i afegir la clau màxima es manté O(1). Cost: 2 B per cel·la.
Compressió de prefixos a les fulles. Les claus d’una fulla solen compartir un prefix llarg — postings d’un mateix terme ([0x03, fts_id, 0x00, term_id]), entrades d’un mateix índex ([0x02, index_id]). Emmagatzemar aquest prefix un cop per pàgina en lloc d’un cop per cel·la encongeix tots els índexs (és una part important del motiu pel qual el full-text va quedar per sota de l’FTS5 de SQLite), sense cap canvi al model lògic: les comparacions del hot path retallen el prefix de l’objectiu i comparen sufixos zero-copy; només el cursor d’escaneig reconstrueix la clau completa. El cursor d’append afegeix un sufix quan la clau comparteix el prefix, de manera que la fulla activa més a la dreta es manté comprimida amb appends O(1), i les pàgines més antigues es llegeixen de la mateixa manera.
Invariants comprovats en descodificar: les claus no són buides i són estrictament creixents dins d’un node. delete no reequilibra els nodes mig buits — només elimina els buits; vacuum reequilibra en reescriure.
Espais de claus
El catàleg emmagatzema una versió d’esquema amb cada taula. L’esquema lògic ha crescut v7 (restriccions CHECK) → v8 (full-text) → v9 (vectors) → v10 (re-rank vectorial) — cada pas afegeix espais de claus, de manera que el format_version en disc (pàgina 0) es manté a 1. Aquesta és la progressió canònica a què fan referència els docs de SQL, full-text i vectors.
Arbre de dades (data_root — es bifurca amb la branca)
[0x00, 0x01, table_name UTF-8] → {table_id u32, schema} catalogue
[0x00, 0x02, table_id BE] → next_rowid u64 rowid counter
[enc_oint(table_id), enc_oint(rowid)] → record (row)
[0x02, index_id BE, value*, rowid u64 BE] → secondary index entry
enc_oint és un i64 de longitud variable, autodelimitat i que preserva l’ordre: una capçalera d’un byte codifica el signe i el nombre de bytes significatius, de manera que les magnituds petites ocupen ~2 B mentre l’ordre (table_id, rowid) del B-tree es preserva exactament. La clau de fila no necessita cap byte d’espai de noms — table_id ≥ 1 dona una capçalera enc_oint de 0x80+, disjunta de 0x00 (catàleg) i 0x02 (índex), així que una clau s’autoidentifica pel seu primer byte. Les entrades d’índex mantenen un rowid u64 BE fix com a sufix final després del valor de longitud variable.
L’esquema viu a l’arbre de dades a propòsit: una migració en una branca canvia l’esquema només en aquella branca.
Arbre meta (meta_root — global, lineal, no es bifurca mai)
[0x01, branch_name] → {head_version u64, head_page u64} refs
[0x02, version BE] → {commit_page u64, ts_ms u64, branch} history index
[0x03, ts_ms BE, version BE] → ∅ AS OF TIMESTAMP
Registre (fila)
[ncols varint][tag u8 × ncols][payloads in order]
tags: 0 NULL · 1 FALSE · 2 TRUE · 3 INTEGER (zigzag varint)
4 REAL (f64 LE, 8 B) · 5 TEXT (varint len + UTF-8) · 6 BLOB (varint len + bytes)
Les columnes finals absents es llegeixen com a NULL — que és el que permet que ALTER TABLE ADD COLUMN funcioni sense reescriure ni una sola fila existent.
Protocol de commit i recuperació
Una escriptura, des de l’únic escriptor:
1. new pages (copy-on-write) → append
2. commit page → append
3. fdatasync ← the durability point — exactly one
4. alternating meta slot → write in place (lazy hint; no fsync)
Un fdatasync per commit, sense cap barrera entre les pàgines de dades i la pàgina de commit. El tag d’integritat per pàgina fa il·legible qualsevol pàgina escrita parcialment o no escrita; la recuperació escaneja en ordre i s’atura a la primera pàgina il·legible. Com que les pàgines de dades s’afegeixen abans que la pàgina de commit, un commit escrit a mitges no s’adopta mai — només un commit amb totes les pàgines llegibles (el fdatasync es va completar) esdevé el head. Estalviar-se la segona barrera val aproximadament 2–2.5× la taxa d’escriptures durables respecte d’un rollback journal de SQLite, a l’alçada del WAL, mantenint la durabilitat completa en un sol fitxer.
Obertura i recuperació:
1. Validate the header (magic, format version, flags).
2. Read meta A and B → candidate = the valid one with the highest version. Both corrupt → full scan.
3. Validate the candidate commit page (tag + magic + optional chain).
4. Scan forward from n_pages: each further valid commit page advances the head.
5. A broken tail (pages no valid commit covers) is ignored.
El pas 5 és el “replay del WAL”: no hi ha redo ni undo, només es descarta el que mai no es va confirmar.
Vacuum (compactació)
vacuum(retention) reescriu les versions vives en un fitxer temporal segons la política — KeepAll | KeepLast(u64) | KeepSince(SystemTime) — escriu un commit de checkpoint que s’encadena a la història truncada, i després fa un fsync + rename atòmics sobre l’original. Fa alhora de mecanisme de rotació de claus: tot es reescriu sota la clau nova.