diff --git a/indexer/index.mjs b/indexer/index.mjs index ef9e7fb..4acdfcf 100644 --- a/indexer/index.mjs +++ b/indexer/index.mjs @@ -109,7 +109,7 @@ function saveCursor(cursor) { let isPolling = false; let shutdownRequested = false; -let intervalId; +let pollingTimerId; let backoffTimeoutId; let resumeBackoff; let pendingStartLedger; @@ -142,8 +142,9 @@ function rpcCall(operation) { function handleShutdown(signal) { log("info", `Received ${signal}. Shutting down gracefully...`, { signal }); shutdownRequested = true; - if (intervalId) { - clearInterval(intervalId); + if (pollingTimerId) { + clearTimeout(pollingTimerId); + pollingTimerId = undefined; } if (backoffTimeoutId) { clearTimeout(backoffTimeoutId); @@ -242,6 +243,27 @@ async function poll() { } } +export async function runPollingLoop({ + pollFn = poll, + pollMs = POLL_MS, + schedule = (callback, delay) => setTimeout(callback, delay), + logger = (message) => console.error(message), +} = {}) { + try { + await pollFn(); + } catch (error) { + logger(error?.message ?? error); + } + + if (shutdownRequested) { + return; + } + + pollingTimerId = schedule(() => { + void runPollingLoop({ pollFn, pollMs, schedule, logger }); + }, pollMs); +} + const isMain = process.argv[1] && resolve(process.argv[1]).toLowerCase() === @@ -249,7 +271,15 @@ const isMain = let log = () => {}; let metricsTracker = createMetricsTracker(); -let RPC_URL, CONTRACT_ID, LOG_LEVEL, OUT, STATE, POLL_MS, BACKOFF_INITIAL_MS, BACKOFF_MAX_MS, server; +let RPC_URL, + CONTRACT_ID, + LOG_LEVEL, + OUT, + STATE, + POLL_MS, + BACKOFF_INITIAL_MS, + BACKOFF_MAX_MS, + server; if (isMain) { const config = validateConfig(); @@ -311,16 +341,13 @@ if (isMain) { logLevel: LOG_LEVEL, }); - await poll(); - intervalId = setInterval( - () => - poll().catch((e) => { - const metrics = metricsTracker.recordError(); - log("error", "Unhandled error in poll interval", { - error: e?.message ?? String(e), - errorsTotal: metrics.errorsTotal, - }); - }), - POLL_MS - ); + void runPollingLoop({ + logger: (message) => { + const metrics = metricsTracker.recordError(); + log("error", "Unhandled error in polling loop", { + error: String(message), + errorsTotal: metrics.errorsTotal, + }); + }, + }); } diff --git a/indexer/index.test.mjs b/indexer/index.test.mjs index 804e2c1..c38d68a 100644 --- a/indexer/index.test.mjs +++ b/indexer/index.test.mjs @@ -1,6 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; + import { initialScanPosition, parseArgs } from './cli.mjs'; + import { validateConfig, shouldLog, @@ -8,6 +10,7 @@ import { cursorLedger, calculateScanLag, createMetricsTracker, + runPollingLoop, } from './index.mjs'; test('validateConfig rejects missing required env values', () => { @@ -15,7 +18,6 @@ test('validateConfig rejects missing required env values', () => { CONTRACT_ID: '', RPC_URL: '', }); - assert.equal(result.ok, false); assert.match(result.error, /CONTRACT_ID/); assert.match(result.error, /RPC_URL/); @@ -26,7 +28,6 @@ test('validateConfig accepts populated env values and defaults LOG_LEVEL to info CONTRACT_ID: 'CC123', RPC_URL: 'https://example.com', }); - assert.equal(result.ok, true); assert.equal(result.value.CONTRACT_ID, 'CC123'); assert.equal(result.value.RPC_URL, 'https://example.com'); @@ -56,10 +57,8 @@ test('shouldLog respects log level severity threshold', () => { assert.equal(shouldLog('info', 'info'), true); assert.equal(shouldLog('info', 'warn'), true); assert.equal(shouldLog('info', 'error'), true); - assert.equal(shouldLog('warn', 'info'), false); assert.equal(shouldLog('warn', 'warn'), true); - assert.equal(shouldLog('debug', 'debug'), true); }); @@ -69,7 +68,6 @@ test('formatLogEntry formats structured JSON log entry', () => { scanLagLedgers: 5, }); const parsed = JSON.parse(raw); - assert.equal(parsed.level, 'info'); assert.equal(parsed.message, 'Poll completed'); assert.equal(parsed.eventsIndexedTotal, 42); @@ -82,7 +80,6 @@ test('cursorLedger extracts ledger sequence from cursor', () => { // 100 << 32 = 429496729600. Cursor format: "-" const packedLedger = (BigInt(100) << 32n).toString(); const cursor = `${packedLedger}-0000000001`; - assert.equal(cursorLedger(cursor), 100); assert.equal(cursorLedger('invalid'), null); assert.equal(cursorLedger(null), null); @@ -91,7 +88,6 @@ test('cursorLedger extracts ledger sequence from cursor', () => { test('calculateScanLag calculates ledger difference accurately', () => { const packedLedger = (BigInt(500) << 32n).toString(); const cursor = `${packedLedger}-0000000001`; - assert.equal(calculateScanLag(510, cursor), 10); assert.equal(calculateScanLag(500, cursor), 0); assert.equal(calculateScanLag(490, cursor), 0); // clamp to 0 if cursor ahead @@ -151,3 +147,74 @@ test('from-ledger overrides a saved cursor for the initial scan', () => { cursor: '999-1', }); }); + +test('runPollingLoop serializes polls and reschedules after failure', async () => { + let releaseFirstPoll; + let activePolls = 0; + let maxActivePolls = 0; + let pollCount = 0; + const scheduled = []; + const loggerMessages = []; + + const flush = async () => { + await Promise.resolve(); + await Promise.resolve(); + }; + + const pollFn = async () => { + pollCount += 1; + activePolls += 1; + maxActivePolls = Math.max(maxActivePolls, activePolls); + + try { + if (pollCount === 1) { + await new Promise((resolve) => { + releaseFirstPoll = resolve; + }); + } else if (pollCount === 2) { + throw new Error('boom'); + } + } finally { + activePolls -= 1; + } + }; + + const schedule = (callback, delay) => { + const entry = { callback, delay }; + scheduled.push(entry); + return entry; + }; + + const logger = (message) => loggerMessages.push(message); + + void runPollingLoop({ pollFn, pollMs: 50, schedule, logger }); + + assert.equal(activePolls, 1); + assert.equal(maxActivePolls, 1); + assert.equal(scheduled.length, 0); + + releaseFirstPoll(); + await flush(); + + assert.equal(activePolls, 0); + assert.equal(scheduled.length, 1); + assert.equal(scheduled[0].delay, 50); + + scheduled[0].callback(); + await flush(); + + assert.equal(pollCount, 2); + assert.equal(activePolls, 0); + assert.deepEqual(loggerMessages, ['boom']); + assert.equal(maxActivePolls, 1); + assert.equal(scheduled.length, 2); + + scheduled[1].callback(); + await flush(); + + assert.equal(pollCount, 3); + assert.equal(activePolls, 0); + assert.equal(maxActivePolls, 1); + assert.equal(scheduled.length, 3); + assert.equal(scheduled[2].delay, 50); +});