Skip to content
6 changes: 2 additions & 4 deletions packages/utils/src/lib/create-runner-files.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { writeFile } from 'node:fs/promises';
import path from 'node:path';
import { threadId } from 'node:worker_threads';
import type { RunnerFilesPaths } from '@code-pushup/models';
import { ensureDirectoryExists, pluginWorkDir } from './file-system.js';
import { getUniqueProcessThreadId } from './process-id.js';

/**
* Function to create timestamp nested plugin runner files for config and output.
Expand All @@ -14,9 +14,7 @@ export async function createRunnerFiles(
pluginSlug: string,
configJSON: string,
): Promise<RunnerFilesPaths> {
// Use timestamp + process ID + threadId
// This prevents race conditions when running the same plugin for multiple projects in parallel
const uniqueId = `${(performance.timeOrigin + performance.now()) * 10}-${process.pid}-${threadId}`;
const uniqueId = getUniqueProcessThreadId();
const runnerWorkDir = path.join(pluginWorkDir(pluginSlug), uniqueId);
const runnerConfigPath = path.join(runnerWorkDir, 'plugin-config.json');
const runnerOutputPath = path.join(runnerWorkDir, 'runner-output.json');
Expand Down
113 changes: 113 additions & 0 deletions packages/utils/src/lib/process-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import process from 'node:process';
import { threadId } from 'node:worker_threads';

/**
* Counter interface for generating sequential instance IDs.
* Encapsulates increment logic within the counter-implementation.
*/
export type Counter = {
/**
* Returns the next counter-value and increments the internal state.
* @returns The next counter-value
*/
next: () => number;
};

/**
* Base regex pattern for time ID format: yyyymmdd-hhmmss-ms
*/
export const TIME_ID_BASE = /\d{8}-\d{6}-\d{3}/;

/**
* Regex patterns for validating process and instance ID formats.
* All patterns use strict anchors (^ and $) to ensure complete matches.
*/
export const ID_PATTERNS = Object.freeze({
/**
* Time ID / Run ID format: yyyymmdd-hhmmss-ms
* Example: "20240101-120000-000"
* Used by: getUniqueTimeId()
*/
TIME_ID: new RegExp(`^${TIME_ID_BASE.source}$`),
/**
* Group ID format: alias by convention, semantically represents a group of instances
* Example: "20240101-120000-000"
* Used by: grouping related instances by time
*/
GROUP_ID: new RegExp(`^${TIME_ID_BASE.source}$`),
/**
* Process/Thread ID format: timeId-pid-threadId
* Example: "20240101-120000-000-12345-1"
* Used by: getUniqueProcessThreadId()
*/
PROCESS_THREAD_ID: new RegExp(`^${TIME_ID_BASE.source}-\\d+-\\d+$`),
/**
* Instance ID format: timeId.pid.threadId.counter
* Example: "20240101-120000-000.12345.1.1"
* Used by: getUniqueInstanceId()
*/
INSTANCE_ID: new RegExp(`^${TIME_ID_BASE.source}\\.\\d+\\.\\d+\\.\\d+$`),
} as const);

/**
* Generates a unique run ID.
* This ID uniquely identifies a run/execution with a globally unique, sortable, human-readable date string.
* Format: yyyymmdd-hhmmss-ms
* Example: "20240101-120000-000"
*
* @returns A unique run ID string in readable date format
*/
export function getUniqueTimeId(): string {
return sortableReadableDateString(
Math.floor(performance.timeOrigin + performance.now()),
);
}

/**
* Generates a unique process/thread ID.
* This ID uniquely identifies a process/thread execution and prevents race conditions when running
* the same plugin for multiple projects in parallel.
* Format: timeId-pid-threadId
* Example: "20240101-120000-000-12345-1"
*
* @returns A unique ID string combining timestamp, process ID, and thread ID
*/
export function getUniqueProcessThreadId(): string {
return `${getUniqueTimeId()}-${process.pid}-${threadId}`;
}

