Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

HydraCache

HydraCache emblem

HydraCache is a Rust-native cache runtime for applications that need cache behavior to be explicit: keys, tags, TTL, single-flight loading, invalidation, query-result caching, and local-first distributed coordination.

The goal is not to hide caching behind a magical map. The goal is to make cache semantics visible enough for production code review. A cache entry should answer three questions:

  • what exact value is being reused?
  • which writes can make it unsafe?
  • what freshness policy applies while the backing source changes?

HydraCache starts with a local cache because local behavior is the foundation. From there it grows toward database query-result caching and local-first distributed invalidation without hiding the database or repository layer behind magic interception.

Why HydraCache

HydraCache is useful when a service needs more than HashMap-style reuse:

  • Reuse typed values locally. Local cache with typed serialization boundaries.
  • Avoid duplicate miss storms. Single-flight loading for same-key requests.
  • Expire stale values eventually. TTL as a fallback freshness bound.
  • Remove related values after writes. Tag invalidation by entity, collection, tenant, or query family.
  • Cache repository/query results. Database-neutral query policies with explicit keys and tags.
  • Grow toward multi-node behavior. Local-first invalidation that can be carried to peers.

The core idea is simple: a cache key identifies one value, and tags describe which writes can invalidate groups of values. Everything else builds on that contract.

Choose Your Path

GoalStart here
Cache one local value or loaderLocal Cache
Reuse one typed namespace across call sitesTyped Cache
Cache ordinary async functionsCacheable Functions
Use function and entity macrosMacros
Cache repository or query resultsDatabase Query Caching
Pick SQLx, Diesel, or SeaORM integrationSQLx, Diesel, SeaORM
Expose a Redis-compatible RESP edgeRedis API
Review production readinessProduction Checklist
Avoid common cache mistakesAnti-patterns

First Path

If you are new to HydraCache, read these pages in order:

  1. Getting Started
  2. Architecture
  3. Decision Guide
  4. Production Checklist
  5. Keys and Tags
  6. Local Cache
  7. Macros
  8. Database Query Caching

Concepts vs Guides

The documentation is split deliberately:

  • Concepts explain why the API is shaped this way.
  • Guides show how to use the API in application code.
  • Reference pages describe maintenance rules for the documentation itself.

If you need to make a design decision, start with concepts. If you already know what you want to build, start with guides.

Use Use Cases for concrete scenarios and API Links when you need exact Rust signatures on docs.rs.

Common search terms for this site include memoization, function caching, repository cache, query result cache, near cache, read-through cache, stale-if-error, stale while revalidate, single-flight, and distributed invalidation.

Documentation Shape

This site is intentionally separate from the longer Quarto book draft under docs/book/. The docs site is the practical public surface. The book can remain a deeper narrative track for design history and long-form explanation.

The public docs should be self-contained. The article series below is linked as background and publication history, but the material in this site should not require a reader to leave the docs.

Article Series

The Medium series is the narrative origin of several concepts in this site:

Getting Started

The quickest way to understand HydraCache is to build one local cache flow:

  1. create a cache;
  2. store a typed value with tags and TTL;
  3. load a missing value through get_or_load;
  4. invalidate the related tag after a write.

For a published crate, add HydraCache to an application:

cargo add hydracache

Inside this repository, the documentation examples use path dependencies so they are checked against the current branch.

    let cache = HydraCache::local()
        .default_ttl(Duration::from_secs(300))
        .build();

    let user_42_options = CacheOptions::new()
        .ttl(Duration::from_secs(60))
        .tag("users")
        .tag("user:42");

    cache
        .put(
            "user:42",
            User {
                id: 42,
                name: "Ada".to_owned(),
            },
            user_42_options,
        )
        .await?;

    let cached: Option<User> = cache.get("user:42").await?;
    assert_eq!(cached.as_ref().map(|user| user.name.as_str()), Some("Ada"));

    let loads = Arc::new(AtomicUsize::new(0));
    let user_43_options = CacheOptions::new()
        .ttl(Duration::from_secs(60))
        .tag("users")
        .tag("user:43");

    let loaded = cache
        .get_or_load("user:43", user_43_options, {
            let loads = Arc::clone(&loads);
            move || async move {
                loads.fetch_add(1, Ordering::Relaxed);
                Ok::<_, std::io::Error>(User {
                    id: 43,
                    name: "Grace".to_owned(),
                })
            }
        })
        .await?;

    assert_eq!(loaded.name, "Grace");
    assert_eq!(loads.load(Ordering::Relaxed), 1);

    cache.invalidate_tag("user:42").await?;
    assert_eq!(cache.get::<User>("user:42").await?, None);

The important parts are:

  • values are typed at the cache boundary;
  • CacheOptions carries TTL and invalidation metadata;
  • the loader runs only when the value is missing or expired;
  • tag invalidation removes values related to a write;
  • the example is compiled by the documentation examples crate.

Run the checked example from the repository root:

cargo run --manifest-path docs-site/examples/Cargo.toml --bin quick_start

Next, read Keys and Tags. Most HydraCache usage becomes straightforward once key identity and invalidation tags are clear.

Installation

Add HydraCache to an application crate:

[dependencies]
hydracache = "0.67"

For local development inside this repository, examples use path dependencies so the documentation is checked against the branch being edited:

hydracache = { path = "../../crates/hydracache" }
hydracache-db = { path = "../../crates/hydracache-db" }

Optional Crates

HydraCache keeps adapters in separate crates so applications can opt into only the integrations they need.

CratePurpose
hydracacheCore user-facing local cache runtime.
hydracache-dbDatabase-neutral query result caching policies and helpers.
hydracache-sqlxSQLx adapter helpers.
hydracache-dieselDiesel adapter helpers.
hydracache-seaormSeaORM adapter helpers.
hydracache-redis-compatOptional Redis RESP compatibility facade primitives.
hydracache-serverStandalone server that can expose the optional Redis RESP listener.

Local Verification

Build the public docs site:

mdbook build docs-site

Check the runnable documentation examples:

cargo check --manifest-path docs-site/examples/Cargo.toml --all-targets

Architecture

HydraCache is built as a set of explicit cache boundaries rather than one invisible cache layer.

The local runtime owns typed serialization, TTL, tags, single-flight loading, diagnostics, and event streams. Database adapters build query-result descriptors on top of that runtime. Distributed invalidation and cluster APIs carry invalidation intent and membership metadata without hiding the local cache.

Runtime Flow

Application call site sends key, tags, and TTL policy to the local runtime, which either returns a fresh typed value or runs a single-flight loader and stores the encoded result.

On a hit, the runtime decodes and returns the cached value. On a miss, get_or_load runs a loader and stores the result under the chosen key and tags. Concurrent same-key misses share one in-flight loader.

Query Flow

Repository method builds a DbCache policy, asks the local runtime, returns cached query result on hit, or runs SQLx, Diesel, SeaORM, or repository loader on miss.

HydraCache does not parse SQL or infer table dependencies. The application names the result and the writes that can invalidate it.

Invalidation Flow

Write path after commit invalidates keys or tags locally and optionally publishes invalidation intent to peer local caches.

The bus propagates intent, not values. This keeps distributed behavior local-first: each process remains responsible for its own cache contents and loader code.

Cluster Flow

Client/member cluster mode adds role, node id, generation, membership, ownership, and peer-fetch vocabulary. It does not turn HydraCache into a production data grid by itself.

Use cluster APIs when the application needs stable membership diagnostics or a future route toward owner-based peer reads. Keep local cache semantics visible even when multiple processes participate.

Use Cases

HydraCache fits services where cache correctness depends on domain knowledge.

Expensive Async Work

Use get_or_load or cacheable_loader! when a value is expensive to compute and the application can name the value with a stable key.

Good examples:

  • HTTP API responses keyed by tenant, endpoint, and parameters;
  • generated reports keyed by tenant, date range, and permission scope;
  • authorization or feature-flag lookups keyed by subject and context.

Database Query Results

Use hydracache-db or an adapter crate when a repository result is reusable and the application knows which writes make it stale.

Good examples:

  • one user by id;
  • one page of a tenant-scoped search;
  • a small collection used on many requests;
  • a repository method whose SQL or ORM shape should remain in application code.

Write-side Invalidation

Use tags when a write affects more than one cached key.

Examples:

  • user:42 for one entity;
  • users for collection-level reads;
  • tenant:7 for tenant-wide invalidation;
  • permission:abc for permission-scoped query families.

Request Storm Control

