diff --git a/README.md b/README.md index 0b6cdb7c..7a0ed644 100644 --- a/README.md +++ b/README.md @@ -389,6 +389,8 @@ Or interactively at [http://localhost:3002/graphiql](http://localhost:3002/graph Detailed system design and data flow diagrams can be found in the [Architecture Overview](docs/architecture.md). The API specification is available in [OpenAPI 3.0 format](openapi.yaml) and is auto-published to GitHub Pages at https://miracle656.github.io/lens/openapi.json. +Operators: start with the [Operations runbook](docs/RUNBOOK.md) — what runs, how each external dependency degrades, how to check ingestion health via `/status` and `/metrics`, the known failure modes with their symptoms, and deploy/rollback procedures. + Operational docs: [HTTP metrics](docs/http-metrics.md), [ingest metrics](docs/ingest-metrics.md), [websocket backpressure](docs/websocket-backpressure.md), [health probes](docs/health.md), [GraphQL](docs/graphql.md). ## Examples diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md new file mode 100644 index 00000000..71db33bf --- /dev/null +++ b/docs/RUNBOOK.md @@ -0,0 +1,435 @@ +# Operations runbook + +How Lens actually behaves today: what runs, what breaks when it is unavailable, +how to tell, and what to do. **Every claim here is checkable against the code +at this commit** — the defining file or symbol is named next to each one. +Where the system does the wrong thing, this document says so; it does not +describe an aspirational system. + +--- + +## 1. Processes + +### The one process that matters + +Lens runs as a **single Node process** (`npm start` → `node dist/index.js`, +`CMD` in `Dockerfile`). Everything below runs inside it — there is no separate +worker, ingester or scheduler process: + +| Component | What it does | Where | +|---|---|---| +| HTTP API | REST routes, `/graphql` + GraphiQL, webhooks, x402 facilitator, admin/usage routes | `buildApp()` in `src/app.ts`, routes under `src/api/` and `src/routes/` | +| WebSocket server | `graphql-transport-ws` subscriptions on `/graphql`; local fan-out plus cross-instance fan-out over Redis pub/sub | `src/ws/fanout.ts`, `src/ws/` | +| Venue ingesters | One infinite poll loop per (venue × enabled network) for **SDEX**, **Horizon AMM**, **Soroswap**, **Aquarius** | registry `src/ingest/venues/index.ts`, started in `main()` (`src/index.ts`) via `startVenueAdapter` (`src/ingest/venues/runner.ts`) | +| Snapshot ingester | Appends one `price_snapshots` row per active pair at every minute boundary, per network | `src/ingesters/snapshot.ts` | +| BullMQ workers | `aggregate-refresh` (one queue/worker per network), `snapshot-retention` (hourly), `price_points` partition maintenance (hourly) — **in the same process** | `src/jobs/aggregateRefresh.ts`, `src/jobs/snapshotRetention.ts`, `src/jobs/pricePointsPartitionJob.ts` | +| Webhook dispatcher | Threshold alerts, delivered inline after each ingested price write | `src/webhookDispatcher.ts`, `src/alerts/delivery.ts` | +| Schema auto-migration | `prisma db push --accept-data-loss` runs **at every boot, before the API listens** | `execSync(...)` in `main()` (`src/index.ts`) | +| Middleware | Network selector, API-key auth, per-key/IP rate limits, the per-instance global request ceiling (`GLOBAL_RATE_LIMIT_MAX`, sheds with 503), x402 payment gate, cache policy, HTTP metrics | `src/app.ts` registration order | +| Health probes | `GET /live` (liveness) and `GET /ready` (readiness: boot state + Postgres/Redis), plus `GET /snapshot`, a cached whole-network freshness object | `src/health.ts`, `src/routes/health.ts`, `src/routes/snapshot.ts` — doc: [`docs/health.md`](health.md) | +| Metrics | Prometheus registry exposed on `GET /metrics` | `src/metrics.ts`, route in `src/app.ts` | + +**Startup order and failure behaviour** (all in `main()`, `src/index.ts`): + +1. `prisma db push --accept-data-loss` — failure throws → `fatal startup error`, exit 1. +2. `redis.connect()` — **soft**: on failure logs `redis unavailable, continuing without cache` and continues. +3. `pgPool.connect()` — **hard**: rejection → `fatal startup error`, exit 1. +4. Load persisted pairs (`loadPersistedPairs()`). +5. `buildApp()` + `app.listen()` — failure (e.g. port in use) → exit 1. +6. `fanOutManager.initialize()` — soft, warn `websocket fan-out init skipped`. +7. Aggregate-refresh worker per enabled network — soft, warn + `aggregate refresh worker skipped (redis unavailable)`. **No fallback is installed for this one.** +8. Snapshot retention: prune once, then schedule hourly — on Redis failure warn + `falling back to an in-process hourly prune` and install an in-process `setInterval`. +9. Price-points partition maintenance: same pattern, same in-process hourly fallback. +10. Ingesters started per network through `restartIngester`: a crash is logged + (`ingester crashed; restarting in 10s`) and restarted after 10 s, isolated + per `(venue, network)` — one dying ingester does not affect the others. +11. `markBootComplete()` — **last**, and only once every ingester above is + running: until that line `GET /ready` answers 503, keeping a starting + instance out of the load balancer (`src/index.ts`, `src/health.ts`). + +### Supporting services (`docker-compose.yml`) + +| Service | Notes | +|---|---| +| `lens` | Port 3002; `healthcheck: curl -f http://localhost:3002/status`, every 10 s, `start_period: 20s` | +| `postgres` | `postgres:15-alpine`, volume `pgdata`, healthcheck `pg_isready` | +| `redis` | `redis:7-alpine`, `--appendonly yes` (AOF persistence), volume `redisdata` | + +### Optional processes (not started by the server) + +`examples/oracle-relay`, `examples/alert-bot`, `examples/mcp-server` (stdio), +and `scripts/*` (smoke, seed, key issuance) are run manually via their npm +scripts. Nothing in `src/index.ts` depends on them. + +### GitHub Actions + +| Workflow | Trigger | What it does | +|---|---|---| +| `ci.yml` | every PR and push to `main` | `npm ci` → `prisma generate` → `tsc --noEmit` → `npm run build` → `npm test` | +| `release.yml` | push to `main` | changesets: version PR or tag (tag-only release, no npm publish) | +| `openapi-publish.yml` | push to `main` | regenerates `openapi.json`, publishes to `gh-pages` | +| `db-backup.yml` | daily 03:00 UTC | `pg_dump` per network, age-encrypted, 14-day artifact (see §5) | +| `load.yml` | daily 04:00 UTC | k6 load test, p95 < 80 ms @ 5k RPS — **currently red on `main`, see §6** | + +--- + +## 2. External dependencies and what degrades without them + +### PostgreSQL — `DATABASE_URL` + +**Used for:** everything durable — `price_points`, `price_snapshots`, +`pool_snapshots`, `price_aggregates`, `indexer_state`, pairs, webhooks, +API keys/usage. It is the source of truth; Redis is only a cache +("Redis is a cache and a job broker, not a source of truth" — comment in `src/index.ts`). + +| | Behaviour | +|---|---| +| Down at **boot** | `pgPool.connect()` rejects → `fatal startup error` → **process exits 1**. Nothing serves. | +| Down at **runtime** | Queries throw → affected routes return 5xx. `/status` itself queries the DB, so it fails too. | +| Where you see it | `GET /status` refused/500 **and `GET /ready` → 503 with `checks.postgres.status: "down"`** (the probe also asserts the schema exists via `to_regclass('public.indexer_state')`, so a reachable-but-unmigrated database reports not-ready too); `GET /metrics` → `http_requests_total{status_class="5xx"}` rising, `db_query_duration_seconds` tail; pino error logs. | +| Recovery | Restore the nightly dump (`docs/backup-restore.md`, produced by `db-backup.yml`), or re-ingest from chain — Lens data is re-derivable by ingesting again (`docs/DEPLOY_MAINNET.md`). | + +### Redis — `REDIS_URL` + +**Used for:** the price cache (`getCachedPrice`/`setCachedPrice`, TTL +`PRICE_CACHE_TTL`, default 10 s), BullMQ queues/workers, cross-instance +WebSocket pub/sub. + +| | Behaviour | +|---|---| +| Down at **boot** | Soft: `redis unavailable, continuing without cache`. The API still serves — **every cache read falls back to Postgres** (`getCachedPrice` catches and returns `null`), writes are best-effort (swallowed). Per-worker effects: **aggregate refresh is skipped with no fallback**; retention and partition maintenance fall back to hourly in-process timers; ws cross-instance fan-out is skipped. | +| Down at **runtime** | The client rejects commands immediately (`enableOfflineQueue: false`, `src/redis.ts` — no request hangs waiting for a connection), reconnects with backoff capped at 60 s, and logs at most **one `redis connection error` per minute**. Reads keep falling back to Postgres. BullMQ's own connections (separate ioredis instances) log `[ioredis] Unhandled error event` while away and pause the queue-driven jobs. | +| What breaks concretely | • No cache: every `/price` request recomputes from Postgres — correct, just slower.
• **`price_aggregates` freeze** if the aggregate-refresh worker is not running (skipped at boot, or paused while BullMQ cannot reach Redis). Consumers: `/price/:assetA/:assetB/history`, `/screener`, and the GraphQL aggregate queries — their buckets stop advancing while `/price/:assetA/:assetB` keeps serving live values (it computes VWAP from `price_points` on a cache miss — `getAggregatedPrice`, `src/aggregator/vwap.ts`).
• Multi-instance WebSocket fan-out stops: clients still receive events published by *their own* instance (the local `priceEmitter` subscription always works — `src/ws/fanout.ts`), but not events from other instances.
• **Readiness fails**: `GET /ready` PINGs Redis and reports `down` (503) while Redis is away — the instance leaves rotation even though reads still work from Postgres (`READINESS_REDIS_CHECK=false` excludes Redis from readiness). | +| Where you see it | `X-Cache: MISS` on every `/price` response (`src/api/rest.ts`); `/screener` and `/price/.../history` stop moving; `aggregate refresh worker skipped (redis unavailable)` / `redis connection error` in logs; `GET /ready` → 503 with `checks.redis.status: "down"`; `ws_messages_sent_total` low on one instance while ingestion metrics are healthy. **`GET /metrics` has no Redis gauge** — the cache signal is the `X-Cache` response header, the readiness signal is `/ready`. | +| Note on #345 | The issue title says Redis is "noticed once and never again"; today's code reconnects forever with capped backoff and throttled logging (`src/redis.ts`). The genuinely unfixed part is the **missing aggregate-refresh fallback** (see §4). | + +### Stellar Horizon — `HORIZON_URL` / `HORIZON_URL_` + +**Used for:** SDEX trade ingestion and Horizon AMM snapshots. + +| | Behaviour | +|---|---| +| Down / rate-limited | No 429-specific handling anywhere in the ingest path (#348). SDEX fetch failures are caught inside `fetchSdexTrades` (`src/ingest/venues/sdex.ts`), logged as `error fetching trades`, and the cycle continues having ingested nothing. Failures that escape an adapter fail the cycle and trigger exponential backoff 10 s → 60 s cap: `venue adapter cycle failed; retrying` (`src/ingest/venues/runner.ts`). | +| Where you see it | `GET /status` → `ingestLagSeconds` grows; `GET /metrics` → `trades_ingested_total` flat, `time() - last_trade_timestamp` growing (staleness alert recipe: `docs/ingest-metrics.md`). | +| Recovery | Automatic: backoff retries forever; ingestion resumes from the persisted cursor in `indexer_state` (`setIndexerCursor`), so no trades are skipped once Horizon recovers — only the outage window is missing. | + +### Soroban RPC, Soroswap factory, Aquarius API — `RPC_URL*`, `SOROSWAP_FACTORY_ADDRESS*`, `AQUARIUS_*` + +**Used for:** AMM pool reserves/snapshots (Soroswap), Aquarius pool discovery, +Reflector oracle reads (on demand, not a background loop). + +| | Behaviour | +|---|---| +| Bad/missing config | `config.ts` warns naming the env var and disables the feature; the adapter logs `venue adapter is disabled — ingester not started` and returns. | +| Down at runtime | Cycle failures back off like above. Aquarius is quieter: `if (!res.ok) return []` (`src/ingest/venues/aquarius.ts`) — **a failing API returns empty results with no error**. | +| Where you see it | `GET /metrics` → `amm_snapshots_total` flat; `/pools` pool `timestamp` goes stale; the `ammPrice` half of `/price/:assetA/:assetB` stops updating while `sdexPrice` keeps moving. | + +### x402 facilitator — `X402_FACILITATOR_URL` (default `https://x402.org/facilitator`) + +**Used for:** verifying payments on the gated routes (`/price`, `/pools`, +`/candles` — `GATED_ROUTES` in `src/middleware/x402.ts`). Only active when +`ORACLE_PAYMENT_ADDRESS` is set; otherwise the plugin logs +`[oracle] ORACLE_PAYMENT_ADDRESS not set — x402 gating disabled` and the gated +routes are **free**. + +| | Behaviour | +|---|---| +| Unreachable / verify throws | Caught in the preHandler → **402** `{error: "Payment verification failed", reason: ...}` (fails closed). | +| Settle fails | Settlement is async after the response is sent; failure is only logged `[oracle] x402 settle error` — the request was already served. | +| Where you see it | Paying clients suddenly receive 402s; `GET /metrics` → `x402_payments_received_total` stops incrementing; `webhook`-style pino logs from the `oracle` logger. | + +### Subscriber webhook endpoints (outbound HTTP) + +**Used for:** threshold alerts registered via `POST /webhooks`. + +| | Behaviour | +|---|---| +| Failing subscriber | 10 s timeout, **3 attempts** with delays 1 s, 2 s (`retryDelayMs`, `src/alerts/delivery.ts`), **no jitter** (#341). A 4xx stops immediately; final failure is only logged `webhook delivery failed after retries`. Nothing is persisted or re-queued — **the event is gone** (#342). | +| Where you see it | The `webhook` pino logger only. **`GET /metrics` exports no webhook series at all** — silence on `/metrics` does not mean delivery worked. | + +### Keep-alive monitor (production on Render free tier) + +`docs/DEPLOY_MAINNET.md`: Render free services sleep after ~15 min idle, so an +UptimeRobot monitor pings `GET /status` every 5 minutes. + +| | Behaviour | +|---|---| +| Monitor stops / misconfigured | The service sleeps → `GET /status` connection refused **and ingestion sleeps with it** — staleness follows. | +| Where you see it | `/status` refused while nothing else is wrong; UptimeRobot's own alerting. | + +### Environment variables + +There is **no central env validation** (#338): `WATCHED_PAIRS` format throws at +startup (`parseWatchedPairs`, `src/config.ts` → fatal exit 1), everything else +is read directly from `process.env` at first use. Common misconfigurations and +their exact symptoms: + +| Misconfiguration | Symptom | Signal | +|---|---|---| +| Bad `DATABASE_URL` | Process never starts | log `fatal startup error`, exit 1 | +| Bad `REDIS_URL` | Runs, uncached | boot warn `redis unavailable, continuing without cache`; `X-Cache: MISS` forever on `/price` | +| `ORACLE_PAYMENT_ADDRESS` unset | Payment gating silently off — **gated data served free** | log `x402 gating disabled`; no 402s from `/price` | +| `ADMIN_TOKEN` unset | Admin surface unusable (by design) | every `POST/DELETE /admin/keys` and `/admin/usage*` → **401** `code: admin_unauthorized` (`isAdminAuthorized`, `src/api/auth.ts`), counted in `admin_auth_failures_total` on `GET /metrics` | +| `REQUIRE_API_KEY=false` | API-key auth off entirely | log `[auth] REQUIRE_API_KEY=false — API key authentication disabled` | +| `GLOBAL_RATE_LIMIT_MAX` invalid (not a positive integer) | Process never starts | `buildApp` throws → `fatal startup error`, exit 1. Default ceiling when unset: 1000 req/min per instance, with expensive route templates shed at 80% and health probes exempt (`src/middleware/globalRateLimit.ts`) | +| `READINESS_REDIS_CHECK=false` | Redis dropped from readiness | `GET /ready` no longer fails when Redis is down | +| Bad `WATCHED_PAIRS` | Process never starts | log `fatal startup error` with the parse error, exit 1 | + +--- + +## 3. Telling whether ingestion is healthy + +### `GET /status` — liveness + lag, per network + +Public (no API key), rate-limit exempt — `/status`, `/live` and `/ready` are +the `PROBE_PATHS` allow-list in `src/app.ts`, and all three are excluded from +`http_requests_total` so probes neither throttle nor pollute the traffic +metrics. +Answers for one network: pass `?network=mainnet` or the `x-network` header. + +```json +{ + "ok": true, + "network": "testnet", + "watchedPairs": ["XLM:native/USDC:GBBD47..."], + "lastIndexedLedger": 53842917, + "lastProcessedAt": "2026-10-19T12:00:00.000Z", + "ingestLagSeconds": 4 +} +``` + +Read it honestly (implementation: `GET /status` handler, `src/api/rest.ts`): + +- **`ok: true` only means the DB query worked.** The handler returns `ok: true` + unconditionally — it does **not** assert freshness. +- **`ingestLagSeconds`** = seconds since the most recent `indexer_state` write + for that network. It is `null` until the first write; `lastIndexedLedger` is + `null` until the SDEX ingester has recorded a ledger. +- **Caveat, checkable in code:** only the **SDEX and AMM** ingesters write + `indexer_state` (`setIndexerCursor` in `src/ingest/venues/sdex.ts` and + `src/ingest/venues/amm.ts`), and only when a cycle actually ingested + records — a network with no trades in the window shows a growing lag **even + when everything is healthy**. Cross-check `/metrics` (below) before treating + lag alone as an incident. The 1-minute snapshot ingester is a separate loop + and never touches `indexer_state`. +- The compose healthcheck (`docker-compose.yml`) and the Render keep-alive + monitor both poll this endpoint. + +### `GET /live` and `GET /ready` — the platform probes + +Both public, rate-limit exempt, and excluded from `http_requests_total`; +rationale and semantics live in [`docs/health.md`](health.md). + +- **`GET /live`** — `200 {ok, uptimeSeconds}` as long as the event loop is + turning; it touches neither Postgres nor Redis. That is deliberate: a + liveness probe that fails during a dependency outage would have the + orchestrator restart every healthy instance at once. +- **`GET /ready`** — `200` only after **boot completes** (`markBootComplete()` + is the last line of `main()`) **and** every checked dependency is up; + otherwise `503` carrying `booted`, `checks.postgres`, `checks.redis` and + `checkedAt`. Postgres is probed for reachability *and* migration state + (`to_regclass('public.indexer_state')`), Redis with `PING` (2 s probe + deadline, 2 s answer cache — `READINESS_TIMEOUT_MS` / `READINESS_CACHE_MS`; + `READINESS_REDIS_CHECK=false` drops Redis from readiness). Reasons in the + body are fixed phrases, never raw errors — the endpoint is public; the real + error is logged instead. + +### `GET /snapshot` — one-shot freshness view + +`GET /snapshot?network=…` returns the whole network's shape — venues, TVL, 24h +volume, top pairs, peg readings and data freshness — in one cached, timestamped +object (`src/routes/snapshot.ts`, cached in Redis via `getCachedSnapshot`). + +### `GET /metrics` — the freshness and error signals + +Public Prometheus exposition (`src/metrics.ts`, route in `src/app.ts`). + +**Ingestion health:** + +| Signal | Meaning / recipe | +|---|---| +| `rate(trades_ingested_total{network}[5m])` | Flat = SDEX/AMM ingestion stopped for those pairs | +| `time() - last_trade_timestamp{pair,network}` | Per-pair staleness — the canonical alert; worked example in `docs/ingest-metrics.md` (labels are `pair × network`, bounded by config) | +| `rate(amm_snapshots_total{network}[5m])` | AMM venue snapshots; flat = Soroswap/Aquarius/Horizon-AMM side stalled | +| `rate(price_snapshots_total{network}[5m])` | Snapshot ingester; healthy ≈ (active pairs) rows/min per network | +| `ws_clients_total`, `ws_messages_sent_total`, `ws_messages_dropped_total`, `ws_clients_dropped_total`, `ws_buffered_bytes` | Subscriber count, delivery rate, backpressure drops, clients killed and in-memory buffer depth (labels are bounded: `pair` is the literal key, `other`, or aggregate — see `docs/websocket-backpressure.md`) | + +**API health:** + +| Signal | Meaning | +|---|---| +| `http_requests_total{method,route,status_class}` | Rate and 5xx ratio; `route` is the route *template*, `status_class` is `2xx/4xx/5xx` — both deliberately bounded | +| `http_request_duration_seconds` | Latency histogram with Lens-specific buckets (5 ms … 10 s); p95 recipe in `README.md` and `docs/http-metrics.md` | +| `db_query_duration_seconds` | Postgres query latency (includes pool wait — the timer wraps `pgPool.query`) | +| `api_key_auth_failures_total{reason}`, `admin_auth_failures_total` | Auth failures by bounded reason (`missing`/`malformed`/`unknown`/`revoked`/`expired`; admin counted separately) | +| `rate_limit_rejections_total{scope}`, `global_shed_requests_total` | Per-key/IP limiter 429s (`key`/`ip`) and per-instance ceiling sheds (503) | +| `x402_payments_received_total`, `price_requests_total` | Payment/request counters | + +**Known blind spots:** no gauge for Redis state, no gauge for the pg pool +(see #339 in §4), no queue-lag metric, no webhook-delivery metric. + +### Triage sequence + +1. **Is the process answering?** `curl -s localhost:3002/live`, then `/ready`: + - *`/live` connection refused / sleeping* → process dead or host asleep (see §5 deploys, §2 keep-alive). + - *`/live` 200 but `/ready` 503* → boot not finished, or a checked dependency is down — the `checks` body says which (Postgres down/unmigrated, Redis down; §2). + - *`/status` 500 / refused* → PostgreSQL (§2). +2. **Is data fresh?** `ingestLagSeconds` + `time() - last_trade_timestamp` (§3 caveat above). +3. **Which layer?** grep the pino logs for the exact lines named in this + document: `fatal startup error`, `error fetching trades`, + `venue adapter cycle failed; retrying`, `ingester crashed; restarting in 10s`, + `redis connection error`, `aggregate refresh worker skipped`, + `webhook delivery failed after retries`, `[oracle] x402 settle error`. +4. **Is it only the aggregates/cache?** `/price/:assetA/:assetB` fresh but `/screener` or + `/price/.../history` frozen → aggregate-refresh worker / Redis (§2). +5. **Full deployed check:** `SMOKE_MAX_AGE_SECONDS=120 npm run smoke -- https://` + (`scripts/deployed-smoke.mjs`) — asserts `/ready` + `/live` (it fails hard if + `/ready` 404s or reports `ok: false`), then `/status`, `/price`, and a live + `graphql-transport-ws` subscription. On an x402-gated installation an + unauthenticated `/price` call returns 402 and the smoke check **fails by + design**; it never bypasses payment (README). + +--- + +## 4. Known failure modes in this batch + +Each row states what the code does **today**, not what it should do, and names +the endpoint, metric, or log line that shows it. Issue numbers are the open +tracking issues for the batch this runbook was filed in. + +| # | Failure mode | What happens today (code) | Where you see it | +|---|---|---|---| +| 1 | **Unhandled rejection kills the process** (#344) | There is **no** `unhandledRejection`/`uncaughtException` handler anywhere in `src/`; Node's default is to exit — with no application log line for the cause. | Process gone: `GET /status` connection refused, compose healthcheck fails, `http_requests_total` flatlines while the container restarts. The cause is only inferable from the last log line before the exit. | +| 2 | **Shutdown is not graceful** (#347) | There is **no** `SIGTERM`/`SIGINT` handler anywhere in `src/`; the process exits immediately. In-flight requests are cut and WebSocket clients are dropped **without a close frame**. | During any deploy/restart: WS clients see an abnormal closure (no `1000/1001` close frame), `ws_clients_total` disappears, `http_requests_total` stops mid-request. | +| 3 | **Horizon rate limiting is not handled** (#348) | No 429 branch exists in the ingest path. SDEX 429s surface as a caught error → log `error fetching trades` → `null`, and the loop retries on the normal poll interval without backoff or accounting. | `GET /status` `ingestLagSeconds` grows; `GET /metrics` `trades_ingested_total` flat + stale `last_trade_timestamp`; repeating `error fetching trades` or `venue adapter cycle failed; retrying` in the logs. | +| 4 | **A single ingester cycle has no deadline** (#351) | `startVenueAdapter` runs `while (true)` and awaits every watched pair **sequentially** with no cycle-level timeout (`src/ingest/venues/runner.ts`); there is no per-cycle upper bound. | The whole network's ingestion quietly spreads/stalls: `last_trade_timestamp` ages and `/status.ingestLagSeconds` grows **while no `venue adapter cycle failed` line appears** — no error, just lag. | +| 5 | **Webhook retries have no jitter** (#341) | Delay is exactly `2^(attempt-1) × 1000` ms (`retryDelayMs`, `src/alerts/delivery.ts`) — subscribers triggered together retry together. | Bursts of `webhook delivery failed after retries` (logger `webhook`) arriving in lockstep after any upstream blip; `GET /metrics` has **no** webhook series to graph this. | +| 6 | **A webhook that fails 3 times disappears** (#342) | After 3 attempts (or any 4xx on the first) the failure is logged and dropped — no persistence, no queue, no dead-letter. | The subscriber silently never receives the threshold event; the only trace is the `webhook` log line. Nothing on `/metrics` moves. | +| 7 | **DB pool is default-sized and unmeasured** (#339) | `new Pool({connectionString})` with pg defaults (max 10 connections); instrumentation is only the `db_query_duration_seconds` histogram — no pool gauge exists (`src/db.ts`, `src/metrics.ts`). | Under saturation both `db_query_duration_seconds` and `http_request_duration_seconds` shift right together (the timer includes pool wait); `GET /metrics` offers **no waiter/pool gauge** to confirm it. | +| 8 | **Env vars are not centrally validated** (#338) | Only `WATCHED_PAIRS` parsing throws at startup; every other variable is consumed lazily at first use. | Misconfiguration surfaces at first use — see the symptom table in §2 (bad `REDIS_URL` → `X-Cache: MISS` forever; unset `ORACLE_PAYMENT_ADDRESS` → gating silently off; unset `ADMIN_TOKEN` → admin routes always 401). | +| 9 | **Redis away at boot: aggregate refresh has no fallback** (the unfixed half of #345) | Retention and partition maintenance fall back to hourly in-process timers when their BullMQ schedule fails at boot; the **aggregate-refresh worker does not** — it is skipped and never retried for the life of the process (`src/index.ts`). | `aggregate refresh worker skipped (redis unavailable)` at boot, then: `/screener` and `/price/:assetA/:assetB/history` (and GraphQL aggregate queries) freeze while `/price/:assetA/:assetB` keeps serving live prices from `price_points`. | + +--- + +## 5. Deploy, rollback, and what a deploy interrupts + +### How a deploy happens + +- **Production** (`docs/DEPLOY_MAINNET.md`): Render web service built from this + repo/image, env vars in the Render dashboard only, Neon Postgres, managed + Redis, UptimeRobot keep-alive on `/status`. +- **Self-hosted / local**: `docker compose up -d --build` (image built by the + multi-stage `Dockerfile`, `CMD npm start`). +- In both cases the boot sequence in §1 applies — **including the automatic + `prisma db push --accept-data-loss`**. + +### What a deploy interrupts + +1. **The schema may change destructively, automatically.** Step 1 of boot runs + `prisma db push --accept-data-loss` (`src/index.ts`) before the API listens: + any schema drift is applied on start, and the flag means a change that drops + a column/table is applied **without prompting**. This is the one + irreversible part of any deploy.2. **The API is down until `listen` returns, and not *ready* until boot ends.** + Single instance ⇒ no rolling deploy; compose allows 20 s (`start_period`) + before healthchecks fail. `GET /ready` stays 503 from process start until + `markBootComplete()` — gate rollouts on `/ready`; `/live` alone only proves + the event loop turns. +3. **No graceful shutdown** (#347, §4): in-flight requests are cut and WS + clients are dropped without a close frame. +4. **Ingestion pauses for the restart, then resumes from persisted cursors.** + Cursor state lives in `indexer_state`, writes are idempotent + (`skipDuplicates` in `upsertPricePoints`, `ON CONFLICT DO NOTHING` on + snapshots), so replay after restart is safe; the gap is the trades that + happened while the process was down (visible as missing `price_points` for + that window and a lag spike in `/status`). +5. **In-memory state is lost:** rate-limit windows (429 counters reset), WS + client list, the ingesters' last-price map (the webhook previous-price + baseline restarts, so the first post-restart tick compares against itself). + Redis, being external, keeps the price cache warm across a deploy. +6. **Maintenance jobs re-run safely.** Prune and partition maintenance run once + at boot regardless, the hourly schedules are re-registered, and BullMQ jobs + are at-least-once — safe here because every job is an upsert/prune. + +### How to roll back + +1. **App code:** redeploy the previous known-good revision — + Render: roll back to the previous deploy in the dashboard; + self-hosted: `git checkout && docker compose up -d --build`. +2. **Schema:** `prisma db push` is forward-only; rolling back the code does not + roll back the schema. If the previous revision needs the old schema, run + `npx prisma db push` **from the old checkout** against the database to + converge it back. ⚠️ Like the boot-time push, it will accept data loss for + whatever the diff drops — **take the backup path (step 3) first** if the + diff in question removes data. +3. **Data (if a bad deploy or its auto-migration destroyed rows):** restore the + nightly dump per [`docs/backup-restore.md`](backup-restore.md) — dumps are + produced daily at 03:00 UTC by `.github/workflows/db-backup.yml`, + age-encrypted, 14-day retention. Alternatively re-ingest: Lens data is + re-derivable from chain (`docs/DEPLOY_MAINNET.md`), bounded only by how far + back the cursors point. +4. **Config:** env vars live in the host (Render dashboard / `.env`), not in + the repo. Keep the previous env set alongside the previous image — a code + rollback with the new env is only half a rollback. +5. **Verify the rollback:** + ```bash + curl -s https:///ready # 200 {"ok":true,...} + curl -s https:///status # ok:true, lag small + curl -s -o /dev/null -w '%{http_code}\n' https:///price/XLM/USDC + # 200 (ungated) or 402 (x402-gated) — not 5xx + curl -s https:///metrics | grep -E 'trades_ingested_total|last_trade_timestamp' + SMOKE_MAX_AGE_SECONDS=120 npm run smoke -- https:// + ``` + +--- + +## 6. CI status on `main` + +The PR gate is `.github/workflows/ci.yml`; run it locally before opening a PR +(identical to CONTRIBUTING's "Before opening a PR"): + +```bash +npm ci +node node_modules/prisma/build/index.js generate +npx tsc --noEmit +npm run build +npm test --if-present +``` + +**`Load test (prices)` is failing on `main` and is a known, unrelated red.** +It runs daily at 04:00 UTC (`load.yml`, k6 threshold p95 < 80 ms @ 5k RPS) and +has been failing continuously — it is **not** your change that broke it. Every +other check (CI build/typecheck/tests, release, OpenAPI publish, nightly +backup) should be green; treat those as the gate. + +--- + +## Quick reference + +```bash +# Health +curl -s localhost:3002/ready | jq # readiness: boot + dependencies +curl -s localhost:3002/live | jq # liveness: event loop only +curl -s localhost:3002/status | jq # ingestion lag +curl -s localhost:3002/status?network=mainnet # the other network +curl -s localhost:3002/metrics | grep -E 'trades_ingested_total|last_trade_timestamp' + +# Logs & restart (compose) +docker compose logs -f lens | grep -E 'fatal|failed|crashed|redis' +docker compose restart lens + +# Deployed smoke check (see README for the x402 caveat) +SMOKE_MAX_AGE_SECONDS=120 npm run smoke -- https:// + +# Tests +npm test # full vitest suite +npx vitest run src/__tests__/status.test.ts # one file +``` + +Related docs: [`architecture.md`](architecture.md) · +[`http-metrics.md`](http-metrics.md) (label reference, alert rules) · +[`ingest-metrics.md`](ingest-metrics.md) (staleness alert, cardinality) · +[`backup-restore.md`](backup-restore.md) (nightly dump, restore) · +[`DEPLOY_MAINNET.md`](DEPLOY_MAINNET.md) (Render/Neon production shape) · +[`DUAL_NETWORK.md`](DUAL_NETWORK.md) (testnet + mainnet side-by-side)