/**
* Generates a unique instance ID based on performance time origin, process ID, thread ID, and instance count.
* This ID uniquely identifies an instance across processes and threads.
* Format: timestamp.pid.threadId.counter
* Example: "20240101-120000-000.12345.1.1"
*
* @param counter - Counter that provides the next instance count value
* @returns A unique ID string combining timestamp, process ID, thread ID, and counter
*/
export function getUniqueInstanceId(counter: Counter): string {
return `${getUniqueTimeId()}.${process.pid}.${threadId}.${counter.next()}`;
}

/**
* Converts a timestamp in milliseconds to a sortable, human-readable date string.
* Format: yyyymmdd-hhmmss-ms
* Example: "20240101-120000-000"
*
* @param timestampMs - Timestamp in milliseconds
* @returns A sortable date string in yyyymmdd-hhmmss-ms format
*/
export function sortableReadableDateString(timestampMs: number): string {
const date = new Date(timestampMs);
const MILLISECONDS_PER_SECOND = 1000;
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const ms = String(timestampMs % MILLISECONDS_PER_SECOND).padStart(3, '0');

return `${yyyy}${mm}${dd}-${hh}${min}${ss}-${ms}`;
}
189 changes: 189 additions & 0 deletions packages/utils/src/lib/process-id.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { threadId } from 'node:worker_threads';
import {
type Counter,
ID_PATTERNS,
TIME_ID_BASE,
getUniqueInstanceId,
getUniqueProcessThreadId,
getUniqueTimeId,
sortableReadableDateString,
} from './process-id.js';

describe('TIME_ID_BASE', () => {
it.each([
'20231114-221320-000',
'20240101-120000-000',
'20231231-235959-999',
])('should match valid time ID format: %s', timeId => {
expect(timeId).toMatch(TIME_ID_BASE);
});

it.each(['2023-11-14', '20231114', '20231114-221320', 'invalid'])(
'should not match invalid time ID format: %s',
timeId => {
expect(timeId).not.toMatch(TIME_ID_BASE);
},
);
});

describe('ID_PATTERNS', () => {
it.each(['20231114-221320-000', '20240101-120000-000'])(
'TIME_ID should match valid time ID: %s',
timeId => {
expect(timeId).toMatch(ID_PATTERNS.TIME_ID);
},
);

it.each(['20231114-221320-000.123', '20231114-221320'])(
'TIME_ID should not match invalid format: %s',
timeId => {
expect(timeId).not.toMatch(ID_PATTERNS.TIME_ID);
},
);

it.each(['20231114-221320-000'])(
'GROUP_ID should match valid group ID: %s',
groupId => {
expect(groupId).toMatch(ID_PATTERNS.GROUP_ID);
},
);

it.each(['20231114-221320-000-12345-1', '20240101-120000-000-99999-99'])(
'PROCESS_THREAD_ID should match valid process/thread ID: %s',
processThreadId => {
expect(processThreadId).toMatch(ID_PATTERNS.PROCESS_THREAD_ID);
},
);

it.each(['20231114-221320-000', '20231114-221320-000-12345'])(
'PROCESS_THREAD_ID should not match invalid format: %s',
processThreadId => {
expect(processThreadId).not.toMatch(ID_PATTERNS.PROCESS_THREAD_ID);
},
);

it.each(['20231114-221320-000.12345.1.1', '20240101-120000-000.99999.99.42'])(
'INSTANCE_ID should match valid instance ID: %s',
instanceId => {
expect(instanceId).toMatch(ID_PATTERNS.INSTANCE_ID);
},
);

it.each(['20231114-221320-000', '20231114-221320-000-12345-1'])(
'INSTANCE_ID should not match invalid format: %s',
instanceId => {
expect(instanceId).not.toMatch(ID_PATTERNS.INSTANCE_ID);
},
);

it.each(['20231114-221320-000.12345.1.1'])(
'INSTANCE_ID should match valid instance ID: %s',
instanceId => {
expect(instanceId).toMatch(ID_PATTERNS.INSTANCE_ID);
},
);

it.each(['20231114-221320-000'])(
'TIME_ID should match valid time ID: %s',
timeId => {
expect(timeId).toMatch(ID_PATTERNS.TIME_ID);
},
);
});

