Skip to content
ZoonTronLabsPublic

About

Rust hybrid cache with async and native APIs, fail-safe, per-key stampede protection and observable background work. FusionCache-inspired; measured results and qualification gaps documented.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

amalgam

amalgam

crates.io docs.rs CI MIT

A Rust hybrid cache with async operations, inspired by FusionCache, with local caching, optional distributed storage, fail-safe values, background refresh and observable mutations. Minimum Rust version: 1.88, edition 2024.

The 0.4 release series provides the eight-operation API documented below. The outage-policy section describes its defaults. Install the published crate:

[dependencies]
amalgam = { package = "amalgam-cache", version = "0.4" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

The published package is amalgam-cache; the Rust library is imported as amalgam. The release scope and still-open full functionality inventory are tracked in FULL_CONTRACT.md and the changelog.

Amalgam vs FusionCache 2.9

Time per operation, one worker — lower is better. Both libraries run the same workload on the same machine in alternating processes. FusionCache 2.9.0 uses normal .NET tiered compilation with Dynamic PGO after a settled warmup; Amalgam is built with the default release profile (no LTO). "Through L2" uses an in-memory distributed cache with the JSON serializer and skips L1 reads.

Linux — GitHub CI runner, AMD EPYC 9V74 (run)

Operation Amalgam FusionCache 2.9 Amalgam is
try_get, value in memory 112 ns 155 ns 1.4× faster
get_or_set, value in memory 109 ns 193 ns 1.8× faster
Synchronous try_get 77 ns 111 ns 1.4× faster
Synchronous get_or_set 90 ns 144 ns 1.6× faster
set (replace a value) 188 ns 220 ns 1.2× faster
try_get through L2 715 ns 1,176 ns 1.6× faster
get_or_set through L2 946 ns 1,388 ns 1.5× faster
Cold get_or_set (runs the factory) ¹ 2,402 ns 3,430 ns 1.4× faster

macOS — Apple M4 Pro, local, FusionCache with invariant globalization (details)

Operation Amalgam FusionCache 2.9 Amalgam is
try_get, value in memory 38 ns 84 ns 2.2× faster
get_or_set, value in memory 42 ns 121 ns 2.9× faster
Synchronous try_get 33 ns 63 ns 1.9× faster
Synchronous get_or_set 37 ns 91 ns 2.5× faster
set (replace a value) 73 ns 114 ns 1.6× faster
try_get through L2 423 ns 668 ns 1.6× faster
get_or_set through L2 494 ns 767 ns 1.6× faster
Cold get_or_set (runs the factory) 1,007 ns 1,600 ns 1.6× faster
get_or_set, 8 workers on distinct keys ² 6.2 ns 27.2 ns 4.4× faster

¹ FusionCache's cold warmup did not settle on the Linux runner; treat this row as diagnostic. ² Aggregate elapsed time divided by completed operations.

Warm memory hits and set allocate nothing; an L2 read allocates three times. With Redis, a cold L2 read under the default durable-marker policy is one round trip (pipelined GET and HMGET): on a local Valkey it fell from 386 µs to 200 µs. These are cache microbenchmarks, not whole-application speedups. CPU models of hosted runners vary between runs, so compare numbers only within one run. Ranges, allocation counts, the same-runner improvement over 0.4.1 and open budgets are in PERFORMANCE.

0.4.0 implementation and measured results

The release completes eight lazy fluent operations: get_or_set, try_get, get_or_default, set, remove, expire, remove_by_tag and clear. try_get distinguishes a miss from a typed error; mutations have opt-in receipts. Native and async callers share cache state and lifecycle. Legacy operation adapters and MaybeValue are removed. Measured warm L1 reads and replacement sets use zero allocations per operation. See migration.

The release commit passes all 20 CI jobs. Local validation includes 770 default / 835 all-feature checks with live Redis and doctests, Rust 1.88/stable Clippy, package consumers and a clean publish dry-run. The FR/RS inventory has 20 Same, 6 Diff and 2 Gap rows. Complete paired FC qualification and arbitrary custom L2 marker parity remain open; see PARITY.

Compared with the published 0.3.1 package

Each cell is 0.4.0 time / 0.3.1 time for the same workload and machine; lower is faster. For example, 0.640 means 36% less time per operation. Both tables use the final Rust source and three alternating process pairs. All eight workloads pass the blocking regression guard with settled warmups.

Workload M4 Pro Linux
Cold factory / 1 worker 0.203 0.289
Warm L1 get_or_set / 1 worker 0.133 0.160
Warm L1 get_or_set / 8 workers 0.009 0.101
Warm L1 read / 1 worker 0.149 0.172
Warm L1 read / 8 workers 0.007 0.093
L2 JSON get_or_set / 1 worker 0.640 0.673
L2 JSON read / 1 worker 0.544 0.635
Replacement set / 1 worker 0.022 0.041

Eight-worker values describe aggregate throughput, not individual request latency. These are cache microbenchmarks, not whole-application speedups. The regression fixture is separate from the FC fixture below; their ns/op values must not be mixed. Full values, source identities and method.

Compared with FusionCache 2.9.0 at 0.4.0

At 0.4.0, Amalgam was faster than FusionCache for memory hits, set and cold factories, but slower through L2 on Linux (1.38× FC time for try_get, 1.56× for get_or_set). The unreleased work above closes that gap. The 0.4.0 tables, budgets and limitations remain in PERFORMANCE.

Basic use

use amalgam::Cache;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cache: Cache<String> = Cache::builder().try_build()?;
    let greeting = cache
        .get_or_set("greeting", |_| async move {
            Ok::<_, std::convert::Infallible>("hello, world".to_owned())
        })
        .await?;
    assert_eq!(greeting, "hello, world");

    cache.set("greeting", "hello again".to_owned()).await?;
    assert_eq!(
        cache
            .try_get("greeting")
            .await?
            .as_ref()
            .map(String::as_str),
        Some("hello again")
    );
    cache.remove("greeting").await?;
    assert!(!cache.try_get("greeting").await?.is_some());
    cache.shutdown().await?;
    Ok(())
}

Use fallible APIs for new callers. try_get returns Result<Option<V>>, distinguishing a successful miss from a storage, copy or configuration failure. get_or_default returns Result<V>. Mutations return Result<()>; .with_receipt() explicitly requests a MutationReceipt. Completed contains its stage report; Scheduled contains awaitable cache-owned completion. wait() observes completion and requested error rethrows. Reports also expose skipped stages, suppressed failures and recovery admission according to the configured policy.

Legacy read and invalidation adapters have been removed in 0.4.0. See the 0.4 migration guide for replacement APIs.

Fluent requests

set, get_or_set, remove, expire, tag invalidation and clear execute when awaited. An option transformation starts from a copy of the cache defaults, so changing duration retains fail-safe and other settings. String tags are validated before storage or factory work.

use std::time::Duration;

let value = cache
    .get_or_set("profile", |_| async move { Ok::<_, std::convert::Infallible>("Alice".to_owned()) })
    .options(|options| options.with_duration(Duration::from_secs(30)))
    .tags(["users"])
    .await?;

cache.set("profile", value).tags(["users"]).await?;
let observed = cache
    .get_or_set("profile", |_| async { Ok::<_, std::convert::Infallible>("Alice".to_owned()) })
    .with_receipt()
    .await?;
observed.commit.wait().await?;

Optional .fail_safe_default(Some(value)) and .cancellation(token) apply to the individual request. None removes the fallback; for nullable cached values, Some(None) means a present null fallback. A manually polled request first calls .into_future(); this separates configuration from pinned execution. Use source::value(value) as the second argument to get_or_set for a supplied value. It preserves constant-source behavior without factory timeouts or eager refresh. source::factory(|ctx| ...) supplies the inferred context type when the callback uses its methods.

Synchronous use

BlockingCache<V> provides caller-thread operations and as_async() for the same entries and lifecycle. Timed/cancellable/eager factories use owned, bounded callback pools. Mutation receipts expose actual completion. Dispatch, resource bounds and lifecycle describe the tested contracts and intentional differences in 0.4.0.

use amalgam::{BlockingCache, source};

let cache = BlockingCache::<u64>::new()?;
let value = cache.get_or_set("number", source::factory(|_| {
    Ok::<_, std::convert::Infallible>(7)
})).execute()?;
cache.set("number", value + 1).execute()?;
cache.remove("number").with_receipt().execute()?.wait()?;
cache.shutdown()?;

Native retrieval and mutation requests are lazy until .execute(). Request options, tags, fallback and cancellation use the same fluent choices as async.

Optional provider and advanced APIs

provider contains storage, serialization, backplane, Redis, locking and clock interfaces/implementations. advanced contains explicit receipts, stronger policies, marker snapshots, recovery inspection and detailed event payloads. The root exposes the ordinary cache, options, factory context and errors. Imports preserve existing runtime behavior; additional contracts are selected through configuration or request choices.

