Maintenance — API Reference
Package: dodil.tables.v1 · Service: Tables
Three maintenance RPCs plus one SQL statement cover the operational lifecycle of a table. The plane drains the WAL into Delta automatically; you reach for these for explicit file packing, space reclamation, and rollback.
| Surface | Kind | When to call |
|---|---|---|
Tables.OptimizeTable | RPC | After heavy writes — bin-pack small files into larger ones |
Tables.VacuumTable | RPC | Reclaim space — remove superseded files past retention |
Tables.Compact | RPC | Force the WAL to drain into Delta now, instead of waiting for the tick |
RESTORE … TO VERSION AS OF n | SQL, via Tables.Execute | Roll a table back to an earlier committed version |
There is no
HistoryRPC and noRestoreRPC.dodil.tables.v1has neither, and no/historyor/restoreroute exists on any door. Rollback is theRESTOREstatement; the commit log has no read API at all. Verified againstdodil-tables/proto/api/tables.proto:30-125andcrates/adapter/src/rest.rs:1073-1096.
The RPC-shaped calls reach the plane over the tables gRPC door (table-rpc.<region>.dodil.io:443) or the CLI. They are not REST routes on the tables HTTP door, which serves only /v1/sql/*, /v1/residency/*, /v1/observe/*, /v1/vector/* and /v1/graph/*.
OptimizeTable
Compacts small Delta files into larger ones. Two modes, chosen by whether you pass z_order_columns:
- Bin-pack (default,
z_order_columnsempty) — coalesce small files per partition. - Z-order — rewrite files clustered by the given columns, improving read locality for queries that filter on them.
Request
dodil data
# Bin-pack
dodil data table optimize events -b kb-prod
# Z-order
dodil data table optimize events -b kb-prod \
--z-order-column event_type --z-order-column user_idNote the field names: db_id and table, not bucket and table_name. There is no target_file_size_mb field — the target size is not caller-tunable.
Response
dodil data (-o json)
{
"table_name": "events",
"version": 44,
"optimize_type": "compact",
"files_added": 1,
"files_removed": 12
}Six fields — there are no partitions_optimized, total_considered_files, total_files_skipped, num_batches, bytes_added or bytes_removed counters. files_removed − files_added is the packing win.
When to call: after large bulk writes, nightly batches, migrations. Routinely-written HTAP tables stay reasonably packed on their own.
VacuumTable
Permanently delete Delta files that are no longer referenced and are older than the retention window. This is destructive: vacuumed files are gone, and any version that depended on them can no longer be read or restored.
Request
dodil data
# Dry run first — always
dodil data table vacuum events -b kb-prod --dry-run
# Real run (168 h is both the default and the floor)
dodil data table vacuum events -b kb-prod --retention-hours 168Four fields. There is no disable_retention_check — no bypass flag exists on the request, on the CLI, or anywhere in the plane. The auto-maintenance loop passes enforce_retention: true unconditionally and clamps the configured window with max(configured, 168); MIN_VACUUM_RETENTION_HOURS is a hard 168. Sources: proto/api/tables.proto:568-573, crates/compactor/src/maintenance/config.rs:20, runner.rs:411, cli-shell/cli-k3/cmd/table.go:714-715.
Response
dodil data (-o json)
{
"table_name": "events",
"version": 44,
"dry_run": false,
"files_deleted": 12
}The plane returns a count, never a path list — there is no files_deleted_paths field. A dry run reports what would be deleted under the same shape.
Vacuum destroys the history
RESTOREdepends on. Once the files backing version N are vacuumed,RESTORE … TO VERSION AS OF Nfails and any time-travel read of that version fails with it. Take the restore target you might need before you vacuum, and remember that the 168-hour floor is the only protection you get — there is nothing to relax and nothing to tighten below it.
Compact
Force the WAL to drain into Delta now. The plane does this on a background tick; call it manually for:
- After bulk writes — materialise the rows in Delta for analytical reads immediately.
- Before maintenance —
CompactthenOptimizeTableis the canonical post-batch sequence. - Tests and e2e checks — drain the log so a warehouse read is deterministic.
Request
dodil data
dodil data table compact events -b kb-prodTwo fields. There is no batch_size — the drain sizes itself.
Response
dodil data (-o json)
{
"wal_entries_processed": 247,
"wal_unique_keys": 189,
"drained_high_ulid": "01JXQ7…"
}| Field | Meaning |
|---|---|
drained_high_ulid | The highest ULID now in Delta. Absent when nothing drained. |
wal_entries_processed | WAL blobs read this tick |
wal_unique_keys | Distinct PKs after newest-per-key dedup — lower than wal_entries_processed is normal |
fence_aborted | A competing drain held the fence; this call did nothing |
truncated | The drain hit its internal cap and the log has more — re-run until it returns false |
There are no rows_merged, rows_rejected, tombstones_seen or last_drain_target_version fields. For standing backlog, read GetDatabaseStats — wal_segments, wal_entries_estimate, wal_high_ulid, and per-table wal_backlog_entries / rejected_rows (proto/api/tables.proto:770-823).
RESTORE
Rollback is a SQL statement, not an RPC. It is DDL-class: it lowers to the same spec_json compactor route TRUNCATE uses, so it rides Tables.Execute and needs no endpoint of its own (crates/tables-sql/src/restore.rs:1-27).
RESTORE TABLE events TO VERSION AS OF 40;
RESTORE TABLE events TO TIMESTAMP AS OF '2026-05-27T08:00:00Z';TABLE is optional. The two anchors are mutually exclusive. The timestamp form requires an offset (Z or +02:00) — a naive local timestamp has no defined meaning across a fleet spanning regions, so it is rejected rather than guessed. It resolves to the last version committed at or before that instant.
psql
psql "postgresql://$USER:$TOKEN@pg.uk-lon-1.dodil.io:5432/kb-prod?sslmode=require" \
-c "RESTORE TABLE events TO VERSION AS OF 40"A restore is itself a forward commit — it publishes the target version’s file set as a new version, so the restore is restorable and history is preserved (until you vacuum it away).
The two things a restore does that surprise people
1. It destroys the tail, and wipes the WAL to make that stick. Rows written after the target version are gone — that is the operation, not a side effect. The compactor also wipes the un-drained WAL, because otherwise those rows would fold back in on the next drain. If the wipe fails, the response carries a loud warning:
RESTORE committed, but the WAL wipe reported '<err>' — rows written after the restored version may resurrect on the next drain; re-run the RESTORERe-run it. Source:
crates/coordinator/src/execute/ddl_stmt.rs:120-128,167-180,crates/delta-store/src/restore.rs.
2. Secondary indexes are NOT rolled back. An index is a derived table with its own history and no version corresponding to the base table’s target. Read-time revalidation and backfill reconcile them afterwards, exactly as they do after any other out-of-band change. When the table has indexes, the response says so:
secondary indexes (<cols>) were NOT rolled back — an index has its own history with no counterpart to the base's restored version; read-time revalidation and backfill reconcile themSource:
ddl_stmt.rs:181-193.
Restoring a table that does not exist is a FAILED_PRECONDITION, not a silent no-op. The plane also clears the table’s cached row-count estimate rather than leaving a stale one that would skew join planning.
Finding a restore target
There is no commit-log read API — no History RPC, no SHOW HISTORY statement, no route. The plane’s SHOW vocabulary is SHOW DATABASES, SHOW TABLES, SHOW GRAPHS, SHOW VECTOR INDEXES and SHOW MIGRATIONS; none of them list Delta commits.
What you can get:
- The current version —
DdlResult.versionon any DDL, andTableStats.delta_versionfromGetDatabaseStats. - A point-in-time anchor — use
TO TIMESTAMP AS OFinstead of a version number when you know when rather than which. - A single historical row —
GetRowacceptsas_of_version, a pure historical snapshot with no live overlay (proto/api/tables.proto:292-294). It is a PK point lookup, not a scan.
Recording the version returned by each write is the practical answer; the plane does not keep a queryable log for you.
See also
- Execute — the RPC every SQL statement here rides
- Execute → DDL —
TRUNCATE,RESTORE,CREATE INDEXand the rest of the DDL surface - Execute → DELETE — why a delete does not free space until all three of these have run
- Recipes → Time travel + Restore — the runnable flow, with the vacuum interaction