Execute — API Reference
Package: dodil.k3.tables.v1 · Service: TableService
The canonical SQL surface. Send a DuckDB SQL string; K3’s planner parses it, picks the right dispatch strategy (keyed/non-keyed write, eventual/strong/federated read, DDL, CTAS), and runs it. Every shape Data RPCs cover is reachable here, plus everything they don’t (JOINs, CTEs, window functions, CTAS, ALTER TABLE).
| RPC | HTTP |
|---|---|
Execute | POST /:bucket/tables/_execute |
Materialize | POST /:bucket/tables/:source_table/materialize |
Request / response
rpc Execute(ExecuteRequest) returns (ExecuteResponse);
message ExecuteRequest {
string bucket = 1;
string sql = 2; // DuckDB-flavored SQL
Freshness freshness = 3; // Read-freshness override — ignored for write/DDL
}
// Response is a oneof — the variant matches the SQL kind.
message ExecuteResponse {
oneof result {
ExecuteQueryResult query = 1;
ExecuteWriteResult write = 2;
ExecuteDdlResult ddl = 3;
}
}
message ExecuteQueryResult {
repeated string columns = 1;
repeated string rows = 2; // JSON-encoded rows
int64 row_count = 3;
ServedBy served_by = 4; // WAREHOUSE | MERGED | OLTP
QueryStrategy strategy = 5;
string strategy_reason = 7; // e.g. "table is not partitioned"
repeated string warnings = 6;
}
message ExecuteWriteResult {
int64 rows_written = 1;
WriteStrategy strategy = 2;
bool noop = 3; // true when keys list resolved to zero rows
bool pending_drain = 4; // true when write went through the write log
string target_table = 5;
repeated string warnings = 6;
}
message ExecuteDdlResult {
string op = 1; // "create_table" | "create_table_as_select" | "alter_table" | "drop_table"
string target_table = 2;
optional int64 version = 3; // present for CREATE/ALTER; absent for DROP
}Generic Execute invocation via grpcurl:
grpcurl \
-H "Authorization: Bearer $DODIL_TOKEN" \
-d '{
"bucket": "kb-prod",
"sql": "SELECT 1"
}' \
$K3_GRPC \
dodil.k3.tables.v1.TableService/ExecuteSQL constraints: one statement per call. No BEGIN/COMMIT/ROLLBACK. The planner refuses both — see Refusals.
For the dialect itself (types, JSON ops, statement shapes), see SQL Compatibility.
Sub-pages — by SQL shape
- DDL —
CREATE TABLE(with PRIMARY KEY + PARTITIONED BY), CTAS (type inference),ALTER TABLE ADD COLUMN,DROP TABLE. Start here — define the table before you query or write to it. - SELECT — read strategies (
UNARY_WAREHOUSE,UNARY_MERGED_STRONG,FEDERATED_AGGREGATE / SCAN / TO_SINGLE), JSON column reads, joins / CTEs / windows. - INSERT — keyed (
KEYED_INSERT_SINGLE / BULK / FROM_SELECT) and non-keyed (NON_KEYED_INSERT,NON_KEYED_INSERT_FROM_SELECT). - UPDATE — keyed (
KEYED_UPDATE,KEYED_RANGE,KEYED_FROM_SUBQUERY) and non-keyed (with WAL-bypass warning and the two safe patterns). - DELETE — keyed (
KEYED_DELETE,KEYED_RANGE_DELETE,KEYED_DELETE_FROM_SUBQUERY) and non-keyed. - MERGE —
MERGE_ROWS / MERGE_QUERY / MERGE_TABLE,WHEN MATCHED THEN DELETE, pre/post-drain semantics. - Materialize — structured
SELECT INTO-style RPC; same dispatcher as CTAS with separate inputs for source / SQL / target / partitioning / mode.
Freshness — read-side request control
For SELECT plans, you control freshness via ExecuteRequest.freshness; the planner picks the strategy based on table size + partitioning + the query shape.
Freshness request | Typical strategy | served_by | What it does |
|---|---|---|---|
EVENTUAL (default) | UNARY_WAREHOUSE or FEDERATED_* | WAREHOUSE | Delta-only — OLAP-optimized, may miss recent un-drained writes |
STRONG | UNARY_MERGED_STRONG | MERGED | Write log + Delta merged on read — read-your-writes |
OLTP_ONLY | (single-pod write-log scan) | OLTP | Write log only — niche; for point lookups during heavy backlog |
QueryStrategy — read-side observability
Returned on ExecuteQueryResult.strategy. Full per-strategy walkthrough on SELECT.
| Strategy | When the planner picks it |
|---|---|
UNARY_WAREHOUSE | Default analytical read — Delta-only, no fan-out |
UNARY_MERGED_STRONG | freshness=STRONG — single-pod merge of write log + Delta |
FEDERATED_AGGREGATE | Large partitioned table + aggregate SELECT — fan-out + reduce |
FEDERATED_SCAN | Large partitioned table + non-aggregate SELECT — fan-out + concat |
FEDERATED_TO_SINGLE | Federated plan downgraded — strategy_reason says why |
WriteStrategy — write-side observability
Returned on ExecuteWriteResult.strategy. The planner classifies the SQL into one of 16 variants based on: (1) does the table have merge_keys, (2) does the predicate match all merge_keys, (3) is the SQL shape INSERT / UPDATE / DELETE / MERGE.
| Strategy | SQL shape | Page |
|---|---|---|
KEYED_INSERT_SINGLE / _BULK / _FROM_SELECT | INSERT INTO pk_table VALUES (...) / multi-VALUES / INSERT ... SELECT | INSERT |
NON_KEYED_INSERT / _FROM_SELECT | INSERT INTO no_pk_table ... | INSERT |
KEYED_UPDATE / KEYED_RANGE / KEYED_FROM_SUBQUERY | UPDATE with PK predicate / range / subquery | UPDATE |
NON_KEYED_UPDATE ⚠️ | UPDATE whose predicate doesn’t match merge_keys | UPDATE |
KEYED_DELETE / KEYED_RANGE_DELETE / KEYED_DELETE_FROM_SUBQUERY | DELETE with PK predicate / range / subquery | DELETE |
NON_KEYED_DELETE ⚠️ | DELETE whose predicate doesn’t match merge_keys | DELETE |
MERGE_ROWS / MERGE_QUERY / MERGE_TABLE | MERGE INTO ... USING ... (source = VALUES list, subquery, or table) | MERGE |
Keyed strategies route through the write log → compactor MERGE on next drain. Safe for concurrent writes; visible immediately under freshness=STRONG.
Non-keyed strategies ⚠️ go directly to Delta, bypassing the write log. If pending log entries for the same rows haven’t compacted yet, the next drain may overwrite the Delta-side change. See UPDATE → Non-keyed safe patterns.
ServedBy
Returned on ExecuteQueryResult.served_by to make it obvious which engine answered.
| Value | Meaning |
|---|---|
SERVED_BY_WAREHOUSE | Delta-only read |
SERVED_BY_MERGED | Write log + Delta merged at query time |
SERVED_BY_OLTP | Write log only |
What you can put in an Execute SQL string
| SQL shape | Strategy family | Routes through |
|---|---|---|
SELECT (no freshness override) | UNARY_WAREHOUSE / FEDERATED_* | Delta |
SELECT ... freshness=STRONG | UNARY_MERGED_STRONG | Write log + Delta |
INSERT INTO pk_table ... | KEYED_INSERT_* | Write log |
INSERT INTO no_pk_table ... | NON_KEYED_INSERT* | Delta |
UPDATE with PK predicate | KEYED_UPDATE / _RANGE / _FROM_SUBQUERY | Write log |
UPDATE without PK predicate | NON_KEYED_UPDATE ⚠️ | Delta-direct |
DELETE with PK predicate | KEYED_DELETE / _RANGE_DELETE / _DELETE_FROM_SUBQUERY | Write log |
DELETE without PK predicate | NON_KEYED_DELETE ⚠️ | Delta-direct |
MERGE INTO ... USING <values|select|table> | MERGE_ROWS / MERGE_QUERY / MERGE_TABLE | Write log |
CREATE TABLE [AS SELECT] | DDL | Delta + sidecar |
ALTER TABLE ADD COLUMN | DDL | Delta + sidecar |
DROP TABLE | DDL | Delta + sidecar |
Refusals
The following are valid DuckDB SQL but K3 refuses them:
| Refused | Why | Workaround |
|---|---|---|
BEGIN / COMMIT / ROLLBACK | No transaction coordinator | For multi-statement ACID, use Postgres. Individual Execute calls are atomic against Delta. |
Multi-statement bodies (stmt1; stmt2;) | One SQL per call | Chain on the caller side. |
ALTER TABLE DROP COLUMN | Not implemented | Recreate via CTAS without the column. |
ALTER TABLE RENAME COLUMN | Not implemented | Recreate via CTAS with the new name. |
When the response says noop
ExecuteWriteResult.noop = true means the planner picked a strategy but the keys list resolved to zero rows — no dispatch happened. Common cases:
KEYED_RANGE/KEYED_RANGE_DELETEwhose range matched no existing PKsKEYED_FROM_SUBQUERYwhere the subquery returned empty
Read it as a clean “no rows affected” — same outcome as a SELECT with zero matches.
See also
- Data — typed shortcuts for Query / Insert / Merge / Update / DeleteRows
- Tables — table lifecycle + DescribeTable (post-drain row classification)
- Maintenance → Compact — force write-log drain before non-keyed writes
- Core Concepts → WriteStrategy + QueryStrategy — full enum reference
- SQL Compatibility — DuckDB dialect specifics (types, JSON ops, refusals)
- Recipes → Manual table + CTAS / Materialize — runnable end-to-end flows