A robust, multi-level, fail-safe cache for Rust — a faithful, idiomatic port of the resiliency model pioneered by .NET's FusionCache.
An amalgam is a fusion of metals. This crate fuses an in-memory L1 cache
(built on moka) with an optional distributed
L2 cache and a multi-node backplane, and gives you the features that make
a cache robust rather than merely fast — on top of tokio.
[]
= "0.1"
Why a cache needs more than get/set
A plain TTL cache collapses under load and failure: when a hot key expires, every
request stampedes the database at once; when the database hiccups, every request
fails. amalgam solves both, the way FusionCache does:
- Cache-stampede protection — only one factory runs per key; everyone else awaits that single result (single-flight).
- Fail-safe — if the factory fails, serve the last known-good (stale) value instead of propagating an error.
- Soft / hard timeouts — a slow factory returns a stale value immediately and finishes in the background.
- Eager refresh — refresh proactively before expiration, off the hot path.
- Adaptive caching — the factory can change an entry's options per call.
- Conditional refresh — HTTP-style
NotModifiedreuse of a stale value. - Tagging — invalidate many entries at once, lazily, by tag.
- L1 + L2 + backplane — a pluggable distributed cache and multi-node sync.
See docs/PARITY.md for the feature-by-feature mapping to
FusionCache, and PORTING.md for the C#→Rust translation method.
Quickstart
use ;
async
Signal a factory failure with ctx.fail(..) (or return any FactoryError); wrap
a source error with FactoryError::from_source(e):
let user = cache
.get_or_set
.await?;
Fail-safe + timeouts
Configure resiliency per call through EntryOptions:
use ;
use Duration;
let opts = cache
.entry_options
.with_duration
// enable fail-safe: keep values for up to 1h, re-serve stale for 30s between retries
.with_fail_safe
// if the factory takes > 100ms and a stale value exists, return it now and finish in the background
.with_factory_timeouts;
let value = cache.get_or_set_with.await?;
If the factory later errors, amalgam serves the stale value and fires a
FailSafeActivate event instead of returning an Err.
Adaptive & conditional refresh
The factory receives a [FactoryContext] it can use to adapt caching or do an
HTTP-style conditional request:
let html = cache.get_or_set.await?;
Multi-level: L1 + L2 + backplane
Add a distributed L2 (anything implementing DistributedCache) and a backplane
to keep several nodes' L1 caches coherent. Reference in-memory/in-process backends
ship by default for testing and single-process multi-instance setups; a real
Redis L2 + backplane + locker ship behind the redis feature.
use ;
use Arc;
let clock: = new;
let l2: = new;
let backplane: = new;
// `V` must be `Clone + Serialize + DeserializeOwned` to cross the L2 wire.
let cache: = builder
.distributed
.serializer
.backplane
.instance_id
.build;
Now get_or_set reads through L1 → L2 → factory and writes through to both; a
set/remove/expire on one node publishes a backplane message so peers drop
their stale L1 copy and re-pull the authoritative value from L2.
Observe what the cache is doing
use CacheEvent;
let mut events = cache.events.subscribe;
spawn;
Resilience: circuit breakers + auto-recovery
When the L2 cache or backplane is flaky, two FusionCache mechanisms keep the cache fast and self-healing:
- Circuit breakers stop hammering a known-bad dependency. After a failure the
breaker opens for a fixed window; while open, L2 / backplane ops are skipped
(and queued for recovery), then it auto-closes. A
Duration::ZERObreaker is permanently closed — the default, matching FusionCache. - Auto-recovery queues the ops that failed (or were skipped while a breaker was open) and replays them on a background drain, with latest-wins dedup per key and a bounded queue + retry budget. It is enabled by default whenever an L2 or backplane is configured.
use ;
use Duration;
let cache: = builder
.distributed
.serializer
.backplane
// open the L2 breaker for 5s after a failure (ZERO = disabled, the default)
.distributed_circuit_breaker
.backplane_circuit_breaker
// tune the retry queue (this is also the default when L2/backplane is present)
.auto_recovery
.build;
A breaker opening or closing fires CacheEvent::CircuitBreakerChange { component, closed }.
Cross-node single-flight (distributed locker)
In-process stampede protection runs one factory per key per node. A
DistributedLocker extends that to one factory per key across the cluster — it
is acquired after the local lock. Share one InMemoryDistributedLocker between
caches in a single process, or use the Redis-backed locker for a real cluster.
Opt a single call out with EntryOptions::with_skip_distributed_locker.
use ;
use Arc;
let clock: = new;
let locker: = new;
let cache: = builder
.distributed
.serializer
.distributed_locker
.build;
Plugins & metrics
A Plugin observes every CacheEvent (and an on_start lifecycle hook) — the
Rust counterpart of IFusionCachePlugin. Plugins run on the (already cheap,
non-blocking) event path, so a plugin must not block; offload real work to its own
task.
use ;
;
let cache: = builder
.plugin
.build;
With the metrics feature, MetricsPlugin is a ready-made plugin that records
counters (hits, misses, sets, factory errors/timeouts, fail-safe activations, eager
refreshes) through the metrics facade — point any
compatible exporter (Prometheus, OTLP, …) at it:
use MetricsPlugin; // requires `features = ["metrics"]`
let cache: = builder
.plugin
.build;
Named caches & dynamic defaults
Where FusionCache resolves named caches from a DI container, amalgam offers a
CacheRegistry (register/resolve by name) and a DefaultEntryOptionsProvider
(per-key default options, consulted when a call passes no explicit options):
use ;
use Arc;
use Duration;
let registry: = new;
let users = registry.get_or_create;
;
let cache: = builder
.default_options_provider
.build;
let _ = ;
Cargo features
All distributed backends and extras are opt-in; the default build is dependency-light and uses the in-memory / in-process reference backends.
| Feature | Enables |
|---|---|
| (default) | L1 + reference L2/backplane/locker (InMemoryDistributedCache, InProcessBackplane, InMemoryDistributedLocker), JsonSerializer. |
redis |
RedisDistributedCache, RedisBackplane, RedisDistributedLocker on redis::aio::ConnectionManager. |
messagepack |
MessagePackSerializer (compact L2 payloads via rmp-serde). |
postcard |
PostcardSerializer (smallest L2 payloads, serde-native binary). |
metrics |
MetricsPlugin (counters via the metrics facade). |
opentelemetry |
otel::init_otlp(..) — export the crate's tracing spans over OTLP. |
full |
all of the above. |
[]
= { = "0.1", = ["full"] }
The Redis adapters connect with an async constructor:
use ;
use Arc;
let l2: =
new;
let backplane: =
new;
let cache: = builder
.distributed
.serializer
.backplane
.instance_id
.build;
Design notes
amalgam is a type-driven port: where FusionCache leans on .NET runtime type
info, exceptions, or null, amalgam uses Rust idioms that make whole bug
classes unrepresentable.
Cache<V>is generic over one value type — nodyn Anydowncasts.Timeout { Infinite, After(Duration) }replaces the-1mssentinel.MaybeValue<V>/Resultreplacenull/ exceptions; a cache miss is never an error, and a fail-safe-rescued failure returns a value, not anErr.Clockis injected (SystemClock/ManualClock), so all expiration is deterministic in tests.#![forbid(unsafe_code)].
Status
The full FusionCache feature set is implemented: the L1 resiliency model (stampede, fail-safe, soft/hard timeouts with background completion, eager refresh, adaptive + conditional refresh, tagging) plus L1 + L2 + backplane with read-through / write-through, multi-node invalidation and cross-node tag/clear propagation, circuit breakers, auto-recovery, a cross-node distributed locker, plugins, a named-cache registry with a dynamic default-options provider, and an optional Redis backend (L2 + backplane + locker), MessagePack serializer, and metrics plugin behind feature flags.
It is verified by a behavioural test oracle (tests/behavior.rs), end-to-end
multi-level tests (tests/multilevel.rs), feature/recovery tests, and Redis
integration tests run against a live server via docker-compose.yml: the default
suite (60 tests) is green and cargo clippy is warning-clean on the default build
and --features full; #![forbid(unsafe_code)].
OpenTelemetry is supported: an always-on tracing span (amalgam.get_or_set)
works with any subscriber, and otel::init_otlp(service, endpoint) (feature
opentelemetry) exports spans over OTLP/gRPC to a collector such as Jaeger — try
docker compose up -d then cargo run --example otel --features opentelemetry.
Still roadmap (kept honest): a Microsoft.Extensions.DependencyInjection-style DI
integration (the registry is the Rust-idiomatic substitute); and serializers beyond
JSON / MessagePack (Protobuf / MemoryPack). See docs/PARITY.md
for the precise, row-by-row status of every feature.
Acknowledgements
- FusionCache by Jody Donetti — the design this crate ports.
- The C#→Rust porting methodology (
PORTING.md) adapts the decision-table approach Bun used for its Zig→Rust AI port. - Built on moka and tokio.