mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:58:17 +08:00
347 lines
9.8 KiB
JavaScript
347 lines
9.8 KiB
JavaScript
require('ts-node/register/transpile-only');
|
|
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs/promises');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { test } = require('node:test');
|
|
const { Sequelize } = require('sequelize');
|
|
const {
|
|
defineSchemaMigrationModel,
|
|
} = require('../../back/data/schemaMigration');
|
|
const {
|
|
workerRegistryMigration,
|
|
} = require('../../back/migrations/0008-worker-registry');
|
|
const { runMigrations } = require('../../back/migrations/runner');
|
|
const {
|
|
LegacySequelizeWorkerRegistryRepository,
|
|
} = require('../../back/runtime/adapters/legacy-sequelize/workerRegistryRepository');
|
|
const {
|
|
BoundWorkerControlPlaneClient,
|
|
WorkerControlService,
|
|
WorkerPrincipalMismatchError,
|
|
} = require('../../back/runtime/application/workerControlService');
|
|
const {
|
|
WorkerFenceRejectedError,
|
|
WorkerSessionConflictError,
|
|
hashWorkerCapabilities,
|
|
normalizeWorkerCapabilities,
|
|
parseWorkerCapabilities,
|
|
serializeWorkerCapabilities,
|
|
} = require('../../back/runtime/domain/worker');
|
|
|
|
const SESSION_1 = '019f7500-0000-7000-8000-000000000001';
|
|
const SESSION_2 = '019f7500-0000-7000-8000-000000000002';
|
|
|
|
function capabilities(overrides = {}) {
|
|
return {
|
|
architecture: 'arm64',
|
|
operatingSystem: 'linux',
|
|
executors: ['local_process'],
|
|
runtimes: [
|
|
{ name: 'node', version: '24.14.0' },
|
|
{ name: 'python', version: '3.12.2' },
|
|
],
|
|
labels: { region: 'home', tier: 'edge' },
|
|
capacity: {
|
|
cpuCores: 2,
|
|
memoryBytes: 512 * 1024 * 1024,
|
|
diskBytes: 8 * 1024 * 1024 * 1024,
|
|
},
|
|
features: ['direct_file_log'],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function fixture(t) {
|
|
const database = new Sequelize({
|
|
dialect: 'sqlite',
|
|
storage: ':memory:',
|
|
logging: false,
|
|
});
|
|
t.after(() => database.close());
|
|
await runMigrations({
|
|
database,
|
|
migrationModel: defineSchemaMigrationModel(database),
|
|
migrations: [workerRegistryMigration],
|
|
logger: { info() {} },
|
|
});
|
|
let nowMs = 1_000;
|
|
const repository = new LegacySequelizeWorkerRegistryRepository(database);
|
|
const service = new WorkerControlService(repository, {
|
|
leaseDurationMs: 5_000,
|
|
clock: { now: () => nowMs },
|
|
});
|
|
return {
|
|
repository,
|
|
service,
|
|
setNow(value) {
|
|
nowMs = value;
|
|
},
|
|
client(workerId) {
|
|
return new BoundWorkerControlPlaneClient(service, { workerId });
|
|
},
|
|
};
|
|
}
|
|
|
|
test('normalizes a bounded canonical capability snapshot', () => {
|
|
const first = serializeWorkerCapabilities(capabilities());
|
|
const second = serializeWorkerCapabilities(
|
|
capabilities({
|
|
executors: ['local_process'],
|
|
runtimes: [
|
|
{ name: 'python', version: '3.12.2' },
|
|
{ name: 'node', version: '24.14.0' },
|
|
],
|
|
labels: { tier: 'edge', region: 'home' },
|
|
}),
|
|
);
|
|
assert.equal(first, second);
|
|
assert.equal(hashWorkerCapabilities(first), hashWorkerCapabilities(second));
|
|
assert.deepEqual(parseWorkerCapabilities(first), capabilities());
|
|
|
|
assert.throws(
|
|
() => normalizeWorkerCapabilities({ ...capabilities(), command: 'secret' }),
|
|
/fields do not match/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
normalizeWorkerCapabilities({
|
|
...capabilities(),
|
|
executors: ['local_process', 'local_process'],
|
|
}),
|
|
/duplicates/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
normalizeWorkerCapabilities({
|
|
...capabilities(),
|
|
labels: Object.fromEntries(
|
|
Array.from({ length: 33 }, (_, index) => [`key-${index}`, 'value']),
|
|
),
|
|
}),
|
|
/at most 32/,
|
|
);
|
|
});
|
|
|
|
test('registers idempotently, fences replaced sessions, and preserves drain', async (t) => {
|
|
const context = await fixture(t);
|
|
const client = context.client('worker-a');
|
|
const request = {
|
|
workerId: 'worker-a',
|
|
sessionId: SESSION_1,
|
|
capabilities: capabilities(),
|
|
maxConcurrentRuns: 2,
|
|
availableSlots: 2,
|
|
};
|
|
const first = await client.register(request);
|
|
assert.equal(first.generation, 1);
|
|
assert.equal(first.version, 0);
|
|
assert.equal(first.leaseExpiresAtMs, 6_000);
|
|
|
|
context.setNow(2_000);
|
|
assert.deepEqual(await client.register(request), first);
|
|
await assert.rejects(
|
|
client.register({ ...request, availableSlots: 1 }),
|
|
WorkerSessionConflictError,
|
|
);
|
|
|
|
const replacement = await client.register({
|
|
...request,
|
|
sessionId: SESSION_2,
|
|
});
|
|
assert.equal(replacement.generation, 2);
|
|
assert.equal(replacement.version, 1);
|
|
await assert.rejects(
|
|
client.heartbeat({
|
|
workerId: 'worker-a',
|
|
sessionId: SESSION_1,
|
|
generation: 1,
|
|
expectedVersion: 0,
|
|
availableSlots: 2,
|
|
}),
|
|
(error) =>
|
|
error instanceof WorkerFenceRejectedError &&
|
|
error.reason === 'session_mismatch',
|
|
);
|
|
|
|
context.setNow(2_500);
|
|
const heartbeat = await client.heartbeat({
|
|
workerId: 'worker-a',
|
|
sessionId: SESSION_2,
|
|
generation: 2,
|
|
expectedVersion: 1,
|
|
availableSlots: 1,
|
|
});
|
|
assert.equal(heartbeat.version, 2);
|
|
assert.equal(heartbeat.availableSlots, 1);
|
|
assert.equal(heartbeat.leaseExpiresAtMs, 7_500);
|
|
|
|
context.setNow(3_000);
|
|
const draining = await client.drain({
|
|
workerId: 'worker-a',
|
|
sessionId: SESSION_2,
|
|
generation: 2,
|
|
expectedVersion: 2,
|
|
});
|
|
assert.equal(draining.status, 'draining');
|
|
assert.equal(draining.availableSlots, 0);
|
|
|
|
context.setNow(3_500);
|
|
const drainedHeartbeat = await client.heartbeat({
|
|
workerId: 'worker-a',
|
|
sessionId: SESSION_2,
|
|
generation: 2,
|
|
expectedVersion: 3,
|
|
availableSlots: 2,
|
|
});
|
|
assert.equal(drainedHeartbeat.status, 'draining');
|
|
assert.equal(drainedHeartbeat.availableSlots, 0);
|
|
|
|
context.setNow(4_000);
|
|
const offline = await client.disconnect({
|
|
workerId: 'worker-a',
|
|
sessionId: SESSION_2,
|
|
generation: 2,
|
|
expectedVersion: 4,
|
|
});
|
|
assert.equal(offline.status, 'offline');
|
|
await assert.rejects(
|
|
client.heartbeat({
|
|
workerId: 'worker-a',
|
|
sessionId: SESSION_2,
|
|
generation: 2,
|
|
expectedVersion: 5,
|
|
availableSlots: 0,
|
|
}),
|
|
(error) =>
|
|
error instanceof WorkerFenceRejectedError && error.reason === 'offline',
|
|
);
|
|
});
|
|
|
|
test('queries only live available Workers with a bounded stable cursor', async (t) => {
|
|
const context = await fixture(t);
|
|
for (const [index, workerId] of [
|
|
'worker-a',
|
|
'worker-b',
|
|
'worker-c',
|
|
].entries()) {
|
|
await context.client(workerId).register({
|
|
workerId,
|
|
sessionId: `019f7500-0000-7000-8000-${String(index + 10).padStart(
|
|
12,
|
|
'0',
|
|
)}`,
|
|
capabilities: capabilities(),
|
|
maxConcurrentRuns: 1,
|
|
availableSlots: workerId === 'worker-b' ? 0 : 1,
|
|
});
|
|
}
|
|
|
|
context.setNow(2_000);
|
|
const firstPage = await context.service.listAvailable({ limit: 1 });
|
|
assert.deepEqual(
|
|
firstPage.workers.map((worker) => worker.id),
|
|
['worker-a'],
|
|
);
|
|
assert.equal(firstPage.truncated, true);
|
|
const secondPage = await context.service.listAvailable({
|
|
afterWorkerId: firstPage.nextCursor,
|
|
limit: 1,
|
|
});
|
|
assert.deepEqual(
|
|
secondPage.workers.map((worker) => worker.id),
|
|
['worker-c'],
|
|
);
|
|
assert.equal(secondPage.truncated, false);
|
|
|
|
context.setNow(6_000);
|
|
assert.deepEqual((await context.service.listAvailable()).workers, []);
|
|
await assert.rejects(
|
|
context.service.listAvailable({ limit: 65 }),
|
|
/MAX_AVAILABLE_WORKER_PAGE_SIZE/,
|
|
);
|
|
});
|
|
|
|
test('binds Worker identity at the authenticated transport boundary', async (t) => {
|
|
const context = await fixture(t);
|
|
await assert.rejects(
|
|
context.service.register(
|
|
{ workerId: 'worker-a' },
|
|
{
|
|
workerId: 'worker-b',
|
|
sessionId: SESSION_1,
|
|
capabilities: capabilities(),
|
|
maxConcurrentRuns: 1,
|
|
availableSlots: 1,
|
|
},
|
|
),
|
|
WorkerPrincipalMismatchError,
|
|
);
|
|
assert.equal(await context.repository.findById('worker-b'), null);
|
|
});
|
|
|
|
test('serializes two control-plane connections racing to replace one Worker', async (t) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql3-workers-'));
|
|
const storage = path.join(root, 'workers.sqlite');
|
|
const databases = [
|
|
new Sequelize({ dialect: 'sqlite', storage, logging: false }),
|
|
new Sequelize({ dialect: 'sqlite', storage, logging: false }),
|
|
];
|
|
t.after(async () => {
|
|
await Promise.all(databases.map((database) => database.close()));
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
});
|
|
await runMigrations({
|
|
database: databases[0],
|
|
migrationModel: defineSchemaMigrationModel(databases[0]),
|
|
migrations: [workerRegistryMigration],
|
|
logger: { info() {} },
|
|
});
|
|
for (const database of databases) {
|
|
await database.query('PRAGMA journal_mode=WAL');
|
|
await database.query('PRAGMA busy_timeout=1000');
|
|
}
|
|
const services = databases.map(
|
|
(database) =>
|
|
new WorkerControlService(
|
|
new LegacySequelizeWorkerRegistryRepository(database),
|
|
{ leaseDurationMs: 5_000, clock: { now: () => 1_000 } },
|
|
),
|
|
);
|
|
const requests = [SESSION_1, SESSION_2].map((sessionId) => ({
|
|
workerId: 'worker-race',
|
|
sessionId,
|
|
capabilities: capabilities(),
|
|
maxConcurrentRuns: 1,
|
|
availableSlots: 1,
|
|
}));
|
|
const registered = await Promise.all(
|
|
services.map((service, index) =>
|
|
new BoundWorkerControlPlaneClient(service, {
|
|
workerId: 'worker-race',
|
|
}).register(requests[index]),
|
|
),
|
|
);
|
|
assert.deepEqual(
|
|
registered.map((worker) => worker.generation).sort(),
|
|
[1, 2],
|
|
);
|
|
const repository = new LegacySequelizeWorkerRegistryRepository(databases[0]);
|
|
const current = await repository.findById('worker-race');
|
|
assert.equal(current.generation, 2);
|
|
const stale = registered.find((worker) => worker.generation === 1);
|
|
await assert.rejects(
|
|
repository.heartbeat({
|
|
workerId: stale.id,
|
|
sessionId: stale.sessionId,
|
|
generation: stale.generation,
|
|
expectedVersion: stale.version,
|
|
availableSlots: 1,
|
|
heartbeatAtMs: 2_000,
|
|
leaseExpiresAtMs: 7_000,
|
|
}),
|
|
WorkerFenceRejectedError,
|
|
);
|
|
});
|