@nolag/queue 0.1.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.md +198 -0
- package/dist/EventEmitter.d.ts +15 -0
- package/dist/JobStore.d.ts +57 -0
- package/dist/NoLagQueue.d.ts +84 -0
- package/dist/PresenceManager.d.ts +40 -0
- package/dist/QueueRoom.d.ts +85 -0
- package/dist/WorkerManager.d.ts +42 -0
- package/dist/browser.d.ts +10 -0
- package/dist/browser.js +2 -0
- package/dist/browser.js.map +1 -0
- package/dist/constants.d.ts +12 -0
- package/dist/index.cjs +1078 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.mjs +1071 -0
- package/dist/index.mjs.map +1 -0
- package/dist/types.d.ts +150 -0
- package/dist/utils.d.ts +2 -0
- package/package.json +57 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var jsSdk = require('@nolag/js-sdk');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tiny typed event emitter — framework-agnostic base for NoLag SDKs.
|
|
7
|
+
*
|
|
8
|
+
* EventMap is a record of event name → tuple of handler arguments.
|
|
9
|
+
*/
|
|
10
|
+
class EventEmitter {
|
|
11
|
+
constructor() {
|
|
12
|
+
this._handlers = new Map();
|
|
13
|
+
}
|
|
14
|
+
on(event, handler) {
|
|
15
|
+
if (!this._handlers.has(event)) {
|
|
16
|
+
this._handlers.set(event, new Set());
|
|
17
|
+
}
|
|
18
|
+
this._handlers.get(event).add(handler);
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
off(event, handler) {
|
|
22
|
+
if (handler) {
|
|
23
|
+
this._handlers.get(event)?.delete(handler);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
this._handlers.delete(event);
|
|
27
|
+
}
|
|
28
|
+
return this;
|
|
29
|
+
}
|
|
30
|
+
removeAllListeners() {
|
|
31
|
+
this._handlers.clear();
|
|
32
|
+
return this;
|
|
33
|
+
}
|
|
34
|
+
emit(event, ...args) {
|
|
35
|
+
const handlers = this._handlers.get(event);
|
|
36
|
+
if (!handlers)
|
|
37
|
+
return;
|
|
38
|
+
for (const handler of handlers) {
|
|
39
|
+
try {
|
|
40
|
+
handler(...args);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
console.error(`Error in ${String(event)} handler:`, e);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
listenerCount(event) {
|
|
48
|
+
return this._handlers.get(event)?.size ?? 0;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Valid state transitions for job lifecycle */
|
|
53
|
+
const VALID_TRANSITIONS = {
|
|
54
|
+
pending: ['claimed'],
|
|
55
|
+
claimed: ['active'],
|
|
56
|
+
active: ['completed', 'failed'],
|
|
57
|
+
failed: ['pending'], // retry path
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* In-memory job store with deduplication, filtering, and state-transition validation.
|
|
61
|
+
*/
|
|
62
|
+
class JobStore {
|
|
63
|
+
constructor(maxSize) {
|
|
64
|
+
this._jobs = new Map();
|
|
65
|
+
this._maxSize = maxSize;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Add a job to the store.
|
|
69
|
+
* Returns true if added, false if already present (dedup).
|
|
70
|
+
*/
|
|
71
|
+
add(job) {
|
|
72
|
+
if (this._jobs.has(job.id))
|
|
73
|
+
return false;
|
|
74
|
+
// Evict oldest entry if at capacity
|
|
75
|
+
if (this._jobs.size >= this._maxSize) {
|
|
76
|
+
const firstKey = this._jobs.keys().next().value;
|
|
77
|
+
if (firstKey !== undefined) {
|
|
78
|
+
this._jobs.delete(firstKey);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
this._jobs.set(job.id, job);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Get a job by ID.
|
|
86
|
+
*/
|
|
87
|
+
get(id) {
|
|
88
|
+
return this._jobs.get(id);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Get all jobs, optionally filtered.
|
|
92
|
+
*/
|
|
93
|
+
getAll(filter) {
|
|
94
|
+
const jobs = Array.from(this._jobs.values());
|
|
95
|
+
if (!filter)
|
|
96
|
+
return jobs;
|
|
97
|
+
return jobs.filter((job) => {
|
|
98
|
+
if (filter.status !== undefined && job.status !== filter.status)
|
|
99
|
+
return false;
|
|
100
|
+
if (filter.type !== undefined && job.type !== filter.type)
|
|
101
|
+
return false;
|
|
102
|
+
if (filter.priority !== undefined && job.priority !== filter.priority)
|
|
103
|
+
return false;
|
|
104
|
+
return true;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Transition a job to a new status with optional data update.
|
|
109
|
+
* Validates the state transition. Returns updated job or null if invalid.
|
|
110
|
+
*/
|
|
111
|
+
updateStatus(id, status, data) {
|
|
112
|
+
const job = this._jobs.get(id);
|
|
113
|
+
if (!job)
|
|
114
|
+
return null;
|
|
115
|
+
const allowed = VALID_TRANSITIONS[job.status];
|
|
116
|
+
if (!allowed || !allowed.includes(status))
|
|
117
|
+
return null;
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
const updated = {
|
|
120
|
+
...job,
|
|
121
|
+
status,
|
|
122
|
+
updatedAt: now,
|
|
123
|
+
...data,
|
|
124
|
+
};
|
|
125
|
+
if (status === 'completed' || status === 'failed') {
|
|
126
|
+
updated.completedAt = now;
|
|
127
|
+
}
|
|
128
|
+
this._jobs.set(id, updated);
|
|
129
|
+
return updated;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Update the progress percentage (0–100) for a job.
|
|
133
|
+
* Returns the updated job or null if not found.
|
|
134
|
+
*/
|
|
135
|
+
updateProgress(id, progress) {
|
|
136
|
+
const job = this._jobs.get(id);
|
|
137
|
+
if (!job)
|
|
138
|
+
return null;
|
|
139
|
+
const clamped = Math.min(100, Math.max(0, progress));
|
|
140
|
+
const updated = { ...job, progress: clamped, updatedAt: Date.now() };
|
|
141
|
+
this._jobs.set(id, updated);
|
|
142
|
+
return updated;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Number of jobs currently in 'pending' status.
|
|
146
|
+
*/
|
|
147
|
+
get pendingCount() {
|
|
148
|
+
let count = 0;
|
|
149
|
+
for (const job of this._jobs.values()) {
|
|
150
|
+
if (job.status === 'pending')
|
|
151
|
+
count++;
|
|
152
|
+
}
|
|
153
|
+
return count;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Number of jobs currently in 'active' status.
|
|
157
|
+
*/
|
|
158
|
+
get activeCount() {
|
|
159
|
+
let count = 0;
|
|
160
|
+
for (const job of this._jobs.values()) {
|
|
161
|
+
if (job.status === 'active')
|
|
162
|
+
count++;
|
|
163
|
+
}
|
|
164
|
+
return count;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Check if a job with the given ID exists.
|
|
168
|
+
*/
|
|
169
|
+
has(id) {
|
|
170
|
+
return this._jobs.has(id);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Total number of jobs in the store.
|
|
174
|
+
*/
|
|
175
|
+
get size() {
|
|
176
|
+
return this._jobs.size;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Clear all jobs from the store.
|
|
180
|
+
*/
|
|
181
|
+
clear() {
|
|
182
|
+
this._jobs.clear();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Tracks all known queue workers (local and remote).
|
|
188
|
+
*/
|
|
189
|
+
class WorkerManager {
|
|
190
|
+
constructor() {
|
|
191
|
+
this._workers = new Map();
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Add or replace a worker entry.
|
|
195
|
+
*/
|
|
196
|
+
addWorker(worker) {
|
|
197
|
+
this._workers.set(worker.workerId, worker);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Remove a worker by workerId.
|
|
201
|
+
* Returns the removed worker, or null if not found.
|
|
202
|
+
*/
|
|
203
|
+
removeWorker(workerId) {
|
|
204
|
+
const worker = this._workers.get(workerId) ?? null;
|
|
205
|
+
this._workers.delete(workerId);
|
|
206
|
+
return worker;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Get a worker by workerId.
|
|
210
|
+
*/
|
|
211
|
+
getWorker(workerId) {
|
|
212
|
+
return this._workers.get(workerId);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Get all tracked workers.
|
|
216
|
+
*/
|
|
217
|
+
getAll() {
|
|
218
|
+
return Array.from(this._workers.values());
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Increment the active job count for a worker.
|
|
222
|
+
* Returns the updated worker or null if not found.
|
|
223
|
+
*/
|
|
224
|
+
incrementActiveJobs(workerId) {
|
|
225
|
+
const worker = this._workers.get(workerId);
|
|
226
|
+
if (!worker)
|
|
227
|
+
return null;
|
|
228
|
+
const updated = { ...worker, activeJobs: worker.activeJobs + 1 };
|
|
229
|
+
this._workers.set(workerId, updated);
|
|
230
|
+
return updated;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Decrement the active job count for a worker (floor 0).
|
|
234
|
+
* Returns the updated worker or null if not found.
|
|
235
|
+
*/
|
|
236
|
+
decrementActiveJobs(workerId) {
|
|
237
|
+
const worker = this._workers.get(workerId);
|
|
238
|
+
if (!worker)
|
|
239
|
+
return null;
|
|
240
|
+
const updated = {
|
|
241
|
+
...worker,
|
|
242
|
+
activeJobs: Math.max(0, worker.activeJobs - 1),
|
|
243
|
+
};
|
|
244
|
+
this._workers.set(workerId, updated);
|
|
245
|
+
return updated;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Check whether a worker can accept more work (activeJobs < concurrency).
|
|
249
|
+
*/
|
|
250
|
+
canAcceptWork(workerId) {
|
|
251
|
+
const worker = this._workers.get(workerId);
|
|
252
|
+
if (!worker)
|
|
253
|
+
return false;
|
|
254
|
+
return worker.activeJobs < worker.concurrency;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Clear all tracked workers.
|
|
258
|
+
*/
|
|
259
|
+
clear() {
|
|
260
|
+
this._workers.clear();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Maps actorTokenId ↔ QueueWorker, filtering self.
|
|
266
|
+
*/
|
|
267
|
+
class PresenceManager {
|
|
268
|
+
constructor(localActorId) {
|
|
269
|
+
this._workers = new Map();
|
|
270
|
+
this._actorToWorkerId = new Map();
|
|
271
|
+
this._localActorId = localActorId;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Add or update a worker from presence data.
|
|
275
|
+
* Returns the QueueWorker if it's a remote worker, null if it's self.
|
|
276
|
+
*/
|
|
277
|
+
addFromPresence(actorTokenId, presence, joinedAt) {
|
|
278
|
+
const isLocal = actorTokenId === this._localActorId;
|
|
279
|
+
// Skip self
|
|
280
|
+
if (isLocal)
|
|
281
|
+
return null;
|
|
282
|
+
const existing = this._actorToWorkerId.get(actorTokenId);
|
|
283
|
+
const workerId = presence.workerId || existing || actorTokenId;
|
|
284
|
+
const worker = {
|
|
285
|
+
workerId,
|
|
286
|
+
actorTokenId,
|
|
287
|
+
role: presence.role,
|
|
288
|
+
activeJobs: presence.activeJobs ?? 0,
|
|
289
|
+
concurrency: presence.concurrency ?? 1,
|
|
290
|
+
metadata: presence.metadata,
|
|
291
|
+
joinedAt: joinedAt || Date.now(),
|
|
292
|
+
isLocal: false,
|
|
293
|
+
};
|
|
294
|
+
this._workers.set(workerId, worker);
|
|
295
|
+
this._actorToWorkerId.set(actorTokenId, workerId);
|
|
296
|
+
return worker;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Remove a worker by actorTokenId.
|
|
300
|
+
* Returns the removed worker, or null if not found / is self.
|
|
301
|
+
*/
|
|
302
|
+
removeByActorId(actorTokenId) {
|
|
303
|
+
if (actorTokenId === this._localActorId)
|
|
304
|
+
return null;
|
|
305
|
+
const workerId = this._actorToWorkerId.get(actorTokenId);
|
|
306
|
+
if (!workerId)
|
|
307
|
+
return null;
|
|
308
|
+
const worker = this._workers.get(workerId) ?? null;
|
|
309
|
+
this._workers.delete(workerId);
|
|
310
|
+
this._actorToWorkerId.delete(actorTokenId);
|
|
311
|
+
return worker;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Get a worker by workerId.
|
|
315
|
+
*/
|
|
316
|
+
getWorker(workerId) {
|
|
317
|
+
return this._workers.get(workerId);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Get a worker by actorTokenId.
|
|
321
|
+
*/
|
|
322
|
+
getWorkerByActorId(actorTokenId) {
|
|
323
|
+
const workerId = this._actorToWorkerId.get(actorTokenId);
|
|
324
|
+
return workerId ? this._workers.get(workerId) : undefined;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Get all remote workers.
|
|
328
|
+
*/
|
|
329
|
+
getAll() {
|
|
330
|
+
return Array.from(this._workers.values());
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Get the workers Map (readonly view).
|
|
334
|
+
*/
|
|
335
|
+
get workers() {
|
|
336
|
+
return this._workers;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Clear all tracked workers.
|
|
340
|
+
*/
|
|
341
|
+
clear() {
|
|
342
|
+
this._workers.clear();
|
|
343
|
+
this._actorToWorkerId.clear();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function generateId() {
|
|
348
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
349
|
+
return crypto.randomUUID();
|
|
350
|
+
}
|
|
351
|
+
return 'xxxx-xxxx-xxxx-xxxx'.replace(/x/g, () => Math.floor(Math.random() * 16).toString(16));
|
|
352
|
+
}
|
|
353
|
+
function createLogger(prefix, enabled) {
|
|
354
|
+
if (!enabled) {
|
|
355
|
+
return (..._args) => { };
|
|
356
|
+
}
|
|
357
|
+
return (...args) => {
|
|
358
|
+
console.log(`[${prefix}]`, ...args);
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Default app name for NoLag queue SDK */
|
|
363
|
+
const DEFAULT_APP_NAME = 'queue';
|
|
364
|
+
/** Default maximum number of jobs to cache in memory */
|
|
365
|
+
const DEFAULT_MAX_JOB_CACHE = 1000;
|
|
366
|
+
/** Default maximum number of attempts before a job is permanently failed */
|
|
367
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
368
|
+
/** Topic name for job lifecycle messages within a queue room */
|
|
369
|
+
const TOPIC_JOBS = 'jobs';
|
|
370
|
+
/** Topic name for job progress updates within a queue room */
|
|
371
|
+
const TOPIC_PROGRESS = '_progress';
|
|
372
|
+
/** Lobby ID for global online presence */
|
|
373
|
+
const LOBBY_ID = 'online';
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* QueueRoom — a single named queue with job lifecycle, progress tracking, and worker presence.
|
|
377
|
+
*
|
|
378
|
+
* Created via `NoLagQueue.joinQueue(name)`. Do not instantiate directly.
|
|
379
|
+
*/
|
|
380
|
+
class QueueRoom extends EventEmitter {
|
|
381
|
+
/** @internal */
|
|
382
|
+
constructor(name, roomContext, localWorkerId, options, log) {
|
|
383
|
+
super();
|
|
384
|
+
this.name = name;
|
|
385
|
+
this._roomContext = roomContext;
|
|
386
|
+
this._localWorkerId = localWorkerId;
|
|
387
|
+
this._options = options;
|
|
388
|
+
this._log = log;
|
|
389
|
+
this._jobStore = new JobStore(options.maxJobCache);
|
|
390
|
+
this._workerManager = new WorkerManager();
|
|
391
|
+
this._presenceManager = new PresenceManager(''); // local actor set after connect
|
|
392
|
+
}
|
|
393
|
+
/** @internal Set the local actor ID once connected */
|
|
394
|
+
_setLocalActorId(actorId) {
|
|
395
|
+
this._presenceManager = new PresenceManager(actorId);
|
|
396
|
+
}
|
|
397
|
+
// ============ Producer Methods ============
|
|
398
|
+
/**
|
|
399
|
+
* Add a new job to the queue. Only producers should call this.
|
|
400
|
+
*/
|
|
401
|
+
addJob(opts) {
|
|
402
|
+
const now = Date.now();
|
|
403
|
+
const job = {
|
|
404
|
+
id: generateId(),
|
|
405
|
+
type: opts.type,
|
|
406
|
+
payload: opts.payload,
|
|
407
|
+
priority: opts.priority ?? 'normal',
|
|
408
|
+
status: 'pending',
|
|
409
|
+
progress: 0,
|
|
410
|
+
attempts: 0,
|
|
411
|
+
maxAttempts: opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
|
412
|
+
createdBy: this._localWorkerId,
|
|
413
|
+
createdAt: now,
|
|
414
|
+
updatedAt: now,
|
|
415
|
+
isReplay: false,
|
|
416
|
+
};
|
|
417
|
+
this._jobStore.add(job);
|
|
418
|
+
this._log('Job added:', job.id, job.type);
|
|
419
|
+
this._roomContext.emit(TOPIC_JOBS, { event: 'jobAdded', job }, { echo: true });
|
|
420
|
+
this.emit('jobAdded', job);
|
|
421
|
+
return job;
|
|
422
|
+
}
|
|
423
|
+
// ============ Worker Methods ============
|
|
424
|
+
/**
|
|
425
|
+
* Claim a pending job. Only workers should call this.
|
|
426
|
+
*/
|
|
427
|
+
claimJob(jobId) {
|
|
428
|
+
const updated = this._jobStore.updateStatus(jobId, 'claimed', {
|
|
429
|
+
claimedBy: this._localWorkerId,
|
|
430
|
+
});
|
|
431
|
+
if (!updated)
|
|
432
|
+
return null;
|
|
433
|
+
this._log('Job claimed:', jobId, 'by', this._localWorkerId);
|
|
434
|
+
this._roomContext.emit(TOPIC_JOBS, { event: 'jobClaimed', job: updated }, { echo: true });
|
|
435
|
+
this.emit('jobClaimed', updated);
|
|
436
|
+
return updated;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Report progress on an active job (0–100).
|
|
440
|
+
*/
|
|
441
|
+
reportProgress(jobId, progress) {
|
|
442
|
+
const job = this._jobStore.updateProgress(jobId, progress);
|
|
443
|
+
if (!job)
|
|
444
|
+
return;
|
|
445
|
+
const progressEvent = {
|
|
446
|
+
jobId,
|
|
447
|
+
progress: job.progress,
|
|
448
|
+
workerId: this._localWorkerId,
|
|
449
|
+
timestamp: Date.now(),
|
|
450
|
+
};
|
|
451
|
+
this._log('Job progress:', jobId, job.progress + '%');
|
|
452
|
+
this._roomContext.emit(TOPIC_PROGRESS, progressEvent, { echo: true });
|
|
453
|
+
this.emit('jobProgress', progressEvent);
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Mark a claimed/active job as completed with an optional result.
|
|
457
|
+
*/
|
|
458
|
+
completeJob(jobId, result) {
|
|
459
|
+
// Transition claimed → active → completed in one step for simplicity
|
|
460
|
+
let updated = this._jobStore.updateStatus(jobId, 'active');
|
|
461
|
+
if (!updated) {
|
|
462
|
+
// Already active — go straight to completed
|
|
463
|
+
updated = this._jobStore.get(jobId) ?? null;
|
|
464
|
+
}
|
|
465
|
+
if (!updated)
|
|
466
|
+
return null;
|
|
467
|
+
const completed = this._jobStore.updateStatus(jobId, 'completed', { result });
|
|
468
|
+
if (!completed)
|
|
469
|
+
return null;
|
|
470
|
+
this._log('Job completed:', jobId);
|
|
471
|
+
this._roomContext.emit(TOPIC_JOBS, { event: 'jobCompleted', job: completed }, { echo: true });
|
|
472
|
+
this.emit('jobCompleted', completed);
|
|
473
|
+
return completed;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Mark an active job as failed with an optional error message.
|
|
477
|
+
* Automatically retries if attempts < maxAttempts.
|
|
478
|
+
*/
|
|
479
|
+
failJob(jobId, error) {
|
|
480
|
+
const job = this._jobStore.get(jobId);
|
|
481
|
+
if (!job)
|
|
482
|
+
return null;
|
|
483
|
+
// Move to active if still claimed
|
|
484
|
+
if (job.status === 'claimed') {
|
|
485
|
+
this._jobStore.updateStatus(jobId, 'active');
|
|
486
|
+
}
|
|
487
|
+
const nextAttempts = job.attempts + 1;
|
|
488
|
+
const failed = this._jobStore.updateStatus(jobId, 'failed', {
|
|
489
|
+
error,
|
|
490
|
+
attempts: nextAttempts,
|
|
491
|
+
});
|
|
492
|
+
if (!failed)
|
|
493
|
+
return null;
|
|
494
|
+
this._log('Job failed:', jobId, 'attempts:', nextAttempts, '/', failed.maxAttempts);
|
|
495
|
+
this._roomContext.emit(TOPIC_JOBS, { event: 'jobFailed', job: failed }, { echo: true });
|
|
496
|
+
this.emit('jobFailed', failed);
|
|
497
|
+
// Auto-retry if under maxAttempts
|
|
498
|
+
if (nextAttempts < failed.maxAttempts) {
|
|
499
|
+
const retried = this._jobStore.updateStatus(jobId, 'pending');
|
|
500
|
+
if (retried) {
|
|
501
|
+
this._log('Job retrying:', jobId, 'attempt', nextAttempts + 1);
|
|
502
|
+
this._roomContext.emit(TOPIC_JOBS, { event: 'jobRetrying', job: retried }, { echo: true });
|
|
503
|
+
this.emit('jobRetrying', retried);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return failed;
|
|
507
|
+
}
|
|
508
|
+
// ============ Monitor / Query Methods ============
|
|
509
|
+
/**
|
|
510
|
+
* Get a single job by ID.
|
|
511
|
+
*/
|
|
512
|
+
getJob(id) {
|
|
513
|
+
return this._jobStore.get(id);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Get all jobs, optionally filtered.
|
|
517
|
+
*/
|
|
518
|
+
getJobs(filter) {
|
|
519
|
+
return this._jobStore.getAll(filter);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Number of pending jobs.
|
|
523
|
+
*/
|
|
524
|
+
get pendingCount() {
|
|
525
|
+
return this._jobStore.pendingCount;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Number of active jobs.
|
|
529
|
+
*/
|
|
530
|
+
get activeCount() {
|
|
531
|
+
return this._jobStore.activeCount;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* All workers currently in this queue room.
|
|
535
|
+
*/
|
|
536
|
+
get workers() {
|
|
537
|
+
return this._presenceManager.workers;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Get all workers in this queue room.
|
|
541
|
+
*/
|
|
542
|
+
getWorkers() {
|
|
543
|
+
return this._presenceManager.getAll();
|
|
544
|
+
}
|
|
545
|
+
// ============ Internal (called by NoLagQueue) ============
|
|
546
|
+
/** @internal Subscribe to jobs and progress topics, attach listeners */
|
|
547
|
+
_subscribe() {
|
|
548
|
+
this._log('Room subscribe:', this.name);
|
|
549
|
+
// Workers use load balancing so each job event is delivered to only ONE worker
|
|
550
|
+
// (round-robin across all workers in the same group). Producers and monitors
|
|
551
|
+
// receive all messages so they can track full queue state.
|
|
552
|
+
if (this._options.role === 'worker') {
|
|
553
|
+
const group = this._options.loadBalanceGroup ?? `queue-workers-${this.name}`;
|
|
554
|
+
this._log('Subscribing with load balance, group:', group);
|
|
555
|
+
this._roomContext.subscribe(TOPIC_JOBS, { loadBalance: true, loadBalanceGroup: group });
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
this._roomContext.subscribe(TOPIC_JOBS);
|
|
559
|
+
}
|
|
560
|
+
this._roomContext.subscribe(TOPIC_PROGRESS);
|
|
561
|
+
this._roomContext.on(TOPIC_JOBS, (data) => {
|
|
562
|
+
this._handleJobMessage(data);
|
|
563
|
+
});
|
|
564
|
+
this._roomContext.on(TOPIC_PROGRESS, (data) => {
|
|
565
|
+
this._handleProgressMessage(data);
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
/** @internal Set presence and fetch room members */
|
|
569
|
+
_activate() {
|
|
570
|
+
this._log('Room activate:', this.name);
|
|
571
|
+
this._setPresence();
|
|
572
|
+
this._roomContext.fetchPresence().then((actors) => {
|
|
573
|
+
this._log('Room presence fetched:', this.name, actors.length, 'actors');
|
|
574
|
+
for (const actor of actors) {
|
|
575
|
+
if (actor.presence) {
|
|
576
|
+
const worker = this._presenceManager.addFromPresence(actor.actorTokenId, actor.presence, actor.joinedAt);
|
|
577
|
+
if (worker) {
|
|
578
|
+
this._workerManager.addWorker(worker);
|
|
579
|
+
this.emit('workerJoined', worker);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}).catch((err) => {
|
|
584
|
+
this._log('Failed to fetch room presence:', err);
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
/** @internal Re-set presence after reconnect */
|
|
588
|
+
_updateLocalPresence() {
|
|
589
|
+
this._setPresence();
|
|
590
|
+
}
|
|
591
|
+
/** @internal Handle a presence:join event */
|
|
592
|
+
_handlePresenceJoin(actorTokenId, presenceData) {
|
|
593
|
+
const worker = this._presenceManager.addFromPresence(actorTokenId, presenceData);
|
|
594
|
+
if (worker) {
|
|
595
|
+
this._log('Worker joined queue:', this.name, worker.workerId);
|
|
596
|
+
this._workerManager.addWorker(worker);
|
|
597
|
+
this.emit('workerJoined', worker);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/** @internal Handle a presence:leave event */
|
|
601
|
+
_handlePresenceLeave(actorTokenId) {
|
|
602
|
+
const worker = this._presenceManager.removeByActorId(actorTokenId);
|
|
603
|
+
if (worker) {
|
|
604
|
+
this._log('Worker left queue:', this.name, worker.workerId);
|
|
605
|
+
this._workerManager.removeWorker(worker.workerId);
|
|
606
|
+
this.emit('workerLeft', worker);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/** @internal Handle a presence:update event */
|
|
610
|
+
_handlePresenceUpdate(actorTokenId, presenceData) {
|
|
611
|
+
const worker = this._presenceManager.addFromPresence(actorTokenId, presenceData);
|
|
612
|
+
if (worker) {
|
|
613
|
+
this._workerManager.addWorker(worker);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
/** @internal Unsubscribe and clean up */
|
|
617
|
+
_cleanup() {
|
|
618
|
+
this._log('Room cleanup:', this.name);
|
|
619
|
+
this._roomContext.unsubscribe(TOPIC_JOBS);
|
|
620
|
+
this._roomContext.unsubscribe(TOPIC_PROGRESS);
|
|
621
|
+
this._roomContext.off(TOPIC_JOBS);
|
|
622
|
+
this._roomContext.off(TOPIC_PROGRESS);
|
|
623
|
+
this._jobStore.clear();
|
|
624
|
+
this._workerManager.clear();
|
|
625
|
+
this._presenceManager.clear();
|
|
626
|
+
this.removeAllListeners();
|
|
627
|
+
}
|
|
628
|
+
// ============ Private ============
|
|
629
|
+
_handleJobMessage(data) {
|
|
630
|
+
const msg = data;
|
|
631
|
+
if (!msg?.event || !msg?.job)
|
|
632
|
+
return;
|
|
633
|
+
const { event, job } = msg;
|
|
634
|
+
this._log('Job message:', event, job.id);
|
|
635
|
+
switch (event) {
|
|
636
|
+
case 'jobAdded':
|
|
637
|
+
if (this._jobStore.add(job)) {
|
|
638
|
+
this.emit('jobAdded', job);
|
|
639
|
+
}
|
|
640
|
+
break;
|
|
641
|
+
case 'jobClaimed': {
|
|
642
|
+
const existing = this._jobStore.get(job.id);
|
|
643
|
+
if (existing) {
|
|
644
|
+
this._jobStore.updateStatus(job.id, 'claimed', { claimedBy: job.claimedBy });
|
|
645
|
+
const updated = this._jobStore.get(job.id);
|
|
646
|
+
this.emit('jobClaimed', updated);
|
|
647
|
+
}
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
case 'jobCompleted': {
|
|
651
|
+
const existing = this._jobStore.get(job.id);
|
|
652
|
+
if (existing) {
|
|
653
|
+
// Sync final state directly since remote already processed transitions
|
|
654
|
+
const synced = { ...existing, ...job };
|
|
655
|
+
this._jobStore.add(synced);
|
|
656
|
+
this.emit('jobCompleted', synced);
|
|
657
|
+
}
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
case 'jobFailed': {
|
|
661
|
+
const existing = this._jobStore.get(job.id);
|
|
662
|
+
if (existing) {
|
|
663
|
+
const synced = { ...existing, ...job };
|
|
664
|
+
this._jobStore.add(synced);
|
|
665
|
+
this.emit('jobFailed', synced);
|
|
666
|
+
}
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
case 'jobRetrying': {
|
|
670
|
+
const existing = this._jobStore.get(job.id);
|
|
671
|
+
if (existing) {
|
|
672
|
+
const synced = { ...existing, ...job };
|
|
673
|
+
this._jobStore.add(synced);
|
|
674
|
+
this.emit('jobRetrying', synced);
|
|
675
|
+
}
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
_handleProgressMessage(data) {
|
|
681
|
+
const progress = data;
|
|
682
|
+
if (!progress?.jobId)
|
|
683
|
+
return;
|
|
684
|
+
this._jobStore.updateProgress(progress.jobId, progress.progress);
|
|
685
|
+
this._log('Job progress update:', progress.jobId, progress.progress + '%');
|
|
686
|
+
this.emit('jobProgress', progress);
|
|
687
|
+
}
|
|
688
|
+
_setPresence() {
|
|
689
|
+
const presenceData = {
|
|
690
|
+
workerId: this._localWorkerId,
|
|
691
|
+
role: this._options.role,
|
|
692
|
+
activeJobs: 0,
|
|
693
|
+
concurrency: this._options.concurrency,
|
|
694
|
+
metadata: this._options.metadata,
|
|
695
|
+
};
|
|
696
|
+
this._roomContext.setPresence(presenceData);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* NoLagQueue — high-level real-time job queue SDK built on @nolag/js-sdk.
|
|
702
|
+
*
|
|
703
|
+
* Provides job lifecycle management, progress tracking, worker management,
|
|
704
|
+
* and global presence tracking — all framework-agnostic via events.
|
|
705
|
+
*
|
|
706
|
+
* @example
|
|
707
|
+
* ```typescript
|
|
708
|
+
* import { NoLagQueue } from '@nolag/queue';
|
|
709
|
+
*
|
|
710
|
+
* const queue = new NoLagQueue(token, { role: 'worker', concurrency: 2 });
|
|
711
|
+
*
|
|
712
|
+
* queue.on('connected', () => console.log('Connected!'));
|
|
713
|
+
*
|
|
714
|
+
* await queue.connect();
|
|
715
|
+
*
|
|
716
|
+
* const room = queue.joinQueue('image-processing');
|
|
717
|
+
* room.on('jobAdded', (job) => {
|
|
718
|
+
* room.claimJob(job.id);
|
|
719
|
+
* // process...
|
|
720
|
+
* room.reportProgress(job.id, 50);
|
|
721
|
+
* room.completeJob(job.id, { output: 'result' });
|
|
722
|
+
* });
|
|
723
|
+
* ```
|
|
724
|
+
*/
|
|
725
|
+
class NoLagQueue extends EventEmitter {
|
|
726
|
+
constructor(token, options = {}) {
|
|
727
|
+
super();
|
|
728
|
+
this._client = null;
|
|
729
|
+
this._localWorker = null;
|
|
730
|
+
this._queues = new Map();
|
|
731
|
+
this._lobby = null;
|
|
732
|
+
this._onlineWorkers = new Map();
|
|
733
|
+
this._actorToWorkerId = new Map();
|
|
734
|
+
this._token = token;
|
|
735
|
+
this._workerId = options.workerId ?? generateId();
|
|
736
|
+
this._options = {
|
|
737
|
+
workerId: this._workerId,
|
|
738
|
+
role: options.role ?? 'monitor',
|
|
739
|
+
concurrency: options.concurrency ?? 1,
|
|
740
|
+
metadata: options.metadata,
|
|
741
|
+
appName: options.appName ?? DEFAULT_APP_NAME,
|
|
742
|
+
url: options.url,
|
|
743
|
+
maxJobCache: options.maxJobCache ?? DEFAULT_MAX_JOB_CACHE,
|
|
744
|
+
debug: options.debug ?? false,
|
|
745
|
+
reconnect: options.reconnect ?? true,
|
|
746
|
+
queues: options.queues ?? [],
|
|
747
|
+
loadBalanceGroup: options.loadBalanceGroup,
|
|
748
|
+
};
|
|
749
|
+
this._log = createLogger('NoLagQueue', this._options.debug);
|
|
750
|
+
}
|
|
751
|
+
// ============ Public Properties ============
|
|
752
|
+
/** Whether the underlying connection is established */
|
|
753
|
+
get connected() {
|
|
754
|
+
return this._client?.connected ?? false;
|
|
755
|
+
}
|
|
756
|
+
/** The local worker's info (available after connect) */
|
|
757
|
+
get localWorker() {
|
|
758
|
+
return this._localWorker;
|
|
759
|
+
}
|
|
760
|
+
/** All currently joined queue rooms */
|
|
761
|
+
get queues() {
|
|
762
|
+
return this._queues;
|
|
763
|
+
}
|
|
764
|
+
// ============ Lifecycle ============
|
|
765
|
+
/**
|
|
766
|
+
* Connect to NoLag and set up global presence.
|
|
767
|
+
*/
|
|
768
|
+
async connect() {
|
|
769
|
+
this._log('Connecting...');
|
|
770
|
+
const clientOptions = {
|
|
771
|
+
debug: this._options.debug,
|
|
772
|
+
reconnect: this._options.reconnect,
|
|
773
|
+
};
|
|
774
|
+
if (this._options.url) {
|
|
775
|
+
clientOptions.url = this._options.url;
|
|
776
|
+
}
|
|
777
|
+
this._client = jsSdk.NoLag(this._token, clientOptions);
|
|
778
|
+
// Wire client lifecycle events
|
|
779
|
+
this._client.on('connect', () => {
|
|
780
|
+
this._log('Connected');
|
|
781
|
+
if (this._queues.size > 0) {
|
|
782
|
+
this._log('Reconnected — restoring queues...');
|
|
783
|
+
this._restoreQueues();
|
|
784
|
+
this.emit('reconnected');
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
this._client.on('disconnect', (reason) => {
|
|
788
|
+
this._log('Disconnected:', reason);
|
|
789
|
+
this.emit('disconnected', reason);
|
|
790
|
+
});
|
|
791
|
+
this._client.on('reconnect', () => {
|
|
792
|
+
this._log('Reconnecting...');
|
|
793
|
+
});
|
|
794
|
+
this._client.on('error', (error) => {
|
|
795
|
+
this._log('Error:', error);
|
|
796
|
+
this.emit('error', error);
|
|
797
|
+
});
|
|
798
|
+
// Connect
|
|
799
|
+
await this._client.connect();
|
|
800
|
+
// Wire room-level presence events
|
|
801
|
+
this._client.on('presence:join', (data) => {
|
|
802
|
+
this._handleRoomPresenceJoin(data);
|
|
803
|
+
});
|
|
804
|
+
this._client.on('presence:leave', (data) => {
|
|
805
|
+
this._handleRoomPresenceLeave(data);
|
|
806
|
+
});
|
|
807
|
+
this._client.on('presence:update', (data) => {
|
|
808
|
+
this._handleRoomPresenceUpdate(data);
|
|
809
|
+
});
|
|
810
|
+
// Create local worker record
|
|
811
|
+
this._localWorker = {
|
|
812
|
+
workerId: this._workerId,
|
|
813
|
+
actorTokenId: this._client.actorId,
|
|
814
|
+
role: this._options.role,
|
|
815
|
+
activeJobs: 0,
|
|
816
|
+
concurrency: this._options.concurrency,
|
|
817
|
+
metadata: this._options.metadata,
|
|
818
|
+
joinedAt: Date.now(),
|
|
819
|
+
isLocal: true,
|
|
820
|
+
};
|
|
821
|
+
this._log('Local worker:', this._localWorker.workerId, '→', this._localWorker.actorTokenId);
|
|
822
|
+
// Set up lobby for global presence
|
|
823
|
+
await this._setupLobby();
|
|
824
|
+
// Emit connected now that _localWorker and lobby are ready
|
|
825
|
+
this.emit('connected');
|
|
826
|
+
// Deferred lobby refetch to catch workers who joined during the setup window
|
|
827
|
+
setTimeout(() => {
|
|
828
|
+
if (this._lobby && this._client?.connected) {
|
|
829
|
+
this._lobby.fetchPresence().then((state) => {
|
|
830
|
+
this._hydrateOnlineWorkers(state);
|
|
831
|
+
}).catch(() => { });
|
|
832
|
+
}
|
|
833
|
+
}, 2000);
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Disconnect from NoLag and clean up all queue rooms.
|
|
837
|
+
*/
|
|
838
|
+
disconnect() {
|
|
839
|
+
this._log('Disconnecting...');
|
|
840
|
+
// Clean up queue rooms
|
|
841
|
+
for (const name of [...this._queues.keys()]) {
|
|
842
|
+
this.leaveQueue(name);
|
|
843
|
+
}
|
|
844
|
+
// Unsubscribe from lobby
|
|
845
|
+
this._lobby?.unsubscribe();
|
|
846
|
+
this._lobby = null;
|
|
847
|
+
// Disconnect client
|
|
848
|
+
this._client?.disconnect();
|
|
849
|
+
this._client = null;
|
|
850
|
+
// Clear state
|
|
851
|
+
this._onlineWorkers.clear();
|
|
852
|
+
this._actorToWorkerId.clear();
|
|
853
|
+
this._localWorker = null;
|
|
854
|
+
}
|
|
855
|
+
// ============ Queue Management ============
|
|
856
|
+
/**
|
|
857
|
+
* Join a queue room. Creates, subscribes, and activates it.
|
|
858
|
+
* Returns an existing room if already joined.
|
|
859
|
+
*/
|
|
860
|
+
joinQueue(name) {
|
|
861
|
+
if (!this._client || !this._localWorker) {
|
|
862
|
+
throw new Error('Not connected — call connect() first');
|
|
863
|
+
}
|
|
864
|
+
let room = this._queues.get(name);
|
|
865
|
+
if (!room) {
|
|
866
|
+
room = this._subscribeQueue(name);
|
|
867
|
+
room._activate();
|
|
868
|
+
}
|
|
869
|
+
return room;
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Leave a queue room. Fully unsubscribes and removes it.
|
|
873
|
+
*/
|
|
874
|
+
leaveQueue(name) {
|
|
875
|
+
const room = this._queues.get(name);
|
|
876
|
+
if (!room)
|
|
877
|
+
return;
|
|
878
|
+
this._log('Leaving queue:', name);
|
|
879
|
+
room._cleanup();
|
|
880
|
+
this._queues.delete(name);
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Get all joined queue rooms.
|
|
884
|
+
*/
|
|
885
|
+
getQueues() {
|
|
886
|
+
return Array.from(this._queues.values());
|
|
887
|
+
}
|
|
888
|
+
// ============ Global Presence ============
|
|
889
|
+
/**
|
|
890
|
+
* Get all workers currently online across all queue rooms.
|
|
891
|
+
*/
|
|
892
|
+
getOnlineWorkers() {
|
|
893
|
+
return Array.from(this._onlineWorkers.values());
|
|
894
|
+
}
|
|
895
|
+
// ============ Private: Queue Setup ============
|
|
896
|
+
_subscribeQueue(name) {
|
|
897
|
+
if (!this._client || !this._localWorker) {
|
|
898
|
+
throw new Error('Not connected — call connect() first');
|
|
899
|
+
}
|
|
900
|
+
this._log('Subscribing queue:', name);
|
|
901
|
+
const roomContext = this._client.setApp(this._options.appName).setRoom(name);
|
|
902
|
+
const room = new QueueRoom(name, roomContext, this._workerId, this._options, createLogger(`QueueRoom:${name}`, this._options.debug));
|
|
903
|
+
room._setLocalActorId(this._localWorker.actorTokenId);
|
|
904
|
+
this._queues.set(name, room);
|
|
905
|
+
room._subscribe();
|
|
906
|
+
return room;
|
|
907
|
+
}
|
|
908
|
+
// ============ Private: Room Presence ============
|
|
909
|
+
_handleRoomPresenceJoin(data) {
|
|
910
|
+
if (data.actorTokenId === this._localWorker?.actorTokenId)
|
|
911
|
+
return;
|
|
912
|
+
const presenceData = data.presence;
|
|
913
|
+
if (!presenceData?.workerId)
|
|
914
|
+
return;
|
|
915
|
+
const worker = this._presenceToWorker(data.actorTokenId, presenceData);
|
|
916
|
+
this._actorToWorkerId.set(data.actorTokenId, worker.workerId);
|
|
917
|
+
if (!this._onlineWorkers.has(worker.workerId)) {
|
|
918
|
+
this._onlineWorkers.set(worker.workerId, worker);
|
|
919
|
+
this.emit('workerOnline', worker);
|
|
920
|
+
}
|
|
921
|
+
// Route to all queue rooms
|
|
922
|
+
for (const room of this._queues.values()) {
|
|
923
|
+
room._handlePresenceJoin(data.actorTokenId, presenceData);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
_handleRoomPresenceLeave(data) {
|
|
927
|
+
if (data.actorTokenId === this._localWorker?.actorTokenId)
|
|
928
|
+
return;
|
|
929
|
+
// Route to all queue rooms
|
|
930
|
+
for (const room of this._queues.values()) {
|
|
931
|
+
room._handlePresenceLeave(data.actorTokenId);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
_handleRoomPresenceUpdate(data) {
|
|
935
|
+
if (data.actorTokenId === this._localWorker?.actorTokenId)
|
|
936
|
+
return;
|
|
937
|
+
const presenceData = data.presence;
|
|
938
|
+
if (!presenceData?.workerId)
|
|
939
|
+
return;
|
|
940
|
+
if (this._onlineWorkers.has(presenceData.workerId)) {
|
|
941
|
+
const worker = this._presenceToWorker(data.actorTokenId, presenceData);
|
|
942
|
+
this._onlineWorkers.set(worker.workerId, worker);
|
|
943
|
+
}
|
|
944
|
+
// Route to all queue rooms
|
|
945
|
+
for (const room of this._queues.values()) {
|
|
946
|
+
room._handlePresenceUpdate(data.actorTokenId, presenceData);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
// ============ Private: Lobby ============
|
|
950
|
+
async _setupLobby() {
|
|
951
|
+
if (!this._client)
|
|
952
|
+
return;
|
|
953
|
+
this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
|
|
954
|
+
const lobbyHandler = (type) => (data) => {
|
|
955
|
+
const event = data;
|
|
956
|
+
if (type === 'join')
|
|
957
|
+
this._handleLobbyJoin(event);
|
|
958
|
+
else if (type === 'leave')
|
|
959
|
+
this._handleLobbyLeave(event);
|
|
960
|
+
else
|
|
961
|
+
this._handleLobbyUpdate(event);
|
|
962
|
+
};
|
|
963
|
+
this._client.on('lobbyPresence:join', lobbyHandler('join'));
|
|
964
|
+
this._client.on('lobbyPresence:leave', lobbyHandler('leave'));
|
|
965
|
+
this._client.on('lobbyPresence:update', lobbyHandler('update'));
|
|
966
|
+
try {
|
|
967
|
+
const initialState = await this._lobby.subscribe();
|
|
968
|
+
this._hydrateOnlineWorkers(initialState);
|
|
969
|
+
this._log('Lobby subscribed, online workers:', this._onlineWorkers.size);
|
|
970
|
+
}
|
|
971
|
+
catch (err) {
|
|
972
|
+
this._log('Lobby subscription failed:', err);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
_handleLobbyJoin(event) {
|
|
976
|
+
const { actorId, data } = event;
|
|
977
|
+
if (actorId === this._localWorker?.actorTokenId)
|
|
978
|
+
return;
|
|
979
|
+
const presenceData = data;
|
|
980
|
+
if (!presenceData.workerId)
|
|
981
|
+
return;
|
|
982
|
+
const worker = this._presenceToWorker(actorId, presenceData);
|
|
983
|
+
this._actorToWorkerId.set(actorId, worker.workerId);
|
|
984
|
+
if (!this._onlineWorkers.has(worker.workerId)) {
|
|
985
|
+
this._onlineWorkers.set(worker.workerId, worker);
|
|
986
|
+
this.emit('workerOnline', worker);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
_handleLobbyLeave(event) {
|
|
990
|
+
const { actorId, data } = event;
|
|
991
|
+
if (actorId === this._localWorker?.actorTokenId)
|
|
992
|
+
return;
|
|
993
|
+
const presenceData = data;
|
|
994
|
+
const workerId = presenceData?.workerId
|
|
995
|
+
|| this._actorToWorkerId.get(actorId)
|
|
996
|
+
|| this._findWorkerIdByActorId(actorId);
|
|
997
|
+
if (workerId) {
|
|
998
|
+
const worker = this._onlineWorkers.get(workerId);
|
|
999
|
+
if (worker) {
|
|
1000
|
+
this._onlineWorkers.delete(workerId);
|
|
1001
|
+
this._actorToWorkerId.delete(actorId);
|
|
1002
|
+
this.emit('workerOffline', worker);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
_handleLobbyUpdate(event) {
|
|
1007
|
+
const { actorId, data } = event;
|
|
1008
|
+
if (actorId === this._localWorker?.actorTokenId)
|
|
1009
|
+
return;
|
|
1010
|
+
const presenceData = data;
|
|
1011
|
+
if (!presenceData.workerId)
|
|
1012
|
+
return;
|
|
1013
|
+
const worker = this._presenceToWorker(actorId, presenceData);
|
|
1014
|
+
this._onlineWorkers.set(worker.workerId, worker);
|
|
1015
|
+
}
|
|
1016
|
+
_hydrateOnlineWorkers(state) {
|
|
1017
|
+
for (const roomId of Object.keys(state)) {
|
|
1018
|
+
const roomPresence = state[roomId];
|
|
1019
|
+
for (const actorId of Object.keys(roomPresence)) {
|
|
1020
|
+
if (actorId === this._localWorker?.actorTokenId)
|
|
1021
|
+
continue;
|
|
1022
|
+
const raw = roomPresence[actorId];
|
|
1023
|
+
const presenceData = (raw?.presence ?? raw);
|
|
1024
|
+
if (presenceData?.workerId) {
|
|
1025
|
+
const worker = this._presenceToWorker(actorId, presenceData);
|
|
1026
|
+
this._actorToWorkerId.set(actorId, worker.workerId);
|
|
1027
|
+
if (!this._onlineWorkers.has(worker.workerId)) {
|
|
1028
|
+
this._onlineWorkers.set(worker.workerId, worker);
|
|
1029
|
+
this.emit('workerOnline', worker);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
// ============ Private: Helpers ============
|
|
1036
|
+
_presenceToWorker(actorTokenId, data) {
|
|
1037
|
+
return {
|
|
1038
|
+
workerId: data.workerId,
|
|
1039
|
+
actorTokenId,
|
|
1040
|
+
role: data.role,
|
|
1041
|
+
activeJobs: data.activeJobs ?? 0,
|
|
1042
|
+
concurrency: data.concurrency ?? 1,
|
|
1043
|
+
metadata: data.metadata,
|
|
1044
|
+
joinedAt: Date.now(),
|
|
1045
|
+
isLocal: false,
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
_findWorkerIdByActorId(actorTokenId) {
|
|
1049
|
+
for (const worker of this._onlineWorkers.values()) {
|
|
1050
|
+
if (worker.actorTokenId === actorTokenId)
|
|
1051
|
+
return worker.workerId;
|
|
1052
|
+
}
|
|
1053
|
+
return undefined;
|
|
1054
|
+
}
|
|
1055
|
+
_restoreQueues() {
|
|
1056
|
+
// On reconnect, js-sdk auto-restores subscriptions.
|
|
1057
|
+
// Re-set presence on all active queue rooms.
|
|
1058
|
+
for (const room of this._queues.values()) {
|
|
1059
|
+
room._updateLocalPresence();
|
|
1060
|
+
}
|
|
1061
|
+
// Re-fetch lobby presence
|
|
1062
|
+
this._lobby?.fetchPresence().then((state) => {
|
|
1063
|
+
this._onlineWorkers.clear();
|
|
1064
|
+
this._actorToWorkerId.clear();
|
|
1065
|
+
this._hydrateOnlineWorkers(state);
|
|
1066
|
+
}).catch((err) => {
|
|
1067
|
+
this._log('Failed to re-fetch lobby presence:', err);
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
exports.EventEmitter = EventEmitter;
|
|
1073
|
+
exports.JobStore = JobStore;
|
|
1074
|
+
exports.NoLagQueue = NoLagQueue;
|
|
1075
|
+
exports.PresenceManager = PresenceManager;
|
|
1076
|
+
exports.QueueRoom = QueueRoom;
|
|
1077
|
+
exports.WorkerManager = WorkerManager;
|
|
1078
|
+
//# sourceMappingURL=index.cjs.map
|