Skip to content

Commit e5a45fe

Browse files
authored
Merge pull request #119 from zeekman/feat/redis-bullmq
feat(redis-bullmq): Redis module, BullMQ queues, cache helpers, metrics
2 parents 7bf854a + 47bc18c commit e5a45fe

23 files changed

Lines changed: 1382 additions & 16 deletions

.env.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Copy this file to .env and fill in values.
2+
# .env is gitignored — never commit secrets.
3+
4+
# ── Redis ─────────────────────────────────────────────────────────────────────
5+
REDIS_HOST=127.0.0.1
6+
REDIS_PORT=6379
7+
# Minimum 32 random characters in production
8+
REDIS_PASSWORD=devpassword
9+
# Set to "true" and configure TLS certs for production managed Redis
10+
REDIS_TLS=false
11+
12+
# ── Application ───────────────────────────────────────────────────────────────
13+
NODE_ENV=development
14+
PORT=3000

.github/workflows/ci.yml

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,16 +54,32 @@ jobs:
5454
defaults:
5555
run:
5656
working-directory: backend
57+
58+
services:
59+
redis:
60+
image: redis:7-alpine
61+
ports:
62+
- 6379:6379
63+
options: >-
64+
--health-cmd "redis-cli ping"
65+
--health-interval 5s
66+
--health-timeout 3s
67+
--health-retries 5
68+
--health-start-period 5s
69+
70+
env:
71+
REDIS_HOST: 127.0.0.1
72+
REDIS_PORT: 6379
73+
NODE_ENV: test
74+
5775
steps:
5876
- uses: actions/checkout@v4
5977

6078
- uses: actions/setup-node@v4
6179
with:
6280
node-version: 22
63-
cache: npm
64-
cache-dependency-path: backend/package-lock.json
6581

66-
- run: npm ci
82+
- run: npm install
6783
- run: npm run lint
6884
- run: npm run build
6985
- run: npm test

