Skip to content
Merged
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
85 changes: 72 additions & 13 deletions src/run-in-loop/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>, 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' },
]);
});
});
30 changes: 29 additions & 1 deletion src/run-in-loop/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking we could have also added a start counter. E.g. you want to start from the current timestamp, but then realized you could just specify it as a prefix and also add a separator, e.g. 1771580212- which would make logs more readable.

Copy link
Contributor Author

@matejfalat matejfalat Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I mean it's trivial to add start option as well, but I can't really see a use case for it (as you noted, block numbers/timestamps are better in the prefix) 🤷

};

export interface RunInLoopOptions {
/** An API3 logger instance required to execute the callback with context. */
logger: Logger;
Expand Down Expand Up @@ -44,8 +62,16 @@ 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) =>
options.type === 'random' ? generateRandomBytes32() : `${options.prefix ?? ''}${iteration}`;

export const runInLoop = async (
fn: () => Promise<{ shouldContinueRunning: boolean } | void>,
options: RunInLoopOptions
Expand All @@ -60,6 +86,7 @@ export const runInLoop = async (
hardTimeoutMs,
enabled = true,
initialDelayMs,
executionIdOptions = { type: 'random' },
} = options;

if (hardTimeoutMs && hardTimeoutMs < softTimeoutMs) {
Expand All @@ -71,9 +98,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 };
Expand Down