Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/skade

skade-flight/README.md

skade-flight

Apache Arrow Flight SQL over a skade Iceberg warehouse. DataFusion SQL in, streamed Arrow out. The door Power BI, the Arrow Flight SQL ODBC driver, ADBC (adbc_driver_flightsql) and DataFusion clients connect to.

This is skade remote: embedded callers link skade and query in-process; remote callers hit this server. There is no second query engine — skade is DataFusion.

skade-flight --warehouse /var/lib/skade/warehouse --listen 0.0.0.0:10009 \
             --token "$(cat ~/.skade/token)" --basic pbi:hunter2

What a client gets

Surface Behaviour
Handshake Basic user:pass → bearer token (Power BI / ODBC flow); bearer accepted directly
GetSqlInfo ~70 rows generated from the typed builder — declares exactly what DataFusion 55 plans (read-only, no transactions, full outer joins, subqueries, unions, " quoting)
GetXdbcTypeInfo 13 ODBC type rows for the Arrow types an Iceberg table carries
GetCatalogs / GetDbSchemas / GetTables catalog skade, one schema per Iceberg namespace, one row per table with its Arrow schema (t$snapshots / t$manifests metadata tables are hidden from the listing but stay queryable)
GetFlightInfo(statement) the statement is planned, not run; the result schema comes back with a ticket carrying the SQL
DoGet plan + execute; the SendableRecordBatchStream goes straight into the Flight encoder — nothing is collected
Prepared statements stateless: the handle is the SQL
DoPut update / ingest refused by name — the door is read-only

Bad SQL is INVALID_ARGUMENT with the planner's message; everything else is INTERNAL.

Naming

SELECT * FROM t resolves in skade's default namespace (main). skade.<namespace>.<table> always works. Unquoted identifiers are lowercased, quoted ones preserved — DataFusion's rules, and what GetSqlInfo declares.

Performance shape

Auth

The bearer token comes from the ONE nordisk ladder (nornir-service): --token → $SKADE_FLIGHT_TOKEN → ~/.skade/token. Empty everywhere means the server is open and says so on stderr. --basic USER:PASS (or $SKADE_FLIGHT_BASIC) is the credential the handshake exchanges for the token. TLS / mTLS: --tls-cert, --tls-key, --tls-client-ca with the tls feature.

Deploying (from the first Windows test, 2026-09-09)

Lifecycle door

skade-flight --unit                 # print the systemd unit --install writes
skade-flight --install [--system] [--env K=V]...
skade-flight --start | --stop | --status | --uninstall

Rendered and installed through skidbladnir. Never copy the binary by hand, never write a drop-in.

As a library

let wh = skade::Warehouse::open("/var/lib/skade/warehouse").await?;
let engine = Arc::new(skade_flight::Engine::new(wh).await?);
let svc = skade_flight::SkadeFlightService::new(engine, Arc::new(Auth::bearer(token)));
// add FlightServiceServer::with_interceptor(svc, skade_flight::auth::interceptor(auth)) to your router

korp mounts it this way beside its Cypher Flight SQL service (knut's graphar-flight): two dialects, two services, one wire.

Locking

The warehouse's redb catalog is single-writer. skade-flight opens it like skade-katalog serve does — run one of them per warehouse directory, not both. (A consumer that wants both SQL and Iceberg-REST on one warehouse mounts this service into the same process.)

Tests

tests/tpch_flight_sql.rs is TPC-H through the door as a GATE, not a report: all 22 queries (bench/tpch_shared.rs's texts, by inclusion) over tpchgen data. tpch_22_execute_over_flight_sql runs at SF 0.01 in the ordinary suite; tpch_sf1_answers_are_canonical_over_flight_sql is #[ignore] and gates every row count, Q1's A/F count_order (1 478 493) and Q6's revenue (123 141 078.2283) against the dbgen answer sets tpchgen bundles — measured on oden 2026-09-10: 8 661 245 rows loaded in 2.4 s, 22/22 canonical, 2.1 s total, median 75 ms, slowest Q18 0.3 s (release):

TMPDIR=~/scratch/cargo/skade-b5/tpch-tmp \
  cargo test --release --test tpch_flight_sql tpch_sf1 -- --ignored --nocapture

q15_spec_create_view_runs_or_is_refused_by_name_never_an_internal_error sends Q15 as the spec writes it (CREATE VIEW revenue0 …; SELECT …; DROP VIEW) one statement at a time and accepts two outcomes only: the three run through a session view and the SELECT is one row, or a refusal that names the view and the CTE rewrite. Before 5c4ef64 the door answered Internal error: … register_table does not support tables with data — the test was written red on that and went green when session views landed.

cargo test runs a real tonic server on an ephemeral port and drives it with the stock FlightSqlServiceClient: introspection, SELECT * FROM "t", filter/order/limit folding, aggregates, a two-table join, prepared statements, bad SQL, refused writes, a late commit becoming visible, and the Basic → bearer handshake with wrong-password refusal.

Part of the nordisk constellation.

ODBC dialect shim

Power Query's SQL92 generator and the Flight SQL ODBC driver forward ODBC escapes verbatim: SELECT TOP n, {d '…'} {t '…'} {ts '…'}, {fn UCASE(x)}, {oj …}, {escape '\'}. odbc.rs rewrites them before planning (~60 ODBC scalar functions mapped, TIMESTAMPADD/TIMESTAMPDIFF for every SQL_TSI unit, CONVERT(x, SQL_T) → CAST). Plain SQL is returned borrowed, untouched.

What the ODBC driver needs, and gets

From the driver sources (dremio/flightsql-odbc, apache/arrow's ODBC donation, GizmoSQL's fork): every schema field carries ARROW:FLIGHT:SQL:TYPE_NAME, PRECISION, SCALE, IS_SEARCHABLE (the driver dereferences them unchecked, and Power Query folds only on columns whose TYPE_NAME is in GetXdbcTypeInfo); the three bitmask infos the driver reads first-bit-wins carry ONE bit; the function lists use ODBC names; the handshake always answers with a Bearer header; GetPrimaryKeys answers Iceberg identifier fields; the FK commands answer empty sets with the spec schema.

Datatype matrix

tests/datatype_matrix.rs writes one table with every Arrow type the Iceberg bridge accepts and drives 14 × 7 cells over the wire: wire type, values, predicate, ORDER BY, CAST to VARCHAR, aggregates, ODBC type row. Every cell is a functional_status row; cargo test --features testmatrix drains them and SKADE_FLIGHT_MATRIX_OUT=<file.jsonl> writes them. Reds that are not ours are named in KNOWN_RED with the reason, and the test fails if one of them turns green unannounced.

col wire_type values predicate order_by cast_varchar aggregate xdbc_row field_metadata
b, i16→i32, i32, i64, f32, f64, dec, d, ts, tstz, tsns, s, bin ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅
t (TIME) ✅ ✅ ✅ (TIME '…' lowered ns→µs before coercion, time_literal.rs) ✅ ✅ ✅ ✅ ✅

Power BI matrix

tests/powerbi_matrix.rs — 129 statement shapes Power Query's ODBC path emits (navigator, projection, filter, sort, top/limit, aggregate, join, cast, string, date, numeric, plus 38 documented verbatim from Microsoft's diagnostics and captured traces: CRLF formatting, "ITBL"/"OTBL" derived tables, cast("id" as DOUBLE) = 3, the Date.From timestampadd(timestampdiff) nesting, DirectQuery limits). 129/129. Two matrix reds were vendored-Iceberg bugs and are fixed there: no Double→Float datum conversion (no float column predicate could push down) and no Double→Long (Power Query's numeric equality shape failed at scan).

The first run of the matrix found that no predicate on a FLOAT column could be pushed down (the vendored Iceberg Datum::to had no Double→Float arm). Fixed in vendor/iceberg-arrow59.

Benchmark

cargo run --release --example flight_bench          # 2M rows, 8 clients
SKADE_FLIGHT_BENCH_ROWS=20000000 cargo run --release --example flight_bench

One BenchResult JSON line per arm on stdout (the nordisk envelope), a table on stderr. Arms: plan latency, GetSqlInfo and GetTables round trips, engine-only scans (no wire), raw-frame scan (no client decode), full and projected scans, filter pushdown, aggregate, GROUP BY, and 8 concurrent full scans.

2M rows × 6 columns, 16 cores, loopback, both peers at 1 MiB HTTP/2 frames:

arm result
plan_latency (cached) 40–80 µs/op
engine_scan_full (no wire) 60M rows/s
scan_full_raw (no client decode) 37M rows/s, 2.0 GB/s
scan_full 35–40M rows/s
scan_projected 87M rows/s
filter_pushdown / aggregate / group_by 200–270M base rows/s

The single-stream ceiling is HTTP/2 framing: 16 KiB frames gave 23M rows/s, 1 MiB frames 40M. The client's advertised frame size governs DoGet.