@hile/schedule 2.0.1 → 3.0.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/README.md +44 -4
- package/dist/index.d.ts +5 -6
- package/dist/index.js +22 -2
- package/dist/runner.d.ts +11 -0
- package/dist/runner.js +70 -0
- package/dist/scheduler.d.ts +4 -8
- package/dist/scheduler.js +38 -29
- package/dist/types.d.ts +33 -6
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Declarative job scheduler based on [node-schedule](https://github.com/node-schedule/node-schedule).
|
|
4
4
|
|
|
5
|
+
从 3.0.0 开始,新增或重构的 Hile 架构包统一进入 3.x 版本线,2.x 时代结束。
|
|
6
|
+
|
|
5
7
|
## Usage
|
|
6
8
|
|
|
7
9
|
### Code-defined jobs
|
|
@@ -25,6 +27,29 @@ scheduler.add('delayed-task', { delay: 5000 }, () => {
|
|
|
25
27
|
scheduler.stop() // cancel all jobs
|
|
26
28
|
```
|
|
27
29
|
|
|
30
|
+
### Distributed jobs
|
|
31
|
+
|
|
32
|
+
`@hile/schedule` can use `@hile/redis-lock` to make a job run on only one process when several app instances register the same schedule.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
const scheduler = new Scheduler()
|
|
36
|
+
|
|
37
|
+
scheduler.add('daily-report', '0 8 * * *', async () => {
|
|
38
|
+
await generateDailyReport()
|
|
39
|
+
}, {
|
|
40
|
+
distributed: {
|
|
41
|
+
redis,
|
|
42
|
+
ttl: 60_000,
|
|
43
|
+
},
|
|
44
|
+
onSkip(info) {
|
|
45
|
+
console.log('job skipped because another instance owns the lock', info.id)
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The default policy is `skip-if-locked`: if another process owns `schedule:{namespace}:{jobId}`, this run is skipped. Without `namespace`, the default namespace is `default`. Use `policy: 'wait'` with `wait` when a delayed wait is acceptable.
|
|
51
|
+
Set `distributed.namespace` when several apps share the same Redis but can run jobs with the same id independently.
|
|
52
|
+
|
|
28
53
|
### Auto-load from directory
|
|
29
54
|
|
|
30
55
|
Create files with `{name}.schedule.ts`:
|
|
@@ -33,8 +58,10 @@ Create files with `{name}.schedule.ts`:
|
|
|
33
58
|
// tasks/daily-report.schedule.ts
|
|
34
59
|
import { defineJob } from '@hile/schedule'
|
|
35
60
|
|
|
36
|
-
export default defineJob('0 8 * * *', () => {
|
|
61
|
+
export default defineJob('daily-report', '0 8 * * *', () => {
|
|
37
62
|
console.log('daily report generated')
|
|
63
|
+
}, {
|
|
64
|
+
distributed: { redis, ttl: 60_000 },
|
|
38
65
|
})
|
|
39
66
|
```
|
|
40
67
|
|
|
@@ -55,14 +82,27 @@ await scheduler.load('./jobs', { suffix: 'job' })
|
|
|
55
82
|
|
|
56
83
|
### API
|
|
57
84
|
|
|
58
|
-
#### `defineJob(expression, handler)`
|
|
85
|
+
#### `defineJob(id, expression, handler, options?)`
|
|
86
|
+
|
|
87
|
+
- `id: string | number` — stable task id. Recommended for distributed jobs and logs.
|
|
88
|
+
- `expression: string | { delay: number }` — cron 表达式或延迟毫秒数
|
|
89
|
+
- Returns `{ id, type: 'job', expression, handler, options }`
|
|
90
|
+
|
|
91
|
+
#### `defineJob(expression, handler, options?)`
|
|
59
92
|
|
|
60
93
|
- `expression: string | { delay: number }` — cron 表达式或延迟毫秒数
|
|
61
|
-
- Returns `{ id: number, type: 'job', expression, handler }`
|
|
94
|
+
- Returns `{ id: number, idAutoGenerated: true, type: 'job', expression, handler, options }`
|
|
95
|
+
- When loaded through `scheduler.load()`, auto-generated ids are replaced with the file route path, so `daily-report.schedule.ts` registers as `/daily-report`.
|
|
62
96
|
|
|
63
|
-
#### `scheduler.add(id, expression | { delay }, handler)`
|
|
97
|
+
#### `scheduler.add(id, expression | { delay }, handler, options?)`
|
|
64
98
|
|
|
65
99
|
- `id: string | number` — 任务唯一标识,重复添加抛异常
|
|
100
|
+
- `options.distributed.redis` — Redis 客户端
|
|
101
|
+
- `options.distributed.ttl` — job 锁租约,单位毫秒
|
|
102
|
+
- `options.distributed.namespace` — 可选,默认锁 key 的命名空间
|
|
103
|
+
- `options.distributed.lockKey` — 可选,自定义锁 key,默认 `schedule:{namespace || 'default'}:{id}`
|
|
104
|
+
- `options.onSkip(info)` — 没拿到锁时回调
|
|
105
|
+
- `options.onError(error, info)` — handler 或锁流程异常时回调
|
|
66
106
|
|
|
67
107
|
#### `scheduler.remove(id)`
|
|
68
108
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { Scheduler } from './scheduler.js';
|
|
2
|
-
import type { JobDefinition, JobHandler } from './types.js';
|
|
2
|
+
import type { JobDefinition, JobExpression, JobHandler, JobId, JobOptions } from './types.js';
|
|
3
3
|
export { Scheduler };
|
|
4
|
-
export
|
|
5
|
-
export type { JobDefinition, JobHandler };
|
|
6
|
-
export declare function defineJob(expression:
|
|
7
|
-
|
|
8
|
-
}, handler: () => Promise<void> | void): JobDefinition;
|
|
4
|
+
export { ScheduleJobRunner } from './runner.js';
|
|
5
|
+
export type { DistributedJobOptions, JobDefinition, JobExpression, JobHandler, JobId, JobInfo, JobOptions, JobRunInfo, } from './types.js';
|
|
6
|
+
export declare function defineJob<TId extends JobId>(id: TId, expression: JobExpression, handler: JobHandler, options?: JobOptions): JobDefinition<TId>;
|
|
7
|
+
export declare function defineJob(expression: JobExpression, handler: JobHandler, options?: JobOptions): JobDefinition<number>;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { Scheduler } from './scheduler.js';
|
|
2
2
|
export { Scheduler };
|
|
3
|
+
export { ScheduleJobRunner } from './runner.js';
|
|
3
4
|
let _jobId = 1;
|
|
4
|
-
export function defineJob(
|
|
5
|
-
|
|
5
|
+
export function defineJob(idOrExpression, expressionOrHandler, handlerOrOptions, maybeOptions) {
|
|
6
|
+
if (typeof expressionOrHandler === 'function') {
|
|
7
|
+
return {
|
|
8
|
+
id: _jobId++,
|
|
9
|
+
idAutoGenerated: true,
|
|
10
|
+
type: 'job',
|
|
11
|
+
expression: idOrExpression,
|
|
12
|
+
handler: expressionOrHandler,
|
|
13
|
+
options: handlerOrOptions,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
if (typeof handlerOrOptions !== 'function') {
|
|
17
|
+
throw new TypeError('defineJob handler is required');
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
id: idOrExpression,
|
|
21
|
+
type: 'job',
|
|
22
|
+
expression: expressionOrHandler,
|
|
23
|
+
handler: handlerOrOptions,
|
|
24
|
+
options: maybeOptions,
|
|
25
|
+
};
|
|
6
26
|
}
|
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { JobHandler, JobOptions, JobRunInfo } from './types.js';
|
|
2
|
+
export declare class ScheduleJobRunner {
|
|
3
|
+
private readonly options;
|
|
4
|
+
private readonly locks?;
|
|
5
|
+
constructor(options?: JobOptions);
|
|
6
|
+
createHandler(handler: JobHandler, info: JobRunInfo): () => void;
|
|
7
|
+
private run;
|
|
8
|
+
private runDistributed;
|
|
9
|
+
private resolveLockKey;
|
|
10
|
+
private reportError;
|
|
11
|
+
}
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { LockConflictError, LockTimeoutError, RedisLock, } from '@hile/redis-lock';
|
|
2
|
+
function isLockUnavailableError(error) {
|
|
3
|
+
return error instanceof LockConflictError || error instanceof LockTimeoutError;
|
|
4
|
+
}
|
|
5
|
+
export class ScheduleJobRunner {
|
|
6
|
+
options;
|
|
7
|
+
locks;
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
this.options = options;
|
|
10
|
+
if (options.distributed) {
|
|
11
|
+
this.locks = new RedisLock(options.distributed.redis);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
createHandler(handler, info) {
|
|
15
|
+
return () => {
|
|
16
|
+
void this.run(handler, info).catch(error => {
|
|
17
|
+
this.reportError(error, info);
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
async run(handler, info) {
|
|
22
|
+
const distributed = this.options.distributed;
|
|
23
|
+
if (!distributed) {
|
|
24
|
+
await handler(info);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
await this.runDistributed(handler, info, distributed);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (isLockUnavailableError(error)) {
|
|
32
|
+
await this.options.onSkip?.(info);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async runDistributed(handler, info, distributed) {
|
|
39
|
+
if (!this.locks)
|
|
40
|
+
throw new Error('Distributed schedule runner was not initialized');
|
|
41
|
+
const lockKey = this.resolveLockKey(info, distributed);
|
|
42
|
+
const wait = distributed.policy === 'wait' ? distributed.wait ?? distributed.ttl : 0;
|
|
43
|
+
await this.locks.withLock(lockKey, {
|
|
44
|
+
ttl: distributed.ttl,
|
|
45
|
+
wait,
|
|
46
|
+
pollInterval: distributed.pollInterval,
|
|
47
|
+
maxPollInterval: distributed.maxPollInterval,
|
|
48
|
+
renew: distributed.renew,
|
|
49
|
+
}, async () => {
|
|
50
|
+
await handler(info);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
resolveLockKey(info, distributed) {
|
|
54
|
+
if (typeof distributed.lockKey === 'function')
|
|
55
|
+
return distributed.lockKey(info);
|
|
56
|
+
if (distributed.lockKey !== undefined)
|
|
57
|
+
return distributed.lockKey;
|
|
58
|
+
return `schedule:${distributed.namespace ?? 'default'}:${info.id}`;
|
|
59
|
+
}
|
|
60
|
+
reportError(error, info) {
|
|
61
|
+
try {
|
|
62
|
+
const reported = this.options.onError?.(error, info);
|
|
63
|
+
if (reported)
|
|
64
|
+
void reported.catch(() => { });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Error reporters should not turn handled job failures into unhandled rejections.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
package/dist/scheduler.d.ts
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
import type { JobHandler } from './types.js';
|
|
2
|
-
export type JobInfo = {
|
|
3
|
-
id: string;
|
|
4
|
-
type: 'cron' | 'delay';
|
|
5
|
-
expression: string;
|
|
6
|
-
};
|
|
1
|
+
import type { JobHandler, JobInfo, JobOptions } from './types.js';
|
|
7
2
|
export declare class Scheduler {
|
|
8
3
|
private jobs;
|
|
9
4
|
private meta;
|
|
10
|
-
add(id: string | number, expression: string, handler: JobHandler): void;
|
|
5
|
+
add(id: string | number, expression: string, handler: JobHandler, options?: JobOptions): void;
|
|
11
6
|
add(id: string | number, options: {
|
|
12
7
|
delay: number;
|
|
13
|
-
}, handler: JobHandler): void;
|
|
8
|
+
}, handler: JobHandler, jobOptions?: JobOptions): void;
|
|
14
9
|
remove(id: string | number): void;
|
|
15
10
|
stop(): void;
|
|
16
11
|
getJobs(): JobInfo[];
|
|
@@ -24,4 +19,5 @@ export declare class Scheduler {
|
|
|
24
19
|
load(directory: string, options?: {
|
|
25
20
|
suffix?: string;
|
|
26
21
|
}): Promise<() => void>;
|
|
22
|
+
private resolveLoadedJobId;
|
|
27
23
|
}
|
package/dist/scheduler.js
CHANGED
|
@@ -1,33 +1,32 @@
|
|
|
1
1
|
import { scheduleJob } from 'node-schedule';
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { ScheduleJobRunner } from './runner.js';
|
|
3
4
|
import { scanDirectory } from '@hile/loader';
|
|
4
5
|
export class Scheduler {
|
|
5
6
|
jobs = new Map();
|
|
6
7
|
meta = new Map();
|
|
7
|
-
add(id, exprOrOpts, handler) {
|
|
8
|
+
add(id, exprOrOpts, handler, options) {
|
|
8
9
|
const key = String(id);
|
|
9
10
|
if (this.jobs.has(key))
|
|
10
11
|
throw new Error(`Job "${key}" already exists`);
|
|
11
|
-
// 包装 handler,捕获异步错误防止 node-schedule 未捕获 rejection
|
|
12
|
-
const safeHandler = () => {
|
|
13
|
-
try {
|
|
14
|
-
const result = handler();
|
|
15
|
-
if (result && typeof result.catch === 'function') {
|
|
16
|
-
result.catch(() => { });
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
catch {
|
|
20
|
-
// handler 同步错误也不应影响调度器
|
|
21
|
-
}
|
|
22
|
-
};
|
|
23
12
|
if (typeof exprOrOpts === 'string') {
|
|
24
|
-
|
|
25
|
-
|
|
13
|
+
const info = { id: key, type: 'cron', expression: exprOrOpts };
|
|
14
|
+
const safeHandler = new ScheduleJobRunner(options).createHandler(handler, info);
|
|
15
|
+
const job = scheduleJob(exprOrOpts, safeHandler);
|
|
16
|
+
if (!job)
|
|
17
|
+
throw new Error(`Failed to schedule job "${key}" with expression "${exprOrOpts}"`);
|
|
18
|
+
this.jobs.set(key, job);
|
|
19
|
+
this.meta.set(key, { type: info.type, expression: info.expression });
|
|
26
20
|
}
|
|
27
21
|
else {
|
|
28
22
|
const date = new Date(Date.now() + exprOrOpts.delay);
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
const info = { id: key, type: 'delay', expression: `delay:${exprOrOpts.delay}` };
|
|
24
|
+
const safeHandler = new ScheduleJobRunner(options).createHandler(handler, info);
|
|
25
|
+
const job = scheduleJob(date, safeHandler);
|
|
26
|
+
if (!job)
|
|
27
|
+
throw new Error(`Failed to schedule job "${key}" with delay ${exprOrOpts.delay}`);
|
|
28
|
+
this.jobs.set(key, job);
|
|
29
|
+
this.meta.set(key, { type: info.type, expression: info.expression });
|
|
31
30
|
}
|
|
32
31
|
}
|
|
33
32
|
remove(id) {
|
|
@@ -63,23 +62,33 @@ export class Scheduler {
|
|
|
63
62
|
async load(directory, options) {
|
|
64
63
|
const files = await scanDirectory(directory, { suffix: options?.suffix || 'schedule' });
|
|
65
64
|
const offFns = [];
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
65
|
+
try {
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
const mod = await import(pathToFileURL(file.absolute).href);
|
|
68
|
+
const jobDef = mod.default;
|
|
69
|
+
if (!jobDef || jobDef.type !== 'job')
|
|
70
|
+
continue;
|
|
71
|
+
const key = this.resolveLoadedJobId(jobDef, file);
|
|
72
|
+
if (typeof jobDef.expression === 'string') {
|
|
73
|
+
this.add(key, jobDef.expression, jobDef.handler, jobDef.options);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
this.add(key, jobDef.expression, jobDef.handler, jobDef.options);
|
|
77
|
+
}
|
|
78
|
+
offFns.push(() => this.remove(key));
|
|
77
79
|
}
|
|
78
|
-
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
for (const off of offFns.reverse())
|
|
83
|
+
off();
|
|
84
|
+
throw error;
|
|
79
85
|
}
|
|
80
86
|
return () => {
|
|
81
87
|
for (const off of offFns)
|
|
82
88
|
off();
|
|
83
89
|
};
|
|
84
90
|
}
|
|
91
|
+
resolveLoadedJobId(jobDef, file) {
|
|
92
|
+
return jobDef.idAutoGenerated ? file.routePath : String(jobDef.id);
|
|
93
|
+
}
|
|
85
94
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,9 +1,36 @@
|
|
|
1
|
-
|
|
2
|
-
export type
|
|
3
|
-
id:
|
|
1
|
+
import type { RedisLockLike, WithLockOptions } from '@hile/redis-lock';
|
|
2
|
+
export type JobRunInfo = {
|
|
3
|
+
id: string;
|
|
4
|
+
type: 'cron' | 'delay';
|
|
5
|
+
expression: string;
|
|
6
|
+
};
|
|
7
|
+
export type JobInfo = JobRunInfo;
|
|
8
|
+
export type JobHandler = (info: JobRunInfo) => Promise<void> | void;
|
|
9
|
+
export type JobId = string | number;
|
|
10
|
+
export type JobExpression = string | {
|
|
11
|
+
delay: number;
|
|
12
|
+
};
|
|
13
|
+
export type DistributedJobOptions = {
|
|
14
|
+
redis: RedisLockLike;
|
|
15
|
+
ttl: number;
|
|
16
|
+
namespace?: string;
|
|
17
|
+
lockKey?: string | ((info: JobRunInfo) => string);
|
|
18
|
+
policy?: 'skip-if-locked' | 'wait';
|
|
19
|
+
wait?: number;
|
|
20
|
+
pollInterval?: number;
|
|
21
|
+
maxPollInterval?: number;
|
|
22
|
+
renew?: WithLockOptions['renew'];
|
|
23
|
+
};
|
|
24
|
+
export type JobOptions = {
|
|
25
|
+
distributed?: DistributedJobOptions;
|
|
26
|
+
onError?: (error: unknown, info: JobRunInfo) => Promise<void> | void;
|
|
27
|
+
onSkip?: (info: JobRunInfo) => Promise<void> | void;
|
|
28
|
+
};
|
|
29
|
+
export type JobDefinition<TId extends JobId = JobId> = {
|
|
30
|
+
id: TId;
|
|
31
|
+
idAutoGenerated?: boolean;
|
|
4
32
|
type: 'job';
|
|
5
|
-
expression:
|
|
6
|
-
delay: number;
|
|
7
|
-
};
|
|
33
|
+
expression: JobExpression;
|
|
8
34
|
handler: JobHandler;
|
|
35
|
+
options?: JobOptions;
|
|
9
36
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hile/schedule",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Declarative job scheduler based on node-schedule",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
"vitest": "^4.0.18"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@hile/loader": "^2.
|
|
26
|
+
"@hile/loader": "^2.1.1",
|
|
27
|
+
"@hile/redis-lock": "^3.0.1",
|
|
27
28
|
"node-schedule": "^2.1.1"
|
|
28
29
|
},
|
|
29
|
-
"gitHead": "
|
|
30
|
+
"gitHead": "88f52fb95743f86761778776aff23631fcf9d821"
|
|
30
31
|
}
|