@crewhaus/queue-consumer 0.4.0 → 0.5.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/dist/index.d.ts CHANGED
@@ -3,9 +3,18 @@
3
3
  *
4
4
  * Long-running consumer loop. Pulls jobs from any `QueueAdapter`, runs
5
5
  * the user's handler with `concurrency`-bounded parallelism, wraps each
6
- * call in an idempotency-key cache so retries hit cache, and acks /
6
+ * call in an idempotency-key cache so redeliveries hit cache, and acks /
7
7
  * nacks based on the handler's outcome.
8
8
  *
9
+ * Idempotency window: the cache is keyed on the job id alone and holds
10
+ * only SUCCESSFUL results for `idempotencyTtlMs`. It fires when a job that
11
+ * already completed comes back — a swallowed ack, a crash between handler
12
+ * and ack, a visibility lease that expired mid-handler, a competing
13
+ * consumer — and the handler is skipped (`fromCache: true`). A clean
14
+ * pull → success → ack cycle never re-delivers, so a healthy run reports
15
+ * `fromCache: false` throughout; that is the window doing nothing because
16
+ * nothing was duplicated, not the window being inert.
17
+ *
9
18
  * Visibility renewal: while a handler is running, a sidecar timer
10
19
  * extends the job's visibility every `visibilityRenewIntervalMs` until
11
20
  * either the handler completes or the consumer is told to stop. This
@@ -18,6 +27,17 @@
18
27
  * - handler throws + `attempt >= maxRetries` → `nack(permanent)` so
19
28
  * the queue moves the job to its DLQ.
20
29
  * - handler resolves → `ack`.
30
+ * - handler throws `approval_pending` (loop contract 0.4, G11) → DEFER:
31
+ * neither ack nor nack, push the visibility lease out by
32
+ * `deferVisibilityMs`, and do not count the attempt.
33
+ *
34
+ * Why parking is not a retry: a parked job is waiting on a human running
35
+ * `crewhaus approvals grant`, which takes as long as it takes. Counted as a
36
+ * failure — the pre-G11 behaviour — the default 3-retry budget was spent in
37
+ * under a minute and the job dead-lettered long before anyone looked at it,
38
+ * so the approval seam could never actually complete on this shape. The
39
+ * grant is keyed on `(toolName, inputHash)`, so any later re-delivery finds
40
+ * it; the only thing that had to change was not throwing the job away first.
21
41
  *
22
42
  * Drain semantics: `drain()` stops new pulls but lets in-flight handlers
23
43
  * complete + ack. `stop()` is `drain()` plus a wait — used by the
@@ -39,6 +59,19 @@ export type ConsumerHandlerOutcome<TResult> = {
39
59
  kind: "fail";
40
60
  reason: NackReason;
41
61
  error: unknown;
62
+ }
63
+ /**
64
+ * The handler PARKED awaiting a human decision (`approval_pending`) rather
65
+ * than failing. The job is neither ack'd nor nack'd: its visibility lease
66
+ * is pushed out by `deferVisibilityMs`, and when that expires the adapter
67
+ * returns it to pending for a later re-delivery. `defers` is how many times
68
+ * THIS job has parked, and those attempts are excluded from the retry
69
+ * budget — see `handleOne`.
70
+ */
71
+ | {
72
+ kind: "deferred";
73
+ error: unknown;
74
+ defers: number;
42
75
  };