Use single-flight loaders when many requests can miss the same key at the same time. HydraCache lets one loader run while other callers join the in-flight work.

Local Near-cache Before Coordination

Use the local cache first. Add the invalidation bus or cluster APIs when several local caches need to react to the same invalidation intent.

Related terms: near cache, read-through cache, memoization, function caching, repository cache, query result cache, stale-if-error, stale while revalidate.

Decision Guide

Use this page when you know the shape of the problem but not the HydraCache API to start with.

SituationStart with
Store or load one typed value in-processLocal Cache
Several call sites reuse one value type and namespaceTyped Cache
A normal async function should be cached with less boilerplateCacheable Functions
A DB/repository result needs explicit query-result cachingDatabase Query Caching
A stale value may be acceptable during refreshRefresh and Stale Reads
You need to prove hit/miss/load behaviorDiagnostics and Events
Multiple local caches should observe invalidationsDistributed Invalidation
You need role, membership, generation, or owner vocabularyClient and Member Cluster

API Choices

Use put and get for simple explicit storage.

Use get_or_load when the loader can fail and you want the full local cache API.

Use get_or_insert_with when the loader cannot fail.

Use get_or_load_with_refresh when freshness behavior is a product decision and stale fallback must be visible.

Use cacheable_loader! only after the key, tags, TTL, and loader boundary are already obvious.

Use hydracache-db when cached values are repository or query results, not arbitrary function results.

Production Checklist

Use this checklist before a cached path moves beyond experimentation.

Cache Boundary

  • The key names exactly one reusable value.
  • Tenant, locale, permission, feature flag, pagination, and sort dimensions are present when they affect the value.
  • The cached type is serializable and stable enough for the chosen cache lifetime.
  • The loader boundary is small enough to review.

Invalidation

  • Every write path that can make the value stale has an explicit invalidation plan.
  • Entity reads have entity tags.
  • Collection or search reads have collection/query-family tags.
  • Transactional writes invalidate only after commit.
  • Tag names are treated as domain API, not throwaway strings.

Freshness

  • TTL is a fallback bound, not the only freshness model for mutable data.
  • Refresh/stale behavior is explicitly accepted by the product path.
  • Stale fallback windows are bounded.
  • Loader errors are observable when stale fallback is allowed.

Operations

  • Hits, misses, loads, invalidations, and stale load discards are observable.
  • Diagnostics are checked in local smoke tests.
  • High-volume access events are enabled only when needed.
  • Distributed invalidation is tested with two cache instances before adding real transport.

Documentation

  • The example lives under docs-site/examples when it appears in public docs.
  • The Markdown includes the checked snippet instead of copying code by hand.
  • Link checks and visual smoke checks pass before publishing.

Anti-patterns

These patterns usually mean the cache boundary is hiding correctness work.

Key Without Tenant

If tenant or account scope changes the value, it belongs in the key.

Bad:

profile:42

Better:

tenant:7:profile:42

TTL As The Only Freshness Model

TTL eventually removes stale values. It does not know which write made a value unsafe.

Use tags for write-side invalidation and TTL as the fallback bound.

Permission-scoped Result Without Permission Scope

Search results, reports, and dashboards often depend on the caller’s permissions. If the permission scope changes the rows or fields, include that scope in the key.

Hidden Repository Magic

Avoid a generic repository wrapper that caches everything behind one trait. A good HydraCache call site shows key, tags, TTL or refresh policy, and the loader boundary.

Collection Invalidation Without Entity Invalidation

Updating one entity may affect both user:42 and users or tenant:7:users. Invalidate all tags that describe stale readers.

Unbounded Stale Fallback

stale_while_revalidate and stale_on_loader_error are product choices. Keep windows bounded and observable.

Caching Arbitrary Remote Code

HydraCache cluster APIs do not ship closures to another process. Keep execution local and move only invalidation intent or encoded cached bytes through explicit transports.

Cache Semantics

HydraCache treats cache behavior as application semantics, not as a hidden map lookup.

A production cache entry needs a contract:

  • key: the exact result identity;
  • tags: invalidation handles for writes that can make the result stale;
  • TTL: a fallback freshness bound;
  • loader: the backing operation used on misses;
  • events and stats: evidence that the cache avoids work and invalidates the right values.

The key mistake HydraCache tries to prevent is answering a different question than the caller asked. A key like users:active may be fine for a toy app, but it is unsafe when tenants, principals, filters, policy versions, locale, region, or feature flags change the result.

Use explicit cache semantics first. Add convenience only after the manual shape is clear.

Why a Plain Map Is Not Enough

A map answers one narrow question:

does this key currently have a value?

A production cache has to answer more:

is this value still safe for this caller, tenant, query shape, and write history?

That difference is why HydraCache exposes explicit options instead of treating every cache operation as a simple get and insert.

Review Shape

When reviewing a cached operation, prefer code that makes the decision visible:

  • the key is named in domain terms;
  • the tags correspond to write paths;
  • the TTL is a fallback, not the only correctness mechanism;
  • the loader boundary is clear;
  • invalidation happens after successful writes.

The API can become more ergonomic over time, but the semantics should remain visible.

Keys and Tags

Keys and tags are the center of HydraCache’s cache contract.

A key identifies exactly one cached value. A tag identifies a set of cached values that a write can make stale.

Those two jobs should stay separate.

Key Identity

A good key includes every dimension that changes the result.

This key is usually too weak:

users:active

It does not say which tenant, caller, page, sort order, locale, feature variant, or permission policy shaped the result.

A safer key is explicit:

tenant:7:users:status=active:page=1:sort=name:principal=42:policy=3

That key is longer because the result has more meaning. The length is a signal, not a failure.

Tag Invalidation

Tags answer a different question: “Which writes might make this value unsafe?”

For the same cached user search, useful tags might be:

tenant:7
users
users:search

A write to one user might invalidate user:42. A bulk import might invalidate users. A tenant-level policy change might invalidate tenant:7.

Common Mistakes

Do not use a collection tag as the key:

key = users
tag = users

That caches one result under a name that sounds like every result. It cannot distinguish active users from disabled users, page 1 from page 2, or tenant 7 from tenant 8.

Do not omit visibility dimensions:

tenant:7:users:active

If the caller’s permissions shape the result, the caller, role, permission hash, or policy version belongs in the key.

Do not assume TTL fixes a bad key. TTL can limit the time window of a wrong answer, but it cannot make the answer correct.

Practical Rule

When reviewing a cached read, ask:

  1. Does the key identify one exact value shape?
  2. Do tags match real write paths?
  3. Does the TTL describe fallback freshness rather than primary correctness?
  4. Can a future maintainer see why the key and tags were chosen?

If the answer is unclear, keep the cache call explicit. Convenience wrappers should come after the semantics are obvious.

Single-flight

Single-flight means concurrent misses for the same key share one loader call.

It is not just an optimization. It protects the backing source during bursts, cold starts, retries, and partial outages. Without it, a cache miss can multiply load exactly when the system is already under pressure.

HydraCache keeps single-flight local to the cache runtime:

  • the first caller starts the loader;
  • same-key callers wait for the result;
  • the loaded value is stored once;
  • all joined callers receive the same value or error.

The cache key matters here too. If two different queries accidentally share a key, single-flight can join requests that should have remained separate.

Why It Matters

Without single-flight, a cache miss can multiply into many concurrent backing calls:

100 requests -> 100 misses -> 100 database calls

With same-key single-flight, the first caller runs the loader and the rest wait:

100 requests -> 1 miss load + 99 joins

That is not just faster. It changes failure behavior during cold starts, cache expiry, retry bursts, and upstream slowdowns.

Boundaries

Single-flight is only as correct as the key. If a key is missing tenant, permission, filter, or pagination dimensions, the runtime may join work that should be separate.

The sequence is:

  1. design the key correctly;
  2. use the cache API to coalesce same-key loads;
  3. observe loads and joins to confirm the cache is doing useful work.

TTL and Invalidation

TTL is a fallback freshness bound. It is not a complete consistency model.

TTL-only caching is acceptable for values where temporary staleness is harmless. It is weaker for application data that changes through known write paths. In those cases, use invalidation tags so writes can remove related cached values immediately after commit.

HydraCache separates the two concerns:

  • TTL limits how long a value may be reused without refresh.
  • Tags say which writes can make a value unsafe.

The useful production question is not “what TTL did we pick?” It is “what makes this value stale, and does that write path invalidate the right tags?”

