HydraCache
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
| Goal | Start here |
|---|---|
| Cache one local value or loader | Local Cache |
| Reuse one typed namespace across call sites | Typed Cache |
| Cache ordinary async functions | Cacheable Functions |
| Use function and entity macros | Macros |
| Cache repository or query results | Database Query Caching |
| Pick SQLx, Diesel, or SeaORM integration | SQLx, Diesel, SeaORM |
| Expose a Redis-compatible RESP edge | Redis API |
| Review production readiness | Production Checklist |
| Avoid common cache mistakes | Anti-patterns |
First Path
If you are new to HydraCache, read these pages in order:
- Getting Started
- Architecture
- Decision Guide
- Production Checklist
- Keys and Tags
- Local Cache
- Macros
- 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.
Project Links
Article Series
The Medium series is the narrative origin of several concepts in this site:
- Part 1: Why Rust Needs Cache Semantics, Not Just Another Cache Map
- Part 2: Single-flight Is Not an Optimization
- Part 3: TTL Is Not Enough
- Part 4: Local-first Distributed Invalidation
- Part 5: Typed Query Caching in Rust
Getting Started
The quickest way to understand HydraCache is to build one local cache flow:
- create a cache;
- store a typed value with tags and TTL;
- load a missing value through
get_or_load; - 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;
CacheOptionscarries 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.
| Crate | Purpose |
|---|---|
hydracache | Core user-facing local cache runtime. |
hydracache-db | Database-neutral query result caching policies and helpers. |
hydracache-sqlx | SQLx adapter helpers. |
hydracache-diesel | Diesel adapter helpers. |
hydracache-seaorm | SeaORM adapter helpers. |
hydracache-redis-compat | Optional Redis RESP compatibility facade primitives. |
hydracache-server | Standalone 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
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
HydraCache does not parse SQL or infer table dependencies. The application names the result and the writes that can invalidate it.
Invalidation Flow
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:42for one entity;usersfor collection-level reads;tenant:7for tenant-wide invalidation;permission:abcfor 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.
| Situation | Start with |
|---|---|
| Store or load one typed value in-process | Local Cache |
| Several call sites reuse one value type and namespace | Typed Cache |
| A normal async function should be cached with less boilerplate | Cacheable Functions |
| A DB/repository result needs explicit query-result caching | Database Query Caching |
| A stale value may be acceptable during refresh | Refresh and Stale Reads |
| You need to prove hit/miss/load behavior | Diagnostics and Events |
| Multiple local caches should observe invalidations | Distributed Invalidation |
| You need role, membership, generation, or owner vocabulary | Client 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/exampleswhen 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:
- Does the key identify one exact value shape?
- Do tags match real write paths?
- Does the TTL describe fallback freshness rather than primary correctness?
- 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:
- design the key correctly;
- use the cache API to coalesce same-key loads;
- 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:
- the local cache removes values by key or tag;
- the invalidation event can be published to peers;
- peers apply the same invalidation locally;
- 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:
putstores a typed value;getreads the same typed value;get_or_loadavoids 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_aheadrefreshes a value before expiry while still serving the fresh cached value;stale_while_revalidateserves an expired value inside a bounded window and refreshes in the background;stale_on_loader_errorcan 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
| Macro | Use 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. |
HydraCacheEntity | Entity 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:
- What exact result does this key identify?
- Which write paths invalidate the tags?
- What TTL or stale behavior is acceptable if invalidation is delayed?
- 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
| Crate | Role |
|---|---|
hydracache-redis-compat | RESP2/RESP3 parsing, command translation, Redis-style responses, resource limits, and HydraCache extension commands. |
hydracache-server | Optional 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, andTYPE; - string and key operations such as
GET,SET,MGET,MSET,DEL, andEXISTS; - TTL operations such as
EXPIRE,PEXPIRE,PERSIST,TTL, andPTTL; - lock-oriented
EVAL,EVALSHA,SCRIPT LOAD, andSCRIPT EXISTSfor the supported compare-value lock scripts; - bounded
SUBSCRIBE,UNSUBSCRIBE,PSUBSCRIBE, andPUNSUBSCRIBEfor off-by-default keyspace notifications; and - HydraCache extension commands such as
HC.STATS,HC.DIAGNOSTICS,HC.INVALIDATE,HC.NAMESPACE,HC.TAG,HC.SETTAGS, andHC.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-chitchatfor chitchat-backed candidate discovery; - use
hydracache-cluster-raftfor the raft-rs metadata control-plane runtime; - use
hydracache-clusterfor the standard chitchat plus raft composition; - use
hydracache-cluster-transport-axumwhen 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
| Method | Purpose |
|---|---|
get | Return Ok(Some(T)) for a usable value, or Ok(None) when missing or expired. |
put | Store a typed value with CacheOptions. |
contains_key | Check whether a key currently maps to a usable value. Expired entries are removed and reported as absent. |
remove | Local-cache spelling for key invalidation. |
Loaders
| Method | Purpose |
|---|---|
get_or_load | Run a fallible loader on miss, store the loaded value, and share same-key concurrent loads. |
get_or_load_with_refresh | Like get_or_load, with explicit refresh-ahead and stale behavior. |
get_or_insert_with | Short spelling for infallible async loaders. |
try_get_or_insert_with | Fallible-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 method | Purpose |
|---|---|
CacheKeyBuilder | Build escaped :-separated keys from segments. |
TagSet | Collect reusable invalidation tags. |
CacheOptions::tag | Attach one tag. |
CacheOptions::tags | Attach several tags. |
CacheOptions::tag_set | Attach a prebuilt TagSet. |
invalidate_key | Remove one key. |
invalidate_tag | Remove all entries currently associated with the tag. |
flush | Remove 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
| Method | Purpose |
|---|---|
stats | Return lightweight counters for hits, misses, loads, single-flight joins, invalidations, stale load discards, events, and transport diagnostics. |
diagnostics().await | Return stats plus local backend approximate entry count for smoke checks. |
subscribe and tag/key filtered variants | Observe cache behavior through event streams. |
| callback listeners | Register 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.
| Crate | Use when |
|---|---|
hydracache | You need the local async cache, typed cache, TTLs, tags, single-flight, stats, diagnostics, cacheable macros, and client/member cluster API. |
hydracache-db | You are wrapping database or repository calls with explicit query-result caching. |
hydracache-sqlx | You want SQLx-facing helpers such as sqlx_one, sqlx_optional, and sqlx_all. |
hydracache-diesel | You want Diesel-facing aliases, re-exports, and blocking diesel_one, diesel_optional, and diesel_all helpers. |
hydracache-seaorm | You want SeaORM-facing aliases, re-exports, and async sea_one, sea_optional, and sea_all helpers. |
hydracache-observability | You need a framework-neutral registry and serializable diagnostic snapshots. |
hydracache-actuator-axum | You want read-only HydraCache diagnostics exposed through Axum routes. |
hydracache-cluster | You want the standard chitchat plus raft adapter composition without wiring every handle manually. |
hydracache-cluster-chitchat | You want real chitchat-backed cluster candidate discovery. |
hydracache-cluster-raft | You want the real raft-rs metadata control-plane runtime behind ClusterControlPlane. |
hydracache-cluster-transport-axum | Cluster members should expose HTTP peer-fetch over encoded cache bytes or use read-through near-cache hydration. |
hydracache-redis-compat | You need the optional Redis RESP compatibility facade, command translation, resource limits, or HydraCache Redis extension commands. |
hydracache-server | You want the standalone daemon with optional HTTP/admin/client surfaces and the optional Redis RESP listener. |
hydracache-core | You need shared core types without the user-facing runtime. |
hydracache-macros | Usually use this through re-exports from hydracache, hydracache-db, or adapter crates. |
hydracache-sandbox | You 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.
| Path | Purpose |
|---|---|
crates/hydracache-core | Core public types: keys, tags, options, stats, diagnostics, codec, and errors. |
crates/hydracache | User-facing local cache runtime, typed cache, single-flight, tag index, diagnostics, invalidation bus, and client/member cluster API. |
crates/hydracache-db | Database-neutral query result-cache adapter API. |
crates/hydracache-sqlx | SQLx-facing integration crate and helper methods. |
crates/hydracache-diesel | Diesel-facing integration crate and helper methods. |
crates/hydracache-seaorm | SeaORM-facing integration crate and helper methods. |
crates/hydracache-macros | Procedural macros such as cacheable_loader!, cacheable_infallible!, HydraCacheEntity, and query_cache_policy!. |
crates/hydracache-observability | Framework-neutral cache registry and serializable diagnostic snapshots. |
crates/hydracache-actuator-axum | Optional read-only Axum actuator routes. |
crates/hydracache-cluster-chitchat | Optional real chitchat-backed cluster discovery adapter. |
crates/hydracache-cluster-raft | Optional real raft-rs metadata control-plane runtime. |
crates/hydracache-cluster | Optional composition helpers for the standard chitchat plus raft cluster setup. |
crates/hydracache-cluster-transport-axum | Optional Axum/HTTP peer-fetch transport and read-through near-cache hydration. |
crates/hydracache-sandbox | Non-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:
| Module | Purpose |
|---|---|
cache.rs | HydraCache runtime API. |
builder.rs | Local cache builder. |
typed.rs | TypedCache<T> namespaced view. |
cluster.rs | Client/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
| API | Link |
|---|---|
cacheable_loader! | https://docs.rs/hydracache/latest/hydracache/macro.cacheable_loader.html |
cacheable_infallible! | https://docs.rs/hydracache/latest/hydracache/macro.cacheable_infallible.html |
#[cacheable] | https://docs.rs/hydracache/latest/hydracache/attr.cacheable.html |
Database Caching
Redis Compatibility
Adapter Crates
- https://docs.rs/hydracache-sqlx/latest/hydracache_sqlx/
- https://docs.rs/hydracache-diesel/latest/hydracache_diesel/
- https://docs.rs/hydracache-seaorm/latest/hydracache_seaorm/
- https://docs.rs/hydracache-observability/latest/hydracache_observability/
- https://docs.rs/hydracache-actuator-axum/latest/hydracache_actuator_axum/
- https://docs.rs/hydracache-redis-compat/latest/hydracache_redis_compat/
- https://docs.rs/hydracache-server/latest/hydracache_server/
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
latestunless documenting a version-specific behavior; - build
docs-sitefrom 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.mdstays short and points readers intodocs-site.- Public docs should be self-contained; Medium articles are linked only from the home page as background.
- Runnable snippets belong in
docs-site/examplesand 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/diagramsas 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/bookthrough 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 topic | New location |
|---|---|
| Why HydraCache exists | HydraCache, Cache Semantics |
| v0 scope and crate selection | Crate Map, Workspace Layout |
| Local cache quick start | Getting Started, Local Cache |
| Cacheable function macros | Cacheable Functions, Macros |
| Entity and query policy macros | Macros, Database Query Caching |
| Redis compatibility surface | Redis API, Crate Map |
| API notes | Local Cache API, API Links |
| Diagnostics and events | Diagnostics and Events |
| Distributed invalidation bus | Distributed Invalidation |
| Client and member cluster mode | Client and Member Cluster |
| SQLx adapter | SQLx Adapter |
| Diesel adapter | Diesel Adapter |
| SeaORM adapter | SeaORM Adapter |
| Quality gate | Quality Gate |
| Docs publishing | Publishing 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,ignoreonly 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.