@hile/schedule 2.1.1 → 3.0.2
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/AI.md +263 -0
- package/README.md +36 -60
- 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 +6 -4
package/AI.md
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# AI Guide For @hile/schedule
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
<!-- Generated by scripts/build-ai-context.mjs from docs/ai. Do not edit by hand. -->
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
Purpose: Run cron or delayed jobs, optionally in distributed mode with Redis lock protection.
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
Use this file when an AI agent installs the npm package and needs package-local examples, package selection rules, boundaries, and verification steps.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## Package Selection
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
| User asks for | Use | Also read |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| Run cron or delayed jobs | `@hile/schedule` | `packages/infrastructure.md` |
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Infrastructure Helpers
|
|
28
|
+
|
|
29
|
+
Packages: `@hile/typeorm`, `@hile/ioredis`, `@hile/logger`, `@hile/cache`, `@hile/schedule`, `@hile/loader`.
|
|
30
|
+
|
|
31
|
+
## Use When
|
|
32
|
+
|
|
33
|
+
Use these packages for database connections, Redis connections, structured logging, typed Redis caches, scheduled jobs, and file-system loaders.
|
|
34
|
+
|
|
35
|
+
## Do Not Use When
|
|
36
|
+
|
|
37
|
+
- Do not use default TypeORM or Redis services for multiple connections.
|
|
38
|
+
- Do not use `@hile/cache` without returning `new Cache(...)` from the loader function.
|
|
39
|
+
- Do not use `@hile/schedule` distributed mode without Redis lock TTLs sized for job duration.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pnpm add @hile/typeorm @hile/ioredis @hile/logger @hile/cache @hile/schedule @hile/loader
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Imports
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import typeormService, { transaction } from '@hile/typeorm'
|
|
51
|
+
import redisService from '@hile/ioredis'
|
|
52
|
+
import { createLogger } from '@hile/logger'
|
|
53
|
+
import { Cache, defineCache, RedisCache } from '@hile/cache'
|
|
54
|
+
import { Scheduler, defineJob } from '@hile/schedule'
|
|
55
|
+
import { scanDirectory, compileRoutePath, toRouterPath, normalizePath, Loader } from '@hile/loader'
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Copy-Paste Example
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { loadService } from '@hile/core'
|
|
62
|
+
import redisService from '@hile/ioredis'
|
|
63
|
+
import { Cache, defineCache, RedisCache } from '@hile/cache'
|
|
64
|
+
|
|
65
|
+
const userProfile = defineCache('user:{id:string}:profile', async ({ id }) => {
|
|
66
|
+
const user = await loadUser(id)
|
|
67
|
+
return new Cache(user).setExpire(300)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const redis = await loadService(redisService)
|
|
71
|
+
const cache = new RedisCache('app:', redis)
|
|
72
|
+
const users = await cache.loadCache(userProfile)
|
|
73
|
+
const user = await users.read({ id: 'u1' })
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## More Examples
|
|
77
|
+
|
|
78
|
+
TypeORM transaction with compensation:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
await transaction(ds, async (runner, rollback) => {
|
|
82
|
+
const user = await runner.manager.save(User, input)
|
|
83
|
+
rollback(() => redis.del(`user:${user.id}`))
|
|
84
|
+
await runner.manager.save(AuditLog, { userId: user.id, action: 'create' })
|
|
85
|
+
return user
|
|
86
|
+
})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Distributed scheduled job:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const scheduler = new Scheduler()
|
|
93
|
+
scheduler.add('daily-report', '0 8 * * *', async () => {
|
|
94
|
+
await sendDailyReport()
|
|
95
|
+
}, {
|
|
96
|
+
distributed: {
|
|
97
|
+
redis,
|
|
98
|
+
ttl: 60_000,
|
|
99
|
+
namespace: 'reports',
|
|
100
|
+
policy: 'skip-if-locked',
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Compose With
|
|
106
|
+
|
|
107
|
+
- Use `RedisCache` with `@hile/ioredis`.
|
|
108
|
+
- Use `defineCache(..., { singleflight: true })` to reduce stampedes; internally it uses Redis locks.
|
|
109
|
+
- Use scheduler distributed mode with `@hile/redis-lock`.
|
|
110
|
+
- Use `Loader` only when building a new file-system based convention.
|
|
111
|
+
|
|
112
|
+
## Runtime And Lifecycle Notes
|
|
113
|
+
|
|
114
|
+
- `Cache(undefined)` removes the key unless negative caching is configured.
|
|
115
|
+
- `Cache#setExpire(seconds)` uses seconds.
|
|
116
|
+
- `defineCache` typed placeholders support `string`, `number`, and `boolean`.
|
|
117
|
+
- `RedisCache.loadCache()` returns `read`, `write`, `remove`, `has`, and `multi`.
|
|
118
|
+
- `RedisCache.removeTag(tag)` removes tagged cache entries.
|
|
119
|
+
- `fieldable` caches use Redis hashes and cannot combine with stale or negative cache options.
|
|
120
|
+
- `Scheduler.add()` supports cron strings and `{ delay }`.
|
|
121
|
+
- `Scheduler.load()` reads default exports from `*.schedule.*` files produced by `defineJob()`.
|
|
122
|
+
- `scanDirectory()` matches `.ts`, `.js`, `.tsx`, `.jsx`, and `.mjs`.
|
|
123
|
+
|
|
124
|
+
## Anti-Patterns
|
|
125
|
+
|
|
126
|
+
- Returning raw values from `defineCache` handlers instead of `new Cache(value)`.
|
|
127
|
+
- Treating cache as source of truth.
|
|
128
|
+
- Forgetting to destroy manually created Redis or TypeORM clients.
|
|
129
|
+
- Scheduling jobs without idempotency when side effects can repeat.
|
|
130
|
+
|
|
131
|
+
## Verification Checklist
|
|
132
|
+
|
|
133
|
+
- Manual Redis clients call `disconnect()` during cleanup.
|
|
134
|
+
- Manual TypeORM data sources call `destroy()` during cleanup.
|
|
135
|
+
- Cache keys include app/tenant prefixes when shared Redis is used.
|
|
136
|
+
- Scheduled jobs have clear duplicate-run policy.
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# Related Recipes
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Queue Worker With Idempotency
|
|
145
|
+
|
|
146
|
+
## Complete Example
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
// src/services/email-worker.boot.ts
|
|
150
|
+
import { defineService, loadService } from '@hile/core'
|
|
151
|
+
import redisService from '@hile/ioredis'
|
|
152
|
+
import { RedisIdempotency, stableHash } from '@hile/redis-idempotency'
|
|
153
|
+
import { RedisStreamQueue, defineQueue } from '@hile/redis-stream-queue'
|
|
154
|
+
|
|
155
|
+
type EmailPayload = {
|
|
156
|
+
tenantId: string
|
|
157
|
+
userId: string
|
|
158
|
+
template: 'welcome'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const emailQueue = defineQueue<EmailPayload>('email')
|
|
162
|
+
|
|
163
|
+
export default defineService('email.worker', async (shutdown) => {
|
|
164
|
+
const redis = await loadService(redisService)
|
|
165
|
+
const queue = new RedisStreamQueue(redis, { prefix: 'app:' })
|
|
166
|
+
const idempotency = new RedisIdempotency(redis)
|
|
167
|
+
|
|
168
|
+
const worker = queue.worker(emailQueue, async (job) => {
|
|
169
|
+
await idempotency.run(
|
|
170
|
+
`idem:email:${job.data.tenantId}:${job.jobId ?? job.id}`,
|
|
171
|
+
() => sendEmail(job.data),
|
|
172
|
+
{
|
|
173
|
+
lockTtl: 60_000,
|
|
174
|
+
resultTtl: 86_400_000,
|
|
175
|
+
fingerprint: stableHash(job.data),
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
}, {
|
|
179
|
+
group: 'email-workers',
|
|
180
|
+
consumer: process.env.HOSTNAME ?? `${process.pid}`,
|
|
181
|
+
concurrency: 8,
|
|
182
|
+
claimIdle: 60_000,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
worker.start()
|
|
186
|
+
shutdown(() => worker.stop())
|
|
187
|
+
|
|
188
|
+
return { queue, worker }
|
|
189
|
+
})
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Enqueue:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
await queue.add(emailQueue, {
|
|
196
|
+
tenantId: 't1',
|
|
197
|
+
userId: 'u1',
|
|
198
|
+
template: 'welcome',
|
|
199
|
+
}, {
|
|
200
|
+
jobId: 'welcome:t1:u1',
|
|
201
|
+
maxAttempts: 5,
|
|
202
|
+
backoff: { type: 'exponential', baseMs: 1_000, maxMs: 60_000 },
|
|
203
|
+
})
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## File Layout
|
|
207
|
+
|
|
208
|
+
```text
|
|
209
|
+
src/
|
|
210
|
+
services/email-worker.boot.ts
|
|
211
|
+
queues/email.queue.ts
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## User Intent
|
|
215
|
+
|
|
216
|
+
Use this recipe for at-least-once background work where side effects must survive retries safely.
|
|
217
|
+
|
|
218
|
+
## Packages To Use
|
|
219
|
+
|
|
220
|
+
- `@hile/redis-stream-queue`
|
|
221
|
+
- `@hile/redis-idempotency`
|
|
222
|
+
- `@hile/ioredis`
|
|
223
|
+
- `@hile/core`
|
|
224
|
+
|
|
225
|
+
## Implementation Steps
|
|
226
|
+
|
|
227
|
+
1. Define the queue and payload type.
|
|
228
|
+
2. Enqueue with a stable `jobId`.
|
|
229
|
+
3. Wrap side effects with `RedisIdempotency.run()`.
|
|
230
|
+
4. Use business identifiers in idempotency keys.
|
|
231
|
+
5. Monitor DLQ with `readDeadLetters()`.
|
|
232
|
+
|
|
233
|
+
## Failure And Cleanup Behavior
|
|
234
|
+
|
|
235
|
+
- Queue delivery is at-least-once.
|
|
236
|
+
- Failed attempts retry until `maxAttempts`, then move to DLQ.
|
|
237
|
+
- `jobId` dedupes enqueue, not side effects.
|
|
238
|
+
- Idempotency caches successful results but is not exactly-once.
|
|
239
|
+
|
|
240
|
+
## Verification Checklist
|
|
241
|
+
|
|
242
|
+
- Handler is idempotent.
|
|
243
|
+
- Idempotency key is business-derived.
|
|
244
|
+
- Worker stop is registered with `shutdown`.
|
|
245
|
+
- DLQ read path exists.
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# Global Guardrails
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
## Never Generate These Patterns
|
|
254
|
+
|
|
255
|
+
- Do not call `loadService()` at module top level; it starts resources during import.
|
|
256
|
+
- Do not default-export plain functions from `*.boot.*` files; `hile start` expects a Hile service.
|
|
257
|
+
- Do not set `ctx.body` and also return a controller value.
|
|
258
|
+
- Do not assume `@hile/http` Zod validation mutates or coerces `ctx.query`, `ctx.params`, or `ctx.request.body`.
|
|
259
|
+
- Do not put reusable business logic only in controllers, pages, queue workers, or message handlers.
|
|
260
|
+
- Do not use old message examples that append a secondary response getter; current request APIs return promises directly.
|
|
261
|
+
- Do not claim exactly-once delivery or execution from Redis locks, queues, idempotency, or rate limits.
|
|
262
|
+
- Do not use queue `jobId` as the only side-effect idempotency boundary.
|
|
263
|
+
- Do not log the entire async context by default.
|
package/README.md
CHANGED
|
@@ -1,83 +1,59 @@
|
|
|
1
1
|
# @hile/schedule
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<!-- Generated by scripts/build-ai-context.mjs from docs/ai. Do not edit by hand. -->
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Run cron or delayed jobs, optionally in distributed mode with Redis lock protection.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
This README is intentionally short and example-first. The complete AI-facing guide ships in `AI.md` in this package.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
import { Scheduler, defineJob } from '@hile/schedule'
|
|
11
|
-
import { second } from '@hile/schedule'
|
|
9
|
+
## When To Use
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
Use these packages for database connections, Redis connections, structured logging, typed Redis caches, scheduled jobs, and file-system loaders.
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
scheduler.add('daily-report', '0 8 * * *', () => {
|
|
17
|
-
console.log('generating daily report...')
|
|
18
|
-
})
|
|
13
|
+
## Install
|
|
19
14
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
console.log('ran after 5 seconds')
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
scheduler.stop() // cancel all jobs
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add @hile/schedule
|
|
26
17
|
```
|
|
27
18
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
Create files with `{name}.schedule.ts`:
|
|
19
|
+
## Copy-Paste Example
|
|
31
20
|
|
|
32
21
|
```ts
|
|
33
|
-
|
|
34
|
-
import
|
|
22
|
+
import { loadService } from '@hile/core'
|
|
23
|
+
import redisService from '@hile/ioredis'
|
|
24
|
+
import { Cache, defineCache, RedisCache } from '@hile/cache'
|
|
35
25
|
|
|
36
|
-
|
|
37
|
-
|
|
26
|
+
const userProfile = defineCache('user:{id:string}:profile', async ({ id }) => {
|
|
27
|
+
const user = await loadUser(id)
|
|
28
|
+
return new Cache(user).setExpire(300)
|
|
38
29
|
})
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
Load them:
|
|
42
30
|
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
31
|
+
const redis = await loadService(redisService)
|
|
32
|
+
const cache = new RedisCache('app:', redis)
|
|
33
|
+
const users = await cache.loadCache(userProfile)
|
|
34
|
+
const user = await users.read({ id: 'u1' })
|
|
47
35
|
```
|
|
48
36
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
```ts
|
|
52
|
-
await scheduler.load('./jobs', { suffix: 'job' })
|
|
53
|
-
// loads *.job.ts, *.job.js, etc.
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
### API
|
|
57
|
-
|
|
58
|
-
#### `defineJob(expression, handler)`
|
|
59
|
-
|
|
60
|
-
- `expression: string | { delay: number }` — cron 表达式或延迟毫秒数
|
|
61
|
-
- Returns `{ id: number, type: 'job', expression, handler }`
|
|
62
|
-
|
|
63
|
-
#### `scheduler.add(id, expression | { delay }, handler)`
|
|
64
|
-
|
|
65
|
-
- `id: string | number` — 任务唯一标识,重复添加抛异常
|
|
66
|
-
|
|
67
|
-
#### `scheduler.remove(id)`
|
|
68
|
-
|
|
69
|
-
#### `scheduler.stop()`
|
|
70
|
-
|
|
71
|
-
取消所有任务
|
|
37
|
+
## Boundaries
|
|
72
38
|
|
|
73
|
-
|
|
39
|
+
- Do not use default TypeORM or Redis services for multiple connections.
|
|
40
|
+
- Do not use `@hile/cache` without returning `new Cache(...)` from the loader function.
|
|
41
|
+
- Do not use `@hile/schedule` distributed mode without Redis lock TTLs sized for job duration.
|
|
74
42
|
|
|
75
|
-
|
|
43
|
+
- Returning raw values from `defineCache` handlers instead of `new Cache(value)`.
|
|
44
|
+
- Treating cache as source of truth.
|
|
45
|
+
- Forgetting to destroy manually created Redis or TypeORM clients.
|
|
46
|
+
- Scheduling jobs without idempotency when side effects can repeat.
|
|
76
47
|
|
|
77
|
-
|
|
48
|
+
## Verify
|
|
78
49
|
|
|
79
|
-
|
|
50
|
+
- Manual Redis clients call `disconnect()` during cleanup.
|
|
51
|
+
- Manual TypeORM data sources call `destroy()` during cleanup.
|
|
52
|
+
- Cache keys include app/tenant prefixes when shared Redis is used.
|
|
53
|
+
- Scheduled jobs have clear duplicate-run policy.
|
|
80
54
|
|
|
81
|
-
##
|
|
55
|
+
## More Context
|
|
82
56
|
|
|
83
|
-
|
|
57
|
+
- `AI.md` in this package: full package-local AI guide.
|
|
58
|
+
- Root `llms-full.txt`: full monorepo AI context.
|
|
59
|
+
- Root `references/`: source files copied from `docs/ai`.
|
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.2",
|
|
4
4
|
"description": "Declarative job scheduler based on node-schedule",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"dist",
|
|
14
|
-
"README.md"
|
|
14
|
+
"README.md",
|
|
15
|
+
"AI.md"
|
|
15
16
|
],
|
|
16
17
|
"license": "MIT",
|
|
17
18
|
"publishConfig": {
|
|
@@ -23,8 +24,9 @@
|
|
|
23
24
|
"vitest": "^4.0.18"
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|
|
26
|
-
"@hile/loader": "^
|
|
27
|
+
"@hile/loader": "^3.0.0",
|
|
28
|
+
"@hile/redis-lock": "^3.0.2",
|
|
27
29
|
"node-schedule": "^2.1.1"
|
|
28
30
|
},
|
|
29
|
-
"gitHead": "
|
|
31
|
+
"gitHead": "0985b6f8abc1f4de0a36324063585fdc3ac1375b"
|
|
30
32
|
}
|