Skip to content

ApexBase Storage Architecture

Overview

ApexBase uses a unified StorageEngine as the single entry point for all storage operations, with a Database/Session façade on top that Python bindings, the embedded Rust API, the PostgreSQL Wire server, and the Arrow Flight server all call. SQL execution is owned by ApexExecutor, which the Database façade routes to; storage internals never call the SQL executor.

The on-disk format is the V4 row-group columnar format (.apex). Legacy V3 files were dropped in v1.10.0 and are rejected on open; the APEXV3 magic is retained in the header for compatibility with readers that check it.

Format versions and compatibility

A file declares its format version in the header (byte 8). 4 is the V4 row-group layout; 5 adds V5-only column encodings — currently a Float32 column stored at its declared four bytes per value (FLAG_NARROW_FLOAT32 in the header flags, COL_ENCODING_PLAIN_F32 per column). A table that uses a V5-only encoding declares version 5.

Compatibility is one-way by design:

  • V5 builds read V4 files (and V4 data written by V5, e.g. with APEX_WIDTH_FIDELITY=0, which keeps the V4 layout and the version-4 declaration).
  • V4 builds must not read V5 files. Older builds do not know the new encodings and would misread values instead of failing, so do not point a pre-V5 build at a version-5 file. Use APEX_WIDTH_FIDELITY=0 when a file has to be handed to an older build.
  • V5 carries an optional parameterised (logical) type section. A schema may append a LogicalType section after the constraint/derivation sections, one entry per column (Uuid, Decimal { precision, scale }, TimestampTz { offset_minutes }, List { element }, Extension { codec, version }, ...). V5 is the newest and, as of v2.0.0, stable format (it does not use a higher version number), so tables with such columns still declare version 5; tables without logical types keep writing 5/4 exactly as before. UUID/Decimal values are fixed 16-byte payloads and project as bytes; APEX_LOGICAL_TYPES=0 refuses creating such columns. The time family adds three type tags (time of day, fixed-offset timestamp and duration) whose payload is the same fixed 8-byte integer as timestamp/date, and every read lane presents the family as typed Arrow timestamp/date32/time64/duration columns (D5), never as raw integers. The list type is tag 33: the physical column reuses the Binary layout and each row payload is [element tag:u8][count:u32][values...] (fixed 8-byte integers/floats, 1-byte bools, length-prefixed UTF-8 for strings). The declared element type also travels in LogicalType::List, and the batch producers retype list columns to Arrow ListArrays at the result boundary; row NULLs and empty/NULL/nested/oversized lists are rejected by the writer. The section is part of format version 5 rather than a new version, so files containing parameterised types must be read by a build that understands it (v2.0.0 or newer); older V5 preview builds must be upgraded before reading such files.

This document defines the architecture and engineering guidelines for future development.

Architecture Diagram

┌─────────────────────────────────────────────────────────────────────┐
│                     Python Client (client.py)                       │
│                store() / retrieve() / execute() / ...               │
└─────────────────────────────┬───────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────────┐
│              PyO3 Bindings (python/bindings/)                       │
│  wrapper.rs (ApexStorageImpl) + read / write / sql / arrow / blob   │
│                                                                     │
│  Responsibilities:                                                  │
│  - File locking (acquire_write_lock / acquire_read_lock)            │
│  - FTS index coordination                                           │
│  - Local cached_backends per ApexStorageImpl instance               │
│  - Type conversion (Python ↔ Rust)                                  │
└─────────────────────────────┬───────────────────────────────────────┘
                              │ All storage ops via Database façade
                              ▼
┌─────────────────────────────────────────────────────────────────────┐
│                Database / Session façade (database.rs)              │
│  - open_backend / cached_backend / create_backend / invalidate      │
│  - write / write_typed → StorageEngine + index notification         │
│  - execute / query / txn → ApexExecutor                             │
└──────────────┬──────────────────────────────────┬───────────────────┘
               │                                   │ SQL / DML / DDL
               ▼                                   ▼
┌────────────────────────────────────┐  ┌─────────────────────────────┐
│   StorageEngine (engine.rs)        │  │  ApexExecutor               │
│   [SINGLETON]                      │  │  (query/executor/)          │
│                                    │  │  - SQL planning + fast paths│
│  Core Responsibilities:            │  │  - QuerySignature classifier│
│  - Smart write routing             │  │  - Index / stats / FTS      │
│    (classify_write)                │  │    coordination             │
│  - LRU backend cache (64 entries)  │  └─────────────────────────────┘
│  - Epoch-checked cache invalidation│
│  - Delta compaction coordination   │
└──────────────┬─────────────────────┘
               │
               ▼
┌─────────────────────────────────────────────────────────────────────┐
│              TableStorageBackend (backend.rs)                       │
│                                                                     │
│  Low-level operations:                                              │
│  - scan(ScanRequest) -> Morsel / SelectionVector                    │
│  - insert_rows() / insert_rows_to_delta()                           │
│  - delete() / replace() / update                                    │
│  - add_column() / drop_column() / rename_column()                   │
│  - save() / compact() / open_for_insert / open_for_delete           │
└──────────────┬──────────────────────────────────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────────────────────────────────┐
│                OnDemandStorage (on_demand/)                         │
│                                                                     │
│  - V4 columnar file I/O (read_write.rs, header.rs, storage_core.rs) │
│  - Memory-mapped scans (mmap_scan/{predicate,projection,groupby,    │
│    topk,vector,statistics}.rs)                                      │
│  - DeltaStore (.apex.delta) + compaction                            │
│  - Blob sidecars (<table>.blobs/)                                   │
│  - Stats sidecar (<table>.stats)                                    │
│  - Plan-feedback sidecar (<table>.plan_feedback)                    │
│  - Fused predicate-lane group kernel (fused.rs)                     │
│  - Aggregation WAL (agg_wal.rs)                                     │
└─────────────────────────────────────────────────────────────────────┘

Supporting subsystems (not shown above, all under apexbase/src/storage/):

Module Role
epoch.rs Generation counters; caches are only trusted when epoch + mtime match. The per-table sidecar (<table>.apex.lock, the same file the cross-process write lock uses) holds two u64 slots: the published generation and the shared row-id high-water mark
cross_process_lock.rs The single <table>.apex.lock flock rule for every write path, re-entrant per thread
mvcc/ Snapshot / version store / GC used by transactions
index/ B-Tree and hash secondary indexes (IndexManager); persistent postings are marked stale before committed table changes and refused in planning/execution until REINDEX rebuilds them (marker: <table>.index.stale)
delta/ DeltaStore: update log + delete bitmap, merge logic
incremental.rs Legacy append-only WAL backend; the transactional .apex.wal is written by on_demand/agg_wal.rs
table_catalog.rs .apex_tables / .apex_schemas memory-mapped registries
bloom.rs, concurrent.rs Filter helpers and concurrent primitives
scan.rs SQL-independent scan request, Arrow column views, selection vectors, and morsels
backend.rs (OverlayState) Single read-visibility gate derived from appended_rows / pending_cells / unflushed_rows / base_in_memory, exposed through overlay_state(), has_pending_writes(), needs_merged_read(), and is_clean_view()

Scan And Operator Boundary

TableStorageBackend::scan() is the shared boundary for new composable query paths. Base-only V4 data can select a string-equality or zone-map-estimated numeric mmap candidate before projected materialization. Any delta, pending write, or in-memory overlay instead uses the authoritative merged read. Both lanes return the same Morsel contract and reapply the full predicate conjunction before physical operators consume the selection.

The first two composable pipelines share the boundary. scan() emits a single morsel; the row-group batch stream (scan_batches, also split into disjoint ranges by scan_batches_ranges) feeds the serial and parallel WHERE -> GROUP BY -> HAVING -> ordered TopK slice. Unsupported expressions and numeric literals that cannot be represented exactly use the general evaluator.

Every read lane decides which lane it may take by probing overlay_state() once instead of rebuilding has_delta() / pending_v4_in_memory_rows() combinations at the call site; see Read-Path Capabilities for the lane-by-lane matrix of supported visibility states, gates, and fallback targets. See Scan & Physical Execution for the complete protocol, fallback boundaries, and extension rules.

Persistent And In-Memory Backends

ApexClient(path) and ApexClient(":memory:") converge at the Database/Session façade and use the same executor and TableStorageBackend operations. The difference is how a backend is opened and persisted:

Concern Filesystem database ":memory:" database
Backend lookup V4 .apex file plus engine cache Process-local StorageEngine memory registry
Table/catalog state .apex, .apex_tables, .apex_schemas Registry/backend state only
WAL/delta/blob/index persistence Files and sidecars as required Kept in memory; no sidecar files
Lifetime Survives client/process restart Bound to the explicit client; dropped on close
SQL and feature routing Database/Session → ApexExecutor Same routing and validation paths

The Python constructor maps the public ":memory:" sentinel to a unique internal apexbase_memory: path. Database::open_backend() first checks the engine's memory registry; create_backend() registers a memory table instead of materializing a file. DDL, DML, indexes, FTS, schema changes, and query cache invalidation therefore reuse the normal database semantics while file creation, mmap, fsync, catalog writes, and atomic rename are bypassed.

The module-level apexbase.execute() API owns a separate lazily created default in-memory client. It is intentionally shared by later module-level calls in the same process; independently constructed ApexClient(":memory:") instances remain isolated.

StorageEngine API Reference

Write Operations

Method Description Write Mode
write(path, rows, durability) Smart write routing Auto (delta/full)
write_one(path, row, durability) Single row write Auto
write_typed(path, columns..., durability) Typed columnar write V4 append / full

query() and retrieve() are no longer StorageEngine methods: SQL goes through Database::execute / Database::query, and point retrieval through the embedded API / Python bindings.

Read Operations

Method Description
exists(path, id) Check if record exists
row_count(path) Total row count (base + delta)
active_row_count(path) Excluding deleted rows

Delete Operations

Method Description
delete(path, ids, durability) Delete multiple by IDs
delete_one(path, id, durability) Delete single record

Schema Operations

Method Description
create_table(path, durability) Create new table
create_table_with_schema(path, durability, schema) Create with explicit schema
get_schema(path) Get table schema
add_column(path, name, dtype, durability) Add column
drop_column(path, name, durability) Remove column
rename_column(path, old, new, durability) Rename column
list_columns(path) List all columns
get_column_type(path, name) Get column type

Cache Management

Method Description
invalidate(path) Invalidate single table cache
invalidate_dir(dir) Invalidate all tables in directory
get_read_backend(path) Cached read backend (epoch-checked)
get_write_backend(path, durability) Write backend, compacts delta first
get_insert_backend(path, durability) Append backend reused for incremental inserts

Memory Budgets

Process-wide storage caches are bounded by APEX_STORAGE_BUDGET_MB (1 GiB by default), split into scan buffers (1/2), numeric aggregation state (1/8), decompressed row groups (1/16) and the per-backend string read caches (1/16). The scan, decompressed-row-group and string-read slices have explicit overrides: APEX_SCAN_BUFFER_MB, APEX_DECOMPRESSED_RG_MB and APEX_READ_CACHE_MB (0 means unlimited). The string read caches — the dictionary cache and the first-row-id accelerator behind WHERE <string> = 'x' LIMIT 1 — grow with the number of distinct values rather than with the file size, so a backend drops and rebuilds them when they exceed their slice instead of holding them for the table's lifetime.

Smart Write Routing

Write routing is decided by StorageEngine::classify_write(), which combines the legacy delta check with V4 detection in a single pass and uses an epoch-checked schema cache to avoid file I/O on the fast path:

// engine.rs — classify_write(table_path, rows) -> (use_delta, is_v4)
// 1. Table must exist and be non-empty (file size >= 256 bytes)
// 2. V4 files ALWAYS take the full-write path:
//      get_insert_backend() + insert_rows() + save()
//      save() appends a new Row Group (no full rewrite, no delta file)
// 3. Non-V4 legacy files with an exact column match use the delta path:
//      insert_rows_to_delta() appends to .apex.delta

Write Mode Selection

Condition Write Mode Reason
New table Full Need to create file structure
Empty table Full Need to establish schema
V4 table (any schema) Full (Row Group append) Default format; save() appends via append_row_group
Legacy non-V4, new/missing columns Full Delta doesn't support schema evolution
Legacy non-V4, same columns Delta Memory-efficient append-only

Notes:

  • The Python store() / store_columnar() path calls Database::write_typed, which uses the append_row_group fast path when the V4 table exists with a matching schema — incremental inserts avoid rewriting the base file.
  • SQL INSERT inside a transaction goes through ApexExecutor → try_apply_txn_insert_delta() → insert_rows_to_delta() and appends to .apex.delta directly (guarded: no constraints, no secondary indexes, no FTS on the table).
  • V3 files are rejected on open (Unsupported legacy file format (V3)), so the non-V4 delta branch above is a legacy code path, not a supported on-disk format.

Engineering Guidelines

1. Always Use Database Façade / StorageEngine

DO:

// In bindings
crate::Database::write(&table_path, &rows, durability)?;

// Storage-only call sites (engine-internal code)
let engine = crate::storage::engine::engine();
engine.write(&table_path, &rows, durability)?;

DON'T:

// Direct backend access - AVOID
let backend = TableStorageBackend::open(&table_path)?;
backend.insert_rows(&rows)?;
backend.save()?;

2. Lock Ordering

Always acquire locks in this order to prevent deadlocks:

  1. File lock (acquire_write_lock / acquire_read_lock)
  2. StorageEngine operation
  3. Release file lock
  4. Invalidate local caches
// Correct pattern
let lock_file = Self::acquire_write_lock(&table_path)?;
let result = crate::Database::write(&table_path, &rows, durability);
Self::release_lock(lock_file);
self.invalidate_backend(&table_name);

3. Cache Invalidation

  • StorageEngine invalidates its own backend/schema caches internally.
  • The Database façade invalidates per-instance cached_backends and the global engine cache after writes.
  • Python bindings invalidate their cached_backends map after writes (invalidate_backend).
  • Do NOT reach into ApexExecutor caches from storage code — the executor coordinates its own index/stats/FTS invalidation through Database.

4. Error Handling

All StorageEngine methods return io::Result<T>. Convert to PyErr at the bindings layer:

engine.write(&table_path, &rows, durability)
    .map_err(|e| PyIOError::new_err(e.to_string()))?;

5. Durability Levels

Level fsync Behavior Use Case
Fast No fsync Development, testing
Safe fsync on save/flush Production default
Max fsync on every write Critical data

.apex.wal is written by the on-demand transactional path (storage/on_demand/agg_wal.rs): Safe and Max durability write transactional DML to the WAL before the commit point, Max fsyncs each WAL append, and Safe fsyncs at save/flush. The legacy storage/incremental.rs append-only backend is a separate mechanism that shares the file extension; the two WALs must not be conflated.

File Format

V4 Row Group Format (.apex) — Default

V4 is the current default save format; legacy V3 compatibility was dropped in v1.10.0 (2026-03-18). Data is split into adaptive-size Row Groups (default 65,536 rows; 131,072 for very narrow rows, 32,768 for wide rows), each self-contained with IDs, deletion vector, and per-column data.

┌─────────────────────────────────────┐
│ Header (256 bytes)                   │
│ - Magic "APEXV3\0\0" (retained),     │
│   version=4, flags, row_count,       │
│   column_count, row_group_size,      │
│   schema_offset, column_index_offset,│
│   id_offset, checksum                │
├─────────────────────────────────────┤
│ Schema Block                         │
├─────────────────────────────────────┤
│ Column Index (32 bytes per column)   │
├─────────────────────────────────────┤
│ ID Column (contiguous u64 array)     │
├─────────────────────────────────────┤
│ Row Group 0                          │
│ ┌─────────────────────────────────┐ │
│ │ RG Header (32B): magic "APXG",  │ │
│ │ row_count, col_count, min_id,   │ │
│ │ max_id, flags (LZ4/ZSTD/NONE)   │ │
│ │ IDs (u64, contiguous-encoded)   │ │
│ │ Deletion vector (bitmap)        │ │
│ │ Col 0: null bitmap + data       │ │
│ │ Col 1: null bitmap + data       │ │
│ │ ...                             │ │
│ └─────────────────────────────────┘ │
├─────────────────────────────────────┤
│ Row Group 1 ...                      │
├─────────────────────────────────────┤
│ V4 Footer                            │
│ - Schema                            │
│ - Vec<RowGroupMeta> (40B each)      │
│ - Optional zone maps (ZMAP)         │
│ - Optional RCIX offsets (per-RG     │
│   per-column body offsets)          │
│ - footer_size + magic "APXFOOT\0"   │
└─────────────────────────────────────┘

Key design decisions:

  • String columns are dict-encoded on disk for low-cardinality data (transparent to the read path); in-memory state always uses plain String.
  • Row groups can be LZ4- or ZSTD-compressed (flag bits in the RG header); default is no compression for maximum read performance.
  • A compressed row group stores each column as its own compressed frame by default (when that costs at most 8% more bytes than a single flat frame; APEX_CHUNK_INDEX=0 opts back into the flat frame). A scan that touches one column then decodes one column instead of the whole group; the footer's CHNK directory records every column's offset in the flat body, so all readers keep addressing the same layout. Window reads (LIMIT/OFFSET) and filtered aggregates decode only the columns they project or aggregate, and a table that stores chunked groups declares format version 5. The directory is shared behind an Arc so the footer clone every read entry point makes stays cheap. A full-file rewrite (for example the compressed-row-group DELETE path) preserves the source table's layout.
  • Appends are chunked by row_group_size: a single large batch is split into multiple Row Groups, so per-RG buffers and later streaming rewrites stay bounded regardless of batch size.
  • Zone maps (ZMAP, numeric min/max per RG) let scans skip row groups that cannot match a filter; the RCIX section enables O(1) direct seeks for cold-start SELECT * LIMIT N.
  • A statistics sidecar <table>.stats (magic APEXSTAT) stores per-column null counts / ranges / histograms for the query optimizer and is invalidated with each DDL/DML/compaction generation.
  • save_v4() pre-filters deleted rows, writes clean data, sets in-memory state directly (no disk reload), and appends new Row Groups for incremental writes.
  • The Python store() / store_columnar() path builds Row Group columns directly from borrowed Python buffers (&str / &[u8], no per-element String/Vec allocation) and passes them to StorageEngine::write_typed_columns, which appends without re-copying into a typed intermediate layer. Peak memory for a string-heavy batch drops from ~2× the data to ~1× (the caller's input plus one column buffer).
  • In-memory string/binary columns use u64 offsets; the on-disk format keeps u32 offsets per Row Group (blocks stay far below 4 GiB), so columns larger than 4 GiB never silently truncate in memory. The disk format is unchanged.
  • V3 files are not auto-converted: opening one fails with Unsupported legacy file format (V3). Please re-create the table.

Blob Sidecar Storage

Blob columns follow a Lance-like layout. The main table stores only a descriptor, so scans that do not project the blob column avoid loading large payloads.

  • Inline blobs up to 64KB are embedded directly in the descriptor.
  • Packed blobs from 64KB to 4MB are appended to <table>.blobs/packed.blob.
  • Dedicated blobs larger than 4MB are written as separate files under <table>.blobs/objects/.
  • Descriptors include length and checksum metadata. Point reads can fetch descriptor metadata, full bytes, or byte ranges without forcing unrelated columns to load.
  • Projected blob reads return Arrow LargeBinary; scans that omit blob columns keep using descriptor-only column data.

Delta File (.apex.delta)

.apex.delta persists the DeltaStore: an update log ((row, col, new_value)) plus a delete bitmap, serialized with bincode. It is used for:

  • pending deletes and row updates applied on top of the base V4 file;
  • SQL transactional INSERT batches appended via insert_rows_to_delta();
  • legacy non-V4 delta appends.

While an overlay is visible, read lanes stay on their fast paths where the overlay can be merged in place: point reads (retrieve / the SQL _id fast path) apply the pending cell overrides to the base row, and the executor's secondary-index lane unions the rows a pending update moved into the predicate with the postings' candidates before re-evaluating the residual WHERE on the merged values (covering index-only scans stand down). DeltaMerger returns a batch zero-copy when none of its rows are touched and reuses columns with no pending updates, so one-cell overlays no longer rebuild whole batches. Appended delta rows and unflushed buffers still take the merged lanes.

Compaction

Delta files are compacted into the base file when:

  • Delta size > 10MB (DELTA_COMPACT_SIZE)
  • Delta rows > 100,000 (DELTA_COMPACT_ROWS)
  • Before opening a write backend when a delta exists (open_for_compact)

Compaction merges delta into the V4 base file and clears the delta store. For mmap-only V4 backends (the production path) this is a streaming RG-by-RG rewrite (compact_streaming_v4): each Row Group is parsed from the mmap, merged with the DeltaStore updates/deletes and the appended delta rows, and written to a fresh .apex.tmp before an atomic rename. Peak memory is O(largest Row Group + delta payloads), so tables larger than physical memory can be compacted. The legacy in-memory merge path remains for backends that already materialized the base.

Schema Changes (bounded memory)

  • ADD COLUMN on mmap-only V4 tables is footer-only: only the footer schema is updated, no data is rewritten. Read paths synthesize all-NULL values for rows already on disk; later compactions materialize the column.
  • DROP COLUMN runs a streaming RG-by-RG rewrite (rewrite_v4_drop_columns) that physically removes the column without loading the table. Any pending delta is compacted first.
  • RENAME COLUMN updates the footer schema in place.

Deletes (bounded memory)

  • Uncompressed Row Groups: deletion vectors are updated in place (O(row groups) writes) via save_delete_only.
  • Compressed Row Groups (deletion vectors live inside the compressed body): the delete triggers a streaming rewrite (rewrite_v4_active_rows) that drops the deleted rows RG-by-RG via mmap instead of loading the whole table.

Table Catalog (.apex_tables)

Since v1.28.0, each database directory owns a memory-mapped binary table registry, .apex_tables, that is the authoritative source of table names across processes. It replaces the earlier behavior where create_table on an existing table could silently rebuild it: a fresh process calling create_table on an existing table now raises Table already exists.

  • Layout: 32-byte header with magic APXTBL02 and format version 2, followed by fixed-size slots (name length + 128-byte name + CRC32), default capacity 1024.
  • Concurrency: all mutations run under an exclusive advisory lock (.apex_tables.lock) and update the mapped region in place, so CREATE/DROP no longer pay a full file rewrite per DDL.
  • Integrity: each slot carries its own CRC32, so accidental corruption or manual edits are detected and rejected.
  • Readers: take an optimistic generation snapshot, verify CRCs, and retry if the generation changed. A snapshot cache keyed by generation plus file mtime guards against external rewrites.
  • Migration: legacy databases without a catalog, or with the earlier one-shot binary/JSON formats, are backfilled/migrated on first access.

Lazy schema information is kept in a second memory-mapped registry, .apex_schemas (magic APXSCM01, version 1, 256 KiB region), used by the on-demand storage layer to avoid parsing full .apex headers for schema-only reads.

Concurrency and MVCC

  • Epochs: every cache entry records the table epoch at insertion time; readers re-check epoch + mtime before trusting a cached backend. All storage mutations take a logical_write epoch guard.
  • Row ids across processes: every writer derives its next id from the base file it read, so two processes would hand out the same _id. Write entry points reserve a range against the sidecar's shared high-water mark instead (epoch::reserve_row_ids); ids may be skipped, never repeated, and the single-process sequence stays contiguous because the reservation minimum is that same next id.
  • MVCC: transaction reads use Snapshot / SnapshotManager; row versions live in a VersionStore and are reclaimed by the GarbageCollector.
  • Locks: table file locks (advisory) serialize write paths; the catalog uses its own .apex_tables.lock; writes are serialized per table while V4 mmap-only reads run in parallel.

Adding New Operations

When adding a new storage operation:

  1. Add the method to StorageEngine (engine.rs)
  2. Handle cache invalidation
  3. Use appropriate backend method
  4. Return io::Result<T>

  5. Expose it through the Database façade (database.rs) if bindings or embedded callers need it; keep SQL-facing behavior in ApexExecutor.

  6. Update the bindings (python/bindings/) to use the new façade method

  7. Acquire file lock
  8. Call the façade/engine method
  9. Release lock
  10. Invalidate local cached_backends

  11. Write tests covering:

  12. Normal operation
  13. Edge cases (empty data, non-existent table)
  14. Concurrent access

  15. Update this document if the operation changes the data flow

Testing

Run all tests to verify storage operations:

maturin develop --release
pytest
cargo test

All Python and Rust tests must pass; see ENGINEERING_GUIDELINES.md for the full validation sequence.