TTL-Only Caching

TTL-only caching can work when:

  • the value changes rarely;
  • stale reads are acceptable;
  • no write path can cheaply identify affected entries;
  • the value is defensive or advisory rather than authoritative.

Examples include feature metadata, low-risk catalog data, or values where the product already tolerates short staleness windows.

Invalidation-First Caching

Use invalidation when the application knows the write paths.

For example, a write to user:42 can invalidate:

user:42
users
tenant:7

Those tags may remove one entity entry, several collection entries, and broader tenant-scoped query results.

The Combined Model

The strongest practical model often uses both:

  • invalidate after known writes;
  • keep a TTL as a fallback if an invalidation is missed or delayed.

TTL bounds the damage window. Invalidation keeps the normal path fresh.

Local-first Invalidation

Distributed invalidation should start with a correct local invalidation model.

HydraCache treats local invalidation as the source of truth:

  1. the local cache removes values by key or tag;
  2. the invalidation event can be published to peers;
  3. peers apply the same invalidation locally;
  4. each node keeps read behavior simple and observable.

This avoids making the distributed layer responsible for interpreting database writes or query semantics. The application still decides which keys and tags describe a value. The distributed layer carries that decision to other nodes.

Why Local First

If a cache cannot invalidate correctly inside one process, distributing that invalidation only spreads uncertainty.

The local model should be clear before multi-node behavior enters the picture:

  • writes know which tags they affect;
  • cache entries attach those tags consistently;
  • invalidation happens after commit;
  • metrics show which keys and tags were removed.

After that, distributed invalidation becomes a transport problem: carry the same invalidation decision to other nodes.

Expected Semantics

Local-first distributed invalidation should be treated as a freshness improvement, not a replacement for the database’s consistency model.

Applications should still design for:

  • delayed messages;
  • duplicated invalidation messages;
  • nodes that miss a message and rely on TTL fallback;
  • external writers that need their own invalidation path.

Typed Query Caching

Typed query caching starts with the Rust value type, but the type is not enough.

Vec<User> can mean many different results:

  • all active users in a tenant;
  • users visible to a principal;
  • one page of a filtered search;
  • a result shaped by a feature flag or policy version.

HydraCache separates the value type from query-result identity. The database client, ORM, or repository remains responsible for SQL execution and row mapping. HydraCache owns the cache boundary around the result: key, tags, TTL, single-flight, serialization, stale behavior, diagnostics, and invalidation.

Use explicit query policies when a result is reused:

    let local = HydraCache::local().build();
    let queries = DbCache::new(local, "db");

    let user = queries
        .entity::<User>("user", 42)
        .collection_tag("users")
        .ttl(Duration::from_secs(60))
        .fetch_with(|| async {
            // Replace this with SQLx, Diesel, SeaORM, or repository code.
            Ok::<_, std::io::Error>(User {
                id: 42,
                name: "Ada".to_owned(),
            })
        })
        .await?;

    assert_eq!(user.name, "Ada");

What the Type Gives You

The Rust type gives the cache a serialization and deserialization boundary. A cached User comes back as a User. A cached Vec<User> comes back as a Vec<User>.

That is useful, but it is not the whole contract.

The cache still needs explicit identity:

  • tenant;
  • principal or permission policy;
  • entity id;
  • filters;
  • pagination;
  • sort order;
  • locale or region;
  • feature variant;
  • time window.

What the Database Keeps Owning

HydraCache should not become the query authority.

SQLx, Diesel, SeaORM, or the repository layer still own:

  • query construction;
  • transactions;
  • row mapping;
  • retries;
  • database errors;
  • database-specific performance tuning.

HydraCache owns the cache boundary around that query result.

What HydraCache Is Not

HydraCache is intentionally narrow in several places. These boundaries are part of the design.

Not an ORM

HydraCache does not replace SQLx, Diesel, SeaORM, or a repository layer.

The database library remains responsible for SQL, query planning, transactions, row mapping, retries, and database errors. HydraCache wraps the result boundary: key, tags, TTL, loader execution, single-flight, serialization, and invalidation.

Not SQL Interception

HydraCache does not transparently intercept arbitrary SQL and decide what to cache.

Applications need to describe query-result identity explicitly. This keeps tenant, principal, permission, filter, page, sort, feature, and policy dimensions visible in code review.

Not Automatic CDC

HydraCache does not install database triggers or provide change data capture by default.

If writes happen outside the service, those writers need an invalidation path too. The cache cannot safely infer external changes by looking only at reads.

Not a Strongly Consistent Distributed Database

Distributed invalidation can reduce stale reads across nodes, but it does not turn a local cache into a strongly consistent replicated database.

HydraCache starts local-first: make the local invalidation contract correct, then carry that invalidation to peers.

Not Just a Map

HydraCache can store typed values, but its main purpose is not “a map with TTL.”

The runtime exists to keep cache semantics explicit:

  • key identity;
  • tag invalidation;
  • TTL and stale policy;
  • single-flight loading;
  • query-result caching;
  • observability around hits, misses, loads, and invalidations.

This makes the API more deliberate than a raw map, and that deliberateness is the point.

Local Cache

Use the local cache when the current process can safely reuse a value without contacting a backing source.

The local cache is the full-control API. You choose:

  • the key;
  • the TTL and refresh behavior;
  • the invalidation tags;
  • the loader boundary;
  • the typed value stored at that boundary.
    let cache = HydraCache::local().build();
    let loads = Arc::new(AtomicUsize::new(0));

    let first = cache
        .get_or_load(
            "profile:42",
            CacheOptions::new()
                .ttl(Duration::from_secs(60))
                .tag("profiles")
                .tag("profile:42"),
            {
                let loads = Arc::clone(&loads);
                move || async move {
                    loads.fetch_add(1, Ordering::Relaxed);
                    Ok::<_, LoadError>(Profile {
                        id: 42,
                        display_name: "Ada".to_owned(),
                    })
                }
            },
        )
        .await?;

    let second: Option<Profile> = cache.get("profile:42").await?;
    assert_eq!(second, Some(first));
    assert_eq!(loads.load(Ordering::Relaxed), 1);

    cache.invalidate_tag("profile:42").await?;
    assert_eq!(cache.get::<Profile>("profile:42").await?, None);

This guide intentionally keeps the example small:

  • put stores a typed value;
  • get reads the same typed value;
  • get_or_load avoids repeated loader calls;
  • tag invalidation removes related entries after writes.

Production code should give keys and tags names that match the domain model. A key identifies one cached value. A tag identifies a group of values that a write can make stale.

Typed Namespaces

typed::<T>("namespace") creates a typed, namespaced view over the same cache.

Use it when several call sites work with the same value type and domain namespace. The view keeps shared storage, stats, single-flight, tags, and invalidation safety, but it removes repeated type annotations at call sites and prefixes keys with the namespace.

Refresh Behavior

TTL says when a value expires. Refresh behavior says what the cache may do around expiry.

Use explicit refresh options when a production path can tolerate a recently expired value while a background refresh runs. Keep this choice visible in code review because stale fallback is a product decision, not a storage detail.

Where To Go Next

  • Use Cacheable Functions when ordinary async functions need the same explicit cache boundary with less boilerplate.
  • Use Local Cache API as a compact reference for local runtime methods.

Cacheable Functions

Use cacheable function helpers when ordinary async work needs the same cache boundary with less boilerplate.

The macros are intentionally explicit. They do not discover a global cache, generate hidden keys from every function argument, or hide the loader. They build CacheOptions and call the same runtime methods you could call manually.

Loader Macro

cacheable_loader! is the compact form for fallible async loaders.

    let profile_id = 42_u64;
    let profile = cacheable_loader!(
        cache = cache,
        key = "profile:42",
        tags = ["profiles", "profile:42"],
        ttl_secs = 60,
        load = move || async move {
            Ok::<_, LoadError>(Profile {
                id: profile_id,
                name: "Ada".to_owned(),
            })
        },
    )
    .await?;

    assert_eq!(profile.id, 42);

Use this when you already have a cache instance and want the call site to show key, tags, TTL, and loader in one expression.

Infallible Loader

Use cacheable_infallible! when the loader cannot fail and Ok::<_, Error>(value) would only add ceremony.

    let total = cacheable_infallible!(
        cache = cache,
        key = "profiles:count",
        tags = ["profiles"],
        ttl_secs = 60,
        load = || async { 1_u64 },
    )
    .await?;

    assert_eq!(total, 1);

