Skip to main content
This page is the operator map for a self-hosted 3ngram. It explains what runs where, what to check after a deploy, and which operational guarantees are intentionally not promised before v1.0. Operations for the managed 3ngram platform are handled by the maintainers and are not covered in this repository.

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_URL is required.
  • One-shot (node dist/once.js <job>): one pass with no Redis, then the pool is closed and it exits 0 (success), 1 (failure) or 2 (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_user role.
The public API reference describes the REST contract. The MCP tool reference describes each tool and its required scope.

Rate limits

RATE_LIMIT_STORE picks where limiter buckets live:
  • redis keeps them in Redis and requires REDIS_URL.
  • postgres keeps them in the rate_limits table (migration 0042) on DATABASE_URL, so a production server needs no Redis.
  • memory keeps them per process. In production it is an explicit opt-in for a single replica and logs a warning at boot.
Unset, the server uses Redis when 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.
The /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:
  1. Apply database migration 0027_cimd_clients.sql before starting the new server.
  2. Exercise one retained DCR client and one public CIMD+PKCE client through authorization, code exchange, refresh, and /mcp.
  3. Run the official-client contract tests for both legacy and 2026-07-28.
  4. Verify authorization-server metadata advertises client_id_metadata_document_supported: true while retaining registration_endpoint.
  5. Monitor OAuth invalid_client/metadata-fetch failures, protocol negotiation, and the bounded mcp.header_requests status labels during the soak.
Do not remove DCR during rollback or cleanup. Before CIMD grants are issued, the server can roll back without data cleanup; after issuance, retain a CIMD-capable binary so existing grants continue to resolve. Materialized client rows are foreign-key/display state and must not be treated as fetched-metadata authority.

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, verify RATE_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=false until 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=false until its eval sets the thresholds and proposals can be reviewed through review_proposals; with it off the scheduler is removed and its jobs no-op.