drizzle-team / drizzle-team/drizzle-orm
Upstash cache: per-table composite-key Sets grow unboundedly when no mutation fires on those tables
- Dominant language
- TypeScript
- Stars
- 35.8k
- Forks
- 1.6k
- Avg merge
- 2d 7h
- Merged PRs (30d)
- 4
Description
## Bug Report
### Summary
In the Upstash cache implementation (`drizzle-orm/src/cache/upstash/cache.ts`), each `put()` call does a `SADD` on a per-table Redis Set (key pattern `__CTS__`) to track which composite-key hashes belong to that table:
```ts
for (const table of tables) {
pipeline.sadd(this.addTablePrefix(table), compositeKey);
}
```
These Sets are only removed inside `onMutate` via `SUNION` + `DEL`. When no mutation (`INSERT`/`UPDATE`/`DELETE`) fires on a given table, its `__CTS__` Set is never cleaned up. Every subsequent SELECT that touches that table appends a new member to the Set. Because no TTL or field-level expiry is applied to the Set itself or its members, the Set grows without bound.
### Steps to reproduce
```ts
import { upstashCache } from 'drizzle-orm/cache/upstash';
import { drizzle } from 'drizzle-orm/...';
import * as schema from './schema';
const db = drizzle(client, {
schema,
cache: upstashCache({ url: UPSTASH_URL, token: UPSTASH_TOKEN, global: true }),
});
// Read-only workload: no INSERT/UPDATE/DELETE ever fires on `users`.
for (let i = 0; i < 10_000; i++) {
await db.select().from(schema.users).where(eq(schema.users.id, i));
}
// At this point:
// SCARD __CTS__users => up to 10 000 entries (one per distinct composite key seen)
// The Set keeps growing with every new SELECT, and is never trimmed.
```
### Root cause
`put()` (cache.ts) appends `compositeKey` to `this.addTablePrefix(table)` on every cache write but never calls `EXPIRE` / `EXPIREAT` on the Set key itself, and there is no `SREM` path outside of `onMutate`.
`onMutate()` invokes the `onMutateScript` Lua script, which performs:
```lua
local compositeTableNames = redis.call('SUNION', unpack(tables))
-- ... deletes composite hashes ...
redis.call('DEL', unpack(keysToDelete)) -- also deletes the __CTS__ Set
```
For tables that are **only ever read** (e.g. reference/lookup tables, audit logs written by a separate service, or any table in a read-only replica scenario) that `DEL` never fires, so the Set accumulates indefinitely.
### Impact
- **Memory**: each Set member is a string of the form `__CT__table1,table2,...`; high-read workloads on static tables can push Sets into the tens or hundreds of thousands of entries.
- **Latency spike on first mutation**: when a mutation eventually does fire, `SUNION` over a very large Set causes a latency spike proportional to cardinality before the `DEL` can execute.
- **Upstash cost**: Upstash bills on commands and bandwidth; unbounded `SADD` on hot read paths inflates both.
### Expected behaviour
The `__CTS__` Set should be bounded. Options:
1. **`EXPIRE` on the Set itself** — call `EXPIRE __CTS__` inside `put()` alongside the existing `hexpire` calls. Because `EXPIRE` on a Redis Set replaces any existing TTL, this keeps the Set alive as long as queries keep arriving while still guaranteeing eventual GC when reads stop.
2. **`SREM` on field expiry** — hook into the `hexpire` completion to remove stale composite keys from the Set (harder without keyspace notifications).
3. **Bounded Set via scored members** — use a sorted set (`ZADD` with timestamp scores) and prune old entries with `ZREMRANGEBYSCORE`.
Option 1 is the minimal, lowest-risk fix: a single additional `pipeline.expire(this.addTablePrefix(table), ttlSeconds)` after the `sadd` call in `put()`.
### Environment
- `drizzle-orm`: latest `main` (the `__CTS__` / `compositeTableSetPrefix` implementation)
- `@upstash/redis`: any
- Affected file: `drizzle-orm/src/cache/upstash/cache.ts`
Contributor guide
Assessment
This issue has not been assessed yet.