- Status: Accepted
- Date: 2026-06-15
- Deciders: Achref Soua
ADR-0027 added a live migration connector for Qdrant — point Quiver at a
running source and it pulls the points directly, normalizing each through the
same per-source mapper the offline importers use. It deferred live Chroma
and live Postgres, leaving both on the offline export → quiver admin import
path (ADR-0024):
- Chroma was deferred because its HTTP API had churned (v1 vs v2, tenant / database addressing, name→id resolution) and an unvalidated connector against a moving API would over-promise.
- Postgres was declined to avoid pulling an async runtime into an otherwise synchronous import path.
This ADR extends ADR-0027 and reverses both deferrals, completing one-command live migration from all three supported sources. Two facts have made that the right call now:
- Chroma's v2 HTTP API is the stable, pinnable shape. Records come from
POST /api/v2/tenants/{tenant}/databases/{database}/collections/{id}/getwith aninclude/limit/offsetbody and return parallel{ids, embeddings, metadatas, documents}arrays — the exact shape the offline Chromacollection.get(...)mapper already consumes. The only wrinkle is that the/getpath is keyed by the collection UUID, not its name. tokiois already a workspace dependency (ADR-0002 — the REST/gRPC server runs on it). The embeddable engine (quiver-embed/quiver-core) is and stays tokio-free, but the workspace already builds and audits tokio, so a live Postgres connector adds the rust-postgres driver family to the import crate — not a new async runtime to the engine.
1. Ship a live Chroma connector on ureq — the same blocking,
rustls-over-ring, tokio-free client ADR-0027 chose, so no new dependency
is added for Chroma. The connector:
- Resolves the collection name → UUID by listing collections
(
GET /api/v2/tenants/{tenant}/databases/{database}/collections) and matching onname. This avoids depending on whether a given Chroma build accepts a name in the/getpath — the historical churn point — and works uniformly across v2 servers. - Paginates
POST …/collections/{id}/getwith{"include":["embeddings","metadatas","documents"],"limit":N,"offset":M}, advancingoffsetuntil a short page ends the scroll. - Feeds each
{ids, embeddings, metadatas, documents}page through the existing Chroma normalization, so live and offline Chroma share one mapper and oneimport_intowrite path. - Targets Chroma's v2 API;
tenant/databasedefault todefault_tenant/default_databaseand are overridable; an optionalx-chroma-tokenheader carries an API key.
2. Ship a live Postgres/pgvector connector on the postgres crate — the
maintained, blocking rust-postgres client (a synchronous wrapper over
tokio-postgres), which fits quiver-import's synchronous shape. The
connector:
- Connects with a libpq-style URL (
postgresql://user:pass@host:port/dbname), over TLS via the existing rustls/ringstack (matching the transport and Qdrant/Chroma TLS), falling back to a plaintext connection forsslmode=disable/ trusted-network use. - Reads each row as
row_to_json(...)— the same JSON shape the offline pgvector path already parses (a JSON object whose id and vector columns are named and whose other columns become payload) — and normalizes it through the existing pgvector mapper. - Reuses the shared
import_intowrite path, so an encrypted import is byte-identical to one from a file.
3. Both connectors reuse the per-source mappers, exactly as ADR-0027 established for Qdrant: one normalization, one write path, exercised by both the live and offline routes.
4. Keep the dependency tree honest. Any new license the rust-postgres family
introduces is allow-listed in deny.toml; cargo deny and cargo audit gate
every PR.
Shipped across four PRs:
- Chroma —
quiver_import::live::fetch_chromaon the existingureqseam, no new dependency. It resolves the collection id by listing collections, paginates…/collections/{id}/getwithinclude:[embeddings,metadatas,documents], and reuses the extractedchroma_pointsmapper shared with the offlineparse_chroma. - Postgres —
quiver_import::live::fetch_pgvectoron the blockingpostgresdriver with rustls/ringTLS (tokio-postgres-rustls+webpki-roots, no aws-lc-rs). It readsrow_to_json(...)and reuses the offlinepgvector_pointmapper.cargo denyandcargo auditare clean on the enlarged tree (singlerustls0.23, no aws-lc-rs); the licence allow-list needed no additions. - CLI —
quiver admin import --chroma-url(with--chroma-tenant/--chroma-database) and--postgres-url(with--table).
Honest deviations: Chroma is validated hermetically (an in-process HTTP
server); the Postgres I/O path has no hermetic test (its wire protocol can't
be faked in-process) — the row mapping is unit-tested and a real instance is
covered by an #[ignore]d test against QUIVER_PG_TEST_URL. Real-server
validation is an operator step for both.
- + One-command live migration from all three sources (Qdrant, Chroma, Postgres) — no manual export step for any of them.
- + Live and offline paths share normalization, so the mappers stay consistent and are exercised twice.
- + Chroma adds zero new dependencies (it reuses the
ureqseam). - − Chroma's API has churned historically; this connector targets v2 and is validated hermetically — a check against a running Chroma is an operator step, like the Qdrant precedent. The name→UUID-by-listing choice is the hedge against the churn.
- − Postgres adds the rust-postgres driver family
(
postgres/tokio-postgres/postgres-protocol/postgres-types) plus a rustls TLS adapter toquiver-import. The engine (quiver-embed/quiver-core) stays tokio-free; the import crate and the already-tokio CLI binary gain the driver. Bounded bydeny/audit. - − The Postgres wire protocol and its SCRAM auth cannot be faked in-process, so the live Postgres I/O path has no hermetic test; its row→point mapping is unit-tested and an env-gated integration test covers a real instance.
- Chroma — hermetic. An in-process HTTP server (
std::net::TcpListeneron a loopback port, the ADR-0027 pattern) serves a canned v2 collection-list plus paginated/getresponses; the test asserts name→id resolution, pagination, normalization, and import. No external Chroma required. - Postgres — mapping unit-tested + gated integration. The row→
ImportPointnormalization is covered through the shared pgvector mapper; an#[ignore]d integration test imports from a real Postgres atQUIVER_PG_TEST_URLwhen an operator supplies one. Real-instance validation is an operator step. cargo denyandcargo auditkeep the enlarged dependency tree honest.
- Keep Chroma and Postgres offline (ADR-0027 status quo) — rejected: the manual export step is real friction, and both blockers have eased (Chroma's v2 API is pinnable; tokio is already in the workspace tree).
- Resolve the Chroma collection by putting its name in the
/getpath — rejected: whether a name is accepted there has varied across Chroma builds (the churn that deferred it); listing and matching by name is version-robust. - Hand-roll the Postgres wire protocol over
std::netto stay driver-free — rejected: re-implementing the startup flow, SCRAM-SHA-256 auth, and row decoding is security-sensitive and far more code than a one-shot migration convenience warrants; the maintainedpostgrescrate is better audited than a bespoke client. sqlxfor Postgres — rejected: heavier (a larger async/macro surface and compile-time query checking Quiver does not need) than the minimal blockingpostgresclient.reqwestfor Chroma — rejected for the same reason as ADR-0027:ureqkeeps the HTTP path blocking and reuses the rustls/ringstack with no async runtime.