13 KiB
Design — @suaveplan/storage-mux (complete)
Non-normative companion to proposal.md and specs/storage-mux/spec.md.
1. Role in the family
@suaveplan/storage AbstractStorage core (key/value, in-memory)
├─ @suaveplan/storage-cdn CdnAdapter contract
│ ├─ @suaveplan/storage-cdn-bunnycdn
│ └─ @suaveplan/storage-cdn-rackspace
├─ @suaveplan/storage-s3
├─ @suaveplan/storage-http
├─ @suaveplan/storage-file
└─ @suaveplan/storage-mux Multi-backend composition (THIS PACKAGE)
└─ wraps N StorageLike (incl. other Mux), exposes 1 StorageLike
storage-mux is a composition package, not a transport. A StorageMux is itself a StorageLike, so a sharded-then-failed-over deployment is Mux(round-robin, [Mux(failover, regionA), Mux(failover, regionB)]). Recursion depth is capped at 8 (RECURSION_DEPTH_EXCEEDED) so a configuration cycle cannot DoS the process.
2. File layout
src/
index.ts # explicit named re-exports
error-codes/
error-codes.ts # defineCodes("STORAGE_MUX", [...]) + StorageMuxError
error-codes.md
error-codes.test.ts # reachability sweep — every code triggered
types/
backend-entry.types.ts # BackendEntry interface
mux-options.types.ts # StorageMuxOptions, MuxMode union, MuxStats
mux-options.schemas.ts # Zod boundary schemas
classify/
classify.ts # error → "transient" | "semantic" | "abort"
classify.md
classify.test.ts
health/
health.ts # BackendHealth tracker + cooldown + active probe scheduler
health.md
health.test.ts
bulkhead/
bulkhead.ts # Promise.race timeout + AbortSignal.any composition
bulkhead.md
bulkhead.test.ts
stats/
stats.ts # per-backend counters + reservoir-sampled latency
stats.md
stats.test.ts
modes/
selector.types.ts # BackendSelector + BackendExecutor interfaces
failover/
failover.ts
failover.md
failover.test.ts
round-robin/
round-robin.ts
round-robin.md
round-robin.test.ts
weighted/
weighted.ts # Walker alias-method
weighted.md
weighted.test.ts
sticky-by-key/
sticky-by-key.ts # FNV-1a → backend index
sticky-by-key.md
sticky-by-key.test.ts
shield/
shield.ts # origin-first reads + best-effort mirror writes
shield.md
shield.test.ts
mirror/
mirror.ts # parallel writes (Promise.allSettled) + read-first-success
mirror.md
mirror.test.ts
mutate/
mutate.ts # addBackend / removeBackend / setWeight / drain
mutate.md
mutate.test.ts
observability/
events.ts # event topic constants + emit helpers
events.md
events.test.ts
otel.ts # per-attempt span hierarchy + attributes
otel.md
otel.test.ts
mux/
mux.ts # StorageMux extends AbstractStorage
mux.md
mux.test.ts
mux.spec-conformance.test.ts # runStorageConformance × 6 modes + 1 recursive
3. Mode state machines
3.1 failover
Sequential fall-through. Per-attempt: runWithRetry(retryStrategy, () => bulkhead(backend[idx].op(...))). Transient errors advance idx; semantic errors surface; abort surfaces BACKEND_ABORTED. Exhausting the list throws ALL_BACKENDS_DOWN with the prior error as cause and attemptCount = backends.length.
3.2 round-robin
Atomic counter modulo healthy.length. Down/draining backends skipped. Zero healthy → ALL_BACKENDS_DOWN.
3.3 weighted
Walker alias-method table rebuilt on addBackend / removeBackend / setWeight / health transition. Selection is O(1) — one random byte for the slot, one for the alias coin. weight < 0 throws INVALID_WEIGHT. Construction without weights on any backend throws WEIGHTED_REQUIRES_WEIGHTS. Fall-through on transient: re-roll up to healthy.length times, then ALL_BACKENDS_DOWN.
3.4 sticky-by-key
index = fnv1a(utf8(key)) % healthy.length. Same key → same backend. Backend removal redistributes via the same formula recomputed against the shrunken set (acceptable cache-warmth trade-off). Operations without a key (e.g. list) round-robin internally.
3.5 shield
backends[0] = origin, backends[1..n] = mirrors.
- read: origin first; on transient/timeout, cascade to mirrors in order; first success wins.
- write:
Promise.allSettledover all backends. Origin rejection throws (cause = origin error, mirror outcomes discarded). Origin success + any mirror rejection emitsstorage-mux.fanout-partialand returns successfully — unlessthrowOnFanoutPartial: true, then throwsWRITE_FANOUT_PARTIAL.
3.6 mirror
Symmetric peers — no designated origin.
- read:
Promise.anyrace across healthy backends; first success wins; all-reject →ALL_BACKENDS_DOWN. - write:
Promise.allSettledacross all healthy backends; any rejection emitsstorage-mux.fanout-partial; all rejections →ALL_BACKENDS_DOWN.MIRROR_ORIGIN_REQUIREDis not thrown in this mode — every backend is equal.
The difference vs shield: shield privileges backends[0] (writes must succeed there); mirror treats every backend identically. Both fan out writes; only mirror races reads in parallel.
4. Error classification
| Error shape | Class | Triggers fall-through? | Code surfaced |
|---|---|---|---|
AbortError / signal.aborted from caller |
abort | NO | STORAGE_MUX.BACKEND_ABORTED |
STORAGE.KEY_NOT_FOUND |
semantic | NO | rethrow as-is |
STORAGE.INVALID_KEY |
semantic | NO | rethrow as-is |
STORAGE_CDN.CREDENTIAL_EXPIRED |
semantic | NO | rethrow (caller must rotate) |
| HTTP 4xx (except 408 / 429) | semantic | NO | rethrow |
backendTimeoutMs race wins |
transient | YES | wrapped, last in cause chain (BACKEND_TIMEOUT if exhausted) |
ECONNRESET / ETIMEDOUT / fetch network error |
transient | YES | wrapped |
| HTTP 5xx | transient | YES | wrapped |
| HTTP 408 / 429 | transient | YES | wrapped |
| Unknown thrown value (non-Error) | transient | YES | wrapped |
BACKEND_DRAINING re-selection signal (internal) |
transient | YES | re-select next backend |
classify is a pure function exported from src/classify/classify.ts and tested independently with property tests over the boundary shape.
5. Backend isolation guarantees
A slow / hung / throwing backend MUST NOT corrupt mux-wide state.
- Per-backend timeout — every backend call wrapped in
Promise.race([call, timeoutPromise(backendTimeoutMs)]). Default 30 s. Underlying promise is allowed to settle in the background. - AbortSignal.any composition — per-attempt signal =
AbortSignal.any([userSignal, timeoutSignal, drainSignal]). Forwarded to backend; well-behaved backends abort promptly. - State writes are atomic per backend — health table, weighted alias table, round-robin counter, and stats counters mutate through dedicated helpers that take the backend name as a key. A throw inside backend N can never leave backend M in a partially-updated state.
mirror/shieldwrites usePromise.allSettled— neverPromise.all; one slow mirror cannot reject the operation early.- Recursion safety — when a backend is itself a mux, the parent passes its composite
AbortSignal. The child mux is a black box; its internal state is its own.
6. Health tracking
BackendState:
type Status = "up" | "down" | "draining";
interface BackendState {
status: Status;
consecutiveFailures: number;
cooldownUntil: number; // Clock.now() millis
lastError?: unknown;
lastTransitionAt: number;
inFlightCount: number; // for drain accounting
}
Transitions:
- up → down: any classified-transient error.
cooldownUntil = now + min(cooldownMs * 2 ** min(consecutiveFailures, 6), maxCooldownMs). Emitsstorage-mux.backend.down. - down → up:
now >= cooldownUntilAND next selection probes AND probe succeeds. ResetconsecutiveFailures = 0. Emitsstorage-mux.backend.recovered. - * → draining:
mux.drain(name). Removed from new selection. WheninFlightCount === 0, transitions todownand removes from active set. Emitsstorage-mux.drain.startedand.drain.completed. - Active healthcheck: if
BackendEntry.healthcheckis provided ANDStorageMuxOptions.healthcheckIntervalMsis set, the mux schedulesClock.setIntervalto invoke it with a derivedAbortSignal. A rejecting healthcheck marksdownwith causeHEALTHCHECK_FAILED.
Clock is injected (@suaveplan/types#Clock); tests use FakeClock.
7. Stats API
interface BackendStats {
name: string;
status: "up" | "down" | "draining";
hits: number;
misses: number;
errors: number;
timeouts: number;
lastLatencyMs: number;
p50LatencyMs: number;
p99LatencyMs: number;
consecutiveFailures: number;
lastTransitionAt: number;
inFlightCount: number;
}
interface MuxStats {
mode: MuxMode;
totalCalls: number;
totalFailovers: number;
totalFanoutPartials: number;
backends: readonly BackendStats[];
}
Latencies use a 256-slot reservoir sample (Vitter Algorithm R) so memory is bounded regardless of call volume. Counters use plain number increments — JS is single-threaded; no atomics needed.
8. Runtime mutation
| API | Effect |
|---|---|
addBackend(entry) |
Append to backend list; rebuild weighted alias table if mode is weighted; emit backend.added. Conflict on name → BACKEND_NAME_CONFLICT. |
removeBackend(name) |
Synonym for drain(name) then immediate purge from list. |
setWeight(name, weight) |
Mutate BackendEntry.weight; rebuild alias table; emit weight.changed. weight < 0 → INVALID_WEIGHT. |
drain(name, { signal? }) |
Mark draining; await inFlightCount === 0 (with optional caller abort); transition to removed. Returns Promise<void>. |
Mutation is atomic relative to in-flight ops: in-flight requests targeting a draining backend complete normally; new requests skip the backend.
9. Observability
9.1 OTel span hierarchy
storage-mux.<operation> (parent span, attrs: mux.mode, mux.key, mux.policy)
└─ storage-mux.attempt (child span per backend attempt)
attrs: mux.backend.name, mux.attempt.index, mux.mode,
mux.outcome ∈ {ok, transient, semantic, abort, timeout},
mux.latency.ms
No-op when no TracerLike is provided.
9.2 Events (via EventBusLike from @suaveplan/types)
| Topic | Payload |
|---|---|
storage-mux.backend.down |
{ name, cause, code } |
storage-mux.backend.recovered |
{ name, downForMs } |
storage-mux.failover |
{ from, to, attemptIndex, cause } |
storage-mux.fanout-partial |
{ mode, failed: readonly string[], causes: readonly unknown[] } |
storage-mux.drain.started |
{ name, inFlightCount } |
storage-mux.drain.completed |
{ name, drainedDurationMs } |
storage-mux.backend.added |
{ name, weight? } |
storage-mux.backend.removed |
{ name } |
storage-mux.weight.changed |
{ name, from, to } |
No-op when no bus is provided.
10. Tier purity
Universal — no node:*, no bun:*, no DOM. Hashing is pure-JS FNV-1a. Randomness for weighted uses an injectable Rng from @suaveplan/types (defaults to Math.random). Timeouts use injected Clock. AbortSignal.any is standard Web API (ES2024); a tiny shim is included for runtimes that lack it.
11. Trade-offs
- Six modes vs. four. Adding
weightedandmirrorcovers the canary and multi-region-write use cases without forcing callers to wrapfailoverin custom logic. Each mode is ≤ 250 lines; the cost is six test files instead of four. - Walker alias-method for
weighted. O(1) selection beats roulette-wheel O(n) under runtime mutation. The cost is O(n) rebuild on weight changes — acceptable for backend counts ≤ 100. - Recursive composition. Allowed because
StorageMuxis itselfStorageLike. Depth capped at 8 to bound debugging surface and prevent configuration loops. Span hierarchy makes nested traces legible. - Stats use plain counters. JS single-threaded execution makes atomics unnecessary; the reservoir sample bounds memory at 256 floats per backend.
- Drain returns a promise. Callers can
await mux.drain("old-backend")before tearing down credentials — no race between drain completion and credential rotation.