Design: std::influxdb — querying InfluxDB 3 as a DataFrame source
Status: spike complete (2026-09-22), ready for implementation
Goal: add a new stdlib source function alongside std::df::fromcsv — astd::influxdb module whose query function returns a DataFrame<'a>
built by querying an InfluxDB 3 database over its Arrow Flight API.
0. Design constraint (per Nathaniel, 2026-09-22)
The host does not accept an arbitrary query string. The query is always
of the form SELECT <cols> FROM <table>, where the selected columns are
known statically: they are the field labels of the DataFrame<'a> row type
at the call site (the same type-spec mechanism fromcsv uses for
read-time validation). This removes SQL injection surface, makes the result
schema statically determined, and lets the compiler's type-checker (not the
server) reject unknown columns for the caller.
1. How fromcsv works today (the template)
A stdlib module is three layers:
- Guest wrapper —
stdlib/src/df.blr. Declaresresource linear Frame, theextern fn fromcsv_private(path: string, row: reflect::TypeSpec) -> Frame;import, andpub fn fromcsv(path: string) -> DataFrame<'a> { to_dataframe(fromcsv_private(path, type)) }.
Thetypein value position closes the caller's inferred row type into a
type-spec node list passed to the host. - WIT boundary —
stdlib/wit/df.wit. Packageblr:df, interfacedf-interface(resourcesframe/cached-frame/table, plusfromcsv-private: func(path: string, row: list<type-spec-node>) -> frame),
andworld dfwhich imports and re-exports the interface. - Host implementation —
stdlib/src/df.rs.wasmtime::component::bindgen!({ world: "df", with: { frame: DataFrame, … } })
maps theframeresource to a DataFusionDataFrameheld in aResourceTableowned bydf::Host.fromcsv_privatereads via a DataFusionSessionContext, validates the schema against the row type-spec at read
time (validate_csv_schema), and pushes the frame.lang/src/exec.rs
links the world:Df::add_to_linker::<_, HasSelf<_>>(linker, |state| &mut state.df).
influxdb::query follows the same shape; the file read is replaced by a
Flight DoGet, and the type-spec is used to build the query, not just
validate it.
2. Spike results (verified 2026-09-22)
Environment: quay.io/influxdb/influxdb3-core:latest
(= 3.12.0-nightly, serve --node-id spike --object-store memory),
client = scratch bin on arrow-flight 57 + tonic 0.14
(kept at /tmp/spike while the machine is up; the essential code is in
§4.3). Server source checked against main and tag v3.11.4
(core/service_grpc_flight/, influxdb3/src/commands/serve/).
2.1 Endpoint & transport
- The public Arrow Flight service is multiplexed onto the HTTP listener.
v3.11.4 source: single combined "HTTP/gRPC service",DEFAULT_HTTP_BIND_ADDR = "0.0.0.0:8181"(Core). There is no separate
gRPC bind flag anymore. Earlier 3.x docs mention gRPC port9092; in the
current nightly 9092 is an internal gRPC port that rejects public Flight
requests ("operation was cancelled"). The user-suppliedendpointis the
HTTP address (e.g.localhost:8181), which covers both port layouts. - Plaintext gRPC (no TLS in the spike; TLS = later).
- Auth: gRPC metadata
authorization: Bearer <token>
(FlightClient::add_header). Wrong/missing token → cleanUnauthenticatedstatus: "The request does not have valid
authentication credentials".
2.2 Request format — no GetFlightInfo, direct DoGet with a ticket
Server code (core/service_grpc_flight/src/request.rs, identical onmain and v3.11.4) documents the "native IOx API": encode the query as
a Ticket, call DoGet(Ticket) directly, receive a FlightData stream.GetFlightInfo is only part of the separate FlightSQL flow.
The ticket may be encoded three ways; the server tries them in order:
influxdata.iox.querier.v1.ReadInfoprotobuf wrapped in a protobufAny
(type.googleapis.com/influxdata.iox.querier.v1.ReadInfo),- bare
ReadInfoprotobuf, - JSON (the encoding the official Go client uses):
{ "database": "spike", "sql_query": "SELECT host, usage FROM cpu", "query_type": "sql" }
We use the JSON ticket — no vendored proto, no prost codegen, and the
format is documented server-side as a client contract. ReadInfo
protobuf remains the upgrade path if JSON is ever dropped.
2.3 SQL, not InfluxQL
query_type: "sql"returns exactly the selected columns.query_type: "influxql"adds a spuriousiox::measurementcolumn →
breaks the "columns == declared row fields" invariant. SQL is the wire
language.- Identifier quoting:
"host"/"cpu"double-quotes work; unquoted works
for simple names. blr labels are snake_case, so the host emits labels
unquoted and the user-supplied table name as-is (quoted only if it is not
a simple[a-z][a-z0-9_]*identifier). - Missing column →
InvalidArgument: "No field named nope. Valid fields
are cpu.active, cpu.host, …"; missing table → "table
'public.iox.my table' not found". Both surface as tonicStatus— ideal
for panic messages. (The compiler already rejects unknown labels for
the caller; these catch table/renamed-column mismatches at runtime.)
2.4 Result stream
DoGetyields a multi-batch stream (5 rows arrived as 3+1+1 batches
in the spike) → host mustconcat_batches(same ascollect_private).- Columns come back sorted alphabetically by name — identical to blr's
canonical (sorted-by-label) row order. If the host emits the SELECT list
in sorted-label order, input, output, and blr row order all agree. - Column types (from the spike's
cputable — LP-written tags + fields):- tags →
Utf8, nullable - fields →
Int64/Float64/Boolean, nullable time→Timestamp(Nanosecond, None), non-nullable- no dictionary encoding (tags are plain Utf8)
- every field carries
iox::column::typemetadata
(…::tag,…::timestamp,…::field::{integer,float,boolean}) —
a server-authoritative column-kind hint the host may use for
diagnostics.
- tags →
- Missing field values arrive as nulls (a row written without
usage
→usage = NULL). blr has no null/option today andcollectpanics on
nulls; v1 panics with a clear message (see §4.3 step 5). - An empty result is a zero-batch stream with no schema at all. The
host synthesizes the frame schema from the type-spec instead (§4.3 step
6).
3. Proposed user-facing API
import std::influxdb
pub fn main() -> df.DataFrame<'a> {
influxdb::query("localhost:8181", "my-token", "spike", "cpu")
}
influxdb::query(endpoint: string, token: string, database: string, table: string) -> DataFrame<'a>— linearDataFrame, behaves exactly
like afromcsvresult (filter/map/join/aggregate/collect all work).- The selected columns are the row fields of
DataFrame<'a>as
type-checked at the call site: the type-spec's row labels become the
SELECT list, soDataFrame<'a>for a 3-field row issuesSELECT a, b, c FROM <table>.
An open row (DataFrame<{'a .. 'b}>-style,RowOpentype-spec —
"nothing selected") issuesSELECT * FROM "table". tokenis an empty string for unauthenticated servers (--without-auth
sentinel convention, likelimit'sfetch == 0).- Column type compatibility (int/float mutual, timestamp-as-int, …) is
still validated against the returned schema at query time, reusing
fromcsv's validator; extra server columns are impossible by
construction for a closed row (forSELECT *, unselected columns are
ignored, exactly like fromcsv's extra-file-columns rule). - Row order is unspecified (InfluxDB makes no ordering promise without
ORDER BY); usersdf::sortas needed — same as a CSV with no sort.
Deliberately deferred: Config record (TLS, timeouts), WHERE/filter
pushdown, InfluxQL.
4. Architecture
4.1 WIT — new stdlib/wit/influxdb.wit
package blr:influxdb {
interface influxdb-interface {
use blr:df/df-interface.{frame};
use blr:reflect/reflect-interface.{type-spec-node};
// The query read: the row type-spec both selects the columns of
// the generated `SELECT … FROM` and is validated against the
// result schema, like fromcsv-private validates the file schema.
query-private: func(
endpoint: string,
token: string,
database: string,
table: string,
row: list<type-spec-node>,
) -> frame;
}
}
world influxdb {
import blr:df/df-interface;
import blr:reflect/reflect-interface;
import blr:influxdb/influxdb-interface;
export blr:influxdb/influxdb-interface;
}
wasmtime::component::bindgen! picks it up automatically from wit/.
4.2 Guest — new stdlib/src/influxdb.blr
import std::df
import std::reflect
extern fn query_private(endpoint: string, token: string, database: string, table: string, row: reflect::TypeSpec) -> df.Frame;
pub fn query(endpoint: string, token: string, database: string, table: string) -> df.DataFrame<'a> {
to_dataframe(query_private(endpoint, token, database, table, type))
}
DataFrame/to_dataframe are compiler builtins bridging the typedDataFrame<'a> and the Frame resource (lang/src/runtime/df.rs), so the
wrapper is a thin cast. The new embedded stdlib file makesimport std::influxdb resolve automatically.
4.3 Host — new stdlib/src/influxdb.rs
wasmtime::component::bindgen!({
world: "influxdb",
imports: { default: async },
with: { "blr:df/df-interface.frame": DataFrame },
});
Shared frame table. Frame reps must resolve in one ResourceTable:
frames produced here are consumed by df::filter etc. viadf::Host::frames. So the influxdb interface's Host trait is
implemented on the existing df::Host type (impl in influxdb.rs),
and lang/src/exec.rs just links the world against the same state field:
blr_stdlib::influxdb::Influxdb::add_to_linker::<_, HasSelf<_>>(linker, |state| &mut state.df)
.map_err(|err| anyhow::anyhow!("failed to link std::influxdb: {err}"))?;
query_private(endpoint, token, database, table, row) pipeline
(current host style: unwrap()/descriptive panic!):
- Build the SQL from the type-spec:
Row(fields)→SELECT <labels, sorted> FROM <table>(labels unquoted; table quoted
only when not[a-z][a-z0-9_]*);RowOpen→SELECT * FROM <table>. - Connect:
tonic::transport::Endpoint::from_shared("http://{endpoint}")
→connect()→arrow_flight::client::FlightClient::new(channel)
(synchronous constructor in 57.3.1; ifendpointhas no scheme,
prependhttp://). Iftokennon-empty,client.add_header("authorization", "Bearer {token}"). - Query: JSON ticket
{"database", "sql_query": sql, "query_type": "sql"}→client.do_get(Ticket::new(bytes)).await. Map tonicStatus
into the panic message (auth errors, "No field named …", "table … not
found" are already descriptive). - Collect: drain
FlightRecordBatchStream(a futuresStreamofResult<RecordBatch>) intoVec<RecordBatch>;concat_batchesthe
non-empty case (empty case handled in step 6). - Normalize (before MemTable):
Timestamp(_, _)→ cast toInt64epoch nanoseconds (blr has no
time scalar; lossless, arithmetic/sort/collect all work). If the
declared type for that column isstring, cast to RFC 3339Utf8
instead (arrow castTimestamp(Nanosecond) → Utf8emits ISO-8601).Dictionary(_, Utf8)→ cast toUtf8(defensive; the 3.x spike saw
plain Utf8 tags, but 2.x-interop data paths may still use dicts).- Null check: any column with
null_count() > 0→panic!("influxdb::query: column{name}contains nulls")(matchescollect's current behavior; null/option support is a language
follow-up).
- Empty result (zero batches): synthesize the schema from the
type-spec — one field per declared label with the declared arrow type
(Bool→Boolean, Int→Int64, Float→Float64, String→Utf8,
Any/unknown→Null, mirroring fromcsv's empty-fileNullcolumns) — and
build a 0-rowRecordBatch. - Frame:
MemTable::try_new(schema, vec![batch])→SessionContext::read_table(Arc::new(table))(same construction as the
df unit-test helper). - Validate: refactored shared
validate_schema(source, schema, row)
(extracted fromvalidate_csv_schema;source=`influxdb::query` on `{endpoint}`) checks each declared label
exists and is type-compatible — now mostly a defense-in-depth and
diagnostics path, since the server already failed the query on unknown
columns. Rename returned columns to the exact declared labels if the
server folded identifier case (compare case-insensitively). self.frames.push(df).
4.4 Crate wiring
stdlib/Cargo.toml:arrow-flight = "57"— resolves to 57.3.1 in the spike; the workspace
must end up with onearrowcopy (57.x line, compatible with the
DataFusion 51 requirement), soRecordBatch/Schemaare shared with
DataFusion's. Consider a lockfile-assertion test (cf. the stdlib
embedding drift test inlang/src/stdlib.rs).tonic = "0.14"(matching arrow-flight 57's tonic) forEndpoint/Channel.- No
prost, no vendored protos (JSON ticket).
stdlib/src/lib.rs:pub mod influxdb;lang/src/exec.rs: link the world (§4.3). No newStatefield.
5. Phased plan
Phase 0 — Spike: DONE (results in §2; code pattern in §4.3).
Phase 1 — Boundary. stdlib/wit/influxdb.wit, stdlib/src/influxdb.blr,pub mod influxdb;, and a stub query_private (returns an empty
synthesized frame from the type-spec, no network) + Influxdb::add_to_linker.
Proves end-to-end: import std::influxdb, type-spec lowering with the newdatabase/table strings, linearity of the result, and that stub frames
flow through df::filter/collect.
Phase 2 — Host implementation. SQL builder from the type-spec,
tonic/FlightClient, JSON ticket, batch concat, normalization (timestamp,
dictionary, null panic), empty-result synthesis, sharedvalidate_schema refactor, exec.rs link.
Phase 3 — Tests.
- Unit (stdlib, offline): SQL-builder cases (closed row sorted labels, open
row →SELECT *, table quoting); batch→frame normalization on cannedRecordBatches (timestamp→int, dict→utf8, multi-batch concat, null
panic); empty-result synthesis;validate_schemareuse cases. - Integration (opt-in, network): gated on
INFLUXDB_ENDPOINT/INFLUXDB_TOKENenv vars,#[ignore]d when unset; Justfile task
(just influxdb-test) startingquay.io/influxdb/influxdb3-corewithserve --node-id spike --object-store memory, creating a token
(influxdb3 create token --admin --host http://localhost:8181), writing
test data viaPOST /api/v3/write_lp?db=…(line protocol), and running aquery → filter → collectprogram.
Phase 4 — Docs & follow-ups. Knowledge-vault note (this doc), mdbook
page if stdlib docs get one. Follow-ups: TLS endpoint, Config record
(timeouts), InfluxDB 2.x backend (different protocol: Flux over gRPC on
9090 — the query_private shape is unchanged, only request/decode
differ), null/option support, WHERE pushdown, timestamp-first-class type.
6. Open questions (post-spike)
| # | Question | Status |
|---|---|---|
| 1 | Exact ticket format | Resolved: JSON {"database","sql_query","query_type"}; server tries Any(ReadInfo) proto → ReadInfo proto → JSON (request.rs, same on main and v3.11.4) |
| 2 | Is GetFlightInfo required | No — native IOx API is direct DoGet(Ticket) |
| 3 | Public gRPC port | Resolved: multiplexed on the HTTP listener (default 8181 in Core, v3.11+ source-verified); old 9092 = internal-only now. Endpoint is user-supplied either way |
| 4 | Result column types/order | Resolved: alphabetical column order, Utf8/Int64/Float64/Boolean/Timestamp(ns), iox::column::type metadata, no dictionaries in 3.x, nulls for missing values, empty result has no schema |
| 5 | Time representation in blr | Decision: Timestamp → Int64 epoch ns by default (arrow-cast to RFC 3339 string if the column is declared string); revisit if blr grows a time type |
| 6 | Duplicate-arrow hazard | Manage by version: arrow-flight "57" + lockfile; add drift-assertion test if cheap |
| 7 | JSON ticket longevity | It is the documented encoding of the official Go client; ReadInfo proto is the fallback if InfluxDB ever drops JSON |