@forgezero/runtime 0.1.3 → 0.1.4

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 CHANGED
@@ -43,7 +43,9 @@ ArangoDB unique claim, while another caller may use any database or no database.
43
43
  ```ts
44
44
  import { createQueue } from '@forgezero/runtime/queue';
45
45
 
46
- const queue = createQueue({ width: 8 });
46
+ // Default admission is 60% of reported logical CPUs (with one reserved).
47
+ // Use `width` for an exact ceiling, or an explicit dynamic resource policy:
48
+ const queue = createQueue({ resources: { percent: 60, reserve: 1, max: 32 } });
47
49
  const task = queue.run('tenant-a:wallet-7', transfer, amount, destination);
48
50
  const receipt = await task.result;
49
51
 
@@ -55,6 +57,25 @@ queue.cancel(task.id); // pending task only
55
57
  await queue.stop(30_000); // close intake and drain all work
56
58
  ```
57
59
 
60
+ Different async keys overlap immediately. CPU-heavy JavaScript does not become
61
+ multi-core merely by entering a queue: put that handler in Bun/standard Workers
62
+ and await the Worker result from the queue.
63
+
64
+ Jobs accept intervals down to seconds or a local wall-clock schedule with an
65
+ IANA timezone and weekday filter. `overlap: 'wait'` (the default) schedules the
66
+ next run after completion; `overlap: 'skip'` keeps clock cadence and drops a tick
67
+ when the same key is still busy. Same-key overlap is never allowed.
68
+
69
+ ```ts
70
+ defineJob({
71
+ key: 'tenant:acme:invoice',
72
+ label: 'Monthly invoice preparation',
73
+ schedule: { timezone: 'Asia/Kolkata', time: '00:00:15', weekdays: [1] },
74
+ overlap: 'skip',
75
+ run: async ({ signal }) => generateInvoices({ signal })
76
+ });
77
+ ```
78
+
58
79
  ## Three things worth knowing before you use it
59
80
 
60
81
  **Money is never a number.** An amount is minor units as a `bigint` with its
package/dist/audit.js CHANGED
@@ -33,10 +33,41 @@ var DEFAULT_RETRY = {
33
33
  attempts: 1,
34
34
  backoffMs: (attempt) => Math.min(30000, 2 ** attempt * 100)
35
35
  };