backend/REDIS.md

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Redis Architecture — NiffyInsure Backend
2+
3+
## Overview
4+
5+
Redis underpins three operational concerns:
6+
7+
| Concern | Implementation | Fail behaviour |
8+
|---------|---------------|----------------|
9+
| Job queues | BullMQ (`claim-events`, `claim-payouts`) | **Fail closed** — job not enqueued returns error |
10+
| Wallet-auth nonces | `setNonce` / `consumeNonce` in `cache.ts` | **Fail closed** — auth rejected if Redis is down |
11+
| Rate limiting | `incrementRateLimit` in `cache.ts` | **Fail open** — request allowed, warning logged |
12+
| Response caching | `cacheGet` / `cacheSet` in `cache.ts` | **Degrade gracefully** — cache miss falls through to DB |
13+
14+
**Redis is never the authoritative store for financial data. Postgres is.**
15+
16+
---
17+
18+
## Key Naming Conventions
19+
20+
All keys are prefixed with `{NODE_ENV}:niffyinsure:` (set as `keyPrefix` in ioredis).
21+
22+
```
23+
{env}:niffyinsure:cache:policy:{holder}:{policy_id} — policy read cache (30 s TTL)
24+
{env}:niffyinsure:cache:claim:{claim_id} — claim read cache (10 s TTL)
25+
{env}:niffyinsure:nonce:{address} — wallet-auth nonce (5 min TTL)
26+
{env}:niffyinsure:ratelimit:{identifier} — rate-limit counter (60 s TTL)
27+
{env}:niffyinsure:bull:claim-events:* — BullMQ internal keys
28+
{env}:niffyinsure:bull:claim-payouts:* — BullMQ internal keys
29+
```
30+
31+
Segments:
32+
- `{env}``NODE_ENV` value (`development` | `staging` | `production`)
33+
- `niffyinsure` — service constant; prevents collisions in shared Redis
34+
- `{area}``cache` | `nonce` | `ratelimit` | `bull`
35+
- `{id}` — resource-specific identifier
36+
37+
---
38+
39+
## TTL Conventions
40+
41+
Defined in `src/redis/config.ts` as `TTL` — single source of truth.
42+
43+
| Key area | TTL | Rationale |
44+
|----------|-----|-----------|
45+
| Nonce | 5 min | Challenge must be used before wallet session expires |
46+
| Rate limit | 60 s | Sliding window; resets each minute |
47+
| Policy cache | 30 s | Stale-while-revalidate acceptable; policies change infrequently |
48+
| Claim cache | 10 s | Lower TTL; claim status changes on every vote |
49+
50+
---
51+
52+
## Queue Configuration
53+
54+
### `claim-events`
55+
56+
Processes Soroban contract events (ClaimFiled, VoteLogged, ClaimSettled) and writes to Postgres.
57+
58+
| Setting | Value | Rationale |
59+
|---------|-------|-----------|
60+
| `attempts` | 5 | Retry transient failures (network, DB lock) |
61+
| `backoff` | exponential, 1 s base | Avoid thundering herd on DB recovery |
62+
| `concurrency` | 5 per worker | Balance throughput vs DB connection pool |
63+
| `stalledInterval` | 30 s | Redeliver if worker crashes mid-job |
64+
| `maxStalledCount` | 2 | Move to failed after 2 stall cycles |
65+
| `removeOnComplete` | last 100 | Keep for debugging without unbounded growth |
66+
| `removeOnFail` | last 500 | Keep for alerting and manual replay |
67+
68+
**Idempotency requirement**: The Postgres writer must use `INSERT … ON CONFLICT DO NOTHING` keyed on `(ledger, event_index)` — stalled jobs will be redelivered.
69+
70+
### `claim-payouts`
71+
72+
Triggers token transfer for approved claims. Not yet implemented — queue name reserved.
73+
74+
---
75+
76+
## Outage Behaviour
77+
78+
### Redis completely unavailable
79+
80+
| Feature | Behaviour | User impact |
81+
|---------|-----------|-------------|
82+
| Wallet auth (nonce) | **Rejected**`RedisUnavailableError` thrown | User cannot log in; must retry when Redis recovers |
83+
| Rate limiting | **Allowed** — warning logged | Temporary rate-limit bypass; acceptable short-term risk |
84+
| Policy/claim reads | **DB fallback** — cache miss | Slightly higher DB load; no user-visible impact |
85+
| Job enqueue | **Error returned** — caller must handle | Async processing delayed; no data loss if caller retries |
86+
| `/health/ready` | Returns `503 { redis: "down" }` | Load balancer can route away from degraded instance |
87+
88+
### Redis slow (high latency)
89+
90+
- `checkRedisHealth` has a 2 s timeout — returns `false` if exceeded.
91+
- Cache operations have no explicit timeout; they will block the request. Consider adding per-operation timeouts in production if Redis latency is a concern.
92+
93+
---
94+
95+
## Metrics and Alerting
96+
97+
`GET /metrics/redis` returns:
98+
99+
```json
100+
{
101+
"connected": true,
102+
"memory_used_bytes": 1234567,
103+
"memory_used_mb": 1,
104+
"queues": {
105+
"claim-events": {
106+
"waiting": 0,
107+
"active": 1,
108+
"completed": 42,
109+
"failed": 0,
110+
"delayed": 0,
111+
"depth": 1
112+
}
113+
}
114+
}
115+
```
116+
117+
Recommended alert thresholds:
118+
119+
| Metric | Threshold | Action |
120+
|--------|-----------|--------|
121+
| `queues["claim-events"].depth` | > 1000 | Scale worker replicas |
122+
| `queues["claim-events"].failed` | > 10 | Investigate; replay failed jobs |
123+
| `memory_used_mb` | > 200 (of 256 limit) | Increase `maxmemory` or scale Redis |
124+
| `connected: false` | any | Page on-call; wallet auth is down |
125+
126+
---
127+
128+
## Local Development
129+
130+
```bash
131+
# Start Redis
132+
docker compose up -d redis
133+
134+
# Set env vars (copy from .env.example)
135+
cp .env.example .env
136+
137+
# Run backend
138+
npm run build && npm start
139+
140+
# Run tests (Redis must be running)
141+
REDIS_HOST=127.0.0.1 npm test
142+
```
143+
144+
---
145+
146+
## Production Security Checklist
147+
148+
- [ ] `REDIS_PASSWORD` set to ≥ 32 random characters
149+
- [ ] `REDIS_TLS=true` with valid CA cert for managed Redis (e.g. AWS ElastiCache, Upstash)
150+
- [ ] Redis not exposed on public network interface
151+
- [ ] `maxmemory` and `maxmemory-policy` configured (`allkeys-lru` recommended)
152+
- [ ] Separate Redis instance (or logical DB) per environment
153+
- [ ] Alerts wired on queue depth and memory usage
154+
- [ ] Redis password rotated on any suspected compromise

backend/eslint.config.mjs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,24 @@
22
import eslint from "@eslint/js";
33
import tseslint from "typescript-eslint";
44

