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
53 changes: 50 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { DepositController } from './controllers/depositController.js';
import { VaultController } from './controllers/vaultController.js';
import { TransactionBuilderService } from './services/transactionBuilder.js';
import { requestIdMiddleware } from './middleware/requestId.js';
import { createMemoryAccountingMiddleware } from './middleware/memoryAccounting.js';
import { validate } from './middleware/validate.js';
import { requestLogger } from './middleware/logging.js';
import { auditEnrichMiddleware } from './middleware/auditEnrich.js';
Expand Down Expand Up @@ -146,6 +147,8 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
}));

app.use(requestIdMiddleware);
const memoryAccountingMiddleware = createMemoryAccountingMiddleware(config.memoryAccounting);
app.use(memoryAccountingMiddleware);
app.use(metricsMiddleware);

app.use(requestLogger);
Expand Down
7 changes: 7 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@ export const envSchema = z
.string()
.transform((v) => v === "true")
.default(false),

// Memory accounting
MEMORY_ACCOUNTING_ENABLED: z
.string()
.transform((v) => v === "true")
.default(false),
MEMORY_ACCOUNTING_THRESHOLD_MB: z.coerce.number().nonnegative().default(50),
// Test-only chaos harness
SOROBAN_CHAOS: z
.string()
Expand Down
5 changes: 5 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,4 +209,9 @@ export const config = {
warmupTimeoutMs: env.LISTINGS_CACHE_WARMUP_TIMEOUT_MS,
},
bulkEndpointLimit: env.BULK_ENDPOINT_LIMIT,

memoryAccounting: {
enabled: env.MEMORY_ACCOUNTING_ENABLED,
thresholdMb: env.MEMORY_ACCOUNTING_THRESHOLD_MB,
},
} as const;
228 changes: 228 additions & 0 deletions src/middleware/memoryAccounting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import type { Request, Response, NextFunction } from 'express';
import { EventEmitter } from 'events';
import { logger } from './logging.js';
import { createMemoryAccountingMiddleware } from './memoryAccounting.js';

