@fluojs/queue 1.0.2 → 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 CHANGED
@@ -9,6 +9,7 @@ fluo를 위한 Redis 기반 분산 작업 처리 패키지입니다. 데코레
9
9
  - [설치](#설치)
10
10
  - [사용 시점](#사용-시점)
11
11
  - [빠른 시작](#빠른-시작)
12
+ - [NestJS Queue Worker에서 마이그레이션](#nestjs-queue-worker에서-마이그레이션)
12
13
  - [일반적인 패턴](#일반적인-패턴)
13
14
  - [공개 API 개요](#공개-api-개요)
14
15
  - [관련 패키지](#관련-패키지)
@@ -20,6 +21,10 @@ fluo를 위한 Redis 기반 분산 작업 처리 패키지입니다. 데코레
20
21
  npm install @fluojs/queue @fluojs/redis
21
22
  ```
22
23
 
24
+ `@fluojs/queue`는 필수 Node.js 범위 `>=24.0.0 <27`을 패키지 자체의 지원 계약으로 선언합니다. 이 major version을 적용하기 전에 Node.js 24 미만 또는 Node.js 27+를 사용하는 Queue consumer는 지원되는 release로 업그레이드하세요.
25
+
26
+ `@fluojs/queue`는 BullMQ `^5.81.1`을 포함합니다. 업그레이드할 때 application lockfile을 갱신해 BullMQ의 패치된 dependency graph가 설치되도록 하세요. Queue registration, worker discovery, persisted-job contract는 그대로입니다.
27
+
23
28
  ## 사용 시점
24
29
 
25
30
  - 실행 시간이 길거나 리소스를 많이 사용하는 작업을 백그라운드에서 처리해야 할 때.
@@ -54,6 +59,8 @@ export class OrderWorker {
54
59
 
55
60
  `QueueModule.forRoot(...)`는 애플리케이션 수준 큐 등록을 위한 지원되는 루트 엔트리포인트입니다.
56
61
 
62
+ Producer는 job class instance를 넣어 `enqueue(new JobClass(...))`를 호출합니다. `add(name, payload)` 형태의 producer signature는 없습니다. `enqueue(job)`은 `job.constructor`로 대상 worker를 찾고, queue와 named job은 그 worker에 등록된 `jobName`에서 가져옵니다.
63
+
57
64
  ```typescript
58
65
  import { Module, Inject } from '@fluojs/core';
59
66
  import { QueueModule, QueueLifecycleService } from '@fluojs/queue';
@@ -78,6 +85,61 @@ export class OrderService {
78
85
  export class AppModule {}
79
86
  ```
80
87
 
88
+ ## NestJS Queue Worker에서 마이그레이션
89
+
90
+ NestJS queue integration에서 이동하는 consumer는 metadata 기반 processor discovery를 fluo의 명시적 module 및 worker 계약으로 바꿔야 합니다. 이는 compatibility mode가 아니라 source migration입니다.
91
+
92
+ 1. Backing Redis client를 `RedisModule.forRoot(...)`로 등록한 뒤 queue를 소유하는 module graph에서 `QueueModule.forRoot(...)`를 import합니다. NestJS async-module shape를 복사하거나 Queue가 environment configuration을 암묵적으로 읽을 것이라고 기대하지 마세요.
93
+ 2. `@Processor(...)`, `@Process(...)` 또는 그 밖의 NestJS/Bull provider metadata를 TC39 표준 class decorator인 `@QueueWorker(JobClass, options?)`로 바꿉니다. 각 worker는 호출 가능한 `handle(job)` 메서드를 노출해야 합니다.
94
+ 3. Decorated worker class를 singleton으로 `@Module({ providers: [...] })`에 추가합니다. Queue는 compiled provider/controller registration을 scan하며, `@Injectable()` metadata, emit된 constructor type, 임의로 import된 class는 scan하지 않습니다. Constructor dependency는 `@Inject(...)`로 명시적으로 선언합니다.
95
+
96
+ **각 job class와 실제 `jobName`은 worker 하나만 소유합니다.** Queue는 BullMQ resource를 만들기 전에 bootstrap 중 singleton 중복 등록을 거부하며, provider discovery 순서와 무관합니다. 마이그레이션하는 NestJS `@Process(...)` handler마다 별도 job class와 `jobName`을 부여하거나, 여러 handler를 worker 하나의 `handle(job)` 뒤로 통합하세요.
97
+
98
+ 4. Worker가 queue registration에서 도달 가능하도록 유지합니다. 기본 global `QueueModule.forRoot()`는 compiled application graph 전체의 singleton worker를 discovery할 수 있습니다. `global: false`에서는 authored imports/exports를 통해 해당 registration에 도달할 수 있는 module로 discovery가 제한되며, 일치하는 Redis provider도 같은 module tree에서 도달 가능해야 합니다.
99
+ 5. Processor뿐 아니라 producer도 변환합니다. `@InjectQueue('name')`과 `queue.add('job', payload)`를 `@Inject(QueueLifecycleService)`(또는 `QUEUE` / `getQueueToken(scope)` facade)와 `queue.enqueue(new JobClass(...))`로 바꿉니다. Queue에는 name과 payload를 받는 producer signature가 없으며, plain payload object는 constructor가 `Object`이므로 등록된 JobClass worker를 식별할 수 없습니다.
100
+ 6. Queue lifecycle ownership과 중복되는 worker 소유 start/stop hook을 제거합니다. Queue는 application bootstrap 중 resource를 만들고 application bootstrap-ready handoff 이후에만 BullMQ processor를 시작하며, shutdown이 시작된 뒤에는 새 enqueue를 거부하고 graceful close와 필요한 force-close에 각각 `workerShutdownTimeoutMs` budget을 적용합니다.
101
+
102
+ ### Producer 마이그레이션: Bull/BullMQ에서 Queue로
103
+
104
+ NestJS Bull 또는 BullMQ에서는 producer가 queue와 named job을 모두 선택합니다.
105
+
106
+ ```typescript
107
+ // 이전: NestJS Bull/BullMQ
108
+ import { InjectQueue } from '@nestjs/bullmq';
109
+ import type { Queue } from 'bullmq';
110
+
111
+ export class OrdersProducer {
112
+ constructor(@InjectQueue('orders') private readonly queue: Queue) {}
113
+
114
+ async placeOrder(orderId: string) {
115
+ await this.queue.add('process-order', { orderId });
116
+ }
117
+ }
118
+ ```
119
+
120
+ fluo에서는 `ProcessOrderJob`을 `@QueueWorker(ProcessOrderJob, { jobName: 'process-order' })`로 선언하고 등록한 뒤, 정확히 그 exported class의 instance를 enqueue합니다. Worker registration이 BullMQ queue와 named job을 결정하므로 producer는 두 문자열을 제공하지 않습니다.
121
+
122
+ ```typescript
123
+ // 이후: fluo
124
+ import { Inject } from '@fluojs/core';
125
+ import { QueueLifecycleService } from '@fluojs/queue';
126
+
127
+ @Inject(QueueLifecycleService)
128
+ export class OrdersProducer {
129
+ constructor(private readonly queue: QueueLifecycleService) {}
130
+
131
+ async placeOrder(orderId: string) {
132
+ await this.queue.enqueue(new ProcessOrderJob(orderId));
133
+ }
134
+ }
135
+ ```
136
+
137
+ `ProcessOrderJob`은 `@QueueWorker`에 전달한 것과 동일한 constructor reference여야 하며, 복사해서 선언한 class나 plain `{ orderId }` object가 아니어야 합니다. 후자는 `enqueue<TJob extends object>`가 object를 받으므로 type-check는 통과하지만 runtime에서 `No @QueueWorker() registered for job type Object.`로 거부됩니다.
138
+
139
+ Cutover 전에는 persistence identity 차이를 반영하세요. NestJS Bull/BullMQ는 하나의 `queueName` 아래 여러 named job 값을 영속화할 수 있습니다. 반면 fluo는 job type마다 queue/worker pair 하나를 만들면서 worker의 `jobName`을 BullMQ queue name과 named job 양쪽에 사용합니다. 따라서 `jobName`만 설정해서는 여러 named job이 하나의 `queueName`을 공유하는 legacy topology를 보존할 수 없고, `@fluojs/queue`는 NestJS decorator metadata를 해석하거나 기존 serialized payload를 자동 변환하지 않습니다.
140
+
141
+ 애플리케이션이 persisted-job cutover 방식을 선택해야 합니다. Producer를 전환하기 전에 기존 worker로 legacy queue를 drain하거나, 호환 payload를 변환해 fluo의 job별 queue로 다시 enqueue하거나, legacy worker가 이전 작업을 drain하는 동안 fluo에 별도 queue name을 사용하세요. 어떤 경로든 payload class shape, retry/backoff 설정, shutdown budget을 검증한 뒤 producer와 singleton `@QueueWorker(JobClass)` provider를 같은 `QueueModule.forRoot(...)` graph에 배포해야 합니다. `global: false`에서는 worker와 Redis reachability를 보존하고, 처리는 bootstrap-ready handoff 이후에만 시작하며 graceful 또는 forced worker close phase 각각이 `workerShutdownTimeoutMs`로 제한된다는 점을 기억하세요.
142
+
81
143
  ## 일반적인 패턴
82
144
 
83
145
  ### 이름 있는 Redis 클라이언트
@@ -90,11 +152,54 @@ QueueModule.forRoot({ clientName: 'jobs' })
90
152
 
91
153
  `@fluojs/queue`는 애플리케이션 부트스트랩 중 해당 Redis 클라이언트를 조회한 뒤 BullMQ용으로 큐가 소유하는 duplicate 연결을 만듭니다. 공유 `@fluojs/redis` 클라이언트의 소유권은 `RedisModule`에 남아 있으며, Queue는 자신이 만든 BullMQ duplicate 연결만 닫습니다. 이 duplicate 연결은 BullMQ Worker가 요구하는 `maxRetriesPerRequest: null` 설정으로 구성되어 시작 동작이 BullMQ의 실제 런타임 제약과 일치합니다.
92
154
 
155
+ `QueueModule.forRoot({ global: false })`를 사용하면 각 queue 등록은 해당 `QueueModule.forRoot(...)` 호출을 가져온 동일한 module tree에서 도달할 수 있는 worker만 탐색합니다. 서로 다른 scoped queue feature module은 서로 분리된 상태를 유지하며, Redis client provider도 같은 module tree 안에서 도달 가능해야 합니다.
156
+
157
+ ### 범위가 지정된 Queue 등록
158
+
159
+ 애플리케이션이 non-global queue 등록을 둘 이상 가져오면 명시적인 `scope`를 사용하세요. Scope 이름은 trim되며, 비어 있으면 안 되고, 컴파일된 module graph 안에서 고유해야 합니다. `QueueModule.forRoot({ global: false })`를 두 번 가져오는 duplicate default scoped registration이나 `QueueModule.forRoot({ global: false, scope: 'jobs' })`를 두 번 가져오는 duplicate explicit scope는 bootstrap 중 결정적인 오류로 실패합니다.
160
+
161
+ Scope는 DI ownership을 격리하지만 Redis에 저장되는 BullMQ queue를 namespace하지는 않습니다. `clientName`은 DI registration을 선택할 뿐 BullMQ backend identity가 아닙니다. 서로 다른 named client가 같은 Redis database와 BullMQ prefix를 가리킬 수 있습니다.
162
+
163
+ BullMQ backend를 공유하는 scoped registration마다 `ownershipNamespace`를 선언하세요. 이 stable application-supplied 값은 실제 Redis database와 BullMQ prefix topology를 식별하며, 같은 backend의 registration은 `clientName`과 무관하게 같은 값을 사용해야 합니다. 이 값은 validation identity일 뿐 BullMQ key나 prefix를 바꾸지 않습니다.
164
+
165
+ Queue는 BullMQ resource를 만들기 전에 각 `(ownershipNamespace, jobName)` pair를 검증합니다. 2.x에서 `ownershipEnforcement` 기본값은 `'warn'`이므로, namespace가 없거나 collision이 있어도 diagnostic을 기록하고 startup 동작을 보존합니다. Resource 생성 전에 collision을 거부하려면 registration에 `ownershipEnforcement: 'reject'`를 설정하세요. 빈 namespace는 유효하지 않습니다. 실제로 서로 다른 BullMQ backend에만 서로 다른 namespace를 사용하고, 의도적인 격리가 필요하면 서로 다른 `jobName`을 설정하세요.
166
+
167
+ ```typescript
168
+ QueueModule.forRoot({
169
+ clientName: 'orders',
170
+ global: false,
171
+ ownershipNamespace: 'orders-redis-db-0',
172
+ ownershipEnforcement: 'reject',
173
+ scope: 'orders',
174
+ })
175
+ ```
176
+
177
+ ```typescript
178
+ import { Inject, Module } from '@fluojs/core';
179
+ import { getQueueLifecycleServiceToken, getQueueToken, QueueModule, type Queue } from '@fluojs/queue';
180
+
181
+ const EMAIL_QUEUE = getQueueToken('email');
182
+ const EMAIL_QUEUE_LIFECYCLE = getQueueLifecycleServiceToken('email');
183
+
184
+ @Inject(EMAIL_QUEUE)
185
+ export class EmailPublisher {
186
+ constructor(private readonly queue: Queue) {}
187
+ }
188
+
189
+ @Module({
190
+ imports: [QueueModule.forRoot({ global: false, scope: 'email' })],
191
+ providers: [EmailPublisher, EmailWorker],
192
+ })
193
+ export class EmailQueueModule {}
194
+ ```
195
+
196
+ 애플리케이션에 기본 queue 등록이 하나뿐이고 compatibility `QUEUE` 토큰이나 `QueueLifecycleService` 클래스를 직접 주입할 때만 `scope`를 생략하세요. Scoped registration에서는 각 feature module이 기본 compatibility token 대신 자신의 queue instance를 resolve하도록 `getQueueToken(scope)` 또는 `getQueueLifecycleServiceToken(scope)`를 주입하세요.
197
+
93
198
  ### 부트스트랩 및 종료 수명 주기
94
199
 
95
200
  Queue는 애플리케이션 부트스트랩 중 worker를 탐색하고 Queue가 소유하는 BullMQ 리소스를 만들지만, BullMQ worker processor는 runtime이 전체 애플리케이션 bootstrap/readiness sequence 완료를 표시한 뒤에만 시작합니다. 다른 `onApplicationBootstrap()` hook에서 enqueue한 job은 Queue 서비스가 초기화된 뒤에는 받을 수 있으며, processor는 뒤에 실행되는 async bootstrap hook이나 애플리케이션 readiness보다 앞서 실행되지 않고 bootstrap-ready handoff 이후 실행됩니다. Queue status는 해당 BullMQ processor가 실제로 시작될 때까지 degraded readiness를 보고합니다. Processor 시작에 실패하면 lifecycle이 `failed`로 이동하고, status snapshot은 worker를 ready로 숨기지 않고 실패를 노출합니다.
96
201
 
97
- 애플리케이션 종료가 시작되면 Queue는 상태를 `stopping`으로 바꾸고 새 enqueue를 거부한 다음 Queue 소유 worker/queue/connection을 닫고 pending dead-letter write drain합니다. Worker 종료는 `workerShutdownTimeoutMs`로 bounded wait적용하므로 끝나지 않는 active processor애플리케이션 종료를 무기한 막을 없습니다. Timeout이 지나면 Queue는 로그를 남기고 BullMQ worker에 force-close 요청한 나머지 리소스 정리를 계속합니다.
202
+ 애플리케이션 종료가 시작되면 Queue는 상태를 `stopping`으로 바꾸고 새 enqueue를 거부한 다음 Queue 소유 worker/queue/connection을 닫고 pending dead-letter write drain 시도합니다. Queue가 각 pending dead-letter write를 기다리는 시간은 최대 `5_000ms`입니다. 대기가 timeout되면 Queue는 timeout을 기록하고 해당 writepending count에서 제외한 뒤, recordRedis에 도달했다는 보장 없이 종료를 계속합니다. Queue는 graceful worker close와 필요한 경우의 force-close attempt에 각각 최대 `workerShutdownTimeoutMs`를 적용합니다. 어느 close phase든 실패하거나 timeout되면 Queue는 실패를 기록하고 남은 queue, connection, dead-letter cleanup을 계속하므로 unresolved BullMQ force-close 애플리케이션 종료를 무기한 막을 없습니다.
98
203
 
99
204
  ### 분산 재시도 (Distributed Retries)
100
205
 
@@ -109,11 +214,49 @@ Queue는 애플리케이션 부트스트랩 중 worker를 탐색하고 Queue가
109
214
 
110
215
  ### 데드 레터 처리 (Dead-Letter Handling)
111
216
 
112
- 워커가 모든 재시도를 소진하면 Queue는 Redis의 데드 레터 리스트(`fluo:queue:dead-letter:<jobName>`)에 레코드를 append하여, 나중에 수동으로 확인하거나 복구할 수 있게 합니다. BullMQ job 자체를 이동시키는 것은 아닙니다.
217
+ 워커가 모든 재시도를 소진하면 Queue는 Redis의 데드 레터 리스트(`fluo:queue:dead-letter:<jobName>`)에 별도의 레코드를 append하여, 나중에 수동으로 확인하거나 복구할 수 있게 합니다. BullMQ job 자체를 리스트로 이동시키지는 않습니다.
113
218
 
114
219
  `QueueModule.forRoot()`는 기본적으로 작업별 최근 데드 레터 엔트리 `1_000`개만 유지합니다. 무제한 보관이 꼭 필요하면 `defaultDeadLetterMaxEntries: false`로 opt-out 하고, 더 엄격한 운영 예산이 필요하면 더 작은 양의 정수를 지정하세요.
115
220
 
116
- Job은 JSON으로 직렬화 가능한 plain object여야 합니다. Queue는 enqueue 전에 job payload를 직렬화하고, worker 측에서 job prototype을 다시 입힙니다.
221
+ Queue의 Redis key를 직접 읽지 않고 record를 확인하려면 `QueueLifecycleService.inspectDeadLetters(jobName, { limit })` 또는 주입한 `Queue` facade의 같은 메서드를 사용하세요.
222
+
223
+ ```typescript
224
+ const inspection = await queue.inspectDeadLetters('ProcessOrderJob', { limit: 25 });
225
+
226
+ for (const record of inspection.records) {
227
+ console.log(record.jobId, record.failedAt, record.errorMessage);
228
+ }
229
+ ```
230
+
231
+ Inspection은 read-only이며 유효한 record를 최신순으로 반환합니다. Redis read를 worker lifecycle state로 gate하지 않으므로 inspection이 worker를 시작하지 않으며, backing Redis client에 접근 가능한 동안에는 Queue가 `idle`이거나 worker startup이 `failed`에 도달한 뒤에도 사용할 수 있습니다. Queue는 shared Redis client를 소유하지 않습니다. `RedisModule`이 해당 client를 종료한 뒤에는 post-shutdown availability를 보장하지 않고 backing Redis operation error를 그대로 전달합니다. Limit은 기본적으로 저장된 entry `100`개이며 최대 `1_000`개로 제한되고, 잘못된 limit은 기본값으로 대체됩니다. Malformed stored value는 결과에서 제외되고 해당 inspection window의 `malformedRecordCount`에 집계됩니다. `payload`는 `unknown`으로 유지되므로 애플리케이션 코드가 자신의 job data를 직접 narrow해야 합니다. Inspection은 job이나 dead-letter record를 삭제, replay 또는 mutate하지 않습니다.
232
+
233
+ ### Producer dispatch 계약
234
+
235
+ `enqueue(job)`은 job의 정확한 constructor로 dispatch합니다. Queue는 `@QueueWorker(JobClass, options?)`로 discovery한 worker 집합에서 `job.constructor`를 조회하며, 그 constructor가 등록되지 않았으면 `No @QueueWorker() registered for job type <name>.`으로 호출을 거부합니다.
236
+
237
+ 불확실한 전달 또는 반복 dispatch에서도 호출자가 소유한 identity를 유지해야 한다면 두 번째 `enqueue` 인수로 선택적 `deduplicationKey`를 전달하세요. Queue는 이를 BullMQ에 유효한 backing job id로 결정적으로 매핑하므로, 호출자는 BullMQ의 콜론 또는 숫자 전용 custom-id 제한을 상속하지 않으면서 같은 worker queue에 대한 반복 enqueue 시도를 deduplicate할 수 있습니다.
238
+
239
+ ```typescript
240
+ await queue.enqueue(new ProcessOrderJob(id), { deduplicationKey: `order:${id}` });
241
+ ```
242
+
243
+ Plain payload object가 아니라 등록된 job class의 instance를 전달하세요.
244
+
245
+ ```typescript
246
+ // 정상: instance의 constructor가 등록된 ProcessOrderJob class입니다.
247
+ await queue.enqueue(new ProcessOrderJob(id));
248
+
249
+ // runtime에서 거부: object literal의 constructor는 `Object`이므로
250
+ // 등록된 JobClass worker를 식별할 수 없습니다.
251
+ await queue.enqueue({ orderId: id });
252
+
253
+ // 같이 거부: 구조가 동일하더라도 등록되지 않은 class입니다.
254
+ await queue.enqueue(new UnregisteredOrderJob(id));
255
+ ```
256
+
257
+ `enqueue<TJob extends object>(job: TJob)`은 임의의 object를 허용하므로 plain payload도 TypeScript 검사를 통과하고 runtime에서만 실패합니다. Worker 선택 기준은 payload shape, field 이름, job name 문자열이 아니라 constructor identity이므로, class 정의를 복사하거나 다른 module에서 job class를 다시 선언하면 서로 다른 constructor가 되어 등록된 것으로 간주되지 않습니다.
258
+
259
+ Queue는 `new ProcessOrderJob(id)` 같은 class instance를 포함한 job object를 입력으로 받습니다. Enqueue 전에 Queue는 job을 JSON으로 직렬화하며, 직렬화 결과는 `null`이나 array가 아닌 JSON object여야 합니다. Worker 측에서는 그 직렬화된 object 위에 등록된 job prototype을 다시 입힙니다. 직렬화는 constructor 조회가 성공한 뒤에 생기므로, 직렬화 가능한 plain object도 payload 검사 전에 거부됩니다.
117
260
 
118
261
  저수준 provider 조합을 루트 barrel API의 일부가 아니라 내부 구현 세부사항으로 취급해야 합니다. 저수준 provider helper는 문서화된 루트 barrel 계약에 포함되지 않습니다.
119
262
 
@@ -122,16 +265,25 @@ Job은 JSON으로 직렬화 가능한 plain object여야 합니다. Queue는 enq
122
265
  ### 핵심 구성 요소
123
266
  - `QueueModule`: 큐 기능을 위한 기본 모듈입니다.
124
267
  - `QueueModule.forRoot(options)`: 애플리케이션 수준 큐 등록을 구성합니다.
125
- - `QueueLifecycleService`: 작업을 큐에 추가하고 lifecycle/status snapshot 생성(`enqueue(job)`, `createPlatformStatusSnapshot()`)하기 위한 기본 서비스입니다.
268
+ - `QueueLifecycleService`: 작업 enqueue, read-only dead-letter inspection, lifecycle/status snapshot 생성(`enqueue(job, options?)`, `enqueueMany(entries)`, `inspectDeadLetters(jobName, options?)`, `createPlatformStatusSnapshot()`) 위한 기본 서비스입니다.
269
+ - `Queue`: `QUEUE`와 `getQueueToken(scope?)`로 노출되는 공개 producer facade이며, `QueueLifecycleService`와 같은 `enqueue(...)` 및 `enqueueMany(...)` 계약을 제공합니다.
126
270
  - `@QueueWorker(JobClass, options?)`: 특정 작업을 처리할 핸들러를 지정하는 데코레이터입니다.
127
271
  - `QUEUE`: queue facade를 위한 호환성 주입 토큰입니다.
272
+ - `getQueueToken(scope?)`: Queue facade token helper입니다. `scope`를 생략하면 기본 `QUEUE` token을 반환하고, 비어 있지 않은 scope는 해당 scoped registration의 facade token을 반환합니다.
273
+ - `getQueueLifecycleServiceToken(scope?)`: Scoped queue registration을 위한 lifecycle service token helper입니다.
128
274
  - `createQueuePlatformStatusSnapshot(...)`: lifecycle/readiness diagnostics를 위한 status snapshot helper입니다.
129
275
 
130
276
 
131
277
  ### 타입
132
- - `Queue`: 애플리케이션 코드와 `QUEUE` 토큰에서 사용하는 `enqueue(job)` 호환성 facade입니다.
278
+ - `Queue`: 애플리케이션 코드와 `QUEUE` 토큰에서 사용하는 `enqueue(job, options?)`, atomic `enqueueMany(entries)`, read-only `inspectDeadLetters(jobName, options?)` facade입니다.
279
+ - `QueueEnqueueOptions`: idempotent enqueue 시도를 위해 Queue가 BullMQ에 유효한 job id로 매핑하는 호출자 소유 `deduplicationKey`를 포함하는 선택적 producer control입니다.
280
+ - `QueueEnqueueManyEntry`: job과 선택적 `QueueEnqueueOptions`를 포함하는 ordered batch entry입니다.
281
+ - `QueueDeadLetterInspectionOptions`: Bounded dead-letter inspection 설정(`limit`) 타입입니다.
282
+ - `QueueDeadLetterInspectionResult`: 최신순의 유효 record와 inspection window의 `malformedRecordCount`를 제공하는 결과 타입입니다.
283
+ - `QueueDeadLetterRecord`: `unknown` 애플리케이션 payload를 포함하는 typed dead-letter metadata입니다.
133
284
  - `QueueJobType`: job payload class를 식별하고 rehydrate하는 데 사용하는 constructor 타입입니다.
134
- - `QueueModuleOptions`: 전역 큐 설정(`global`, clientName, 기본 시도 횟수, `defaultBackoff`, 동시성, 전송률 제한, dead-letter retention 등)을 위한 타입입니다.
285
+ - `QueueModuleOptions`: 전역 큐 설정(`global`, `clientName`, `ownershipNamespace`, `ownershipEnforcement`, 기본 시도 횟수, `defaultBackoff`, 동시성, 전송률 제한, dead-letter retention 등)을 위한 타입입니다.
286
+ - `QueueOwnershipEnforcement`: Cross-scope ownership collision action(`'warn'` 또는 `'reject'`) 타입입니다.
135
287
  - `QueueWorkerOptions`: 개별 작업 설정(시도 횟수, 백오프, 동시성, jobName, 전송률 제한 등)을 위한 타입입니다.
136
288
  - `QueueBackoffType`: 지원되는 retry backoff strategy 이름(`fixed`, `exponential`)입니다.
137
289
  - `QueueBackoffOptions`: 재시도 백오프 설정(`type`, `delayMs`)을 위한 타입입니다.
@@ -145,13 +297,24 @@ Job은 JSON으로 직렬화 가능한 plain object여야 합니다. Queue는 enq
145
297
  `QueueModuleOptions` 수명 주기/status 설정:
146
298
 
147
299
  - `global`: queue module 등록을 global로 만들지 여부입니다. 기본값은 `true`이며, queue provider를 importing module graph 안에만 scope하고 싶으면 `false`를 지정합니다.
148
- - `workerShutdownTimeoutMs`: 종료 active worker processor를 기다리는 최대 시간입니다. 시간이 지나면 BullMQ worker를 force-close합니다. 기본값은 `30_000`입니다.
300
+ - `scope`: 고유한 non-empty queue registration scope입니다. 하나의 앱에 non-global queue registration이 여러 있으면 필요합니다.
301
+ - `ownershipNamespace`: Redis database와 BullMQ prefix를 위한 stable application-supplied identity입니다. 하나의 BullMQ backend registration은 `clientName`과 무관하게 같은 non-empty 값을 사용해야 합니다.
302
+ - `ownershipEnforcement`: Cross-scope ownership action입니다. 2.x에서는 `'warn'`이 기본값이며, 일치하는 `(ownershipNamespace, jobName)` collision을 BullMQ resource 생성 전에 실패시키려면 `'reject'`를 설정합니다.
303
+ - `workerShutdownTimeoutMs`: 각 BullMQ worker close phase에 허용되는 최대 시간입니다. Graceful close를 먼저 시도하고, 이 단계가 실패하거나 timeout되면 force-close에 같은 budget을 적용합니다. 기본값은 `30_000`입니다.
149
304
  - `defaultDeadLetterMaxEntries`: job별로 유지할 dead-letter record의 최대 개수이며, trimming을 끄려면 `false`를 지정합니다. 기본값은 `1_000`입니다.
150
305
 
151
- `QueueLifecycleService.createPlatformStatusSnapshot()`은 `createQueuePlatformStatusSnapshot(...)`과 같은 공개 snapshot 계약을 사용합니다. Queue가 `started`에 도달하고 탐색된 모든 BullMQ worker processor가 시작된 뒤에만 readiness를 `ready`로 보고합니다. Processor가 아직 pending인 `started` resource와 `starting`은 degraded readiness, `stopping`/`stopped`는 not-ready, worker-start failure는 `workerStartFailures`와 `lastWorkerStartFailure` details를 포함해 not-ready/unhealthy로 보고합니다. Snapshot details에는 Redis dependency id, lifecycle state, ready/discovered worker 수, pending dead-letter write 수, dead-letter drain timeout, `workerShutdownTimeoutMs`가 포함됩니다.
306
+ `QueueLifecycleService.createPlatformStatusSnapshot()`은 `createQueuePlatformStatusSnapshot(...)`과 같은 공개 snapshot 계약을 사용합니다. Queue가 `started`에 도달하고 탐색된 모든 BullMQ worker processor가 시작된 뒤에만 readiness를 `ready`로 보고합니다. 이 조건이 유지되는 동안 pending dead-letter write가 있어도 readiness는 `ready`를 유지하지만, pending count가 0으로 돌아올 때까지 health는 degraded입니다. Processor가 아직 pending인 `started` resource와 `starting`은 degraded readiness, `stopping`은 not-ready/degraded, `stopped`는 not-ready/unhealthy, worker-start failure는 `workerStartFailures`와 `lastWorkerStartFailure` details를 포함해 not-ready/unhealthy로 보고합니다. Snapshot details에는 Redis dependency id, lifecycle state, ready/discovered worker 수, pending dead-letter write 수, `5_000ms` dead-letter drain timeout, `workerShutdownTimeoutMs`가 포함됩니다.
152
307
 
153
308
  singleton `@QueueWorker()` provider/controller만 등록됩니다. request/transient worker는 discovery 중 건너뜁니다.
154
309
 
310
+ ### Atomic producer batch
311
+
312
+ `Queue.enqueueMany(entries)`와 `QueueLifecycleService.enqueueMany(entries)`는 순서가 있는 `QueueEnqueueManyEntry` 값을 받습니다. 각 entry는 하나의 job instance와 `deduplicationKey`를 포함할 수 있는 entry별 `QueueEnqueueOptions`를 제공합니다.
313
+
314
+ 모든 entry는 같은 하나의 BullMQ queue에 등록된 worker로 해석되어야 합니다. Queue는 BullMQ를 호출하기 전에 batch 전체를 검증하므로 worker가 없거나 다른 queue로 해석되는 job이 있으면 어떤 entry도 persist하지 않고 reject합니다. 유효한 batch는 한 번의 atomic BullMQ `addBulk(...)` 호출로 persist되며, 반환 job ID의 순서는 입력 순서와 일치합니다.
315
+
316
+ 각 entry는 Queue가 backing BullMQ job ID로 변환할 때 자신의 `deduplicationKey`를 보존합니다. 기존 `enqueue(job, options?)` 동작은 바뀌지 않으며 호환되는 single-job producer API로 계속 제공됩니다.
317
+
155
318
  ## 관련 패키지
156
319
 
157
320
  - `@fluojs/redis`: 작업 데이터 저장을 위한 필수 백엔드 패키지입니다.
package/README.md CHANGED
@@ -9,6 +9,7 @@ Redis-backed distributed job processing for fluo. It features decorator-based wo
9
9
  - [Installation](#installation)
10
10
  - [When to use](#when-to-use)
11
11
  - [Quick Start](#quick-start)
12
+ - [Migrating from NestJS Queue Workers](#migrating-from-nestjs-queue-workers)
12
13
  - [Common Patterns](#common-patterns)
13
14
  - [Public API](#public-api)
14
15
  - [Related Packages](#related-packages)
@@ -20,6 +21,10 @@ Redis-backed distributed job processing for fluo. It features decorator-based wo
20
21
  npm install @fluojs/queue @fluojs/redis
21
22
  ```
22
23
 
24
+ `@fluojs/queue` requires Node.js `>=24.0.0 <27` as its package-owned support contract. Upgrade Queue consumers from Node.js versions below 24 and Node.js 27+ to a supported release before adopting this major version.
25
+
26
+ `@fluojs/queue` includes BullMQ `^5.81.1`. Refresh the application lockfile when upgrading so BullMQ's patched dependency graph is installed. Queue registration, worker discovery, and persisted-job contracts are unchanged.
27
+
23
28
  ## When to Use
24
29
 
25
30
  - When you need to process long-running or resource-intensive tasks in the background.
@@ -54,6 +59,8 @@ Import `QueueModule` and inject `QueueLifecycleService` to enqueue jobs.
54
59
 
55
60
  `QueueModule.forRoot(...)` is the supported root entrypoint for application-level queue registration.
56
61
 
62
+ Producers call `enqueue(new JobClass(...))` with a job class instance. There is no `add(name, payload)` producer signature: `enqueue(job)` resolves the target worker from `job.constructor` and the queue/named job comes from that worker's registered `jobName`.
63
+
57
64
  ```typescript
58
65
  import { Module, Inject } from '@fluojs/core';
59
66
  import { QueueModule, QueueLifecycleService } from '@fluojs/queue';
@@ -78,6 +85,61 @@ export class OrderService {
78
85
  export class AppModule {}
79
86
  ```
80
87
 
88
+ ## Migrating from NestJS Queue Workers
89
+
90
+ Consumers moving from NestJS queue integrations must replace metadata-driven processor discovery with fluo's explicit module and worker contract. This is a source migration, not a compatibility mode:
91
+
92
+ 1. Register the backing Redis client with `RedisModule.forRoot(...)`, then import `QueueModule.forRoot(...)` from the module graph that owns the queue. Do not copy NestJS async-module shapes or expect Queue to read environment configuration implicitly.
93
+ 2. Replace `@Processor(...)`, `@Process(...)`, or other NestJS/Bull provider metadata with the TC39 standard class decorator `@QueueWorker(JobClass, options?)`. Each worker must expose a callable `handle(job)` method.
94
+ 3. Add the decorated worker class to `@Module({ providers: [...] })` as a singleton. Queue scans compiled provider/controller registrations; it does not scan `@Injectable()` metadata, emitted constructor types, or arbitrary imported classes. Declare constructor dependencies explicitly with `@Inject(...)`.
95
+
96
+ **One worker owns each job class and effective `jobName`.** Queue rejects duplicate singleton registrations during bootstrap before creating BullMQ resources, regardless of provider discovery order. Give each migrated NestJS `@Process(...)` handler its own job class and `jobName`, or consolidate multiple handlers behind one worker's `handle(job)`.
97
+
98
+ 4. Keep the worker reachable from the queue registration. The default global `QueueModule.forRoot()` can discover singleton workers across the compiled application graph. With `global: false`, discovery is limited to modules that can reach that specific registration through their authored imports/exports, and the matching Redis provider must be reachable from the same module tree.
99
+ 5. Convert producers as well as processors. Replace `@InjectQueue('name')` plus `queue.add('job', payload)` with `@Inject(QueueLifecycleService)` (or the `QUEUE` / `getQueueToken(scope)` facade) and `queue.enqueue(new JobClass(...))`. Queue has no name-and-payload producer signature, and a plain payload object has `Object` as its constructor, so it cannot identify a registered JobClass worker.
100
+ 6. Remove worker-owned start/stop hooks that duplicate Queue lifecycle ownership. Queue creates resources during application bootstrap, starts BullMQ processors only after the application bootstrap-ready handoff, rejects new enqueue calls after shutdown starts, and gives graceful close plus any required force-close their own `workerShutdownTimeoutMs` budgets.
101
+
102
+ ### Producer migration: Bull/BullMQ to Queue
103
+
104
+ In NestJS Bull or BullMQ, the producer selects both the queue and the named job:
105
+
106
+ ```typescript
107
+ // Before: NestJS Bull/BullMQ
108
+ import { InjectQueue } from '@nestjs/bullmq';
109
+ import type { Queue } from 'bullmq';
110
+
111
+ export class OrdersProducer {
112
+ constructor(@InjectQueue('orders') private readonly queue: Queue) {}
113
+
114
+ async placeOrder(orderId: string) {
115
+ await this.queue.add('process-order', { orderId });
116
+ }
117
+ }
118
+ ```
119
+
120
+ In fluo, declare and register `ProcessOrderJob` with `@QueueWorker(ProcessOrderJob, { jobName: 'process-order' })`, then enqueue an instance of that exact exported class. The worker registration selects the BullMQ queue and named job; the producer does not supply either string:
121
+
122
+ ```typescript
123
+ // After: fluo
124
+ import { Inject } from '@fluojs/core';
125
+ import { QueueLifecycleService } from '@fluojs/queue';
126
+
127
+ @Inject(QueueLifecycleService)
128
+ export class OrdersProducer {
129
+ constructor(private readonly queue: QueueLifecycleService) {}
130
+
131
+ async placeOrder(orderId: string) {
132
+ await this.queue.enqueue(new ProcessOrderJob(orderId));
133
+ }
134
+ }
135
+ ```
136
+
137
+ `ProcessOrderJob` must be the same constructor reference passed to `@QueueWorker`, not a copied declaration or a plain `{ orderId }` object. The latter type-checks because `enqueue<TJob extends object>` accepts objects, but it is rejected at runtime as `No @QueueWorker() registered for job type Object.`.
138
+
139
+ Before cutover, account for the persistence identity mismatch. NestJS Bull/BullMQ can persist multiple named job values under one `queueName`. fluo instead uses the worker's `jobName` as both the BullMQ queue name and the named job when it creates one queue/worker pair for each job type. Setting `jobName` alone therefore cannot preserve a legacy topology in which multiple named jobs share one `queueName`, and `@fluojs/queue` does not interpret NestJS decorator metadata or transform an existing serialized payload.
140
+
141
+ Choose an application-owned persisted-job cutover: drain the legacy queue with the old workers before switching producers; transform and re-enqueue compatible payloads into fluo's per-job queues; or use separate queue names for fluo while legacy workers drain old work. In every path, verify the payload class shape, retry/backoff settings, and shutdown budget, then deploy producers and singleton `@QueueWorker(JobClass)` providers through the same `QueueModule.forRoot(...)` graph. For `global: false`, preserve worker and Redis reachability, and remember that processing starts only after the bootstrap-ready handoff and each graceful or forced worker close phase is bounded by `workerShutdownTimeoutMs`.
142
+
81
143
  ## Common Patterns
82
144
 
83
145
  ### Named Redis Client
@@ -90,11 +152,54 @@ QueueModule.forRoot({ clientName: 'jobs' })
90
152
 
91
153
  `@fluojs/queue` resolves that Redis client during application bootstrap, then creates queue-owned duplicate connections for BullMQ. The shared `@fluojs/redis` client remains owned by `RedisModule`; Queue closes only the duplicate BullMQ connections it creates. Those duplicate connections are configured with BullMQ's required `maxRetriesPerRequest: null` worker setting so startup behavior matches BullMQ's runtime constraints.
92
154
 
155
+ When `QueueModule.forRoot({ global: false })` is used, each queue registration only discovers workers that are reachable from the same module tree that imported that specific `QueueModule.forRoot(...)` call. Separate scoped queue feature modules stay isolated from one another, and the Redis client provider must be reachable from that same module tree.
156
+
157
+ ### Scoped Queue Registrations
158
+
159
+ Use an explicit `scope` when an application imports more than one non-global queue registration. Scope names are trimmed, must be non-empty, and must be unique per compiled module graph. Duplicate default scoped registrations such as two `QueueModule.forRoot({ global: false })` imports, or duplicate explicit scopes such as two `QueueModule.forRoot({ global: false, scope: 'jobs' })` imports, fail deterministically during bootstrap.
160
+
161
+ A scope isolates DI ownership; it does not namespace the BullMQ queue stored in Redis. `clientName` selects a DI registration and is not a BullMQ backend identity: distinct named clients can point to the same Redis database and BullMQ prefix.
162
+
163
+ Declare `ownershipNamespace` for every scoped registration that shares a BullMQ backend. This stable application-supplied value identifies the actual Redis database plus BullMQ prefix topology; registrations for the same backend must use the same value, regardless of `clientName`. It is a validation identity only and does not change BullMQ keys or prefixes.
164
+
165
+ Queue validates each `(ownershipNamespace, jobName)` pair before it creates BullMQ resources. In 2.x, `ownershipEnforcement` defaults to `'warn'`, so an unconfigured or colliding topology logs a diagnostic and preserves startup behavior. Set `ownershipEnforcement: 'reject'` on a registration to reject a collision before resources are created. A registration with an empty namespace is invalid. Use distinct namespaces only for distinct BullMQ backends, or configure distinct `jobName` values for intentional isolation.
166
+
167
+ ```typescript
168
+ QueueModule.forRoot({
169
+ clientName: 'orders',
170
+ global: false,
171
+ ownershipNamespace: 'orders-redis-db-0',
172
+ ownershipEnforcement: 'reject',
173
+ scope: 'orders',
174
+ })
175
+ ```
176
+
177
+ ```typescript
178
+ import { Inject, Module } from '@fluojs/core';
179
+ import { getQueueLifecycleServiceToken, getQueueToken, QueueModule, type Queue } from '@fluojs/queue';
180
+
181
+ const EMAIL_QUEUE = getQueueToken('email');
182
+ const EMAIL_QUEUE_LIFECYCLE = getQueueLifecycleServiceToken('email');
183
+
184
+ @Inject(EMAIL_QUEUE)
185
+ export class EmailPublisher {
186
+ constructor(private readonly queue: Queue) {}
187
+ }
188
+
189
+ @Module({
190
+ imports: [QueueModule.forRoot({ global: false, scope: 'email' })],
191
+ providers: [EmailPublisher, EmailWorker],
192
+ })
193
+ export class EmailQueueModule {}
194
+ ```
195
+
196
+ Omit `scope` only when the application has a single default queue registration and injects the compatibility `QUEUE` token or `QueueLifecycleService` class directly. For scoped registrations, inject `getQueueToken(scope)` or `getQueueLifecycleServiceToken(scope)` so each feature module resolves its own queue instance instead of the default compatibility token.
197
+
93
198
  ### Bootstrap and Shutdown Lifecycle
94
199
 
95
200
  Queue discovers workers and creates queue-owned BullMQ resources during application bootstrap, but BullMQ worker processors are started only after the runtime marks the full application bootstrap/readiness sequence complete. Jobs enqueued by other `onApplicationBootstrap()` hooks can be accepted once the Queue service is initialized, and their processors run after the bootstrap-ready handoff instead of racing ahead of later async bootstrap hooks or application readiness. Queue status reports degraded readiness until those BullMQ processors have actually started; if a processor fails to start, the lifecycle moves to `failed` and status snapshots expose the failure instead of reporting the workers as ready.
96
201
 
97
- Application shutdown marks Queue as `stopping`, rejects new enqueue attempts, closes queue-owned workers/queues/connections, and drains pending dead-letter writes. Worker shutdown is bounded by `workerShutdownTimeoutMs` so an active processor that never settles cannot block application shutdown indefinitely. When the timeout elapses, Queue logs the timeout and asks BullMQ to force-close the worker before continuing resource cleanup.
202
+ Application shutdown marks Queue as `stopping`, rejects new enqueue attempts, closes queue-owned workers/queues/connections, and then attempts to drain pending dead-letter writes. Queue waits at most `5_000ms` for each pending dead-letter write. If that wait times out, Queue logs the timeout, stops counting the write as pending, and continues shutdown without guaranteeing that the record reached Redis. Queue gives the graceful worker close and, when needed, the force-close attempt up to `workerShutdownTimeoutMs` each. If either close phase fails or times out, Queue logs the failure and continues the remaining queue, connection, and dead-letter cleanup, so an unresolved BullMQ force-close cannot block application shutdown indefinitely.
98
203
 
99
204
  ### Distributed Retries
100
205
 
@@ -109,11 +214,49 @@ Workers can be configured with a maximum number of attempts and backoff strategi
109
214
 
110
215
  ### Dead-Letter Handling
111
216
 
112
- When a worker exhausts its retry attempts, Queue appends a dead-letter record to Redis (`fluo:queue:dead-letter:<jobName>`) for manual inspection or recovery. Queue does not move the BullMQ job itself.
217
+ When a worker exhausts its retry attempts, Queue appends a separate dead-letter record to Redis (`fluo:queue:dead-letter:<jobName>`) for manual inspection or recovery. Queue does not move the BullMQ job itself into that list.
113
218
 
114
219
  `QueueModule.forRoot()` keeps the most recent `1_000` dead-letter entries per job by default. Set `defaultDeadLetterMaxEntries: false` to opt out, or provide a smaller positive number when operators need a tighter retention budget.
115
220
 
116
- Jobs must be JSON-serializable plain objects. Queue serializes the job payload before enqueueing and rehydrates the job prototype on the worker side.
221
+ Use `QueueLifecycleService.inspectDeadLetters(jobName, { limit })` or the same method on an injected `Queue` facade to inspect records without reading Queue's Redis keys directly:
222
+
223
+ ```typescript
224
+ const inspection = await queue.inspectDeadLetters('ProcessOrderJob', { limit: 25 });
225
+
226
+ for (const record of inspection.records) {
227
+ console.log(record.jobId, record.failedAt, record.errorMessage);
228
+ }
229
+ ```
230
+
231
+ Inspection is read-only and returns valid records in newest-first order. It reads Redis without lifecycle-gating the operation, so inspection does not start workers and remains usable while Queue is `idle` or after worker startup reaches `failed`, as long as the backing Redis client is reachable. Queue does not own the shared Redis client; after `RedisModule` shuts that client down, inspection propagates the backing Redis operation error instead of promising post-shutdown availability. The limit defaults to `100` stored entries and is capped at `1_000`; invalid limits fall back to the default. Malformed stored values are omitted and counted in `malformedRecordCount` for the inspected window, and `payload` remains `unknown` so application code must narrow its own job data. Inspection does not delete, replay, or mutate jobs or dead-letter records.
232
+
233
+ ### Producer Dispatch Contract
234
+
235
+ `enqueue(job)` dispatches by the job's exact constructor. Queue looks up `job.constructor` in the workers discovered from `@QueueWorker(JobClass, options?)` and rejects the call with `No @QueueWorker() registered for job type <name>.` when that exact constructor is not registered.
236
+
237
+ Pass an optional `deduplicationKey` as the second `enqueue` argument when one caller-owned identity must survive uncertain delivery or repeated dispatch. Queue deterministically maps it to a BullMQ-safe backing job id, so callers do not inherit BullMQ's colon or numeric-only custom-id restrictions and BullMQ can deduplicate repeated enqueue attempts for the same worker queue.
238
+
239
+ ```typescript
240
+ await queue.enqueue(new ProcessOrderJob(id), { deduplicationKey: `order:${id}` });
241
+ ```
242
+
243
+ Pass an instance of the registered job class, not a plain payload object:
244
+
245
+ ```typescript
246
+ // Correct: the instance's constructor is the registered ProcessOrderJob class.
247
+ await queue.enqueue(new ProcessOrderJob(id));
248
+
249
+ // Rejected at runtime: a plain object literal has `Object` as its constructor,
250
+ // so it cannot identify any registered JobClass worker.
251
+ await queue.enqueue({ orderId: id });
252
+
253
+ // Also rejected: a structurally identical class that was never registered.
254
+ await queue.enqueue(new UnregisteredOrderJob(id));
255
+ ```
256
+
257
+ Because `enqueue<TJob extends object>(job: TJob)` accepts any object, a plain payload satisfies TypeScript and fails only at runtime. Constructor identity — not payload shape, field names, or a job-name string — selects the worker, so a copied class definition or a re-declared job class in another module is a different constructor and is not registered.
258
+
259
+ Queue accepts job objects, including class instances such as `new ProcessOrderJob(id)`. Before enqueueing, Queue JSON-serializes the job and requires the serialized payload to be a non-null, non-array JSON object. On the worker side, Queue rehydrates the registered job prototype over that serialized object. Serialization runs after the constructor lookup succeeds, so a serializable plain object is still rejected before any payload validation.
117
260
 
118
261
  Treat low-level provider assembly as an internal implementation detail: low-level provider helpers are not part of the documented root-barrel contract.
119
262
 
@@ -122,16 +265,25 @@ Treat low-level provider assembly as an internal implementation detail: low-leve
122
265
  ### Core
123
266
  - `QueueModule`: Main entry point for queue registration.
124
267
  - `QueueModule.forRoot(options)`: Registers queue support for an application module.
125
- - `QueueLifecycleService`: Primary service for enqueuing jobs and creating lifecycle/status snapshots (`enqueue(job)`, `createPlatformStatusSnapshot()`).
268
+ - `QueueLifecycleService`: Primary service for enqueuing jobs, read-only dead-letter inspection, and lifecycle/status snapshots (`enqueue(job, options?)`, `enqueueMany(entries)`, `inspectDeadLetters(jobName, options?)`, `createPlatformStatusSnapshot()`).
269
+ - `Queue`: Public producer facade exposed through `QUEUE` and `getQueueToken(scope?)`; it has the same `enqueue(...)` and `enqueueMany(...)` contract as `QueueLifecycleService`.
126
270
  - `@QueueWorker(JobClass, options?)`: Decorator to mark a class as a job handler.
127
271
  - `QUEUE`: Compatibility injection token for the queue facade.
272
+ - `getQueueToken(scope?)`: Queue facade token helper. Omitting `scope` returns the default `QUEUE` token; a non-empty scope returns that scoped registration's facade token.
273
+ - `getQueueLifecycleServiceToken(scope?)`: Lifecycle service token helper for scoped queue registrations.
128
274
  - `createQueuePlatformStatusSnapshot(...)`: Status snapshot helper for lifecycle/readiness diagnostics.
129
275
 
130
276
 
131
277
  ### Types
132
- - `Queue`: Compatibility facade with `enqueue(job)` for application code and the `QUEUE` token.
278
+ - `Queue`: Application facade with `enqueue(job, options?)`, atomic `enqueueMany(entries)`, and read-only `inspectDeadLetters(jobName, options?)` for application code and the `QUEUE` token.
279
+ - `QueueEnqueueOptions`: Optional producer controls, including a caller-owned `deduplicationKey` that Queue maps to a BullMQ-safe job id for idempotent enqueue attempts.
280
+ - `QueueEnqueueManyEntry`: One ordered batch entry containing a job and its optional `QueueEnqueueOptions`.
281
+ - `QueueDeadLetterInspectionOptions`: Bounded dead-letter inspection settings (`limit`).
282
+ - `QueueDeadLetterInspectionResult`: Newest-first valid records plus `malformedRecordCount` for the inspected window.
283
+ - `QueueDeadLetterRecord`: Typed dead-letter metadata with an `unknown` application payload.
133
284
  - `QueueJobType`: Constructor type used to identify and rehydrate a job payload class.
134
- - `QueueModuleOptions`: Global queue settings (`global`, clientName, default attempts, `defaultBackoff`, concurrency, rate limiting, dead-letter retention).
285
+ - `QueueModuleOptions`: Global queue settings (`global`, `clientName`, `ownershipNamespace`, `ownershipEnforcement`, default attempts, `defaultBackoff`, concurrency, rate limiting, dead-letter retention).
286
+ - `QueueOwnershipEnforcement`: Cross-scope ownership collision action (`'warn'` or `'reject'`).
135
287
  - `QueueWorkerOptions`: Per-job settings (attempts, backoff, concurrency, jobName, rate limiting).
136
288
  - `QueueBackoffType`: Supported retry backoff strategy names (`fixed`, `exponential`).
137
289
  - `QueueBackoffOptions`: Retry backoff settings (`type`, `delayMs`).
@@ -145,13 +297,24 @@ Treat low-level provider assembly as an internal implementation detail: low-leve
145
297
  `QueueModuleOptions` lifecycle/status controls:
146
298
 
147
299
  - `global`: whether the queue module registration is global. Defaults to `true`; set `false` when queue providers should stay scoped to the importing module graph.
148
- - `workerShutdownTimeoutMs`: maximum time to wait for active worker processors during shutdown before force-closing the BullMQ worker. Defaults to `30_000`.
300
+ - `scope`: unique non-empty queue registration scope. Required when multiple non-global queue registrations exist in one app.
301
+ - `ownershipNamespace`: stable application-supplied identity for the Redis database and BullMQ prefix. Registrations for one BullMQ backend must use the same non-empty value, independent of `clientName`.
302
+ - `ownershipEnforcement`: cross-scope ownership action. It defaults to `'warn'` in 2.x; set `'reject'` to fail a matching `(ownershipNamespace, jobName)` collision before BullMQ resources are created.
303
+ - `workerShutdownTimeoutMs`: maximum time allotted to each BullMQ worker close phase: graceful close first, then force-close if graceful close fails or times out. Defaults to `30_000`.
149
304
  - `defaultDeadLetterMaxEntries`: maximum retained dead-letter records per job, or `false` to disable trimming. Defaults to `1_000`.
150
305
 
151
- `QueueLifecycleService.createPlatformStatusSnapshot()` uses the same public snapshot contract as `createQueuePlatformStatusSnapshot(...)`. It reports readiness as `ready` only after Queue reaches `started` and every discovered BullMQ worker processor has started. `started` resources with pending processors report degraded readiness, `starting` reports degraded readiness, `stopping`/`stopped` report not-ready, and worker-start failures report not-ready/unhealthy with `workerStartFailures` and `lastWorkerStartFailure` details. Snapshot details include the Redis dependency id, lifecycle state, ready/discovered worker counts, pending dead-letter writes, the dead-letter drain timeout, and `workerShutdownTimeoutMs`.
306
+ `QueueLifecycleService.createPlatformStatusSnapshot()` uses the same public snapshot contract as `createQueuePlatformStatusSnapshot(...)`. It reports readiness as `ready` only after Queue reaches `started` and every discovered BullMQ worker processor has started. While those conditions remain true, pending dead-letter writes keep readiness `ready` but degrade health until the pending count returns to zero. `started` resources with pending processors report degraded readiness, `starting` reports degraded readiness, `stopping` reports not-ready/degraded, `stopped` reports not-ready/unhealthy, and worker-start failures report not-ready/unhealthy with `workerStartFailures` and `lastWorkerStartFailure` details. Snapshot details include the Redis dependency id, lifecycle state, ready/discovered worker counts, pending dead-letter writes, the `5_000ms` dead-letter drain timeout, and `workerShutdownTimeoutMs`.
152
307
 
153
308
  Only singleton `@QueueWorker()` providers/controllers are registered. Request/transient workers are skipped during discovery.
154
309
 
310
+ ### Atomic producer batches
311
+
312
+ `Queue.enqueueMany(entries)` and `QueueLifecycleService.enqueueMany(entries)` accept ordered `QueueEnqueueManyEntry` values. Each entry supplies one job instance and optional per-entry `QueueEnqueueOptions`, including `deduplicationKey`.
313
+
314
+ Every entry must resolve to a registered worker on the same single BullMQ queue. Queue validates the full batch before it calls BullMQ, so a missing worker or a job that resolves to another queue rejects without persisting any entry. A valid batch is persisted with one atomic BullMQ `addBulk(...)` call, and its returned job IDs stay aligned with the input order.
315
+
316
+ Each entry preserves its own `deduplicationKey` when Queue maps it to the backing BullMQ job ID. Existing `enqueue(job, options?)` behavior is unchanged and remains the compatible single-job producer API.
317
+
155
318
  ## Related Packages
156
319
 
157
320
  - `@fluojs/redis`: Required as the backing store for job persistence.
@@ -1,5 +1,5 @@
1
1
  import type { ApplicationLogger } from '@fluojs/runtime';
2
- import type { NormalizedQueueModuleOptions, QueueWorkerDescriptor } from './types.js';
2
+ import type { NormalizedQueueModuleOptions, QueueDeadLetterInspectionOptions, QueueDeadLetterInspectionResult, QueueWorkerDescriptor } from './types.js';
3
3
  /**
4
4
  * Describes the queue dead letter job contract.
5
5
  */
@@ -16,6 +16,7 @@ export interface QueueDeadLetterJob {
16
16
  * Describes the queue redis dead letter client contract.
17
17
  */
18
18
  export interface QueueRedisDeadLetterClient {
19
+ lrange(key: string, start: number, stop: number): Promise<string[]>;
19
20
  ltrim(key: string, start: number, stop: number): Promise<unknown>;
20
21
  rpush(key: string, value: string): Promise<unknown>;
21
22
  }
@@ -29,6 +30,14 @@ export declare class QueueDeadLetterManager {
29
30
  private readonly pendingWrites;
30
31
  constructor(options: NormalizedQueueModuleOptions, logger: ApplicationLogger, getRedisClient: () => QueueRedisDeadLetterClient);
31
32
  get pendingWriteCount(): number;
33
+ /**
34
+ * Reads and parses a bounded dead-letter snapshot without mutating Redis state.
35
+ *
36
+ * @param jobName Queue worker job name whose dead letters should be inspected.
37
+ * @param options Optional inspection limit, capped at `1_000` stored entries.
38
+ * @returns Valid records in newest-first order and the number of malformed entries omitted.
39
+ */
40
+ inspect(jobName: string, options?: QueueDeadLetterInspectionOptions): Promise<QueueDeadLetterInspectionResult>;
32
41
  trackTerminalFailure(descriptor: QueueWorkerDescriptor, job: QueueDeadLetterJob | undefined, error: Error): void;
33
42
  drainPendingWrites(): Promise<void>;
34
43
  private appendDeadLetterRecord;
@@ -1 +1 @@
1
- {"version":3,"file":"dead-letter-manager.d.ts","sourceRoot":"","sources":["../src/dead-letter-manager.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAGzD,OAAO,KAAK,EAAE,4BAA4B,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAMtF;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,OAAO,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE;QACJ,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,CAAC;CACH;AAED;;GAEG;AACH,MAAM,WAAW,0BAA0B;IACzC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAClE,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACrD;AAED;;GAEG;AACH,qBAAa,sBAAsB;IAI/B,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,cAAc;IALjC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA4B;gBAGvC,OAAO,EAAE,4BAA4B,EACrC,MAAM,EAAE,iBAAiB,EACzB,cAAc,EAAE,MAAM,0BAA0B;IAGnE,IAAI,iBAAiB,IAAI,MAAM,CAE9B;IAED,oBAAoB,CAAC,UAAU,EAAE,qBAAqB,EAAE,GAAG,EAAE,kBAAkB,GAAG,SAAS,EAAE,KAAK,EAAE,KAAK,GAAG,IAAI;IAY1G,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC;YAmB3B,sBAAsB;IA+BpC,OAAO,CAAC,iBAAiB;CAQ1B"}
1
+ {"version":3,"file":"dead-letter-manager.d.ts","sourceRoot":"","sources":["../src/dead-letter-manager.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAGzD,OAAO,KAAK,EACV,4BAA4B,EAC5B,gCAAgC,EAChC,+BAA+B,EAE/B,qBAAqB,EACtB,MAAM,YAAY,CAAC;AAQpB;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,OAAO,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE;QACJ,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,CAAC;CACH;AAED;;GAEG;AACH,MAAM,WAAW,0BAA0B;IACzC,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACpE,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAClE,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACrD;AAED;;GAEG;AACH,qBAAa,sBAAsB;IAI/B,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,cAAc;IALjC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA4B;gBAGvC,OAAO,EAAE,4BAA4B,EACrC,MAAM,EAAE,iBAAiB,EACzB,cAAc,EAAE,MAAM,0BAA0B;IAGnE,IAAI,iBAAiB,IAAI,MAAM,CAE9B;IAED;;;;;;OAMG;IACG,OAAO,CACX,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,gCAAqC,GAC7C,OAAO,CAAC,+BAA+B,CAAC;IAoB3C,oBAAoB,CAAC,UAAU,EAAE,qBAAqB,EAAE,GAAG,EAAE,kBAAkB,GAAG,SAAS,EAAE,KAAK,EAAE,KAAK,GAAG,IAAI;IAY1G,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC;YAmB3B,sBAAsB;IA+BpC,OAAO,CAAC,iBAAiB;CAQ1B"}