docs.fermihdi.io — overview.md

/ › hd › 2.0.5 › md › sdk

Download overview.mdView raw

HD SDK

The client library for querying and ingesting against an HD storage cluster. C++ at its core, with bindings for eight languages.

All language wrappers are BETA. They build and link against a real SDK — that is verified on every change by ./build_wrappers.sh — but they have not been exercised against a live cluster in the way the C++ core and the Go binding have. Expect rough edges in the less-travelled ones, and read Wrapper maturity before choosing one for production.


The one thing to understand first

The SDK owns the data plane. It does not own the control plane.

A query is two separate conversations with two different pieces of the system, and almost every mistake people make with this SDK comes from thinking it is one.

  your process                     the cluster
  ┌────────────────────┐
  │  your code         │──── 1. control plane ───▶  Proxy ──▶ Storage Cluster
  │                    │      "run this query"              Controllers (SCC)
  │  ┌──────────────┐  │                                          │
  │  │   the SDK    │  │                                          │ fan-out
  │  │ FHDWP        │  │                                          ▼
  │  │ listener     │◀─┼──── 2. data plane ──────────────  Storage Nodes (SN)
  │  └──────────────┘  │      records, out of band
  └────────────────────┘
  1. Control plane. A query is submitted over HTTPS to a Query Proxy, or over gRPC directly to storage cluster controllers. This is a small request that answers one question: which batches actually exist for what you asked?
  2. Data plane. The Storage Nodes then stream the records straight to your process, to an address and port you advertised in the query. They do not travel back through the proxy. The SDK's job is to be listening.

Consequences that surprise people:

Two ways in: through the Proxy, or direct

The control-plane conversation has two forms, and which one you are in decides what the SDK needs before the first query.

Proxy mode is the default. The client submits to the Query Proxy through the customer's query front door; the front door authenticates the caller and the Proxy's policy applies to the query. The SDK discovers the SCC targets and the dataset schema from the Proxy. Nothing registers anywhere.

Direct mode is selected by constructing the query client with an empty proxy_url. The client then registers itself with the DMS as an HD_CLIENT node — register_with_dms() sends a certificate signing request and a registration token, and returns the certificate the DMS-CA signed, the private key, the dataset schema and the SCC targets — and submits queries over gRPC to those SCCs itself. An HD_CLIENT registration draws on the HD_CLIENT element of the licence, or on the HD_ANY pool when the licence has none, exactly as a deployed node does, and the DMS admits it under the same policy. The Proxy's policy does not apply: a direct client is inside the trust boundary, which is why it needs a registration token an administrator issued. A custom ingester built on FermiHDIIngestClient joins a cluster the same way: it registers as HD_CLIENT, receives the SCC targets, and sends its batches straight to them.

Both modes receive records the same way, over the FHDWP data plane on the interface the client names. Direct mode is for services that live inside the deployment, custom ingesters, and the cases where the Proxy's hop is not wanted; proxy mode is for everything a person or an external application does.

Binding Proxy mode Direct mode Ingest
C++ yes yes yes
Python yes yes yes
JavaScript yes yes no
Go, Rust, Scala, Julia, R, Mojo (C ABI) yes targets only no

The C ABI carries fhd_query_client_set_scc_target but no registration call and no ingest client, so the six bindings built on it can be handed SCC targets but cannot register as HD_CLIENT or ingest. Until the C ABI gains both, direct mode and custom ingesters are C++, Python and, for querying, JavaScript.


The lifecycle of a query

   prepare_query(id)          arm the listener FIRST
          │
          ▼
   submit to the proxy        POST /v1/hd/query  ── control plane
          │                   ← found_batches / missing_batches
          ▼
   set_expected_batches()     wait for exactly what exists, not what you asked
   set_filter_verdict()       and what the filter service would have skipped
          │
          ▼
   execute_dataset()          blocks while records arrive on the DATA plane
   execute_to_sql()           returns an Arrow Table
          │
          ▼
   report the ending          complete, or cancel

The table is yours

Every execute call ends in an Arrow table, and nothing here hides it. In C++ execute_dataset() returns the raw record table (one FixedSizeBinary column of the dataset's record width, untouched), execute_to_sql() the projected one, execute_stream() the record batches as they land, and apply_sql() runs SQL over a table you already hold. Across the C ABI, Go and JS the same table travels as an Arrow IPC stream: execute_dataset_to_ipc (raw), execute_to_ipc (projected), apply_sql_to_ipc (yours in, result out). In Python the table is an ArrowTable with to_ipc(), which pyarrow.ipc.open_stream() reads, and table_from_ipc() brings one back for apply_sql(). Decode it, filter it, or hand it to your own engine: the SDK does not translate anything in it.

Why set_expected_batches matters

Your query names a window. The cluster holds whatever it holds. Ask for a day and it may have eleven hours of it.

The control plane answers with found_batches — the batches that exist — and missing_batches. If you wait for the batches you asked for, you wait for ones that were never stored, and the query hangs until its timeout with a partial result. set_expected_batches(found) is what makes a query finish promptly.

Endings, and why they are not optional

A query costs storage-node work for as long as it runs. Because the data plane is one-way, no storage node can tell whether anyone is still listening. A client that vanishes leaves nodes reading NVMe and streaming into a void.

So a query ends in exactly one of two ways:

ending meaning
complete every expected batch arrived. Stop tracking it.
cancel stopping early, for any reason — an error, a timeout, a user interrupt, a shutdown.

The rule: a client that closes early MUST send a cancel first.

That rule is what lets a clean disconnect be read as "done" rather than as abandonment. It is written up in full in .agent/rules/query-lifecycle-standard.md.

The held stream

When you submit through a proxy, the SDK keeps that connection open for the entire life of the query and sends the ending on it. That connection is the only thing telling the cluster you are still there.

submit_query_to_proxy() does all of this — arms the listener, submits, feeds back the batch list, holds the connection, and reports the ending from the same place the completion and cancellation handlers fire. If you use it, endings are handled and you do not need to register a handler at all.

If you submit the query yourself (direct SCC mode, or your own HTTP client), you own the ending. Register set_cancel_handler() and set_complete_handler() and relay them.


Queries

Batch IDs

A batch is the unit of storage and of fan-out. A batch ID is 64 raw bytes, usually written as 128 hex characters. For time-partitioned datasets the ID encodes the bucket timestamp in its last 8 bytes, which is why the SDK can generate the IDs for a time range without asking anyone:

generate_batch_ids_time(from_epoch_sec, to_epoch_sec)

The full-dataset scan guard

An empty batch_ids list is an open query: every batch on every storage node. It is legitimate, and it is the most expensive thing the platform can do. The cost is invisible in the statement that produced it — a SELECT with no WHERE looks exactly like one with a narrow WHERE.

So it cannot be reached by omission:

generate_hd_query() refuses a query with no batch_ids
unless acknowledge_full_dataset_scan is set, and returns an empty string.

If generate_hd_query() hands you an empty string, this is why.

Sub-queries and filters

Predicates are pushed down to the storage nodes. You express them as filters on named schema fields; the SDK compiles them into the mask/match byte strings the nodes evaluate.

Filter { field_name, value, op }        op: EQUALITY | NOT_EQUAL | GREATER | LESS
SubQuery { filters[] }                   filters within a sub-query are ANDed
HDQuery { sub_queries[] }                sub-queries are ORed

Limit: 16 sub-queries. Beyond that the storage node silently drops the excess. Restructure rather than exceed it.

Addressing

Two different addresses, and confusing them is the single most common cause of "the query returned nothing and said nothing":

meaning
data_plane_return_port the port this client binds
data_plane_advertised_endpoint the address storage nodes should send to

They are the same on a flat network and different behind any NAT — a client in a container binds 6987 inside it while the nodes must be told the host's address and published port. Whatever you advertise must appear in the query's targets.

Two clients on one host cannot both bind the default 6987, so the port is a setting, not a constant. It takes effect only before the listener starts.


Health

get_health_status() answers two different questions, and they are kept apart deliberately:

{"status": "ok", "ready": false, "timestamp": 1788589254.13}
field question probe
status is this process alive and answering? liveness
ready can it serve a query yet? readiness

A client that has been constructed but whose engine has not booted — no schema yet, because the proxy was unreachable at init — is "ok" and not ready. It will boot on the first query. Restarting it would not have made the proxy reachable, so reporting it as unhealthy would be the wrong answer to the question a liveness probe is asking.


Building against the SDK

Getting it

The package image carries everything at /opt/fermihdi-sdk:

/opt/fermihdi-sdk/
├── include/          headers, including fermihdi_c_api.h
├── lib/              libfermihdi_client.so, libfermihdi_client_static.a
├── cgo-env.sh        the link line for Go, ready to source
├── go/ python/ rust/ js/ julia/ r/ scala/ mojo/
└── PACKAGE.txt       version, variant, provenance
COPY --from=cr.fermihdi.io/hd/sdk:<version> /opt/fermihdi-sdk /opt/fermihdi-sdk

The link line — read this before debugging a link error

libfermihdi_client.so embeds the HD engine's runtime through a static archive, and a shared object linked against a static archive does not re-export that archive's transitive dependencies. Linking -lfermihdi_client alone fails with undefined references to fmt, hwloc, NORM, boost, c-ares and yaml-cpp.

Every consumer must name them:

-lfermihdi_client -larrow -lnorm -lfmt -lhwloc -lyaml-cpp \
-lboost_program_options -lboost_thread -lcares -lcurl $(pkg-config --libs libdpdk)

For Go this is written for you: . /opt/fermihdi-sdk/cgo-env.sh.

Use -Wl,--no-as-needed ahead of that list if your own code does not reference those symbols directly — with the default --as-needed the linker records no DT_NEEDED entry, the link succeeds, and the failure moves to runtime as an undefined symbol.

Dynamically loaded consumers need static TLS surplus

This affects Python, R and Node — anything the host process dlopens.

The HD engine's runtime uses initial-exec thread-local storage. A library with initial-exec TLS cannot be dlopened into an already-running process unless the loader reserved surplus static TLS, and you get:

cannot allocate memory in static TLS block

Set the tunable before starting the host process:

GLIBC_TUNABLES=glibc.rtld.optional_static_tls=1048576 R -f script.R
GLIBC_TUNABLES=glibc.rtld.optional_static_tls=1048576 python3 app.py

Compiled consumers that link at build time (Go, Rust, Mojo, C++) are unaffected.

Static linking

libfermihdi_client_static.a is built beside the shared library (-DFERMIHDI_BUILD_STATIC=ON, the default). Link it instead of the .so when you want a binary you can copy to a host on its own, with no rpath to get right.


The C ABI

include/fermihdi_c_api.h exports a flat C surface from the shared library, so any language with FFI can use the SDK without a C++ toolchain. It is what the Mojo binding is built on, and it is the recommended base for a new binding.

FhdQueryClient* fhd_query_client_new(proxy_url, dp_iface, cp_iface, return_port);
void            fhd_query_client_free(client);
bool            fhd_query_client_load_schema(client, schema_json);
char*           fhd_query_client_generate_query(client, hdquery_json);
char*           fhd_query_client_submit_to_proxy(client, query_json, query_id);
const uint8_t*  fhd_query_client_execute_to_ipc(client, query_json, sql, &size);
void            fhd_query_client_cancel_query(client, query_id);

Conventions:


Wrapper maturity

All wrappers are beta. Every one is built and linked against a real SDK by ./build_wrappers.sh, which runs in CI, so none of them is broken. What separates them is how much real use they have had.

language status notes
C++ stable the SDK itself
Go beta — most exercised used by four production services
Python beta pybind11; needs the TLS tunable
Rust beta cxx bridge; narrow surface
JavaScript beta N-API; needs the TLS tunable
Julia beta ccall; narrow surface
R beta Rcpp; needs the TLS tunable
Scala beta JNA; JDK 17 only
Mojo beta on the C ABI; explicit close()

Beta means: it builds, it links, its API is documented and exercised by a smoke build. It does not mean every call has been run against a live cluster. If you are choosing for production today, Go has by far the most mileage.


Verifying a change

Building the core and every wrapper, and the tests that guard them, are the developer guide's subject. A consumer of the published package never builds any of it.


Where to go next

Download overview.mdView raw