mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 02:27:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user