describe('sortableReadableDateString', () => {
it('should format timestamp correctly', () => {
const timestamp = 1_700_000_000_000; // 2023-11-14 22:13:20.000
const result = sortableReadableDateString(timestamp);
expect(result).toBe('20231114-221320-000');
expect(result).toMatch(TIME_ID_BASE);
});
});

describe('getUniqueTimeId', () => {
it('should generate time ID with mocked timeOrigin', () => {
const result = getUniqueTimeId();

expect(result).toMatch(ID_PATTERNS.TIME_ID);
expect(result).toMatch(ID_PATTERNS.GROUP_ID);
expect(result).toBe('20231114-221320-000');
});

it('should generate new ID on each call (not idempotent)', () => {
let callCount = 0;
vi.spyOn(performance, 'now').mockImplementation(() => callCount++);

const id1 = getUniqueTimeId();
const id2 = getUniqueTimeId();

expect(id1).not.toBe(id2);
expect(id1).toMatch(ID_PATTERNS.TIME_ID);
expect(id2).toMatch(ID_PATTERNS.TIME_ID);
});
});

describe('getUniqueProcessThreadId', () => {
it('should generate process/thread ID with correct format', () => {
const result = getUniqueProcessThreadId();

expect(result).toMatch(ID_PATTERNS.PROCESS_THREAD_ID);
expect(result).toContain(`-10001-${threadId}`);
expect(result).toStartWith('20231114-221320-000');
});

it('should generate new ID on each call (not idempotent)', () => {
let callCount = 0;
vi.spyOn(performance, 'now').mockImplementation(() => callCount++);

const id1 = getUniqueProcessThreadId();
const id2 = getUniqueProcessThreadId();

expect(id1).not.toBe(id2);
expect(id1).toMatch(ID_PATTERNS.PROCESS_THREAD_ID);
expect(id2).toMatch(ID_PATTERNS.PROCESS_THREAD_ID);
});
});

