@crewhaus/queue-consumer 0.4.0 → 0.4.2
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 +45 -1
- package/dist/index.js +94 -8
- package/package.json +4 -4
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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.
|
|
3
|
+
"version": "0.4.2",
|
|
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.
|
|
19
|
-
"@crewhaus/idempotency-keys": "0.4.
|
|
20
|
-
"@crewhaus/queue-protocol": "0.4.
|
|
18
|
+
"@crewhaus/errors": "0.4.2",
|
|
19
|
+
"@crewhaus/idempotency-keys": "0.4.2",
|
|
20
|
+
"@crewhaus/queue-protocol": "0.4.2"
|
|
21
21
|
},
|
|
22
22
|
"license": "Apache-2.0",
|
|
23
23
|
"author": {
|