43
76
  export type ConsumerObserver<TInput, TResult> = {
44
77
  onJobStart?(job: Job<TInput>): void;
@@ -60,6 +93,17 @@ export type ConsumerOptions<TInput, TResult> = {
60
93
  readonly idempotencyStore?: IdempotencyStore<TResult>;
61
94
  readonly idempotencyTtlMs?: number;
62
95
  readonly maxRetries?: number;
96
+ /**
97
+ * How long a PARKED job (handler threw `approval_pending`) stays invisible
98
+ * before the queue re-delivers it. Defaults to 60s.
99
+ *
100
+ * A park is not a failure, so the job must come back — but it must not come
101
+ * back IMMEDIATELY. Returning it straight to pending spins the consumer:
102
+ * pull → run a model turn → hit the same ungranted permission → park, over
103
+ * and over, burning a model call per lap while a human is still deciding.
104
+ * The lease push is the backoff.
105
+ */
106
+ readonly deferVisibilityMs?: number;
63
107
  /** Per-pull batch cap. Defaults to `concurrency`. */
64
108
  readonly pullBatchSize?: number;
65
109
  /** Wait between empty-queue pulls. Defaults to 100ms. */
package/dist/index.js CHANGED
@@ -3,9 +3,18 @@
3
3
  *
4
4
  * Long-running consumer loop. Pulls jobs from any `QueueAdapter`, runs
5
5
  * the user's handler with `concurrency`-bounded parallelism, wraps each
6
- * call in an idempotency-key cache so retries hit cache, and acks /
6
+ * call in an idempotency-key cache so redeliveries hit cache, and acks /
7
7
  * nacks based on the handler's outcome.
8
8
  *
9
+ * Idempotency window: the cache is keyed on the job id alone and holds
10
+ * only SUCCESSFUL results for `idempotencyTtlMs`. It fires when a job that
11
+ * already completed comes back — a swallowed ack, a crash between handler
12
+ * and ack, a visibility lease that expired mid-handler, a competing
13
+ * consumer — and the handler is skipped (`fromCache: true`). A clean
14
+ * pull → success → ack cycle never re-delivers, so a healthy run reports
15
+ * `fromCache: false` throughout; that is the window doing nothing because
16
+ * nothing was duplicated, not the window being inert.
17
+ *
9
18
  * Visibility renewal: while a handler is running, a sidecar timer
10
19
  * extends the job's visibility every `visibilityRenewIntervalMs` until
11
20
  * either the handler completes or the consumer is told to stop. This
@@ -18,6 +27,17 @@
18
27
  * - handler throws + `attempt >= maxRetries` → `nack(permanent)` so
19
28
  * the queue moves the job to its DLQ.
20
29
  * - handler resolves → `ack`.
30
+ * - handler throws `approval_pending` (loop contract 0.4, G11) → DEFER:
31
+ * neither ack nor nack, push the visibility lease out by
32
+ * `deferVisibilityMs`, and do not count the attempt.
33
+ *
34
+ * Why parking is not a retry: a parked job is waiting on a human running
35
+ * `crewhaus approvals grant`, which takes as long as it takes. Counted as a
36
+ * failure — the pre-G11 behaviour — the default 3-retry budget was spent in
37
+ * under a minute and the job dead-lettered long before anyone looked at it,
38
+ * so the approval seam could never actually complete on this shape. The
39
+ * grant is keyed on `(toolName, inputHash)`, so any later re-delivery finds
40
+ * it; the only thing that had to change was not throwing the job away first.
21
41
  *
22
42
  * Drain semantics: `drain()` stops new pulls but lets in-flight handlers
23
43
  * complete + ack. `stop()` is `drain()` plus a wait — used by the
@@ -36,12 +56,27 @@ const DEFAULT_VISIBILITY_RENEW_INTERVAL_MS = 5_000;
36
56
  const DEFAULT_IDEMPOTENCY_TTL_MS = 60_000;
37
57
  const DEFAULT_MAX_RETRIES = 3;
38
58
  const DEFAULT_EMPTY_QUEUE_POLL_MS = 100;
59
+ const DEFAULT_DEFER_VISIBILITY_MS = 60_000;
60
+ /**
61
+ * Loop contract 0.4 (G11) — did the handler PARK on a pending approval rather
62
+ * than fail?
63
+ *
64
+ * Structural, not `instanceof`: the error crosses a package boundary (and, in
65
+ * a compiled bundle, possibly a duplicated `@crewhaus/errors` instance), so an
66
+ * identity check would silently fall through to the failure path — which is
67
+ * exactly the bug this function exists to prevent, and it would be invisible.
68
+ */
69
+ function isApprovalPending(err) {
70
+ const report = err?.report;
71
+ return typeof report === "object" && report !== null && report.class === "approval_pending";
72
+ }
39
73
  export function startConsumer(opts) {
40
74
  const visRenewMs = opts.visibilityRenewIntervalMs ?? DEFAULT_VISIBILITY_RENEW_INTERVAL_MS;
41
75
  const idempotencyTtlMs = opts.idempotencyTtlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS;
42
76
  const maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES;
43
77
  const pullBatch = opts.pullBatchSize ?? opts.concurrency;
44
78
  const emptyPollMs = opts.emptyQueuePollMs ?? DEFAULT_EMPTY_QUEUE_POLL_MS;
79
+ const deferVisibilityMs = opts.deferVisibilityMs ?? DEFAULT_DEFER_VISIBILITY_MS;
45
80
  const ts = opts._setTimeout ?? setTimeout;
46
81
  const tc = opts._clearTimeout ?? clearTimeout;
47
82
  const wrappedHandler = opts.idempotencyStore
@@ -50,6 +85,14 @@ export function startConsumer(opts) {
50
85
  let stopping = false;
51
86
  let drainPromise;
52
87
  const inFlight = new Set();
88
+ /**
89
+ * jobId → how many of its deliveries ended in an approval park. Subtracted
90
+ * from `job.attempt` so waiting on a human never advances a job toward the
91
+ * DLQ. Cleared when the job reaches a terminal state (ack / permanent
92
+ * nack), so this holds at most one integer per job currently awaiting
93
+ * approval — a job parked forever is the intended outcome, not a leak.
94
+ */
95
+ const deferCounts = new Map();
53
96
  // Pull loop runs as a background async function. It awaits available
54
97
  // concurrency before pulling the next batch.
55
98
  const loopPromise = (async () => {
@@ -91,7 +134,14 @@ export function startConsumer(opts) {
91
134
  });
92
135
  async function handleOne(job) {
93
136
  opts.observer?.onJobStart?.(job);
94
- const key = idempotencyKey(job.id, job.attempt);
137
+ // Key on the job's IDENTITY only. Including `job.attempt` made the
138
+ // idempotency window unreachable: a redelivery — the one case the cache
139
+ // exists for — always arrives with a bumped attempt (the in-memory and
140
+ // postgres adapters increment it on every pull), so every lookup missed
141
+ // and `idempotencyTtlMs` was dead configuration. Failed attempts cache
142
+ // nothing (see `withIdempotency`), so a retry after a genuine failure
143
+ // still re-runs the handler.
144
+ const key = idempotencyKey(job.id);
95
145
  const stopRenew = startVisibilityRenew(opts.queue, job.id, visRenewMs, ts, tc);
96
146
  let outcome;
97
147
  try {
@@ -101,17 +151,34 @@ export function startConsumer(opts) {
101
151
  outcome = { kind: "ok", value: r.value, fromCache: r.fromCache };
102
152
  }
103
153
  catch (err) {
104
- const isLast = job.attempt >= maxRetries;
105
- outcome = {
106
- kind: "fail",
107
- reason: isLast ? "permanent" : "transient",
108
- error: err,
109
- };
154
+ if (isApprovalPending(err)) {
155
+ // A park is NOT a failure — it is a run waiting on a human, and the
156
+ // only thing that can resolve it is someone running `crewhaus
157
+ // approvals grant`. Counting it against `maxRetries` meant a job
158
+ // almost always dead-lettered before that happened: with the default
159
+ // 3 retries and a visibility timeout in the tens of seconds, the
160
+ // budget was spent in under a minute.
161
+ const defers = (deferCounts.get(job.id) ?? 0) + 1;
162
+ deferCounts.set(job.id, defers);
163
+ outcome = { kind: "deferred", error: err, defers };
164
+ }
165
+ else {
166
+ // Attempts spent parking do not count toward the retry budget, so a
167
+ // job that parked twice and then genuinely fails still gets its full
168
+ // `maxRetries` worth of real attempts.
169
+ const effectiveAttempt = job.attempt - (deferCounts.get(job.id) ?? 0);
170
+ outcome = {
171
+ kind: "fail",
172
+ reason: effectiveAttempt >= maxRetries ? "permanent" : "transient",
173
+ error: err,
174
+ };
175
+ }
110
176
  }
111
177
  finally {
112
178
  stopRenew();
113
179
  }
114
180
  if (outcome.kind === "ok") {
181
+ deferCounts.delete(job.id);
115
182
  try {
116
183
  await opts.queue.ack(job.id);
117
184
  }
@@ -120,7 +187,26 @@ export function startConsumer(opts) {
120
187
  // userland; they'd surface as duplicate work on the next pull.
121
188
  }
122
189
  }
190
+ else if (outcome.kind === "deferred") {
191
+ // Neither ack nor nack: acking would drop the job on the floor, and
192
+ // nacking would either burn the budget (transient) or dead-letter it
193
+ // (permanent). Instead push the lease out — every adapter already
194
+ // implements `extendVisibility`, so this needs no protocol change and
195
+ // behaves the same on in-memory, SQS, Redis and Postgres. When the
196
+ // lease expires the adapter returns the job to pending on its own.
197
+ try {
198
+ await opts.queue.extendVisibility(job.id, deferVisibilityMs);
199
+ }
200
+ catch {
201
+ // If the lease can no longer be extended the job has already been
202
+ // reclaimed — it will be re-delivered anyway, which is the same
203
+ // outcome, just sooner.
204
+ }
205
+ }
123
206
  else {
207
+ // Terminal for this job: stop tracking its parks.
208
+ if (outcome.reason === "permanent")
209
+ deferCounts.delete(job.id);
124
210
  try {
125
211
  await opts.queue.nack(job.id, outcome.reason);
126
212
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/queue-consumer",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Long-running consumer loop for the BATCH target — visibility-timeout-aware, SIGTERM-drains (Section 23 BATCH)",
6
6
  "main": "dist/index.js",
@@ -15,9 +15,9 @@
15
15
  "test": "bun test src"
16
16
  },
17
17
  "dependencies": {
18
- "@crewhaus/errors": "0.4.0",
19
- "@crewhaus/idempotency-keys": "0.4.0",
20
- "@crewhaus/queue-protocol": "0.4.0"
18
+ "@crewhaus/errors": "0.5.0",
19
+ "@crewhaus/idempotency-keys": "0.5.0",
20
+ "@crewhaus/queue-protocol": "0.5.0"
21
21
  },
22
22
  "license": "Apache-2.0",
23
23
  "author": {