mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-15 19:57:07 +08:00
fix: 修复任务生命周期与调度就绪,优化执行和构建开销 (#3069)
* fix: harden task lifecycle and scheduler readiness * fix: confine log writes to the configured log directory * fix: verify complete build artifacts and untracked inputs * fix: reconcile scheduler state and make stop win startup races * fix: isolate cron generations and serialize scheduler recovery
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const ts = require('typescript');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { SchedulerReadiness } = require('../../back/shared/schedulerReadiness');
|
||||
const { AddCronRequest } = require('../../back/protos/cron');
|
||||
|
||||
function fixture(client) {
|
||||
const source = fs.readFileSync('back/services/cron.ts', 'utf8');
|
||||
const a = source.indexOf(' public async autosave_crontab(');
|
||||
const z = source.indexOf(' public async bootTask', a);
|
||||
const js = ts.transpileModule(
|
||||
'class Fixture {\n' + source.slice(a, z) + '}\nmodule.exports=Fixture;',
|
||||
{ compilerOptions: { target: ts.ScriptTarget.ES2020 } },
|
||||
).outputText;
|
||||
const module = { exports: {} };
|
||||
new Function('module', 'isDemoEnv', 'cronClient', 'withSchedulerMutation', js)(
|
||||
module,
|
||||
() => false,
|
||||
client,
|
||||
(fn) => fn(),
|
||||
);
|
||||
const service = new module.exports();
|
||||
service.setCrontab = async () => {};
|
||||
service.logger = { warn() {} };
|
||||
service.shouldUseCronClient = () => true;
|
||||
service.makeCommand = () => 'true';
|
||||
return service;
|
||||
}
|
||||
|
||||
test('recovery reconciles a surviving scheduler after missed deletes and disables, including an empty DB', async () => {
|
||||
const cancelled = [];
|
||||
const stacks = new Map(
|
||||
['removed', 'disabled', 'kept'].map((id) => [
|
||||
id,
|
||||
[{ cancel: () => cancelled.push(id) }],
|
||||
]),
|
||||
);
|
||||
const { addCron } = load('back/schedule/addCron.ts', {
|
||||
'./data': { scheduleStacks: stacks },
|
||||
'node-schedule': {
|
||||
scheduleJob: (id) => ({ cancel: () => cancelled.push(id) }),
|
||||
},
|
||||
'../shared/runCron': {},
|
||||
'../loaders/logger': { info() {}, warn() {} },
|
||||
'../shared/i18n': { tf: (s) => s },
|
||||
});
|
||||
const client = {
|
||||
addCron: async (crons, replace) => {
|
||||
// Exercise the real protobuf field, not just a JavaScript-only flag.
|
||||
const request = AddCronRequest.decode(
|
||||
AddCronRequest.encode({ crons, replace }).finish(),
|
||||
);
|
||||
await new Promise((resolve, reject) =>
|
||||
addCron({ request }, (err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
},
|
||||
};
|
||||
const service = fixture(client);
|
||||
let rows = [
|
||||
{ id: 'kept', schedule: '* * * * *', isDisabled: 0 },
|
||||
{ id: 'disabled', schedule: '* * * * *', isDisabled: 1 },
|
||||
];
|
||||
service.crontabs = async () => ({ data: rows });
|
||||
const state = new SchedulerReadiness(async () => {});
|
||||
state.configure(() => service.autosave_crontab(true));
|
||||
assert.equal(await state.recover(), true);
|
||||
assert.equal(await state.check(), true);
|
||||
assert.deepEqual([...stacks.keys()], ['kept']);
|
||||
assert.deepEqual(cancelled.sort(), ['disabled', 'kept', 'removed']);
|
||||
rows = [];
|
||||
state.invalidate();
|
||||
assert.equal(await state.recover(), true);
|
||||
assert.equal(stacks.size, 0);
|
||||
assert.equal(await state.check(), true);
|
||||
});
|
||||
|
||||
test('invalid replacement leaves the previous schedule intact', async () => {
|
||||
const stacks = new Map([
|
||||
[
|
||||
'old',
|
||||
[
|
||||
{
|
||||
cancel: () => {
|
||||
throw Error('must not cancel');
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
const { addCron } = load('back/schedule/addCron.ts', {
|
||||
'./data': { scheduleStacks: stacks },
|
||||
'../shared/runCron': {},
|
||||
'../loaders/logger': {},
|
||||
'../shared/i18n': { tf: (s) => s },
|
||||
});
|
||||
await assert.rejects(
|
||||
new Promise((resolve, reject) =>
|
||||
addCron(
|
||||
{
|
||||
request: {
|
||||
replace: true,
|
||||
crons: [{ id: 'bad', schedule: '?', extra_schedules: [] }],
|
||||
},
|
||||
},
|
||||
(err) => (err ? reject(err) : resolve()),
|
||||
),
|
||||
),
|
||||
);
|
||||
assert.deepEqual([...stacks.keys()], ['old']);
|
||||
});
|
||||
|
||||
test('pre-RPC channel failures invalidate readiness and return 503 without executing or replaying writes', async () => {
|
||||
for (const method of ['addCron', 'delCron']) {
|
||||
let invalidations = 0,
|
||||
writes = 0;
|
||||
const fake = {
|
||||
waitForReady: (_deadline, cb) => cb(Error('channel unavailable')),
|
||||
addCron: () => writes++,
|
||||
delCron: () => writes++,
|
||||
};
|
||||
const client = load('back/schedule/client.ts', {
|
||||
'../protos/cron': {
|
||||
CronClient: class {
|
||||
constructor() {
|
||||
return fake;
|
||||
}
|
||||
},
|
||||
},
|
||||
'../config': { grpcPort: 5500 },
|
||||
'../config/grpcCerts': {
|
||||
getGrpcCerts: () => ({
|
||||
caCert: 'ca',
|
||||
clientKey: 'key',
|
||||
clientCert: 'cert',
|
||||
}),
|
||||
},
|
||||
'@grpc/grpc-js': {
|
||||
...require('@grpc/grpc-js'),
|
||||
credentials: { createSsl: () => ({}) },
|
||||
},
|
||||
}).default;
|
||||
client.readiness.invalidate = () => invalidations++;
|
||||
await assert.rejects(
|
||||
client[method]([]),
|
||||
(err) => err.status === 503 && /channel unavailable/.test(err.message),
|
||||
);
|
||||
assert.equal(invalidations, 1);
|
||||
assert.equal(writes, 0);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user