Skip to content

Commit 0f76eae

Browse files
feat: add websocket pooling and streaming optimizations
Closes #741. Closes #743. Closes #744. Closes #734.
1 parent 1c92e46 commit 0f76eae

19 files changed

Lines changed: 982 additions & 216 deletions

ENV_VARS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
| VAPID_PRIVATE_KEY | VAPID private key for Web Push API | auto-generated | No |
1515
| WS_ENABLED | Enable/disable WebSocket support | true | No |
1616
| WS_PORT | WebSocket port | 3001 | No |
17+
| DB_READ_REPLICA_URLS | Comma-separated PostgreSQL read replica URLs | - | No |
18+
| DB_REPLICA_MAX_LAG_MS | Maximum replica lag before primary failover | 5000 | No |
19+
| DB_REPLICA_HEALTH_CHECK_INTERVAL_MS | Interval for replica health checks | 30000 | No |
20+
| DB_REPLICA_FAILOVER_COOLDOWN_MS | Cooldown after replica failover | 15000 | No |
1721

1822
## Frontend
1923

@@ -37,6 +41,8 @@ AGENTICPAY_ALLOWED_SIGNATURE_ORIGINS=https://agenticpay.com,http://localhost:300
3741
VAPID_PUBLIC_KEY=your-vapid-public-key
3842
VAPID_PRIVATE_KEY=your-vapid-private-key
3943
WS_ENABLED=true
44+
DB_READ_REPLICA_URLS=
45+
DB_REPLICA_MAX_LAG_MS=5000
4046
```
4147

4248
- `.env.development` — local development

PERFORMANCE_GUIDE.md

Lines changed: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -65,22 +65,20 @@ Reduces payload size by 60-80% on average, improving:
6565

6666
### Compression Methods
6767

68-
1. **Brotli** (preferred): 20-30% smaller than gzip
69-
- Quality level: 5 (balanced speed/compression)
70-
- Mode: Text optimization
68+
AgenticPay uses the maintained Express `compression` middleware with streaming
69+
backpressure support, negotiated encodings, and route-level filters.
7170

72-
2. **Gzip** (fallback): Universal support
73-
- Compression level: 6
74-
- Minimum size threshold: 1KB
71+
- Compression level: 6
72+
- Minimum size threshold: 1KB by default
73+
- Skips images, audio, video, archives, and already-compressed content
7574

7675
### Implementation
7776

7877
Located in: `backend/src/middleware/compression.ts`
7978

8079
```typescript
8180
app.use(compressionMiddleware({
82-
brotliLevel: 5,
83-
gzipLevel: 6,
81+
level: 6,
8482
minSizeBytes: 1024,
8583
}));
8684
```
@@ -90,7 +88,7 @@ app.use(compressionMiddleware({
9088
Access compression metrics via:
9189

9290
```
93-
GET /api/v1/monitoring/pool/compression
91+
GET /api/v1/monitoring/compression
9492
```
9593

9694
Returns:
@@ -108,6 +106,35 @@ Returns:
108106

109107
---
110108

109+
## API Response Streaming
110+
111+
Large exports are streamed with chunked transfer encoding instead of being buffered in memory.
112+
113+
Endpoints:
114+
115+
```bash
116+
GET /api/v1/exports/audit/stream?format=csv&limit=100000
117+
GET /api/v1/exports/audit/stream?format=jsonl&batchSize=1000
118+
GET /api/v1/exports/payments/stream?format=csv
119+
```
120+
121+
Reusable helpers live in `backend/src/middleware/streaming.ts`:
122+
123+
```typescript
124+
const query = parseStreamingQuery(req.query);
125+
await streamDataset({
126+
req,
127+
res,
128+
items: takeStreamItems(fetchRows(), query.limit),
129+
format: query.format,
130+
});
131+
```
132+
133+
The streaming helpers set `Transfer-Encoding: chunked`, disable proxy buffering with
134+
`X-Accel-Buffering: no`, honor HTTP backpressure, and track completed, aborted, and failed streams.
135+
136+
---
137+
111138
## Cursor-Based Pagination
112139

113140
### Overview
@@ -198,6 +225,40 @@ GET /api/v1/payments -H "If-None-Match: abc123def456"
198225

199226
Optimized connection pooling with PgBouncer for efficient resource utilization:
200227

228+
### Read Replicas and Failover
229+
230+
Read replica routing is configured with:
231+
232+
```bash
233+
DB_READ_REPLICA_URLS=postgresql://user:pass@replica-a:5432/agenticpay,postgresql://user:pass@replica-b:5432/agenticpay
234+
DB_REPLICA_MAX_LAG_MS=5000
235+
DB_REPLICA_HEALTH_CHECK_INTERVAL_MS=30000
236+
DB_REPLICA_FAILOVER_COOLDOWN_MS=15000
237+
```
238+
239+
`backend/src/config/database.ts` exposes `ReadReplicaRouter`, which routes `SELECT` and `WITH`
240+
queries across healthy replicas and falls back to `DATABASE_URL` when no replica is available or
241+
replica lag exceeds the configured threshold. Terraform can provision replicas with
242+
`db_read_replica_count` and wires `DB_READ_REPLICA_URLS` into the backend service.
243+
244+
### WebSocket Pooling
245+
246+
WebSocket connections are managed by `backend/src/websocket/pool.ts`. The pool enforces capacity,
247+
tracks active and queued connections, batches outbound messages through `ManagedConnection`, and
248+
supports clean shutdown. Tune batching with:
249+
250+
```typescript
251+
attachWebSocketServer({
252+
server,
253+
options: {
254+
maxConnections: 250,
255+
maxQueueSizePerConnection: 500,
256+
flushIntervalMs: 25,
257+
maxBatchSize: 50,
258+
},
259+
});
260+
```
261+
201262
**Benefits:**
202263
- Prevents connection exhaustion
203264
- Detects and prevents connection leaks
@@ -239,7 +300,7 @@ Located in: `backend/src/config/database.ts`
239300
Access pool health via:
240301

241302
```
242-
GET /api/v1/monitoring/pool/health
303+
GET /api/v1/monitoring/health
243304
```
244305

245306
Returns:
@@ -261,7 +322,7 @@ Returns:
261322
Automatic detection of connection leaks:
262323

263324
```
264-
GET /api/v1/monitoring/pool/leaks
325+
GET /api/v1/monitoring/leaks
265326
```
266327

267328
- Monitors connection acquisition/release
@@ -271,7 +332,7 @@ GET /api/v1/monitoring/pool/leaks
271332
### Metrics Endpoint
272333

273334
```
274-
GET /api/v1/monitoring/pool/metrics
335+
GET /api/v1/monitoring/metrics
275336
```
276337

277338
Returns comprehensive pool statistics including:
@@ -358,7 +419,7 @@ cache.registerWarmer('dashboard:overview',
358419
### Metrics Endpoint
359420

360421
```
361-
GET /api/v1/monitoring/pool/cache
422+
GET /api/v1/monitoring/cache
362423
```
363424

364425
Returns:
@@ -383,7 +444,7 @@ Returns:
383444
Comprehensive view of all performance metrics:
384445

385446
```
386-
GET /api/v1/monitoring/pool/performance
447+
GET /api/v1/monitoring/performance
387448
```
388449

389450
Returns combined metrics:

backend/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ RATE_LIMIT_ENTERPRISE=1000
1010
RATE_LIMIT_WINDOW_MS=900000
1111
COMPRESSION_THRESHOLD=1024
1212

13+
# Database read replicas (comma-separated PostgreSQL URLs)
14+
DB_READ_REPLICA_URLS=
15+
DB_REPLICA_MAX_LAG_MS=5000
16+
DB_REPLICA_HEALTH_CHECK_INTERVAL_MS=30000
17+
DB_REPLICA_FAILOVER_COOLDOWN_MS=15000
18+
1319
# Security headers
1420
HSTS_MAX_AGE_SECONDS=31536000
1521
PERMISSIONS_POLICY=camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), interest-cohort=()

backend/src/config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ const envSchema = z.object({
2626
DB_POOL_ACQUIRE_TIMEOUT_MS: z.string().default('30000'),
2727
DB_POOL_MAX_USES: z.string().default('7500'),
2828
DB_STATEMENT_TIMEOUT_MS: z.string().default('30000'),
29+
DB_READ_REPLICA_URLS: z.string().default(''),
30+
DB_REPLICA_MAX_LAG_MS: z.string().default('5000'),
31+
DB_REPLICA_HEALTH_CHECK_INTERVAL_MS: z.string().default('30000'),
32+
DB_REPLICA_FAILOVER_COOLDOWN_MS: z.string().default('15000'),
2933
HSTS_MAX_AGE_SECONDS: z.string().default('31536000'),
3034
PERMISSIONS_POLICY: z
3135
.string()
@@ -83,6 +87,12 @@ export const config = {
8387
maxUses: Number(env.DB_POOL_MAX_USES),
8488
statementTimeoutMs: Number(env.DB_STATEMENT_TIMEOUT_MS),
8589
},
90+
replicas: {
91+
urls: env.DB_READ_REPLICA_URLS.split(',').map((url) => url.trim()).filter(Boolean),
92+
maxLagMs: Number(env.DB_REPLICA_MAX_LAG_MS),
93+
healthCheckIntervalMs: Number(env.DB_REPLICA_HEALTH_CHECK_INTERVAL_MS),
94+
failoverCooldownMs: Number(env.DB_REPLICA_FAILOVER_COOLDOWN_MS),
95+
},
8696
},
8797
security: {
8898
hsts: {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
ReadReplicaRouter,
4+
buildReplicaConfigs,
5+
isReadQuery,
6+
} from './database';
7+
8+
describe('read replica routing', () => {
9+
it('detects read queries conservatively', () => {
10+
expect(isReadQuery('select * from payments')).toBe(true);
11+
expect(isReadQuery(' WITH recent AS (select 1) select * from recent')).toBe(true);
12+
expect(isReadQuery('update payments set status = $1')).toBe(false);
13+
});
14+
15+
it('routes writes to primary and reads to healthy replicas', () => {
16+
const router = new ReadReplicaRouter(
17+
['postgres://replica-1/db', 'postgres://replica-2/db'],
18+
'postgres://primary/db',
19+
5000,
20+
);
21+
22+
expect(router.select('UPDATE payments SET status = $1')).toEqual({
23+
url: 'postgres://primary/db',
24+
source: 'primary',
25+
reason: 'write_query',
26+
});
27+
28+
expect(router.select('SELECT * FROM payments')).toEqual({
29+
url: 'postgres://replica-1/db',
30+
source: 'replica',
31+
reason: 'healthy_replica',
32+
});
33+
expect(router.select('SELECT * FROM invoices')).toMatchObject({
34+
url: 'postgres://replica-2/db',
35+
source: 'replica',
36+
});
37+
});
38+
39+
it('fails over to primary when all replicas are unhealthy or lagging', () => {
40+
const router = new ReadReplicaRouter(
41+
['postgres://replica-1/db', 'postgres://replica-2/db'],
42+
'postgres://primary/db',
43+
100,
44+
);
45+
46+
router.updateHealth('postgres://replica-1/db', { healthy: false });
47+
router.updateHealth('postgres://replica-2/db', { healthy: true, lagMs: 500 });
48+
49+
expect(router.select('SELECT * FROM payments')).toEqual({
50+
url: 'postgres://primary/db',
51+
source: 'primary',
52+
reason: 'replica_unavailable',
53+
});
54+
});
55+
56+
it('builds replica configs from environment URLs', () => {
57+
const previous = process.env.DB_READ_REPLICA_URLS;
58+
process.env.DB_READ_REPLICA_URLS = 'postgres://user:pass@replica-a:5432/app, postgres://user:pass@replica-b/app';
59+
60+
try {
61+
expect(buildReplicaConfigs()).toMatchObject([
62+
{ host: 'replica-a', port: 5432, database: 'app', user: 'user', enabled: true },
63+
{ host: 'replica-b', port: 5432, database: 'app', user: 'user', enabled: true },
64+
]);
65+
} finally {
66+
if (previous === undefined) delete process.env.DB_READ_REPLICA_URLS;
67+
else process.env.DB_READ_REPLICA_URLS = previous;
68+
}
69+
});
70+
});

backend/src/config/database.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,83 @@ export function isReadQuery(sql: string): boolean {
874874
return /^\s*(SELECT|WITH\s)/i.test(sql);
875875
}
876876

877+
export type ReplicaHealth = "healthy" | "lagging" | "unhealthy";
878+
879+
export interface ReadReplicaTarget {
880+
url: string;
881+
health: ReplicaHealth;
882+
lagMs: number;
883+
lastCheckedAt: number;
884+
failureCount: number;
885+
}
886+
887+
export interface ReplicaSelection {
888+
url: string;
889+
source: "primary" | "replica";
890+
reason: "write_query" | "no_replicas" | "healthy_replica" | "replica_unavailable";
891+
}
892+
893+
export class ReadReplicaRouter {
894+
private replicas: ReadReplicaTarget[];
895+
private nextReplicaIndex = 0;
896+
897+
constructor(
898+
replicaUrls = buildReplicaUrls(),
899+
private readonly primaryUrl = process.env.DATABASE_URL ?? "",
900+
private readonly maxLagMs = envInt("DB_REPLICA_MAX_LAG_MS", 5000),
901+
) {
902+
this.replicas = replicaUrls.map((url) => ({
903+
url,
904+
health: "healthy",
905+
lagMs: 0,
906+
lastCheckedAt: 0,
907+
failureCount: 0,
908+
}));
909+
}
910+
911+
select(sql: string): ReplicaSelection {
912+
if (!isReadQuery(sql)) {
913+
return { url: this.primaryUrl, source: "primary", reason: "write_query" };
914+
}
915+
916+
const healthyReplicas = this.replicas.filter(
917+
(replica) => replica.health === "healthy" && replica.lagMs <= this.maxLagMs,
918+
);
919+
920+
if (this.replicas.length === 0) {
921+
return { url: this.primaryUrl, source: "primary", reason: "no_replicas" };
922+
}
923+
924+
if (healthyReplicas.length === 0) {
925+
return { url: this.primaryUrl, source: "primary", reason: "replica_unavailable" };
926+
}
927+
928+
const replica = healthyReplicas[this.nextReplicaIndex % healthyReplicas.length];
929+
this.nextReplicaIndex = (this.nextReplicaIndex + 1) % healthyReplicas.length;
930+
return { url: replica.url, source: "replica", reason: "healthy_replica" };
931+
}
932+
933+
updateHealth(url: string, params: { healthy: boolean; lagMs?: number; checkedAt?: number }): void {
934+
const replica = this.replicas.find((candidate) => candidate.url === url);
935+
if (!replica) return;
936+
937+
replica.lagMs = params.lagMs ?? replica.lagMs;
938+
replica.lastCheckedAt = params.checkedAt ?? Date.now();
939+
replica.health = !params.healthy
940+
? "unhealthy"
941+
: replica.lagMs > this.maxLagMs
942+
? "lagging"
943+
: "healthy";
944+
replica.failureCount = replica.health === "healthy" ? 0 : replica.failureCount + 1;
945+
}
946+
947+
snapshot(): ReadReplicaTarget[] {
948+
return this.replicas.map((replica) => ({ ...replica }));
949+
}
950+
}
951+
952+
export const readReplicaRouter = new ReadReplicaRouter();
953+
877954
// ── Query Profiler ────────────────────────────────────────────────────────────
878955

879956
export interface QueryProfile {

0 commit comments

Comments
 (0)