Skip to content

FermiHDI HD SDK — Developer Guide

How to write an application that puts records into an HD instance and gets answers back, in any of the eight languages the SDK binds.

This is the guide to hd_sdk. It assumes you can build software and have an HD instance to talk to; it assumes nothing about FermiHDI. If you are standing the instance up rather than writing against it, that is install/ADMIN_GUIDE.md.


1. One engine, eight wrappers

There is one implementation. libfermihdi_client is a C++ engine that speaks the FermiHDI wire protocol, holds the mTLS identity, and hands records to your process without copying them. Every language binding is a thin shim onto that engine — they are not eight ports of the same idea, and they do not drift, because there is nothing to drift.

Language Binding Package
C++ the engine itself FermiHDISDK.hpp, libfermihdi_client.so
Python pybind11 fermihdi_sdk
Go cgo fermihdi_sdk
Rust cxx::bridge fermihdi-sdk
Node.js N-API fermihdi-sdk
R Rcpp FermiHDISDK
Julia CxxWrap FermiHDI.jl
Scala / Java JNA fermihdi-sdk
Mojo C FFI FermiHDISDK

What follows describes the engine's model once. The per-language sections then show the same programme in each, and say only what differs.


2. The model

Two clients, because there are two jobs. IngestClient puts records in; QueryClient asks questions. They register separately, hold separate identities, and are used from different parts of an application. Nothing stops one process holding both.

Two networks, and the distinction is not cosmetic. The control plane carries registration, service discovery and query dispatch. The data plane carries records — in on ingest, out on query — over FHDWP, FermiHDI's wire protocol. Both clients take an interface name for each:

FermiHDIQueryClient client("https://proxy:6986", "eth1", "eth0");
//                          proxy (control)      data   control

Pass an empty data-plane interface and it binds every interface, which is fine on a single-homed host and wrong on any other. The SDK reads no environment variables — every address, interface and URL is passed in by the caller. That is deliberate: an application's networking should not change because a shell did.

Query results do not come back from the query call. The Proxy answers with a manifest — which batches were found, which were missing, how each Storage Cluster fared — and the Storage Nodes stream the records themselves, directly to the address your client is listening on. If your client can reach the Proxy but the Storage Nodes cannot reach your client, every query succeeds and returns nothing.

Identity is issued, not configured. register_with_dms() generates a key, submits a CSR, and comes back with a certificate signed by the deployment's CA. From then on the client's connections are mutually authenticated. You need a bootstrap token to do it once; you do not need to manage certificates afterwards.

Records are Arrow. Ingest builds arrow::RecordBatch and sends it without serialising through an intermediate form; query returns arrow::Table, which DuckDB will read in place. That is what "zero-copy" means here — not that no memory is ever touched, but that your records are not translated between representations on the way through.


3. The two clients

IngestClient

register_with_dms(dms_url, node_name, override_key_pem = "")   -> token/identity
discover_scc_targets(proxy_url)                                -> bool
set_scc_targets([SccAddress])                                     explicit alternative
add_control_header(key, value)                                    routing hints for the Proxy
load_dataset_schema(schema_json)                                  what a record looks like

append_record_to_arrow({field: value})                         -> bool
flush_arrow_batch()                                            -> RecordBatch

stream_insert(batch_id, record | records | RecordBatch, broadcast = false)
send_eob(batch_id, broadcast = false)                             end of batch
send_bbn(batch_id, broadcast = false)                             batch boundary notice

get_health_status() / get_metrics()
stop()

The shape of an ingest loop: register, discover, load the schema, then append records until you have a batch worth sending, flush it to Arrow, stream_insert it, and send_eob to close it. broadcast sends to every SCC rather than the one the batch id routes to.

QueryClient

register_with_dms(dms_url, node_name, override_key_pem = "")   -> RegistrationResult
set_scc_targets([SccAddress])                                     direct mode, no Proxy
load_dataset_schema(schema_json)
add_control_header(key, value)

generate_batch_ids_time(start_time, end_time, interval = 0)    -> [BatchID]
generate_hd_query(HDQuery)                                     -> query JSON

execute_stream(sql_or_json, callback)                             per-batch, as they arrive
execute_dataset(sql_or_json)                                   -> arrow::Table
execute_to_sql(fermihdi_sql, ...)                              -> arrow::Table via DuckDB
set_local_aggregations(agg_config)                                fold as results land

get_found_batch_ids() / get_missing_batch_ids()
had_errors() / get_batch_errors()
stop()

Two ways to consume a result, and the choice matters at scale. execute_dataset gives you a table when everything has arrived. execute_stream calls you per batch as the Storage Nodes deliver, which is what you want when the answer is larger than the memory you would like to spend on it.

Always check get_missing_batch_ids(). A query that returns fewer batches than it asked for is a normal, reportable outcome — an SCC timed out, a node was rebalancing — and it is not an error. had_errors() and get_batch_errors() tell you which, and why.


4. The same programme, in each language

Register, query, read the answer as a table.

C++

#include <FermiHDISDK.hpp>

FermiHDIQueryClient client("https://proxy:6986", "eth1", "eth0");
client.register_with_dms("https://dms-core:6986", "analytics-1");
client.load_dataset_schema(schema_json);

auto table = client.execute_dataset(query_json);
if (client.had_errors()) { /* get_batch_errors() */ }

Link against libfermihdi_client. Headers are include/FermiHDISDK.hpp; the build wants the prebuilt library tree for your CPU variant (see §6).

Python — fermihdi_sdk

import fermihdi_sdk

client = fermihdi_sdk.QueryClient("https://proxy:6986", "eth1", "eth0")
client.register_with_dms("https://dms-core:6986", "analytics-1")
client.load_dataset_schema(schema_json)

table = client.execute_dataset(query_json)     # pyarrow.Table, no copy
df = table.to_pandas()

pybind11, so the Arrow table crosses as a pyarrow.Table sharing the same buffers. get_shared_ingest_client() hands back a process-wide ingest client when you would otherwise build one per worker.

Go — cgo

client := sdk.NewQueryClient("https://proxy:6986", "eth1", "eth0")
defer client.Stop()
client.RegisterWithDMS("https://dms-core:6986", "analytics-1")
table, err := client.ExecuteDataset(queryJSON)

cgo owns the engine's memory; the wrapper keeps it out of Go's heap so the garbage collector never moves a buffer the engine is writing into. Stop() matters here — it releases what the finalizer will not.

Rust — cxx

let mut client = fermihdi_sdk::QueryClient::new("https://proxy:6986", "eth1", "eth0")?;
client.register_with_dms("https://dms-core:6986", "analytics-1")?;
let table = client.execute_dataset(&query_json)?;

cxx::bridge gives a checked boundary rather than raw FFI: lifetimes are enforced at compile time, and the engine's errors arrive as Result.

Node.js — N-API

const { QueryClient } = require('fermihdi-sdk');

const client = new QueryClient('https://proxy:6986', 'eth1', 'eth0');
await client.registerWithDms('https://dms-core:6986', 'analytics-1');
const table = await client.executeDataset(queryJson);

V8 buffers map onto the engine's directly. Calls that wait on the network are async and do not occupy the event loop.

R — Rcpp

library(FermiHDISDK)

client <- QueryClient("https://proxy:6986", "eth1", "eth0")
register_with_dms(client, "https://dms-core:6986", "analytics-1")
df <- execute_dataset(client, query_json)   # via the Arrow C data interface

Julia — CxxWrap

using FermiHDI

client = QueryClient("https://proxy:6986", "eth1", "eth0")
register_with_dms(client, "https://dms-core:6986", "analytics-1")
table = execute_dataset(client, query_json)

Scala / Java — JNA

val client = new FermiHDISDK.QueryClient("https://proxy:6986", "eth1", "eth0")
client.registerWithDms("https://dms-core:6986", "analytics-1")
val table = client.executeDataset(queryJson)

JNA resolves libfermihdi_client.so at run time, so it must be on the library path of the JVM process.

Mojo — C FFI

from FermiHDISDK import QueryClient

var client = QueryClient("https://proxy:6986", "eth1", "eth0")
client.register_with_dms("https://dms-core:6986", "analytics-1")
var table = client.execute_dataset(query_json)

5. Beyond fetching rows

The engine carries two analytical libraries so that work can happen where the records already are, rather than after moving them somewhere else.

  • DuckDB. execute_to_sql() runs SQL over the returned Arrow table in process. Joins, aggregates and window functions without a round trip.
  • USearch. HNSW vector similarity over the same buffers, for nearest-neighbour work against retrieved records.
  • Local aggregation. set_local_aggregations() folds results as they land instead of accumulating them, which is what makes a query larger than memory finish.

6. Building

The wrappers all need the C++ engine, and the engine needs its prebuilt library tree for the CPU variant you are targeting — avx512, avx2 or arm64:

cd hd_sdk
./prebuild_libs.sh              # builds the prebuilt/<variant> tree
cmake -B build -DCMAKE_BUILD_TYPE=Release .
cmake --build build --parallel

release_build.sh does the same and then packages every wrapper, which is what CI runs. Per-language build notes are in each wrapper's own README — hd_sdk/python/README.md and so on — and each has a tests/ directory worth reading as worked examples.

The variant has to match the host you will run on: an AVX-512 build faults on its first vectorised instruction on a host without it.


7. When it does not work

Symptom Usually
Registration fails at the handshake Wrong DMS URL, or the bootstrap token is spent — they are single-use per node
Registration succeeds, queries return nothing The Storage Nodes cannot reach your client on the data plane. Check the interface you passed and what your firewall does with FHDWP
Every query returns 503 from the Proxy The Proxy has not compiled its policy matrix yet. It is a startup race; retry
Queries return 401 Your caller has no X-Forwarded-User — the Proxy authenticates nobody itself, and something in front of it must set the identity headers
Fewer batches than expected, no error Normal and reportable. get_missing_batch_ids() says which; an SCC timed out or a node was rebalancing
SIGILL at first use Wrong CPU variant of the prebuilt libraries
Records go in and cannot be found The dataset schema loaded by the ingest client and the query client disagree

8. Where else to look

Where What
../hd_sdk/README.md the SDK's own overview
../hd_sdk/docs/architecture.md how the engine is put together
../hd_sdk/docs/config_guide.md every configuration value
../hd_sdk/docs/interface_binding.md choosing the data and control interfaces
fermihdi_proxy_api.yaml the Proxy API the SDK speaks, as OpenAPI
fhdwp_network_guide.md the wire protocol and its MTU
../install/ADMIN_GUIDE.md standing up the instance to write against