From 6ca97ea950b519d540f421d40bdd5c2c77c166b6 Mon Sep 17 00:00:00 2001 From: jamilahmadzai Date: Sun, 12 Jul 2026 19:06:07 +0200 Subject: [PATCH] feat: warm configured price caches on startup --- .env.example | 5 +- src/config.js | 39 +++++++++++++ src/index.js | 18 +++++- src/startup/cacheWarm.js | 79 ++++++++++++++++++++++++++ test/cacheWarm.test.js | 118 +++++++++++++++++++++++++++++++++++++++ test/config.test.js | 33 +++++++++++ 6 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 src/startup/cacheWarm.js create mode 100644 test/cacheWarm.test.js diff --git a/.env.example b/.env.example index d8ad42b..bd0854a 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,9 @@ PRICE_STALE_THRESHOLD_MINUTES=5 # PRICE_ANOMALY_THRESHOLD_PCT: number. Default: 20. PRICE_ANOMALY_THRESHOLD_PCT=20 +# WATCHED_ASSETS: comma-separated CODE or CODE:ISSUER values warmed before startup. +WATCHED_ASSETS=XLM,USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN + # API key auth ADMIN_API_KEY= @@ -53,4 +56,4 @@ ADMIN_API_KEY= LOG_LEVEL=info # CORS -CORS_ALLOWED_ORIGINS=http://localhost:4000,http://localhost:3001 \ No newline at end of file +CORS_ALLOWED_ORIGINS=http://localhost:4000,http://localhost:3001 diff --git a/src/config.js b/src/config.js index 80f1547..fce0cc5 100644 --- a/src/config.js +++ b/src/config.js @@ -9,6 +9,40 @@ const stellarAddress = makeValidator((input) => { return input; }); +function parseWatchedAssets(input) { + if (!input || !input.trim()) return []; + + const seen = new Set(); + return input + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const [code, issuer, extra] = entry.split(':'); + + if (extra !== undefined) { + throw new Error(`invalid asset "${entry}"; expected CODE or CODE:ISSUER`); + } + + if (!/^[A-Z0-9]{1,12}$/.test(code)) { + throw new Error(`invalid asset code "${code}"; expected 1-12 uppercase alphanumeric characters`); + } + + if (issuer !== undefined && !/^G[A-Z0-9]{55}$/.test(issuer)) { + throw new Error(`invalid issuer for "${code}"; expected a Stellar public key`); + } + + const asset = { code, issuer: issuer || null }; + const key = asset.issuer ? `${asset.code}:${asset.issuer}` : asset.code; + if (seen.has(key)) return null; + seen.add(key); + return asset; + }) + .filter(Boolean); +} + +const watchedAssets = makeValidator(parseWatchedAssets); + const databaseDevDefault = process.env.NODE_ENV === 'test' ? 'postgres://localhost/smartdrop_test' @@ -38,6 +72,7 @@ const env = cleanEnv(rawEnv, { PRICE_REFRESH_INTERVAL_SECONDS: num({ default: 30 }), PRICE_STALE_THRESHOLD_MINUTES: num({ default: 5 }), PRICE_ANOMALY_THRESHOLD_PCT: num({ default: 20 }), + WATCHED_ASSETS: watchedAssets({ default: '' }), LOG_LEVEL: str({ default: 'info', choices: ['debug', 'info', 'warn', 'error'], @@ -45,6 +80,9 @@ const env = cleanEnv(rawEnv, { }); const usdcIssuer = env.USDC_ISSUER; +const parsedWatchedAssets = Array.isArray(env.WATCHED_ASSETS) + ? env.WATCHED_ASSETS + : parseWatchedAssets(env.WATCHED_ASSETS); module.exports = { nodeEnv: env.NODE_ENV, @@ -75,6 +113,7 @@ module.exports = { staleThresholdMinutes: env.PRICE_STALE_THRESHOLD_MINUTES, anomalyThresholdPercent: env.PRICE_ANOMALY_THRESHOLD_PCT, }, + watchedAssets: parsedWatchedAssets, auth: { adminApiKey: env.ADMIN_API_KEY, }, diff --git a/src/index.js b/src/index.js index f7a5edf..5a935a8 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ const logger = require('./logger'); const cache = require('./services/cache'); const priceRefreshJob = require('./jobs/priceRefresh'); const webhookRetryWorker = require('./jobs/webhookRetryWorker'); +const { warmCache } = require('./startup/cacheWarm'); const buildCorsMiddleware = require('./middleware/cors'); const { requestIdMiddleware } = require('./middleware/requestId'); const { requireApiKey } = require('./middleware/auth'); @@ -62,15 +63,27 @@ function shutdown(signal) { } if (require.main === module) { + startServer().catch((err) => { + logger.error('Startup failed', { error: err.message }); + process.exit(1); + }); + + process.on('SIGTERM', shutdown('SIGTERM')); + process.on('SIGINT', shutdown('SIGINT')); +} + +async function startServer() { + await warmCache(config.watchedAssets); + server = app.listen(config.port, () => { logger.info(`SmartDrop backend running on port ${config.port}`); priceWebSocket.attach(server); priceRefreshJob.start(); webhookRetryWorker.start(); }); + module.exports.server = server; - process.on('SIGTERM', shutdown('SIGTERM')); - process.on('SIGINT', shutdown('SIGINT')); + return server; } module.exports = app; @@ -80,3 +93,4 @@ module.exports.server = server || { if (callback) callback(); }, }; +module.exports.startServer = startServer; diff --git a/src/startup/cacheWarm.js b/src/startup/cacheWarm.js new file mode 100644 index 0000000..b2498bb --- /dev/null +++ b/src/startup/cacheWarm.js @@ -0,0 +1,79 @@ +'use strict'; + +const config = require('../config'); +const logger = require('../logger'); +const priceOracle = require('../services/priceOracle'); + +const DEFAULT_TIMEOUT_MS = 30000; + +function isWarmSuccess(result) { + return ( + result.status === 'fulfilled' && + result.value && + result.value.price_usd !== null && + result.value.redis_unavailable !== true + ); +} + +async function runWarmCache(assets, oracle) { + const startedAt = Date.now(); + const results = await Promise.allSettled( + assets.map(({ code, issuer }) => ( + Promise.resolve().then(() => oracle.fetchFreshPrice(code, issuer || null)) + )) + ); + const succeeded = results.filter(isWarmSuccess).length; + + return { + total: assets.length, + succeeded, + failed: assets.length - succeeded, + timedOut: false, + durationMs: Date.now() - startedAt, + }; +} + +async function warmCache( + assets = config.watchedAssets, + oracle = priceOracle, + { timeoutMs = DEFAULT_TIMEOUT_MS, log = logger } = {} +) { + if (!assets || assets.length === 0) { + log.info('Cache warm skipped: no watched assets configured'); + return { total: 0, succeeded: 0, failed: 0, timedOut: false, durationMs: 0 }; + } + + let timedOut = false; + let timeoutId; + + const warming = runWarmCache(assets, oracle).then((summary) => { + if (!timedOut) { + log.info('Cache warm complete', summary); + } + return summary; + }); + + const timeout = new Promise((resolve) => { + timeoutId = setTimeout(() => { + timedOut = true; + const summary = { + total: assets.length, + succeeded: 0, + failed: assets.length, + timedOut: true, + durationMs: timeoutMs, + }; + log.warn('Cache warm timed out; starting server anyway', summary); + resolve(summary); + }, timeoutMs); + }); + + const summary = await Promise.race([warming, timeout]); + if (!summary.timedOut) clearTimeout(timeoutId); + return summary; +} + +module.exports = { + warmCache, + runWarmCache, +}; diff --git a/test/cacheWarm.test.js b/test/cacheWarm.test.js new file mode 100644 index 0000000..3fdc2ab --- /dev/null +++ b/test/cacheWarm.test.js @@ -0,0 +1,118 @@ +'use strict'; + +jest.mock('../src/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +jest.mock('../src/services/priceOracle', () => ({ + fetchFreshPrice: jest.fn(), +})); + +const logger = require('../src/logger'); +const priceOracle = require('../src/services/priceOracle'); +const { warmCache } = require('../src/startup/cacheWarm'); + +function asset(code, issuer = null) { + return { code, issuer }; +} + +describe('startup cache warming', () => { + beforeEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + test('skips warming when no assets are configured', async () => { + const summary = await warmCache([], priceOracle, { log: logger }); + + expect(summary).toEqual({ + total: 0, + succeeded: 0, + failed: 0, + timedOut: false, + durationMs: 0, + }); + expect(priceOracle.fetchFreshPrice).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith('Cache warm skipped: no watched assets configured'); + }); + + test('fetches all configured assets and counts cached successes', async () => { + priceOracle.fetchFreshPrice + .mockResolvedValueOnce({ price_usd: 0.12, redis_unavailable: false }) + .mockResolvedValueOnce({ price_usd: 1.0, redis_unavailable: false }) + .mockResolvedValueOnce({ price_usd: null, redis_unavailable: false }); + + const assets = [ + asset('XLM'), + asset('USDC', 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), + asset('BAD'), + ]; + + const summary = await warmCache(assets, priceOracle, { log: logger }); + + expect(priceOracle.fetchFreshPrice).toHaveBeenCalledTimes(3); + expect(priceOracle.fetchFreshPrice).toHaveBeenNthCalledWith(1, 'XLM', null); + expect(priceOracle.fetchFreshPrice).toHaveBeenNthCalledWith( + 2, + 'USDC', + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + ); + expect(priceOracle.fetchFreshPrice).toHaveBeenNthCalledWith(3, 'BAD', null); + expect(summary).toMatchObject({ total: 3, succeeded: 2, failed: 1, timedOut: false }); + expect(logger.info).toHaveBeenCalledWith('Cache warm complete', expect.objectContaining({ + total: 3, + succeeded: 2, + failed: 1, + timedOut: false, + })); + }); + + test('starts all asset fetches before awaiting settlement', async () => { + let resolveXlm; + let resolveUsdc; + const xlmPromise = new Promise((resolve) => { resolveXlm = resolve; }); + const usdcPromise = new Promise((resolve) => { resolveUsdc = resolve; }); + + priceOracle.fetchFreshPrice + .mockReturnValueOnce(xlmPromise) + .mockReturnValueOnce(usdcPromise); + + const warming = warmCache([asset('XLM'), asset('USDC')], priceOracle, { log: logger }); + await Promise.resolve(); + + expect(priceOracle.fetchFreshPrice).toHaveBeenCalledTimes(2); + + resolveXlm({ price_usd: 0.12, redis_unavailable: false }); + resolveUsdc({ price_usd: 1.0, redis_unavailable: false }); + await expect(warming).resolves.toMatchObject({ succeeded: 2, failed: 0 }); + }); + + test('returns a timeout summary when warming takes too long', async () => { + jest.useFakeTimers(); + priceOracle.fetchFreshPrice.mockReturnValue(new Promise(() => {})); + + const warming = warmCache([asset('XLM')], priceOracle, { + timeoutMs: 25, + log: logger, + }); + + jest.advanceTimersByTime(25); + await expect(warming).resolves.toEqual({ + total: 1, + succeeded: 0, + failed: 1, + timedOut: true, + durationMs: 25, + }); + expect(logger.warn).toHaveBeenCalledWith('Cache warm timed out; starting server anyway', { + total: 1, + succeeded: 0, + failed: 1, + timedOut: true, + durationMs: 25, + }); + }); +}); diff --git a/test/config.test.js b/test/config.test.js index ee0c74a..ff3f890 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -52,6 +52,7 @@ describe('configuration validation', () => { ' databaseUrl: config.databaseUrl,', ' redisUrl: config.redis.url,', ' price: config.price,', + ' watchedAssets: config.watchedAssets,', '}));', ].join(' '), { NODE_ENV: 'test' } @@ -70,6 +71,38 @@ describe('configuration validation', () => { staleThresholdMinutes: 5, anomalyThresholdPercent: 20, }, + watchedAssets: [], }); }); + + test('parses watched assets from WATCHED_ASSETS', () => { + const issuer = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + const result = runConfig( + [ + "const config = require('./src/config');", + 'console.log(JSON.stringify(config.watchedAssets));', + ].join(' '), + { + NODE_ENV: 'test', + WATCHED_ASSETS: `XLM,USDC:${issuer},XLM`, + } + ); + + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout.trim())).toEqual([ + { code: 'XLM', issuer: null }, + { code: 'USDC', issuer }, + ]); + }); + + test('rejects malformed watched assets during config loading', () => { + const result = runConfig("require('./src/config')", { + NODE_ENV: 'test', + WATCHED_ASSETS: 'usdc:not-a-stellar-address', + }); + + const output = `${result.stdout}\n${result.stderr}`; + expect(result.status).toBe(1); + expect(output).toContain('WATCHED_ASSETS'); + }); });