DART's read-through orchestration: serve an arbitrary byte range of an object
from the block cache, from a peer in the distribution tree, or (as a last resort)
from origin, and stream the requested bytes back. Plus an http.Handler that
realizes the HTTP service layer (arbitrary Range in, non-chunked response out).
- Source:
internal/engine/engine.go,internal/engine/handler.go - Tests:
internal/engine/engine_test.go,internal/engine/handler_test.go - Import path:
github.com/data-accelerator/dart/internal/engine
This is the first end-to-end path (M1): it wires internal/chunk (addressing),
internal/store (block cache), and internal/fetch (origin read-through with
singleflight) into a working single-node caching proxy. A request's byte range
is decomposed into blocks; each block is served from cache or, on a miss,
fetched from origin and stored, then the requested sub-bytes are streamed in
order.
Peer pulls are wired in (optional): when a Cluster, Peer client, and
SelfID are all configured, a cache miss is routed to this node's parent in
the preorder distribution tree (branching factor Fanout); the parent
relays (fetching from its own parent/origin as needed, via PeerStreamSource),
so it usually holds the block. The tree root (owner) fetches origin. This offloads
the owner under a thundering herd.
The tree spans the active reader set when a tracker is configured (so a parent is always another reader — see docs/tracker.md) and otherwise all Ready members. Without P2P configured the engine is single-node (every miss goes to origin).
Reader-set lookups are cached per object for half the granted tracker lease (and the cache doubles as the lease renewal: a hit re-serves the frozen set, a miss re-JOINs, which refreshes this node's own lease). Caching for half the lease guarantees renewal always lands before the lease lapses; a tracker that grants nothing falls back to a 2 s period. Expired entries are swept as the cache grows past a few hundred objects, so the map tracks objects currently being read and stays small. When the tracker is unreachable the engine falls back to all-member routing for that lookup (the reader set is soft state).
| Term | Meaning |
|---|---|
Engine |
Orchestrator: Size + Serve over cache/origin. |
| object size | Learned once per object via a 1-byte probe, cached in-process. |
Handler |
http.Handler translating HTTP Range ⇄ Engine.Serve. |
type Options struct {
Chunk chunk.Config // grid; must be valid
Store store.Store // block cache; required
Fetcher fetch.Fetcher // origin read-through; required (typically *fetch.Coalescing)
Namespace string // chunk-key namespace; default "dart"
// P2P (optional; all three enable peer pulls):
Cluster cluster.Provider // membership source
Peer *peer.Client // peer block client
SelfID string // this node's cluster ID
Fanout int // distribution-tree branching factor (default 2)
Replicas int // HRW candidates that authoritatively hold a chunk (default 1)
Hedge bool // hedge a slow peer fetch (default off)
HedgeRatio float64 // max share of fetches allowed to hedge (default 0.05)
// Active-reader-set tree (optional):
TrackerClient *tracker.Client // reach remote trackers
TrackerRegistry *tracker.Registry // this node's local tracker
}New validates the chunk config and required fields. Peer pulls are enabled
only when Cluster, Peer, and SelfID are all set.
Returns the object's total size, probing origin once (a bytes=0-0 fetch) and
caching the result by object identity (chunk.ObjectID). For immutable
content-addressed objects the size is permanently valid. The probe also
records whether the origin honored the Range: a 200 answer marks the object
Range-unsupported (see §3.9).
A 206 that hides the total (Content-Range: bytes 0-0/*) is a probe
failure, not a size: block geometry is impossible without the total, and
caching a fabricated one would poison every later read of the object (the
cache is write-once and process-lifetime — §3.3). Size returns an error and
caches nothing, so a corrected origin recovers without a restart.
Writes the inclusive range [start, end] to w. end is clamped to the last
byte; start beyond the object returns ErrRangeNotSatisfiable. Each covering
block is served from cache or fetched+stored; only the requested sub-bytes of
each block are written, in order.
Block-length validation. A block's byte length is fixed by the object
geometry (a full BlockSize, or a shorter final block). Before a fetched block
is cached, its length is compared against that expectation, on both the
origin and the peer paths:
- an origin block of the wrong length fails the read (it is never cached);
- a peer that returns a wrongly-sized block is not trusted — the read falls through to origin, which is authoritative;
- the cut-through relay (
relayFromParent) applies the same check to the bytes it forwards: relay responses carry no Content-Length (chunked), so a short clean EOF is indistinguishable from success at the transport — the relay compares the streamed length against the geometry before caching. A mismatched block is still served on (the leaf's own check protects clients) but never cached; when the object size cannot be resolved the relayed bytes cannot be validated and are not cached either.
This is load-bearing because the block cache is write-once per key: a later correct fetch can never overwrite a bad block, only eviction would clear it. So a single short read (e.g. from a range-clamping proxy) that slipped through would be a permanent, self-propagating error. Refusing it at ingestion is what keeps that from happening.
type Handler struct {
E *Engine
Resolve func(*http.Request) (string, error) // request -> origin URL; required
}ServeHTTP:
- Accepts
GET/HEAD(else405). Resolvefailure →400; origin/size failure →502.- Parses a single Range header (
bytes=a-b,bytes=a-,bytes=-n); a missing Range serves the whole object; unsatisfiable/multi-range →416withContent-Range: bytes */size. Every Range form — suffix included — is unsatisfiable on an empty object: a suffixbytes=-nwould otherwise clamp to the inverted interval[0, -1], so the parser rejects it instead of emitting a206with an invalidContent-Range. - Always sets an explicit
Content-LengthandAccept-Ranges: bytes; responds206+Content-Rangefor a range, else200. The explicit Content-Length guarantees a non-chunked response even though the body is streamed block by block (a requirement of the client plane, not an optimization). HEADreturns headers with no body.- If
Sizemarked the object Range-unsupported (the origin ignoresRange), the request is instead proxied verbatim viaServePassthrough— no blocks are fetched, nothing is cached, and the peer plane is not consulted (§3.9).
NewStaticHandler serves every request from one fixed origin (single-origin /
testing). Real multi-mode resolvers (registry mirror, forward proxy, overlaybd
p2pConfig prefix) belong to a future proxy layer.
Returns the relay Source for this node's peer.Server: on a request for a
block it does not hold, it fetches the block via its own tree parent/origin
(using the request's X-DART-Origin), caches it, and serves it. This is what
makes intermediate tree nodes offload the owner. A store-only node can use
peer.StoreSource instead (no relay).
Malformed block indices are declined, not relayed (issue #52). The wire
carries the block index as an unrestricted uint64, while block geometry is
int64 arithmetic. An index above chunk.Config.MaxBlockIndex() — above
MaxInt64, or large enough that index*BlockSize would overflow int64 — can
never come from a legitimate same-config peer, and computing with it would wrap
(wrapped-to-0 start silently serves block 0's bytes; a wrapped-negative start
drops the Range header and pulls the whole object). PeerSource and
PeerStreamSource decline such requests (held=false, recorded as a refused
relay) before cache lookup, relay selection, size probe, or origin I/O.
Declined, not errored: the malformed index is the requester's fault, and a 500
would charge this healthy relay on the requester's circuit breaker.
The cut-through counterpart of PeerSource, for a peer.StreamServer:
- a locally-held block is copied out under the store lock (
Get) and streamed, so a concurrent eviction cannot tear it mid-write; - a missing block is relayed from this node's tree parent, copying bytes to the requester as they arrive while tee-ing them into the local cache;
- the tree root (no parent) fetches from origin, caches, then streams.
The malformed-index decline of §3.5 applies identically here: nothing is streamed, the sizer is not called, and no origin request is made.
This is what cmd/dart mounts, so a multi-hop chain pipelines: the tail node
starts receiving after roughly one block-transfer time instead of
depth x block-transfer time.
When the configured Store also implements store.ClassStore (i.e. a
store.Tiered), the engine classifies every block it caches:
- owned — this node is in the HRW top-
Replicasfor the chunk over all Ready members. That is the authoritative placement, independent of who is reading. - borrowed — anything else: a copy fetched for a local client, or relayed for a peer.
This keeps the two budgets isolated so borrowed churn cannot evict the owned shard set (see docs/store.md §3.4). Without P2P there is no placement, so everything cached is owned. Insertion stays best effort: a full or admission-rejecting cache never fails a read whose bytes are already in hand.
The failure paths already have fallbacks: a 404 or a transport error falls
through to origin. What had no fallback is a peer that is alive but slow (GC
pause, disk hitch, or itself waiting on its own parent) — and in a tree that
stall cascades to the whole subtree below it.
With Hedge enabled, a peer fetch has two distinct escalations, and keeping
them separate matters:
| Escalation | Trigger | Rate limited? |
|---|---|---|
| failover | the parent definitively failed (error, or does not hold the block) | No — it is a reaction to a known-bad peer, not speculation. Throttling it would push the whole subtree to origin while a peer that almost certainly has the block sits idle. |
| hedge | the parent is merely slow (past the estimated p99) | Yes (HedgeRatio) — when a whole cluster is slow, unthrottled duplicates double its load and amplify the congestion they react to. |
So a peer fetch proceeds as:
- ask the tree parent (primary) and start a timer at the estimated p99;
- on expiry, if the hedge budget allows, race a duplicate at the grandparent (or the tree root) — both sit closer to the source, so they more likely already hold the block;
- if a contender fails instead, escalate to the backup immediately and unconditionally;
- take whichever answers
held=truefirst and cancel the loser; - if neither can serve it, fall through to origin as before.
The hedge delay comes from an online estimator (exact p99 over a bounded window of recent fetches), floored at 5 ms so a fast cluster does not hedge on noise and capped at 2 s so a pathological sample cannot disable hedging. Banked hedge credit is capped at one, so an idle period cannot release a burst.
Separately, peer.Client bounds every individual peer request with three
timeouts (dial 1 s, response header 10 s, request 30 s — see docs/peer.md §3.3), so
a stalled or departed peer can never hold a read open until the caller's context
expires. And when a peer.Breaker is configured, target selection skips peers
whose circuit is open — on the buffered Get path by walking further up the
ancestor chain, and on the streaming relay path by skipping an open-circuit
parent and falling through to origin — so a dead branch is routed around rather
than forcing every reader beneath it back to origin. A parent that errors on
the relay path before writing a byte is likewise treated as a decline (fall
through to origin); once bytes have streamed to the requester, a failure can
only propagate. If no ancestor is usable, the read falls through to origin.
Together these bound abrupt node death: the dial timeout makes the first affected read fail in ~1 s, a dial failure opens the circuit on that single observation, and every later read skips the departed peer entirely.
NewMetrics(r *metrics.Registry) *Metrics allocates and registers the
engine's metric set; Engine accepts it via Options.Metrics (a nil one
disables engine metrics). Metrics: dart_hedge_total{event=fired|primary_won|backup_won} and
dart_peer_failover_total. Comparing backup_won against fired shows whether
hedging is paying for itself; dart_peer_failover_total rising marks peers
actually going away. primary_won/backup_won are recorded only when a hedge
actually fired — with hedging disabled, or when the backup answered via
failover rather than speculation, no win is recorded, so the comparison stays
meaningful exactly when it is consulted. The latency estimator learns only from
fetches that actually held the block (held=true): a fast 404 miss must not
collapse the p99, or hedges would arm a few milliseconds into genuine fetches.
RegisterStoreMetrics and RegisterPeerMetrics additionally export cache
occupancy and open-circuit counts (see docs/observability.md).
func (e *Engine) RangeUnsupported(url string) bool
func (e *Engine) ServePassthrough(ctx context.Context, w http.ResponseWriter, r *http.Request, url string) errorAn origin that ignores Range cannot be served through the block layer: every
per-block fetch would pull the whole object, and a block so fetched could never
be safely cached for a different range. When the Size probe (a bytes=0-0
GET) is answered with 200 instead of 206, the object is marked
Range-unsupported (process-local, never expires — a restart re-probes), and
Handler then serves every request for it with ServePassthrough:
- the client's
Rangeheader is forwarded, and the origin's status, entity headers (Content-Type/Content-Length/Content-Range/ETag/Last-Modified/Cache-Control/Accept-Ranges) and body are streamed back verbatim — the client sees exactly what talking to the origin directly would produce; - nothing is cached, nothing is routed through P2P, and passthrough traffic is not coalesced;
- a
HEADis served from an upstream GET (a presigned URL is signed for GET alone), forwarding headers with an empty body; - an error is reported (as a clean
502) only before the response is committed; afterwards a failure can only truncate the body.
ServePassthrough streams through the fetcher's fetch.Opener interface
(HTTPFetcher and Coalescing implement it); with a fetcher that cannot
stream it fails cleanly instead of falling back to per-block pulls. On the
peer plane, a relay that has marked an object Range-unsupported declines
relay requests for it (held=false) on both relay sources
(PeerStreamSource and PeerSource), so the requester uses its own
passthrough path rather than this node pulling the whole object per block on
its behalf. Defense in depth: block() never caches bytes sliced from a
Range-ignored whole-object response even when the marker is absent.
Metrics: dart_passthrough_total{reason="range_unsupported"} counts proxied
requests; their bytes are counted as both client and origin_in wire bytes
(they crossed both wires) but not as any block source.
- Correct bytes:
Servereturns exactlycontent[start:end+1], across block and chunk boundaries. - Read-through + cache: a block is fetched from origin at most once per
coalesced flight and zero times more while cached. Two qualifications:
insertion is best-effort — under cache pressure a block may be evicted
before a later read, which then re-fetches it; and "at most once" assumes
the configured
Fetchercoalesces (the node wiresfetch.Coalescing; a custom non-coalescing Fetcher re-fetches per concurrent miss). Bytes sliced from a Range-ignored whole-object 200 are served but never cached (§3.9). - Non-chunked responses on the block path: client reads served from the
block engine always use Content-Length framing. Verbatim passthrough of a
Range-blind origin forwards the origin's own framing, chunked included.
An empty object is a valid
200withContent-Length: 0; a Range request on it is416(bytes */0), suffix ranges included. - Range semantics: standard
200/206/416with correctContent-Range. - No wrapped geometry on the relay path: a peer-supplied block index that
cannot be represented in int64 range arithmetic
(
> chunk.Config.MaxBlockIndex()) is declined before any cache lookup, relay selection, size probe, or origin I/O — never served, never cached, never turned into a whole-object origin GET (issue #52; the fetch-layer arithmetic guard is in docs/fetch.md §3.4).
Engineis safe for concurrent use: the size cache is mutex-guarded, the store is concurrency-safe, and theCoalescingfetcher dedups concurrent block misses. Verified with-raceunder overlapping concurrentServe.Handleris safe for concurrent use ifResolve/Eare not mutated after construction.
- The block key uses
chunk.ChunkKey(part of the wire protocol via placement), but nothing else here is on the wire. HTTP Range/Content-Range is the external client contract.
- Results:
go vetclean;go testall pass;go test -raceclean. - Coverage: 85.4% of statements. Uncovered lines are mostly rare I/O error branches (short block, mid-stream write error).
- Note: tests use
t.TempDir()(store files); in the sandbox exportTMPDIR=$PWD/.gotmp. - Reproduce:
export TMPDIR=$PWD/.gotmp # plus the cache dirs from docs/README.md
go test ./internal/engine/ -v -count=1
go test ./internal/engine/ -race -count=1
go test ./internal/engine/ -cover -count=1| Test | Property guarded |
|---|---|
TestServeFullAndRange |
full and sub-range serve return exact bytes |
TestServeAcrossBlocksAndChunks |
a range crossing block/chunk boundaries is assembled correctly |
TestServeEndClampAndBadStart |
end clamped to size; start beyond size errors |
TestSize |
object size probed correctly |
TestSizeHiddenTotalIsProbeFailure |
a 206 hiding the total fails loudly, caches nothing, and never fabricates a size |
TestCacheHitAvoidsRefetch |
repeated identical serve makes zero origin requests |
TestConcurrentServe |
overlapping concurrent reads correct and race-free |
TestServeOriginError |
unreachable origin surfaces an error |
TestNewValidation |
invalid config / nil store / nil fetcher rejected |
TestHandlerFull |
200, full body, Accept-Ranges, non-chunked |
TestHandlerRange |
206 + Content-Range + exact bytes, non-chunked |
TestHandlerSuffix / TestHandlerOpenEnded |
suffix and open-ended ranges |
TestHandler416 |
unsatisfiable range → 416 + bytes */size |
TestHandlerHEAD |
HEAD returns headers, empty body |
TestNeverSendsHeadUpstream |
a client HEAD is answered without ever HEADing the origin, against a presigned-GET origin that 403s any other verb |
TestSizeProbeUsesRangedGet |
the size comes from exactly one bytes=0-0 GET |
TestHandlerMethodNotAllowed |
non-GET/HEAD → 405 |
TestHandlerResolveError / TestHandlerOriginError |
400 / 502 error paths |
TestParseRange / TestParseRangeEdge |
Range header parsing incl. edges |
TestEngineP2PPeerHit |
blocks pulled from a warmed owning peer (origin only for the size probe) |
TestEngineP2PPeerMissFallsBackToOrigin |
empty peer (404) → origin fallback, correct bytes |
TestEngineP2PSelfOwnerUsesOrigin |
self is owner → peers not consulted, origin used |
TestPeerSourceRelayFetchOnBehalf |
relay Source fetches-on-behalf and serves a block it did not hold |
TestRelayHopBoundary |
both peer-facing sources: hop == maxHop-1 still relays; hop == maxHop declines without touching the origin |
TestTreeMultiHopRelay |
3-node fanout=1 chain: a tail request populates every node via the relay chain |
TestPeerStreamSourceLocalHit |
locally-held block streamed from the store |
TestPeerStreamSourceRootFetchesOrigin |
tree root satisfies a relay request from origin and caches it |
TestStreamRelayChainThroughEngines |
3-node fanout=1 chain over streaming peer servers: bytes intact, every node cached |
TestStreamRelayRejectsWrongLengthBlock |
a short clean chunked relay stream is served on but never cached; a correct relay is cached |
TestStreamRelayDoesNotCacheWhenSizeUnresolved |
relayed bytes are not cached when the object size cannot be resolved |
TestClassOfMatchesPlacement |
owned iff in HRW top-Replicas, else borrowed; Replicas=3 makes all members owners |
TestClassOfSingleNodeIsOwned |
without P2P everything cached is owned |
TestServeUsesOwnedBudget |
single-node serve fills the owned budget only |
TestServeBorrowedWhenNotOwner |
a non-owner caches into borrowed, never owned |
TestLatencyEstimator* |
quantile needs ≥16 samples; p50/p99 correctness; bounded window ages out old samples |
TestHedgeLimiterEnforcesRatio |
long-run hedge rate tracks the configured ratio (0.05/0.25/0.5) |
TestHedgeLimiterDefaultsAndClamps / NoBurstAfterIdle |
ratio defaults/clamps; banked credit cannot burst |
TestHedgeDelayDisabledAndBounds |
off when disabled or sample-starved; floored and capped |
TestHedgeBeatsSlowPrimary |
a 5 s stalled parent no longer stalls the read — completes in ms via the backup |
TestHedgeDisabledWaitsForPrimary |
the contrast: with hedging off the read waits out the stall and sends no duplicate |
TestPeerSourceDeclinesRangeBlindOrigin |
the buffered relay declines a Range-blind origin like the streaming one; nothing cached |
TestBlockDoesNotCacheRangeIgnored |
defense in depth: bytes sliced from a Range-ignored 200 are served, never cached |
TestHedgeWinMetricsRequireFiredHedge |
no hedge fired → no hedge-win counters (the backup_won/fired comparison stays meaningful) |
TestMissesDoNotFeedLatencyEstimator |
fast 404 misses never arm the hedge delay |
TestReaderSetCacheFollowsGrantedLease |
the reader-set cache renews at half the granted lease, never lapsing mid-read |
TestStreamRelayParentErrorFallsBackToOrigin |
an open-circuit/errored parent is skipped to origin, not propagated as a 500 |
TestEmptyObjectGetServes200 |
empty object: 200 empty on both paths; a Range request stays 416 |
TestParseRangeSuffixEmptyObject |
a suffix range on an empty object is unsatisfiable (no inverted [0,-1] interval); non-empty suffix behavior unchanged |
TestEmptyObjectSuffixRangeServes416 |
bytes=-n on an empty object → 416 + Content-Range: bytes */0 for GET and HEAD, never a 206 with bytes 0--1/0 |
TestNamespaceWithSeparatorRejected |
a namespace containing the chunk-key separator fails engine construction |
TestHedgeFallsBackWhenBothMiss |
both contenders 404 → ok=false → origin |
TestHedgeTargetsPickParentAndGrandparent |
primary=parent, backup=grandparent/root; root has no upstream; non-member asks the owner |
TestHedgeTargetsSkipsOpenCircuit |
an open parent is skipped and the grandparent promoted; all-open yields no target |
TestFromPeerFallsBackWhenCircuitOpen |
no usable ancestor → origin, with zero dials |
TestHedgeSameAddressNotRaced |
no pointless duplicate when there is no distinct backup |
TestFailoverNotRateLimited |
with the hedge budget drained, a dead parent still escalates to the grandparent instead of falling to origin |
TestFailoverDoesNotDoubleLaunch |
an in-flight hedge is not launched a second time when the primary then fails |
TestHandlerPassthroughForRangeBlindOrigin |
a 200-probe origin is proxied verbatim: 2 origin hits (probe+GET), then 1; nothing cached |
TestPassthroughForwardsRangeVerbatim |
the client's Range is forwarded; the origin's 200 full body is returned as-is |
TestPassthroughHEAD |
HEAD proxied via an upstream GET; Content-Length forwarded, empty body |
TestRangeCapableOriginNotMarked |
a 206-capable origin is never marked or passthrough-ed |
TestPassthroughCountsMetrics |
dart_passthrough_total increments; bytes count as client+origin wire bytes, not as any block source |
TestPassthroughUnavailableWithoutOpener |
a non-streaming fetcher fails the passthrough cleanly with 502 |
TestRelayDeclinesRangeBlindOrigin |
a relay declines (held=false) a block of a Range-unsupported object |
TestPeerSourceRejectsMalformedBlockIndex |
wire indices above MaxBlockIndex (2^63, 2^64-1, first window-overflowing index) are declined before cache lookup/size probe/origin I/O; nothing cached; legitimate control still relays (issue #52) |
TestPeerStreamSourceRejectsMalformedBlockIndex |
same decline on the cut-through path: nothing streamed, sizer not called, zero origin requests; legitimate control still streams |
- P2P tree wired: a miss routes to the parent in the preorder tree; relay
nodes fetch-on-behalf and cache, offloading the owner; the root fetches origin.
Loop-bounded by
X-DART-Hop. Cut-through relay is implemented (PeerStreamSource+peer.StreamServer), so hops pipeline rather than store-and-forward. The active-reader-set tree is implemented too: with a tracker configured the tree spans only the nodes reading the object (see docs/tracker.md), falling back to all Ready members otherwise. Hedging, per-request timeouts, and per-peer circuit breaking bound tail latency and isolate sick peers (§3.8). Still missing:splice-level zero copy and full epoch-skew convergence. - Hedging covers
Get, notStream: the cut-through relay path (relayFromParent) is bounded bypeer.Client.Timeoutbut does not yet race a duplicate, because a hedged stream would need the two bodies reconciled. - Range-unsupported origins are proxied, not cached (§3.9): nothing about them is learned beyond the mark itself (no TTL, no re-probe until restart), and their traffic bypasses the P2P tree entirely.
- Readahead:
Servefetches covering blocks sequentially; parallel fan-out and prefetch of subsequent blocks are future work. - Streaming vs buffering: blocks are fetched whole into memory (bounded by
block size) then written; fine for cache hits/
sendfilelater. - Size cache lifetime: sizes are cached for process life with no TTL — correct for immutable objects; mutable objects need the policy layer.
- Mid-stream errors: once headers are sent, a block error can only truncate the body (client detects via Content-Length); prefetching the first block before sending headers could turn some of these into clean error statuses.