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

# Redis Caching

> The cache key model — generation prefixes, invalidation stamps, and the separate keyspace for counters and locks

# Redis Caching

`packages/core/src/redis/` — 8 source files, 7 test files, consumed through the
`@cona/core/redis` subpath (46 imports).

Two keyspaces coexist and they behave differently. Getting them confused is the main
hazard here.

## Two keyspaces

```mermaid theme={null}
flowchart LR
    subgraph Cached["Cache keyspace — versioned"]
        Gen["v{version}: generation prefix"]
        Env["REDIS_ENV prefix"]
        Keys["REDIS_DYNAMIC_KEYS<br/>36 key builders"]
        Gen --> Keys
        Env --> Keys
    end

    subgraph Raw["Counter keyspace — unversioned"]
        Atomic["atomic.ts<br/>rate limits · JTI · security counters"]
        NoPrefix["stored as-is<br/>no REDIS_ENV, no version"]
        Atomic --> NoPrefix
    end

    subgraph Stamps["Stamp keyspace — unversioned"]
        Scopes["REDIS_STAMP_SCOPES<br/>cacheStampKey()"]
        Note["never carry v{version}:"]
        Scopes --> Note
    end

    Redis[("Redis")]
    Keys --> Redis
    NoPrefix --> Redis
    Note --> Redis

    classDef boundary fill:#f8fafc,stroke:#94a3b8,color:#334155

    classDef data fill:#fde8e8,stroke:#c53b3b,color:#111827
    classDef pkg fill:#e8eafd,stroke:#4967E6,color:#111827
    class Redis,Keys,NoPrefix,Note data
    class Gen,Env,Atomic,Scopes pkg
    class Cached,Raw,Stamps boundary
```

`atomic.ts:22` and `:338` both state it explicitly: rate-limit and JTI keys are stored
without the `REDIS_ENV` prefix.

## Generation-based invalidation

Every cache key carries a `v{version}:` prefix. Bumping the version moves reads to a fresh
keyspace and lets the previous generation expire through its own TTLs.

<Warning>
  **`FLUSHDB` is banned** (CONA-1090). The generation prefix exists precisely so nobody needs it — a
  flush in a shared Redis takes out every other service's keys too.
</Warning>

Two ways to roll a generation (`redis/index.ts:60-101`):

| Mechanism               | Default | When to use                                                                    |
| ----------------------- | ------- | ------------------------------------------------------------------------------ |
| `DEFAULT_CACHE_VERSION` | `"1"`   | **preferred** — ships with the code, so every service moves on the same deploy |
| `REDIS_CACHE_VERSION`   | unset   | runtime override; takes precedence via `getExpectedCacheVersion()`             |

Bump `DEFAULT_CACHE_VERSION` when a cached payload's shape changes. The env override is
for rolling a generation without a deploy — but because it takes precedence, a service
that pins it will not follow a code-level bump.

## Stamps

A stamp names a group of caches that are always invalidated together, so one write
invalidates all of them in a single round trip.

Currently one scope (`constants.ts:13-20`):

| Scope           | Key                               | Covers                                                                  |
| --------------- | --------------------------------- | ----------------------------------------------------------------------- |
| `postingMatrix` | `posting_matrix:{organizationId}` | posting matrix rules **and** the GL dimension lists they are built from |

The comment explains the grouping: every write path invalidates both together via
`deletAllGlDimensionsCache`, so splitting them would add a round trip for no extra
precision.

Stamp keys are raw — they never carry the generation prefix, because a stamp that moved
with the generation could not invalidate the previous one.

## Files

| File               | Role                                                                                                                    |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `constants.ts`     | `REDIS_DYNAMIC_KEYS` (36 builders) and `REDIS_STAMP_SCOPES`                                                             |
| `index.ts`         | cache version resolution, public surface                                                                                |
| `atomic.ts`        | `setIfNotExists`, `incrementWithExpiry`, `reserveFixedWindow`, `extendCooldown`, `getWithTtl`, `getKeyTtl`, `deleteKey` |
| `stamp.ts`         | stamp read/write                                                                                                        |
| `stamped-reads.ts` | read-through with stamp validation                                                                                      |
| `observe.ts`       | cache generation reporting                                                                                              |
| `internal.ts`      | client wiring                                                                                                           |

## Atomic primitives

`atomic.ts` backs rate limiting and locking:

| Function                  | Used for                        |
| ------------------------- | ------------------------------- |
| `setIfNotExists`          | locks, one-shot guards          |
| `incrementWithExpiry`     | counters with a TTL             |
| `reserveFixedWindow`      | fixed-window rate limiting      |
| `extendCooldown`          | backoff after repeated failures |
| `getWithTtl`, `getKeyTtl` | inspect remaining window        |
| `deleteKey`               | reset a counter                 |

The portal's three rate limiters are built on these — PLZ brute-force (per access token),
download (per IP), and Shopify entry (per IP). See
[Portal](/architecture/portal-architecture).

## What is cached

36 dynamic key builders. The hot paths:

* `organizationDetails` — read on nearly every request
* `chartOfAccountsListOrderedAsc` — parameterised by organisation, reconcile-only flag,
  and classification filter
* `postingMatrixRules` — read once per dimension per document during accounting impact
  creation, which makes it the highest-volume cached read. See
  [Accounting Engine](/architecture/accounting-engine).

## Environment

| Variable              | Role                                                      |
| --------------------- | --------------------------------------------------------- |
| `REDIS_ENV`           | keyspace prefix separating environments in a shared Redis |
| `REDIS_CACHE_VERSION` | runtime generation override                               |
| `FLY_MACHINE_ID`      | included in cache generation reporting                    |

Local development runs `redis:7.4` from `docker-compose.yml` on host port **6380** —
deliberately not 6379, to avoid colliding with a system Redis.

## Notes

**Redis has an in-degree of 8 inside core** — `accounting_periods`, `general_ledger`,
`reconciliation`, `reconciliation-agent`, `services`, `statuses`, `tax_codes`, and `vat`
all cache.

**Cache generation is observable.** `observe.ts` and `reportCacheGeneration` emit the
active generation so a mismatched service is visible rather than silently serving stale
reads.

**Workers cache too.** `apps/temporal-workers/src/redis-logger-adapter.ts` imports
`@cona/core`, and the accounting queue depends on cached posting matrix rules.