In 0.4.0, L2 providers return immutable owned provider::DistributedBytes snapshots. The in-memory backend shares stored bytes across reads; Redis adopts its received buffer. Snapshots survive replacement, removal and expiration. Custom providers must update their get signature; see the provider migration.

Providers can also declare provider::ReadCompletion::Immediate and answer get_immediate / read_many_immediate from in-process state. A cache whose value and durable-marker providers are immediate, with a synchronous codec and built-in L1, completes warm L2 reads inline on the caller's first poll; the reference in-memory providers do this. A value provider which owns its marker store can implement get_marked to read clear markers with the value in one round trip; Redis pipelines GET and HMGET. All three methods have defaults, so existing providers keep their behavior.

Freshness, origin work and cancellation

Entries have independent logical freshness and physical fail-safe retention. Fail-safe can serve a captured stale value after an ordinary origin failure or timeout, within its physical lifetime. Cancellation stays a cancellation and bypasses fail-safe.

Built-in standalone caches measure duration lifetimes with a monotonic clock anchored to UTC at construction. Civil-clock corrections do not extend or shorten those local lifetimes. Hybrid caches, distributed lockers/backplanes and supplied storage or markers retain live UTC ordering and elapsed physical deadlines. Explicitly injecting SystemClock selects live system UTC; custom clock callbacks run outside L1 reader slots.

Same-key requests coordinate through per-key ownership. A configured finite lock timeout deliberately permits the best-effort factory path when no fallback is available; it weakens unconditional single-flight. Different keys do not serialize because they happen to share a map shard.

Soft factory timeout applies when a fail-safe fallback is available. With background completion enabled, the origin and its ownership move into supervised work; that continuation does not acquire a new hard-timeout budget. Eager refresh is request-driven and does not use the ordinary factory timeout. Adaptive options and conditional not_modified are validated before storage. Snapshot creation order and actual insertion time are separate: delayed origin work cannot invent newer source ordering or renew a replay's physical lifetime.

An explicit caller token and FactoryContext cancellation state identify cancellation reasons. close initiates cancellation; shutdown waits for owned work and plugin cleanup. Dropping the last public cache handle also initiates cleanup. Built-in standalone origin versions prevent a late completion from replacing a newer awaited mutation; removing a key does not cancel unrelated origin work.

Distributed storage and continuity

L2 backends, serializers, backplanes, lockers and copy strategies are open traits. Custom local coordination adds an owned MemoryLocker provider for values and secondary markers, with optional distinct native acquisition through BlockingMemoryLocker; supplied L1 storage provides the actual value store with typed failures and atomic conditional admission. JSON and reference in-memory providers are available by default; Redis, MessagePack and Postcard are optional. L1 and L2 freshness/retention are configured separately. Hydration caps local deadlines by the remaining source lifetime.

Warm healthy L1 reads stay local. Cold L2 reads reconcile tag/clear markers; when the value provider owns the durable markers, untagged cold reads fetch the clear markers with the value in one round trip. Atomic guarantees require an atomic invalidation provider; ordinary byte-store I/O retains its weaker contract. Control markers live outside ordinary value keys and are scoped by the effective physical namespace. Version 0.4.0 includes independent marker reads; arbitrary custom L2 marker parity remains an explicit Gap. These reads use separate tag defaults, budgets and observation limits.

Expiring marker snapshots add independent remote lifetimes and nonzero read repair through an optional atomic provider capability. Snapshot expiry preserves the durable invalidation fact; this addition does not close the remaining marker eager/locker/recovery contract.

In 0.4.0, backplane gaps and reconnects preserve L1 within its normal freshness and fail-safe deadlines. L2-only caches have no periodic global L1 clearing. Known invalidations still apply. ready() and try_build_ready() can await native subscription acknowledgement; ordinary operations do not wait by default. CacheBuilder::strict() selects conservative reconciliation and initial subscription admission. See backplane outage policies.

Distributed lease capabilities are explicit. Native owned acquisition, renewal and token-checked release prevent abandoned ownership. Strict stale-owner commit rejection additionally requires an atomic backend ownership check; renewal alone cannot provide it during a partition. An explicitly selected legacy provider mode carries weaker guarantees; an opaque acquisition that never completes cannot promise bounded cancellation drainage. A finite lock timeout, deliberate lock skipping or another writer outside the protocol can also permit duplicate origin work.

Redis outages and lease policy

Version 0.4.0 defaults to LeasePolicy::Cooperative: a suppressed distributed locker error permits the ordinary factory to run. Fresh L1 hits contact neither the locker nor L2. Retained stale data can serve configured fail-safe during an outage. Nodes may compute concurrently while distributed coordination is unavailable, and missed peer invalidations can remain invisible until expiration.