36
+ var reportedParallelism = () => {
37
+ const reported = globalThis.navigator?.hardwareConcurrency;
38
+ return Number.isSafeInteger(reported) && reported > 0 ? reported : 1;
39
+ };
40
+ function queueWidthFor(policy = {}) {
41
+ const percent = policy.percent ?? 60;
42
+ const reserve = policy.reserve ?? 1;
43
+ const min = policy.min ?? 1;
44
+ const max = policy.max ?? Number.MAX_SAFE_INTEGER;
45
+ const available = (policy.available ?? reportedParallelism)();
46
+ if (!Number.isFinite(percent) || percent <= 0 || percent > 100) {
47
+ throw new RangeError("queue: resource percent must be greater than 0 and at most 100");
48
+ }
49
+ for (const [name, value] of [["reserve", reserve], ["min", min], ["max", max]]) {
50
+ if (!Number.isSafeInteger(value) || value < (name === "reserve" ? 0 : 1)) {
51
+ throw new RangeError(`queue: resource ${name} must be ${name === "reserve" ? "a non-negative" : "a positive"} integer`);
52
+ }
53
+ }
54
+ if (!Number.isSafeInteger(available) || available < 1) {
55
+ throw new RangeError("queue: available parallelism must be a positive integer");
56
+ }
57
+ if (min > max)
58
+ throw new RangeError("queue: resource min cannot exceed max");
59
+ const usable = Math.max(1, available - reserve);
60
+ return Math.min(max, Math.max(min, Math.floor(usable * percent / 100)));
61
+ }
36
62
  function createQueue(options = {}) {
37
- const width = options.width ?? 8;
63
+ if (options.width !== undefined && options.resources !== undefined) {
64
+ throw new Error("queue: choose either width or resources, not both");
65
+ }
66
+ const configuredWidth = options.width;
67
+ const widthNow = () => configuredWidth ?? queueWidthFor(options.resources);
68
+ const initialWidth = widthNow();
38
69
  const retry = { ...DEFAULT_RETRY, ...options.retry };
39
- if (!Number.isSafeInteger(width) || width < 1) {
70
+ if (!Number.isSafeInteger(initialWidth) || initialWidth < 1) {
40
71
  throw new RangeError("queue: width must be a positive integer");
41
72
  }
42
73
  if (!Number.isSafeInteger(retry.attempts) || retry.attempts < 1) {
@@ -68,6 +99,7 @@ function createQueue(options = {}) {
68
99
  announceIdle();
69
100
  return;
70
101
  }
102
+ const width = widthNow();
71
103
  for (const [key, lane] of lanes) {
72
104
  if (running.size >= width)
73
105
  break;
@@ -215,6 +247,7 @@ function createQueue(options = {}) {
215
247
  for (const lane of lanes.values())
216
248
  queued += lane.length;
217
249
  return {
250
+ width: widthNow(),
218
251
  running: running.size,
219
252
  queued,
220
253
  keys: lanes.size,
package/dist/jobs.d.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * Zero dependencies. The lock and the cursor store are interfaces, so this runs
21
21
  * against a database, Redis, or nothing at all in a test.
22
22
  */
23
- import { type DrainReport } from './queue';
23
+ import { type DrainReport, type QueueResourcePolicy } from './queue';
24
24
  /** Injected so a test does not sleep and a resumed run is reproducible. */
25
25
  export interface Clock {
26
26
  now(): number;
@@ -29,6 +29,20 @@ export interface Clock {
29
29
  export declare const systemClock: Clock;
30
30
  /** `30s` → 30000. Throws rather than guessing — a wrong interval is silent. */
31
31
  export declare function everyMs(interval: string | number): number;
32
+ export interface WallClockSchedule {
33
+ /** IANA timezone such as `Asia/Kolkata` or `UTC`. */
34
+ timezone: string;
35
+ /** Local wall time, including optional seconds: `HH:MM` or `HH:MM:SS`. */
36
+ time: string;
37
+ /** Optional local weekdays, Sunday=0 through Saturday=6. */
38
+ weekdays?: readonly number[];
39
+ }
40
+ /**
41
+ * Find the next occurrence strictly after `afterMs` in the requested timezone.
42
+ * Intl owns daylight-saving and historical offset rules; the bounded minute
43
+ * scan avoids implementing a second, inevitably-wrong timezone database here.
44
+ */
45
+ export declare function nextWallClockAt(schedule: WallClockSchedule, afterMs: number): number;
32
46
  /**
33
47
  * A lease, not a mutex.
34
48
  *
@@ -80,6 +94,14 @@ export interface JobSpec {
80
94
  label: string;
81
95
  /** `30s`, `5m`, or milliseconds. Omit for a job only ever run by hand. */
82
96
  every?: string | number;
97
+ /** Local daily/weekly schedule. Mutually exclusive with `every`. */
98
+ schedule?: WallClockSchedule;
99
+ /**
100
+ * `wait` schedules the next occurrence after this run settles. `skip` keeps
101
+ * clock time and drops a tick when the previous run is still queued/running.
102
+ * Neither mode overlaps the same key.
103
+ */
104
+ overlap?: 'wait' | 'skip';
83
105
  run(context: JobContext): Promise<JobResult | void>;
84
106
  /**
85
107
  * Lease length. Defaults to four intervals, so a slow run is not evicted the
@@ -107,6 +129,8 @@ export interface JobReport {
107
129
  consecutiveFailures: number;
108
130
  runs: number;
109
131
  skippedLocked: number;
132
+ skippedOverlap: number;
133
+ nextRunAtMs?: number;
110
134
  }
111
135
  export interface SchedulerOptions {
112
136
  jobs: readonly JobSpec[];
@@ -115,6 +139,10 @@ export interface SchedulerOptions {
115
139
  /** Called on every failure. Wire to telemetry; must not throw. */
116
140
  onError?: (key: string, error: unknown) => void;
117
141
  onLog?: (key: string, message: string, detail?: Record<string, unknown>) => void;
142
+ /** Exact lane ceiling. Mutually exclusive with `resources`. */
143
+ width?: number;
144
+ /** Dynamic resource admission for different job keys. */
145
+ resources?: QueueResourcePolicy;
118
146
  }
119
147
  export declare function createScheduler(options: SchedulerOptions): {
120
148
  start(): void;
package/dist/jobs.js CHANGED
@@ -33,10 +33,41 @@ var DEFAULT_RETRY = {
33
33
  attempts: 1,
34
34
  backoffMs: (attempt) => Math.min(30000, 2 ** attempt * 100)
35
35
  };
36
+ var reportedParallelism = () => {
37
+ const reported = globalThis.navigator?.hardwareConcurrency;
38
+ return Number.isSafeInteger(reported) && reported > 0 ? reported : 1;
39
+ };
40
+ function queueWidthFor(policy = {}) {
41
+ const percent = policy.percent ?? 60;
42
+ const reserve = policy.reserve ?? 1;
43
+ const min = policy.min ?? 1;
44
+ const max = policy.max ?? Number.MAX_SAFE_INTEGER;
45
+ const available = (policy.available ?? reportedParallelism)();
46
+ if (!Number.isFinite(percent) || percent <= 0 || percent > 100) {
47
+ throw new RangeError("queue: resource percent must be greater than 0 and at most 100");
48
+ }
49
+ for (const [name, value] of [["reserve", reserve], ["min", min], ["max", max]]) {
50
+ if (!Number.isSafeInteger(value) || value < (name === "reserve" ? 0 : 1)) {
51
+ throw new RangeError(`queue: resource ${name} must be ${name === "reserve" ? "a non-negative" : "a positive"} integer`);
52
+ }
53
+ }
54
+ if (!Number.isSafeInteger(available) || available < 1) {
55
+ throw new RangeError("queue: available parallelism must be a positive integer");
56
+ }
57
+ if (min > max)
58
+ throw new RangeError("queue: resource min cannot exceed max");
59
+ const usable = Math.max(1, available - reserve);
60
+ return Math.min(max, Math.max(min, Math.floor(usable * percent / 100)));
61
+ }
36
62
  function createQueue(options = {}) {
37
- const width = options.width ?? 8;
63
+ if (options.width !== undefined && options.resources !== undefined) {
64
+ throw new Error("queue: choose either width or resources, not both");
65
+ }
66
+ const configuredWidth = options.width;
67
+ const widthNow = () => configuredWidth ?? queueWidthFor(options.resources);
68
+ const initialWidth = widthNow();
38
69
  const retry = { ...DEFAULT_RETRY, ...options.retry };
39
- if (!Number.isSafeInteger(width) || width < 1) {
70
+ if (!Number.isSafeInteger(initialWidth) || initialWidth < 1) {
40
71
  throw new RangeError("queue: width must be a positive integer");
41
72
  }
42
73
  if (!Number.isSafeInteger(retry.attempts) || retry.attempts < 1) {
@@ -68,6 +99,7 @@ function createQueue(options = {}) {
68
99
  announceIdle();
69
100
  return;
70
101
  }
102
+ const width = widthNow();
71
103
  for (const [key, lane] of lanes) {
72
104
  if (running.size >= width)
73
105
  break;
@@ -215,6 +247,7 @@ function createQueue(options = {}) {
215
247
  for (const lane of lanes.values())
216
248
  queued += lane.length;
217
249
  return {
250
+ width: widthNow(),
218
251
  running: running.size,
219
252
  queued,
220
253
  keys: lanes.size,
@@ -297,6 +330,53 @@ function everyMs(interval) {
297
330
  throw new Error(`"${interval}" is not an interval like 30s, 5m, 1h, 1d.`);
298
331
  return Number(match[1]) * UNITS[match[2]];
299
332
  }
333
+ var WEEKDAY = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
334
+ function wallTime(schedule) {
335
+ const match = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(schedule.time);
336
+ if (!match)
337
+ throw new Error(`"${schedule.time}" is not a wall time like 09:30 or 09:30:15.`);
338
+ const hour = Number(match[1]);
339
+ const minute = Number(match[2]);
340
+ const second = Number(match[3] ?? 0);
341
+ if (hour > 23 || minute > 59 || second > 59)
342
+ throw new Error(`"${schedule.time}" is not a valid wall time.`);
343
+ return { hour, minute, second };
344
+ }
345
+ function nextWallClockAt(schedule, afterMs) {
346
+ if (!Number.isFinite(afterMs))
347
+ throw new RangeError("job: schedule start must be finite");
348
+ const target = wallTime(schedule);
349
+ const weekdays = schedule.weekdays ? new Set(schedule.weekdays) : undefined;
350
+ if (weekdays?.size === 0 || [...weekdays ?? []].some((day) => !Number.isSafeInteger(day) || day < 0 || day > 6)) {
351
+ throw new Error("job: weekdays must contain Sunday=0 through Saturday=6");
352
+ }
353
+ let formatter;
354
+ try {
355
+ formatter = new Intl.DateTimeFormat("en-US", {
356
+ timeZone: schedule.timezone,
357
+ hour: "2-digit",
358
+ minute: "2-digit",
359
+ second: "2-digit",
360
+ weekday: "short",
361
+ hourCycle: "h23"
362
+ });
363
+ } catch {
364
+ throw new Error(`job: unknown IANA timezone "${schedule.timezone}"`);
365
+ }
366
+ const minuteFloor = Math.floor(afterMs / 60000) * 60000;
367
+ let candidate = minuteFloor + target.second * 1000;
368
+ if (candidate <= afterMs)
369
+ candidate += 60000;
370
+ for (let checked = 0;checked < 8 * 24 * 60; checked += 1, candidate += 60000) {
371
+ const parts = Object.fromEntries(formatter.formatToParts(candidate).map((part) => [part.type, part.value]));
372
+ if (Number(parts.hour) !== target.hour || Number(parts.minute) !== target.minute || Number(parts.second) !== target.second)
373
+ continue;
374
+ const weekday = WEEKDAY[parts.weekday];
375
+ if (!weekdays || weekdays.has(weekday))
376
+ return candidate;
377
+ }
378
+ throw new Error("job: no matching wall-clock occurrence was found in the next eight days");
379
+ }
300
380
  function memoryLock(clock = systemClock) {
301
381
  const held = new Map;
302
382
  let fences = 0;
@@ -335,6 +415,10 @@ function defineJob(spec) {
335
415
  throw new Error("A job needs a key — it is the lock key and the report key.");
336
416
  if (spec.every !== undefined)
337
417
  everyMs(spec.every);
418
+ if (spec.every !== undefined && spec.schedule)
419
+ throw new Error("A job must choose either every or schedule.");
420
+ if (spec.schedule)
421
+ nextWallClockAt(spec.schedule, Date.now());
338
422
  return spec;
339
423
  }
340
424
  function spreadOf(key, ceiling) {
@@ -349,16 +433,29 @@ function createScheduler(options) {
349
433
  const jobs = new Map(options.jobs.map((job) => [job.key, job]));
350
434
  const reports = new Map(options.jobs.map((job) => [
351
435
  job.key,
352
- { key: job.key, label: job.label, state: "stopped", consecutiveFailures: 0, runs: 0, skippedLocked: 0 }
436
+ { key: job.key, label: job.label, state: "stopped", consecutiveFailures: 0, runs: 0, skippedLocked: 0, skippedOverlap: 0 }
353
437
  ]));
354
438
  const timers = new Map;
355
- let work = createQueue({ width: Math.max(1, jobs.size) });
439
+ const queueOptions = options.width !== undefined ? { width: options.width } : options.resources !== undefined ? { resources: options.resources } : { width: Math.max(1, jobs.size) };
440
+ let work = createQueue(queueOptions);
356
441
  let workStopped = false;
357
442
  let restartBlocked = false;
358
443
  let controller = new AbortController;
359
444
  let paused = false;
360
445
  let running = false;
361
- const submit = (job) => work.run(job.key, execute, job).result;
446
+ const outstanding = new Map;
447
+ const submit = async (job) => {
448
+ outstanding.set(job.key, (outstanding.get(job.key) ?? 0) + 1);
449
+ try {
450
+ await work.run(job.key, execute, job).result;
451
+ } finally {
452
+ const left = (outstanding.get(job.key) ?? 1) - 1;
453
+ if (left === 0)
454
+ outstanding.delete(job.key);
455
+ else
456
+ outstanding.set(job.key, left);
457
+ }
458
+ };
362
459
  async function execute(job) {
363
460
  const report = reports.get(job.key);
364
461
  const leaseMs = job.leaseMs ?? (job.every ? everyMs(job.every) * 4 : 60000);
@@ -397,17 +494,37 @@ function createScheduler(options) {
397
494
  });
398
495
  }
399
496
  }
497
+ function nextDelay(job) {
498
+ if (job.every !== undefined)
499
+ return everyMs(job.every);
500
+ if (job.schedule)
501
+ return Math.max(0, nextWallClockAt(job.schedule, clock.now()) - clock.now());
502
+ return;
503
+ }
400
504
  function schedule(job, delayMs) {
401
- if (!running || job.every === undefined)
505
+ const next = delayMs ?? nextDelay(job);
506
+ if (!running || next === undefined)
402
507
  return;
508
+ reports.get(job.key).nextRunAtMs = clock.now() + next;
403
509
  timers.set(job.key, setTimeout(() => {
404
510
  timers.delete(job.key);
405
511
  if (!running || paused)
406
512
  return;
407
- submit(job).then(() => schedule(job, everyMs(job.every)), () => {
513
+ if (job.overlap === "skip") {
514
+ schedule(job);
515
+ if ((outstanding.get(job.key) ?? 0) > 0) {
516
+ reports.get(job.key).skippedOverlap += 1;
517
+ return;
518
+ }
519
+ submit(job).catch(() => {
520
+ return;
521
+ });
522
+ return;
523
+ }
524
+ submit(job).then(() => schedule(job), () => {
408
525
  return;
409
526
  });
410
- }, delayMs));
527
+ }, next));
411
528
  }
412
529
  return {
413
530
  start() {
@@ -417,7 +534,7 @@ function createScheduler(options) {
417
534
  throw new Error("scheduler: cannot restart after an incomplete drain while abandoned work may still run");
418
535
  }
419
536
  if (workStopped) {
420
- work = createQueue({ width: Math.max(1, jobs.size) });
537
+ work = createQueue(queueOptions);
421
538
  workStopped = false;
422
539
  }
423
540
  running = true;
@@ -425,8 +542,9 @@ function createScheduler(options) {
425
542
  controller = new AbortController;
426
543
  for (const job of jobs.values()) {
427
544
  reports.get(job.key).state = "idle";
428
- const interval = job.every === undefined ? 0 : everyMs(job.every);
429
- schedule(job, job.startDelayMs ?? spreadOf(job.key, Math.min(interval, 30000)));
545
+ const interval = job.every === undefined ? undefined : everyMs(job.every);
546
+ const initial = job.startDelayMs ?? (interval === undefined ? undefined : spreadOf(job.key, Math.min(interval, 30000)));
547
+ schedule(job, initial);
430
548
  }
431
549
  },
432
550
  async stop(deadlineMs = 30000) {
@@ -440,6 +558,8 @@ function createScheduler(options) {
440
558
  restartBlocked = drained.timedOut;
441
559
  for (const report of reports.values())
442
560
  report.state = "stopped";
561
+ for (const report of reports.values())
562
+ report.nextRunAtMs = undefined;
443
563
  return drained;
444
564
  },
445
565
  pause() {
@@ -450,6 +570,7 @@ function createScheduler(options) {
450
570
  for (const report of reports.values()) {
451
571
  if (report.state !== "running")
452
572
  report.state = "paused";
573
+ report.nextRunAtMs = undefined;
453
574
  }
454
575
  },
455
576
  resume() {
@@ -458,7 +579,7 @@ function createScheduler(options) {
458
579
  paused = false;
459
580
  for (const job of jobs.values()) {
460
581
  reports.get(job.key).state = "idle";
461
- schedule(job, 0);
582
+ schedule(job, job.every !== undefined ? 0 : undefined);
462
583
  }
463
584
  },
464
585
  async runNow(key) {
@@ -483,6 +604,8 @@ function cursorJob(spec) {
483
604
  key: spec.key,
484
605
  label: spec.label,
485
606
  every: spec.every,
607
+ schedule: spec.schedule,
608
+ overlap: spec.overlap,
486
609
  leaseMs: spec.leaseMs,
487
610
  unlocked: spec.unlocked,
488
611
  startDelayMs: spec.startDelayMs,
@@ -516,6 +639,7 @@ var VERSION = "0.1.0";
516
639
  export {
517
640
  systemClock,
518
641
  storeLock,
642
+ nextWallClockAt,
519
643
  memoryLock,
520
644
  everyMs,
521
645
  defineJob,
package/dist/queue.d.ts CHANGED
@@ -43,10 +43,33 @@ export interface RetryPolicy {
43
43
  export interface QueueOptions {
44
44
  /** How many keys may run at once. Ordering within a key is unaffected. */
45
45
  width?: number;
46
+ /**
47
+ * Resource-aware admission. Mutually exclusive with `width`.
48
+ *
49
+ * This limits concurrent KEY lanes; it does not claim that an arbitrary
50
+ * JavaScript closure becomes CPU-parallel. Async I/O overlaps naturally.
51
+ * CPU-bound handlers should use Bun/standard Workers and await them here.
52
+ */
53
+ resources?: QueueResourcePolicy;
46
54
  retry?: Partial<RetryPolicy>;
47
55
  /** Retry delay injection. Shutdown deadlines use a cancellable native timer. */
48
56
  sleep?: (ms: number) => Promise<void>;
49
57
  }
58
+ export interface QueueResourcePolicy {
59
+ /** Percentage of the currently reported logical processors. Default 60. */
60
+ percent?: number;
61
+ /** Logical processors kept outside this queue. Default 1 when possible. */
62
+ reserve?: number;
63
+ /** Never admit fewer lanes than this. Default 1. */
64
+ min?: number;
65
+ /** Optional hard ceiling after percentage and reserve are applied. */
66
+ max?: number;
67
+ /**
68
+ * Re-read on every pump, so a container/runtime can expose a changing quota.
69
+ * The default uses `navigator.hardwareConcurrency` and safely falls back to 1.
70
+ */
71
+ available?: () => number;
72
+ }
50
73
  export interface DrainReport {
51
74
  completed: number;
52
75
  failed: number;
@@ -64,6 +87,8 @@ export declare class QueueKeyStoppedError extends Error {
64
87
  export declare class TaskCancelledError extends Error {
65
88
  constructor();
66
89
  }
90
+ /** Resolve a resource policy to a safe positive queue width. */
91
+ export declare function queueWidthFor(policy?: QueueResourcePolicy): number;
67
92
  export declare function createQueue(options?: QueueOptions): {
68
93
  /**
69
94
  * Submit work and await its value.
@@ -93,6 +118,7 @@ export declare function createQueue(options?: QueueOptions): {
93
118
  resume(): void;
94
119
  /** How much is outstanding, for a health endpoint or a drain decision. */
95
120
  snapshot(): {
121
+ width: number;
96
122
  running: number;
97
123
  queued: number;
98
124
  keys: number;
package/dist/queue.js CHANGED
@@ -33,10 +33,41 @@ var DEFAULT_RETRY = {
33
33
  attempts: 1,
34
34
  backoffMs: (attempt) => Math.min(30000, 2 ** attempt * 100)
35
35
  };
36
+ var reportedParallelism = () => {
37
+ const reported = globalThis.navigator?.hardwareConcurrency;
38
+ return Number.isSafeInteger(reported) && reported > 0 ? reported : 1;
39
+ };
40
+ function queueWidthFor(policy = {}) {
41
+ const percent = policy.percent ?? 60;
42
+ const reserve = policy.reserve ?? 1;
43
+ const min = policy.min ?? 1;
44
+ const max = policy.max ?? Number.MAX_SAFE_INTEGER;
45
+ const available = (policy.available ?? reportedParallelism)();
46
+ if (!Number.isFinite(percent) || percent <= 0 || percent > 100) {
47
+ throw new RangeError("queue: resource percent must be greater than 0 and at most 100");
48
+ }
49
+ for (const [name, value] of [["reserve", reserve], ["min", min], ["max", max]]) {
50
+ if (!Number.isSafeInteger(value) || value < (name === "reserve" ? 0 : 1)) {
51
+ throw new RangeError(`queue: resource ${name} must be ${name === "reserve" ? "a non-negative" : "a positive"} integer`);
52
+ }
53
+ }
54
+ if (!Number.isSafeInteger(available) || available < 1) {
55
+ throw new RangeError("queue: available parallelism must be a positive integer");
56
+ }
57
+ if (min > max)
58
+ throw new RangeError("queue: resource min cannot exceed max");
59
+ const usable = Math.max(1, available - reserve);
60
+ return Math.min(max, Math.max(min, Math.floor(usable * percent / 100)));
61
+ }
36
62
  function createQueue(options = {}) {
37
- const width = options.width ?? 8;
63
+ if (options.width !== undefined && options.resources !== undefined) {
64
+ throw new Error("queue: choose either width or resources, not both");
65
+ }
66
+ const configuredWidth = options.width;
67
+ const widthNow = () => configuredWidth ?? queueWidthFor(options.resources);
68
+ const initialWidth = widthNow();
38
69
  const retry = { ...DEFAULT_RETRY, ...options.retry };
39
- if (!Number.isSafeInteger(width) || width < 1) {
70
+ if (!Number.isSafeInteger(initialWidth) || initialWidth < 1) {
40
71
  throw new RangeError("queue: width must be a positive integer");
41
72
  }
42
73
  if (!Number.isSafeInteger(retry.attempts) || retry.attempts < 1) {
@@ -68,6 +99,7 @@ function createQueue(options = {}) {
68
99
  announceIdle();
69
100
  return;
70
101
  }
102
+ const width = widthNow();
71
103
  for (const [key, lane] of lanes) {
72
104
  if (running.size >= width)
73
105
  break;
@@ -215,6 +247,7 @@ function createQueue(options = {}) {
215
247
  for (const lane of lanes.values())
216
248
  queued += lane.length;
217
249
  return {
250
+ width: widthNow(),
218
251
  running: running.size,
219
252
  queued,
220
253
  keys: lanes.size,
@@ -275,6 +308,7 @@ function createQueue(options = {}) {
275
308
  };
276
309
  }
277
310
  export {
311
+ queueWidthFor,
278
312
  createQueue,
279
313
  TaskCancelledError,
280
314
  QueueStoppedError,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
3
3
  "name": "@forgezero/runtime",
4
- "version": "0.1.3",
4
+ "version": "0.1.4",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"