Skip to content

Serving tier: scatter-gather fan-out, tail-latency, caching - #8

Merged
tamnd merged 4 commits into
mainfrom
serve
Jun 7, 2026
Merged

Serving tier: scatter-gather fan-out, tail-latency, caching#8
tamnd merged 4 commits into
mainfrom
serve

Conversation

@tamnd

@tamnd tamnd commented Jun 7, 2026

Copy link
Copy Markdown
Owner

Builds the serve/ package: the query-serving tier from architecture doc 08 and
implementation doc 08. The whole tier runs in-process against an in-memory
Leaf fake here, so the fan-out shape, the tail-latency techniques, the cutoff,
and the caching are all tested without a network, the same seam-plus-reference
pattern the earlier subsystems use.

What landed

  • serve.Scatter is the fixed fan-out shape of doc 08.1: an errgroup bounded
    by SetLimit, results in a pre-sized slice indexed by shard (no channel, no
    mutex on the hot path), a per-shard sub-deadline off the parent context with
    defer cancel, and partial-result tolerance so a dropped shard degrades the
    query instead of failing it.
  • serve.MergeTopK is the k-way merge across the shards' already-ranked
    lists, max-heap based, with the cascade's deterministic tie-break.
  • serve.Hedge is the hedged request: fire a backup replica after the p95
    delay, take the first success, cancel the losers, recover fast on a leader
    error. It is generic over the call's result type.
  • serve.LRUCache + serve.Loader are the result cache behind a seam, with
    singleflight collapsing a cache stampede to one backend call and honoring
    the caller's context while the shared load runs.
  • serve.Coordinator composes the three: fan out, apply the good-enough
    cutoff (sub-deadline plus a minimum-responded floor), merge the survivors.

This is the first subsystem with real fan-out concurrency, so it adds
golang.org/x/sync for the errgroup and singleflight the engineering
principles (doc 01.3) call for.

Deliberate narrowing

  • Leaf is the seam. The production leaf is a long-lived gRPC client to a
    replica set (doc 08.6); the tests use an in-process fake. The gRPC client
    setup, the connection pooling, and the round-robin resolver are config that
    lands when the leaf binary is wired in cmd/.
  • Cache is the seam. The production in-process cache is Ristretto (TinyLFU
    admission, which drops some writes); LRUCache is the deterministic reference.
  • Hedging is the implemented tail technique. Tied requests, latency-induced
    probation, canary requests, micro-partitioning, and selective replication
    (doc 08.2) are tracked but not built here; tied requests in particular need a
    cross-replica cancel RPC that belongs with the gRPC service.
  • Snippets at serve time (doc 08.4) and the mixer plus the answer-engine
    vertical (doc 08.5) are separate units; the mixer is the same scatter-gather
    shape one tier up and lands with the answer engine (doc 09).
  • The shard maps and health state the routing consumes come from the control
    plane (doc 10).

Tests

go test ./serve/... covers: scatter gathering all shards, tolerating a leaf
error, honoring the per-shard sub-deadline and the parent deadline, and bounding
its concurrency; merge against a brute-force oracle across every k, plus
tie-break and the empty and k-exceeds-total edges; hedge firing the backup only
when the leader is slow, taking the first success, failing fast to the next
replica on an error, the all-fail and single-replica and zero-delay and
context-cancel cases; the LRU get/set/evict/update/zero-capacity behavior, the
loader collapsing a fifty-way stampede to one call, serving from cache, not
caching errors, and respecting the context; and the coordinator merging
children, serving three of four shards under a 0.75 floor, refusing one of four
under a strict floor, and reporting the context error when cancelled. Green
under go vet, golangci-lint, and the race detector.