describe('getUniqueInstanceId', () => {
it('should generate instance ID with correct format', () => {
let counter = 0;
const counterObj: Counter = {
next: () => ++counter,
};

const result = getUniqueInstanceId(counterObj);

expect(result).toMatch(ID_PATTERNS.INSTANCE_ID);
expect(result).toStartWith('20231114-221320-000.');
expect(result).toContain(`.10001.${threadId}.`);
expect(result).toEndWith('.1');
});

it('should use counter to generate incrementing instance IDs', () => {
let counter = 0;
const counterObj: Counter = {
next: () => ++counter,
};

const results = [
getUniqueInstanceId(counterObj),
getUniqueInstanceId(counterObj),
getUniqueInstanceId(counterObj),
];

expect(results[0]).toEndWith('.1');
expect(results[1]).toEndWith('.2');
expect(results[2]).toEndWith('.3');
});

it('should generate different IDs for different calls', () => {
let counter = 0;
const counterObj: Counter = {
next: () => ++counter,
};

expect(getUniqueInstanceId(counterObj)).not.toBe(
getUniqueInstanceId(counterObj),
);
});
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{"cat":"blink.user_timing","ph":"i","name":"stats-profiler:operation-1:start","pid":10001,"tid":1,"ts":1700000005000000,"args":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}
{"cat":"blink.user_timing","ph":"b","name":"stats-profiler:operation-1","id2":{"local":"0x1"},"pid":10001,"tid":1,"ts":1700000005000001,"args":{"data":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}}
{"cat":"blink.user_timing","ph":"e","name":"stats-profiler:operation-1","id2":{"local":"0x1"},"pid":10001,"tid":1,"ts":1700000005000002,"args":{"data":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}}
{"cat":"blink.user_timing","ph":"i","name":"stats-profiler:operation-1:end","pid":10001,"tid":1,"ts":1700000005000003,"args":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}
{"cat":"blink.user_timing","ph":"i","name":"stats-profiler:operation-2:start","pid":10001,"tid":1,"ts":1700000005000004,"args":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}
{"cat":"blink.user_timing","ph":"b","name":"stats-profiler:operation-2","id2":{"local":"0x2"},"pid":10001,"tid":1,"ts":1700000005000005,"args":{"data":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}}
{"cat":"blink.user_timing","ph":"e","name":"stats-profiler:operation-2","id2":{"local":"0x2"},"pid":10001,"tid":1,"ts":1700000005000006,"args":{"data":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}}
{"cat":"blink.user_timing","ph":"i","name":"stats-profiler:operation-2:end","pid":10001,"tid":1,"ts":1700000005000007,"args":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}
{"cat":"blink.user_timing","pid":10001,"tid":1,"ts":1700000005000000,"name":"stats-profiler:operation-1:start","ph":"I","args":{"data":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}}
{"cat":"blink.user_timing","pid":10001,"tid":1,"ts":1700000005000001,"name":"stats-profiler:operation-1","ph":"b","id2":{"local":"0x1"},"args":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}
{"cat":"blink.user_timing","pid":10001,"tid":1,"ts":1700000005000002,"name":"stats-profiler:operation-1","ph":"e","id2":{"local":"0x1"},"args":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}
{"cat":"blink.user_timing","pid":10001,"tid":1,"ts":1700000005000003,"name":"stats-profiler:operation-1:end","ph":"I","args":{"data":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}}
{"cat":"blink.user_timing","pid":10001,"tid":1,"ts":1700000005000004,"name":"stats-profiler:operation-2:start","ph":"I","args":{"data":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}}
{"cat":"blink.user_timing","pid":10001,"tid":1,"ts":1700000005000005,"name":"stats-profiler:operation-2","ph":"b","id2":{"local":"0x2"},"args":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}
{"cat":"blink.user_timing","pid":10001,"tid":1,"ts":1700000005000006,"name":"stats-profiler:operation-2","ph":"e","id2":{"local":"0x2"},"args":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}
{"cat":"blink.user_timing","pid":10001,"tid":1,"ts":1700000005000007,"name":"stats-profiler:operation-2:end","ph":"I","args":{"data":{"detail":"{\"devtools\":{\"track\":\"Stats\",\"dataType\":\"track-entry\"}}"}}}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{"cat":"blink.user_timing","ph":"i","name":"api-server:user-lookup:start","pid":10001,"tid":1,"ts":1700000005000000,"args":{"detail":"{\"devtools\":{\"track\":\"cache\",\"dataType\":\"track-entry\"}}"}}
{"cat":"blink.user_timing","ph":"b","name":"api-server:user-lookup","id2":{"local":"0x1"},"pid":10001,"tid":1,"ts":1700000005000001,"args":{"data":{"detail":"{\"devtools\":{\"track\":\"cache\",\"dataType\":\"track-entry\"}}"}}}
{"cat":"blink.user_timing","ph":"e","name":"api-server:user-lookup","id2":{"local":"0x1"},"pid":10001,"tid":1,"ts":1700000005000002,"args":{"data":{"detail":"{\"devtools\":{\"track\":\"cache\",\"dataType\":\"track-entry\"}}"}}}
{"cat":"blink.user_timing","ph":"i","name":"api-server:user-lookup:end","pid":10001,"tid":1,"ts":1700000005000003,"args":{"detail":"{\"devtools\":{\"track\":\"cache\",\"dataType\":\"track-entry\"}}"}}
{"cat":"blink.user_timing","pid":10001,"tid":1,"ts":1700000005000000,"name":"api-server:user-lookup:start","ph":"I","args":{"data":{"detail":"{\"devtools\":{\"track\":\"cache\",\"dataType\":\"track-entry\"}}"}}}
{"cat":"blink.user_timing","pid":10001,"tid":1,"ts":1700000005000001,"name":"api-server:user-lookup","ph":"b","id2":{"local":"0x1"},"args":{"detail":"{\"devtools\":{\"track\":\"cache\",\"dataType\":\"track-entry\"}}"}}
{"cat":"blink.user_timing","pid":10001,"tid":1,"ts":1700000005000002,"name":"api-server:user-lookup","ph":"e","id2":{"local":"0x1"},"args":{"detail":"{\"devtools\":{\"track\":\"cache\",\"dataType\":\"track-entry\"}}"}}
{"cat":"blink.user_timing","pid":10001,"tid":1,"ts":1700000005000003,"name":"api-server:user-lookup:end","ph":"I","args":{"data":{"detail":"{\"devtools\":{\"track\":\"cache\",\"dataType\":\"track-entry\"}}"}}}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{"cat":"blink.user_timing","ph":"i","name":"write-test:test-operation:start","pid":10001,"tid":1,"ts":1700000005000000,"args":{"detail":"{\"devtools\":{\"track\":\"Test\",\"dataType\":\"track-entry\"}}"}}
{"cat":"blink.user_timing","ph":"b","name":"write-test:test-operation","id2":{"local":"0x1"},"pid":10001,"tid":1,"ts":1700000005000001,"args":{"data":{"detail":"{\"devtools\":{\"track\":\"Test\",\"dataType\":\"track-entry\"}}"}}}
{"cat":"blink.user_timing","ph":"e","name":"write-test:test-operation","id2":{"local":"0x1"},"pid":10001,"tid":1,"ts":1700000005000002,"args":{"data":{"detail":"{\"devtools\":{\"track\":\"Test\",\"dataType\":\"track-entry\"}}"}}}
{"cat":"blink.user_timing","ph":"i","name":"write-test:test-operation:end","pid":10001,"tid":1,"ts":1700000005000003,"args":{"detail":"{\"devtools\":{\"track\":\"Test\",\"dataType\":\"track-entry\"}}"}}
{"cat":"blink.user_timing","pid":10001,"tid":1,"ts":1700000005000000,"name":"write-test:test-operation:start","ph":"I","args":{"data":{"detail":"{\"devtools\":{\"track\":\"Test\",\"dataType\":\"track-entry\"}}"}}}
{"cat":"blink.user_timing","pid":10001,"tid":1,"ts":1700000005000001,"name":"write-test:test-operation","ph":"b","id2":{"local":"0x1"},"args":{"detail":"{\"devtools\":{\"track\":\"Test\",\"dataType\":\"track-entry\"}}"}}
{"cat":"blink.user_timing","pid":10001,"tid":1,"ts":1700000005000002,"name":"write-test:test-operation","ph":"e","id2":{"local":"0x1"},"args":{"detail":"{\"devtools\":{\"track\":\"Test\",\"dataType\":\"track-entry\"}}"}}
{"cat":"blink.user_timing","pid":10001,"tid":1,"ts":1700000005000003,"name":"write-test:test-operation:end","ph":"I","args":{"data":{"detail":"{\"devtools\":{\"track\":\"Test\",\"dataType\":\"track-entry\"}}"}}}
6 changes: 6 additions & 0 deletions packages/utils/src/lib/profiler/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,9 @@ export const PROFILER_DEBUG_ENV_VAR = 'CP_PROFILER_DEBUG';
*/
export const SHARDED_WAL_COORDINATOR_ID_ENV_VAR =
'CP_SHARDED_WAL_COORDINATOR_ID';

/**
* Default base name for WAL files.
* Used as the base name for sharded WAL files (e.g., "trace" in "trace.json").
*/
export const PROFILER_PERSIST_BASENAME = 'trace';
Loading