Runtime topology
A 3ngram deployment is four portable pieces:
Everything runs on stock Postgres and Redis, so deploy to whatever you already
operate: the bundled Compose stack on a single host, a PaaS such as Railway or
Fly.io, managed Postgres such as Neon or Supabase, or any Docker-compatible
runtime. The Self-host quickstart starts Postgres, Redis, and the
server with one command.
Worker modes
Run one worker mode per database; both at once double-runs every pass.- Resident (default,
node dist/index.js): BullMQ on Redis schedules and retries every job.REDIS_URLis required. - One-shot (
node dist/once.js <job>): one pass with no Redis, then the pool is closed and it exits0(success),1(failure) or2(unknown or missing job). For cron-style schedulers such as Azure Container Apps jobs.
Set the scheduler’s retry limit to 3 (it replaces BullMQ’s attempts) and each
execution timeout below the job’s cadence as the overlap guard: advisory locks
are not safe on a transaction-mode pooler such as Neon’s. Passes are idempotent.
session-sweep drains its closers in the same execution, one at a time after
the sweep, deduped by (run, epoch). A failed closer is not retried in-process:
its row backs off and the next sweep tick retries it, and the execution exits
1. With SESSION_CLOSER_ENABLED=false it exits 0 without sweeping.
forgetting-pass walks every tenant once a day. A tenant that has not opted in to judgment is skipped after one read of its opt-in. For the others it stages archive, supersede and conflict proposals for review (Forgetting pass). It never changes a memory itself. One tenant’s failure does not stop the others, but the execution exits 1. Without a judgment gateway (JEV_API_KEY and JEV_MODEL beside the LLM gateway pair) it is a no-op. With FORGETTING_PASS_ENABLED=false it exits 0 without running. On the resident worker it shares the queue, which runs one job at a time, so a long pass delays the next consolidation, sweep or closer job until it finishes.
Deploy verification
After a server deploy, verify the health and OAuth discovery surfaces against your own base URL:Auth and access
3ngram is its own OAuth 2.1 authorization server and resource server.- MCP clients use Client ID Metadata Documents or dynamic registration fallback, PKCE, and OAuth bearer tokens.
- Scripts, CLI, and SDK integrations use API keys through
X-API-Key. - All user-owned database access is tenant-scoped through RLS and the runtime
app_userrole.
Rate limits
RATE_LIMIT_STORE picks where limiter buckets live:
rediskeeps them in Redis and requiresREDIS_URL.postgreskeeps them in therate_limitstable (migration 0042) onDATABASE_URL, so a production server needs no Redis.memorykeeps them per process. In production it is an explicit opt-in for a single replica and logs a warning at boot.
REDIS_URL is set and in-memory buckets
outside production. In production an unset store without REDIS_URL fails
validation at boot; it never falls back to memory.
Current limit dimensions are:
- per-user on
/mcp; - per-IP on login and OAuth authorization-server endpoints;
- per API key/source on
/api/v1.
/oauth/token progressive-delay counter (failed exchanges per client)
follows RATE_LIMIT_STORE too, so Redis and Postgres share it across replicas.
If the store is unavailable, rate limiting fails open so the service keeps serving.
Operators should treat repeated rate-limit: store unavailable warnings as a
degraded-protection incident.
Caching
3ngram does not use an application cache for memory data in v1. Postgres is the source of truth; Redis is not a memory cache. The MCP tool and prompt definitions are a separate, deployment-versioned catalog:2026-07-28 responses mark those
tenant-independent lists public-cacheable for one hour.
MCP 2026 compatibility rollout
Keep the migration on a dedicated compatibility track and promote it only after a staging soak:- Apply database migration
0027_cimd_clients.sqlbefore starting the new server. - Exercise one retained DCR client and one public CIMD+PKCE client through
authorization, code exchange, refresh, and
/mcp. - Run the official-client contract tests for both legacy and
2026-07-28. - Verify authorization-server metadata advertises
client_id_metadata_document_supported: truewhile retainingregistration_endpoint. - Monitor OAuth
invalid_client/metadata-fetch failures, protocol negotiation, and the boundedmcp.header_requestsstatus labels during the soak.
Scaling
The server is stateless across requests, so it can run as multiple replicas behind a load balancer once shared-state dependencies are healthy. Before scaling replicas, verifyRATE_LIMIT_STORE is redis or postgres (not
memory) and Postgres connection pressure is acceptable. Redis is needed only
for the resident worker and RATE_LIMIT_STORE=redis.
Database scaling is your Postgres provider’s operation. Background workers use
BullMQ, ship as their own image (ghcr.io/b3dmar/3ngram-worker), and are part
of both Compose stacks (docker-compose.yml, compose.selfhost.yml). The
worker validates its env as role worker, so it does not need the OAuth
signing keys (OAUTH_JWKS).
Recovery
Use the smallest recovery action that matches the failure:
After recovery, rerun the deploy verification checks and one authenticated read
path before declaring the incident closed.
Current gaps
- Metrics counters are wired, but dashboards and alert policies are not complete.
- The session closer ships on the worker but stays
SESSION_CLOSER_ENABLED=falseuntil its commitment-recall validation bar is measured (see Session continuity); with it off the sweep scheduler is removed and closer jobs no-op, while consolidation, surfacing, and OAuth-client GC always run. - The forgetting pass ships on the worker but stays
FORGETTING_PASS_ENABLED=falseuntil its eval sets the thresholds and proposals can be reviewed throughreview_proposals; with it off the scheduler is removed and its jobs no-op.