Skip to content

Commit bfa2cda

Browse files
WISDOM-WOKE22claude
andcommitted
feat: add event worker deduplication and request ID tracing
- Add unique constraint on StreamEvent(transactionHash, eventType) to prevent duplicate event processing on worker restart - Replace streamEvent.create with upsert pattern that detects and logs duplicate events - Add migration for the unique constraint - Add test_event_processing_is_idempotent_on_restart test verifying no duplicates on replay - Add AsyncLocalStorage-based request ID middleware for end-to-end correlation - Include requestId in all Winston log entries automatically - Add X-Request-ID response header (reads X-Request-ID from request if valid) - Include requestId in SSE connected event payload for client-side tracking - Log request received/response sent with method, path, status, duration, and requestId closes #383, closes #381 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent d16912b commit bfa2cda

10 files changed

Lines changed: 401 additions & 81 deletions

File tree

.claude/settings.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(npm test *)",
5+
"Bash(npm install *)",
6+
"Bash(npx prisma *)",
7+
"Bash(npx tsc *)",
8+
"Bash(git add *)"
9+
]
10+
}
11+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AddUniqueConstraint
2+
ALTER TABLE "StreamEvent" ADD CONSTRAINT "StreamEvent_transactionHash_eventType_key" UNIQUE ("transactionHash", "eventType");

backend/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,5 @@ model StreamEvent {
8080
@@index([transactionHash])
8181
@@index([createdAt])
8282
@@index([streamId, createdAt])
83+
@@unique([transactionHash, eventType])
8384
}

backend/src/app.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { swaggerSpec } from './config/swagger.js';
55
import { apiVersionMiddleware, type VersionedRequest } from './middleware/api-version.middleware.js';
66
import { sandboxMiddleware } from './middleware/sandbox.middleware.js';
77
import { globalRateLimiter } from './middleware/rate-limiter.middleware.js';
8+
import { requestIdMiddleware } from './middleware/requestId.js';
89
import v1Routes from './routes/v1/index.js';
910

1011
import healthRoutes from './routes/health.routes.js';
@@ -25,6 +26,9 @@ if (!process.env.CORS_ALLOWED_ORIGINS && !isProduction) {
2526
// Apply global rate limiter first
2627
app.use(globalRateLimiter);
2728

29+
// Request ID tracing
30+
app.use(requestIdMiddleware);
31+
2832
app.disable('x-powered-by');
2933

3034
// Helmet-equivalent core headers without external dependency.

backend/src/controllers/sse.controller.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Request, Response } from 'express';
22
import { sseService } from '../services/sse.service.js';
33
import { prisma } from '../lib/prisma.js';
4+
import { requestContext } from '../logger.js';
45
import type { AuthenticatedRequest } from '../types/auth.types.js';
56
import { z } from 'zod';
67

@@ -72,7 +73,8 @@ export const subscribe = async (req: Request, res: Response) => {
7273
'X-Accel-Buffering': 'no',
7374
});
7475

75-
res.write(`data: ${JSON.stringify({ type: 'connected', clientId })}\n\n`);
76+
const requestId = requestContext.getStore()?.requestId;
77+
res.write(`data: ${JSON.stringify({ type: 'connected', clientId, requestId })}\n\n`);
7678

7779
sseService.addClient(clientId, res, subscriptions, sourceIp);
7880
} catch (error: any) {

backend/src/logger.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1+
import { AsyncLocalStorage } from 'async_hooks';
12
import { createLogger, format, transports } from 'winston';
23

4+
export const requestContext = new AsyncLocalStorage<{ requestId: string }>();
5+
36
const logger = createLogger({
47
level: process.env.LOG_LEVEL || 'info',
58
format: format.combine(
69
format.timestamp(),
10+
format((info) => {
11+
const ctx = requestContext.getStore();
12+
if (ctx?.requestId) info.requestId = ctx.requestId;
13+
return info;
14+
})(),
715
format.json(),
816
),
917
transports: [new transports.Console()],
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { randomUUID } from 'crypto';
2+
import type { Request, Response, NextFunction } from 'express';
3+
import logger, { requestContext } from '../logger.js';
4+
5+
const MAX_REQUEST_ID_LENGTH = 128;
6+
7+
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction): void {
8+
const header = req.headers['x-request-id'];
9+
const requestId =
10+
typeof header === 'string' && header.length > 0 && header.length <= MAX_REQUEST_ID_LENGTH
11+
? header
12+
: randomUUID();
13+
14+
res.setHeader('X-Request-ID', requestId);
15+
16+
const startMs = Date.now();
17+
18+
res.on('finish', () => {
19+
logger.info('response sent', {
20+
method: req.method,
21+
path: req.path,
22+
status: res.statusCode,
23+
durationMs: Date.now() - startMs,
24+
requestId,
25+
});
26+
});
27+
28+
requestContext.run({ requestId }, () => {
29+
logger.info('request received', { method: req.method, path: req.path, requestId });
30+
next();
31+
});
32+
}

backend/src/workers/soroban-event-worker.ts

Lines changed: 120 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -332,17 +332,27 @@ export class SorobanEventWorker {
332332
},
333333
});
334334

