Files

403 lines
11 KiB
TypeScript

import { createHash } from 'crypto';
export const WORKER_STATUSES = ['online', 'draining', 'offline'] as const;
export type WorkerStatus = (typeof WORKER_STATUSES)[number];
export const MAX_WORKER_ID_LENGTH = 128;
export const MAX_WORKER_CAPABILITIES_BYTES = 16 * 1024;
export const MAX_WORKER_EXECUTORS = 16;
export const MAX_WORKER_RUNTIMES = 32;
export const MAX_WORKER_LABELS = 32;
export const MAX_WORKER_FEATURES = 32;
export const MAX_WORKER_GPUS = 8;
export const MAX_WORKER_CONCURRENT_RUNS = 1024;
const UUID_V7_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const WORKER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
const CAPABILITY_NAME_PATTERN = /^[a-z0-9][a-z0-9._+-]*$/;
const LABEL_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
export interface WorkerRuntimeCapability {
name: string;
version: string;
}
export interface WorkerGpuCapability {
vendor: string;
model?: string;
memoryBytes?: number;
}
export interface WorkerCapacity {
cpuCores?: number;
memoryBytes?: number;
diskBytes?: number;
gpu?: readonly WorkerGpuCapability[];
}
export interface WorkerCapabilities {
architecture: string;
operatingSystem: string;
executors: readonly string[];
runtimes: readonly WorkerRuntimeCapability[];
labels: Readonly<Record<string, string>>;
capacity: WorkerCapacity;
features: readonly string[];
}
export interface WorkerRecord {
id: string;
sessionId: string;
generation: number;
status: WorkerStatus;
version: number;
capabilities: WorkerCapabilities;
capabilitiesHash: string;
maxConcurrentRuns: number;
availableSlots: number;
registeredAtMs: number;
lastHeartbeatAtMs: number;
leaseExpiresAtMs: number;
updatedAtMs: number;
}
export class InvalidWorkerValueError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidWorkerValueError';
}
}
export class WorkerFenceRejectedError extends Error {
constructor(
readonly workerId: string,
readonly reason:
| 'missing'
| 'session_mismatch'
| 'generation_mismatch'
| 'version_mismatch'
| 'lease_expired'
| 'offline',
) {
super(`Worker ${workerId} fence rejected: ${reason}`);
this.name = 'WorkerFenceRejectedError';
}
}
export class WorkerSessionConflictError extends Error {
constructor(readonly workerId: string) {
super(`Worker ${workerId} session replay conflicts with persisted data`);
this.name = 'WorkerSessionConflictError';
}
}
function invalid(message: string): never {
throw new InvalidWorkerValueError(message);
}
function record(value: unknown, name: string): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
invalid(`${name} must be an object`);
}
return value as Record<string, unknown>;
}
function assertKeys(
value: Record<string, unknown>,
name: string,
required: readonly string[],
optional: readonly string[] = [],
): void {
const allowed = new Set([...required, ...optional]);
if (
required.some((key) => !Object.hasOwn(value, key)) ||
Object.keys(value).some((key) => !allowed.has(key))
) {
invalid(`${name} fields do not match the supported schema`);
}
}
function boundedString(
value: unknown,
name: string,
maximum: number,
pattern?: RegExp,
): string {
if (
typeof value !== 'string' ||
value.length < 1 ||
value.length > maximum ||
value.includes('\0') ||
/[\u0000-\u001f\u007f]/.test(value) ||
(pattern && !pattern.test(value))
) {
invalid(`${name} is invalid`);
}
return value;
}
function positiveInteger(
value: unknown,
name: string,
maximum = Number.MAX_SAFE_INTEGER,
): number {
if (
!Number.isSafeInteger(value) ||
(value as number) < 1 ||
(value as number) > maximum
) {
invalid(`${name} must be a positive safe integer`);
}
return value as number;
}
function optionalPositiveInteger(
value: unknown,
name: string,
): number | undefined {
return value === undefined ? undefined : positiveInteger(value, name);
}
function uniqueSortedStrings(
value: unknown,
name: string,
maximumItems: number,
): string[] {
if (!Array.isArray(value) || value.length > maximumItems) {
invalid(`${name} must be an array with at most ${maximumItems} items`);
}
const normalized = value.map((item, index) =>
boundedString(item, `${name}[${index}]`, 64, CAPABILITY_NAME_PATTERN),
);
if (new Set(normalized).size !== normalized.length) {
invalid(`${name} must not contain duplicates`);
}
return normalized.sort();
}
function normalizeRuntimes(value: unknown): WorkerRuntimeCapability[] {
if (!Array.isArray(value) || value.length > MAX_WORKER_RUNTIMES) {
invalid(`runtimes must contain at most ${MAX_WORKER_RUNTIMES} items`);
}
const runtimes = value.map((candidate, index) => {
const item = record(candidate, `runtimes[${index}]`);
assertKeys(item, `runtimes[${index}]`, ['name', 'version']);
return {
name: boundedString(
item.name,
`runtimes[${index}].name`,
64,
CAPABILITY_NAME_PATTERN,
),
version: boundedString(item.version, `runtimes[${index}].version`, 64),
};
});
const keys = runtimes.map((item) => `${item.name}\0${item.version}`);
if (new Set(keys).size !== keys.length) {
invalid('runtimes must not contain duplicates');
}
return runtimes.sort(
(left, right) =>
left.name.localeCompare(right.name) ||
left.version.localeCompare(right.version),
);
}
function normalizeLabels(value: unknown): Record<string, string> {
const labels = record(value, 'labels');
const entries = Object.entries(labels);
if (entries.length > MAX_WORKER_LABELS) {
invalid(`labels must contain at most ${MAX_WORKER_LABELS} entries`);
}
return Object.fromEntries(
entries
.map(([key, candidate]) => [
boundedString(key, 'label key', 128, LABEL_KEY_PATTERN),
boundedString(candidate, `labels.${key}`, 256),
])
.sort(([left], [right]) => left.localeCompare(right)),
);
}
function normalizeGpu(value: unknown): WorkerGpuCapability[] | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value) || value.length > MAX_WORKER_GPUS) {
invalid(`capacity.gpu must contain at most ${MAX_WORKER_GPUS} items`);
}
return value.map((candidate, index) => {
const item = record(candidate, `capacity.gpu[${index}]`);
assertKeys(
item,
`capacity.gpu[${index}]`,
['vendor'],
['model', 'memoryBytes'],
);
return {
vendor: boundedString(
item.vendor,
`capacity.gpu[${index}].vendor`,
64,
CAPABILITY_NAME_PATTERN,
),
...(item.model === undefined
? {}
: {
model: boundedString(
item.model,
`capacity.gpu[${index}].model`,
128,
),
}),
...(item.memoryBytes === undefined
? {}
: {
memoryBytes: positiveInteger(
item.memoryBytes,
`capacity.gpu[${index}].memoryBytes`,
),
}),
};
});
}
function normalizeCapacity(value: unknown): WorkerCapacity {
const capacity = record(value, 'capacity');
assertKeys(
capacity,
'capacity',
[],
['cpuCores', 'memoryBytes', 'diskBytes', 'gpu'],
);
const cpuCores =
capacity.cpuCores === undefined
? undefined
: positiveInteger(capacity.cpuCores, 'capacity.cpuCores', 4096);
const memoryBytes = optionalPositiveInteger(
capacity.memoryBytes,
'capacity.memoryBytes',
);
const diskBytes = optionalPositiveInteger(
capacity.diskBytes,
'capacity.diskBytes',
);
const gpu = normalizeGpu(capacity.gpu);
return {
...(cpuCores === undefined ? {} : { cpuCores }),
...(memoryBytes === undefined ? {} : { memoryBytes }),
...(diskBytes === undefined ? {} : { diskBytes }),
...(gpu === undefined ? {} : { gpu }),
};
}
export function assertWorkerId(value: string): void {
boundedString(value, 'workerId', MAX_WORKER_ID_LENGTH, WORKER_ID_PATTERN);
}
export function assertWorkerSessionId(value: string): void {
if (!UUID_V7_PATTERN.test(value))
invalid('sessionId must be a lowercase UUIDv7');
}
export function normalizeWorkerCapabilities(
value: unknown,
): WorkerCapabilities {
const capabilities = record(value, 'capabilities');
assertKeys(capabilities, 'capabilities', [
'architecture',
'operatingSystem',
'executors',
'runtimes',
'labels',
'capacity',
'features',
]);
const normalized: WorkerCapabilities = {
architecture: boundedString(
capabilities.architecture,
'architecture',
32,
CAPABILITY_NAME_PATTERN,
),
operatingSystem: boundedString(
capabilities.operatingSystem,
'operatingSystem',
32,
CAPABILITY_NAME_PATTERN,
),
executors: uniqueSortedStrings(
capabilities.executors,
'executors',
MAX_WORKER_EXECUTORS,
),
runtimes: normalizeRuntimes(capabilities.runtimes),
labels: normalizeLabels(capabilities.labels),
capacity: normalizeCapacity(capabilities.capacity),
features: uniqueSortedStrings(
capabilities.features,
'features',
MAX_WORKER_FEATURES,
),
};
const serialized = JSON.stringify(normalized);
if (Buffer.byteLength(serialized, 'utf8') > MAX_WORKER_CAPABILITIES_BYTES) {
invalid('capabilities exceed the byte limit');
}
return normalized;
}
export function serializeWorkerCapabilities(value: unknown): string {
return JSON.stringify(normalizeWorkerCapabilities(value));
}
export function parseWorkerCapabilities(value: string): WorkerCapabilities {
if (
Buffer.byteLength(value, 'utf8') < 2 ||
Buffer.byteLength(value, 'utf8') > MAX_WORKER_CAPABILITIES_BYTES
) {
invalid('capabilities size is outside the allowed range');
}
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return invalid('capabilities are not valid JSON');
}
return normalizeWorkerCapabilities(parsed);
}
export function hashWorkerCapabilities(serialized: string): string {
const canonical = serializeWorkerCapabilities(
parseWorkerCapabilities(serialized),
);
return createHash('sha256').update(canonical).digest('hex');
}
export function assertWorkerConcurrency(
maxConcurrentRuns: number,
availableSlots: number,
): void {
positiveInteger(
maxConcurrentRuns,
'maxConcurrentRuns',
MAX_WORKER_CONCURRENT_RUNS,
);
if (
!Number.isSafeInteger(availableSlots) ||
availableSlots < 0 ||
availableSlots > maxConcurrentRuns
) {
invalid('availableSlots must be between 0 and maxConcurrentRuns');
}
}
export function isWorkerLeaseActive(
worker: WorkerRecord,
observedAtMs: number,
): boolean {
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
invalid('observedAtMs must be a non-negative safe integer');
}
return worker.status !== 'offline' && worker.leaseExpiresAtMs > observedAtMs;
}