Consumes the ranking path (#7); feeds the answer engine (doc 09) next.

tamnd added 4 commits June 7, 2026 17:26
The serving tree is the natural Go fan-out: the root fans to aggregators, each
aggregator to leaves, the same shape nested. Scatter fixes that shape per doc
08.1. An errgroup bounded by SetLimit runs the leaf RPCs, results land in a
pre-sized slice indexed by shard so there is no channel or mutex on the hot
path, and each RPC gets a sub-deadline off the parent context with defer cancel
so a straggler's work is freed the moment its budget passes.

The load-bearing decision is partial-result tolerance. A leaf error or a missed
sub-deadline degrades to a missing shard, not a failed query, because important
documents are replicated across leaves so a dropped shard rarely holds the
unique best result. Gathered reports which shards answered so the caller can
apply a good-enough cutoff.

MergeTopK reassembles the global ranking. Each shard returns its results
already ranked, so this is a k-way merge across sorted lists through a max-heap
rather than a full sort, ordered by the cascade convention with ties broken
toward the smaller document id so the page is deterministic regardless of which
shards happened to answer.

This is the first subsystem with real fan-out concurrency, so it brings in
golang.org/x/sync, the errgroup and singleflight the engineering principles
(doc 01.3) call for.
A single leaf's 99th-percentile latency becomes the root's median once a query
fans out wide, so the tail is the defining problem of the tier and waiting for
the slowest leaf is not an option. Hedge is the highest-value technique against
it: send to one replica, and if no reply arrives within the expected
95th-percentile latency, send a backup to the next replica and take the first
to return, cancelling the losers through the context. The backup fires only for
the slow tail, so the extra load is a few percent for a large tail improvement.

A failed attempt does not wait out the hedge delay: it frees the next replica
immediately, so a fast error recovers as quickly as a slow success would have
hedged. With no delay every replica races at once, and with one replica it is a
plain call. The function is generic so the same logic hedges a leaf search, an
aggregator fan-out, or a vertical call.
Caching carries the load and the warm-path latency; the tail techniques carry
the guarantee for the uncached working set, since caching does not solve the
tail. Cache is the result-cache seam. The production in-process cache is
Ristretto, whose TinyLFU admission deliberately drops some writes, so the seam
keeps that swappable; LRUCache is the in-process reference, admitting every Set
so a test sees a deterministic hit pattern.

The Loader is the piece that matters under load. A popular query expiring from
the cache would otherwise let every concurrent request hit the backend at once,
which is exactly when the backend can least afford it. Single-flight collapses
that stampede to one backend call with the rest waiting on its result, and the
wait is done through DoChan so a caller whose own deadline passes returns the
context error while the shared load continues for the others. A load error is
not cached.
The Coordinator is a serving-tree node: it fans a query to its children, drops
the slow tail, and merges the rest. The same type is the root over aggregators
and an aggregator over leaves. The good-enough cutoff is not a separate timer
but the per-shard sub-deadline (slow shards do not answer in time) plus a
minimum-responded floor checked after the gather, so a page built from too few
shards returns ErrInsufficientShards rather than being served as if complete. A
cancelled query reports the context error instead, since a dropped client is a
different failure from a flaky fleet.

The tests run the whole tier in-process against fake leaves: the fan-out
gathers, tolerates errors, honors the sub-deadline and the parent deadline, and
bounds its concurrency; the merge matches a brute-force oracle across every k;
hedging fires the backup only when the leader is slow and recovers fast on a
leader error; the loader collapses a fifty-way stampede to one call; and the
coordinator's cutoff serves three of four shards but refuses one of four. Green
under the race detector.
Comment thread serve/fanout.go
defer cancel()
}
r, err := leaf.Search(cctx, req)
if err != nil {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This return nil is the whole partial-result design in one line, so it is worth being loud about. Returning the error here would make errgroup cancel the sibling RPCs and fail the entire query on one flaky leaf, which is the opposite of what we want at this fan-out width. We swallow it, leave OK[i] false, and let the good-enough cutoff in the coordinator decide whether enough shards came back. The safety argument is doc 05's replication: an important document lives on more than one leaf, so a single missing shard almost never holds the unique best result. A leaf that is wrong rather than slow is a separate problem the health checks handle.

Comment thread serve/hedge.go
}
lastErr = o.err
// A failure frees us to try the next replica right away.
if launched < replicas {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fail-fast branch is easy to miss and matters for correctness of the latency story. Without it, a leader that errors at once would still make us sit out the full hedge delay before trying replica 1, so a fast failure would cost more than a slow success. Stopping the timer and launching the next replica immediately means an error is recovered as quickly as the fleet can answer. The pending counter is what lets the loop keep waiting on already-launched attempts after a failure rather than returning early, so we only return the last error once every launched attempt has reported back.

Comment thread serve/cache.go
return v, nil
})
select {
case <-ctx.Done():

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Selecting on ctx.Done here rather than only on the singleflight channel is deliberate. DoChan dedupes the load, so one slow backend call backs many waiters; if a waiter's own deadline passes we want it to return promptly with its context error instead of being held hostage by the shared flight, which keeps one caller's slowness from leaking into another's latency. The flight keeps running for whoever is still waiting, and its result still populates the cache, so the work is not wasted. The trade-off is that the load closure captures the first caller's context, so a production leaf load should derive its own timeout rather than inherit one caller's deadline; worth a follow-up when the real loader lands.

@tamnd

tamnd commented Jun 7, 2026

Copy link
Copy Markdown
Owner Author

On the merge step: MergeTopK runs a k-way merge over the per-shard result lists with a max-heap, which assumes each shard hands back its own results already sorted by the page convention (score desc, then segment, then local id). That holds because a leaf runs WAND and returns a ranked top-k, so the coordinator never re-sorts a whole shard, it only interleaves already-ordered streams and stops at k. The heap holds one cursor per shard, so the cost is the classic O(k log S) rather than O(total log total). If a future leaf ever returned unsorted results this would silently produce the wrong order, so the contract that a Response is pre-sorted is worth stating on the Leaf interface when we write the real RPC client.

@tamnd

tamnd commented Jun 7, 2026

Copy link
Copy Markdown
Owner Author

Scope note for reviewers, the deliberate narrowing for this tier. Everything here is in-process and tested against the Leaf seam with fakeLeaf, no gRPC or wire format yet, that is the next PR and a mechanical swap behind the same interface. The cache is an LRU reference for correctness; production swaps in Ristretto behind the Cache interface for admission and TinyLFU, the Loader stampede collapse via singleflight is unchanged by that swap. Hedging is wired as a standalone helper rather than threaded into Scatter on purpose, the coordinator's good-enough cutoff already covers the common slow-shard case, and hedging is the heavier hammer we reach for per-replica once the replica topology from doc 05 is real. Numbers like the p95 hedge delay and the good-enough fraction are config, not constants, so they tune per cluster.

@tamnd
tamnd merged commit 739413c into main Jun 7, 2026
4 checks passed
@tamnd
tamnd deleted the serve branch June 7, 2026 10:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant