From 992618530c07f16b60f9c2dceacaa7cb8462cece Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Tue, 22 Sep 2026 10:39:03 -0700 Subject: [PATCH] docs(rust): rewrite the README as a crates.io landing page rust/README.md is what crates.io renders for the dialcache crate, and it never said how to install the crate. This rewrite gives it the shape of the TypeScript README and makes every fact scannable. - Open with the use-case definition, the experimental notice, nine capability bullets and one link line that includes docs.rs. - Add an Install section: cargo add, a feature table, and the MSRV rule (1.85; 1.88 for the redis feature because of the locked redis 1.x). - Show both entry points in the Usage snippet (use_case().register() and get_or_load) plus get_uncached; the snippet compiles, passes rustfmt and runs. Follow it with five gotcha bullets. - Replace fact-list paragraphs with tables for Policy leaves and instance defaults, and keep every paragraph under 60 words and one idea. - Remove protocol-internal terms (request memo, flight, settlement, quiescence) and second person; name the real builder knob shadow_max_in_flight instead of "shadow capacity". - Move contributor-only details (settlement control, driver causality) out; they live in formal/PORTING.md and formal/WALKTHROUGH.md. All 17 links and 3 heading anchors verified. No prose line exceeds 80 columns. Prose length is unchanged (about 2,070 words); readability comes from structure, not deletion. --- rust/README.md | 593 +++++++++++++++++++++++++++---------------------- 1 file changed, 333 insertions(+), 260 deletions(-) diff --git a/rust/README.md b/rust/README.md index 2d40548e..eef22915 100644 --- a/rust/README.md +++ b/rust/README.md @@ -1,309 +1,382 @@ # DialCache for Rust -Read the [shared behavior guides](https://lan17.github.io/DialCache/) and -[Rust integration guide](https://lan17.github.io/DialCache/languages/rust). -The site uses one explanation per feature with selected native examples and notes. - -Rust implements the same portable behavior as the TypeScript library and the -Go port: explicit request enablement, request/local/Redis layers, -deterministic rollout, sparse runtime policy, request and process coalescing, -tracked invalidation, source and read deadlines, stale recovery, dark and -served-hit shadow validation, compression, and failure-isolated observability. - -The [Quint models](https://github.com/lan17/DialCache/blob/main/formal/README.md) are the behavioral source of truth. -The Rust conformance harness replays the same sampled histories and named -public-action regressions as the other ports, plus the fixed scenarios and -Quint-derived protocol vectors, through the shared Node replay coordinator. -These are finite checks of the documented contract, not proof of every -possible input or schedule. - -## Use - -The core crate requires Rust 1.85 or later; the `redis` feature requires Rust -1.88 with the currently locked Redis dependency. CI pins 1.98.1 through +DialCache organizes caching into use cases, with runtime control and +observability for each one. This crate is the Rust port. It behaves like the +[TypeScript library](https://github.com/lan17/DialCache/blob/main/typescript/README.md) +and the [Go port](https://github.com/lan17/DialCache/blob/main/go/README.md): +all three replay the same formally generated histories through their public +APIs. + +**TypeScript is the reference implementation. Rust and Go are experimental.** + +- **Off by default:** a call caches only inside an enabled request `Scope`. +- **Multi-layer:** request-local → process-local → Redis. +- **Runtime policies per use case:** layers, TTLs and rollout ramps. +- **Targeted invalidation:** one call per entity for its tracked Redis results. +- **Coalescing:** same-key reads share one source call when a layer is active. +- **Fail-open:** cache failures fall back to the source. +- **Stale-on-error (opt-in):** a retained Redis value when the source fails. +- **Shadow validation (opt-in):** background checks of Redis against the source. +- **Observability:** Prometheus and Datadog exporters with shared metric names. + +[Shared guides](https://lan17.github.io/DialCache/) +· [Rust integration guide](https://lan17.github.io/DialCache/languages/rust) +· [API reference](https://docs.rs/dialcache) + +## Install + +```sh +cargo add dialcache +cargo add tokio --features macros,rt-multi-thread,time +``` + +The crate needs Rust 1.85 or later. The `redis` feature needs Rust 1.88 +because of the locked `redis` 1.x dependency. CI pins 1.98.1 in `rust/rust-toolchain.toml`, which rustup honors when cargo runs inside `rust/`. -Applications own their Redis connection and its timeout, retry and -resource budgets. The default runtime is the tokio runtime that is current -while the cache is built. + +| Feature | Enables | +| --- | --- | +| `tokio` (default) | `TokioRuntime`: detached work and timers on the current Tokio runtime | +| `redis` | `RedisAdapter` over the `redis` crate (add `redis` with its `tokio-comp` feature) | +| `prometheus` | `PrometheusObserver` on a `prometheus::Registry` | +| `test-util` | `testing::TestExecutor`: a deterministic executor with a virtual clock | + +Structured values need `serde` with `derive`. Datadog needs no feature: +`DatadogObserver` sends through a caller-supplied `DogStatsdClient`. + +## Usage ```rust use std::sync::Arc; -use dialcache::{BoxError, DialCache, KeySpec, Policy}; + +use dialcache::{BoxError, DialCache, Identity, KeySpec, Operation, Policy}; #[tokio::main] async fn main() -> Result<(), BoxError> { + // Startup: build one cache per process and register each use case once. let cache = DialCache::builder().namespace("my-app").build()?; let display_name = cache - .use_case::("user", "displayName") + .use_case::("user", "displayName") // key type, use case .policy(Policy::default().request_local(true).local_ttl_sec(30)) - .key(|id: &u64| KeySpec::new(id)) + .key(|id: &u64| KeySpec::new(id)) // every input that changes the result .source(|_scope, id| async move { - // Replace this with your database or API call. + // The loader: replace with the database or API read. Ok(format!("User {id}")) }) .register()?; + // Request handler: open one scope and pass it to every cached call. let request = cache.enable_guard(); let name: Arc = display_name.get(request.scope(), 42).await?; - println!("Hello, {name}!"); + let again = display_name.get(request.scope(), 42).await?; // hit: same Arc + assert!(Arc::ptr_eq(&name, &again)); + + // Inline form: a key and a loader, no registration. + let email: Arc = cache + .get_or_load( + request.scope(), + Operation::new(Identity::new("user", 42, "email")) + .policy(Policy::default().request_local(true)), + |_scope| async { Ok("ada@example.com".to_owned()) }, + ) + .await?; + println!("Hello, {name} <{email}>!"); + drop(request); // Closes the request scope; the Arcs stay usable. + + // Without a scope, calls go straight to the source. + let fresh = display_name.get_uncached(42).await?; + assert_eq!(*fresh, *name); Ok(()) } ``` -A policy enables no cache layers by default. The example opts into request -caching and a 30-second process-local cache; register the use case once at -startup and create a scope for each request. `Arc` shares one cached value -without requiring `T: Clone`. Settled memory entries holding another Rust type -are misses; compatible remote JSON may still decode into the requested type. -Simultaneous calls sharing a key also share one source result, so an incompatible -coalesced follower returns a type error. Use a consistent value type per key. - -Run the complete [basic example](https://github.com/lan17/DialCache/blob/main/rust/examples/basic.rs), which demonstrates a -structured value, an asynchronous source and reuse across two request scopes: +Rules that apply to every cached call: + +- Build the cache inside a running Tokio runtime with its time driver; building + outside one returns `ConfigError`. `TokioRuntime::from_handle` selects a + runtime explicitly. +- Values come back as `Arc` shared between callers: treat them as immutable. + `T: Clone` is never required. +- Keep one Rust type per key. A memory entry of another type is a miss, and a + coalesced caller that asked for another type gets a type error. +- A source may run again later for shadow validation, so it must be safe to + call repeatedly. +- Dropping the future returned by `get` cancels nothing: the source call, cache + writes and other callers keep their contracts. + +Two complete programs live in +[`rust/examples`](https://github.com/lan17/DialCache/tree/main/rust/examples): +`basic` (a struct value, an async source, two request scopes) and `redis` +(application-owned timeouts, tracked Redis caching, invalidation). ```sh cd rust cargo run --example basic +REDIS_URL=redis://127.0.0.1/ cargo run --features redis --example redis ``` -The [Redis example](https://github.com/lan17/DialCache/blob/main/rust/examples/redis.rs) configures application-owned connection -and command timeouts, enables tracked Redis caching and demonstrates -invalidation. With a Redis server running: +## Request scopes + +Caching is off until a request opens a scope. Pass that scope to every cached +call made for the request, including calls a source makes. Closing the scope +ends the request-local cache: retained `Scope` clones pass through to their +sources, and late work cannot write into the closed request's cache. + +- `enable_guard()` opens the scope and returns a guard. `guard.scope()` is the + handle; dropping the guard closes the scope. +- `enable(|scope| async { ... })` is the closure form. The scope closes when + the future completes or is dropped. +- `enable_in(&scope, ...)` and `disable_in(&scope, ...)` derive nested scopes + that share the request's cache. Wrap mutations in `disable_in` so a write + path cannot cache a read it is about to make stale. +- `Scope::outside()` is the pass-through scope for work that belongs to no + request, and `get_uncached(args)` calls the source directly. + +## Policy + +A `Policy` is a use case's static baseline. `Policy::default()` enables no +layer; each leaf turns one thing on. + +| Leaf | Meaning | +| --- | --- | +| `request_local(bool)` | Reuse results within one request scope | +| `local_ttl_sec(n)` / `remote_ttl_sec(n)` | Process-local and Redis lifetimes in whole seconds, 1 or more; omitted keeps the layer off | +| `local_ramp(pct)` / `remote_ramp(pct)` | Stable cohort of keys served from each layer, 0 to 100; omitted serves every key | +| `coalesce(bool)` | Share one source call among same-key callers; on unless set to false | +| `stale_on_error_max_age_sec(n)` | Serve a retained Redis value up to this age when the source fails | +| `remote_read_timeout_ms(n)` | Redis read deadline before falling through to the source | +| `shadow(ShadowPolicy)` | `ramp` picks the shadow cohort; `log_mismatches` warns once per confirmed mismatch | + +`Policy::enabled(ttl)` sets both TTLs to `ttl` with full ramps. +`Policy::disabled()` turns every layer, recovery and shadow off explicitly, +which makes it a useful runtime overlay. `Policy::from_json` and `to_json` use +the TypeScript JSON shape. + +### Changing policy at runtime + +`policy_provider` on the builder runs once per enabled call and returns a +sparse `RuntimePolicy` overlay. `RuntimePolicy::from(policy)` converts a typed +`Policy`. + +- `Ok(None)` inherits the baseline. +- A present leaf replaces the baseline's; an omitted leaf inherits. That + includes `request_local` and `coalesce`, so a TTL-only overlay keeps the + flags. +- An invalid TTL or ramp disables only that layer. An invalid flag or read + deadline bypasses caching for that call. +- Changes apply to new calls. Nothing already cached is evicted and nothing + already admitted is cancelled. + +### Instance defaults + +| Setting | Default | Where to change it | +| --- | --- | --- | +| Namespace (key prefix) | `urn` | `namespace` on the builder | +| Process-local capacity (LRU) | 10,000 entries | `local_capacity` | +| Redis read deadline | 50 ms | `remote_read_timeout_ms` | +| Source deadline | 60 s | `budget(SourceBudget)` on the use case: `Millis(n)` or `Unbounded` | +| Concurrent shadow jobs | 1 | `shadow_max_in_flight` | +| Compression | zstd level 3 above 4,096 bytes | `compression(CompressionConfig)` or `disable_compression` | + +Invalid builder configuration returns `ConfigError` from `build`; invalid use +case configuration is returned by `register` before any call runs. + +## Errors and deadlines + +- Source errors surface as `Error::Source(Arc)`. Every coalesced + caller receives the same `Arc`, so `Arc::ptr_eq` identifies one failure. +- A source deadline returns `Error::FallbackTimeout` and does not cancel the + source. +- A panic in a source, codec, policy provider or comparator becomes + `Error::Panic`. +- Cache plumbing fails open: a Redis or codec failure falls through to the + source. Maintenance calls return their errors: `Error::Remote` from the + adapter, `Error::MissingRemote` when no remote is configured. +- Observer and logger failures never change a cache, source or maintenance + result. + +## Keys + +`KeySpec::new(id)` and `Identity::new(key_type, id, use_case)` accept strings, +integers and floats, including references such as `&u64`. Integers keep their +exact decimal text and floats use JavaScript number spelling (`f32` is promoted +to `f64`), so one identity produces the same Redis key in every language. For +other ID types pass `id.to_string()` or implement `IntoKeyId`. + +`KeySpec::arg` adds secondary dimensions under the same scalar rules, and +`normalize_args` orders their names by UTF-16 code units. Entries shared across +languages need the same namespace, key dimensions, codec and policy. See +[Keys and identity](https://lan17.github.io/DialCache/keys.html). + +## Redis and invalidation ```sh -REDIS_URL=redis://127.0.0.1/ cargo run --features redis --example redis +cargo add dialcache --features redis +cargo add redis --features tokio-comp ``` -The examples use the crate's existing dependencies. Applications also need -`tokio` with `macros`, `rt-multi-thread` and `time` enabled; structured JSON -values use `serde` with its `derive` feature. The Redis example additionally -needs the `redis` crate with `tokio-comp`, and DialCache's `redis` feature. - -Caching is disabled by default. `DialCache::enable` (closure form) or -`DialCache::enable_guard` (RAII form) opens the outermost enabled scope and -hands out a `Scope`; pass it to every cached call made on behalf of that -request, including calls made inside a source. Completing the callback or -dropping the guard closes the scope: retained `Scope` clones no longer enable -caching and late work cannot publish into the request memo. -`DialCache::enable_in` and `DialCache::disable_in` derive nested scopes that -share the outer request memo. `Scope::outside()` is the pass-through scope of -work that runs on behalf of no request. - -`use_case` registers a typed use case once per instance and returns a -`UseCase` handle; `get_or_load` runs one inline `Operation` without -registration. Both snapshot the static policy and the source budget before any -asynchronous work. Values come back as `Arc`: shared by reference, treat -them as immutable. Both `UseCase` and `Operation` can be cloned -without requiring `T: Clone`. Use case `watermark` is reserved. `coalescing_state` -reports actual process leaders, followers and the oldest leader age. - -Sources are `Fn(Scope, Args) -> Future>`. They may -run again later for served-hit shadow validation, so they must be reusable. -Source errors surface as `Error::Source(Arc)`; every coalesced -caller receives the same shared instance, so `Arc::ptr_eq` identifies one -failure. A source deadline returns `Error::FallbackTimeout` and does not cancel -the source. Dropping the future returned by `get` never cancels the execution: -sources, publications and other callers keep their contracts. - -`Identity::new`, `KeySpec::new` and `DialCache::invalidate` accept strings, -integers and floats, including shared references such as `&u64`. Their `IntoKeyId` conversion -preserves string IDs and exact decimal integers; floats use JavaScript number -spelling, including negative zero and exponents (`f32` is promoted to `f64`). -For custom displayable IDs, pass -`id.to_string()` or implement `IntoKeyId`. `KeySpec::arg` also accepts all -primitive integer and float types, preserving exact integer text and promoting -`f32` to `f64`, as well as borrowed inputs such as `&String` and `&u64`. -`normalize_args` applies the shared scalar spelling to secondary dimensions and -orders names by UTF-16 code units so the same identity produces the same Redis -key in every language. -Use the same namespace, key dimensions, codecs and policy across languages when -sharing entries. - -## Configuration and effects - -`Policy` holds the static leaves: whole-second TTLs (`local_ttl_sec`, -`remote_ttl_sec`), serving ramps, `request_local`, `coalesce`, -`stale_on_error_max_age_sec`, `remote_read_timeout_ms` and `shadow`. -`Policy::from_json` accepts the TypeScript JSON-shaped configuration. -`Policy::enabled(ttl)` and `Policy::disabled()` are the two static helpers. -A `policy_provider` returns a sparse `RuntimePolicy` overlay once per enabled -invocation; `Ok(None)` inherits, present leaves replace operation leaves, and -invalid leaves have the narrower consequences defined in Quint (an invalid TTL -or ramp disables only that layer; an invalid flag or read deadline bypasses -caching for the call). - -Convert a typed policy with `RuntimePolicy::from(policy)` or `policy.into()`. -Omitted leaves remain absent, including `request_local` and `coalesce`, so a -TTL-only overlay preserves the operation's flags. `Policy::to_json` uses the -same sparse representation; library defaults are applied during resolution. - -Defaults are namespace `urn`, local capacity 10,000, 50 ms remote reads, -60,000 ms source calls (`SourceBudget::Default`; `SourceBudget::Unbounded` -disables the deadline), sharing enabled, and shadow capacity one. Policy omits -all cache layers by default. Invalid constructor configuration returns -`ConfigError` from `build`; invalid operation configuration is returned before -execution. - -`Remote` supplies atomic primary snapshots, complete client-stamped frame -writes and surfaced invalidation errors. Writes are one native `SET`; -invalidation dispatches `EVALSHA` and retries once with `EVAL`. No value write -creates or extends a watermark. `DialCache::invalidate` affects shared remote -authority; other processes' local entries and already acquired snapshots -retain the documented lifetime rules. Inline operations with an explicit namespace -can invalidate that same entity through `invalidate_identity`: +`RedisAdapter` wraps a caller-owned `redis` connection: `ConnectionManager`, +`MultiplexedConnection` or a cluster connection. The application owns +connection setup, timeouts, retries and concurrency limits; the +[Redis example](https://github.com/lan17/DialCache/blob/main/rust/examples/redis.rs) +shows one configuration. On a cluster, tracked reads go to the slot primary. + +Key layout, wire format and the invalidation script are shared with TypeScript +and Go, so all three read and write each other's entries. Custom `Remote` +implementations follow the +[custom-client contract](https://lan17.github.io/DialCache/redis.html#custom-client-contract). + +Mark a use case `.tracked(true)` to make its Redis results invalidatable by +entity. After committing a source mutation: ```rust,ignore -let identity = Identity::new("user", 42, "displayName") - .namespace("tenant-b").tracked(true); -// After committing the source mutation: +cache.invalidate("user", 42, 0).await?; +``` + +`invalidate` records an entity-wide cutoff (a watermark) in Redis. Tracked +results of that entity written before the cutoff stop being served, across +every use case and argument variant. The last argument is a future buffer in +milliseconds; zero adds none. + +`invalidate` changes Redis only: request-local and process-local entries in +every process keep their normal lifetimes. An inline operation in another +namespace is invalidated through `invalidate_identity`, which ignores the +identity's `tracked` flag: + +```rust,ignore +let identity = Identity::new("user", 42, "displayName").namespace("tenant-b"); cache.invalidate_identity(identity, 0).await?; ``` -An empty namespace inherits the cache instance's namespace. Invalidation covers -all tracked use cases and argument variants for that namespace, entity type and -ID; the identity's `tracked` flag does not restrict the maintenance operation. - -`Clock` separates wall time from elapsed time; `Runtime` supplies detached -task admission and timers. The defaults are `SystemClock` (aligned to the -process-wide millisecond grid used by local expiry) and `TokioRuntime`, which -captures the current tokio runtime handle when the cache is built: building -outside a tokio context is a `ConfigError`, and `TokioRuntime::from_handle` -selects a runtime explicitly. The runtime needs its time driver. -`SystemClock::with_sources` runs the same grid alignment over caller-supplied -time sources. The `test-util` feature ships `testing::TestExecutor`, a -deterministic single-threaded executor with a virtual clock that runs the -cache's detached work to quiescence on demand and delivers timers only when a -test advances time; the conformance harness is built on it, and its -local-clock replay builds `SystemClock` over the virtual clock. - -Detached work is registered RAII-style: if a runtime drops a leader task -before it settles (the cache outlived a shut-down runtime), its flight is -unregistered and its followers receive an error instead of waiting forever. -Local storage hands removed entries back to the cache so a value's destructor -never runs while a cache lock is held. - -## Values, codecs and observability - -`JsonCodec` (serde_json) is the default; `Codec` is asynchronous, and -`FromSync` adapts a synchronous `SyncCodec`. The TypeScript `undefined` -sentinel decodes as JSON `null`, so `Option` destinations read it as -`None`. Compression defaults to a 4,096-byte threshold and zstd level 3; -`disable_compression` stores payloads raw while reads still accept compressed -entries. The wire contract requires interoperable decompression, not identical -compressed bytes. The async engine dispatches payload compression at 64 KiB or -larger (and compression at levels 10–22 once the configured threshold is met) -and every zstd decompression through `Runtime::spawn_blocking`. Small raw -payloads remain inline; synchronous protocol helpers remain synchronous. - -The default CPU executor is shared across cache instances: two worker threads -and two queued jobs. Admission never waits for queue space. Saturation or worker -creation failure fails open: a read falls through to the source, and a failed -compression skips the write while preserving the source result. Custom runtimes -may supply their own bounded CPU executor. `StepRuntime` queues these jobs on its -controlled executor for deterministic tests. A shadow job retains its capacity -until its admitted CPU work finishes or is discarded, including after a deadline -or async runtime shutdown. Codecs supplied by the application still choose their -own scheduling; `FromSync` does not offload application serialization. - -For remote writes, the engine calls `Codec::encode_owned` with the existing -`Arc`. Its default implementation delegates to `encode(&T)`, so existing -codecs work unchanged. Override `encode_owned` to send a non-`Clone` value to a -background CPU job without copying it or changing the cached value type. -`decode` already receives an owned `Payload`. Application codecs own admission -and the lifetime of jobs they start; those jobs do not inherit the library's -shadow-capacity token. Default JSON encoding/decoding and `FromSync` remain -synchronous, so large values can occupy an async worker. - -`Observer` receives every public diagnostic as a typed `Event`. Shadow -validation exists only to be observed, so a job is admitted only when the -observer opts in through `observes_shadow_outcomes`; the bundled exporters do. -`Logger` receives structured `LogEvent`s and defaults to the `log` facade. -Default stale-recovery decode warnings omit error text that could contain cached -values; JSON errors retain their category and line/column location. A custom -`Logger` can inspect the original error when application-controlled details are -needed. -Mismatch logging is opt-in, confirmed, bounded, and previews values through -the operation's `preview` (JSON for serde values). Default JSON previews retain -only an 8 KiB prefix while checking the entire serialization for errors. Preview -callbacks run through the bounded CPU executor after confirmation; they may run -on a worker thread. Queue rejection omits value previews but still logs the -confirmed mismatch. Diagnostic work holds shadow capacity until it finishes, -including after runtime shutdown. Observer and logger failures never change a -cache, source or maintenance result. - -`MetricKind` maps every event to the metric names, labels and values shared -with the TypeScript and Go exporters. `PrometheusObserver` (feature -`prometheus`) registers the nineteen collectors on a `prometheus::Registry` -under an optional prefix; clone one observer for every instance that exports -to the same registry, because the `prometheus` crate cannot hand back an -existing collector and a second registration of the same names is a -`PrometheusError::Conflict`. `DatadogObserver` sends the same metrics through -a caller-supplied `DogStatsdClient`, with `DatadogOptions` choosing histogram -or distribution and a namespace. - -`RedisAdapter` (feature `redis`) implements `Remote` over the `redis` crate -for `ConnectionManager`, `MultiplexedConnection` and cluster connections, -routing tracked reads to slot primaries and sharing the invalidation script -and frame codec with the other ports. The `redis_integration` test replays -every invalidation vector against real Redis, Valkey and Cluster servers, -and reads/writes shared entries with the production TypeScript adapter in both -directions. Both ports construct keys independently, including numeric rounding -boundaries; the payload tests cover JSON, binary escaping, compression and -invalidation by either language. The tests are `#[ignore]`d, and -`make integration-rust` runs them where Docker is available. - -## Validation and reproducing a trace - -Use the repository [Make targets](https://github.com/lan17/DialCache/blob/main/Makefile) from its root. CI pins Rust -1.98.1, Go 1.27.1, Node 24 and pnpm 10.33.0. Rust conformance tests use the -shared Node replay coordinator for command mappings and assertions; the crate -itself has no Node dependency. +An empty namespace inherits the instance's. See +[Targeted invalidation](https://lan17.github.io/DialCache/invalidation.html) +and [Redis and Valkey](https://lan17.github.io/DialCache/redis.html). + +## Values, codecs and compression + +`JsonCodec` (serde_json) is the default. `Codec` is asynchronous; `FromSync` +adapts a synchronous `SyncCodec`. TypeScript's `undefined` sentinel decodes as +JSON `null`, so an `Option` reads it as `None`. For remote writes the engine +calls `Codec::encode_owned` with the `Arc`; the default delegates to +`encode(&T)`, and overriding it hands a non-`Clone` value to a background job +without copying. + +Compression is on by default. `disable_compression` writes raw while reads +still accept compressed entries; the wire contract requires interoperable +decompression, not identical bytes. + +Heavy work stays off the async threads. Payloads of 64 KiB or more, any +compression at levels 10 to 22, and all decompression run through +`Runtime::spawn_blocking` on a bounded CPU executor shared by every instance: +two workers, two queued jobs. + +Admission never waits. When the executor is full, a read falls through to the +source and a write is skipped, with the source result preserved. Custom +runtimes may supply their own bounded executor. + +Application codecs schedule their own work. The default JSON codec and +`FromSync` run inline, so very large values can occupy an async worker. + +## Observability + +`Observer` receives every diagnostic as a typed `Event`, and `MetricKind` maps +each event to the metric names, labels and values TypeScript and Go export. +Shadow validation exists only to be observed: a shadow job runs only when the +observer returns true from `observes_shadow_outcomes`, as the bundled exporters +do. + +- `PrometheusObserver` (feature `prometheus`) registers its collectors on a + `prometheus::Registry` under an optional prefix. Clone one observer for every + instance exporting to the same registry; registering the same names twice is + `PrometheusError::Conflict`. +- `DatadogObserver` sends the same metrics through a caller-supplied + `DogStatsdClient`; `DatadogOptions` selects histogram or distribution and a + namespace. +- `Logger` receives structured `LogEvent`s and defaults to the `log` facade. + Stale-recovery decode warnings omit error text that could contain cached + values; a custom `Logger` can inspect the original error. +- Mismatch logging (`ShadowPolicy::log_mismatches`) is opt-in and bounded. A + confirmed mismatch is previewed through the use case's `preview` (JSON by + default, 8 KiB prefix) on the CPU executor; when the executor is full, the + mismatch is logged without previews. + +See [Observability](https://lan17.github.io/DialCache/observability.html) for +the metric catalog. + +## Runtime, clock and tests + +`Runtime` supplies detached task admission, timers and `spawn_blocking`; +`Clock` separates wall time from elapsed time. The defaults are `TokioRuntime`, +which captures the runtime handle current at `build`, and `SystemClock`, which +reads on the same whole-millisecond ticks local expiry uses. +`SystemClock::with_sources` applies that to caller-supplied time sources. + +With `test-util`, `testing::TestExecutor` runs detached work until nothing is +left to run and delivers timers only when the test advances time. The +conformance suite is built on it. + +Three guarantees hold under shutdown and eviction: + +- When the cache outlives its runtime and a shared source call is dropped + before it finishes, the callers waiting on it get an error instead of hanging. +- Local storage hands evicted entries back to the cache, so a value's + destructor never runs under a cache lock. +- A shadow job keeps its slot until its CPU work finishes or is discarded, + including after a deadline or runtime shutdown. + +## Differences from TypeScript and Go + +- An explicit `Scope` replaces TypeScript's implicit async context and Go's + `context.Context`. +- Values are `Arc`; sources return `Result`. +- The default shadow comparator is `PartialEq`, so `NaN` differs from itself + where the TypeScript default treats it as equal. Supply a comparator for such + domains. +- Local storage failures surface through the `LocalStore` trait rather than a + clock fault. +- Prometheus collectors are shared by cloning the observer, not by registering + the same names twice. + +## Validation + +The behavior contract is a set of +[Quint](https://github.com/informalsystems/quint) models under +[`formal/`](https://github.com/lan17/DialCache/blob/main/formal/README.md). +Every port replays the same generated histories, named regressions, fixed +scenarios and protocol vectors through its public API. + +Run the Make targets from the repository root. CI pins Rust 1.98.1, Go 1.27.1, +Node 24 and pnpm 10.33.0. The conformance tests use a shared Node replay +coordinator, but the crate itself has no Node dependency. ```sh -make check-rust # fmt, clippy, unit tests, protocol vectors, fixed scenarios and committed smoke histories -make integration-rust # Real Redis, Valkey and Cluster servers through Docker, plus every invalidation vector -make formal # Quint model checks, full corpus, then TypeScript, Go and Rust replay -make formal-rust # Complete prepared Rust replay of the generated corpus -make mutations-rust # Measure the Rust fault catalog (formal/rust-mutations.json) against the replay +make check-rust # fmt, clippy, unit tests, protocol vectors, fixed scenarios, committed smoke histories +make integration-rust # Redis, Valkey and Cluster servers through Docker, plus every invalidation vector +make formal-rust # Complete Rust replay of the generated corpus (after make formal-generate) +make mutations-rust # Require the harness to catch every fault in formal/rust-mutations.json +make formal # Quint checks, corpus generation, then TypeScript, Go and Rust replay ``` -`make mutations-rust` applies each catalogued single-site fault to an isolated -copy of the crate and requires the conformance harness to detect it -(`DIALCACHE_RUST_SUITE=generated` for the Quint-generated evidence, -`DIALCACHE_RUST_SUITE=fixed` for the fixed scenarios); see -[SEMANTIC-COVERAGE.md](https://github.com/lan17/DialCache/blob/main/formal/SEMANTIC-COVERAGE.md). +`cargo test --all-features --test conformance` replays the committed smoke +histories, fixed scenarios and protocol vectors. Environment selectors narrow +or extend that run: -Without overrides, `cargo test --all-features --test conformance` replays the -committed smoke histories, every fixed scenario and every protocol vector. -The same `DIALCACHE_*_TRACE_DIR` / `_TRACE_FILE` selectors as Go replay a -directory or one history; `DIALCACHE_WITNESS_EVIDENCE_DIR` binds the shared -witness evidence and `DIALCACHE_RUST_REPORT` names the JSONL assertion report -the completion checker consumes. Reports and traces are kept in -`.formal-traces/`. +- `DIALCACHE_*_TRACE_DIR` and `DIALCACHE_*_TRACE_FILE` replay a directory or + one history, under the same names Go uses. +- `DIALCACHE_WITNESS_EVIDENCE_DIR` binds the shared witness evidence for a + complete replay. +- `DIALCACHE_RUST_REPORT` names the JSONL assertion report the completion + checker reads. Reports and traces are kept in `.formal-traces/`. ```sh DIALCACHE_FEATURE_TRACE_FILE="$PWD/.formal-traces/regressions/shadow/confirmationPastFreshnessKeepsOriginalPayloadAndAgeTest.itf.json" \ cargo test --manifest-path rust/Cargo.toml --all-features --test conformance ``` -`tests/settlement_control.rs` is the no-settle control required of every -port: a driver that reports observations before the settlement drain fails -every behavior-driver-backed smoke history. - -## Adaptations - -- Explicit `Scope` handles replace the implicit async context of TypeScript - and the `context.Context` of Go. -- Values are `Arc`; sources return `Result`. -- The default shadow comparator is `PartialEq`; `NaN` therefore differs from - itself where the TypeScript default treats it as equal. -- Local storage failures are exposed through the `LocalStore` trait rather - than a clock fault. -- Prometheus collectors are shared by cloning the observer rather than by - registering the same names twice. -- The behavior driver checks source/write causality after every command. - Its test runtime carries driver-owned invocation identities across detached - tasks, independently attributing each write to its actual source callback. -- The core replay, the exporters and the Redis adapter follow their Go - counterparts; real-server integration is a separate lane, as in the other - ports, and not part of the completion claim (see `formal/profiles.json`). +The [formal guide](https://github.com/lan17/DialCache/blob/main/formal/README.md#generating-and-replaying-behavior), +the [walkthrough](https://github.com/lan17/DialCache/blob/main/formal/WALKTHROUGH.md#run-this-example) +and the [fault catalog](https://github.com/lan17/DialCache/blob/main/formal/SEMANTIC-COVERAGE.md) +cover reproducing a trace, mutation measurement and what each lane certifies. +These are finite checks of the documented contract, not proof over every input +or schedule. Real-server integration is a separate lane, as in the other ports, +and not part of the completion claim (see `formal/profiles.json`).