Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ NODEJS_HELPERS=0
# PREPROCESS_TICKER_ENABLED=false
# 给 /api/v1/preprocess-tasks/tick 加共享密钥(可选)。设置后,调用方需在
# x-preprocess-tick-secret 请求头携带该值,否则返回 401;不设置则该端点开放。
# PREPROCESS_TICK_SECRET=
# PREPROCESS_TICK_SECRET=
# 跨 replica 共享缓存 handler 的调试日志(可选)。设为 true 打印 get/set/失效活动。
# 注:handler 用 DATABASE_URL 读写、用 DIRECT_URL(若设置)做 LISTEN/NOTIFY。
# CACHE_HANDLER_DEBUG=false
67 changes: 45 additions & 22 deletions docs/multi-replica.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,13 @@ pod) and set `PREPROCESS_TICKER_ENABLED=false` on the rest.

## 2. Database connections

Each replica opens its own Prisma connection pool. With N replicas the total
connection count is roughly `N × pool_size`. Make sure your PostgreSQL
`max_connections` covers that, or put a pooler such as PgBouncer (transaction
mode) in front of the database.
Each replica opens its own Prisma connection pool, plus the cache handler (§4)
adds its own small pool (max 4) and one persistent `LISTEN` client — roughly
`+5` connections per replica on top of Prisma. With N replicas the total is
about `N × (prisma_pool + 5)`. Make sure your PostgreSQL `max_connections`
covers that, or put a pooler such as PgBouncer (transaction mode) in front of
the database — note the handler's `LISTEN` connection must use a direct
(non-transaction-pooled) URL via `DIRECT_URL`, see §4.

## 3. Session revocation latency

Expand All @@ -73,26 +76,46 @@ TTL is 60s, so a sign-out or session revocation propagates to every replica
within ~60s. Lower it further in `server/auth/index.ts` (`session.cookieCache.maxAge`)
if you need faster propagation.

## 4. Public data cache (current behavior)
## 4. Public data cache (shared across replicas)

The public read paths (gallery listings, album nav, public config) are cached
with Next.js' Data Cache and invalidated on admin writes via `revalidateTag`.
That cache is **per-replica in-memory** by default, so a `revalidateTag` call only
busts the cache on the replica that handled the write. Other replicas pick up the
change when their cache entry's safety-net TTL expires:

- gallery listings: ~60s
- album nav / public config: ~1h (admin writes still bust them instantly on the
writing replica; the TTL only bounds cross-replica propagation)

If you need instant cross-replica consistency for admin changes, a shared
PostgreSQL-backed cache handler is the planned next step (it routes the Data Cache
through Postgres + `LISTEN/NOTIFY` so an invalidation on one replica is seen by all).
with Next.js' Data Cache (`unstable_cache` in `server/lib/cache.ts`) and
invalidated on admin writes via `revalidateTag`. To make that invalidation work
across replicas, the Data Cache is routed through a PostgreSQL-backed cache
handler (`server/lib/pg-cache-handler.cjs`, wired in `next.config.mjs`):

- Cached values and per-tag invalidation timestamps live in Postgres
(`next_cache_entries` / `next_cache_tags`, created automatically), shared by all
replicas. Each replica also keeps a small bounded in-memory L1 for hot reads.
- An admin write calls `revalidateTag` on one replica, which records the
invalidation in Postgres and broadcasts it via `LISTEN/NOTIFY`; every replica's
next read of an affected entry recomputes — so admin changes are visible across
all replicas effectively immediately.
- The tag state is **Postgres-authoritative**: each replica refreshes it from
Postgres on a short interval and on (re)connect, so a missed `NOTIFY` (e.g. a
replica restart, or a connection behind a transaction-mode pooler that can't
`LISTEN`) self-heals within ~1s rather than requiring a restart.
- The per-entry safety-net TTLs still apply (gallery ~60s, album/config ~1h) as a
backstop and to bound the background preprocess ticker's `variants_ready` gap.

Relevant environment:

- The handler uses `DATABASE_URL` for reads/writes and `DIRECT_URL` (when set) for
the `LISTEN` connection, since `LISTEN` needs a persistent session that a
transaction-mode pooler would drop. If only `DATABASE_URL` is set and it points
at such a pooler, instant `NOTIFY` is skipped and propagation falls back to the
~1s Postgres refresh — still correct, just not instant.
- Set `CACHE_HANDLER_DEBUG=true` to log handler get/set/invalidation activity.

The long-unused-entry sweep is driven by the same single cron as the tick
endpoint (§1), so it does not run as a per-replica timer.

## 5. In-flight task cancellation

Cancelling a running preprocess/metadata task signals the replica that owns the
run. If the cancel request lands on a different replica, it is still recorded, and
the running replica stops at the next lease checkpoint (within the lease window).
For rare admin actions this lag is acceptable; a fully cross-replica cancel signal
is a planned follow-up.
Cancelling a running preprocess/metadata task is cross-replica safe. The cancel
endpoint flips the run's row to `status = 'cancelling'` in the database (an atomic
UPDATE visible to all replicas), and the replica actually running the task
re-reads that status at its per-image checkpoint and stops — so cancellation takes
effect within roughly one item's processing time, regardless of which replica
received the cancel request. (If the cancel lands on the same replica that's
running the task, an in-process abort also interrupts it immediately.)
4 changes: 4 additions & 0 deletions hono/preprocess-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
tickPreprocessTaskRuns,
} from '~/server/tasks/image-preprocess-service'
import { ADMIN_TASK_KEY_PREPROCESS_IMAGES, normalizePreprocessTaskScope } from '~/types/admin-tasks'
import { cleanupStaleCacheEntries } from '~/server/lib/cache-cleanup'
import { ok } from '~/hono/_lib/response'
import { badRequest, conflict, notFound, serverError, unauthorized } from '~/hono/_lib/errors'

Expand Down Expand Up @@ -161,6 +162,9 @@ app.post('/tick', async (c) => {
assertTickAuthorized(c.req.header(TICK_SECRET_HEADER))
try {
const data = await tickPreprocessTaskRuns()
// Piggyback the shared cache-entry sweep on the same single cron that drives
// the preprocess tick, so it never runs as a per-replica timer. Best-effort.
await cleanupStaleCacheEntries()
return ok(c, data)
} catch (error) {
console.error('Preprocess task tick failed:', error)
Expand Down
7 changes: 7 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ let nextConfig = {
},
},
serverExternalPackages: ['pg'],
// Route the Data Cache (unstable_cache in server/lib/cache.ts) through a
// PostgreSQL-backed handler so tag invalidation propagates across replicas
// (see docs/multi-replica.md). cacheMaxMemorySize: 0 disables Next's own
// per-instance in-memory cache in front of the handler — the handler keeps
// its own bounded module-level L1 and Postgres is the shared source of truth.
cacheHandler: new URL('./server/lib/pg-cache-handler.cjs', import.meta.url).pathname,
cacheMaxMemorySize: 0,
typescript: {
ignoreBuildErrors: true,
},
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"next-pwa": "5.6.0",
"next-qrcode": "2.5.1",
"next-themes": "0.4.6",
"pg": "^8.22.0",
"react": "19.2.5",
"react-day-picker": "9.14.0",
"react-dom": "19.2.5",
Expand Down Expand Up @@ -116,6 +117,7 @@
"@next/eslint-plugin-next": "16.2.4",
"@tailwindcss/postcss": "4.2.2",
"@types/node": "24.12.4",
"@types/pg": "^8.20.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/rss": "0.0.32",
Expand Down
Loading
Loading