@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-cron-board",
3
3
  "description": "轻量级定时任务看板:环境变量集中管理与定时任务调度,执行体支持本地脚本与 dsh 会话任务",
4
- "version": "0.3.0",
4
+ "version": "0.3.1",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
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
- // 重启孤儿收尾:上次进程遗留的 queued/running 统一标 interrupted,不误标失败
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() {
@@ -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)
@@ -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 })