The cache operation can still fail because serialization, storage, or runtime boundaries can fail. The macro only removes the loader error wrapper.

Attribute Macro

Use #[cacheable] when the cached operation is naturally an async function.

#[cacheable(
    cache = cache,
    key_segments = ["profile", profile_id],
    tag_segments = [["profile", profile_id], ["profiles"]],
    ttl_secs = 60
)]
async fn load_profile(cache: &HydraCache, profile_id: u64) -> Result<Profile, LoadError> {
    Ok(Profile {
        id: profile_id,
        name: "Ada".to_owned(),
    })
}

The cache remains an explicit function argument. The generated wrapper returns hydracache::CacheResult<T> because cache errors can occur outside the loader.

Choosing A Form

Use the explicit local cache API first when designing a new cached operation. Move to a macro when the key, tags, TTL, and loader boundary are already obvious.

Prefer:

  • cacheable_loader! for one-off fallible loaders;
  • cacheable_infallible! for one-off loaders that cannot fail;
  • #[cacheable] for reusable async functions with stable key/tag metadata.

Typed Cache

Use a typed cache view when several call sites share one value type and one domain namespace.

    let cache = HydraCache::local().build();
    let profiles = cache.typed::<Profile>("profiles");

    profiles
        .put(
            "42",
            Profile {
                id: 42,
                display_name: "Ada".to_owned(),
            },
            CacheOptions::new()
                .ttl(Duration::from_secs(60))
                .tag_set(TagSet::new().entity("profile", 42).tag("profiles")),
        )
        .await?;

    let profile = profiles.get("42").await?;
    assert_eq!(profile.as_ref().map(|profile| profile.id), Some(42));

    cache.invalidate_tag("profile:42").await?;
    assert_eq!(profiles.get("42").await?, None);

The typed view does not create separate storage. It is a namespaced view over the same HydraCache runtime, so stats, events, invalidation safety, and single-flight behavior remain shared.

The namespace prefixes keys. In the example, the typed key 42 is stored as profiles:42 in the underlying cache. Use namespaces that match domain boundaries rather than module names.

Typed views are most useful when they remove repeated annotations without hiding cache semantics. Keep keys, tags, and TTLs visible at the call site.

Refresh and Stale Reads

TTL says when a value expires. Refresh options say what the cache may do around expiry.

Use refresh behavior only when stale fallback is acceptable for the product path. It should be visible in code review.

    let cache = HydraCache::local().build();

    cache
        .put(
            "profile:42",
            Profile {
                id: 42,
                display_name: "Ada".to_owned(),
            },
            CacheOptions::new().ttl(Duration::from_millis(10)),
        )
        .await?;

    tokio::time::sleep(Duration::from_millis(20)).await;

    let value = cache
        .get_or_load_with_refresh(
            "profile:42",
            CacheOptions::new().ttl(Duration::from_secs(60)),
            RefreshOptions::new().stale_while_revalidate(Duration::from_secs(5)),
            || async {
                Ok::<_, LoadError>(Profile {
                    id: 42,
                    display_name: "Grace".to_owned(),
                })
            },
        )
        .await?;

    assert_eq!(value.display_name, "Ada");

stale_while_revalidate lets the cache return a recently expired value while a background refresh runs. Once the stale window expires, callers return to foreground loading.

Related choices:

  • refresh_ahead refreshes a value before expiry while still serving the fresh cached value;
  • stale_while_revalidate serves an expired value inside a bounded window and refreshes in the background;
  • stale_on_loader_error can return an expired value when the refresh loader fails.

Use diagnostics to confirm which path is happening in practice.

Macros

HydraCache macros remove repetition around cache boundaries without making the boundary implicit.

They are useful after the key, tags, TTL, and loader shape are already clear. If those choices are still being designed, start with the explicit runtime APIs first.

Function Wrappers

Use cacheable_loader! when one call site has a fallible async loader and the cache metadata belongs next to the call.

Use cacheable_infallible! when the loader returns a value directly. The cache operation can still fail because serialization, storage, or runtime boundaries can fail.

    let user_id = 42_i64;
    let user = cacheable_loader!(
        cache = cache,
        key = "user:42",
        tags = ["user:42", "users"],
        ttl_secs = 60,
        load = move || async move {
            Ok::<_, LoadError>(User {
                id: user_id,
                name: "Ada".to_owned(),
            })
        },
    )
    .await?;

    assert_eq!(user.name, "Ada");

    let count = cacheable_infallible!(
        cache = cache,
        key = "users:count",
        tags = ["users"],
        ttl_secs = 30,
        load = || async { 1_u64 },
    )
    .await?;

    assert_eq!(count, 1);

Use #[cacheable] when the cached operation is naturally a reusable async function.

#[cacheable(
    cache = cache,
    key_segments = ["profile", user_id],
    tag_segments = [["user", user_id], ["users"]],
    ttl_secs = 60
)]
async fn load_profile(cache: &HydraCache, user_id: i64) -> Result<User, LoadError> {
    Ok(User {
        id: user_id,
        name: "Ada".to_owned(),
    })
}

The cache remains an explicit argument. HydraCache does not discover a global cache and does not derive keys from every function argument.

Entity Metadata

Use HydraCacheEntity when repository code repeatedly caches entity-shaped values.

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, HydraCacheEntity)]
#[hydracache(entity = "user", collection = "users")]
struct User {
    #[hydracache(id)]
    id: i64,
    name: String,
}

fn assert_user_entity_contract() {
    assert_eq!(User::cache_key_for(&42), "user:42");
    assert_eq!(User::entity_tag_for(&42), "user:42");
    assert_eq!(User::collection_tag(), Some("users".to_owned()));
}

The derive produces the CacheEntity metadata used by DbCache, QueryCachePolicy, prepared policies, and invalidation plans:

  • entity key: user:42;
  • entity tag: user:42;
  • optional collection tag: users.

Use #[hydracache(id = Type)] on the struct when the id type is generated or not represented by one named field.

Query Policies

Use query_cache_policy! when one query call site should declare the whole key/tag/freshness contract in one expression.

    let policy = query_cache_policy!(
        preset = read_mostly,
        name = "load-user",
        entity = User,
        id = user_id,
        refresh_ahead_secs = 10,
        stale_while_revalidate_secs = 300,
    );

    assert_eq!(policy.key_value(), Some("user:42"));
    assert_eq!(
        policy.tags_value(),
        &["user:42".to_owned(), "users".to_owned()]
    );

The macro does not inspect SQL. It only builds the same QueryCachePolicy that could be written with builder calls.

Use prepared_query_policy! when most metadata is stable and only the entity id changes at call time.

    let queries = DbCache::new(cache.clone(), "db");
    let load_user = queries.prepare::<User>(prepared_query_policy!(
        per_entity = User,
        name = "load-user",
        ttl_secs = 300,
    ));

    let cached = load_user
        .load_id(42, || async {
            Ok::<_, std::io::Error>(User {
                id: 42,
                name: "Ada".to_owned(),
            })
        })
        .await?;

    assert_eq!(cached.id, 42);

Prepared policies keep hot repository methods compact while preserving an explicit dynamic id boundary.

Write-side Invalidation

The entity derive also keeps write-side invalidation small.

    let pending = InvalidationPlan::new().cache_entity::<User>(42);
    let report = pending.execute(&cache).await?;

    assert_eq!(report.tag_count, 2);

Stage invalidations while preparing repository work, then execute the plan only after the database transaction commits.

Choosing A Macro

MacroUse when
cacheable_loader!One fallible async loader needs local cache metadata.
cacheable_infallible!One infallible async loader should avoid Ok::<_, E>(value) ceremony.
#[cacheable]A reusable async function has stable key/tag metadata.
HydraCacheEntityEntity keys and invalidation tags repeat across repository code.
query_cache_policy!One query call site should declare key, tags, TTL, and refresh policy compactly.
prepared_query_policy!A repository method reuses stable policy metadata and binds only ids or segments per call.

Prefer macro inputs that read like the cache contract a reviewer should approve. If a macro invocation becomes hard to review, move some metadata into named builders or prepared policies.

Database Query Caching

Use database query caching when a repository result is expensive enough to reuse and the application can describe its identity and invalidation model.

