Skip to content

Commit f65a20b

Browse files
authored
Merge branch 'main' into chore/1081-fix-nodemon-test-ignore
2 parents a19557c + 4bd2cc2 commit f65a20b

31 files changed

Lines changed: 938 additions & 173 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- Replace the unused (streamId, createdAt) composite with (streamId, timestamp),
2+
-- which matches streamId-scoped event listings that ORDER BY timestamp.
3+
4+
-- CreateIndex
5+
CREATE INDEX IF NOT EXISTS "StreamEvent_streamId_timestamp_idx" ON "StreamEvent"("streamId", "timestamp");
6+
7+
-- DropIndex
8+
DROP INDEX IF EXISTS "StreamEvent_streamId_createdAt_idx";

backend/prisma/schema.prisma

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,5 +86,6 @@ model StreamEvent {
8686
@@index([timestamp])
8787
@@index([transactionHash])
8888
@@index([createdAt])
89-
@@index([streamId, createdAt])
89+
@@index([streamId, timestamp])
90+
@@unique([transactionHash, eventType])
9091
}

backend/src/controllers/sse.controller.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ export const subscribe = async (req: Request, res: Response) => {
3131

3232
try {
3333
const sourceIp = getClientIp(req);
34-
const capacity = sseService.checkCapacity(sourceIp);
34+
const authUserId = (req as AuthenticatedRequest).user?.publicKey;
35+
const capacity = sseService.checkCapacity(sourceIp, authUserId);
3536
if (!capacity.allowed) {
3637
if (capacity.retryAfterSeconds) {
3738
res.setHeader('Retry-After', String(capacity.retryAfterSeconds));
@@ -87,7 +88,7 @@ export const subscribe = async (req: Request, res: Response) => {
8788
const requestId = requestContext.getStore()?.requestId;
8889
res.write(`data: ${JSON.stringify({ type: 'connected', clientId, requestId })}\n\n`);
8990

90-
sseService.addClient(clientId, res, subscriptions, sourceIp);
91+
sseService.addClient(clientId, res, subscriptions, sourceIp, publicKey);
9192
return;
9293
} catch (error: unknown) {
9394
if (error instanceof z.ZodError) {

backend/src/routes/health.routes.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,39 @@ const router = Router();
6565
* type: number
6666
* description: Server uptime in seconds
6767
* example: 3600
68+
* checks:
69+
* type: object
70+
* description: Per-subsystem status breakdown, so callers can tell "DB unreachable" apart from "indexer lagging" instead of inferring it from the top-level status alone.
71+
* properties:
72+
* database:
73+
* type: object
74+
* properties:
75+
* status:
76+
* type: string
77+
* enum: [ok, down]
78+
* indexer:
79+
* type: object
80+
* properties:
81+
* status:
82+
* type: string
83+
* enum: [ok, degraded, disabled]
84+
* enabled:
85+
* type: boolean
86+
* lagSeconds:
87+
* type: integer
88+
* nullable: true
89+
* redis:
90+
* type: object
91+
* properties:
92+
* status:
93+
* type: string
94+
* enum: [ok, unavailable, not_configured]
95+
* sorobanRpc:
96+
* type: object
97+
* properties:
98+
* status:
99+
* type: string
100+
* enum: [ok, down]
68101
* 503:
69102
* description: Service is degraded or unhealthy
70103
*/
@@ -104,6 +137,15 @@ router.get('/', async (_req: Request, res: Response) => {
104137
dbStatus === 'connected' && !indexerLagDegraded && !indexerFailureDegraded;
105138
const status = isHealthy ? 'ok' : 'degraded';
106139

140+
// Redis is optional (single-instance SSE mode falls back gracefully when it's
141+
// absent), so its status never affects the top-level `isHealthy` verdict.
142+
const redisConfigured = !!process.env.REDIS_URL;
143+
const redisStatus = !redisConfigured ? 'not_configured' : isRedisAvailable() ? 'ok' : 'unavailable';
144+
145+
// Soroban RPC reachability is reported for observability only — it does not
146+
// gate liveness, since a transient RPC blip shouldn't take the service down.
147+
const sorobanRpcOk = await checkRpcHealth();
148+
107149
res.status(isHealthy ? 200 : 503).json({
108150
status,
109151
db: dbStatus,
@@ -114,6 +156,22 @@ router.get('/', async (_req: Request, res: Response) => {
114156
lastErrorAt: eventCounters.lastErrorAt,
115157
indexerDegraded: eventCounters.degraded,
116158
uptime: process.uptime(),
159+
checks: {
160+
database: {
161+
status: dbStatus === 'connected' ? 'ok' : 'down',
162+
},
163+
indexer: {
164+
status: !indexerEnabled ? 'disabled' : indexerDegraded ? 'degraded' : 'ok',
165+
enabled: indexerEnabled,
166+
lagSeconds: indexerLag === -1 ? null : indexerLag,
167+
},
168+
redis: {
169+
status: redisStatus,
170+
},
171+
sorobanRpc: {
172+
status: sorobanRpcOk ? 'ok' : 'down',
173+
},
174+
},
117175
});
118176
});
119177

backend/src/services/sorobanService.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,20 @@ function getServer(): rpc.Server {
126126
return _server;
127127
}
128128

129+
/**
130+
* Lightweight connectivity check used by the /health endpoint.
131+
* Calls the RPC server's getHealth() with a bounded timeout so a slow or
132+
* unreachable Soroban RPC endpoint can't hang the health check.
133+
*/
134+
export async function checkRpcHealth(timeoutMs = 3_000): Promise<boolean> {
135+
try {
136+
await withRpcTimeout('soroban rpc health check', () => getServer().getHealth(), timeoutMs);
137+
return true;
138+
} catch {
139+
return false;
140+
}
141+
}
142+
129143
export function setServer(server: rpc.Server): void {
130144
_server = server;
131145
}

backend/src/services/sse.service.ts

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { randomUUID } from 'crypto';
12
import type { Response } from 'express';
2-
import logger from '../logger.js';
3+
import logger, { requestContext } from '../logger.js';
34
import { isRedisAvailable, getPublisher, getSubscriber } from '../lib/redis.js';
45

56
const HEARTBEAT_INTERVAL_MS = 30_000;
67
const MAX_WRITABLE_BUFFER = 64 * 1024;
78
const MAX_CONNECTIONS_PER_IP = 5;
9+
const MAX_CONNECTIONS_PER_USER = 10;
810
const RETRY_AFTER_SECONDS = 60;
911

1012
interface SSEClient {
@@ -13,6 +15,7 @@ interface SSEClient {
1315
subscriptions: Set<string>;
1416
paused: boolean;
1517
ip: string;
18+
userId?: string;
1619
}
1720

1821
interface SSECapacityCheckResult {
@@ -27,8 +30,17 @@ export class SSEService {
2730
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
2831
private slowClientsDropped = 0;
2932
private readonly ipConnectionCounts: Map<string, number> = new Map();
33+
private readonly userConnectionCounts: Map<string, number> = new Map();
3034
private shuttingDown = false;
3135
private perIpPeakConnections = 0;
36+
private perUserPeakConnections = 0;
37+
38+
/**
39+
* Stable id attached to every log line emitted by the heartbeat
40+
* setInterval callback, since it fires outside of any HTTP request and
41+
* would otherwise have no requestContext (and thus no correlation id).
42+
*/
43+
private readonly heartbeatWorkerId = `sse-heartbeat:${randomUUID()}`;
3244

3345
private readonly maxConnections: number = (() => {
3446
const parsed = Number.parseInt(process.env.MAX_SSE_CONNECTIONS ?? '10000', 10);
@@ -61,7 +73,7 @@ export class SSEService {
6173
logger.info('[SSEService] Redis pub/sub subscription active.');
6274
}
6375

64-
checkCapacity(ip: string): SSECapacityCheckResult {
76+
checkCapacity(ip: string, userId?: string): SSECapacityCheckResult {
6577
if (this.clients.size >= this.maxConnections) {
6678
return {
6779
allowed: false,
@@ -80,25 +92,53 @@ export class SSEService {
8092
};
8193
}
8294

95+
// Independent of the per-IP cap: bounds how many concurrent SSE
96+
// subscriptions a single authenticated user can hold regardless of which
97+
// IP(s) they connect from (e.g. multiple tabs/devices behind different NATs).
98+
if (userId) {
99+
const currentUserConnections = this.userConnectionCounts.get(userId) ?? 0;
100+
if (currentUserConnections >= MAX_CONNECTIONS_PER_USER) {
101+
return {
102+
allowed: false,
103+
status: 429,
104+
retryAfterSeconds: RETRY_AFTER_SECONDS,
105+
message: `Too many concurrent SSE connections for this user. Max ${MAX_CONNECTIONS_PER_USER}.`,
106+
};
107+
}
108+
}
109+
83110
return { allowed: true };
84111
}
85112

86-
addClient(clientId: string, res: Response, subscriptions: string[] = [], ip = 'unknown'): void {
113+
addClient(
114+
clientId: string,
115+
res: Response,
116+
subscriptions: string[] = [],
117+
ip = 'unknown',
118+
userId?: string,
119+
): void {
87120
const nextIpCount = (this.ipConnectionCounts.get(ip) ?? 0) + 1;
88121
this.ipConnectionCounts.set(ip, nextIpCount);
89122
this.perIpPeakConnections = Math.max(this.perIpPeakConnections, nextIpCount);
90123

124+
if (userId) {
125+
const nextUserCount = (this.userConnectionCounts.get(userId) ?? 0) + 1;
126+
this.userConnectionCounts.set(userId, nextUserCount);
127+
this.perUserPeakConnections = Math.max(this.perUserPeakConnections, nextUserCount);
128+
}
129+
91130
const client: SSEClient = {
92131
id: clientId,
93132
res,
94133
subscriptions: new Set(subscriptions),
95134
paused: false,
96135
ip,
136+
...(userId !== undefined && { userId }),
97137
};
98138

99139
this.clients.set(clientId, client);
100140
logger.info(
101-
`[SSEService] Connection opened: ${clientId}, ip: ${ip}, subscriptions: ${subscriptions.join(', ')}`
141+
`[SSEService] Connection opened: ${clientId}, ip: ${ip}, userId: ${userId ?? 'n/a'}, subscriptions: ${subscriptions.join(', ')}`
102142
);
103143

104144
res.on('close', () => {
@@ -194,6 +234,18 @@ export class SSEService {
194234
return this.ipConnectionCounts.size;
195235
}
196236

237+
getPerUserPeakConnections(): number {
238+
return this.perUserPeakConnections;
239+
}
240+
241+
getActiveUserCount(): number {
242+
return this.userConnectionCounts.size;
243+
}
244+
245+
getUserConnectionCount(userId: string): number {
246+
return this.userConnectionCounts.get(userId) ?? 0;
247+
}
248+
197249
stopHeartbeat(): void {
198250
if (this.heartbeatTimer) {
199251
clearInterval(this.heartbeatTimer);
@@ -207,7 +259,9 @@ export class SSEService {
207259
}
208260

209261
this.heartbeatTimer = setInterval(() => {
210-
this.sendHeartbeat();
262+
requestContext.run({ requestId: this.heartbeatWorkerId }, () => {
263+
this.sendHeartbeat();
264+
});
211265
}, HEARTBEAT_INTERVAL_MS);
212266
}
213267

@@ -235,6 +289,15 @@ export class SSEService {
235289
this.ipConnectionCounts.set(client.ip, currentIpCount - 1);
236290
}
237291

292+
if (client.userId) {
293+
const currentUserCount = this.userConnectionCounts.get(client.userId) ?? 0;
294+
if (currentUserCount <= 1) {
295+
this.userConnectionCounts.delete(client.userId);
296+
} else {
297+
this.userConnectionCounts.set(client.userId, currentUserCount - 1);
298+
}
299+
}
300+
238301
try {
239302
if (!client.res.writableEnded) {
240303
client.res.end();

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import { randomUUID } from "crypto";
12
import { rpc, xdr, StrKey } from "@stellar/stellar-sdk";
23
import { prisma } from "../lib/prisma.js";
34
import { INDEXER_STATE_ID, ensureIndexerState } from "../lib/indexer-state.js";
45
import { sseService } from "../services/sse.service.js";
5-
import logger from "../logger.js";
6+
import logger, { requestContext } from "../logger.js";
67
import { Prisma } from "../generated/prisma/index.js";
78
import "../lib/stream-id.js";
89

@@ -112,6 +113,13 @@ export class SorobanEventWorker {
112113
/** Recent attempt outcomes for sliding-window spike detection. */
113114
private recentOutcomes: { ok: boolean; at: number }[] = [];
114115

116+
/**
117+
* Stable id attached to every log line emitted by the background poll
118+
* loop, since these callbacks fire outside of any HTTP request and would
119+
* otherwise have no requestContext (and thus no correlation id) at all.
120+
*/
121+
private readonly workerId = `soroban-worker:${randomUUID()}`;
122+
115123
constructor() {
116124
const rpcUrl =
117125
process.env.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org";

backend/tests/claimable.service.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,57 @@ describe('ClaimableAmountService', () => {
131131
expect(third.cached).toBe(false);
132132
});
133133

134+
it('reflects an indexed withdrawal immediately, without waiting for the cache TTL', () => {
135+
// The cache key is derived from getStateFingerprint(), which folds in
136+
// withdrawnAmount and lastUpdateTime (see claimable.service.ts). When the
137+
// indexer processes a TokensWithdrawn event it updates those fields on the
138+
// Stream row, so the *next* read (which is always given the freshly
139+
// reloaded stream from the DB, see stream.controller.ts / withdraw.ts)
140+
// naturally lands on a different cache key and can never return the
141+
// pre-withdrawal cached value — invalidation falls out of the key design
142+
// rather than needing an explicit "on withdrawal, delete this key" hook.
143+
vi.setSystemTime(50_000);
144+
const service = new ClaimableAmountService({
145+
cacheTtlMs: 60_000, // deliberately long TTL to prove this isn't just a TTL expiry
146+
});
147+
148+
const preWithdrawalState = makeStreamState({
149+
streamId: 7,
150+
ratePerSecond: '10',
151+
depositedAmount: '1000',
152+
withdrawnAmount: '0',
153+
lastUpdateTime: 0,
154+
});
155+
156+
// Prime the cache with the pre-withdrawal state.
157+
const primed = service.getClaimableAmount(preWithdrawalState, 40);
158+
expect(primed.cached).toBe(false);
159+
expect(primed.claimableAmount).toBe('400'); // 40s * 10/s
160+
161+
// A repeated read with the identical state still hits the cache.
162+
const repeated = service.getClaimableAmount(preWithdrawalState, 40);
163+
expect(repeated.cached).toBe(true);
164+
165+
// Simulate the indexer processing a TokensWithdrawn event: withdrawnAmount
166+
// and lastUpdateTime are advanced on the stream row, exactly as
167+
// handleTokensWithdrawn does in soroban-event-worker.ts.
168+
const postWithdrawalState = makeStreamState({
169+
streamId: 7,
170+
ratePerSecond: '10',
171+
depositedAmount: '1000',
172+
withdrawnAmount: '400',
173+
lastUpdateTime: 40,
174+
});
175+
176+
// Well within the 60s TTL, so this only passes if the state change (not
177+
// TTL expiry) is what causes the fresh calculation.
178+
vi.advanceTimersByTime(1_000);
179+
const afterWithdrawal = service.getClaimableAmount(postWithdrawalState, 40);
180+
181+
expect(afterWithdrawal.cached).toBe(false);
182+
expect(afterWithdrawal.claimableAmount).toBe('0'); // fully withdrawn as of lastUpdateTime=40
183+
});
184+
134185
it('caps multiplication overflow at the remaining balance', () => {
135186
const i128Max = ((1n << 127n) - 1n).toString();
136187
vi.setSystemTime(1_000_000);

backend/tests/integration/streams.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,17 @@ describe('GET /v1/streams/:id/events — pagination and eventType filter', () =>
231231
expect(res.body).toHaveProperty('hasMore');
232232
expect(Array.isArray(res.body.data)).toBe(true);
233233
expect(res.body.hasMore).toBe(false);
234+
235+
const callArgs = mockPrisma.streamEvent.findMany.mock.calls[0]![0] as {
236+
where: { streamId: number };
237+
orderBy: { timestamp: string };
238+
take: number;
239+
skip: number;
240+
};
241+
expect(callArgs.where.streamId).toBe(1);
242+
expect(callArgs.orderBy).toEqual({ timestamp: 'desc' });
243+
expect(callArgs.take).toBe(10);
244+
expect(callArgs.skip).toBe(0);
234245
});
235246

236247
it('enforces default limit of 50', async () => {

0 commit comments

Comments
 (0)