- Rust 98.3%
- Shell 1.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Generated tonic stubs for the vendored spada.v1 surface (provenance pinned), wrapped: connect with TLS+token, idempotent writes with flush as the visible durability barrier (and its own longer deadline), a byte-and-count-bounded ingest stream that flushes last, a query builder over the common surface with the raw client as the escape hatch, receipts as types (EXACT is a proof; units_dropped surfaced), and S-150 typed error details decoded into a table-driven RetryPolicy — including HomeReleased as the one retryable precondition and SealerFailed as the one where even waiting is wrong. Verified against a live server: quickstart (write, dedup replay, EXACT receipt, passages) and typed_errors (LimitExceeded decoding its setting key as a field) both run clean. 4 unit tests + 1 doc test. Claude-Session: https://claude.ai/code/session_01JrTbCU5Da5hJ19XUrYYxq9 |
||
| examples | ||
| proto | ||
| scripts | ||
| src | ||
| .gitignore | ||
| build.rs | ||
| Cargo.toml | ||
| README.md | ||
spada-sdk — Rust client for the spada search engine
The official Rust SDK for spada's
public spada.v1 gRPC surface: an idiomatic wrapper over generated
tonic/prost stubs, with the semantics the wire cannot express encoded as
types — durability, coverage receipts, and table-driven retry policy.
[dependencies]
spada-sdk = { git = "https://git.teixos.net/yannick/spada-sdk-rs" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
Quickstart
use spada_sdk::{Client, Doc, QueryOptions};
#[tokio::main]
async fn main() -> Result<(), spada_sdk::Error> {
let mut client = Client::connect("http://127.0.0.1:50051").await?;
client
.write("articles")
.doc(Doc::new("munich", "# Munich\n\nBayern beats the rain.").field("lang", "en"))
.mutation_id("ingest-0001") // idempotent: a retry replays, never re-writes
.flush() // the ack becomes a durability barrier
.send()
.await?;
let answer = client
.query("articles", QueryOptions::terms(["bayern"]).k(10))
.await?;
println!("receipt: {}", answer.receipt()); // e.g. "EXACT"
for hit in &answer.hits {
println!("{} {:.3}", hit.doc_id, hit.score);
}
Ok(())
}
Runnable versions: examples/quickstart.rs and
examples/typed_errors.rs — start a dev server
with spada serve --data-dir /tmp/demo --listen 127.0.0.1:50051 and
cargo run --example quickstart.
The three contracts
These are the parts of spada's semantics that a generated stub alone would let you get wrong. The SDK makes each one hard to miss.
1. Durability: flush is the barrier, sealed_segment is not
A non-flush write's ack means accepted and query-visible. The durable
checkpoint completes in the background (spada seals through a pipelined
worker pool — its sealed_segment response flag means a seal was
triggered). When you need "this call returning means it is on disk", use
.flush():
client.write("ns").doc(d).flush().send().await?;
A flush drains the server's seal pool and can legitimately block for
seconds; the SDK automatically gives flushing writes the longer
flush_timeout (default 120 s) instead of the ordinary call deadline.
Client::ingest streams a whole corpus in batches bounded by both
document count and bytes (a count-only bound eventually dies on
limits.max_batch_bytes), and flushes on the final message — so its report
means durable, not merely accepted.
2. Receipts: EXACT is a proof, everything else is a disclosure
Every answer carries a coverage receipt:
let r = answer.receipt();
if !r.is_exact() {
// APPROXIMATE names its stages; UNPROVEN names its reason.
eprintln!("not certified: {r}");
}
if r.units_dropped() > 0 {
// A referenced segment was unreadable: the answer is PARTIAL.
}
Two receipt stages deserve special handling in applications:
"query-embed-skipped" means your hybrid (semantic + lexical) query
silently degraded to lexical-only because no embedding model was available;
units_dropped > 0 means part of the index could not be read. If your
application must never act on an uncertified answer, set
QueryOptions::exactness_required() — the server then refuses (typed)
instead of degrading.
3. Errors: the detail type is the retry policy
Since spada S-150, structured refusals carry a typed detail on the standard
grpc-status-details-bin trailer. Never parse error messages — ask the
error:
use spada_sdk::{ErrorDetail, RetryPolicy};
match client.query("ns", opts).await {
Ok(a) => { /* … */ }
Err(e) => match e.retry_policy() {
RetryPolicy::RetryWithBackoff => { /* transient: back off, retry here */ }
RetryPolicy::RetryElsewhere => { /* re-resolve routing, retry there */ }
RetryPolicy::OperatorAction => { /* page someone; do NOT spin */ }
RetryPolicy::Never => { /* fix the request */ }
},
}
The payloads are fields, not prose: ErrorDetail::LimitExceeded names the
exact setting key (limits.max_top_k, …) and both numbers;
DedupHorizonExceeded names the mutation id and the knob to raise;
NotLeader carries the last-observed leader hint. Two details matter most:
HomeReleased— the oneFAILED_PRECONDITIONthat IS retryable (the namespace moved home; re-resolve and retry there).SealerFailed— the server fail-stopped writes; a retry can never succeed and even backing off is wrong. Surface it to an operator. Reads keep working.
A status with no detail is normal (the gRPC code alone yields the safe
policy), and an unknown detail type from a newer server degrades to that
same safe reading — spada.v1 is additive-only, machine-enforced by the
server repository's CI.
Idempotent writes
Give any write you might retry a mutation_id you generate. A retry with
the same id returns the original result verbatim (deduplicated = true) and writes nothing. The horizon is finite (write.dedup_history
server-side): a retry arriving after its id was evicted is refused typed —
never silently re-executed — so bound your retry window well inside it.
Beyond the wrapper
The wrapper covers the common surface. Everything else —
Export (streaming scans), QueryStream, MultiQuery/BatchQuery,
schema management, recall evaluation — is available through the raw
generated client:
let raw: &mut spada_sdk::pb::data_service_client::DataServiceClient<_> = client.raw();
One rule when you drop down: if you consume QueryStream, always read to
the terminal receipt frame — stopping early leaves you holding an
unverified result. And never set the x-spada-forwarded metadata key; it
is reserved for server-to-server forwarding and setting it suppresses
routing.
TLS and auth
use spada_sdk::{Client, ClientOptions};
use std::time::Duration;
let client = Client::connect_with("https://spada.example:50051", ClientOptions {
tls_ca_pem: Some(std::fs::read("ca.pem")?),
token: Some(std::env::var("SPADA_TOKEN")?),
call_timeout: Duration::from_secs(30),
flush_timeout: Duration::from_secs(120),
}).await?;
Client::health returns dev_mode_no_auth — if it is true, the server
is running without authorization and must not be exposed to a network.
Surface it.
Protos and compatibility
The spada.v1 protos are vendored under proto/ with their
source commit recorded in proto/PROVENANCE.md;
refresh with scripts/sync-protos.sh <spada-checkout>. The surface is
frozen additive-only and machine-enforced upstream, so a newer server never
breaks an older SDK — new fields and details simply refine what this SDK
already handles safely.
Only the public package is vendored: spada.internal.v1 (cluster control
plane) and spada.model.v1 (model sidecars) carry no compatibility
promise, and a client must never speak them.
License
Apache-2.0.