Summa broker
summa-broker is a stateless gRPC service that fronts many summa-server instances behind one address. It serves the exact summa-proto/summa.proto SearchService and IndexService, so every existing client — the Rust, Python, and TypeScript clients alike — switches to it by re-pointing its endpoint, nothing else. A broker-only control surface lives in a separate proto (summa-proto/summa-broker.proto) so the shared wire contract and its generated clients never churn for broker concerns.
Problem
One summa-server process serves all indexes from one data directory on one machine. Large deployments need indexes on different hosts (two big indexes that no longer fit one box), later partitions of one index across hosts, and replicas for read scaling — all without teaching every client about topology.
Topology model
- Backend: one summa-server process, discovered as a Kubernetes pod or a static
--backendentry. - Shard: the unit of placement, identified by the pod label
summa.spacefrontiers.org/shard-id(--shard-label). Backends sharing a shard id are replicas of the same data. - Role:
summa.spacefrontiers.org/role=master|follower(--role-label). Writes go to the master only, never fan out. A shard whose only member is unlabeled is implicitly master — today’s single-pod-per-shard world needs no labels. A multi-member shard with zero or several labeled masters refuses writes withFAILED_PRECONDITION(fail loud, no guessing). - Index → shard mapping is learned, not configured: the broker polls
ListIndexeson every ready backend (15s steady state, 5s while a backend is unhealthy, immediately after a broker-issuedCreateIndex/DeleteIndex). What IS configured are placement rules:--placement "documents*=0"— glob → shard, first match wins — which govern whereCreateIndexlands (dated names follow their family) and pin reads/writes when an index name transiently exists on several shards during a migration.
Backend health
Healthy --poll failure--> Suspect --grace (60s) elapsed--> Evicted
Suspect --success--> Healthy
Evicted --2 consecutive successful probes--> Healthy
A Suspect backend keeps serving reads off its last-known index map (better a possibly-stale answer than none; counted by summa_broker_stale_topology_serves_total). An Evicted backend drops out of every route: its indexes vanish from ListIndexes and reads return NOT_FOUND if no other backend advertises them. Snapshots are immutable and swapped atomically; request handlers never take a lock on the hot path.
Routing (phase 1 — index-level)
| RPC | Behavior |
|---|---|
Search, GetDocument, GetIndexInfo | Exact index → its shard → a healthy replica (rotating), request and response forwarded verbatim |
ListIndexes | Union across routable backends, served from the cached topology (never fans out — this is every client’s health probe and must answer fast) |
BatchIndexDocuments, Commit, ForceMerge, Reorder, RetrainVectorIndex, AlterVectorIndex, DeleteIndex | Whole request to the master of the shard hosting the index; response verbatim |
IndexDocuments (client-streaming) | Buffered per index (512 docs / 4 MiB), forwarded as BatchIndexDocuments, re-routed on mid-stream index_name switches. DocumentError.index positions are flush-relative — the server’s own stream handling already numbers per internal batch, so no fidelity is lost |
CreateIndex | Placement rule (or --placement-default single: the shard hosting the fewest indexes; reject: refuse) → that shard’s master |
| Unknown index | NOT_FOUND("index '…' is not present on any healthy backend") |
| Index on several shard ids without a rule | Reads: lexicographically-first shard, deterministic, counted by summa_broker_ambiguous_index_total; writes: FAILED_PRECONDITION until a placement rule pins the writable shard |
Contract guarantees clients rely on:
- Byte-faithful responses on the write path: duplicate-primary-key and backpressure
DocumentErrors pass through untouched (client retry loops string-match them),indexed_count/error_countand error indices are the backend’s own. - No broker-imposed deadlines. The incoming
grpc-timeoutheader is propagated minus a 50ms epsilon (floor 10ms); an absent header means the outbound RPC carries none. Untimed index-builder channels and 24h adminReorder/ForceMergedeadlines work unchanged. - Admission mirrors the backend. Per-backend in-flight Search permits (
--backend-max-searches, default 16 = the production--max-concurrent-searches) plus an optional broker-global cap. Rejection isRESOURCE_EXHAUSTEDwith the server’s exact message, so client backoff logic cannot tell broker and backend apart. Only genuine unavailability surfaces asUNAVAILABLE(it trips client circuit breakers). - Transport limits and tuning mirror summa-server by default (search 4 MiB decode / 256 MiB encode, index 256 MiB decode / 64 MiB encode, gzip+zstd). All six message caps are startup flags (
--search-max-decode-mb,--search-max-encode-mb,--index-max-decode-mb,--index-max-encode-mb, plus--backend-max-decode-mb/--backend-max-encode-mbfor the broker→backend channels); inconsistent combinations warn loudly at startup.
Coordinated searches (fusion, L1, score exports and traces) additionally use --coordinator-max-transfer-mb (default 64 MiB). This is the total uncompressed response allowance for a search, not a per-shard allowance. The decoder divides it by the number of target shards, even on compressed connections. The same setting bounds combined responses, selected output and RRF diagnostic retention. Requests exceeding a bound fail with RESOURCE_EXHAUSTED; results are never silently truncated to fit.
The value must be nonzero, fit in bytes, and not exceed either --backend-max-decode-mb or --search-max-encode-mb; invalid combinations fail at startup. Clients and shard encoders must also accept the required messages. Increasing generic gRPC limits alone does not raise the coordinator limit. Startup logs report the coordinator allowance and both admission caps.
For a bounded increase, use --coordinator-max-transfer-mb 128 --max-concurrent-searches 8: three shards get about 42.7 MiB each. Eight simultaneous coordinated searches have up to 1 GiB of incoming protobuf payload, before decoded objects, ranking buffers and response copies. This is a transfer bound, not a process RSS limit; measure broker memory and admission rejections before expanding concurrency. This setting does not change retrieval depth, formulas, candidate completeness or shard concurrency.
Pass-through responses are proto-equal, not always byte-equal: protobuf map fields (SearchHit.fields) may re-serialize entries in a different order.
Discovery
Kubernetes mode watches Pods (not EndpointSlices — shard identity and role are pod labels, and the pod carries labels, IP, and readiness in one object) in --namespace with a label-existence selector on the shard label. Readiness = PodReady ∧ has IP ∧ not terminating; unready pods are visible in the admin surface but never routed or polled. RBAC: get/list/watch pods in the summa namespace. Static mode (--discovery static --backend "id=..,addr=..,shard=..[,role=..]") feeds the identical machinery and is what local development and the integration tests use.
Phase 2: partitioned indexes
One logical index across several shards, declared by a multi-shard placement rule: --placement "documents*=2,3,4". Partition order = rule order (an immutable contract: repartitioning or reordering = full rebuild). Every partition must host the index; a partition without it fails the request with FAILED_PRECONDITION instead of serving a partial view.
Writes:
CreateIndexcreates the index on every partition master (the schema is sent verbatim to each).BatchIndexDocumentsand streamingIndexDocumentsroute each document to the partition of a pinned FNV-1a 64 hash of its primary key (the field declaredprimaryin the schema, read once viaGetIndexInfoand cached per index). A document without the primary key is refused at the broker with its request position;DocumentError.indexvalues from a partition are mapped back to request positions. The stream’s 512-message / 4 MiB flushes are split per partition.Commit,ForceMerge,Reorder,DeleteIndex,RetrainVectorIndexandAlterVectorIndexgo to every partition master; counts are summed,successis the conjunction.
Reads:
Searchfirst asks every partition forGetTextStatsof the query’s text terms (skipped for queries without BM25 terms or when the caller already suppliedtext_stats), sums them, and sends the sum asSearchRequest.text_statsso every partition scores with corpus-wide document frequencies and lengths. Plain pointwise searches query partitions withoffset=0, limit=offset+limitand merge by score descending, ties by(segment_id, doc_id). Top-level fusion without the legacy vector reranker exports the per-branch shard nomination lists and computes global RRF (or normalized weighted sum) using core fusion at the broker. L1 applies the same core model on shards and broker, retaining sufficient passage rows for the requested combiner.formula_v1compilesl1.formulaonce, preserves organic scores and supports optional missing-only backfill plus learned raw missing defaults; defaults never fill raw export maps. A formula referencingrrfrequires the full bounded union and all scored passage rows from every shard; the broker applies the formula with global votes before selection. See candidate scoring for transport bounds and mixed-version checks. Legacy fusion nested inside another query or combined with the vector reranker retains its existing shard execution; it does not claim global RRF candidate selection.total_hits= saturating sum, timings = maximum plus coordinator work,truncated= any (incomplete candidate exports are rejected). Admission takes one permit per partition backend and retains it through coordinator CPU work.GetDocumentasks every partition; the one holding the segment answers.GetIndexInfosums document/segment/memory counts and per-field stats;GetTextStatsmerges like the search prepass.
Partial partition failure fails the request (partition '<shard>' of index '<name>': <status>) — a silently-partial result set is a wrong answer. The admin GetTopology reports merge_policy = "score" and the cached primary-key field for partitioned indexes.
Phase 3 (designed, not yet built): master/follower replication
A new ReplicationService on summa-server (separate proto): GetIndexState (metadata generation + segment metas), FetchSegmentFile (chunk stream). A follower (--replicate-from) polls the master after commits, pulls missing write-once segment files, atomically installs the new metadata.json, and hot-reloads — which requires adding a reload-from-disk path (IndexReader::do_reload_check currently only consults in-memory segment-manager state). Followers are read-only; the broker already routes writes to masters only, and spreads reads across master + fresh followers with per-replica lag surfaced from GetIndexState generations. There is no oplog: a follower that diverges beyond the client’s retry horizon is rebuilt.
Operations
- Health:
grpc.health.v1on the broker itself —SERVINGonce the first topology snapshot has ≥1 healthy backend,NOT_SERVINGwhile draining. Kubernetes gRPC probes work out of the box. - Admin:
summa.broker.BrokerService—GetTopology(per-replica livenum_docs/num_segments, for migration verification),GetBackends,RefreshTopology. - Metrics:
summa_broker_*, documented in metrics.md. - Shutdown mirrors summa-server: SIGTERM → refuse new RPCs with
UNAVAILABLE("Summa broker is shutting down"), drain, stop.
Testing
- Unit: topology assembly, placement globs, master validation, ambiguity rules, health transitions,
grpc-timeoutparsing (all pure functions). - Integration (
tests/broker_integration.rs): the real broker binary with static discovery against in-process mock backends — pass-through equality, index routing, ambiguity + placement pinning, stream re-grouping, deadline presence/absence at the backend, eviction and two-probe recovery. - End-to-end (
tests/e2e_real_server.rs,--ignored, CI runs it after building summa-server): two real summa-servers, placement-routedCreateIndex, batch write + commit, duplicate-primary-key pass-through, search +GetDocumentby address, cross-shard isolation.