5-
export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended);
5+
export default tseslint.config(
6+
// Global ignores
7+
{
8+
ignores: ["dist/**", "dist-test/**", "node_modules/**"],
9+
},
10+
// JS recommended for all files
11+
eslint.configs.recommended,
12+
// TypeScript rules — type-unaware (no parserOptions.project needed)
13+
// Using recommendedTypeChecked requires a tsconfig; skip for now to keep
14+
// CI fast. Switch to recommendedTypeChecked once tsconfig paths are stable.
15+
...tseslint.configs.recommended,
16+
// Override rules that are noisy during early development
17+
{
18+
rules: {
19+
// Allow void-returning async handlers in express routes
20+
"@typescript-eslint/no-floating-promises": "off",
21+
// Allow empty catch blocks with a comment
22+
"no-empty": ["error", { allowEmptyCatch: true }],
23+
},
24+
}
25+
);
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* claim-events queue end-to-end test.
3+
*
4+
* Verifies that a job enqueued by the producer is picked up and processed
5+
* by the worker. Requires a running Redis instance.
6+
*/
7+
8+
import { enqueueClaimEvent, ClaimEventJobData, closeClaimEventsQueue } from "../queues/claimEvents.queue";
9+
import { startClaimEventsWorker } from "../queues/claimEvents.worker";
10+
import { closeRedisClient, getBullMQConnection } from "../redis/client";
11+
import { Queue, Worker } from "bullmq";
12+
13+
const REDIS_AVAILABLE = process.env.REDIS_HOST !== undefined || process.env.CI === "true";
14+
const describeIfRedis = REDIS_AVAILABLE ? describe : describe.skip;
15+
16+
describeIfRedis("claim-events queue end-to-end", () => {
17+
let worker: Worker<ClaimEventJobData>;
18+
let processedJobs: ClaimEventJobData[];
19+
20+
beforeEach(() => {
21+
processedJobs = [];
22+
worker = startClaimEventsWorker(async (job) => {
23+
processedJobs.push(job.data);
24+
});
25+
});
26+
27+
afterEach(async () => {
28+
await worker.close();
29+
// Drain the queue between tests
30+
const conn = getBullMQConnection();
31+
const q = new Queue("claim-events", { connection: conn });
32+
await q.obliterate({ force: true });
33+
await q.close();
34+
await conn.quit();
35+
});
36+
37+
afterAll(async () => {
38+
await closeClaimEventsQueue();
39+
await closeRedisClient();
40+
});
41+
42+
test("enqueued job is processed by worker", async () => {
43+
const data: ClaimEventJobData = {
44+
eventType: "claim:filed",
45+
ledger: 12345,
46+
payload: JSON.stringify({ claim_id: 1, amount: 100_000 }),
47+
};
48+
49+
const jobId = await enqueueClaimEvent(data);
50+
expect(jobId).toBeTruthy();
51+
52+
// Wait for worker to process
53+
await new Promise<void>((resolve, reject) => {
54+
const timeout = setTimeout(() => reject(new Error("job not processed in time")), 10_000);
55+
worker.on("completed", () => {
56+
clearTimeout(timeout);
57+
resolve();
58+
});
59+
worker.on("failed", (_job: unknown, err: Error) => {
60+
clearTimeout(timeout);
61+
reject(err);
62+
});
63+
});
64+
65+
expect(processedJobs).toHaveLength(1);
66+
expect(processedJobs[0].eventType).toBe("claim:filed");
67+
expect(processedJobs[0].ledger).toBe(12345);
68+
});
69+
70+
test("failed job is retried", async () => {
71+
let attempts = 0;
72+
const failingWorker = startClaimEventsWorker(async () => {
73+
attempts++;
74+
if (attempts < 2) throw new Error("transient failure");
75+
});
76+
77+
const data: ClaimEventJobData = {
78+
eventType: "vote:logged",
79+
ledger: 99,
80+
payload: "{}",
81+
};
82+
83+
await enqueueClaimEvent(data);
84+
85+
await new Promise<void>((resolve, reject) => {
86+
const timeout = setTimeout(() => reject(new Error("retry not observed")), 15_000);
87+
failingWorker.on("completed", () => {
88+
clearTimeout(timeout);
89+
resolve();
90+
});
91+
});
92+
93+
expect(attempts).toBeGreaterThanOrEqual(2);
94+
await failingWorker.close();
95+
});
96+
});
97+
98+
describe("queue module (unit — no Redis)", () => {
99+
test("ClaimEventJobData shape is correct", () => {
100+
const data: ClaimEventJobData = {
101+
eventType: "claim:settled",
102+
ledger: 1,
103+
payload: "{}",
104+
};
105+
expect(data.eventType).toBe("claim:settled");
106+
});
107+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Health endpoint tests — no Redis required.
3+
* These run in every CI environment without any service containers.
4+
*/
5+
6+
import request from "supertest";
7+
import app from "../index";
8+
9+
describe("GET /health", () => {
10+
test("returns 200 with status ok", async () => {
11+
const res = await request(app).get("/health");
12+
expect(res.status).toBe(200);
13+
expect(res.body).toEqual({ status: "ok" });
14+
});
15+
});
16+
17+
describe("GET /health/ready", () => {
18+
test("returns a status field", async () => {
19+
// Redis may or may not be available in unit test context.
20+
// We only assert the response shape, not the specific status code.
21+
const res = await request(app).get("/health/ready");
22+
expect(res.body).toHaveProperty("status");
23+
expect(res.body).toHaveProperty("redis");
24+
expect(["ok", "degraded"]).toContain(res.body.status);
25+
});
26+
});
27+
28+
describe("GET /metrics/redis", () => {
29+
test("returns metrics shape", async () => {
30+
const res = await request(app).get("/metrics/redis");
31+
expect(res.status).toBe(200);
32+
expect(res.body).toHaveProperty("connected");
33+
expect(res.body).toHaveProperty("queues");
34+
});
35+
});

0 commit comments

Comments
 (0)