๐Ÿ“ฆ EqualifyEverything / equalify-iris

๐Ÿ“„ queue.ts ยท 129 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129// A bounded, FIFO queue for pipeline runs.
//
// Runs used to start with a bare `void runPipeline(...)` straight out of the
// request handler, so N simultaneous uploads meant N unthrottled pipelines: N
// jsdom+axe instances, and N ร— extraction_concurrency model calls in flight.
// Nothing in the process bounded that. On a single-machine deployment โ€” a
// laptop, a Mac Mini, a self-hosted box, all first-class targets (README, "One
// machine, no vendor lock-in") โ€”
// the failure mode is the whole service degrading for everyone at once instead
// of one run taking longer.
//
// Two design choices are worth stating, because both take the expensive-but-safe
// direction the rest of the pipeline takes:
//
//   * Over the cap a session WAITS; it is not rejected. The upload has already
//     been received and written to disk, so a 429 would discard work the user
//     has already paid for (potentially a 25-page PDF) to save a few seconds of
//     queueing. Waiting sessions sit in the `queued` status the store has always
//     had โ€” a value that, until now, no client could observe for more than an
//     instant.
//   * The cap is GLOBAL, not per user. The resources it protects โ€” memory, jsdom
//     instances, the provider's rate limit โ€” are global, so a per-user cap would
//     let ten users each start a run and still exhaust the machine. The cost is
//     fairness: order is strictly FIFO, so a burst from one user delays others.
//     For a single-instance deployment whose operator knows its users that is
//     the right trade; per-user fairness needs a real scheduler, not a counter.
//
// Note what this canNOT bound: multer has already buffered the entire upload in
// memory by the time a handler runs (it parses the body before the route sees
// it), so no gate here reduces that. The upload-side ceiling is set by multer's
// own `limits` in routes/sessions.ts.

export interface QueueStats {
  /** Runs executing right now. Never more than `limit`. */
  running: number;
  /** Runs admitted but not yet started. */
  waiting: number;
  /** The configured cap (`defaults.max_concurrent_runs`). */
  limit: number;
}

interface Job {
  task: () => Promise<unknown>;
  onStart?: () => void;
  settle: () => void;
}

export class RunQueue {
  readonly limit: number;
  private active = 0;
  private pending: Job[] = [];
  private onError: (error: unknown) => void;

  constructor(limit: number, onError?: (error: unknown) => void) {
    // The caller normalizes (config.normalizeMaxConcurrentRuns), but a queue
    // whose limit is 0 would silently accept work and never run it โ€” the worst
    // possible failure here โ€” so refuse to construct one.
    this.limit = Math.max(1, Math.floor(limit) || 1);
    // A task that rejects is a bug: runPipeline handles its own failures and
    // records them on the session. Log it rather than swallowing it, but never
    // let it propagate โ€” see submit().
    this.onError = onError ?? ((e) => console.error("run queue task threw:", e));
  }

  /**
   * Admit a run. Returns immediately: the returned promise settles when the task
   * finishes and **always resolves**, including when the task throws. That
   * matters because every call site is `void queue.submit(...)`, and `void` does
   * not attach a rejection handler โ€” a rejecting promise there would surface as
   * an unhandled rejection and take the process down with it.
   *
   * `onStart` fires when the job actually leaves the queue, which is where the
   * caller records how long it waited.
   */
  submit(task: () => Promise<unknown>, onStart?: () => void): Promise<void> {
    return new Promise<void>((settle) => {
      this.pending.push({ task, onStart, settle });
      this.pump();
    });
  }

  stats(): QueueStats {
    return { running: this.active, waiting: this.pending.length, limit: this.limit };
  }

  private pump(): void {
    // A loop rather than a single `if`. Today it never iterates twice โ€” submit()
    // pumps once per push and the finally below frees exactly one slot โ€” so this
    // is defensive, not load-bearing, and no test can tell the two apart. It is
    // written this way so pump() stays correct as "drain whatever slots are free"
    // rather than "start at most one job", which is what a later caller (a
    // resizable limit, a batch admit) would need and would otherwise silently not
    // get.
    while (this.active < this.limit && this.pending.length > 0) {
      const job = this.pending.shift()!;
      this.active++;
      try {
        job.onStart?.();
      } catch (e) {
        // Observability must never cost a slot or drop a run.
        this.onError(e);
      }
      // Invoked directly rather than through Promise.resolve().then(...) so the
      // task's SYNCHRONOUS prefix runs before submit() returns. runPipeline
      // writes `status: "running"` there, and deferring it by even a microtask
      // would leave a window where the response says a run started and the store
      // still says `queued`. A synchronous throw is converted to a rejection so
      // both failure modes take the same path.
      let settled: Promise<unknown>;
      try {
        settled = Promise.resolve(job.task());
      } catch (e) {
        settled = Promise.reject(e);
      }
      // Deliberately not awaited. The .finally() is the only place `active` is
      // decremented, so a throwing task cannot leak a slot and wedge the queue
      // at capacity forever โ€” which would leave every later session stuck in
      // `queued` with no error anywhere to explain it.
      void settled
        .catch((e) => this.onError(e))
        .finally(() => {
          this.active--;
          job.settle();
          this.pump();
        });
    }
  }
}