mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-07 01:27:39 +08:00
Use MikroORM's official Node SQLite dialect with bounded database isolation, preserve legacy schema and data for rollback, and require Node 22.17 or newer. Update Debian runtimes and retain ARMv6 musl compatibility without a SQLite addon. Complete short archive writes, stop orphaned SQL workers after owner death, preserve buffered startup requests, and prestart the archived backend on every platform. Cover real package installation, migration, failure recovery, and legacy database compatibility. Keep authentication storage failures distinct from expired sessions with a pinned Keyv runtime, propagated cache errors, and retryable HTTP 503 responses. Preserve genuine 401 failures and verify legacy cache bytes, initialization safety, and session recovery.
409 lines
14 KiB
JavaScript
409 lines
14 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const grpc = require('@grpc/grpc-js');
|
|
const load = require('../helpers/load-security-module.cjs');
|
|
const logger = { info() {}, debug() {}, warn() {}, error() {} };
|
|
const tick = () => new Promise(setImmediate);
|
|
function createLimit() {
|
|
return load('back/shared/pLimit.ts', {
|
|
'../data/system': {
|
|
AuthDataType: { systemConfig: 'systemConfig' },
|
|
SystemModel: {
|
|
sync: async () => {},
|
|
findOne: async (where, read = {}) => {
|
|
return { info: { cronConcurrency: 1 } };
|
|
},
|
|
},
|
|
},
|
|
'../loaders/logger': logger,
|
|
'../services/notify': class {},
|
|
'../shared/i18n': { t: (x) => x, tf: (x) => x },
|
|
'../config': {},
|
|
'../config/grpcCerts': { getGrpcCerts: () => null },
|
|
'../protos/api': {},
|
|
}).default;
|
|
}
|
|
function createSystem(client, limit, options = {}) {
|
|
const store = options.store || {
|
|
state: {
|
|
id: 1,
|
|
type: 'systemConfig',
|
|
info: { cronConcurrency: 1, timezone: 'Asia/Shanghai' },
|
|
},
|
|
};
|
|
const writes = [];
|
|
const Service = load('back/services/system.ts', {
|
|
'../config': options.config || {},
|
|
'../shared/schedulerMutationLock': options.config
|
|
? load('back/shared/schedulerMutationLock.ts', {
|
|
'../config': options.config,
|
|
})
|
|
: { withSchedulerMutation: (operation) => operation() },
|
|
'../config/const': {},
|
|
'../config/util': {},
|
|
'../data/dependence': {},
|
|
'../data/notify': {},
|
|
'../data/system': {
|
|
AuthDataType: { systemConfig: 'systemConfig' },
|
|
SystemModel: {
|
|
findOne: async (where, read = {}) => {
|
|
return {
|
|
...{ ...store.state },
|
|
...store.state,
|
|
};
|
|
},
|
|
nativeUpdate: async (where, values) => {
|
|
writes.push(values);
|
|
store.state = { ...store.state, ...values };
|
|
},
|
|
},
|
|
},
|
|
'../shared/pLimit': limit,
|
|
'../schedule/client': client,
|
|
'../shared/i18n': { t: (x) => x },
|
|
'./notify': class {},
|
|
'./schedule': class {},
|
|
'./sock': class {},
|
|
}).default;
|
|
return {
|
|
service: new Service(logger, {}, {}),
|
|
writes,
|
|
state: () => store.state,
|
|
};
|
|
}
|
|
async function rpcFixture(t) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-concurrency-rpc-'));
|
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const certs = await load('back/config/grpcCerts.ts', {
|
|
'./index': { configPath: root },
|
|
'./util': { fileExist: async (p) => fs.existsSync(p) },
|
|
'../loaders/logger': logger,
|
|
}).initGrpcCerts();
|
|
const schedulerLimit = createLimit(),
|
|
apiLimit = createLimit();
|
|
await tick();
|
|
const handler = load('back/schedule/setConcurrency.ts', {
|
|
'../shared/pLimit': schedulerLimit,
|
|
});
|
|
let port;
|
|
const Grpc = load('back/services/grpc.ts', {
|
|
'../config': { bindHostGrpc: '127.0.0.1', grpcPort: 0 },
|
|
'../config/grpcCerts': { initGrpcCerts: async () => certs },
|
|
'../loaders/logger': logger,
|
|
'./metrics': { metricsService: { record() {} } },
|
|
'../schedule/addCron': {},
|
|
'../schedule/delCron': {},
|
|
'../schedule/api': {},
|
|
'../schedule/health': { check: (_call, cb) => cb(null, { status: 1 }) },
|
|
'../schedule/setConcurrency': handler,
|
|
'@grpc/grpc-js': {
|
|
...grpc,
|
|
Server: class extends grpc.Server {
|
|
bindAsync(address, creds, cb) {
|
|
super.bindAsync(address, creds, (error, boundPort) => {
|
|
port = boundPort;
|
|
cb(error, boundPort);
|
|
});
|
|
}
|
|
},
|
|
},
|
|
}).GrpcServerService;
|
|
const server = new Grpc();
|
|
await server.initialize();
|
|
const client = load('back/schedule/client.ts', {
|
|
'../config': { grpcPort: port },
|
|
'../config/grpcCerts': { getGrpcCerts: () => certs },
|
|
}).default;
|
|
t.after(async () => {
|
|
client._client?.close();
|
|
await server.shutdown();
|
|
});
|
|
return {
|
|
...createSystem(client, apiLimit),
|
|
schedulerLimit,
|
|
apiLimit,
|
|
client,
|
|
};
|
|
}
|
|
function enqueue(t, limit, count) {
|
|
const releases = [],
|
|
runs = [];
|
|
for (let i = 0; i < count; i++) {
|
|
let release;
|
|
const gate = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
releases.push(release);
|
|
runs.push(limit.runWithCronLimit({ id: String(i) }, () => gate));
|
|
}
|
|
t.after(async () => {
|
|
releases.forEach((release) => release());
|
|
await Promise.all(runs);
|
|
});
|
|
return { releases, runs };
|
|
}
|
|
test('concurrency changes reach the independent scheduler queue over mTLS immediately and preserve running jobs', async (t) => {
|
|
const f = await rpcFixture(t);
|
|
const { releases, runs } = enqueue(t, f.schedulerLimit, 3);
|
|
await tick();
|
|
assert.equal(f.schedulerLimit.cronLimitActiveCount, 1);
|
|
assert.equal(f.schedulerLimit.cronLimitPendingCount, 2);
|
|
assert.equal(
|
|
(await f.service.updateCronConcurrency({ cronConcurrency: 2 })).code,
|
|
200,
|
|
);
|
|
await tick();
|
|
assert.equal(f.schedulerLimit.cronLimitActiveCount, 2);
|
|
assert.equal(f.schedulerLimit.cronLimitPendingCount, 1);
|
|
await f.service.updateCronConcurrency({ cronConcurrency: 1 });
|
|
assert.equal(
|
|
f.schedulerLimit.cronLimitActiveCount,
|
|
2,
|
|
'lowering the limit must not stop active jobs',
|
|
);
|
|
releases[0]();
|
|
await runs[0];
|
|
await tick();
|
|
assert.equal(f.schedulerLimit.cronLimitActiveCount, 1);
|
|
assert.equal(f.schedulerLimit.cronLimitPendingCount, 1);
|
|
releases[1]();
|
|
await runs[1];
|
|
await tick();
|
|
assert.equal(f.schedulerLimit.cronLimitActiveCount, 1);
|
|
assert.equal(f.schedulerLimit.cronLimitPendingCount, 0);
|
|
releases[2]();
|
|
await runs[2];
|
|
assert.equal(f.state().info.cronConcurrency, 1);
|
|
});
|
|
test('zero, null and omitted concurrency restore the default in both API and scheduler queues without a restart', async (t) => {
|
|
const f = await rpcFixture(t);
|
|
for (const value of [0, null, undefined]) {
|
|
await f.service.updateCronConcurrency({ cronConcurrency: 1 });
|
|
const scheduler = enqueue(t, f.schedulerLimit, 4),
|
|
manual = enqueue(t, f.apiLimit, 4);
|
|
await tick();
|
|
assert.equal(f.schedulerLimit.cronLimitActiveCount, 1);
|
|
assert.equal(f.apiLimit.cronLimitActiveCount, 1);
|
|
await f.service.updateCronConcurrency(
|
|
value === undefined ? {} : { cronConcurrency: value },
|
|
);
|
|
await tick();
|
|
assert.equal(
|
|
f.state().info.cronConcurrency,
|
|
0,
|
|
'persist the same default that both queues apply',
|
|
);
|
|
assert.equal(f.schedulerLimit.cronLimitActiveCount, 4);
|
|
assert.equal(f.apiLimit.cronLimitActiveCount, 4);
|
|
[...scheduler.releases, ...manual.releases].forEach((release) => release());
|
|
await Promise.all([...scheduler.runs, ...manual.runs]);
|
|
}
|
|
});
|
|
test('invalid concurrency is rejected before configuration writes or queue changes', async () => {
|
|
const mutations = [];
|
|
const f = createSystem(
|
|
{ setConcurrency: async (value) => mutations.push(value) },
|
|
{ setCustomLimit: async (value) => mutations.push(value) },
|
|
);
|
|
for (const value of [-1, 1.5, NaN, Infinity, 2147483648])
|
|
await assert.rejects(
|
|
f.service.updateCronConcurrency({ cronConcurrency: value }),
|
|
{ status: 400 },
|
|
);
|
|
assert.deepEqual(f.writes, []);
|
|
assert.deepEqual(mutations, []);
|
|
const handler = load('back/schedule/setConcurrency.ts', {
|
|
'../shared/pLimit': {
|
|
setCustomLimit: async (value) => mutations.push(value),
|
|
},
|
|
}).setConcurrency;
|
|
for (const value of [-1, 0.5, 2147483648])
|
|
await handler({ request: { concurrency: value } }, (error) =>
|
|
assert.equal(error.code, grpc.status.INVALID_ARGUMENT),
|
|
);
|
|
assert.deepEqual(mutations, []);
|
|
});
|
|
for (const code of [grpc.status.UNAVAILABLE, grpc.status.DEADLINE_EXCEEDED]) {
|
|
test(`concurrency RPC failure ${code} invalidates readiness and is not reported as success or retried`, async () => {
|
|
let writes = 0,
|
|
invalidations = 0;
|
|
const fake = {
|
|
waitForReady: (_deadline, callback) => callback(),
|
|
setConcurrency: (_request, _metadata, options, callback) => {
|
|
assert.ok(options.deadline > Date.now());
|
|
writes++;
|
|
callback(Object.assign(new Error('scheduler unavailable'), { code }));
|
|
},
|
|
};
|
|
const client = load('back/schedule/client.ts', {
|
|
'../protos/cron': {
|
|
CronClient: class {
|
|
constructor() {
|
|
return fake;
|
|
}
|
|
},
|
|
},
|
|
'../config': { grpcPort: 1 },
|
|
'../config/grpcCerts': {
|
|
getGrpcCerts: () => ({
|
|
caCert: 'ca',
|
|
clientKey: 'key',
|
|
clientCert: 'cert',
|
|
}),
|
|
},
|
|
'@grpc/grpc-js': { ...grpc, credentials: { createSsl: () => ({}) } },
|
|
}).default;
|
|
client.readiness.invalidate = () => invalidations++;
|
|
const f = createSystem(client, { setCustomLimit: async () => {} });
|
|
await assert.rejects(
|
|
f.service.updateCronConcurrency({ cronConcurrency: 2 }),
|
|
{ status: 503 },
|
|
);
|
|
assert.equal(writes, 1);
|
|
assert.equal(invalidations, 1);
|
|
});
|
|
}
|
|
function mutationConfig(t) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-concurrency-lock-'));
|
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
return { crontabFile: path.join(root, 'crontab.list') };
|
|
}
|
|
test('independent API service instances serialize database and scheduler updates with the shared filesystem lock', async (t) => {
|
|
const config = mutationConfig(t);
|
|
const store = { state: { id: 1, info: { cronConcurrency: 1 } } };
|
|
let release, entered;
|
|
const firstEntered = new Promise((resolve) => (entered = resolve));
|
|
const gate = new Promise((resolve) => (release = resolve));
|
|
t.after(() => release());
|
|
let scheduler = 1,
|
|
api = 1;
|
|
const client = {
|
|
setConcurrency: async (value) => {
|
|
if (value === 10) {
|
|
entered();
|
|
await gate;
|
|
}
|
|
scheduler = value;
|
|
},
|
|
};
|
|
const limit = { setCustomLimit: async (value) => (api = value) };
|
|
const a = createSystem(client, limit, { config, store });
|
|
const b = createSystem(client, limit, { config, store });
|
|
const first = a.service.updateCronConcurrency({ cronConcurrency: 10 });
|
|
await firstEntered;
|
|
const second = b.service.updateCronConcurrency({ cronConcurrency: 1 });
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
assert.equal(
|
|
b.writes.length,
|
|
0,
|
|
'a second writer cannot persist ahead of the first RPC',
|
|
);
|
|
release();
|
|
await Promise.all([first, second]);
|
|
assert.deepEqual(
|
|
[store.state.info.cronConcurrency, api, scheduler],
|
|
[1, 1, 1],
|
|
);
|
|
});
|
|
for (const newerLimit of [undefined, 2]) {
|
|
test(`production recovery restores persisted concurrency before health becomes ready (newer limit: ${newerLimit})`, async (t) => {
|
|
const config = mutationConfig(t);
|
|
const store = { state: { id: 1, info: { cronConcurrency: 1 } } };
|
|
let scheduler = 1,
|
|
api = 1,
|
|
fail = false,
|
|
restoreJobs = 0;
|
|
let unblockRecovery, recoveryEntered;
|
|
const entered = new Promise((resolve) => (recoveryEntered = resolve));
|
|
const gate = new Promise((resolve) => (unblockRecovery = resolve));
|
|
t.after(() => unblockRecovery());
|
|
const fake = {
|
|
waitForReady: (_deadline, callback) => callback(),
|
|
makeUnaryRequest: (...args) => args.at(-1)(null, { status: 1 }),
|
|
setConcurrency: ({ concurrency }, _metadata, _options, callback) => {
|
|
if (fail) {
|
|
fail = false;
|
|
callback(
|
|
Object.assign(new Error('lost RPC'), {
|
|
code: grpc.status.UNAVAILABLE,
|
|
}),
|
|
);
|
|
} else {
|
|
scheduler = concurrency;
|
|
callback(null, {});
|
|
}
|
|
},
|
|
};
|
|
const client = load('back/schedule/client.ts', {
|
|
'../protos/cron': {
|
|
CronClient: class {
|
|
constructor() {
|
|
return fake;
|
|
}
|
|
},
|
|
},
|
|
'../config': { grpcPort: 1 },
|
|
'../config/grpcCerts': {
|
|
getGrpcCerts: () => ({
|
|
caCert: 'ca',
|
|
clientKey: 'key',
|
|
clientCert: 'cert',
|
|
}),
|
|
},
|
|
'@grpc/grpc-js': { ...grpc, credentials: { createSsl: () => ({}) } },
|
|
}).default;
|
|
const f = createSystem(
|
|
client,
|
|
{ setCustomLimit: async (value) => (api = value) },
|
|
{ config, store },
|
|
);
|
|
const source = fs.readFileSync('back/loaders/initData.ts', 'utf8');
|
|
const callback = source.match(
|
|
/cronClient\.readiness\.configure\((async \(\) => \{[\s\S]*?\n \})\);/,
|
|
)[1];
|
|
const restore = new Function(
|
|
'cronService',
|
|
'systemService',
|
|
`return ${callback}`,
|
|
)(
|
|
{
|
|
autosave_crontab: async (strict) => {
|
|
assert.equal(strict, true);
|
|
restoreJobs++;
|
|
if (restoreJobs > 1) {
|
|
recoveryEntered();
|
|
await gate;
|
|
}
|
|
},
|
|
},
|
|
f.service,
|
|
);
|
|
client.readiness.configure(restore);
|
|
assert.equal(await client.readiness.recover(), true);
|
|
fail = true;
|
|
await assert.rejects(
|
|
f.service.updateCronConcurrency({ cronConcurrency: 10 }),
|
|
{ status: 503 },
|
|
);
|
|
await entered;
|
|
assert.equal(await client.readiness.check(), false);
|
|
assert.equal(scheduler, 1);
|
|
// A newer successful request must win over the failed request being recovered.
|
|
if (newerLimit !== undefined)
|
|
await f.service.updateCronConcurrency({ cronConcurrency: newerLimit });
|
|
scheduler = 1; // Simulate the scheduler restarting while recovery is in progress.
|
|
const recovered = client.readiness.recover();
|
|
unblockRecovery();
|
|
assert.equal(await recovered, true);
|
|
const expected = newerLimit ?? 10;
|
|
assert.deepEqual(
|
|
[store.state.info.cronConcurrency, api, scheduler],
|
|
[expected, expected, expected],
|
|
);
|
|
assert.equal(await client.readiness.check(), true);
|
|
});
|
|
}
|