Skip to content
API reference

TypeScript is the reference implementation. Go and Rust are experimental.

Getting started ​

Documentation · Next: How DialCache works

Create one long-lived cache instance and define reusable readers once. A reader's source function remains authoritative: DialCache calls it whenever the active layers cannot supply a value. Caching is disabled outside an enabled request scope, and each operation must also opt into at least one layer.

Choose your language in the site selector. The behavioral explanation is shared; examples and native integration notes follow that choice.

Install ​

bash
npm install dialcache

The package provides ESM, CommonJS and TypeScript declarations. Supported Node.js versions are >=22.15.0 <23.0.0 || >=23.8.0. See the TypeScript guide for native API and serializer details.

bash
go get github.com/lan17/DialCache/go@latest

The module requires Go 1.25 or later. Pin the version selected by go get in your application's go.mod. See the Go guide for contexts, durations and module-version details.

For a published Rust release, run cargo add dialcache. Before the first crates.io publication, or for unreleased source, use a path dependency pointing at a repository checkout's rust/ directory:

toml
[dependencies]
dialcache = { path = "../DialCache/rust" }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }

Adjust the path to your checkout. The core crate requires Rust 1.85; the redis feature currently requires Rust 1.88. See the Rust guide for runtime, feature and value-ownership details.

The Python package is currently unpublished. Install from a repository checkout:

sh
python3 -m pip install './python[redis]'

Python 3.11 or later is required. Omit the Redis extra for local-only use. See the Python guide for asyncio and client ownership.

Wrap a reader ​

Start with request-local caching so no Redis server or expiration timer is needed. This example proves three observable outcomes:

  1. Two calls outside an enabled scope each invoke the source.
  2. Two same-key calls inside one enabled scope invoke the source once.
  3. A new request scope invokes the source again.

The displayed region comes from a native test that CI executes. Its surrounding file supplies imports and test setup; the source link opens the complete file.

typescript
const cache = new DialCache();
let sourceCalls = 0;
const lookup = cache.cached(async (_id: string) => ++sourceCalls, {
  keyType: "user",
  useCase: "requestScope",
  cacheKey: (id) => id,
  // Only request-local storage is enabled: shared layers stay off.
  defaultConfig: new DialCacheKeyConfig({ requestLocal: true }),
});

// Outside an enabled scope, each call reaches the source.
assert.equal(await lookup("42"), 1);
assert.equal(await lookup("42"), 2);

await cache.enable(async () => {
  assert.equal(await lookup("42"), 3);
  assert.equal(await lookup("42"), 3);
});
// A new request starts with an empty request-local cache.
await cache.enable(async () => {
  assert.equal(await lookup("42"), 4);
});
assert.equal(sourceCalls, 4);

Complete executable example

go
cache := dialcache.MustNew()
var sourceCalls atomic.Int64
lookup, err := dialcache.Cached(cache, dialcache.Operation[int64]{
	Identity: dialcache.Identity{KeyType: "user", UseCase: "requestScope"},
	// Only request-local storage is enabled: shared layers stay off.
	Policy: dialcache.Policy{RequestLocal: true},
}, func(id string) (dialcache.Identity, error) {
	return dialcache.Identity{ID: id}, nil
}, func(context.Context, string) (int64, error) {
	return sourceCalls.Add(1), nil
})
if err != nil {
	t.Fatal(err)
}

// An ordinary context does not enable caching.
for _, want := range []int64{1, 2} {
	got, err := lookup(context.Background(), "42")
	if err != nil || got != want {
		t.Fatalf("disabled lookup = %d, %v; want %d", got, err, want)
	}
}
ctx, done := cache.Enable(context.Background())
defer done()
for range 2 {
	got, err := lookup(ctx, "42")
	if err != nil || got != 3 {
		t.Fatalf("enabled lookup = %d, %v; want 3", got, err)
	}
}
done() // Closing the request discards its request-local cache.

next, closeNext := cache.Enable(context.Background())
defer closeNext()
got, err := lookup(next, "42")
if err != nil || got != 4 || sourceCalls.Load() != 4 {
	t.Fatalf("new request = %d, %v; source calls = %d; want 4", got, err, sourceCalls.Load())
}

Complete executable example

rust
let cache = DialCache::builder().build()?;
let source_calls = Arc::new(AtomicUsize::new(0));
let calls = source_calls.clone();
let lookup = cache
    .use_case::<u64, usize>("user", "requestScope")
    // Only request-local storage is enabled: shared layers stay off.
    .policy(Policy::default().request_local(true))
    .key(|id: &u64| KeySpec::new(id))
    .source(move |_scope, _id| {
        let value = calls.fetch_add(1, Ordering::SeqCst) + 1;
        async move { Ok(value) }
    })
    .register()?;

// Calls without an enabled scope go directly to the source.
assert_eq!(*lookup.get_uncached(42).await?, 1);
assert_eq!(*lookup.get_uncached(42).await?, 2);

let request = cache.enable_guard();
let first = lookup.get(request.scope(), 42).await?;
let again = lookup.get(request.scope(), 42).await?;
assert_eq!(*first, 3);
assert!(Arc::ptr_eq(&first, &again));
drop(request); // Drops the request cache; returned Arcs remain usable.

let next = cache.enable_guard();
assert_eq!(*lookup.get(next.scope(), 42).await?, 4);
assert_eq!(source_calls.load(Ordering::SeqCst), 4);

Complete executable example

python
cache = DialCache()
source_calls = 0

@cache.cached(
    key_type="user",
    use_case="requestScope",
    cache_key=lambda id: id,
    # Only request-local storage is enabled; shared layers stay off.
    default_config=Policy(request_local=True),
)
async def lookup(id):
    nonlocal source_calls
    source_calls += 1
    return source_calls

# Outside an enabled scope, every call reaches the source.
assert await lookup("42") == 1
assert await lookup("42") == 2
async with cache.enable():
    assert await lookup("42") == 3
    assert await lookup("42") == 3
# A new request starts with an empty memo.
async with cache.enable():
    assert await lookup("42") == 4
assert source_calls == 4

Complete executable example

The example enables only request-local storage. It has no TTL or capacity limit and disappears when the outer scope closes. Keep requests and their key counts bounded. Add a process-local TTL when reuse across requests is appropriate; the read model explains all three layer lifetimes.

Include every input that can change the result in the key, including tenant, locale or authorization dimensions. Keys and identity explains how result identity also determines shared work and invalidation groups.

Choose the enabled scope ​

Put one enabled scope around each read request. Nested enabled scopes reuse the outer request memo; a disabled region bypasses caching without deleting entries. A scope's lifetime ends explicitly, so detached work must not assume a retained scope keeps caching enabled forever.

dialcache.enable(async () => ...) propagates scope through Node's AsyncLocalStorage. Use dialcache.disable(() => ...) for nested uncached work. The outer callback settling closes its request-local memo. Registered readers return promises and preserve their source parameters.

ctx, done := cache.Enable(parent) returns the context to pass to cached readers. Call done() when the request ends, usually with defer done(). Use cache.Disable(ctx) for nested uncached work. Contexts retained after done() no longer enable caching.

cache.enable_guard() returns a guard; pass request.scope() to each reader. Dropping the guard closes the request memo, including retained scope clones. enable_in and disable_in derive nested scopes; Scope::outside() is the pass-through scope. Reader values are shared as Arc<T>.

Use async with cache.enable(): or with cache.enable(): around request reads. cache.disable() temporarily bypasses caching. The outer scope owns the memo; tasks inheriting its context pass through after it closes. Values are shared Python references and should be treated as immutable.

Disabling caching does not invalidate stored data. After a source mutation, freshness still depends on TTLs and invalidation policy. Treat reused in-memory values as immutable.

Keep a calculation inline ​

An inline operation uses the same cache path without registering a reusable reader. All call sites sharing an identity must agree on value meaning and serialization.

Use dialcache.getOrLoad(loader, options) with a direct key instead of the cacheKey selector. See the TypeScript API.

Use dialcache.GetOrLoad[T](ctx, cache, operation, loader). Operation[T] carries the identity, policy and codec. See the Go API.

Use cache.get_or_load(scope, operation, loader) with an Operation<T>. The result is still Arc<T>. See the Rust API.

Use await cache.get_or_load(loader, key=..., key_type=..., use_case=..., default_config=...) for an inline read, or await cache.aget(key, loader, ...) for a structured Key. See the Python API.

Introduce runtime policy ​

Keep stable defaults next to the reader and use a runtime provider for sparse overrides. Omitted fields inherit; explicit false and zero values disable their respective features. A provider runs once per enabled invocation before lookup, so keep it inexpensive and bound asynchronous work.

Configuration and rollout follows an executed example that inherits the local TTL through a sparse overlay, then explicitly disables both configured layers. A ramp selects a stable cohort of keys, not a percentage of requests. Policy changes do not cancel admitted work or evict existing entries.

Add shared caching when needed ​

Connect an application-owned Redis client and pass its native DialCache adapter. Add a remote TTL to participating readers; start remote serving at zero while checking configuration and observability.

Redis and Valkey covers adapters, serialization, Cluster routing and client ownership. Observability covers Prometheus and Datadog. For mutable data, read Targeted invalidation before enabling remote serving. Shadow validation compares Redis with the source during a rollout.