@mzzsfy/dsh-cron-board 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api.mjs +3 -0
- package/src/scheduler.mjs +12 -1
- package/test/routes.test.mjs +33 -0
- package/test/scheduler.test.mjs +47 -0
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -367,6 +367,9 @@ export function createApi({ store, logger, executor, scheduler, periodic, sessio
|
|
|
367
367
|
const existing = store.jobs.get(params.id)
|
|
368
368
|
if (!existing) throw new Error(MESSAGES.jobNotFound)
|
|
369
369
|
const fields = normalizeJob({ ...existing, ...body })
|
|
370
|
+
// 编辑保存:请求未显式携带 nextRunAt 即按当前 schedule 重算。
|
|
371
|
+
// 合并旧任务行会把既有 nextRunAt 当作显式值透传,改期后残留旧触发点导致误执行
|
|
372
|
+
if (body.nextRunAt === undefined) fields.nextRunAt = nextRunAtOf(fields.schedule)
|
|
370
373
|
const row = await store.jobs.update(params.id, fields)
|
|
371
374
|
sendJson(res, 200, row)
|
|
372
375
|
},
|
package/src/scheduler.mjs
CHANGED
|
@@ -11,12 +11,23 @@ export function createScheduler({ store, executor, readTickMs, now = () => Date.
|
|
|
11
11
|
// 重入闸门:上一 tick 未完成(store 写链排队/慢盘)时跳过本轮,防同一触发点双触发
|
|
12
12
|
let ticking = false
|
|
13
13
|
|
|
14
|
-
//
|
|
14
|
+
// 历史版本编辑残留的自愈:nextRunAt 不在当前 schedule 的触发点上即重算,
|
|
15
|
+
// 防残留值到点误执行;合法触发点与空值(无未来触发点)不动,保留宽限执行与 misfire 语义
|
|
16
|
+
function isLegalTrigger(schedule, at) {
|
|
17
|
+
return nextRunAtOf(schedule, new Date(at - 1000)) === at
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 重启恢复:孤儿运行收尾 + 触发点残留自愈
|
|
15
21
|
async function recover() {
|
|
16
22
|
for (const run of [...store.runs.rows]) {
|
|
17
23
|
if (run.status !== 'queued' && run.status !== 'running') continue
|
|
18
24
|
await store.runs.update(run.runId, { status: 'interrupted', endedAt: now(), message: INTERRUPTED_MESSAGE })
|
|
19
25
|
}
|
|
26
|
+
for (const job of store.jobs.list()) {
|
|
27
|
+
if (typeof job.nextRunAt !== 'number' || Number.isNaN(job.nextRunAt)) continue
|
|
28
|
+
if (isLegalTrigger(job.schedule, job.nextRunAt)) continue
|
|
29
|
+
await store.jobs.update(job.id, { nextRunAt: nextRunAtOf(job.schedule, new Date(now())) })
|
|
30
|
+
}
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
async function tick() {
|
package/test/routes.test.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { createStore } from '../src/store.mjs'
|
|
|
12
12
|
import { createLogger } from '../src/logger.mjs'
|
|
13
13
|
import { createExecutor } from '../src/executor.mjs'
|
|
14
14
|
import { createApi } from '../src/api.mjs'
|
|
15
|
+
import { nextRunAtOf } from '../src/cron.mjs'
|
|
15
16
|
|
|
16
17
|
async function makeApi(t, overrides = {}) {
|
|
17
18
|
const dir = await mkdtemp(join(tmpdir(), 'cron-board-api-'))
|
|
@@ -192,6 +193,38 @@ test('jobs 路由:非法 cron 表达式被拒', async (t) => {
|
|
|
192
193
|
assert.match(res.payload.error, /cron/)
|
|
193
194
|
})
|
|
194
195
|
|
|
196
|
+
test('jobs 路由:编辑保存按当前 schedule 重算 nextRunAt', async (t) => {
|
|
197
|
+
const { api } = await makeApi(t)
|
|
198
|
+
// Given 任务按工作日 09:55 排期
|
|
199
|
+
const created = await call(api, 'POST', '/api/cron-board/jobs', {
|
|
200
|
+
name: '改期', kind: 'shell', command: 'x', schedule: '55 9 * * 1-5',
|
|
201
|
+
})
|
|
202
|
+
assert.equal(created.status, 200)
|
|
203
|
+
const jobId = created.payload.id
|
|
204
|
+
const before = created.payload.nextRunAt
|
|
205
|
+
// When 编辑改为每天 09:00(表单契约:请求体不带 nextRunAt)
|
|
206
|
+
const patched = await call(api, 'PATCH', '/api/cron-board/jobs/' + jobId, { schedule: '0 9 * * *', prompt: 'x' })
|
|
207
|
+
// Then 触发点随新表达式重算,不残留工作日旧值
|
|
208
|
+
assert.equal(patched.status, 200)
|
|
209
|
+
assert.notEqual(patched.payload.nextRunAt, before)
|
|
210
|
+
assert.equal(patched.payload.nextRunAt, nextRunAtOf('0 9 * * *'))
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('jobs 路由:PATCH 显式 nextRunAt 优先于重算(导入保留原状态契约)', async (t) => {
|
|
214
|
+
const { api } = await makeApi(t)
|
|
215
|
+
const created = await call(api, 'POST', '/api/cron-board/jobs', {
|
|
216
|
+
name: '导入', kind: 'shell', command: 'x', schedule: '* * * * *',
|
|
217
|
+
})
|
|
218
|
+
assert.equal(created.status, 200)
|
|
219
|
+
// When 请求体显式携带 nextRunAt
|
|
220
|
+
const patched = await call(api, 'PATCH', '/api/cron-board/jobs/' + created.payload.id, {
|
|
221
|
+
schedule: '0 9 * * *', nextRunAt: 1750000000000,
|
|
222
|
+
})
|
|
223
|
+
// Then 显式值落库,不被重算覆盖
|
|
224
|
+
assert.equal(patched.status, 200)
|
|
225
|
+
assert.equal(patched.payload.nextRunAt, 1750000000000)
|
|
226
|
+
})
|
|
227
|
+
|
|
195
228
|
test('jobs 路由:手动运行端到端——RunRecord 产生、日志可读、状态终态', async (t) => {
|
|
196
229
|
// Given shell 任务(node -e 输出标记)
|
|
197
230
|
const { store, api } = await makeApi(t)
|
package/test/scheduler.test.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { createStore } from '../src/store.mjs'
|
|
|
11
11
|
import { createLogger } from '../src/logger.mjs'
|
|
12
12
|
import { createExecutor } from '../src/executor.mjs'
|
|
13
13
|
import { createScheduler } from '../src/scheduler.mjs'
|
|
14
|
+
import { nextRunAtOf } from '../src/cron.mjs'
|
|
14
15
|
|
|
15
16
|
const TICK_MS = 30 * 1000
|
|
16
17
|
|
|
@@ -144,6 +145,52 @@ test('scheduler:recover 将遗留 queued/running 孤儿记录标 interrupted', a
|
|
|
144
145
|
assert.equal(store.runs.get('r-done').status, 'success')
|
|
145
146
|
})
|
|
146
147
|
|
|
148
|
+
test('scheduler:recover 自愈残留触发点——nextRunAt 非当前表达式触发点即重算', async (t) => {
|
|
149
|
+
// Given 旧版本编辑残留:工作日任务带着非工作日表达式的触发点(历史 bug 数据)
|
|
150
|
+
const { store, scheduler } = await makeScheduler(t)
|
|
151
|
+
const legal = nextRunAtOf('55 9 * * 1-5')
|
|
152
|
+
const stale = nextRunAtOf('0 9 * * *')
|
|
153
|
+
const jobStale = await store.jobs.create({
|
|
154
|
+
name: '残留', kind: 'shell', command: 'x', schedule: '55 9 * * 1-5',
|
|
155
|
+
enabled: true, timeoutMs: 1000, nextRunAt: stale,
|
|
156
|
+
})
|
|
157
|
+
const jobStaleDisabled = await store.jobs.create({
|
|
158
|
+
name: '残留禁用', kind: 'shell', command: 'x', schedule: '55 9 * * 1-5',
|
|
159
|
+
enabled: false, timeoutMs: 1000, nextRunAt: stale,
|
|
160
|
+
})
|
|
161
|
+
const jobLegal = await store.jobs.create({
|
|
162
|
+
name: '合法', kind: 'shell', command: 'x', schedule: '55 9 * * 1-5',
|
|
163
|
+
enabled: true, timeoutMs: 1000, nextRunAt: legal,
|
|
164
|
+
})
|
|
165
|
+
const jobNull = await store.jobs.create({
|
|
166
|
+
name: '空值', kind: 'shell', command: 'x', schedule: '0 0 30 2 *',
|
|
167
|
+
enabled: true, timeoutMs: 1000, nextRunAt: null,
|
|
168
|
+
})
|
|
169
|
+
// When 重启恢复
|
|
170
|
+
await scheduler.recover()
|
|
171
|
+
// Then 残留值(启用与禁用)按当前 schedule 重算为合法触发点;合法值与空值不动
|
|
172
|
+
assert.equal(store.jobs.get(jobStale.id).nextRunAt, legal)
|
|
173
|
+
assert.equal(store.jobs.get(jobStaleDisabled.id).nextRunAt, legal)
|
|
174
|
+
assert.equal(store.jobs.get(jobLegal.id).nextRunAt, legal)
|
|
175
|
+
assert.equal(store.jobs.get(jobNull.id).nextRunAt, null)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('scheduler:recover 自愈幂等——修复后的数据再次恢复不再改写', async (t) => {
|
|
179
|
+
const { store, scheduler } = await makeScheduler(t)
|
|
180
|
+
const legal = nextRunAtOf('55 9 * * 1-5')
|
|
181
|
+
const job = await store.jobs.create({
|
|
182
|
+
name: '残留', kind: 'shell', command: 'x', schedule: '55 9 * * 1-5',
|
|
183
|
+
enabled: true, timeoutMs: 1000, nextRunAt: nextRunAtOf('0 9 * * *'),
|
|
184
|
+
})
|
|
185
|
+
await scheduler.recover()
|
|
186
|
+
assert.equal(store.jobs.get(job.id).nextRunAt, legal)
|
|
187
|
+
// When 时钟推进后再次恢复
|
|
188
|
+
await new Promise((resolve) => setTimeout(resolve, 1100))
|
|
189
|
+
await scheduler.recover()
|
|
190
|
+
// Then 已合法的触发点不被重算推迟
|
|
191
|
+
assert.equal(store.jobs.get(job.id).nextRunAt, legal)
|
|
192
|
+
})
|
|
193
|
+
|
|
147
194
|
test('scheduler:并发闸门——maxConcurrent=1 时第二个单元排队,首个完成后推进', async (t) => {
|
|
148
195
|
// Given 两个任务同时到期,全局并发 1
|
|
149
196
|
const { store, runner, scheduler, advance } = await makeScheduler(t, { maxConcurrent: 1 })
|