mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
431 lines
12 KiB
TypeScript
431 lines
12 KiB
TypeScript
import { satisfies, valid, validRange } from 'semver';
|
|
import {
|
|
isWorkerLeaseActive,
|
|
type WorkerCapabilities,
|
|
type WorkerRecord,
|
|
} from './worker';
|
|
|
|
export const MAX_PLACEMENT_VALUES = 16;
|
|
export const MAX_PLACEMENT_PREFERENCES = 16;
|
|
export const MAX_PLACEMENT_CANDIDATES = 64;
|
|
|
|
export interface WorkerRuntimeRequirement {
|
|
name: string;
|
|
versionRange?: string;
|
|
}
|
|
|
|
export interface WorkerPlacementRequired {
|
|
architectures?: readonly string[];
|
|
operatingSystems?: readonly string[];
|
|
executors?: readonly string[];
|
|
runtimes?: readonly WorkerRuntimeRequirement[];
|
|
labels?: Readonly<Record<string, string>>;
|
|
minMemoryBytes?: number;
|
|
minDiskBytes?: number;
|
|
gpuVendor?: string;
|
|
features?: readonly string[];
|
|
}
|
|
|
|
export interface WorkerPlacementPreference {
|
|
labels: Readonly<Record<string, string>>;
|
|
weight: number;
|
|
}
|
|
|
|
export interface WorkerPlacementSpec {
|
|
required?: WorkerPlacementRequired;
|
|
preferred?: readonly WorkerPlacementPreference[];
|
|
}
|
|
|
|
export type WorkerPlacementMismatch =
|
|
| 'worker_unavailable'
|
|
| 'architecture'
|
|
| 'operating_system'
|
|
| 'executor'
|
|
| 'runtime'
|
|
| 'label'
|
|
| 'memory'
|
|
| 'disk'
|
|
| 'gpu'
|
|
| 'feature';
|
|
|
|
export interface WorkerPlacementDecision {
|
|
matches: boolean;
|
|
score: number;
|
|
mismatches: readonly WorkerPlacementMismatch[];
|
|
}
|
|
|
|
export interface WorkerPlacementCandidate {
|
|
worker: WorkerRecord;
|
|
score: number;
|
|
}
|
|
|
|
function invalid(message: string): never {
|
|
throw new TypeError(`Worker PlacementSpec is invalid: ${message}`);
|
|
}
|
|
|
|
function object(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,
|
|
keys: readonly string[],
|
|
): void {
|
|
if (Object.keys(value).some((key) => !keys.includes(key))) {
|
|
invalid(`${name} contains an unknown field`);
|
|
}
|
|
}
|
|
|
|
function stringValue(value: unknown, name: string, maximum = 128): string {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length < 1 ||
|
|
value.length > maximum ||
|
|
value.includes('\0') ||
|
|
/[\u0000-\u001f\u007f]/.test(value)
|
|
) {
|
|
invalid(`${name} is invalid`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function stringList(value: unknown, name: string): string[] | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (!Array.isArray(value) || value.length > MAX_PLACEMENT_VALUES) {
|
|
invalid(`${name} must contain at most ${MAX_PLACEMENT_VALUES} values`);
|
|
}
|
|
const values = value.map((item, index) =>
|
|
stringValue(item, `${name}[${index}]`, 64),
|
|
);
|
|
if (new Set(values).size !== values.length) {
|
|
invalid(`${name} must not contain duplicates`);
|
|
}
|
|
return values.sort();
|
|
}
|
|
|
|
function labels(
|
|
value: unknown,
|
|
name: string,
|
|
): Record<string, string> | undefined {
|
|
if (value === undefined) return undefined;
|
|
const source = object(value, name);
|
|
const entries = Object.entries(source);
|
|
if (entries.length > MAX_PLACEMENT_VALUES) {
|
|
invalid(`${name} must contain at most ${MAX_PLACEMENT_VALUES} labels`);
|
|
}
|
|
return Object.fromEntries(
|
|
entries
|
|
.map(([key, candidate]) => [
|
|
stringValue(key, `${name} key`, 128),
|
|
stringValue(candidate, `${name}.${key}`, 256),
|
|
])
|
|
.sort(([left], [right]) => left.localeCompare(right)),
|
|
);
|
|
}
|
|
|
|
function positiveInteger(value: unknown, name: string): number | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
|
invalid(`${name} must be a positive safe integer`);
|
|
}
|
|
return value as number;
|
|
}
|
|
|
|
function runtimes(value: unknown): WorkerRuntimeRequirement[] | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (!Array.isArray(value) || value.length > MAX_PLACEMENT_VALUES) {
|
|
invalid(
|
|
`required.runtimes must contain at most ${MAX_PLACEMENT_VALUES} values`,
|
|
);
|
|
}
|
|
const requirements = value.map((candidate, index) => {
|
|
const requirement = object(candidate, `required.runtimes[${index}]`);
|
|
assertKeys(requirement, `required.runtimes[${index}]`, [
|
|
'name',
|
|
'versionRange',
|
|
]);
|
|
const name = stringValue(
|
|
requirement.name,
|
|
`required.runtimes[${index}].name`,
|
|
64,
|
|
);
|
|
if (requirement.versionRange === undefined) return { name };
|
|
const versionRange = stringValue(
|
|
requirement.versionRange,
|
|
`required.runtimes[${index}].versionRange`,
|
|
128,
|
|
);
|
|
if (!validRange(versionRange)) {
|
|
invalid(`required.runtimes[${index}].versionRange is not semver`);
|
|
}
|
|
return { name, versionRange };
|
|
});
|
|
if (
|
|
new Set(requirements.map((item) => item.name)).size !== requirements.length
|
|
) {
|
|
invalid('required.runtimes must not repeat a runtime name');
|
|
}
|
|
return requirements.sort((left, right) =>
|
|
left.name.localeCompare(right.name),
|
|
);
|
|
}
|
|
|
|
function normalizeRequired(
|
|
value: unknown,
|
|
): WorkerPlacementRequired | undefined {
|
|
if (value === undefined) return undefined;
|
|
const required = object(value, 'required');
|
|
assertKeys(required, 'required', [
|
|
'architectures',
|
|
'operatingSystems',
|
|
'executors',
|
|
'runtimes',
|
|
'labels',
|
|
'minMemoryBytes',
|
|
'minDiskBytes',
|
|
'gpuVendor',
|
|
'features',
|
|
]);
|
|
const architectures = stringList(
|
|
required.architectures,
|
|
'required.architectures',
|
|
);
|
|
const operatingSystems = stringList(
|
|
required.operatingSystems,
|
|
'required.operatingSystems',
|
|
);
|
|
const executors = stringList(required.executors, 'required.executors');
|
|
const runtimeRequirements = runtimes(required.runtimes);
|
|
const requiredLabels = labels(required.labels, 'required.labels');
|
|
const minMemoryBytes = positiveInteger(
|
|
required.minMemoryBytes,
|
|
'required.minMemoryBytes',
|
|
);
|
|
const minDiskBytes = positiveInteger(
|
|
required.minDiskBytes,
|
|
'required.minDiskBytes',
|
|
);
|
|
const gpuVendor =
|
|
required.gpuVendor === undefined
|
|
? undefined
|
|
: stringValue(required.gpuVendor, 'required.gpuVendor', 64);
|
|
const features = stringList(required.features, 'required.features');
|
|
return {
|
|
...(architectures === undefined ? {} : { architectures }),
|
|
...(operatingSystems === undefined ? {} : { operatingSystems }),
|
|
...(executors === undefined ? {} : { executors }),
|
|
...(runtimeRequirements === undefined
|
|
? {}
|
|
: { runtimes: runtimeRequirements }),
|
|
...(requiredLabels === undefined ? {} : { labels: requiredLabels }),
|
|
...(minMemoryBytes === undefined ? {} : { minMemoryBytes }),
|
|
...(minDiskBytes === undefined ? {} : { minDiskBytes }),
|
|
...(gpuVendor === undefined ? {} : { gpuVendor }),
|
|
...(features === undefined ? {} : { features }),
|
|
};
|
|
}
|
|
|
|
function normalizePreferred(
|
|
value: unknown,
|
|
): WorkerPlacementPreference[] | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (!Array.isArray(value) || value.length > MAX_PLACEMENT_PREFERENCES) {
|
|
invalid(
|
|
`preferred must contain at most ${MAX_PLACEMENT_PREFERENCES} values`,
|
|
);
|
|
}
|
|
return value.map((candidate, index) => {
|
|
const preference = object(candidate, `preferred[${index}]`);
|
|
assertKeys(preference, `preferred[${index}]`, ['labels', 'weight']);
|
|
const preferredLabels = labels(
|
|
preference.labels,
|
|
`preferred[${index}].labels`,
|
|
);
|
|
if (!preferredLabels || Object.keys(preferredLabels).length === 0) {
|
|
invalid(`preferred[${index}].labels must not be empty`);
|
|
}
|
|
if (
|
|
!Number.isSafeInteger(preference.weight) ||
|
|
(preference.weight as number) < 1 ||
|
|
(preference.weight as number) > 100
|
|
) {
|
|
invalid(`preferred[${index}].weight must be between 1 and 100`);
|
|
}
|
|
return {
|
|
labels: preferredLabels,
|
|
weight: preference.weight as number,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function normalizeWorkerPlacementSpec(
|
|
value: unknown,
|
|
): WorkerPlacementSpec {
|
|
const placement = object(value, 'placement');
|
|
assertKeys(placement, 'placement', ['required', 'preferred']);
|
|
const required = normalizeRequired(placement.required);
|
|
const preferred = normalizePreferred(placement.preferred);
|
|
return {
|
|
...(required === undefined ? {} : { required }),
|
|
...(preferred === undefined ? {} : { preferred }),
|
|
};
|
|
}
|
|
|
|
function hasLabels(
|
|
capabilities: WorkerCapabilities,
|
|
expected: Readonly<Record<string, string>>,
|
|
): boolean {
|
|
return Object.entries(expected).every(
|
|
([key, value]) => capabilities.labels[key] === value,
|
|
);
|
|
}
|
|
|
|
function hasRuntime(
|
|
capabilities: WorkerCapabilities,
|
|
requirement: WorkerRuntimeRequirement,
|
|
): boolean {
|
|
return capabilities.runtimes.some((runtime) => {
|
|
if (runtime.name !== requirement.name) return false;
|
|
if (!requirement.versionRange) return true;
|
|
return (
|
|
valid(runtime.version) !== null &&
|
|
satisfies(runtime.version, requirement.versionRange, {
|
|
includePrerelease: true,
|
|
})
|
|
);
|
|
});
|
|
}
|
|
|
|
function matchNormalizedWorkerPlacement(
|
|
worker: WorkerRecord,
|
|
placement: WorkerPlacementSpec,
|
|
observedAtMs: number,
|
|
): WorkerPlacementDecision {
|
|
const required = placement.required ?? {};
|
|
const capabilities = worker.capabilities;
|
|
const mismatches: WorkerPlacementMismatch[] = [];
|
|
if (
|
|
worker.status !== 'online' ||
|
|
worker.availableSlots < 1 ||
|
|
!isWorkerLeaseActive(worker, observedAtMs)
|
|
) {
|
|
mismatches.push('worker_unavailable');
|
|
}
|
|
if (
|
|
required.architectures?.length &&
|
|
!required.architectures.includes(capabilities.architecture)
|
|
) {
|
|
mismatches.push('architecture');
|
|
}
|
|
if (
|
|
required.operatingSystems?.length &&
|
|
!required.operatingSystems.includes(capabilities.operatingSystem)
|
|
) {
|
|
mismatches.push('operating_system');
|
|
}
|
|
if (
|
|
required.executors?.some(
|
|
(executor) => !capabilities.executors.includes(executor),
|
|
)
|
|
) {
|
|
mismatches.push('executor');
|
|
}
|
|
if (
|
|
required.runtimes?.some((runtime) => !hasRuntime(capabilities, runtime))
|
|
) {
|
|
mismatches.push('runtime');
|
|
}
|
|
if (required.labels && !hasLabels(capabilities, required.labels)) {
|
|
mismatches.push('label');
|
|
}
|
|
if (
|
|
required.minMemoryBytes !== undefined &&
|
|
(capabilities.capacity.memoryBytes ?? 0) < required.minMemoryBytes
|
|
) {
|
|
mismatches.push('memory');
|
|
}
|
|
if (
|
|
required.minDiskBytes !== undefined &&
|
|
(capabilities.capacity.diskBytes ?? 0) < required.minDiskBytes
|
|
) {
|
|
mismatches.push('disk');
|
|
}
|
|
if (
|
|
required.gpuVendor !== undefined &&
|
|
!capabilities.capacity.gpu?.some((gpu) => gpu.vendor === required.gpuVendor)
|
|
) {
|
|
mismatches.push('gpu');
|
|
}
|
|
if (
|
|
required.features?.some(
|
|
(feature) => !capabilities.features.includes(feature),
|
|
)
|
|
) {
|
|
mismatches.push('feature');
|
|
}
|
|
const score = (placement.preferred ?? []).reduce(
|
|
(total, preference) =>
|
|
total +
|
|
(hasLabels(capabilities, preference.labels) ? preference.weight : 0),
|
|
0,
|
|
);
|
|
return { matches: mismatches.length === 0, score, mismatches };
|
|
}
|
|
|
|
export function matchesWorkerPlacement(
|
|
worker: WorkerRecord,
|
|
placementValue: unknown,
|
|
observedAtMs: number,
|
|
): WorkerPlacementDecision {
|
|
return matchNormalizedWorkerPlacement(
|
|
worker,
|
|
normalizeWorkerPlacementSpec(placementValue),
|
|
observedAtMs,
|
|
);
|
|
}
|
|
|
|
export function selectWorkerCandidates(
|
|
workers: readonly WorkerRecord[],
|
|
placement: unknown,
|
|
observedAtMs: number,
|
|
limit = 16,
|
|
): WorkerPlacementCandidate[] {
|
|
if (workers.length > MAX_PLACEMENT_CANDIDATES) {
|
|
throw new RangeError(
|
|
`workers must contain at most ${MAX_PLACEMENT_CANDIDATES} candidates`,
|
|
);
|
|
}
|
|
if (
|
|
!Number.isSafeInteger(limit) ||
|
|
limit < 1 ||
|
|
limit > MAX_PLACEMENT_CANDIDATES
|
|
) {
|
|
throw new RangeError(
|
|
`limit must be between 1 and ${MAX_PLACEMENT_CANDIDATES}`,
|
|
);
|
|
}
|
|
const normalizedPlacement = normalizeWorkerPlacementSpec(placement);
|
|
return workers
|
|
.map((worker) => ({
|
|
worker,
|
|
decision: matchNormalizedWorkerPlacement(
|
|
worker,
|
|
normalizedPlacement,
|
|
observedAtMs,
|
|
),
|
|
}))
|
|
.filter((candidate) => candidate.decision.matches)
|
|
.sort(
|
|
(left, right) =>
|
|
right.decision.score - left.decision.score ||
|
|
right.worker.availableSlots - left.worker.availableSlots ||
|
|
left.worker.id.localeCompare(right.worker.id),
|
|
)
|
|
.slice(0, limit)
|
|
.map(({ worker, decision }) => ({ worker, score: decision.score }));
|
|
}
|