From 93fc1cad3fa00c56b4e347e21c11ef5e54f06149 Mon Sep 17 00:00:00 2001 From: Matej Falat Date: Thu, 19 Feb 2026 17:26:43 +0100 Subject: [PATCH 1/2] Support incremental executionId in runInLoop --- src/run-in-loop/index.test.ts | 85 +++++++++++++++++++++++++++++------ src/run-in-loop/index.ts | 34 +++++++++++++- 2 files changed, 105 insertions(+), 14 deletions(-) diff --git a/src/run-in-loop/index.test.ts b/src/run-in-loop/index.test.ts index 1380055a..1714e06d 100644 --- a/src/run-in-loop/index.test.ts +++ b/src/run-in-loop/index.test.ts @@ -1,23 +1,82 @@ -import { createLogger } from '../logger'; +import { type Logger } from '../logger'; +import * as utils from '../utils'; import { runInLoop } from './index'; +const createMockLogger = (): Logger => ({ + runWithContext: jest.fn((_: Record, fn: () => any) => fn()) as Logger['runWithContext'], + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() as Logger['error'], + child: jest.fn() as Logger['child'], +}); + +const createTestRunInLoopFunction = (executions: number) => { + let callCount = 0; + + return jest.fn(async () => { + callCount += 1; + return { shouldContinueRunning: callCount < executions }; + }); +}; + +const getRunContexts = (mockedLogger: Logger) => { + const runWithContextMock = jest.mocked(mockedLogger.runWithContext); + return runWithContextMock.mock.calls.map(([context]) => context); +}; + describe(runInLoop.name, () => { - const logger = createLogger({ - colorize: true, - enabled: true, - minLevel: 'info', - format: 'json', + afterEach(() => { + jest.restoreAllMocks(); }); it('stops the loop after getting the stop signal', async () => { - const fn = async () => ({ shouldContinueRunning: false }); - const fnSpy = jest - .spyOn({ fn }, 'fn') - .mockImplementationOnce(async () => ({ shouldContinueRunning: true })) - .mockImplementationOnce(async () => ({ shouldContinueRunning: true })) - .mockImplementationOnce(async () => ({ shouldContinueRunning: false })); - await runInLoop(fnSpy as any, { logger }); + const mockedLogger = createMockLogger(); + const fnSpy = createTestRunInLoopFunction(3); + await runInLoop(fnSpy, { logger: mockedLogger }); expect(fnSpy).toHaveBeenCalledTimes(3); }); + + it('uses random execution IDs by default', async () => { + const mockedLogger = createMockLogger(); + const fnSpy = createTestRunInLoopFunction(1); + jest.spyOn(utils, 'generateRandomBytes32').mockReturnValue('0xrandom'); + + await runInLoop(fnSpy, { logger: mockedLogger }); + + expect(getRunContexts(mockedLogger)).toStrictEqual([{ executionId: '0xrandom' }]); + }); + + it('uses incremental execution IDs', async () => { + const mockedLogger = createMockLogger(); + const fnSpy = createTestRunInLoopFunction(3); + + await runInLoop(fnSpy, { + logger: mockedLogger, + executionIdOptions: { type: 'incremental' }, + }); + + expect(getRunContexts(mockedLogger)).toStrictEqual([ + { executionId: '0' }, + { executionId: '1' }, + { executionId: '2' }, + ]); + }); + + it('uses incremental execution IDs with prefix', async () => { + const mockedLogger = createMockLogger(); + const fnSpy = createTestRunInLoopFunction(3); + + await runInLoop(fnSpy, { + logger: mockedLogger, + executionIdOptions: { type: 'incremental', prefix: 'worker' }, + }); + + expect(getRunContexts(mockedLogger)).toStrictEqual([ + { executionId: 'worker-0' }, + { executionId: 'worker-1' }, + { executionId: 'worker-2' }, + ]); + }); }); diff --git a/src/run-in-loop/index.ts b/src/run-in-loop/index.ts index 1cba13da..1b10b18e 100644 --- a/src/run-in-loop/index.ts +++ b/src/run-in-loop/index.ts @@ -3,6 +3,24 @@ import { go } from '@api3/promise-utils'; import { type Logger } from '../logger'; import { generateRandomBytes32, sleep } from '../utils'; +export type RunInLoopExecutionIdOptions = + | { + /** + * Generate a random 32-byte execution ID for each iteration. + */ + type: 'random'; + } + | { + /** + * Generate execution IDs as incrementing numbers starting from 0. + */ + type: 'incremental'; + /** + * Optional prefix prepended to the incrementing number (e.g. "my-prefix-0"). + */ + prefix?: string; + }; + export interface RunInLoopOptions { /** An API3 logger instance required to execute the callback with context. */ logger: Logger; @@ -44,8 +62,20 @@ export interface RunInLoopOptions { * callback is executed immediately. */ initialDelayMs?: number; + + /** + * Configures how execution IDs are generated. Defaults to random IDs. + */ + executionIdOptions?: RunInLoopExecutionIdOptions; } +const getExecutionId = (iteration: number, options: RunInLoopExecutionIdOptions) => { + if (options.type === 'random') { + return generateRandomBytes32(); + } + return options.prefix ? `${options.prefix}-${iteration}`.trim() : iteration.toString(); +}; + export const runInLoop = async ( fn: () => Promise<{ shouldContinueRunning: boolean } | void>, options: RunInLoopOptions @@ -60,6 +90,7 @@ export const runInLoop = async ( hardTimeoutMs, enabled = true, initialDelayMs, + executionIdOptions = { type: 'random' }, } = options; if (hardTimeoutMs && hardTimeoutMs < softTimeoutMs) { @@ -71,9 +102,10 @@ export const runInLoop = async ( if (initialDelayMs) await sleep(initialDelayMs); + let iteration = 0; while (true) { const executionStart = performance.now(); - const executionId = generateRandomBytes32(); + const executionId = getExecutionId(iteration++, executionIdOptions); if (enabled) { const context = logLabel ? { executionId, label: logLabel } : { executionId }; From e107e4a245c8532914cedcfec5a17cfcc2e56902 Mon Sep 17 00:00:00 2001 From: Matej Falat Date: Fri, 20 Feb 2026 11:45:39 +0100 Subject: [PATCH 2/2] Review fixes --- src/run-in-loop/index.test.ts | 2 +- src/run-in-loop/index.ts | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/run-in-loop/index.test.ts b/src/run-in-loop/index.test.ts index 1714e06d..7df3d98a 100644 --- a/src/run-in-loop/index.test.ts +++ b/src/run-in-loop/index.test.ts @@ -70,7 +70,7 @@ describe(runInLoop.name, () => { await runInLoop(fnSpy, { logger: mockedLogger, - executionIdOptions: { type: 'incremental', prefix: 'worker' }, + executionIdOptions: { type: 'incremental', prefix: 'worker-' }, }); expect(getRunContexts(mockedLogger)).toStrictEqual([ diff --git a/src/run-in-loop/index.ts b/src/run-in-loop/index.ts index 1b10b18e..4197901b 100644 --- a/src/run-in-loop/index.ts +++ b/src/run-in-loop/index.ts @@ -69,12 +69,8 @@ export interface RunInLoopOptions { executionIdOptions?: RunInLoopExecutionIdOptions; } -const getExecutionId = (iteration: number, options: RunInLoopExecutionIdOptions) => { - if (options.type === 'random') { - return generateRandomBytes32(); - } - return options.prefix ? `${options.prefix}-${iteration}`.trim() : iteration.toString(); -}; +const getExecutionId = (iteration: number, options: RunInLoopExecutionIdOptions) => + options.type === 'random' ? generateRandomBytes32() : `${options.prefix ?? ''}${iteration}`; export const runInLoop = async ( fn: () => Promise<{ shouldContinueRunning: boolean } | void>,