Use Cache::<V>::builder().strict() when invalidation continuity and lease-fenced commits take priority over outage availability. Fenced configuration is rejected at construction if the locker lacks owned-token lifetime support or L2 lacks atomic fenced writes. Custom providers declare those capabilities explicitly.

Published 0.3.1 has different defaults: fenced ownership, conservative gap cleanup, and periodic clearing for L2-only configurations. Existing consumers retain those defaults until they upgrade the crate. Explicit cancellation remains an error; owned cleanup failures can still be reported by shutdown() after recovery.

Failed or skipped distributed effects can enter recovery with their original bytes, remaining lifetime and pending stage. Local same-key commit lanes order replay, foreground effects and publication. This protects an awaited newer local mutation from an older replay. Independent nodes and custom writes are not globally linearizable without a participating conditional backend protocol.

Copying and capacity

Rust Clone does not isolate mutable state inside Arc or similar shared handles. with_enable_auto_clone(true) requires an actual fallible ValueCloner; built-in serializers can provide value round trips. Isolation is applied at storage and caller-output boundaries, including stale/fail-safe values. A failed copy is an error, never an ordinary shared clone fallback.

Entry count and entry weight are separate limits. Under pressure the priority policy evicts Low before Normal before High and uses recency for ties. NeverRemove entries still consume capacity and expire at their physical deadline; further admission can be rejected. Admission and eviction are reported through the shared event/plugin route.

Recovery, events and diagnostics

Recovery defaults to enabled for a configured distributed provider, with a 5-second delay and 1024 queued items in 0.4.0. Explicit max_items: None permits an unlimited queue. The bound is a deliberate difference from the FusionCache/0.2 default. Queue-full rejection and exhausted retry budgets are observable. Markers are compacted conservatively rather than silently forgotten.

Transport failures trip the corresponding circuit breaker; codec or value-copy failures do not declare every key's transport unhealthy. Default breaker duration is zero, meaning disabled. Read I/O budgets and provider lifecycle budgets are distinct from the intentionally unbounded default cache write/remove contract.

CachePlugin<V> adds operational access to the same cache through a weak typed context. Start, event handlers and Stop can read, compute and mutate; views do not keep the application lifecycle alive. See plugin cache operations for lifetime and teardown boundaries.

subscribe_layers() exposes typed memory, distributed and backplane facts independently of logical outcomes. Full peer payloads, physical hit eligibility and typed read-deadline behavior are documented in component events. Original-value eviction and handler policy remain open.

Each cache has its own plugin sessions, including when a plugin object is shared. Dynamic registration detaches and stops exactly once. One event hub reports reads, misses, admission, eviction, origins, distributed effects and operation outcomes. Use the resilient event subscription when a slow observer must recover from broadcast lag.

Metrics use a bounded cache-name label budget. Keys and instance IDs belong in traces rather than metric labels. OpenTelemetry exposes a composable tracing layer and native metric plugin with application-owned providers and separate L1/L2/backplane scopes. The convenience tracing initializer preserves an existing subscriber/provider on failure.

Features

Feature Integration
default Local cache, JSON and in-memory distributed/backplane/locker providers
redis Redis value store, durable markers, pub/sub and owned leases
messagepack MessagePack snapshot/value-copy codec
postcard Postcard snapshot/value-copy codec
metrics Exporter-independent metrics plugin
opentelemetry Composable tracing, native metrics and OTLP provider helpers
full All integrations above

The default distributed namespace is v2 with Prefix. New codecs read legacy raw payloads, but running 0.2 nodes cannot read 0.3 framed snapshots. Use a coordinated fresh namespace. KeyModifierMode::None or intentional reuse of v1 requires a fresh physical prefix or a coordinated migration; it does not make a mixed-version rollout safe.

See migration and tested FusionCache contract, validation and limits, porting design, examples, cache internals and the changelog. These documents describe supported contracts and evidence; they do not claim universal one-to-one parity or equal performance for every workload.

Acknowledgements

FusionCache by ZiggyCreatures provides the resiliency model and comparison reference. Amalgam uses Tokio, independently locked L1 storage and the open Rust integration ecosystem. Distributed providers and copying strategies remain extensible; internal finite outcomes use typed enums. Cache logic uses safe Rust; the private reader-slot synchronization boundary contains documented unsafe operations.

About

Rust hybrid cache with async and native APIs, fail-safe, per-key stampede protection and observable background work. FusionCache-inspired; measured results and qualification gaps documented.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages