> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useduro.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Multi-Tenancy & Caching

> Lazy per-mode clients, connection pooling, and version-bump cache invalidation that never needs to SCAN.

The [data model](/architecture/data-model) page covered *why* there are three schemas. This page covers *how* the runtime keeps them isolated and fast.

## Lazy, pooled, per-mode clients

`Database` exposes `core` and `data(mode)`. The data clients are instantiated **lazily** — a process that only ever serves test traffic never opens a connection to the live schema — and each runs with a small `connection_limit` so a fleet of PM2 workers doesn't exhaust Postgres.

```mermaid theme={null}
flowchart TB
    DBROOT["Database.fromBaseUrl(url)"] --> CORE["core() — lazy"]
    DBROOT --> TEST["data('test') — lazy<br/>search_path = sandbox"]
    DBROOT --> LIVE["data('live') — lazy<br/>search_path = live"]
    CORE & TEST & LIVE --> POOL["pgbouncer / pooled<br/>connection_limit=5"]
    POOL --> PG[("Postgres 16")]
```

The mode-to-schema mapping is the only place the words "sandbox" and "live" appear. Everything above it speaks in `Mode` (`'test' | 'live'`); the client translates that to a `search_path`.

## Caching without the footguns

Reads are cached in Redis through a `CacheStore` with three properties that matter:

<CardGroup cols={3}>
  <Card title="Single-flight" icon="users-slash">
    `remember(key, ttl, fn)` collapses a stampede: if a thousand requests miss the same key at once, the loader runs once and the rest await it. No cache-miss storm.
  </Card>

  <Card title="Null-cacheable" icon="circle-half-stroke">
    Values are wrapped in an envelope `{ v }` so a legitimately-`null` result is cacheable and distinguishable from a miss. No re-querying for things that don't exist.
  </Card>

  <Card title="Version-bump invalidation" icon="hashtag">
    A resource's list cache lives under a namespace whose version is bumped on write. Invalidation is a single counter increment — never a `SCAN` over Redis keyspace.
  </Card>
</CardGroup>

### Why version-bump beats key-deletion

The naive approach to "invalidate all the list pages for this tenant's customers" is to `SCAN` for matching keys and delete them. `SCAN` is O(keyspace) and blocks. Instead, every list key embeds a version:

```mermaid theme={null}
flowchart LR
    W["write a customer"] --> BUMP["INCR version:tenant:customers"]
    R["read customer list"] --> V["GET version:tenant:customers → 7"]
    V --> KEY["key = tenant:customers:v7:cursor:limit"]
    BUMP -. next read sees v8 .-> MISS["v7 keys are now<br/>unreachable — they expire on TTL"]
```

Bumping the version makes every old key instantly unreachable. The stale entries aren't deleted; they simply age out on their TTL while no one reads them. Invalidation is O(1) and lock-free.

## The cache is mode-aware too

Cache keys are namespaced by `mode:tenantId:resource`. Test-mode writes can never invalidate live-mode reads, and vice versa — the same isolation guarantee as the schemas, carried into Redis.

Next: the [Billing Engine](/billing/subscription-lifecycle) — the subscription state machine that everything orbits.
