Skip to content

Commit 7af30b2

Browse files
authored
Merge pull request #595 from auraroom/fix-cache-pool-content-type-retention
fix: cache, pool sizing, content-type validation, and retention
2 parents 6a089d4 + 84ed3d2 commit 7af30b2

7 files changed

Lines changed: 207 additions & 2 deletions

File tree

‎src/config.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ const env = cleanEnv(rawEnv, {
160160
CACHE_WARM_CONCURRENCY: num({ default: 8 }),
161161
CACHE_WARM_JITTER_MS: num({ default: 0 }),
162162
CACHE_WARM_LOCK_TTL_MS: num({ default: 60000 }),
163+
DB_POOL_MIN: positiveInteger({ default: 2 }),
164+
DB_POOL_MAX: positiveInteger({ default: 10 }),
163165
});
164166

165167
const usdcIssuer = env.USDC_ISSUER;
@@ -179,6 +181,12 @@ const config = module.exports = {
179181
maxAgeSeconds: env.STATIC_ASSETS_MAX_AGE_SECONDS,
180182
},
181183
databaseUrl: env.DATABASE_URL,
184+
database: {
185+
pool: {
186+
min: env.DB_POOL_MIN,
187+
max: env.DB_POOL_MAX,
188+
},
189+
},
182190
redis: {
183191
url: env.REDIS_URL,
184192
},