335-
await tx.streamEvent.create({
336-
data: {
337-
streamId,
338-
eventType: 'CREATED',
339-
amount: depositedAmount,
340-
transactionHash: event.txHash,
341-
ledgerSequence: event.ledger,
342-
timestamp: startTime,
343-
metadata: JSON.stringify({ tokenAddress, ratePerSecond }),
344-
},
335+
const existingEvent = await tx.streamEvent.findUnique({
336+
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'CREATED' } },
337+
select: { id: true },
345338
});
339+
if (existingEvent) {
340+
logger.warn(`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=CREATED`);
341+
} else {
342+
await tx.streamEvent.upsert({
343+
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'CREATED' } },
344+
create: {
345+
streamId,
346+
eventType: 'CREATED',
347+
amount: depositedAmount,
348+
transactionHash: event.txHash,
349+
ledgerSequence: event.ledger,
350+
timestamp: startTime,
351+
metadata: JSON.stringify({ tokenAddress, ratePerSecond }),
352+
},
353+
update: {},
354+
});
355+
}
346356
});
347357

348358
sseService.broadcastToStream(String(streamId), 'stream.created', {
@@ -382,17 +392,27 @@ export class SorobanEventWorker {
382392
},
383393
});
384394

385-
await tx.streamEvent.create({
386-
data: {
387-
streamId,
388-
eventType: 'TOPPED_UP',
389-
amount,
390-
transactionHash: event.txHash,
391-
ledgerSequence: event.ledger,
392-
timestamp,
393-
metadata: JSON.stringify({ newDepositedAmount }),
394-
},
395+
const existingEvent = await tx.streamEvent.findUnique({
396+
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'TOPPED_UP' } },
397+
select: { id: true },
395398
});
399+
if (existingEvent) {
400+
logger.warn(`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=TOPPED_UP`);
401+
} else {
402+
await tx.streamEvent.upsert({
403+
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'TOPPED_UP' } },
404+
create: {
405+
streamId,
406+
eventType: 'TOPPED_UP',
407+
amount,
408+
transactionHash: event.txHash,
409+
ledgerSequence: event.ledger,
410+
timestamp,
411+
metadata: JSON.stringify({ newDepositedAmount }),
412+
},
413+
update: {},
414+
});
415+
}
396416
});
397417

398418
sseService.broadcastToStream(String(streamId), 'stream.topped_up', {
@@ -438,17 +458,27 @@ export class SorobanEventWorker {
438458
},
439459
});
440460

441-
await tx.streamEvent.create({
442-
data: {
443-
streamId,
444-
eventType: 'WITHDRAWN',
445-
amount,
446-
transactionHash: event.txHash,
447-
ledgerSequence: event.ledger,
448-
timestamp,
449-
metadata: JSON.stringify({ recipient }),
450-
},
461+
const existingEvent = await tx.streamEvent.findUnique({
462+
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'WITHDRAWN' } },
463+
select: { id: true },
451464
});
465+
if (existingEvent) {
466+
logger.warn(`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=WITHDRAWN`);
467+
} else {
468+
await tx.streamEvent.upsert({
469+
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'WITHDRAWN' } },
470+
create: {
471+
streamId,
472+
eventType: 'WITHDRAWN',
473+
amount,
474+
transactionHash: event.txHash,
475+
ledgerSequence: event.ledger,
476+
timestamp,
477+
metadata: JSON.stringify({ recipient }),
478+
},
479+
update: {},
480+
});
481+
}
452482
});
453483

454484
sseService.broadcastToStream(String(streamId), 'stream.withdrawn', {
@@ -486,17 +516,27 @@ export class SorobanEventWorker {
486516
},
487517
});
488518

489-
await tx.streamEvent.create({
490-
data: {
491-
streamId,
492-
eventType: 'CANCELLED',
493-
amount: refundedAmount,
494-
transactionHash: event.txHash,
495-
ledgerSequence: event.ledger,
496-
timestamp,
497-
metadata: JSON.stringify({ amountWithdrawn, refundedAmount }),
498-
},
519+
const existingEvent = await tx.streamEvent.findUnique({
520+
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'CANCELLED' } },
521+
select: { id: true },
499522
});
523+
if (existingEvent) {
524+
logger.warn(`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=CANCELLED`);
525+
} else {
526+
await tx.streamEvent.upsert({
527+
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'CANCELLED' } },
528+
create: {
529+
streamId,
530+
eventType: 'CANCELLED',
531+
amount: refundedAmount,
532+
transactionHash: event.txHash,
533+
ledgerSequence: event.ledger,
534+
timestamp,
535+
metadata: JSON.stringify({ amountWithdrawn, refundedAmount }),
536+
},
537+
update: {},
538+
});
539+
}
500540
});
501541

502542
sseService.broadcastToStream(String(streamId), 'stream.cancelled', {
@@ -534,17 +574,27 @@ export class SorobanEventWorker {
534574
},
535575
});
536576

537-
await tx.streamEvent.create({
538-
data: {
539-
streamId,
540-
eventType: 'COMPLETED',
541-
amount: totalWithdrawn,
542-
transactionHash: event.txHash,
543-
ledgerSequence: event.ledger,
544-
timestamp,
545-
metadata: JSON.stringify({ recipient }),
546-
},
577+
const existingEvent = await tx.streamEvent.findUnique({
578+
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'COMPLETED' } },
579+
select: { id: true },
547580
});
581+
if (existingEvent) {
582+
logger.warn(`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=COMPLETED`);
583+
} else {
584+
await tx.streamEvent.upsert({
585+
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'COMPLETED' } },
586+
create: {
587+
streamId,
588+
eventType: 'COMPLETED',
589+
amount: totalWithdrawn,
590+
transactionHash: event.txHash,
591+
ledgerSequence: event.ledger,
592+
timestamp,
593+
metadata: JSON.stringify({ recipient }),
594+
},
595+
update: {},
596+
});
597+
}
548598
});
549599

550600
sseService.broadcastToStream(String(streamId), 'stream.completed', {
@@ -573,17 +623,27 @@ export class SorobanEventWorker {
573623
const token = decodeAddress(body['token']);
574624
const timestamp = Math.floor(Date.now() / 1000);
575625

576-
await prisma.streamEvent.create({
577-
data: {
578-
streamId,
579-
eventType: 'FEE_COLLECTED',
580-
amount: feeAmount,
581-
transactionHash: event.txHash,
582-
ledgerSequence: event.ledger,
583-
timestamp,
584-
metadata: JSON.stringify({ treasury, token }),
585-
},
626+
const existingEvent = await prisma.streamEvent.findUnique({
627+
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'FEE_COLLECTED' } },
628+
select: { id: true },
586629
});
630+
if (existingEvent) {
631+
logger.warn(`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=FEE_COLLECTED`);
632+
} else {
633+
await prisma.streamEvent.upsert({
634+
where: { transactionHash_eventType: { transactionHash: event.txHash, eventType: 'FEE_COLLECTED' } },
635+
create: {
636+
streamId,
637+
eventType: 'FEE_COLLECTED',
638+
amount: feeAmount,
639+
transactionHash: event.txHash,
640+
ledgerSequence: event.ledger,
641+
timestamp,
642+
metadata: JSON.stringify({ treasury, token }),
643+
},
644+
update: {},
645+
});
646+
}
587647

588648
// Broadcast to admin channel for treasury reporting
589649
sseService.broadcast('admin', 'stream.fee_collected', {

0 commit comments

Comments
 (0)