9.6 KiB
Complete @suaveplan/storage-mux — feature-complete multi-backend storage facade
Package: @suaveplan/storage-mux
Status: PROPOSED
Type: new-package (genesis-original)
- Design: ./design.md
- Spec delta: ./specs/storage-mux/spec.md
Why
@suaveplan/storage-cdn (archived 2026-05-16) explicitly deferred origin shielding and multi-origin failover as out-of-scope, naming storage-mux as the future home (openspec/archive/2026-05-16-add-storage-cdn/proposal.md "Out of scope": "Origin shielding / multi-origin failover. A failover/shielding layer is a separate concern composed via storage-mux (future) or application logic."). The deferral was correct — failover semantics are orthogonal to CDN-specific surface (purge/signing/edgeUrl) and belong in a composable layer over the bare AbstractStorage contract.
The first draft of this change (openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-storage-mux/) scoped four policies (failover, round-robin, sticky-by-key, shield) and was archived as part of a deferral sweep. This rewrite widens the surface to the full set required by real consumers — weighted distribution, mirror-mode (write-fan-out + read-first), runtime mutation, drain, bulkhead timeout, stats API, recursive composition, and a per-attempt OTel span hierarchy — so the package ships once as a feature-complete primitive instead of accreting modes over six follow-up changes.
storage-mux is a universal-tier composition layer: it accepts a declarative policy plus an ordered list of StorageLike backends and exposes a single StorageLike to consumers. Backends can be any storage adapter — storage-cdn-*, storage-s3, storage-http, storage-file, in-memory, or another StorageMux — making the mux the canonical primitive for CDN failover, origin shielding, mirror reads, shard distribution, and tiered cache hierarchies.
≥2-consumer justification (per openspec/AGENTS.md §0 step 6)
Forward-declared consumers that share the mux primitive:
- CDN failover for user-uploaded content — primary
storage-cdn-bunnycdnwithstorage-cdn-rackspacefallback (failovermode). - Cache mirroring for
storage-plugin-cache— localstorage-filecache in front of remotestorage-s3origin (shieldmode). - Drive chunk shielding —
drive-plugin-chunksreads chunks from a regional mirror set (sticky-by-key+failover). - Multi-region write durability — application code writes media to three S3 buckets in parallel (
mirrormode), surviving any single-region outage. - Weighted canary — gradually shift 10% / 25% / 50% of write traffic to a new backend before cutover (
weightedmode + runtime weight mutation + drain on rollback).
All five scenarios share the same primitive (backend list + mode + health + retry); without a shared mux each consumer reinvents the same state machine and the STORAGE_MUX.* registry fragments.
What changes
- ADDED:
StorageMuxclass extendingAbstractStoragefrom@suaveplan/storage. Wraps the configured backend list and mode and exposes the fullStorageLikesurface (get,set,setPartial,has,delete,list,stat,getStream,setStream,capabilities). - ADDED:
MuxModediscriminated string union —"failover" | "round-robin" | "weighted" | "sticky-by-key" | "shield" | "mirror". Each mode is implemented as aBackendSelector/BackendExecutorstrategy class behind a common interface. - ADDED:
BackendEntryinterface —{ name: string; storage: StorageLike; weight?: number; healthcheck?: (signal: AbortSignal) => Promise<void>; tags?: readonly string[] }. - ADDED:
StorageMuxOptions— declarative policy bundle:{ backends, mode, retry?, clock?, events?, logger?, tracer?, cooldownMs?, maxCooldownMs?, healthcheckIntervalMs?, backendTimeoutMs?, hashFn?, throwOnFanoutPartial?, stats? }. - ADDED: Six execution modes (full design in
design.md§3) includingweighted(Walker alias-method O(1) selection overweightfield) andmirror(write-fan-out viaPromise.allSettled; read-first-success via cascading race). - ADDED:
BackendHealthtracker with passive degrade (mark down on classified-transient error class, exponential cooldown capped atmaxCooldownMs) and active polling (configurablehealthcheckfn invoked on injectedClockinterval). - ADDED: Error classification table (
design.md§4):transient→ retry+fall-through;semantic→ surface as-is;abort→ surface asBACKEND_ABORTED. Pure functionclassify(error: unknown) => "transient" | "semantic" | "abort". - ADDED: Retry composition via
@suaveplan/retry(runWithRetry+ injected strategy). The mux never reimplements backoff, jitter, or attempt counting. - ADDED: Bulkhead — every backend call wrapped in
Promise.raceagainst aClock-drivenbackendTimeoutMsso a hung primary cannot block siblings or starve fan-out. Underlying promise is allowed to settle in the background; the mux never awaits it. - ADDED: AbortSignal.any composition linking user signal + per-attempt timeout signal + drain signal; the combined signal is forwarded to every backend call.
- ADDED: Backend isolation — failure, hang, or throw inside one backend MUST NOT corrupt the mux's internal state (health table, round-robin counter, weighted-mode alias table, stats counters). Per-backend state is mutated only through atomic helpers exercised by stress tests.
- ADDED: Recursive composition — a
StorageMuxis itself aStorageLikeand SHALL be acceptable as aBackendEntry.storage. The conformance harness exercises a two-level mux tree. - ADDED: Runtime mutation API —
addBackend(entry),removeBackend(name),setWeight(name, weight),drain(name, { signal? }). Mutation is atomic relative to in-flight operations; a drained backend completes its outstanding calls before being removed. - ADDED: Drain semantics —
drain(name)marks the backend asdraining, removes it from new-selection eligibility, awaits all in-flight ops (with caller-visible promise), then transitions todownand removes from the active set. - ADDED: Stats API —
mux.stats()returns per-backend counters (hits,misses,errors,timeouts,lastLatencyMs,p50LatencyMs,p99LatencyMs,consecutiveFailures,lastTransitionAt). Counters are sampled, not synchronous-locked. - ADDED: Observability:
- OTel per-backend-attempt span as a child of the operation span, attributes
mux.backend.name,mux.attempt.index,mux.mode,mux.outcome,mux.latency.ms. EventBusLikeemissions onstorage-mux.backend.down,.recovered,.failover,.fanout-partial,.drain.started,.drain.completed,.backend.added,.backend.removed,.weight.changed.
- OTel per-backend-attempt span as a child of the operation span, attributes
- ADDED:
STORAGE_MUX_CODESviadefineCodes("STORAGE_MUX", [...])— comprehensive registry (ALL_BACKENDS_DOWN,NO_BACKENDS_CONFIGURED,HEALTHCHECK_FAILED,BACKEND_TIMEOUT,INVALID_MODE,INVALID_WEIGHT,WRITE_FANOUT_PARTIAL,BACKEND_ABORTED,BACKEND_NOT_FOUND,BACKEND_NAME_CONFLICT,BACKEND_DRAINING,WEIGHTED_REQUIRES_WEIGHTS,MIRROR_ORIGIN_REQUIRED,RECURSION_DEPTH_EXCEEDED). - ADDED:
StorageMuxError extends SuaveplanError<StorageMuxErrorCode>withbackendName,attemptIndex,attemptCount,modediscriminants. - ADDED:
runStorageConformance(from@suaveplan/storage) executed againstStorageMuxwith NMemoryStoragebackends under every mode, plus a recursiveStorageMux(failover, [StorageMux(mirror, [...]), StorageMux(mirror, [...])])configuration.
Out of scope
- Backend implementations. Each backend stays its own package (
storage-cdn-*,storage-s3,storage-http,storage-file, etc.). The mux composesStorageLikevalues handed in by the caller; it does not instantiate or authenticate any backend.
Source mapping
Genesis-original; no upstream in ../suaveplan/. Heavily reuses openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-storage-mux/ as a starting point (same family conventions, same error registry namespace, same conformance approach) — this draft widens that surface; it does not narrow it. Industry references: HAProxy / Envoy passive+active health checks; HashiCorp Consul service-mesh failover; AWS Route 53 health-check failover routing; NGINX upstream max_fails / fail_timeout; Netflix Hystrix bulkhead.
Verification approach
Every requirement in specs/storage-mux/spec.md carries a **Verified by:** line naming planned test files. Coverage gate: 100/100/100/100. The conformance harness runs runStorageConformance once per mode against a three-backend MemoryStorage fleet, plus once against a two-level recursive mux to prove recursion does not break invariants.
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/storage-mux to enumerate. The package is new — no current dependents.
- No current dependents (verified by
bunx turbo ls --filter=...@suaveplan/storage-muxreturning the package itself). - Forward-declared consumers listed under the consumer justification above; each will land its own OpenSpec change to wire the mux in.
Affected upstreams
@suaveplan/storage— consumed viaAbstractStorage+StorageLike+runStorageConformance. No augmentation. [x] no action needed.@suaveplan/retry— consumed viaRetryStrategy+runWithRetry. No augmentation. [x] no action needed.@suaveplan/error—defineCodes+SuaveplanError. [x] no action needed.@suaveplan/events—EventBusLikeconsumer only. [x] no action needed.@suaveplan/types—Clock,LoggerLike,TracerLike,EventBusLike. [x] no action needed.