describe('createMemoryAccountingMiddleware', () => {
let warnSpy: jest.SpyInstance;
let memoryUsageSpy: jest.SpyInstance;

beforeEach(() => {
warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
memoryUsageSpy = jest.spyOn(process, 'memoryUsage').mockImplementation(() => ({
heapUsed: 50 * 1024 * 1024,
heapTotal: 100 * 1024 * 1024,
rss: 150 * 1024 * 1024,
arrayBuffers: 0,
external: 0,
}));
});

afterEach(() => {
warnSpy.mockRestore();
memoryUsageSpy.mockRestore();
});

function makeReq(overrides?: Partial<Request>): Request {
return {
id: 'test-req-id',
method: 'GET',
path: '/test',
header: jest.fn(),
...overrides,
} as unknown as Request;
}

function makeRes(): Response {
const res = new EventEmitter() as unknown as Response;
res.statusCode = 200;
res.setHeader = jest.fn();
res.getHeader = jest.fn();
res.headersSent = false;
return res;
}

describe('when disabled', () => {
test('calls next immediately without attaching finish listener', () => {
const middleware = createMemoryAccountingMiddleware({ enabled: false, thresholdMb: 50 });
const req = makeReq();
const res = makeRes();
const next = jest.fn() as NextFunction;

const listenerCountBefore = res.listenerCount('finish');
middleware(req, res, next);
const listenerCountAfter = res.listenerCount('finish');

expect(next).toHaveBeenCalledTimes(1);
expect(listenerCountAfter).toBe(listenerCountBefore);
expect(warnSpy).not.toHaveBeenCalled();
});

test('does not sample heap when disabled', () => {
const middleware = createMemoryAccountingMiddleware({ enabled: false, thresholdMb: 50 });
middleware(makeReq(), makeRes(), jest.fn() as NextFunction);
expect(memoryUsageSpy).not.toHaveBeenCalled();
});
});

describe('when enabled', () => {
test('calls next and registers finish listener', () => {
const middleware = createMemoryAccountingMiddleware({ enabled: true, thresholdMb: 50 });
const req = makeReq();
const res = makeRes();
const next = jest.fn() as NextFunction;

middleware(req, res, next);

expect(next).toHaveBeenCalledTimes(1);
expect(res.listenerCount('finish')).toBe(1);
});

test('logs warning when heap delta exceeds threshold', () => {
const middleware = createMemoryAccountingMiddleware({ enabled: true, thresholdMb: 10 });
const req = makeReq();
const res = makeRes();
const next = jest.fn() as NextFunction;

memoryUsageSpy
.mockReturnValueOnce({ heapUsed: 10 * 1024 * 1024, heapTotal: 50 * 1024 * 1024, rss: 100 * 1024 * 1024, arrayBuffers: 0, external: 0 })
.mockReturnValueOnce({ heapUsed: 30 * 1024 * 1024, heapTotal: 50 * 1024 * 1024, rss: 100 * 1024 * 1024, arrayBuffers: 0, external: 0 });

middleware(req, res, next);
res.emit('finish');

expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.objectContaining({
requestId: 'test-req-id',
method: 'GET',
path: '/test',
heapDeltaBytes: 20 * 1024 * 1024,
thresholdMb: 10,
}),
'memory threshold exceeded'
);
});

test('does not log when heap delta is within threshold', () => {
const middleware = createMemoryAccountingMiddleware({ enabled: true, thresholdMb: 50 });
const req = makeReq();
const res = makeRes();
const next = jest.fn() as NextFunction;

memoryUsageSpy
.mockReturnValueOnce({ heapUsed: 10 * 1024 * 1024, heapTotal: 50 * 1024 * 1024, rss: 100 * 1024 * 1024, arrayBuffers: 0, external: 0 })
.mockReturnValueOnce({ heapUsed: 20 * 1024 * 1024, heapTotal: 50 * 1024 * 1024, rss: 100 * 1024 * 1024, arrayBuffers: 0, external: 0 });

middleware(req, res, next);
res.emit('finish');

expect(warnSpy).not.toHaveBeenCalled();
});

test('does not log when heap delta is zero', () => {
const middleware = createMemoryAccountingMiddleware({ enabled: true, thresholdMb: 1 });
const req = makeReq();
const res = makeRes();
const next = jest.fn() as NextFunction;

memoryUsageSpy
.mockReturnValueOnce({ heapUsed: 25 * 1024 * 1024, heapTotal: 50 * 1024 * 1024, rss: 100 * 1024 * 1024, arrayBuffers: 0, external: 0 })
.mockReturnValueOnce({ heapUsed: 25 * 1024 * 1024, heapTotal: 50 * 1024 * 1024, rss: 100 * 1024 * 1024, arrayBuffers: 0, external: 0 });

middleware(req, res, next);
res.emit('finish');

expect(warnSpy).not.toHaveBeenCalled();
});

test('does not log when heap delta is negative (GC reclaimed memory)', () => {
const middleware = createMemoryAccountingMiddleware({ enabled: true, thresholdMb: 1 });
const req = makeReq();
const res = makeRes();
const next = jest.fn() as NextFunction;

memoryUsageSpy
.mockReturnValueOnce({ heapUsed: 30 * 1024 * 1024, heapTotal: 50 * 1024 * 1024, rss: 100 * 1024 * 1024, arrayBuffers: 0, external: 0 })
.mockReturnValueOnce({ heapUsed: 10 * 1024 * 1024, heapTotal: 50 * 1024 * 1024, rss: 100 * 1024 * 1024, arrayBuffers: 0, external: 0 });

middleware(req, res, next);
res.emit('finish');

expect(warnSpy).not.toHaveBeenCalled();
});

test('uses fallback requestId when req.id is not set', () => {
const middleware = createMemoryAccountingMiddleware({ enabled: true, thresholdMb: 0 });
const req = makeReq({ id: undefined as unknown as string });
const res = makeRes();
const next = jest.fn() as NextFunction;

memoryUsageSpy
.mockReturnValueOnce({ heapUsed: 0, heapTotal: 50 * 1024 * 1024, rss: 100 * 1024 * 1024, arrayBuffers: 0, external: 0 })
.mockReturnValueOnce({ heapUsed: 10 * 1024 * 1024, heapTotal: 50 * 1024 * 1024, rss: 100 * 1024 * 1024, arrayBuffers: 0, external: 0 });

middleware(req, res, next);
res.emit('finish');

expect(warnSpy).toHaveBeenCalledWith(
expect.objectContaining({
requestId: undefined,
heapDeltaBytes: 10 * 1024 * 1024,
}),
'memory threshold exceeded'
);
});

test('uses req.id as requestId when set', () => {
const middleware = createMemoryAccountingMiddleware({ enabled: true, thresholdMb: 0 });
const req = makeReq({ id: 'explicit-id' });
const res = makeRes();
const next = jest.fn() as NextFunction;

memoryUsageSpy
.mockReturnValueOnce({ heapUsed: 0, heapTotal: 50 * 1024 * 1024, rss: 100 * 1024 * 1024, arrayBuffers: 0, external: 0 })
.mockReturnValueOnce({ heapUsed: 5 * 1024 * 1024, heapTotal: 50 * 1024 * 1024, rss: 100 * 1024 * 1024, arrayBuffers: 0, external: 0 });

middleware(req, res, next);
res.emit('finish');

expect(warnSpy).toHaveBeenCalledWith(
expect.objectContaining({ requestId: 'explicit-id' }),
'memory threshold exceeded'
);
});

test('warns with threshold of 0 on any positive delta', () => {
const middleware = createMemoryAccountingMiddleware({ enabled: true, thresholdMb: 0 });
const req = makeReq();
const res = makeRes();
const next = jest.fn() as NextFunction;

memoryUsageSpy
.mockReturnValueOnce({ heapUsed: 10 * 1024 * 1024, heapTotal: 50 * 1024 * 1024, rss: 100 * 1024 * 1024, arrayBuffers: 0, external: 0 })
.mockReturnValueOnce({ heapUsed: 10 * 1024 * 1024 + 1, heapTotal: 50 * 1024 * 1024, rss: 100 * 1024 * 1024, arrayBuffers: 0, external: 0 });

middleware(req, res, next);
res.emit('finish');

expect(warnSpy).toHaveBeenCalledTimes(1);
});

test('samples heap at start and end of request', () => {
const middleware = createMemoryAccountingMiddleware({ enabled: true, thresholdMb: 50 });
const req = makeReq();
const res = makeRes();
const next = jest.fn() as NextFunction;

memoryUsageSpy
.mockReturnValueOnce({ heapUsed: 5 * 1024 * 1024, heapTotal: 50 * 1024 * 1024, rss: 100 * 1024 * 1024, arrayBuffers: 0, external: 0 })
.mockReturnValueOnce({ heapUsed: 10 * 1024 * 1024, heapTotal: 50 * 1024 * 1024, rss: 100 * 1024 * 1024, arrayBuffers: 0, external: 0 });

middleware(req, res, next);
res.emit('finish');

expect(memoryUsageSpy).toHaveBeenCalledTimes(2);
});
});
});
Loading