The database layer remains visible. HydraCache does not parse SQL, infer table dependencies, install triggers, or replace SQLx, Diesel, SeaORM, or a repository layer.

    let local = HydraCache::local().build();
    let queries = DbCache::new(local, "db");

    let user = queries
        .entity::<User>("user", 42)
        .collection_tag("users")
        .ttl(Duration::from_secs(60))
        .fetch_with(|| async {
            // Replace this with SQLx, Diesel, SeaORM, or repository code.
            Ok::<_, std::io::Error>(User {
                id: 42,
                name: "Ada".to_owned(),
            })
        })
        .await?;

    assert_eq!(user.name, "Ada");

Before enabling a cached query, answer:

  1. What exact result does this key identify?
  2. Which write paths invalidate the tags?
  3. What TTL or stale behavior is acceptable if invalidation is delayed?
  4. How will hits, misses, loader calls, and invalidations be observed?

For SQLx, Diesel, and SeaORM integrations, keep the adapter helper small. Drop to fetch_with when a transaction, macro-shaped query, or repository method needs more control.

Policy Macro

Use query_cache_policy! when a query has stable metadata and repeated builder calls would obscure the key/tag contract.

    let user_id = 42_i64;
    let policy = query_cache_policy!(
        preset = read_mostly,
        name = "load-user",
        entity = User,
        id = user_id,
        refresh_ahead_secs = 10,
        stale_while_revalidate_secs = 300,
    );

    assert_eq!(policy.name(), Some("load-user"));
    assert_eq!(policy.key_value(), Some("user:42"));
    assert!(policy.refresh_policy_value().is_some());

    let search = query_cache_policy!(
        name = "search-users",
        key_segments = ["tenant", 7_u64, "q", "ada:lovelace", "page", 1_u32],
        tag_segments = [["tenant", 7_u64], ["users"]],
        ttl_secs = 30,
    );

    assert_eq!(search.key_value(), Some("tenant:7:q:ada%3Alovelace:page:1"));
    assert_eq!(
        search.tags_value(),
        &["tenant:7".to_owned(), "users".to_owned()]
    );

The macro is still explicit: it names the key source, tags, TTL or preset, and optional refresh behavior. It does not inspect SQL or infer invalidation.

SQLx Adapter

Use hydracache-sqlx when SQLx remains the query authority and HydraCache owns only the cache boundary.

The adapter re-exports DbCache, policy types, HydraCacheEntity, and query_cache_policy! for SQLx users. It does not replace SQLx macros, pools, transactions, or row mapping.

    let queries = DbCache::new(HydraCache::local().build(), "sqlx");

    let user = queries
        .for_entity::<User>(42)
        .fetch_with(|| async {
            // Replace this with SQLx query_as!, a transaction, or repository code.
            Ok::<_, hydracache_sqlx::sqlx::Error>(User {
                id: 42,
                name: "Ada".to_owned(),
            })
        })
        .await?;

    assert_eq!(user.id, 42);

Use fetch_with when SQLx macros, transactions, or repository functions need to stay in charge. Use sqlx_one, sqlx_optional, and sqlx_all for small pool-like helper calls.

Diesel Adapter

Use hydracache-diesel when Diesel remains the query authority and cached values should still use HydraCache keys, tags, TTL, and diagnostics.

Diesel is synchronous, so Diesel helpers run the supplied loader on tokio::task::spawn_blocking. The loader should own or acquire its connection inside the closure.

    let queries = DieselCache::new(HydraCache::local().build(), "diesel");

    let user_name = queries
        .entity::<String>("user", 42)
        .collection_tag("users")
        .diesel_one(move || {
            // Acquire or use a Diesel connection inside this blocking closure.
            Ok::<_, hydracache_diesel::diesel::result::Error>("Ada".to_owned())
        })
        .await?;

    assert_eq!(user_name, "Ada");

Use fetch_with when custom repository code, transactions, or an async Diesel wrapper needs more control.

SeaORM Adapter

Use hydracache-seaorm when SeaORM remains the query authority and HydraCache should wrap only the query-result boundary.

    let queries = SeaOrmCache::new(HydraCache::local().build(), "seaorm");

    let user_name = queries
        .entity::<String>("user", 42)
        .collection_tag("users")
        .sea_one(|| async {
            // Replace this with a SeaORM query or repository method.
            Ok::<_, hydracache_seaorm::sea_orm::DbErr>("Ada".to_owned())
        })
        .await?;

    assert_eq!(user_name, "Ada");

Use fetch_with when a SeaORM query, transaction, or repository function does not fit the convenience helper shape.

Redis API

HydraCache can expose an optional Redis-compatible RESP edge for clients that already speak Redis.

This surface is a compatibility facade over HydraCache client-surface commands. It is not a promise that HydraCache is a full Redis replacement, and it does not add SQL/query semantics. Use it when Redis client interoperability is useful but the application still wants HydraCache ownership of cache data, TTLs, tags, diagnostics, and invalidation intent.

Crates

CrateRole
hydracache-redis-compatRESP2/RESP3 parsing, command translation, Redis-style responses, resource limits, and HydraCache extension commands.
hydracache-serverOptional TCP listener that serves the Redis RESP facade in the standalone daemon.

Application crates that only use the embedded HydraCache runtime do not need these crates.

Enable The Listener

The server disables the Redis facade by default. Enable it explicitly and keep it on a separate address from HTTP, admin, and cluster listeners.

$env:HYDRACACHE_REDIS_API_ENABLED = "true"
$env:HYDRACACHE_REDIS_ADDR = "127.0.0.1:6379"

For authenticated local or staging use:

$env:HYDRACACHE_REDIS_AUTH_REQUIRED = "true"
$env:HYDRACACHE_REDIS_AUTH_USERNAME = "default"
$env:HYDRACACHE_REDIS_AUTH_TOKEN_FILE = "C:\secrets\hydracache-redis-token.txt"

For rediss://, enable server TLS and the Redis TLS facade together:

$env:HYDRACACHE_TLS_ENABLED = "true"
$env:HYDRACACHE_TLS_CERT_PATH = "C:\certs\server.crt"
$env:HYDRACACHE_TLS_KEY_PATH = "C:\certs\server.key"
$env:HYDRACACHE_TLS_CA_PATH = "C:\certs\ca.crt"
$env:HYDRACACHE_REDIS_REDISS_ENABLED = "true"

HydraCache rejects rediss startup without complete TLS material.

Keyspace notifications are a separate off-by-default 0.68 capability:

$env:HYDRACACHE_REDIS_KEYSPACE_EVENTS_ENABLED = "true"
$env:HYDRACACHE_REDIS_MAX_EVENT_SUBSCRIPTIONS_PER_CONNECTION = "64"
$env:HYDRACACHE_REDIS_MAX_EVENT_SUBSCRIPTION_BYTES_PER_CONNECTION = "65536"

Use SUBSCRIBE/PSUBSCRIBE for __keyspace@0__:* or __keyevent@0__:*; use the matching unsubscribe command to release the live connection state. Admission is bounded by both unique subscription count and retained channel/pattern bytes. The event mode supports only cache-mutation notifications, not arbitrary Pub/Sub or PUBLISH.

Observe a native backend write from a Redis client

The server also projects successful native writes into this notification stream. Use the native typed namespace named redis: its physical redis:<key> prefix is stripped before the Redis channel is rendered. This explicit namespace fence prevents unrelated native cache entries from being exposed to Redis subscribers. Values never enter the event message or the RESP client-surface store, so a native-write notification does not make Redis GET return that native value.

This stream is at-most-once. Removing the final subscription releases the native receiver, and writes made while no subscription exists are not replayed after resubscription. Slow subscribers are disconnected on a detectable gap; they must reconnect, resubscribe, and repair state with ordinary reads. RESP/HC2 and native events retain their own source order, but no global order is claimed between those independent sources.

