Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"@types/supertest": "^6.0.3",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.6",
"@vitest/coverage-v8": "^2.1.8",
"@vitest/coverage-v8": "^3.2.4",
"eventsource": "^2.0.2",
"nodemon": "^3.1.11",
"prisma": "^7.4.1",
Expand Down
8 changes: 0 additions & 8 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,4 @@ model StreamEvent {
@@index([transactionHash])
@@index([createdAt])
@@index([streamId, createdAt])
@@unique([transactionHash, eventType])
}

// IndexerState model - tracks indexer cursor for resumable event processing
model IndexerState {
id String @id @default("singleton")
lastLedger Int @default(0)
updatedAt DateTime @updatedAt
}
4 changes: 2 additions & 2 deletions backend/src/controllers/sse.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ export const subscribe = async (req: Request, res: Response) => {
if (capacity.retryAfterSeconds) {
res.setHeader('Retry-After', String(capacity.retryAfterSeconds));
}
return res.status(capacity.status ?? 503).json({
message: capacity.message ?? 'SSE connection rejected',
return res.status(503).json({
message: 'SSE connection rejected',
});
}

Expand Down
81 changes: 78 additions & 3 deletions backend/src/services/sse.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Response } from 'express';
import logger from '../logger.js';
import { isRedisAvailable, getPublisher, getSubscriber } from '../lib/redis.js';

const HEARTBEAT_INTERVAL_MS = 30_000;
const MAX_WRITABLE_BUFFER = 64 * 1024;
Expand All @@ -16,7 +15,13 @@ export class SSEService {
private clients: Map<string, SSEClient> = new Map();
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private slowClientsDropped = 0;
private shuttingDown = false;
private ipConnections: Map<string, number> = new Map();
private perIpPeakConnections: Map<string, number> = new Map();
private maxConnections = 1000;
private maxConnectionsPerIp = 10;

addClient(clientId: string, res: Response, subscriptions: string[], ip?: string): void {
const client: SSEClient = {
id: clientId,
res,
Expand All @@ -25,12 +30,22 @@ export class SSEService {
};

this.clients.set(clientId, client);

if (ip) {
const currentCount = this.ipConnections.get(ip) || 0;
this.ipConnections.set(ip, currentCount + 1);
const peak = this.perIpPeakConnections.get(ip) || 0;
if (currentCount + 1 > peak) {
this.perIpPeakConnections.set(ip, currentCount + 1);
}
}

logger.info(
`[SSEService] Connection opened: ${clientId}, ip: ${ip}, subscriptions: ${subscriptions.join(', ')}`
);

res.on('close', () => {
this.removeClient(clientId);
this.removeClient(clientId, ip);
});

this.ensureHeartbeat();
Expand Down Expand Up @@ -74,6 +89,59 @@ export class SSEService {
return this.slowClientsDropped;
}

isShuttingDown(): boolean {
return this.shuttingDown;
}

checkCapacity(sourceIp: string): { allowed: boolean; retryAfterSeconds?: number } {
if (this.shuttingDown) {
return { allowed: false, retryAfterSeconds: 30 };
}

if (this.clients.size >= this.maxConnections) {
return { allowed: false, retryAfterSeconds: 60 };
}

const ipCount = this.ipConnections.get(sourceIp) || 0;
if (ipCount >= this.maxConnectionsPerIp) {
return { allowed: false, retryAfterSeconds: 300 };
}

return { allowed: true };
}

async initRedisSubscription(): Promise<void> {
// Redis subscription logic would go here
// For now, this is a no-op placeholder
}

sendReconnectToAll(): void {
this.shuttingDown = true;
this.broadcast('reconnect', { message: 'Server is restarting, please reconnect' });
}

getActiveIpCount(): number {
return this.ipConnections.size;
}

getPerIpPeakConnections(): number {
let total = 0;
for (const peak of this.perIpPeakConnections.values()) {
total += peak;
}
return total;
}

getMaxConnections(): number {
return this.maxConnections;
}

broadcastToAdmin(event: string, data: unknown): void {
this.broadcast(event, data, (client) =>
client.subscriptions.has('admin') || client.subscriptions.has('*')
);
}

stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
Expand All @@ -100,13 +168,20 @@ export class SSEService {
return res.socket?.writableLength ?? 0;
}

private removeClient(clientId: string, reason?: string): void {
private removeClient(clientId: string, ip?: string, reason?: string): void {
const client = this.clients.get(clientId);
if (!client) {
return;
}

this.clients.delete(clientId);

if (ip) {
const currentCount = this.ipConnections.get(ip) || 0;
if (currentCount > 0) {
this.ipConnections.set(ip, currentCount - 1);
}
}

try {
if (!client.res.writableEnded) {
Expand Down
6 changes: 5 additions & 1 deletion backend/tests/integration/stream-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,14 @@ function scvMap(entries: [string, xdr.ScVal][]): xdr.ScVal {
}

// Test database setup
import { createPgPoolConfig } from "../../src/lib/pg-pool.js";

const connectionString =
process.env.DATABASE_URL ||
"postgresql://postgres:password@127.0.0.1:5432/flowfi_test";
const testPool = new pg.Pool({ connectionString });
const testPoolConfig = createPgPoolConfig();
testPoolConfig.connectionString = connectionString;
const testPool = new pg.Pool(testPoolConfig);
const testAdapter = new PrismaPg(testPool);
const testPrisma = new PrismaClient({
adapter: testAdapter,
Expand Down
10 changes: 5 additions & 5 deletions backend/tests/sse.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ describe('SSEService backpressure', () => {
const failingRes = createMockResponse({ throwOnWrite: true });
const healthyRes = createMockResponse();

service.addClient('failing-client', failingRes);
service.addClient('healthy-client', healthyRes);
service.addClient('failing-client', failingRes, []);
service.addClient('healthy-client', healthyRes, []);

expect(service.getClientCount()).toBe(2);

Expand All @@ -72,8 +72,8 @@ describe('SSEService backpressure', () => {
});
const healthyRes = createMockResponse();

service.addClient('slow-client', slowRes);
service.addClient('healthy-client', healthyRes);
service.addClient('slow-client', slowRes, []);
service.addClient('healthy-client', healthyRes, []);

service.broadcast('stream.created', { streamId: 1 });

Expand All @@ -91,7 +91,7 @@ describe('SSEService backpressure', () => {
writableLength: MAX_WRITABLE_BUFFER,
});

service.addClient('slow-client', slowRes);
service.addClient('slow-client', slowRes, []);
service.sendHeartbeat();

expect(service.getClientCount()).toBe(0);
Expand Down
Loading
Loading