kdb-next schema evolution runbook
When you need this. A Koder service whose repository is backed by kdb-next (
pkg/kdbnext, gRPCEnsureTable) fails to boot after a deploy because the table schema in the running master binary no longer matches the schema the kdb-next gateway already holds for that tenant. This runbook is the decision procedure + the mechanics for every path out of that wall.
The kdb-next gRPC EnsureTable contract is strict: any mismatch between the schema a binary declares and the schema the catalog already holds is a hard boot error. (The legacy kdb-1 HTTP path tolerated additive column-bag changes silently — kdb-next does not.) That strictness is correct (it surfaces drift instead of silently corrupting reads), but it means every EnsureTable change on a kdb-next-backed table is a schema migration and must be planned, not shipped by accident.
This is the kdb-next companion to the Postgres story in always-on.kmd §3 (zero-downtime schema evolution) and the generic expand→migrate→contract recipe in specs/migrations/expand-migrate-contract.kmd.
1. When the bootstrap fails
Symptom
A freshly-built binary crash-loops at startup with a kdb-next bootstrap error naming the table and the word schema:
ERROR kdb-next admin stores bootstrap failed
error="audit: ensure admin_audit_log: kdbnext: EnsureTable(\"koder-id\"/\"admin_audit_log\"):
rpc error: code = Aborted desc = table tenant:.../\"admin_audit_log\"
already exists with a different schema"The currently-running prod binary keeps working — it compiles against the schema the catalog still holds. Only the new binary trips the check, so the immediate mitigation is always: roll back to the last-known-good binary (keep a *.bak-pre-<ticket> copy on every deploy) and resolve the drift out-of-band. Prod is never down while you decide.
Diagnose — read both sides
- Catalog side (what the gateway holds today):
`bash export KDB_ENDPOINT=https:/gateway-host>:18090 export KDBAUTHTOKEN="$(cat etckoder-idkdb-nextbearer-token)" export KDBTLSCA=etckoder-idkdb-nextca.pem # only for https endpoints
kdbctl inspect tenant tenant table table
`
- Master side (what the new binary declares): read the
EnsureTablecall in the repository's
*_kdbnext.go— column list, types, primary key, indexes. The diff between the two is the migration you must perform.
Classify the diff — it picks the path in §2:
| Diff class | Example | Path |
|---|---|---|
| Add a nullable column / add an index | + ua_carrier text NULL |
SchemaOnly (additive) |
| Add a non-null column | + status int64 NOT NULL |
Expand → backfill → SchemaOnly (never direct) |
| Rename / type change of an existing column | recorded_at text → timestamp |
StampRows (rewrites rows) |
| Whole-table reshape on an append-only / disposable table | audit log gains 7 columns | Catalog rename to *_v2 (split history) |
2. Decision matrix
There are three sanctioned paths. Pick by how coherent the history must stay, not by what is fastest.
a) Catalog rename to *_v2 — fast, zero-downtime, splits history
The binary bumps the table name constant (admin_audit_log → admin_audit_log_v2); kdb-next creates a fresh table with the new schema. The old table stays in the tenant as a read-only archive (rows still recoverable by direct PK lookup).
- Wire-compatible under
always-on.kmd §2: kdb-next stores opaque protopayloads, so the row shape inside the gateway is unchanged — only the catalog key differs. Old readers/writers of the original table keep working; the new binary reads/writes
_v2. - Cost: history splits at the cutover instant. Queries that must span
the boundary have to union both tables.
- Use for: append-only logs / event tables / disposable caches where the
split is acceptable, or as an emergency unblock — but then track a follow-up StampRows/backfill ticket to reunify, and a DROP ticket for the archive once it ages out (per
always-on.kmd §3 R3.3). - Never use for: a primary entity table (
users,clients,sessions)where queries must see one coherent history. Splitting those is data loss by another name.
There is also a first-class kdbctl migrate rename-table (updates the catalog name without row rewrites). The table fingerprint changes because the name is part of the canonical bytes, so follow a rename with migrate apply --kind stamp-rows to clear drift warnings on existing rows (or accept them on a soon-to-be-archived table).
b) kdbctl migrate apply --kind schema-only — proper additive migration
Installs the new schema in the catalog in place, O(1), preserving the table identity and all rows. This is the architecturally-right path for additive changes (new nullable column, new index) on a table whose history must stay coherent.
- Use for: primary tables, additive evolution, history must stay intact.
- Does not rewrite existing rows — old rows are read back against the new
schema with the new (nullable) columns reading as null.
- For a non-null column, this is still a 3-step expand→backfill→contract
(see §3 R3.2 of
always-on.kmd): add nullable → backfill async → only then declare non-null in a later release.
c) kdbctl migrate apply --kind stamp-rows — reshape, rewrites rows
Installs the new schema and rewrites every row's fingerprint header, O(n). Needed when a column changes shape (rename, type change) so that existing rows carry the new canonical bytes.
- Use for: column rename / type change on a table that must keep one
identity and history.
- Heavier: rewrites every row — treat as an online migration (run it as a
tracked job; poll with
migrate status/migrate get).
Tie-break (Quality > Speed,
stack-principles.kmd §2). When the table carries real entity history, prefer (b)/(c) — the migration that keeps one coherent table — over (a) the rename, even though the rename is faster. The rename is reserved for append-only/disposable tables or a tracked emergency unblock.
3. Mechanics
Environment (all kdbctl migrate commands)
kdbctl reads connection config from global flags or env vars:
| Env var | Flag | Meaning |
|---|---|---|
KDB_ENDPOINT |
--endpoint |
Gateway gRPC endpoint, e.g. https://<host>:18090 |
KDB_AUTH_TOKEN |
--auth-token |
Bearer token (prod id: /etc/koder-id/kdb-next/bearer-token) |
KDB_TLS_CA |
--tls-ca |
PEM CA to verify the gateway TLS cert (https endpoints only) |
export KDB_ENDPOINT=https://<gateway-host>:18090
export KDB_AUTH_TOKEN="$(cat /etc/koder-id/kdb-next/bearer-token)"
export KDB_TLS_CA=/etc/koder-id/kdb-next/ca.pem⚠️ Known blocker (as of 2026-05-29).
kdbctlpanics at startup on any https endpoint because the binary never installs a rustlsCryptoProvider(rustls::crypto::ring::default_provider().install_default()) — the kdb-pgwire crate installs it at every TLS site, kdb-cli misses it entirely. Until that fix ships ininfra/data/kdb, theplan/applypaths against the prod TLS gateway are not runnable; only path (a) (the name-constant rename, which needs no kdbctl) and migrations against a plaintext dev gateway work. Tracked as a kdb-cli bug; this runbook's migrate paths become operational the moment it lands. Build kdbctl ons.khost1.dev-linux-kdb(cargo build --release -p kdb-cli, ~1m).
Schema file (JSON)
migrate plan|apply take a --schema-file — a transcript of the protobuf Schema message. ColumnFile.ty is one of text, int64, bool, bytes, float64, timestamp.
{
"name": "admin_audit_log",
"primary_key": ["tenant_id", "id"],
"columns": [
{ "name": "tenant_id", "ty": "int64", "nullable": false },
{ "name": "id", "ty": "text", "nullable": false },
{ "name": "actor_id", "ty": "text", "nullable": false },
{ "name": "action", "ty": "text", "nullable": false },
{ "name": "created_at", "ty": "timestamp", "nullable": false },
{ "name": "ua_carrier", "ty": "text", "nullable": true }
],
"indexes": [
{ "name": "by_actor", "id": 1, "columns": ["actor_id", "created_at"], "unique": false }
]
}Plan → Apply flow
# 1. Dry-run: show what would change, no writes.
kdbctl migrate plan --tenant <tenant> --table <table> --schema-file ./schema.json
# 2. Apply. --kind schema-only (additive, O(1)) or stamp-rows (reshape, O(n)).
kdbctl migrate apply --tenant <tenant> --table <table> --schema-file ./schema.json \
--kind schema-only
# 3. Track (StampRows returns a migration id; poll it).
kdbctl migrate status --tenant <tenant> # most-recent-first list
kdbctl migrate get --id <ulid> # one migration's state row
kdbctl migrate cancel --id <ulid> # cancel a pending/running one
# Adjuncts:
kdbctl migrate rename-table --tenant <tenant> --table <old> --new-name <new>
kdbctl migrate drop-index --tenant <tenant> --table <table> --index-name <name>4. Worked examples
Example A — admin_audit_log reshape (real, engine#186)
A master admin binary added 7 UA-carrier columns to the kdb-next admin_audit_log (append-only audit trail). The new EnsureTable (15 columns) collided with the deployed 8-column schema → boot crash.
- Class: whole-table reshape on an append-only table.
- Path taken: (a) catalog rename — bumped the constant to
admin_audit_log_v2; kdb-next created the fresh 15-column table; the original stayed as read-only archive. History splits at 2026-05-28 (older admin events in the original, newer in_v2) — acceptable for an append-only audit log. - Why not (b)/(c): the proper
migrate applypath was blocked by thekdbctl rustls bug against the prod TLS gateway (§3 blocker). The rename needs no kdbctl, so it was the correct emergency unblock.
- Follow-up owed: drop the v1 archive via
kdbctlonce it ages past theretention window (after the rustls fix ships).
Example B — users gains a nullable locale column (hypothetical)
A primary entity table must keep one coherent history — the rename path is forbidden here.
- Class: additive, nullable.
- Path: (b) SchemaOnly. Author
schema.jsonwith the existingcolumns +
{ "name": "locale", "ty": "text", "nullable": true },migrate planto confirm the diff is additive, thenmigrate apply --kind schema-only. O(1); existing rows readlocaleas null until backfilled. Iflocalemust become non-null, that is a later release (expand→backfill→contract,always-on.kmd §3 R3.2) — never a direct non-null add.
Example C — sessions.expires_at changes text → timestamp (hypothetical)
A type change on an existing column on a table that must keep its identity.
- Class: column type change.
- Path: (c) StampRows. Author
schema.jsonwithexpires_atretypedto
timestamp,migrate plan, thenmigrate apply --kind stamp-rows(O(n) — rewrites every row's fingerprint header). Pollmigrate status/migrate get --id <ulid>to confirm completion before deploying the binary that reads the new type. Because old and new fingerprints differ, do not deploy the reader binary until the stamp completes, or stage it behind a read-both-shapes window (always-on.kmd §3 R3.1expand→migrate→contract).
5. Gate
Every engine ticket that adds, removes, renames, or retypes a column (or adds/drops an index) on a kdb-next-backed table MUST reference this runbook in its acceptance criteria and name the path it takes (a / b / c). The migration mechanism is chosen explicitly, never by accident — an unplanned EnsureTable change is a deploy-time crash waiting to happen.
Cross-linked from always-on.kmd §3 (zero-downtime schema evolution) for the kdb-next case.