The example below is compiled by the documentation build. It starts the real RESP TCP listener, subscribes with redis-rs, writes with the ordinary native HydraCache API, and verifies the received Redis key event.

    let cache = HydraCache::local().build();
    let resp = Arc::new(
        RedisRespServer::new(
            Arc::new(ClientSurfaceState::new(ClientSurfaceLimits::default())?),
            RedisListenerConfig {
                keyspace_events: RedisKeyspaceEventConfig {
                    enabled: true,
                    ..RedisKeyspaceEventConfig::default()
                },
                ..RedisListenerConfig::default()
            },
        )?
        .with_native_cache_events(cache.clone()),
    );
    let listener = TcpListener::bind("127.0.0.1:0").await?;
    let address = listener.local_addr()?;
    let accepting = tokio::spawn(async move {
        while let Ok((stream, _)) = listener.accept().await {
            let resp = Arc::clone(&resp);
            tokio::spawn(async move {
                let _ = resp.serve_connection(stream).await;
            });
        }
    });

    // This is a normal Redis client using a normal keyspace subscription.
    let client = redis::Client::open(format!("redis://{address}/"))?;
    let mut pubsub = client.get_async_pubsub().await?;
    pubsub.subscribe("__keyevent@0__:set").await?;

    // The backend writes through the native HydraCache API. The `redis`
    // typed namespace is the explicit, non-leaking bridge to Redis key bytes.
    cache
        .typed::<String>("redis")
        .put("user:42", "Ada".to_owned(), CacheOptions::new())
        .await?;

    let message = tokio::time::timeout(Duration::from_secs(2), pubsub.on_message().next())
        .await?
        .ok_or("Redis subscription closed before the event")?;
    assert_eq!(message.get_channel_name(), "__keyevent@0__:set");
    assert_eq!(message.get_payload::<String>()?, "user:42");

    drop(pubsub);
    accepting.abort();

Supported Shape

The facade targets Redis string/cache-client interoperability:

  • connection and introspection basics such as PING, ECHO, HELLO, AUTH, CLIENT SETNAME, COMMAND, INFO, SELECT, and TYPE;
  • string and key operations such as GET, SET, MGET, MSET, DEL, and EXISTS;
  • TTL operations such as EXPIRE, PEXPIRE, PERSIST, TTL, and PTTL;
  • lock-oriented EVAL, EVALSHA, SCRIPT LOAD, and SCRIPT EXISTS for the supported compare-value lock scripts;
  • bounded SUBSCRIBE, UNSUBSCRIBE, PSUBSCRIBE, and PUNSUBSCRIBE for off-by-default keyspace notifications; and
  • HydraCache extension commands such as HC.STATS, HC.DIAGNOSTICS, HC.INVALIDATE, HC.NAMESPACE, HC.TAG, HC.SETTAGS, and HC.INVALIDATE_TAG.

Unsupported Redis data structures or broad server features should be treated as outside the facade. For example, hash commands are not the goal of this surface.

Client Examples

Any Redis client can use the facade for the supported command subset.

PING
SET user:42 Ada PX 60000
GET user:42
MSET user:43 Grace user:44 Linus
MGET user:42 user:43 user:44
TTL user:42
DEL user:44

HydraCache-specific tags are available through extension commands:

HC.SETTAGS user:42 user:42 users
HC.INVALIDATE_TAG users
HC.STATS
HC.DIAGNOSTICS

Use tags when Redis-facing clients should participate in the same invalidation model as native HydraCache code.

Boundaries

The Redis API is best for interoperability, migration seams, smoke tests, and operational probes. Prefer the native Rust API when application code can call HydraCache directly because native calls preserve typed values, loader boundaries, single-flight behavior, and compile-time policy structure.

Review these boundaries before enabling the facade:

  • RESP resource limits protect frame size, array length, bulk string size, read buffer size, and idle connections.
  • AUTH is optional but should be required for any shared environment.
  • The listener address must not conflict with HTTP, cluster, or admin addresses.
  • Redis keys map into the Redis namespace of the HydraCache client surface.
  • Keyspace notifications are node-local, metadata-only, at-most-once hints. A lagging client is disconnected and must reconnect, resubscribe, and repair state with ordinary reads.
  • Redis clients send bytes, not typed Rust values; typed decoding remains a native API concern.

Use Redis compatibility to meet existing clients where they are. Use HydraCache semantics to decide what should be cached and invalidated.

Diagnostics and Events

Use diagnostics to prove cache behavior locally before adding more infrastructure. The simplest useful check is: call the same cached operation twice, then inspect hit, load, and request counters.

    let cache = HydraCache::local().build();

    let first = cacheable_infallible!(
        cache = cache,
        key = "expensive:42",
        tags = ["expensive"],
        ttl_secs = 60,
        load = || async { 42_u64 },
    )
    .await?;

    let second = cacheable_infallible!(
        cache = cache,
        key = "expensive:42",
        tags = ["expensive"],
        ttl_secs = 60,
        load = || async { 7_u64 },
    )
    .await?;

    let diagnostics = cache.diagnostics().await;

    assert_eq!((first, second), (42, 42));
    assert_eq!(diagnostics.stats.loads, 1);
    assert_eq!(diagnostics.stats.hits, 1);
    assert_eq!(diagnostics.total_requests(), 2);
    assert_eq!(diagnostics.hit_ratio(), Some(0.5));
    assert!(!diagnostics.is_empty());

The first call misses and runs the loader. The second call hits the cache and avoids the fallback loader value.

Event Streams

Use event streams when an application needs to observe cache behavior without wrapping every cache call manually.

    let cache = HydraCache::local().build();
    let mut events = cache.subscribe_tag("users");

    cache
        .put("user:42", 42_u64, CacheOptions::new().tag("users"))
        .await?;

    let event = events.recv().await.expect("stored event");
    assert_eq!(event.kind(), CacheEventKind::Stored);
    assert_eq!(event.key(), Some("user:42"));

    cache.invalidate_tag("users").await?;
    let invalidation = events.recv().await.expect("tag invalidation");
    assert_eq!(invalidation.kind(), CacheEventKind::TagInvalidated);

Mutation and invalidation events are published when subscribers exist. Hit, miss, and load events are higher volume, so they are opt-in with enable_access_events(true).

    let cache = HydraCache::local()
        .enable_access_events(true)
        .event_buffer_capacity(256)
        .build();
    let mut events = cache.subscribe_access();

    let answer = cache
        .get_or_insert_with("answer", CacheOptions::new(), || async { 42_u64 })
        .await?;

    assert_eq!(answer, 42);
    let event = events.next_event().await.expect("access event");
    assert_eq!(event.kind(), CacheEventKind::Miss);

Subscribers use a bounded buffer. Slow subscribers can lag, but cache operations do not wait for listeners.

Callback Listeners

Callback listeners are useful for lightweight integration points. Keep the returned handle alive for as long as the listener should be active.

    let cache = HydraCache::local().build();
    let listener = cache.on_mutation(|event| {
        println!("cache changed: {event:?}");
    });

    cache.put("user:42", 42_u64, CacheOptions::new()).await?;
    listener.unsubscribe();

HydraCache builds owned event payloads only when an event kind is enabled and at least one active subscriber can receive it.

Distributed Invalidation

Use a shared invalidation bus when several cache instances should share invalidation intent.

The bus propagates invalidations, not values. Each cache keeps its own local entries. When one cache invalidates a key, tag, or all entries, peers receive the same intent and remove their local copies.

    let bus = Arc::new(InMemoryInvalidationBus::default());
    let first = HydraCache::local()
        .shared_invalidation_bus(bus.clone())
        .invalidation_node_id("first")
        .build();
    let second = HydraCache::local()
        .shared_invalidation_bus(bus)
        .invalidation_node_id("second")
        .build();

    first
        .put("user:42", 42_u64, CacheOptions::new().tag("users"))
        .await?;
    second
        .put("user:42", 42_u64, CacheOptions::new().tag("users"))
        .await?;

    let mut events = second.subscribe_tag("users");
    first.invalidate_tag("users").await?;

    let event = tokio::time::timeout(Duration::from_millis(500), events.recv())
        .await
        .expect("remote invalidation event")
        .expect("subscription stays open");

    assert_eq!(event.origin(), CacheEventOrigin::DistributedBus);
    assert!(!second.contains_key("user:42").await);
    assert_eq!(first.stats().distributed_invalidations_published, 1);
    assert_eq!(second.stats().distributed_invalidations_applied, 1);

Important semantics:

  • cached values are never replicated;
  • the in-memory bus is best-effort and does not replay messages after restart;
  • self-originated messages are ignored to avoid echo loops;
  • remote invalidations emit normal events with CacheEventOrigin::DistributedBus;
  • diagnostics expose published, received, applied, lagged, decode-error, and closed-receiver counters.

Framed Boundary

InMemoryFramedInvalidationBus serializes each message into a CacheInvalidationFrame before delivery. It is still in-process, but it exercises the binary boundary that external transports can use later.

    let bus = Arc::new(InMemoryFramedInvalidationBus::for_cluster("orders", 128));
    let first = HydraCache::local()
        .shared_invalidation_bus(bus.clone())
        .invalidation_node_id("first")
        .build();
    let second = HydraCache::local()
        .shared_invalidation_bus(bus)
        .invalidation_node_id("second")
        .build();

    let _ = (first, second);

