tpuf #
a turbopuffer client for zig 0.16, over
zttp. request bodies are plain zig
values serialized with std.json, so a query reads the way the api docs
write it; responses keep their attributes dynamic, because a namespace's
schema belongs to whoever wrote it.
install #
requires zig 0.16+.
zig fetch --save https://tangled.org/zzstoatzz.io/tpuf/archive/main
then in build.zig:
const tpuf = b.dependency("tpuf", .{ .target = target, .optimize = optimize }).module("tpuf");
exe.root_module.addImport("tpuf", tpuf);
use #
const tpuf = @import("tpuf");
const zttp = @import("zttp");
var transport = zttp.Client.initWithUserAgent(io, allocator, "myapp/1.0 (+https://example.com)");
defer transport.deinit();
const client: tpuf.Client = .init(&transport, .{ .api_key = key, .host = .{ .region = "gcp-us-central1" } });
const ns = try client.namespace("posts");
_ = try ns.write(allocator, .{
.distance_metric = .cosine_distance,
.upsert_rows = &docs, // any struct with `id`, `vector`, and attribute fields
}, .{});
const result = try ns.query(allocator, .{
.rank_by = tpuf.rank.ann("vector", embedding),
.top_k = 10,
.filters = tpuf.filter.@"and"(.{
tpuf.filter.eq("kind", "post"),
tpuf.filter.gte("ts", since),
}),
.include_attributes = tpuf.Attributes.all,
}, .{});
defer result.deinit();
var rows = result.rows();
while (rows.next()) |row| {
const uri = row.string("uri") orelse continue;
const dist = row.dist().?;
...
}
Successful queries expose the provider's billing and performance fields:
const billing = result.billing();
const performance = result.performance();
std.log.info("tpuf request_bytes={d} response_bytes={d} queried={?d} cache={?s}", .{
result.request_bytes,
result.response_bytes,
billing.billable_logical_bytes_queried,
performance.cache_temperature,
});
hybrid search
const result = try ns.query(allocator, .{
.queries = .{
.{ .rank_by = tpuf.rank.ann("vector", embedding), .top_k = 20 },
.{ .rank_by = tpuf.rank.max(.{
tpuf.rank.bm25("name", text),
tpuf.rank.bm25("caption", text),
}), .top_k = 20 },
},
}, .{});
defer result.deinit();
var results = result.results(); // one view per query, in order
one round trip for both legs; add .rerank_by = .{"RRF"} to have the api fuse them.
typed rows
const Hit = struct { id: tpuf.Id, @"$dist": f64, uri: ?[]const u8 = null };
const hits = try result.rowsAs(Hit, arena.allocator());
unknown attributes are ignored, so a namespace that predates a field still deserializes.
patch, delete, export
_ = try ns.write(allocator, .{ .patch_rows = .{.{ .id = old_id, .status = "superseded" }} }, .{});
_ = try ns.write(allocator, .{ .deletes = &ids }, .{});
var pages = ns.exporter(allocator, .{ .attributes = .only(&.{"uri"}) });
while (try pages.next()) |page| {
defer page.deinit();
...
}
export pages in id order with an id > last cursor, the way the api guide describes.
errors
var diag: tpuf.Diagnostics = .{};
ns.query(allocator, req, .{ .diagnostics = &diag }) catch |err| switch (err) {
error.BadRequest => log.err("{s}", .{diag.message()}), // the api's own text
else => return err,
};
a non-2xx status maps to a named error and the api's message lands in Diagnostics; transport failures come through as zttp's named errors.
namespaces, warmth, ids
const page = try client.listNamespaces(allocator, .{ .prefix = "user-" });
const meta = try ns.metadata(allocator, .{});
try ns.warmCache(.{});
var keepalive: tpuf.Keepalive = .init(ns, allocator, .{});
try keepalive.start();
const id = tpuf.id.hash(at_uri); // 32 hex chars, under the 64-byte id cap
design #
- requests are values, not builders —
std.jsonturns tuples into arrays, sorankandfilterreturn tuples that nest into any body. the api's grammar stays visible and nothing here has to be updated when it grows. - responses stay dynamic — a row is a json object with typed getters, or
rowsAs(T)when a struct is wanted. the namespace decides its schema; the client does not pretend to know it. - one transport, bounded — a borrowed
zttp.Clientpools connections across namespaces and threads and puts a deadline on every phase of a request. tpuf adds only what the api needs on top: status retries withRetry-After, and named errors for each status. - nothing global — no module-level state; a
Clientis a value, and aNamespaceis a client plus a name. several namespaces per process is the normal case, not the exception.
develop #
zig build test # unit tests, no network
zig build smoke # against the real api: needs TURBOPUFFER_API_KEY, optional TURBOPUFFER_REGION
docs #
| docs/adopters.md | the services that carry a hand-rolled copy of this today, and what each needs from it |
| turbopuffer in production | operational lessons that shaped the api: id caps, schema evolution, cold namespaces |