Under the Hood
KoldStore keeps PostgreSQL authoritative for the hot tier and stores flushed history as open Parquet plus catalog metadata. This page explains the moving parts so API behavior makes sense.
End-to-end path
There is no trigger/strict capture path today. Foreground DML writes the
heap only; the serialized WAL applier allocates authoritative seq values.
Hot tier
- Ordinary heap table with a primary key
- Application columns only (clean schema — no KoldStore columns on the user table)
- Native indexes cover hot rows only after flush
- Transactions, locking, MVCC, RLS, and permissions stay PostgreSQL-native
Change-log mirror (koldstore.<table>__cl)
One row per primary key (latest state), not an append-only event log:
| Column | Meaning |
|---|---|
| PK columns | Same as the heap |
seq | Snowflake effect id — ordering / flush cutoffs / change cursor |
op | 1 = INSERT, 2 = UPDATE, 3 = DELETE |
commit_lsn | Diagnostics only — not the public resume cursor |
Tombstones (op = 3) remain until flush prunes them. seq is not a commit
LSN and is not a commit-order guarantee beyond “allocated by the serialized
applier.”
WAL capture consistency
| Mode | Behavior |
|---|---|
| Normal | Worker polls (~100 ms) and applies available WAL |
| Strong fence | koldstore.wait_for_async_mirror() drains to a boundary |
| Flush | Calls the same fence automatically |
Lag is expected. Call the fence before comparing heap state to mirror/seq
cursors when you need a strong boundary.
Other capture rules:
- Primary-key
UPDATEs are rejected (BEFORE UPDATE OF pk guard) TRUNCATEon async managed tables is rejected- Publication
koldstore_async_mirror+ one deterministic logical slot per database are auto-managed
Cold tier
- Parquet segments under a registered storage backend
- Manifest metadata in PostgreSQL catalogs (
koldstore.manifest,koldstore.cold_segments, …) - Object storage is not WAL-protected — back up PostgreSQL and the cold prefix together
Default path templates:
- Shared:
{namespace}/{tableName}/ - User-scoped (intended):
{namespace}/{tableName}/{scopeId}/
Scoped cold folders and full per-user_id physical layout are still hardening;
session koldstore.user_id is required for user-typed tables today.
Flush policy (intuition)
Example: hot_row_limit = 10000, min_flush_rows = 1000, max_rows_per_file = 1000
| Mirror rows | Result |
|---|---|
| 10,505 | No flush (505 excess < min) |
| 11,000 | Flush 1,000 → 1 file |
| 11,250 | Flush 1,000, keep 10,250 hot |
Age policy uses koldstore_move_after (for example '90 days') measured from
mirror seq. It is mutually exclusive with hot_row_limit.
koldstore_move_when is reserved and fails closed.
KoldMergeScan
Contracts that matter for operators:
- Cold-capable predicates →
KoldMergeScanwith hot child + planned cold access - Hot-only / cold-proven-empty PK lookups may keep a native plan with no wrapper (intentional performance path)
koldstore.enable_merge_scan = off→ ERROR at execution (never silent hot-only)- Unavailable cold storage → query fails (no partial hot-only results)
Catalog touchpoints
Useful relations/functions while debugging:
koldstore.schemas— managed table optionskoldstore.jobs— migrate / flush progresskoldstore.describe_table(...)— hot/mirror/cold counts, sizes, pending jobskoldstore.async_mirror_status()— slot lag, retained bytes, health
Next
- Manage & Flush — how to drive policy and jobs
- Change Feed — exclusive
seqcatch-up - SQL API Reference — full function list