Use this for transport experiments and compatibility tests. It is not a production Redis, NATS, Postgres, or TCP adapter.

Custom Transports

External transports implement CacheInvalidationBus and return a receiver.

#[derive(Debug, Clone)]
struct MyBus;

#[async_trait]
impl CacheInvalidationBus for MyBus {
    async fn publish(&self, message: CacheInvalidationMessage) -> CacheResult<()> {
        // Send `message` through Redis, NATS, Postgres LISTEN/NOTIFY, etc.
        let _ = message;
        Ok(())
    }

    fn subscribe(&self) -> Box<dyn CacheInvalidationReceiver> {
        Box::new(MyReceiver)
    }
}

struct MyReceiver;

#[async_trait]
impl CacheInvalidationReceiver for MyReceiver {
    async fn recv(&mut self) -> CacheInvalidationReceive {
        // Return Message(...) for normal delivery, Lagged(n) for skipped messages,
        // and Closed when the stream is no longer usable.
        CacheInvalidationReceive::Closed
    }
}

Return Message(...) for normal delivery, Lagged(n) when the transport reports skipped messages, and Closed when the stream is no longer usable.

Client and Member Cluster

HydraCache::client() and HydraCache::member() are the embedded cluster shape.

A client is an application-side near-cache. A member is a cluster participant. Both can join an InMemoryCluster, share invalidation, and expose role, node id, generation, bootstrap, lifecycle, and participant diagnostics.

The cluster vocabulary is useful today even before production remote-value distribution:

  • roles distinguish application clients from member nodes;
  • generations protect against stale processes reusing node ids;
  • local-first invalidation keeps value ownership explicit;
  • diagnostics make runtime state visible to health and actuator surfaces.

The current cluster surface intentionally does not replicate cached values. It gives applications a stable model for membership, invalidation, ownership, and future peer-fetch routing while keeping local cache semantics intact.

Support Boundary

Current support includes:

  • local, client, and member cache roles;
  • generation-safe admission, leave, and invalidation publishing;
  • in-memory cluster control plane for tests, demos, and local embedding;
  • chitchat-backed discovery candidate adapter;
  • raft-rs-backed metadata/control-plane adapter;
  • deterministic ownership resolution over admitted members;
  • transport-neutral peer-fetch over encoded cache bytes;
  • read-only diagnostics and observability surfaces.

It intentionally does not yet include:

  • production multi-node Raft networking or full durable Raft log storage;
  • transparent remote closures or arbitrary executable code;
  • value replication, backup ownership, or failover repair;
  • external invalidation transports such as Redis, NATS, or Postgres LISTEN/NOTIFY;
  • TLS/certificate management, external identity, or write-enabled admin APIs.

See the repository guide docs/PRODUCTION_CLUSTER_READINESS.md for the current staging checklist and non-goals.

Optional Adapters

The cluster APIs are split into optional crates so applications can adopt only the pieces they need:

  • use hydracache-cluster-chitchat for chitchat-backed candidate discovery;
  • use hydracache-cluster-raft for the raft-rs metadata control-plane runtime;
  • use hydracache-cluster for the standard chitchat plus raft composition;
  • use hydracache-cluster-transport-axum when members expose HTTP peer-fetch over encoded cache bytes.

Start with InMemoryCluster for tests and demos. Add real adapters only when the deployment has a real discovery and metadata story.

Local Cache API

This page summarizes the main local cache methods. Use it as a map, not as a replacement for Rustdoc.

Reads And Writes

MethodPurpose
getReturn Ok(Some(T)) for a usable value, or Ok(None) when missing or expired.
putStore a typed value with CacheOptions.
contains_keyCheck whether a key currently maps to a usable value. Expired entries are removed and reported as absent.
removeLocal-cache spelling for key invalidation.

Loaders

MethodPurpose
get_or_loadRun a fallible loader on miss, store the loaded value, and share same-key concurrent loads.
get_or_load_with_refreshLike get_or_load, with explicit refresh-ahead and stale behavior.
get_or_insert_withShort spelling for infallible async loaders.
try_get_or_insert_withFallible-loader spelling; equivalent in intent to get_or_load.

Concurrent same-key loader calls share one in-flight load. Cache hits bypass single-flight entirely.

Keys And Tags

Type or methodPurpose
CacheKeyBuilderBuild escaped :-separated keys from segments.
TagSetCollect reusable invalidation tags.
CacheOptions::tagAttach one tag.
CacheOptions::tagsAttach several tags.
CacheOptions::tag_setAttach a prebuilt TagSet.
invalidate_keyRemove one key.
invalidate_tagRemove all entries currently associated with the tag.
flushRemove all local entries.

If a tag is invalidated while a tagged loader is still running, HydraCache skips storing that stale loader result. Callers after the invalidation start or join a fresh load instead of joining the stale one.

Typed Views

typed::<T>("namespace") creates a TypedCache<T> namespaced view over the same runtime.

It keeps shared storage, stats, single-flight, tags, and invalidation behavior, while making repeated typed operations less noisy.

Diagnostics

MethodPurpose
statsReturn lightweight counters for hits, misses, loads, single-flight joins, invalidations, stale load discards, events, and transport diagnostics.
diagnostics().awaitReturn stats plus local backend approximate entry count for smoke checks.
subscribe and tag/key filtered variantsObserve cache behavior through event streams.
callback listenersRegister callback-style mutation/access listeners while the returned handle is alive.

Use diagnostics to prove basic cache behavior locally: the first call should miss and load, and the second same-key call should hit.

For exact signatures, see API Links.

Crate Map

Use this page to choose the right crate for an application or integration.

CrateUse when
hydracacheYou need the local async cache, typed cache, TTLs, tags, single-flight, stats, diagnostics, cacheable macros, and client/member cluster API.
hydracache-dbYou are wrapping database or repository calls with explicit query-result caching.
hydracache-sqlxYou want SQLx-facing helpers such as sqlx_one, sqlx_optional, and sqlx_all.
hydracache-dieselYou want Diesel-facing aliases, re-exports, and blocking diesel_one, diesel_optional, and diesel_all helpers.
hydracache-seaormYou want SeaORM-facing aliases, re-exports, and async sea_one, sea_optional, and sea_all helpers.
hydracache-observabilityYou need a framework-neutral registry and serializable diagnostic snapshots.
hydracache-actuator-axumYou want read-only HydraCache diagnostics exposed through Axum routes.
hydracache-clusterYou want the standard chitchat plus raft adapter composition without wiring every handle manually.
hydracache-cluster-chitchatYou want real chitchat-backed cluster candidate discovery.
hydracache-cluster-raftYou want the real raft-rs metadata control-plane runtime behind ClusterControlPlane.
hydracache-cluster-transport-axumCluster members should expose HTTP peer-fetch over encoded cache bytes or use read-through near-cache hydration.
hydracache-redis-compatYou need the optional Redis RESP compatibility facade, command translation, resource limits, or HydraCache Redis extension commands.
hydracache-serverYou want the standalone daemon with optional HTTP/admin/client surfaces and the optional Redis RESP listener.
hydracache-coreYou need shared core types without the user-facing runtime.
hydracache-macrosUsually use this through re-exports from hydracache, hydracache-db, or adapter crates.
hydracache-sandboxYou are running the non-published manual sandbox for actuator, Swagger, memory, SQLite, Postgres Docker, scenario labs, and cluster-adapter checks.

Most application code should start with hydracache. Add adapter crates only when the application needs that integration surface.

Quality Gate

Use these checks before publishing a documentation or release branch.

cargo fmt --all -- --check
cargo check --workspace --all-targets --locked
cargo test --workspace --all-targets --locked
cargo clippy --workspace --all-targets --all-features --exclude hydracache --locked -- -D warnings
cargo clippy -p hydracache --all-targets --locked -- -D warnings
cargo test --doc --workspace --locked
cargo llvm-cov --workspace --all-targets --locked --summary-only

For the public docs site specifically:

cargo fmt --manifest-path docs-site/examples/Cargo.toml --check
cargo check --manifest-path docs-site/examples/Cargo.toml --all-targets --locked
mdbook build docs-site
node scripts/docs-link-check.mjs
node scripts/docs-visual-smoke.mjs

Cluster load stability checks live in a separate integration target. The small smoke test runs in the normal suite, and the heavier manual workload is ignored by default.

cargo test -p hydracache --test cluster_load_stability --locked -- --nocapture
cargo test -p hydracache --test cluster_load_stability --locked -- --ignored --nocapture

Coverage is tracked with cargo-llvm-cov. The current target is 95%+ line coverage for reusable library crates and a workspace trend toward 95%+, including the manual sandbox.

Workspace Layout

HydraCache is split into focused crates so applications can depend only on the surfaces they use.

PathPurpose
crates/hydracache-coreCore public types: keys, tags, options, stats, diagnostics, codec, and errors.
crates/hydracacheUser-facing local cache runtime, typed cache, single-flight, tag index, diagnostics, invalidation bus, and client/member cluster API.
crates/hydracache-dbDatabase-neutral query result-cache adapter API.
crates/hydracache-sqlxSQLx-facing integration crate and helper methods.
crates/hydracache-dieselDiesel-facing integration crate and helper methods.
crates/hydracache-seaormSeaORM-facing integration crate and helper methods.
crates/hydracache-macrosProcedural macros such as cacheable_loader!, cacheable_infallible!, HydraCacheEntity, and query_cache_policy!.
crates/hydracache-observabilityFramework-neutral cache registry and serializable diagnostic snapshots.
crates/hydracache-actuator-axumOptional read-only Axum actuator routes.
crates/hydracache-cluster-chitchatOptional real chitchat-backed cluster discovery adapter.
crates/hydracache-cluster-raftOptional real raft-rs metadata control-plane runtime.
crates/hydracache-clusterOptional composition helpers for the standard chitchat plus raft cluster setup.
crates/hydracache-cluster-transport-axumOptional Axum/HTTP peer-fetch transport and read-through near-cache hydration.
crates/hydracache-sandboxNon-published manual backend for actuator, database, listener, scenario, and cluster checks.

The hydracache crate keeps public API re-exports in src/lib.rs and splits runtime code into focused modules:

ModulePurpose
cache.rsHydraCache runtime API.
builder.rsLocal cache builder.
typed.rsTypedCache<T> namespaced view.
cluster.rsClient/member cluster roles, in-memory discovery, cluster model, generation guard, and diagnostics.

API Links

Use docs.rs for exact Rust API signatures. This mdBook explains how the pieces fit together.

Core Runtime

Function Caching

Database Caching

Redis Compatibility

Adapter Crates

Publishing Docs

The public documentation site lives under docs-site.

Local Preview

From the repository root:

mdbook serve docs-site --hostname 127.0.0.1 --port 3000

Open:

http://127.0.0.1:3000

Build

mdbook build docs-site

The generated site is written to:

docs-site/book

Example Checks

Documentation snippets are included from checked Rust files:

cargo fmt --manifest-path docs-site/examples/Cargo.toml --check
cargo check --manifest-path docs-site/examples/Cargo.toml --all-targets --locked
node scripts/docs-link-check.mjs

When an API changes, update the Rust example and the Markdown page in the same branch.

Rust Playground

The Rust Playground run button is disabled in book.toml:

[output.html.playground]
runnable = false
copyable = true

This is deliberate. The public docs include snippets from checked examples that use local path dependencies, while Rust Playground can only run against published crates. Keep the run button disabled until the runnable examples target a published crate version and each runnable block is a complete standalone program.

Visual Smoke

With mdbook serve running:

node scripts/docs-visual-smoke.mjs

The smoke test opens the home page, architecture page, production checklist, database guide, and API links page on desktop and mobile viewports. It checks that content is present, the home logo is visible, static architecture diagrams render, and pages do not introduce horizontal overflow.

Assets

Brand assets live under:

docs-site/src/assets/brand

Use the *-256.png asset in pages and README content. Keep the original PNG as the source asset.

Publishing Target

The intended production target is GitHub Pages, optionally behind a custom domain later. The book.toml site-url is set to /hydracache/, which matches the repository GitHub Pages path.

The Documentation Site workflow builds docs-site/book, runs checks, and uploads that directory as the GitHub Pages artifact on main pushes that touch docs content. Treat docs-site as the production Pages source; other browser demos should be linked from the docs or published under their own path without replacing the docs artifact.

Versioning

The published docs should describe the released crate line that readers can install from crates.io.

Branches

main is the source of truth for the next published docs site. Documentation branches should compile examples against the code in the same branch.

Releases

When a crate release is published:

  • update release notes under docs/releases;
  • make sure docs.rs links point at latest unless documenting a version-specific behavior;
  • build docs-site from the same commit that passed release checks;
  • publish the GitHub Pages site from that reviewed state.

URLs

The repository GitHub Pages path is expected to be:

https://javaquasar.github.io/hydracache/

If a custom domain is added later, keep /hydracache/ compatibility or add redirects so existing article and README links do not break.

Examples

Public examples in docs-site/examples are branch-coupled. A documentation PR should fail if examples no longer compile against that branch.

Rust Playground examples are different: they run against published crates, not the current branch. Only enable runnable playground blocks for APIs that exist in the latest published crate version, or pin the docs to a version-specific target.

Docs Ownership

The public docs site is the primary reader-facing documentation surface.

Rules

  • README.md stays short and points readers into docs-site.
  • Public docs should be self-contained; Medium articles are linked only from the home page as background.
  • Runnable snippets belong in docs-site/examples and are included into Markdown.
  • Branch docs compile against branch code.
  • Playground run buttons stay disabled until snippets target published crates.io versions.
  • Brand assets live under docs-site/src/assets/brand.
  • Architecture diagrams live under docs-site/src/assets/diagrams as checked static assets, not CDN-rendered runtime dependencies.

Review

A documentation PR should be reviewed for:

  • correctness of keys, tags, TTL, refresh, and invalidation examples;
  • whether new examples compile or are intentionally marked as non-runnable;
  • local link integrity;
  • mobile readability and lack of horizontal overflow;
  • whether content duplicates README instead of linking to the right docs page.

Docs Release Checklist

Use this checklist before publishing a crate release or updating the public docs site.

Content

  • README still reads as a short project entry point.
  • Crate Map reflects all public crates and adapter names.
  • API Links point to the correct docs.rs crates.
  • Versioning page matches the release branch and GitHub Pages target.
  • Adapter pages match current SQLx, Diesel, and SeaORM helper names.
  • Production Checklist and Anti-patterns still describe current behavior.

Checks

cargo fmt --manifest-path docs-site/examples/Cargo.toml --check
cargo check --manifest-path docs-site/examples/Cargo.toml --all-targets --locked
mdbook build docs-site
node scripts/docs-link-check.mjs
node scripts/docs-visual-smoke.mjs

Publishing

  • Build docs from the reviewed commit.
  • Publish docs-site/book through the GitHub Pages workflow.
  • Confirm no other workflow replaces the docs Pages artifact for the same release.
  • Confirm favicon, logo, static diagrams, search, and mobile navigation after deploy.
  • Keep article links on the home page only.

Legacy README Map

The root README used to carry most public documentation. It is now a short entry point. Use this map when looking for content that moved into the docs site.

Old README topicNew location
Why HydraCache existsHydraCache, Cache Semantics
v0 scope and crate selectionCrate Map, Workspace Layout
Local cache quick startGetting Started, Local Cache
Cacheable function macrosCacheable Functions, Macros
Entity and query policy macrosMacros, Database Query Caching
Redis compatibility surfaceRedis API, Crate Map
API notesLocal Cache API, API Links
Diagnostics and eventsDiagnostics and Events
Distributed invalidation busDistributed Invalidation
Client and member cluster modeClient and Member Cluster
SQLx adapterSQLx Adapter
Diesel adapterDiesel Adapter
SeaORM adapterSeaORM Adapter
Quality gateQuality Gate
Docs publishingPublishing Docs

Older release notes, implementation plans, and operational evidence remain under the repository docs/ directory.

Examples Contract

Documentation examples should stay tied to real code.

The public docs use checked Rust files under:

docs-site/examples/src/bin/

Markdown pages include snippets from those files with mdBook include directives. This keeps the displayed code and the compiled code in one place.

Local Checks

Build the documentation site:

mdbook build docs-site

Compile the documentation examples:

cargo check --manifest-path docs-site/examples/Cargo.toml --all-targets

For a complete docs publishing workflow, see Publishing Docs.

Rules

  • Runnable examples belong in docs-site/examples.
  • Markdown should include snippets instead of copying them by hand.
  • Use rust,ignore only for intentionally incomplete sketches.
  • When an API changes, update the example and the page in the same branch.

This means a documentation branch is checked against the code in that branch, and the published site from main reflects code that has already passed review.