@fluojs/cron 1.1.0 → 3.0.0
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.ko.md +65 -9
- package/README.md +65 -9
- package/dist/decorators.d.ts.map +1 -1
- package/dist/decorators.js +4 -1
- package/dist/distributed-lock-manager.d.ts +13 -6
- package/dist/distributed-lock-manager.d.ts.map +1 -1
- package/dist/distributed-lock-manager.js +76 -34
- package/dist/module.d.ts.map +1 -1
- package/dist/module.js +37 -11
- package/dist/random-id.d.ts +3 -0
- package/dist/random-id.d.ts.map +1 -0
- package/dist/random-id.js +8 -0
- package/dist/service.d.ts +10 -3
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +137 -63
- package/dist/task-discovery.d.ts +15 -1
- package/dist/task-discovery.d.ts.map +1 -1
- package/dist/task-discovery.js +28 -1
- package/dist/types.d.ts +4 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -8
package/README.ko.md
CHANGED
|
@@ -1,15 +1,35 @@
|
|
|
1
1
|
# @fluojs/cron
|
|
2
2
|
|
|
3
3
|
<p><a href="./README.md"><kbd>English</kbd></a> <strong><kbd>한국어</kbd></strong></p>
|
|
4
|
+
<!-- fluo:cron-nestjs-migration: timezone-mapping -->
|
|
5
|
+
<!-- fluo:cron-nestjs-migration: wait-for-completion -->
|
|
6
|
+
<!-- fluo:cron-nestjs-migration: unsupported-options -->
|
|
7
|
+
<!-- fluo:cron-nestjs-migration: absolute-time -->
|
|
8
|
+
<!-- fluo:cron-nestjs-migration: named-interval-timeout -->
|
|
9
|
+
<!-- fluo:cron-nestjs-migration: async-configuration -->
|
|
10
|
+
<!-- fluo:cron-nestjs-migration: global-visibility -->
|
|
11
|
+
<!-- fluo:cron-nestjs-migration: category-switches -->
|
|
12
|
+
| Migration proposition | Governed fluo rule |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| `timezone-mapping` | `timeZone`은 `timezone`으로 매핑되며 `CronTaskOptions.timezone`은 문자열입니다. |
|
|
15
|
+
| `wait-for-completion` | `protect: true`가 Croner 호출의 중복을 막고 `CronLifecycleService`는 작업 실행 중 tick을 거부합니다. |
|
|
16
|
+
| `unsupported-options` | 문서화된 fluo 옵션 외 NestJS scheduler 옵션은 지원되지 않습니다. |
|
|
17
|
+
| `absolute-time` | `@Cron`은 cron-expression 문자열만 받고 `Date`와 `DateTime` overload는 지원되지 않습니다. |
|
|
18
|
+
| `named-interval-timeout` | `@Interval(ms, options)`와 `@Timeout(ms, options)`는 millisecond와 선택적 named task option을 받습니다. |
|
|
19
|
+
| `async-configuration` | `CronModule.forRoot(...)`는 동기식이며 async configuration은 호출 전에 해석합니다. |
|
|
20
|
+
| `global-visibility` | `CronModule.forRoot(...)`는 기본적으로 local이고 필요할 때 `global: true`를 명시합니다. |
|
|
21
|
+
| `category-switches` | `cronJobs`, `intervals`, `timeouts` category switch는 지원되지 않습니다. |
|
|
4
22
|
|
|
5
23
|
fluo 애플리케이션을 위한 데코레이터 기반 스케줄링 패키지입니다. 앱 라이프사이클에 맞춰 시작/종료를 관리하고, Redis 기반 분산 락(Distributed Locking) 기능을 제공합니다.
|
|
6
24
|
|
|
7
25
|
## 목차
|
|
8
26
|
|
|
9
27
|
- [설치](#설치)
|
|
28
|
+
- [Node.js 호환성](#nodejs-호환성)
|
|
10
29
|
- [사용 시점](#사용-시점)
|
|
11
30
|
- [빠른 시작](#빠른-시작)
|
|
12
31
|
- [공통 패턴](#공통-패턴)
|
|
32
|
+
- [NestJS Cron 옵션 마이그레이션](#nestjs-cron-옵션-마이그레이션)
|
|
13
33
|
- [분산 락 사용하기](#분산-락-사용하기)
|
|
14
34
|
- [동적 스케줄링](#동적-스케줄링)
|
|
15
35
|
- [제한된 종료](#제한된-종료)
|
|
@@ -20,13 +40,21 @@ fluo 애플리케이션을 위한 데코레이터 기반 스케줄링 패키지
|
|
|
20
40
|
## 설치
|
|
21
41
|
|
|
22
42
|
```bash
|
|
23
|
-
npm install @fluojs/cron
|
|
43
|
+
npm install @fluojs/cron
|
|
24
44
|
```
|
|
25
45
|
|
|
26
|
-
|
|
46
|
+
`@fluojs/cron`이 `croner`를 runtime dependency로 소유하므로 소비자가 scheduler engine을 직접 설치할 필요가 없습니다.
|
|
27
47
|
|
|
28
48
|
`@fluojs/redis`는 Redis distributed locking을 활성화할 때만 필요합니다. Non-distributed scheduling 경로는 package import, module registration, bootstrap, status snapshot 생성 중 Redis integration을 로드하지 않습니다.
|
|
29
49
|
|
|
50
|
+
## Node.js 호환성
|
|
51
|
+
|
|
52
|
+
`@fluojs/cron`은 패키지 자체의 지원 정책으로 Node.js `>=24.0.0 <27`을 요구합니다. Node.js 24 미만과 Node.js 27+는 지원하지 않습니다. Portable `@fluojs/runtime`의 engine omission은 별도 계약입니다.
|
|
53
|
+
|
|
54
|
+
### 3으로 마이그레이션
|
|
55
|
+
|
|
56
|
+
`@fluojs/cron` 2에서 업그레이드하기 전에 배포 host를 Node.js `>=24.0.0 <27`로 옮기세요. `@fluojs/cron` 3을 설치하기 전에 제거된 Node.js version을 사용하는 runtime image, CI matrix, local development environment를 업데이트해야 합니다.
|
|
57
|
+
|
|
30
58
|
## 사용 시점
|
|
31
59
|
|
|
32
60
|
- 정기적인 백그라운드 작업(예: 데이터베이스 정리, 리포트 생성)이 필요할 때 사용합니다.
|
|
@@ -41,7 +69,7 @@ npm install @fluojs/cron croner
|
|
|
41
69
|
애플리케이션 모듈의 스케줄링 등록은 `CronModule.forRoot(...)`로 구성합니다.
|
|
42
70
|
Cron 표현식은 다섯 필드(`minute hour day month weekday`) 또는 여섯 필드(`second minute hour day month weekday`)를 사용할 수 있습니다. 내장 `CronExpression` preset은 sub-minute 정밀도가 필요할 때 여섯 필드 표현식을 사용합니다. Cron task는 application bootstrap 이후에만 시작되고, 이미 시작된 registry에 동적으로 등록한 cron task는 등록 시점에 시작되며, fluo는 `timezone`과 no-overlap 보호를 scheduler에 전달해 같은 task instance가 자기 자신과 겹쳐 실행되지 않게 합니다.
|
|
43
71
|
|
|
44
|
-
Scheduling decorator는 public instance method에만 적용됩니다. NestJS에서 사용하던 private scheduled method, static helper, legacy decorator metadata 가정 뒤에 숨은 method name을 그대로 옮기지 마세요. 공개 provider/controller method를 노출하고 private 구현 세부사항은 그 method 뒤에 두세요.
|
|
72
|
+
Scheduling decorator는 public instance method에만 적용됩니다. NestJS에서 사용하던 private scheduled method, static helper, legacy decorator metadata 가정 뒤에 숨은 method name을 그대로 옮기지 마세요. 공개 provider/controller method를 노출하고 private 구현 세부사항은 그 method 뒤에 두세요. 명시적인 decorator `name` 값은 non-empty string이어야 하며, dynamic registry validation contract와 동일하게 검증됩니다.
|
|
45
73
|
|
|
46
74
|
```typescript
|
|
47
75
|
import { Module } from '@fluojs/core';
|
|
@@ -73,6 +101,34 @@ class AppModule {}
|
|
|
73
101
|
|
|
74
102
|
## 공통 패턴
|
|
75
103
|
|
|
104
|
+
### NestJS Cron 옵션 마이그레이션
|
|
105
|
+
|
|
106
|
+
NestJS `@Cron()` 옵션은 `CronTaskOptions`에 그대로 전달할 수 없습니다. NestJS `timeZone`을 fluo `timezone`으로 바꾸세요:
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
// NestJS
|
|
110
|
+
@Cron('0 9 * * *', { timeZone: 'Asia/Seoul', waitForCompletion: true })
|
|
111
|
+
|
|
112
|
+
// fluo
|
|
113
|
+
@Cron('0 9 * * *', { timezone: 'Asia/Seoul' })
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`waitForCompletion`을 복사하거나 overlap flag를 만들지 마세요. fluo는 두 옵션을 모두 노출하지 않으며, 모든 cron task에 scheduler-level no-overlap protection과 in-process running guard를 함께 적용합니다. 같은 task instance가 아직 실행 중일 때 다음 tick이 도착하면 fluo는 새 실행을 queue하지 않고 해당 tick을 건너뜁니다. 따라서 NestJS에서 `waitForCompletion: true`였던 task는 마이그레이션할 때 이 옵션을 생략합니다. NestJS task가 `waitForCompletion`을 생략하거나 `false`로 설정해 의도적으로 overlapping run에 의존했다면 fluo에서 overlap을 활성화할 수 있다고 가정하지 말고 application-owned queue 또는 worker 뒤로 작업을 재설계하세요.
|
|
117
|
+
|
|
118
|
+
이 guard는 한 application process 안의 같은 task instance만 보호합니다. 여러 application instance가 같은 task를 동시에 실행하지 않아야 한다면 [분산 락 사용하기](#분산-락-사용하기)를 적용하세요.
|
|
119
|
+
|
|
120
|
+
나머지 NestJS scheduling option에는 `CronTaskOptions` 대응 항목이 없습니다. `utcOffset`, `unrefTimeout`, `disabled`, `threshold`, `initialDelay`을 복사하지 마세요. fluo는 이를 받거나 흉내 내지 않습니다. Disabled 또는 category별 schedule은 해당 provider/module을 생략하는 application composition으로 소유하거나, application-owned condition이 만족된 뒤에만 dynamic task를 등록하세요. Threshold/recovery policy도 application-owned work로 다루세요. `@Timeout(ms, ...)`은 application startup 기준 지연이 의도한 동작일 때만 사용합니다. NestJS `@Cron(Date)` 또는 `@Cron(DateTime)`의 absolute-time 대체 수단이 아닙니다.
|
|
121
|
+
|
|
122
|
+
| NestJS scheduling form | fluo 마이그레이션 |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| `@Cron(expression, { timeZone })` | `@Cron(expression, { timezone })` |
|
|
125
|
+
| `@Cron(Date)` 또는 `@Cron(DateTime)` | 직접 대응 없음. Application code에서 absolute-time plan을 해석하고 startup-relative `@Timeout`으로 대체하지 마세요. |
|
|
126
|
+
| `@Interval('name', ms)` | `@Interval(ms, { name: 'name' })` |
|
|
127
|
+
| `@Timeout('name', ms)` | `@Timeout(ms, { name: 'name' })` |
|
|
128
|
+
| `ScheduleModule.forRootAsync(...)` | Application bootstrap/composition boundary에서 async configuration을 해석한 뒤 동기 `CronModule.forRoot(...)`를 호출합니다. |
|
|
129
|
+
| NestJS의 global-by-default schedule registration | Global visibility가 필요하면 `CronModule.forRoot({ global: true })`를 사용합니다. fluo 기본값은 `false`입니다. |
|
|
130
|
+
| `cronJobs`, `intervals`, `timeouts` category switch | 직접 대응 없음. Application에서 conditional provider/module composition 또는 명시적 dynamic registration을 소유하세요. |
|
|
131
|
+
|
|
76
132
|
### 분산 락 사용하기
|
|
77
133
|
|
|
78
134
|
여러 서버 인스턴스에서 스케줄링된 작업이 동시에 실행되는 것을 방지하려면 분산 모드를 활성화하세요. 이 기능은 `@fluojs/redis`가 필요하며, Redis peer는 `distributed.enabled`가 `true`일 때만 로드되고 resolve됩니다.
|
|
@@ -97,11 +153,11 @@ import { RedisModule } from '@fluojs/redis';
|
|
|
97
153
|
class AppModule {}
|
|
98
154
|
```
|
|
99
155
|
|
|
100
|
-
`distributed.clientName`을 생략하면 위의 기본 Redis 등록을 계속 사용합니다. 분산 락에 기본 Redis가 아닌 다른 연결을 쓰려면 `RedisModule.forRoot({ name, ... })`로 등록한 이름을 `distributed.clientName`에 지정하세요.
|
|
156
|
+
`distributed.clientName`을 생략하면 위의 기본 Redis 등록을 계속 사용합니다. 분산 락에 기본 Redis가 아닌 다른 연결을 쓰려면 `RedisModule.forRoot({ name, ... })`로 등록한 이름을 `distributed.clientName`에 지정하세요. fluo는 module option normalization 중 configured client name을 trim하고, lifecycle 또는 status reporting이 Redis dependency name을 사용하기 전에 blank 값을 거부합니다.
|
|
101
157
|
|
|
102
|
-
`distributed.lockTtlMs`는 `1_000ms` 이상이어야 합니다. fluo는
|
|
158
|
+
`distributed.lockTtlMs`는 `1_000ms` 이상이어야 합니다. Distributed locking이 활성화된 경우 fluo는 Redis를 load, resolve, probe하기 전에 module option normalization 중 module-level TTL을 검증합니다. Task-level `lockTtlMs` override는 module distributed mode와 해당 task의 distributed locking이 모두 활성화된 경우에만 검증됩니다. Module 또는 task locking이 비활성화되어 있으면 사용되지 않는 TTL이 distributed 최소값보다 낮다는 이유만으로 실패하지 않습니다. fluo는 활성 TTL이 만료되기 전에 Redis 락을 갱신하며, 최소 지원 경계인 `1_000ms`도 포함됩니다.
|
|
103
159
|
|
|
104
|
-
각 scheduler instance는 platform-neutral 기본 `distributed.ownerId`를 사용합니다. 배포 환경에 더 강한 stable-owner 규칙이 있을 때만 `distributed.ownerId`를 명시적으로 지정하세요. Lock release는 task 실행 뒤 `finally` 경로에서 수행됩니다. Redis release가 실패하면 fluo는 status snapshot의 local ownership을 유지하고 shutdown 중 다시 release를 시도합니다. Redis가 다른 owner의 key라고 응답하면 fencing이 이미 다른 곳으로 이동한 것이므로 local ownership을 정리합니다. Redis TTL과 renewal timing은 drift 영향을 받는 coordination primitive이지 강한 fencing token 자체는 아니므로, stale work가 위험한 long-running job은 idempotent하게 작성하고 application-level fencing을 함께 사용해야 합니다.
|
|
160
|
+
각 scheduler instance는 platform-neutral 기본 `distributed.ownerId`를 사용합니다. 배포 환경에 더 강한 stable-owner 규칙이 있을 때만 `distributed.ownerId`를 명시적으로 지정하세요. `distributed.ownerId`를 제공한 경우 fluo는 module option normalization 중에 값을 trim하고, scheduler 또는 Redis lifecycle setup 전에 blank 또는 non-string 값을 거부합니다. 따라서 유효하지 않거나 빈 owner 식별자가 Redis lock ownership 상태로 들어갈 수 없습니다. 성공한 acquisition마다 이 owner identity에서 고유한 lease token을 만들고 renewal과 release는 정확히 그 token을 비교합니다. 따라서 만료된 이전 acquisition의 늦은 release는 두 acquisition이 같은 configured `ownerId`를 재사용해도 더 새로운 lease를 삭제할 수 없습니다. Lock release는 task 실행 뒤 `finally` 경로에서 수행됩니다. Distributed tick이 이미 실행 중인 상태에서 bootstrap이 나중에 실패하면 startup rollback은 해당 active task가 drain되어 락을 release할 수 있을 때까지 Redis lock client를 유지합니다. Redis release가 실패하면 fluo는 status snapshot의 local ownership을 유지하고 shutdown 중 다시 release를 시도합니다. Redis가 다른 owner의 key라고 응답하면 fencing이 이미 다른 곳으로 이동한 것이므로 local ownership을 정리합니다. Redis TTL과 renewal timing은 drift 영향을 받는 coordination primitive이지 강한 fencing token 자체는 아니므로, stale work가 위험한 long-running job은 idempotent하게 작성하고 application-level fencing을 함께 사용해야 합니다.
|
|
105
161
|
|
|
106
162
|
```typescript
|
|
107
163
|
@Module({
|
|
@@ -149,15 +205,15 @@ class TaskManager {
|
|
|
149
205
|
}
|
|
150
206
|
```
|
|
151
207
|
|
|
152
|
-
Registry는 `addCron`, `addInterval`, `addTimeout`, `remove`, `enable`, `disable`, `get`, `getAll`, `updateCronExpression`, `updateIntervalMs`를 제공합니다. 첫 번째 `name` 인자는 기본 registry key이며, `options.name`을 전달하면 dynamic task의 실제 registry key, scheduler metadata name, 기본 distributed lock key가 이를 사용해 decorator naming semantics와 일치합니다. `get`과 `getAll`은 live `CronJob` handle
|
|
208
|
+
Registry는 `addCron`, `addInterval`, `addTimeout`, `remove`, `enable`, `disable`, `get`, `getAll`, `updateCronExpression`, `updateIntervalMs`를 제공합니다. 첫 번째 `name` 인자는 기본 registry key이며, `options.name`을 전달하면 dynamic task의 실제 registry key, scheduler metadata name, 기본 distributed lock key가 이를 사용해 decorator naming semantics와 일치합니다. Registry, decorator, dynamic `options.name` task name은 non-empty string이어야 합니다. Blank dynamic override name은 scheduler 또는 registry state를 남기기 전에 거부됩니다. `get`과 `getAll`은 live `CronJob` handle이나 mutable registry state가 아니라 immutable `SchedulingTaskDescriptor` snapshot을 반환합니다. Timeout task는 한 번 실행된 뒤 비활성화되지만 registry에는 남아 있어 의도적으로 다시 활성화할 수 있습니다.
|
|
153
209
|
|
|
154
|
-
Dynamic cron 등록은 scheduler startup과 원자적으로 처리됩니다. Scheduler가 새 cron job을 거부하면 registry는 half-registered task를 남기지 않습니다. 실행 중인 cron expression 또는 interval cadence update도 rollback-safe합니다.
|
|
210
|
+
Dynamic cron 등록은 scheduler startup과 원자적으로 처리됩니다. Scheduler가 새 cron job을 거부하면 registry는 half-registered task를 남기지 않습니다. 실행 중인 cron expression 또는 interval cadence update도 rollback-safe합니다. Provisional replacement는 이전 scheduled handle의 stop이 성공하고 registry가 새 handle token을 commit할 때까지 tick을 실행할 수 없으며, retired handle이 이미 queue한 callback도 무시됩니다. Replacement scheduling이 실패하거나 이전 handle을 stop할 수 없으면 fluo는 provisional replacement를 stop하고 이전 expression 또는 interval milliseconds와 handle을 복원한 뒤 failure를 다시 throw하므로 duplicate schedule을 조용히 남기지 않습니다. Active task를 disable 또는 remove할 때도 `stop()`이 성공한 뒤에만 scheduler handle을 지웁니다. Stop failure는 log로 드러나고 handle은 안전한 retry를 위해 registry에 남으며 operation은 `false`를 반환합니다. 실패한 `disable()`은 task descriptor를 disabled 상태로 두어 다음 tick을 계속 차단하고, 이후 disable 또는 shutdown이 cleanup을 재시도합니다. 실패한 `remove()`는 이후 removal이 성공할 때까지 task를 유지합니다. 첫 shutdown cleanup 시도도 실패하면 다음 shutdown lifecycle hook이 같은 retained handle을 다시 시도하고, `stop()`이 성공한 뒤에만 handle을 지웁니다. Cron task는 scheduler-level no-overlap protection과 fluo의 in-process running guard를 함께 사용하므로 같은 task instance가 overlapping tick으로 실행되지 않습니다.
|
|
155
211
|
|
|
156
212
|
### 제한된 종료
|
|
157
213
|
|
|
158
214
|
`CronModule`은 애플리케이션 종료 시 실행 중인 작업을 제한된 타임아웃 안에서 drain합니다. 따라서 하나의 hung task 때문에 프로세스 종료가 영원히 막히지 않습니다.
|
|
159
215
|
|
|
160
|
-
기본적으로 shutdown drain은 최대 `10_000ms` 동안 기다립니다. 이 시간이 지나면 스케줄러는 경고 로그를 남기고 hung task가 끝나기를 더 기다리지 않은 채 종료를 계속합니다. 분산 락을 사용하는 경우 아직 실행 중인 작업이 보유한 락은 timeout 시점에 즉시 해제하지 않습니다. 해당 작업이 정상적으로 끝날 때까지 락 소유권을 유지하거나, 프로세스가 종료된 뒤 Redis TTL로 만료되게
|
|
216
|
+
Shutdown은 scheduled handle을 stop하기 전에 tick admission을 닫습니다. 따라서 이미 queue된 callback도 drain이 시작된 뒤에는 task body를 시작하거나 distributed lease를 획득할 수 없습니다. 기본적으로 shutdown drain은 최대 `10_000ms` 동안 기다립니다. 이 시간이 지나면 스케줄러는 경고 로그를 남기고 hung task가 끝나기를 더 기다리지 않은 채 종료를 계속합니다. 같은 `shutdown.timeoutMs` 경계는 shutdown 중 Redis owned-lock release I/O에도 적용되므로, 멈춘 Redis release가 process termination을 무기한 막지 못합니다. Task의 post-run `finally` release와 즉시 이어지는 stopped-state retry는 shutdown 시작 시 설정되어 `shutdown.timeoutMs` 뒤에 만료되는 deadline의 남은 시간만 사용합니다. 따라서 deadline 뒤에 task가 settle되어도 새 release window를 열지 않습니다. 분산 락을 사용하는 경우 아직 실행 중인 작업이 보유한 락은 timeout 시점에 즉시 해제하지 않습니다. 해당 작업이 정상적으로 끝날 때까지 락 소유권을 유지하거나, 프로세스가 종료된 뒤 Redis TTL로 만료되게 둡니다. fluo는 lock renewal timer에 `unref()`를 호출하므로 다른 작업이 Node.js event loop를 활성 상태로 유지하는 동안에는 갱신을 계속하지만 timer 자체만으로 process를 유지하지 않으며, task가 settle되면 timer를 clear합니다. Release I/O 자체가 timeout되면 fluo는 Redis가 release를 확인하거나 다른 owner가 key를 보유한다고 응답할 때까지 local owned-lock visibility/status를 보존하고 ownership을 지우지 않습니다. 이렇게 원래 작업이 아직 실행 중인데 다른 노드가 같은 작업을 시작하지 않도록 합니다.
|
|
161
217
|
|
|
162
218
|
```typescript
|
|
163
219
|
@Module({
|
package/README.md
CHANGED
|
@@ -1,15 +1,35 @@
|
|
|
1
1
|
# @fluojs/cron
|
|
2
2
|
|
|
3
3
|
<p><strong><kbd>English</kbd></strong> <a href="./README.ko.md"><kbd>한국어</kbd></a></p>
|
|
4
|
+
<!-- fluo:cron-nestjs-migration: timezone-mapping -->
|
|
5
|
+
<!-- fluo:cron-nestjs-migration: wait-for-completion -->
|
|
6
|
+
<!-- fluo:cron-nestjs-migration: unsupported-options -->
|
|
7
|
+
<!-- fluo:cron-nestjs-migration: absolute-time -->
|
|
8
|
+
<!-- fluo:cron-nestjs-migration: named-interval-timeout -->
|
|
9
|
+
<!-- fluo:cron-nestjs-migration: async-configuration -->
|
|
10
|
+
<!-- fluo:cron-nestjs-migration: global-visibility -->
|
|
11
|
+
<!-- fluo:cron-nestjs-migration: category-switches -->
|
|
12
|
+
| Migration proposition | Governed fluo rule |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| `timezone-mapping` | `timeZone` maps to `timezone`; `CronTaskOptions.timezone` is a string. |
|
|
15
|
+
| `wait-for-completion` | `protect: true` prevents overlapping Croner invocations, and `CronLifecycleService` rejects a tick while its task is running. |
|
|
16
|
+
| `unsupported-options` | NestJS scheduler options other than the documented fluo options are unsupported. |
|
|
17
|
+
| `absolute-time` | `@Cron` accepts a cron-expression string only; `Date` and `DateTime` overloads are unsupported. |
|
|
18
|
+
| `named-interval-timeout` | `@Interval(ms, options)` and `@Timeout(ms, options)` accept milliseconds and optional named task options. |
|
|
19
|
+
| `async-configuration` | `CronModule.forRoot(...)` is synchronous; resolve async configuration before calling it. |
|
|
20
|
+
| `global-visibility` | `CronModule.forRoot(...)` is local by default; pass `global: true` explicitly when needed. |
|
|
21
|
+
| `category-switches` | `cronJobs`, `intervals`, and `timeouts` category switches are unsupported. |
|
|
4
22
|
|
|
5
23
|
Decorator-based scheduling for fluo applications with lifecycle-managed startup/shutdown and optional Redis distributed locking.
|
|
6
24
|
|
|
7
25
|
## Table of Contents
|
|
8
26
|
|
|
9
27
|
- [Installation](#installation)
|
|
28
|
+
- [Node.js Compatibility](#nodejs-compatibility)
|
|
10
29
|
- [When to Use](#when-to-use)
|
|
11
30
|
- [Quick Start](#quick-start)
|
|
12
31
|
- [Common Patterns](#common-patterns)
|
|
32
|
+
- [Migrating NestJS Cron Options](#migrating-nestjs-cron-options)
|
|
13
33
|
- [Distributed Locking](#distributed-locking)
|
|
14
34
|
- [Dynamic Scheduling](#dynamic-scheduling)
|
|
15
35
|
- [Bounded Shutdown](#bounded-shutdown)
|
|
@@ -20,13 +40,21 @@ Decorator-based scheduling for fluo applications with lifecycle-managed startup/
|
|
|
20
40
|
## Installation
|
|
21
41
|
|
|
22
42
|
```bash
|
|
23
|
-
npm install @fluojs/cron
|
|
43
|
+
npm install @fluojs/cron
|
|
24
44
|
```
|
|
25
45
|
|
|
26
|
-
|
|
46
|
+
`@fluojs/cron` owns `croner` as a runtime dependency, so consumers do not need to install the scheduler engine directly.
|
|
27
47
|
|
|
28
48
|
`@fluojs/redis` is needed only when Redis distributed locking is enabled. Non-distributed scheduling paths do not load the Redis integration during package import, module registration, bootstrap, or status snapshot creation.
|
|
29
49
|
|
|
50
|
+
## Node.js Compatibility
|
|
51
|
+
|
|
52
|
+
`@fluojs/cron` requires Node.js `>=24.0.0 <27` as its package-owned support policy. Node.js versions below 24 and Node.js 27+ are not supported. The portable `@fluojs/runtime` engine omission is a separate contract.
|
|
53
|
+
|
|
54
|
+
### Migration to 3
|
|
55
|
+
|
|
56
|
+
Before upgrading from `@fluojs/cron` 2, move deployment hosts to Node.js `>=24.0.0 <27`. Update any runtime image, CI matrix, or local development environment that still uses a removed Node.js version before installing `@fluojs/cron` 3.
|
|
57
|
+
|
|
30
58
|
## When to Use
|
|
31
59
|
|
|
32
60
|
- When you need to run periodic background tasks (e.g., database cleanup, report generation).
|
|
@@ -41,7 +69,7 @@ Register the `CronModule` and use decorators to schedule your methods.
|
|
|
41
69
|
Use `CronModule.forRoot(...)` to register scheduling for an application module.
|
|
42
70
|
Cron expressions may use either five fields (`minute hour day month weekday`) or six fields (`second minute hour day month weekday`). The built-in `CronExpression` presets use six-field expressions when sub-minute precision is needed. Cron tasks start only after application bootstrap, dynamically registered cron tasks start when added to a started registry, and fluo forwards `timezone` plus no-overlap protection to the scheduler so one task instance does not overlap itself.
|
|
43
71
|
|
|
44
|
-
Scheduling decorators apply to public instance methods only. Do not migrate NestJS private scheduled methods, static helpers, or method names that are hidden behind legacy decorator metadata assumptions as-is; expose a public provider/controller method and keep any private implementation details behind that method.
|
|
72
|
+
Scheduling decorators apply to public instance methods only. Do not migrate NestJS private scheduled methods, static helpers, or method names that are hidden behind legacy decorator metadata assumptions as-is; expose a public provider/controller method and keep any private implementation details behind that method. Explicit decorator `name` values must be non-empty strings, matching the dynamic registry validation contract.
|
|
45
73
|
|
|
46
74
|
```typescript
|
|
47
75
|
import { Module } from '@fluojs/core';
|
|
@@ -73,6 +101,34 @@ class AppModule {}
|
|
|
73
101
|
|
|
74
102
|
## Common Patterns
|
|
75
103
|
|
|
104
|
+
### Migrating NestJS Cron Options
|
|
105
|
+
|
|
106
|
+
NestJS `@Cron()` options are not a drop-in `CronTaskOptions` object. Rename NestJS `timeZone` to fluo `timezone`:
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
// NestJS
|
|
110
|
+
@Cron('0 9 * * *', { timeZone: 'Asia/Seoul', waitForCompletion: true })
|
|
111
|
+
|
|
112
|
+
// fluo
|
|
113
|
+
@Cron('0 9 * * *', { timezone: 'Asia/Seoul' })
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Do not copy `waitForCompletion` or invent an overlap flag. fluo does not expose either option: every cron task uses scheduler-level no-overlap protection plus an in-process running guard. If another tick arrives while the same task instance is still running, fluo skips that tick instead of queueing another run. A NestJS task with `waitForCompletion: true` therefore omits the option when migrated. If the NestJS task left `waitForCompletion` unset or set it to `false` and intentionally depended on overlapping runs, redesign that work behind an application-owned queue or worker rather than expecting fluo to enable overlap.
|
|
117
|
+
|
|
118
|
+
This guard covers one task instance in one application process. Use [Distributed Locking](#distributed-locking) when multiple application instances must not run the same task concurrently.
|
|
119
|
+
|
|
120
|
+
The remaining NestJS scheduling options have no `CronTaskOptions` equivalent. Do not copy `utcOffset`, `unrefTimeout`, `disabled`, `threshold`, or `initialDelay`: fluo does not accept or emulate them. Make disabled or category-specific schedules part of application composition by omitting the provider/module that declares them, or register a dynamic task only after the application-owned condition is satisfied. Treat threshold/recovery policy as application-owned work. Use `@Timeout(ms, ...)` only when a delay relative to application startup is the intended behavior; it is not an absolute-time replacement for NestJS `@Cron(Date)` or `@Cron(DateTime)`.
|
|
121
|
+
|
|
122
|
+
| NestJS scheduling form | fluo migration |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| `@Cron(expression, { timeZone })` | `@Cron(expression, { timezone })` |
|
|
125
|
+
| `@Cron(Date)` or `@Cron(DateTime)` | No direct equivalent. Resolve the absolute-time plan in application code; do not substitute a startup-relative `@Timeout`. |
|
|
126
|
+
| `@Interval('name', ms)` | `@Interval(ms, { name: 'name' })` |
|
|
127
|
+
| `@Timeout('name', ms)` | `@Timeout(ms, { name: 'name' })` |
|
|
128
|
+
| `ScheduleModule.forRootAsync(...)` | Resolve async configuration at the application bootstrap/composition boundary, then call synchronous `CronModule.forRoot(...)`. |
|
|
129
|
+
| NestJS global-by-default schedule registration | `CronModule.forRoot({ global: true })` when global visibility is required; fluo defaults to `false`. |
|
|
130
|
+
| `cronJobs`, `intervals`, or `timeouts` category switches | No direct equivalent. Own the conditional provider/module composition or explicit dynamic registration in the application. |
|
|
131
|
+
|
|
76
132
|
### Distributed Locking
|
|
77
133
|
|
|
78
134
|
To prevent scheduled tasks from running concurrently across multiple server instances, enable distributed mode. This requires `@fluojs/redis`; the Redis peer is loaded and resolved only when `distributed.enabled` is `true`.
|
|
@@ -97,11 +153,11 @@ import { RedisModule } from '@fluojs/redis';
|
|
|
97
153
|
class AppModule {}
|
|
98
154
|
```
|
|
99
155
|
|
|
100
|
-
Leave `distributed.clientName` unset to keep using the default Redis registration above. To use a non-default Redis connection for distributed locks, set `distributed.clientName` to the name registered through `RedisModule.forRoot({ name, ... })`.
|
|
156
|
+
Leave `distributed.clientName` unset to keep using the default Redis registration above. To use a non-default Redis connection for distributed locks, set `distributed.clientName` to the name registered through `RedisModule.forRoot({ name, ... })`. fluo trims the configured client name during module option normalization and rejects blank values before lifecycle or status reporting uses the Redis dependency name.
|
|
101
157
|
|
|
102
|
-
`distributed.lockTtlMs` must stay at or above `1_000ms`. fluo renews the Redis lock before
|
|
158
|
+
`distributed.lockTtlMs` must stay at or above `1_000ms`. When distributed locking is enabled, fluo validates that module-level TTL during option normalization before loading, resolving, or probing Redis. Task-level `lockTtlMs` overrides are validated only when module distributed mode and that task's distributed locking are both enabled. Disabled module or task locking does not fail solely because an inactive TTL is below the distributed minimum. fluo renews the Redis lock before the active TTL expires, including the minimum supported `1_000ms` boundary.
|
|
103
159
|
|
|
104
|
-
Each scheduler instance uses a platform-neutral default `distributed.ownerId`; set `distributed.ownerId` explicitly only when your deployment has a stronger stable-owner convention. Lock release runs in a `finally` path after task execution. If Redis release fails, fluo keeps local ownership in status snapshots and retries during shutdown; if Redis reports that another owner holds the key, local ownership is cleared because fencing has already moved elsewhere. Redis TTL and renewal timing are still drift-sensitive coordination primitives rather than hard fencing tokens, so long-running jobs should remain idempotent and use application-level fencing when stale work would be unsafe.
|
|
160
|
+
Each scheduler instance uses a platform-neutral default `distributed.ownerId`; set `distributed.ownerId` explicitly only when your deployment has a stronger stable-owner convention. When `distributed.ownerId` is provided, fluo trims it during module option normalization and rejects blank or non-string values before scheduler or Redis lifecycle setup, so invalid or empty owner identifiers cannot enter Redis lock ownership state. Every successful acquisition derives a unique lease token from that owner identity, and renewal and release compare the exact token. A delayed release from an expired acquisition therefore cannot delete a newer lease even when both acquisitions reuse the same configured `ownerId`. Lock release runs in a `finally` path after task execution. If bootstrap later fails while a distributed tick is already running, startup rollback keeps the Redis lock client available until that active task can drain and release its lock. If Redis release fails, fluo keeps local ownership in status snapshots and retries during shutdown; if Redis reports that another owner holds the key, local ownership is cleared because fencing has already moved elsewhere. Redis TTL and renewal timing are still drift-sensitive coordination primitives rather than hard fencing tokens, so long-running jobs should remain idempotent and use application-level fencing when stale work would be unsafe.
|
|
105
161
|
|
|
106
162
|
```typescript
|
|
107
163
|
@Module({
|
|
@@ -149,15 +205,15 @@ class TaskManager {
|
|
|
149
205
|
}
|
|
150
206
|
```
|
|
151
207
|
|
|
152
|
-
The registry exposes `addCron`, `addInterval`, `addTimeout`, `remove`, `enable`, `disable`, `get`, `getAll`, `updateCronExpression`, and `updateIntervalMs`. The first `name` argument is the default registry key; passing `options.name` overrides the actual registry key, scheduler metadata name, and default distributed lock key for dynamic tasks so dynamic registration matches decorator naming semantics. `get` and `getAll` return
|
|
208
|
+
The registry exposes `addCron`, `addInterval`, `addTimeout`, `remove`, `enable`, `disable`, `get`, `getAll`, `updateCronExpression`, and `updateIntervalMs`. The first `name` argument is the default registry key; passing `options.name` overrides the actual registry key, scheduler metadata name, and default distributed lock key for dynamic tasks so dynamic registration matches decorator naming semantics. Registry, decorator, and dynamic `options.name` task names must be non-empty strings; blank dynamic override names are rejected before scheduler or registry state is retained. `get` and `getAll` return immutable `SchedulingTaskDescriptor` snapshots, not live `CronJob` handles or mutable registry state. Timeout tasks run once, then disable themselves while remaining in the registry so they can be re-enabled deliberately.
|
|
153
209
|
|
|
154
|
-
Dynamic cron registration is atomic with scheduler startup: if the scheduler rejects a new cron job, the registry does not retain a half-registered task. Updating a running cron expression or interval cadence is also rollback-safe. If
|
|
210
|
+
Dynamic cron registration is atomic with scheduler startup: if the scheduler rejects a new cron job, the registry does not retain a half-registered task. Updating a running cron expression or interval cadence is also rollback-safe. A provisional replacement remains tick-ineligible until the previous scheduled handle stops successfully and the registry commits the new handle token; callbacks already queued by a retired handle are ignored. If replacement scheduling fails or the previous handle cannot be stopped, fluo stops the provisional replacement, restores the previous expression or interval milliseconds and handle, and rethrows the failure instead of silently retaining duplicate schedules. Disabling or removing an active task clears its scheduler handle only after `stop()` succeeds. A stop failure is logged, the handle remains registered for a safe retry, and the operation returns `false`. A failed `disable()` leaves the task descriptor disabled so later ticks stay gated while another disable or shutdown retries cleanup; a failed `remove()` keeps the task registered until a later removal succeeds. If the first shutdown cleanup attempt also fails, the next shutdown lifecycle hook retries the same retained handle and clears it only after `stop()` succeeds. Cron tasks use both scheduler-level no-overlap protection and fluo's in-process running guard, so the same task instance will not run overlapping ticks.
|
|
155
211
|
|
|
156
212
|
### Bounded Shutdown
|
|
157
213
|
|
|
158
214
|
`CronModule` drains active task executions during application shutdown with a bounded timeout so one hung task cannot block process termination forever.
|
|
159
215
|
|
|
160
|
-
By default the shutdown drain waits up to `10_000ms`. If that timeout expires, the scheduler logs a warning and continues shutdown without waiting for the hung task to settle. When distributed locking is enabled, locks held by still-running tasks are not eagerly released on timeout; they remain owned by that task until it settles normally, or until Redis expires the lock after the process exits. This prevents another node from starting the same job while the original task is still running.
|
|
216
|
+
Shutdown closes tick admission before scheduled handles are stopped, so callbacks that were already queued cannot start task bodies or acquire distributed leases after draining begins. By default the shutdown drain waits up to `10_000ms`. If that timeout expires, the scheduler logs a warning and continues shutdown without waiting for the hung task to settle. The same `shutdown.timeoutMs` boundary also applies to Redis owned-lock release I/O during shutdown, so a stuck Redis release cannot block process termination indefinitely. A task's post-run `finally` release and any immediate stopped-state retry use only the time remaining on the deadline established when shutdown starts, which expires after `shutdown.timeoutMs`; a task that settles after that deadline does not open a new release window. When distributed locking is enabled, locks held by still-running tasks are not eagerly released on timeout; they remain owned by that task until it settles normally, or until Redis expires the lock after the process exits. fluo calls `unref()` on lock renewal timers so they continue renewing while other work keeps the Node.js event loop active without retaining the process by themselves, and it clears them when the task settles. If release I/O itself times out, fluo preserves local owned-lock visibility/status and does not clear ownership until Redis confirms release or reports that another owner holds the key. This prevents another node from starting the same job while the original task is still running.
|
|
161
217
|
|
|
162
218
|
```typescript
|
|
163
219
|
@Module({
|
package/dist/decorators.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"decorators.d.ts","sourceRoot":"","sources":["../src/decorators.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAEV,eAAe,EAEf,mBAAmB,EAEnB,kBAAkB,EACnB,MAAM,YAAY,CAAC;AAGpB,KAAK,yBAAyB,GAAG,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,2BAA2B,KAAK,IAAI,CAAC;AACjG,KAAK,mBAAmB,GAAG,yBAAyB,CAAC;
|
|
1
|
+
{"version":3,"file":"decorators.d.ts","sourceRoot":"","sources":["../src/decorators.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAEV,eAAe,EAEf,mBAAmB,EAEnB,kBAAkB,EACnB,MAAM,YAAY,CAAC;AAGpB,KAAK,yBAAyB,GAAG,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,2BAA2B,KAAK,IAAI,CAAC;AACjG,KAAK,mBAAmB,GAAG,yBAAyB,CAAC;AA4CrD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,IAAI,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB,GAAG,mBAAmB,CAoB3F;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,mBAAmB,CAgB3F;AAED;;;;;;GAMG;AACH,wBAAgB,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,GAAE,kBAAuB,GAAG,mBAAmB,CAgBzF"}
|
package/dist/decorators.js
CHANGED
|
@@ -8,7 +8,7 @@ function getStandardMetadataBag(metadata) {
|
|
|
8
8
|
function defineStandardSchedulingMetadata(metadata, propertyKey, taskMetadata) {
|
|
9
9
|
const bag = getStandardMetadataBag(metadata);
|
|
10
10
|
const current = bag[schedulingMetadataSymbol];
|
|
11
|
-
const map = current
|
|
11
|
+
const map = Object.hasOwn(bag, schedulingMetadataSymbol) && current !== undefined ? current : new Map(current);
|
|
12
12
|
map.set(propertyKey, {
|
|
13
13
|
...taskMetadata,
|
|
14
14
|
options: {
|
|
@@ -26,6 +26,9 @@ function assertMethodIsPublic(context, decoratorName) {
|
|
|
26
26
|
if (context.private) {
|
|
27
27
|
throw new Error(`${decoratorName}() cannot be used on private methods.`);
|
|
28
28
|
}
|
|
29
|
+
if (context.static) {
|
|
30
|
+
throw new Error(`${decoratorName}() cannot be used on static methods.`);
|
|
31
|
+
}
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
/**
|
|
@@ -11,12 +11,17 @@ export interface LockRenewalMonitor {
|
|
|
11
11
|
getPostRunError(): Promise<Error | undefined>;
|
|
12
12
|
stop(): void;
|
|
13
13
|
}
|
|
14
|
+
interface DistributedLockLease {
|
|
15
|
+
readonly lockKey: string;
|
|
16
|
+
readonly taskName: string;
|
|
17
|
+
readonly token: string;
|
|
18
|
+
}
|
|
14
19
|
/** Coordinates Redis lock acquisition, renewal, and release for scheduled cron tasks. */
|
|
15
20
|
export declare class CronDistributedLockManager {
|
|
16
21
|
private readonly options;
|
|
17
22
|
private readonly runtimeContainer;
|
|
18
23
|
private readonly logger;
|
|
19
|
-
private readonly
|
|
24
|
+
private readonly ownedLeases;
|
|
20
25
|
private lockIoError;
|
|
21
26
|
private redisClient;
|
|
22
27
|
private lockOwnershipLosses;
|
|
@@ -29,18 +34,20 @@ export declare class CronDistributedLockManager {
|
|
|
29
34
|
get renewalFailures(): number;
|
|
30
35
|
resolveClient(): Promise<void>;
|
|
31
36
|
reset(): void;
|
|
32
|
-
tryAcquireLock(descriptor: CronTaskDescriptor): Promise<
|
|
33
|
-
startLockRenewalMonitor(descriptor: CronTaskDescriptor): LockRenewalMonitor;
|
|
34
|
-
releaseLock(
|
|
35
|
-
releaseOwnedLocks(excludedLockKeys?: ReadonlySet<string
|
|
37
|
+
tryAcquireLock(descriptor: CronTaskDescriptor): Promise<DistributedLockLease | undefined>;
|
|
38
|
+
startLockRenewalMonitor(descriptor: CronTaskDescriptor, lease: DistributedLockLease): LockRenewalMonitor;
|
|
39
|
+
releaseLock(lease: DistributedLockLease, timeoutMs?: number): Promise<boolean>;
|
|
40
|
+
releaseOwnedLocks(excludedLockKeys?: ReadonlySet<string>, timeoutMs?: number): Promise<void>;
|
|
36
41
|
private createLockRenewalState;
|
|
37
42
|
private queueDueLockRenewalAttempts;
|
|
38
43
|
private runLockRenewalAttempt;
|
|
39
44
|
private toLockPostRunError;
|
|
40
45
|
private renewLock;
|
|
41
|
-
private
|
|
46
|
+
private releaseLease;
|
|
47
|
+
private clearOwnedLease;
|
|
42
48
|
private verifyLockIoAvailability;
|
|
43
49
|
private markLockIoAvailable;
|
|
44
50
|
private markLockIoUnavailable;
|
|
45
51
|
}
|
|
52
|
+
export {};
|
|
46
53
|
//# sourceMappingURL=distributed-lock-manager.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"distributed-lock-manager.d.ts","sourceRoot":"","sources":["../src/distributed-lock-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"distributed-lock-manager.d.ts","sourceRoot":"","sources":["../src/distributed-lock-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAGzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,2BAA2B,EAAE,MAAM,YAAY,CAAC;AAElF,yEAAyE;AACzE,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,WAAW,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACrF,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;CAC7G;AAED,mEAAmE;AACnE,MAAM,WAAW,kBAAkB;IACjC,eAAe,IAAI,OAAO,CAAC,KAAK,GAAG,SAAS,CAAC,CAAC;IAC9C,IAAI,IAAI,IAAI,CAAC;CACd;AAWD,UAAU,oBAAoB;IAC5B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAuED,yFAAyF;AACzF,qBAAa,0BAA0B;IAQnC,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM;IATzB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA2C;IACvE,OAAO,CAAC,WAAW,CAAoB;IACvC,OAAO,CAAC,WAAW,CAA8B;IACjD,OAAO,CAAC,mBAAmB,CAAK;IAChC,OAAO,CAAC,mBAAmB,CAAK;gBAGb,OAAO,EAAE,2BAA2B,EACpC,gBAAgB,EAAE,SAAS,EAC3B,MAAM,EAAE,iBAAiB;IAG5C,IAAI,cAAc,IAAI,eAAe,GAAG,SAAS,CAEhD;IAED,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED,IAAI,eAAe,IAAI,OAAO,CAM7B;IAED,IAAI,eAAe,IAAI,MAAM,CAE5B;IAED,IAAI,eAAe,IAAI,MAAM,CAE5B;IAEK,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC;IAsBpC,KAAK,IAAI,IAAI;IAKP,cAAc,CAAC,UAAU,EAAE,kBAAkB,GAAG,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC;IA6C/F,uBAAuB,CAAC,UAAU,EAAE,kBAAkB,EAAE,KAAK,EAAE,oBAAoB,GAAG,kBAAkB;IA8BlG,WAAW,CAAC,KAAK,EAAE,oBAAoB,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAI9E,iBAAiB,CAAC,gBAAgB,GAAE,WAAW,CAAC,MAAM,CAAa,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAkB7G,OAAO,CAAC,sBAAsB;IAa9B,OAAO,CAAC,2BAA2B;YAcrB,qBAAqB;IAqBnC,OAAO,CAAC,kBAAkB;YAYZ,SAAS;YA2CT,YAAY;IAyC1B,OAAO,CAAC,eAAe;YAMT,wBAAwB;IAoBtC,OAAO,CAAC,mBAAmB;IAI3B,OAAO,CAAC,qBAAqB;CAG9B"}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createCronRandomId } from './random-id.js';
|
|
2
|
+
|
|
1
3
|
/** Minimal Redis command surface required for distributed cron locks. */
|
|
2
4
|
|
|
3
5
|
/** Tracks renewal state for one acquired distributed cron lock. */
|
|
@@ -16,6 +18,26 @@ function isMissingRedisPeer(error) {
|
|
|
16
18
|
function createRedisBootstrapError() {
|
|
17
19
|
return new Error(['Cron distributed mode requires @fluojs/redis to be installed and registered.', 'Install and import @fluojs/redis, or disable distributed locking with distributed.enabled: false.'].join(' '));
|
|
18
20
|
}
|
|
21
|
+
function createLockReleaseTimeoutError(timeoutMs) {
|
|
22
|
+
return new Error(`Distributed cron lock release timed out after ${String(timeoutMs)}ms.`);
|
|
23
|
+
}
|
|
24
|
+
async function withTimeout(operation, timeoutMs) {
|
|
25
|
+
if (timeoutMs === undefined) {
|
|
26
|
+
return await operation;
|
|
27
|
+
}
|
|
28
|
+
let timeoutHandle;
|
|
29
|
+
try {
|
|
30
|
+
return await Promise.race([operation, new Promise((_resolve, reject) => {
|
|
31
|
+
timeoutHandle = setTimeout(() => {
|
|
32
|
+
reject(createLockReleaseTimeoutError(timeoutMs));
|
|
33
|
+
}, timeoutMs);
|
|
34
|
+
})]);
|
|
35
|
+
} finally {
|
|
36
|
+
if (timeoutHandle) {
|
|
37
|
+
clearTimeout(timeoutHandle);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
19
41
|
async function resolveRedisPeerModule() {
|
|
20
42
|
try {
|
|
21
43
|
return await loadRedisPeerModule();
|
|
@@ -29,7 +51,7 @@ async function resolveRedisPeerModule() {
|
|
|
29
51
|
|
|
30
52
|
/** Coordinates Redis lock acquisition, renewal, and release for scheduled cron tasks. */
|
|
31
53
|
export class CronDistributedLockManager {
|
|
32
|
-
|
|
54
|
+
ownedLeases = new Map();
|
|
33
55
|
lockIoError;
|
|
34
56
|
redisClient;
|
|
35
57
|
lockOwnershipLosses = 0;
|
|
@@ -43,7 +65,7 @@ export class CronDistributedLockManager {
|
|
|
43
65
|
return this.redisClient;
|
|
44
66
|
}
|
|
45
67
|
get ownedLocks() {
|
|
46
|
-
return this.
|
|
68
|
+
return this.ownedLeases.size;
|
|
47
69
|
}
|
|
48
70
|
get lockIoAvailable() {
|
|
49
71
|
if (!this.options.distributed.enabled) {
|
|
@@ -82,23 +104,33 @@ export class CronDistributedLockManager {
|
|
|
82
104
|
async tryAcquireLock(descriptor) {
|
|
83
105
|
const redis = this.redisClient;
|
|
84
106
|
if (!redis) {
|
|
85
|
-
return
|
|
107
|
+
return {
|
|
108
|
+
lockKey: descriptor.lockKey,
|
|
109
|
+
taskName: descriptor.taskName,
|
|
110
|
+
token: `${this.options.distributed.ownerId}:${createCronRandomId()}`
|
|
111
|
+
};
|
|
86
112
|
}
|
|
113
|
+
const lease = {
|
|
114
|
+
lockKey: descriptor.lockKey,
|
|
115
|
+
taskName: descriptor.taskName,
|
|
116
|
+
token: `${this.options.distributed.ownerId}:${createCronRandomId()}`
|
|
117
|
+
};
|
|
87
118
|
try {
|
|
88
|
-
const result = await redis.set(descriptor.lockKey,
|
|
119
|
+
const result = await redis.set(descriptor.lockKey, lease.token, 'PX', descriptor.lockTtlMs, 'NX');
|
|
89
120
|
this.markLockIoAvailable();
|
|
90
121
|
if (result === 'OK') {
|
|
91
|
-
this.
|
|
122
|
+
this.ownedLeases.set(descriptor.lockKey, lease);
|
|
123
|
+
return lease;
|
|
92
124
|
}
|
|
93
|
-
return
|
|
125
|
+
return undefined;
|
|
94
126
|
} catch (error) {
|
|
95
127
|
this.markLockIoUnavailable(error);
|
|
96
128
|
this.logger.error(`Failed to acquire distributed cron lock for ${descriptor.taskName}.`, error, 'CronLifecycleService');
|
|
97
|
-
return
|
|
129
|
+
return undefined;
|
|
98
130
|
}
|
|
99
131
|
}
|
|
100
|
-
startLockRenewalMonitor(descriptor) {
|
|
101
|
-
const renewalState = this.createLockRenewalState(descriptor.lockTtlMs);
|
|
132
|
+
startLockRenewalMonitor(descriptor, lease) {
|
|
133
|
+
const renewalState = this.createLockRenewalState(descriptor.lockTtlMs, lease);
|
|
102
134
|
const renewalTimer = setInterval(() => {
|
|
103
135
|
if (renewalState.stopped) {
|
|
104
136
|
return;
|
|
@@ -107,7 +139,7 @@ export class CronDistributedLockManager {
|
|
|
107
139
|
renewalState.renewalChain = renewalState.renewalChain.then(async () => {
|
|
108
140
|
await this.runLockRenewalAttempt(descriptor, renewalState);
|
|
109
141
|
});
|
|
110
|
-
}, renewalState.renewalIntervalMs);
|
|
142
|
+
}, renewalState.renewalIntervalMs).unref();
|
|
111
143
|
return {
|
|
112
144
|
getPostRunError: async () => {
|
|
113
145
|
this.queueDueLockRenewalAttempts(descriptor, renewalState);
|
|
@@ -123,24 +155,28 @@ export class CronDistributedLockManager {
|
|
|
123
155
|
}
|
|
124
156
|
};
|
|
125
157
|
}
|
|
126
|
-
async releaseLock(
|
|
127
|
-
return await this.
|
|
158
|
+
async releaseLock(lease, timeoutMs) {
|
|
159
|
+
return await this.releaseLease(lease, timeoutMs);
|
|
128
160
|
}
|
|
129
|
-
async releaseOwnedLocks(excludedLockKeys = new Set()) {
|
|
130
|
-
if (!this.redisClient || this.
|
|
161
|
+
async releaseOwnedLocks(excludedLockKeys = new Set(), timeoutMs) {
|
|
162
|
+
if (!this.redisClient || this.ownedLeases.size === 0) {
|
|
131
163
|
return;
|
|
132
164
|
}
|
|
133
|
-
const
|
|
134
|
-
if (
|
|
165
|
+
const leases = Array.from(this.ownedLeases.values()).filter(lease => !excludedLockKeys.has(lease.lockKey));
|
|
166
|
+
if (leases.length === 0) {
|
|
135
167
|
return;
|
|
136
168
|
}
|
|
137
|
-
await Promise.all(
|
|
138
|
-
await this.
|
|
169
|
+
await Promise.all(leases.map(async lease => {
|
|
170
|
+
await this.releaseLease({
|
|
171
|
+
...lease,
|
|
172
|
+
taskName: lease.lockKey
|
|
173
|
+
}, timeoutMs);
|
|
139
174
|
}));
|
|
140
175
|
}
|
|
141
|
-
createLockRenewalState(lockTtlMs) {
|
|
176
|
+
createLockRenewalState(lockTtlMs, lease) {
|
|
142
177
|
const renewalIntervalMs = Math.max(250, Math.floor(lockTtlMs / 2));
|
|
143
178
|
return {
|
|
179
|
+
lease,
|
|
144
180
|
lockPostRunError: undefined,
|
|
145
181
|
nextRenewalDueAt: Date.now() + renewalIntervalMs,
|
|
146
182
|
renewalChain: Promise.resolve(),
|
|
@@ -158,7 +194,7 @@ export class CronDistributedLockManager {
|
|
|
158
194
|
}
|
|
159
195
|
}
|
|
160
196
|
async runLockRenewalAttempt(descriptor, renewalState) {
|
|
161
|
-
const outcome = await this.renewLock(descriptor);
|
|
197
|
+
const outcome = await this.renewLock(descriptor, renewalState.lease);
|
|
162
198
|
if (outcome === 'ownership-lost') {
|
|
163
199
|
this.lockOwnershipLosses += 1;
|
|
164
200
|
}
|
|
@@ -179,13 +215,13 @@ export class CronDistributedLockManager {
|
|
|
179
215
|
}
|
|
180
216
|
return undefined;
|
|
181
217
|
}
|
|
182
|
-
async renewLock(descriptor) {
|
|
218
|
+
async renewLock(descriptor, lease) {
|
|
183
219
|
const redis = this.redisClient;
|
|
184
220
|
if (!redis) {
|
|
185
221
|
return 'renewed';
|
|
186
222
|
}
|
|
187
223
|
try {
|
|
188
|
-
const result = await redis.eval(RENEW_LOCK_SCRIPT, 1, descriptor.lockKey,
|
|
224
|
+
const result = await redis.eval(RENEW_LOCK_SCRIPT, 1, descriptor.lockKey, lease.token, String(descriptor.lockTtlMs));
|
|
189
225
|
if (typeof result === 'number' && result <= 0) {
|
|
190
226
|
this.markLockIoAvailable();
|
|
191
227
|
this.logger.warn(`Distributed cron lock ownership was lost for ${descriptor.taskName}.`, 'CronLifecycleService');
|
|
@@ -200,38 +236,44 @@ export class CronDistributedLockManager {
|
|
|
200
236
|
return 'renewal-failed';
|
|
201
237
|
}
|
|
202
238
|
}
|
|
203
|
-
async
|
|
239
|
+
async releaseLease(lease, timeoutMs) {
|
|
204
240
|
const redis = this.redisClient;
|
|
205
|
-
if (!redis) {
|
|
206
|
-
return
|
|
241
|
+
if (!redis || timeoutMs !== undefined && timeoutMs <= 0) {
|
|
242
|
+
return redis === undefined;
|
|
207
243
|
}
|
|
208
244
|
try {
|
|
209
|
-
const result = await redis.eval(RELEASE_LOCK_SCRIPT, 1, lockKey,
|
|
245
|
+
const result = await withTimeout(redis.eval(RELEASE_LOCK_SCRIPT, 1, lease.lockKey, lease.token), timeoutMs);
|
|
210
246
|
if (typeof result === 'number' && result <= 0) {
|
|
211
247
|
this.markLockIoAvailable();
|
|
212
|
-
this.logger.warn(`Distributed cron lock for ${taskName} was already released or owned by another node.`, 'CronLifecycleService');
|
|
213
|
-
this.
|
|
248
|
+
this.logger.warn(`Distributed cron lock for ${lease.taskName} was already released or owned by another node.`, 'CronLifecycleService');
|
|
249
|
+
this.clearOwnedLease(lease);
|
|
214
250
|
return true;
|
|
215
251
|
}
|
|
216
252
|
this.markLockIoAvailable();
|
|
217
|
-
this.logger.log(`Released distributed cron lock for ${taskName}.`, 'CronLifecycleService');
|
|
218
|
-
this.
|
|
253
|
+
this.logger.log(`Released distributed cron lock for ${lease.taskName}.`, 'CronLifecycleService');
|
|
254
|
+
this.clearOwnedLease(lease);
|
|
219
255
|
return true;
|
|
220
256
|
} catch (error) {
|
|
221
257
|
this.markLockIoUnavailable(error);
|
|
222
|
-
this.logger.error(`Failed to release distributed cron lock for ${taskName}.`, error, 'CronLifecycleService');
|
|
258
|
+
this.logger.error(`Failed to release distributed cron lock for ${lease.taskName}.`, error, 'CronLifecycleService');
|
|
223
259
|
return false;
|
|
224
260
|
}
|
|
225
261
|
}
|
|
262
|
+
clearOwnedLease(lease) {
|
|
263
|
+
if (this.ownedLeases.get(lease.lockKey)?.token === lease.token) {
|
|
264
|
+
this.ownedLeases.delete(lease.lockKey);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
226
267
|
async verifyLockIoAvailability() {
|
|
227
268
|
const redis = this.redisClient;
|
|
228
269
|
if (!redis) {
|
|
229
270
|
return;
|
|
230
271
|
}
|
|
231
|
-
const
|
|
272
|
+
const probeToken = `${this.options.distributed.ownerId}:${createCronRandomId()}`;
|
|
273
|
+
const probeKey = `${this.options.distributed.keyPrefix}:__probe:${probeToken}`;
|
|
232
274
|
try {
|
|
233
|
-
await redis.set(probeKey,
|
|
234
|
-
await redis.eval(RELEASE_LOCK_SCRIPT, 1, probeKey,
|
|
275
|
+
await redis.set(probeKey, probeToken, 'PX', 1_000, 'NX');
|
|
276
|
+
await redis.eval(RELEASE_LOCK_SCRIPT, 1, probeKey, probeToken);
|
|
235
277
|
this.markLockIoAvailable();
|
|
236
278
|
} catch (error) {
|
|
237
279
|
this.markLockIoUnavailable(error);
|
package/dist/module.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"module.d.ts","sourceRoot":"","sources":["../src/module.ts"],"names":[],"mappings":"AACA,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"module.d.ts","sourceRoot":"","sources":["../src/module.ts"],"names":[],"mappings":"AACA,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAMhE,OAAO,KAAK,EAAE,iBAAiB,EAAE,2BAA2B,EAAE,MAAM,YAAY,CAAC;AA0FjF;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,GAAE,iBAAsB,GAAG,2BAA2B,CAMvG;AAeD,iEAAiE;AACjE,qBAAa,UAAU;IACrB;;;;;;;;;;;;;;;;OAgBG;IACH,MAAM,CAAC,OAAO,CAAC,OAAO,GAAE,iBAAsB,GAAG,UAAU;CAS5D"}
|