‎src/database/index.js‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@ function afterCreateUtcConnection(conn, done) {
3232

3333
function getDb(overrideConfig = {}) {
3434
if (!db) {
35-
const pool = { min: 2, max: 10, afterCreate: afterCreateUtcConnection, ...(overrideConfig.pool || {}) };
35+
const pool = {
36+
min: overrideConfig.pool?.min ?? config.database.pool.min ?? 2,
37+
max: overrideConfig.pool?.max ?? config.database.pool.max ?? 10,
38+
afterCreate: afterCreateUtcConnection,
39+
...(overrideConfig.pool || {}),
40+
};
3641
db = knexFactory({
3742
client: 'pg',
3843
connection: overrideConfig.connection || config.databaseUrl,

‎src/index.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const {
3535
API_CSP_DIRECTIVES,
3636
docsCspMiddleware,
3737
} = require("./middleware/csp");
38+
const { contentTypeValidationMiddleware } = require("./middleware/contentTypeValidation");
3839
const requestLoggerMiddleware = require("./middleware/requestLogger");
3940
const { createStaticAssetsMiddleware } = require("./middleware/staticAssets");
4041
const {
@@ -145,6 +146,7 @@ app.use(buildCorsMiddleware(config.corsAllowedOrigins));
145146
// their own express.json({ limit }) middleware (#287).
146147
const DEFAULT_BODY_LIMIT = "1mb";
147148
app.use(express.json({ limit: DEFAULT_BODY_LIMIT }));
149+
app.use(contentTypeValidationMiddleware);
148150

149151
const EMPTY_QUEUE_STATS = {
150152
pendingRetries: null,
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
'use strict';
2+
3+
const logger = require('../logger');
4+
5+
const ALLOWED_CONTENT_TYPES = new Set([
6+
'application/json',
7+
'application/x-www-form-urlencoded',
8+
'multipart/form-data',
9+
'text/plain',
10+
'application/octet-stream',
11+
]);
12+
13+
function contentTypeValidationMiddleware(req, res, next) {
14+
const contentType = req.get('content-type');
15+
16+
if (!req.method || ['GET', 'HEAD', 'DELETE'].includes(req.method)) {
17+
return next();
18+
}
19+
20+
if (!contentType) {
21+
logger.warn('Request missing Content-Type header', {
22+
method: req.method,
23+
path: req.path,
24+
ip: req.ip,
25+
});
26+
return res.status(400).json({
27+
error: 'Content-Type header is required',
28+
code: 'MISSING_CONTENT_TYPE',
29+
});
30+
}
31+
32+
const baseContentType = contentType.split(';')[0].trim().toLowerCase();
33+
34+
if (!ALLOWED_CONTENT_TYPES.has(baseContentType)) {
35+
logger.warn('Request rejected: invalid Content-Type', {
36+
method: req.method,
37+
path: req.path,
38+
contentType: baseContentType,
39+
ip: req.ip,
40+
});
41+
return res.status(415).json({
42+
error: `Unsupported Media Type: ${baseContentType}`,
43+
code: 'UNSUPPORTED_MEDIA_TYPE',
44+
});
45+
}
46+
47+
next();
48+
}
49+
50+
module.exports = {
51+
contentTypeValidationMiddleware,
52+
};

‎src/repositories/deliveryRepository.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const crypto = require('crypto');
4848
const cache = require('../services/cache');
4949
const logger = require('../logger');
5050
const webhookRepository = require('./webhookRepository');
51+
const dataRetention = require('../services/dataRetention');
5152

5253
const RETRY_QUEUE_KEY = 'webhooks:retries';
5354
const RECENT_DELIVERIES_LIMIT = 100;
@@ -162,6 +163,8 @@ async function create({ webhook_id, event_id, event_type, trace_id, request_id }
162163
// the ids they can actually return.
163164
queueIndexWrite(multi, statusIndexKey(webhook_id, record.status), score, id);
164165
await multi.exec();
166+
// Apply data retention policy (#567)
167+
await dataRetention.applyDeliveryRetention(id);
165168
return record;
166169
}
167170

@@ -200,6 +203,8 @@ async function update(id, patch) {
200203
);
201204
}
202205
await multi.exec();
206+
// Apply data retention policy (#567)
207+
await dataRetention.applyDeliveryRetention(id);
203208
return next;
204209
}
205210

‎src/services/cache.js‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,24 @@ async function del(key) {
220220
}
221221
}
222222

223+
/**
224+
* Bulk delete operation for cache invalidation on batch updates (#564).
225+
* Invalidates multiple cache keys in parallel while respecting concurrency limits.
226+
*
227+
* @param {string[]} keys - Array of cache keys to delete
228+
* @returns {Promise<number>} Number of keys deleted
229+
*/
230+
async function delMany(keys) {
231+
if (!Array.isArray(keys) || keys.length === 0) return 0;
232+
const release = await operationSemaphore.acquire(5000);
233+
try {
234+
const redis = getClient();
235+
return await redis.del(...keys);
236+
} finally {
237+
release();
238+
}
239+
}
240+
223241
async function disconnect() {
224242
if (client) {
225243
await client.quit();
@@ -228,6 +246,6 @@ async function disconnect() {
228246
}
229247

230248
module.exports = {
231-
get, mget, set, del, disconnect, getClient, isConnected,
249+
get, mget, set, del, delMany, disconnect, getClient, isConnected,
232250
getCommandQueueLength, getConcurrencyStats,
233251
};

‎src/services/dataRetention.js‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
'use strict';
2+
3+
/**
4+
* Data retention policies service (#567).
5+
*
6+
* Manages automatic cleanup of old records based on configured retention periods.
7+
* Prevents storage bloat and helps with compliance/privacy concerns.
8+
*/
9+
10+
const cache = require('./cache');
11+
const logger = require('../logger');
12+
13+
function getEnvNumber(key, defaultValue) {
14+
const val = process.env[key];
15+
if (val !== undefined && val !== '') {
16+
const num = Number(val);
17+
if (Number.isSafeInteger(num) && num > 0) return num;
18+
}
19+
return defaultValue;
20+
}
21+
22+
const DELIVERY_RETENTION_DAYS = getEnvNumber('DELIVERY_LOG_RETENTION_DAYS', 30);
23+
const SESSION_RETENTION_DAYS = getEnvNumber('SESSION_RETENTION_DAYS', 7);
24+
25+
const DELIVERY_TTL_MS = DELIVERY_RETENTION_DAYS * 24 * 60 * 60 * 1000;
26+
const SESSION_TTL_MS = SESSION_RETENTION_DAYS * 24 * 60 * 60 * 1000;
27+
28+
/**
29+
* Applies retention policy to a cache record by setting its TTL.
30+
* Records older than the retention period are automatically removed by Redis.
31+
*
32+
* @param {string} key - Cache key
33+
* @param {number} ttlSeconds - TTL in seconds
34+
* @returns {Promise<void>}
35+
*/
36+
async function applyRetentionPolicy(key, ttlSeconds) {
37+
try {
38+
const redis = cache.getClient();
39+
if (redis) {
40+
await redis.expire(key, ttlSeconds);
41+
}
42+
} catch (err) {
43+
logger.warn('Failed to apply retention policy', { key, error: err.message });
44+
}
45+
}
46+
47+
/**
48+
* Applies delivery log retention policy.
49+
* Keeps delivery records for the configured retention period.
50+
*
51+
* @param {string} deliveryId - Delivery ID
52+
* @returns {Promise<void>}
53+
*/
54+
async function applyDeliveryRetention(deliveryId) {
55+
await applyRetentionPolicy(`webhook_delivery:${deliveryId}`, DELIVERY_RETENTION_DAYS * 24 * 60 * 60);
56+
}
57+
58+
/**
59+
* Applies session retention policy.
60+
* Expires old session records to prevent unbounded accumulation.
61+
*
62+
* @param {string} sessionId - Session ID
63+
* @returns {Promise<void>}
64+
*/
65+
async function applySessionRetention(sessionId) {
66+
await applyRetentionPolicy(`session:${sessionId}`, SESSION_RETENTION_DAYS * 24 * 60 * 60);
67+
}
68+
69+
/**
70+
* Cleanup callback for removing expired records from secondary indexes.
71+
* Called when a record expires, removes its entries from any associated indexes.
72+
*
73+
* @param {string} recordKey - The key of the expired record
74+
* @returns {Promise<void>}
75+
*/
76+
async function cleanupExpiredRecordIndexes(recordKey) {
77+
try {
78+
const redis = cache.getClient();
79+
if (!redis) return;
80+
81+
if (recordKey.startsWith('webhook_delivery:')) {
82+
const deliveryId = recordKey.substring('webhook_delivery:'.length);
83+
const pattern = `webhook:*:deliveries*`;
84+
const scan = (cursor) =>
85+
redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
86+
87+
let nextCursor = '0';
88+
do {
89+
const [cursor, keys] = await scan(nextCursor);
90+
nextCursor = cursor;
91+
92+
if (keys.length > 0) {
93+
const pipeline = redis.multi();
94+
keys.forEach((key) => {
95+
pipeline.zrem(key, deliveryId);
96+
});
97+
await pipeline.exec();
98+
}
99+
} while (nextCursor !== '0');
100+
}
101+
} catch (err) {
102+
logger.warn('Cleanup of record indexes failed', { recordKey, error: err.message });
103+
}
104+
}
105+
106+
module.exports = {
107+
DELIVERY_RETENTION_DAYS,
108+
SESSION_RETENTION_DAYS,
109+
DELIVERY_TTL_MS,
110+
SESSION_TTL_MS,
111+
applyRetentionPolicy,
112+
applyDeliveryRetention,
113+
applySessionRetention,
114+
cleanupExpiredRecordIndexes,
115+
};

0 commit comments

Comments
 (0)