@babelqueue/redis 1.0.0 → 1.2.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 CHANGED
@@ -8,11 +8,14 @@ the same wire contract as the PHP, Python and Go SDKs. Implements
8
8
  Redis lists carry **no native metadata** — the list element **is** the canonical envelope JSON,
9
9
  byte-for-byte, with **no wrapping** (unlike `@babelqueue/bullmq`, which uses BullMQ's own job
10
10
  layout). Produce is `RPUSH`; consume reserves the head into a `<queue>:processing` list
11
- (`BRPOPLPUSH`, so an in-flight message survives a crash), routes by URN, and `LREM`s it on success.
11
+ (`LMOVE`, so an in-flight message survives a crash), routes by URN, and acknowledges it on success.
12
12
 
13
- > This is a Node-owned reliable queue. Full parity with Laravel's reserved-sorted-set reservation
14
- > on a *shared* Redis queue is a separate task — for a mixed PHP+Node fleet, prefer a queue this
15
- > consumer owns end-to-end.
13
+ Two layouts are available ([ADR-0033](https://babelqueue.com/docs/spec/1.x/broker-bindings#redis)):
14
+
15
+ - **`legacy`** (default) — the Node-owned `:processing` reliable queue, now with deadline recovery.
16
+ - **`zset`** — the Laravel-compatible `queues:<q>` / `:delayed` / `:reserved` / `:notify` layout, so a
17
+ Node worker can share a queue with a Laravel producer or worker, with native delays and
18
+ `retry_after` recovery.
16
19
 
17
20
  ## Install
18
21
 
@@ -21,7 +24,9 @@ npm install @babelqueue/redis ioredis
21
24
  ```
22
25
 
23
26
  `ioredis` is an optional peer — you provide the client; an `ioredis` instance satisfies the
24
- adapter structurally.
27
+ adapter structurally. Consuming also needs `evalBuffer` (Lua eval returning raw `Buffer`s, so a
28
+ non-UTF-8 body can be detected as poison); without it `reserve()` throws. A non-`ioredis` client
29
+ must provide it.
25
30
 
26
31
  ## Produce
27
32
 
@@ -54,10 +59,51 @@ const consumer = new RedisConsumer(client, "orders", handlers, {
54
59
  await consumer.run(() => true); // reserve → process → LREM, until you stop it
55
60
  ```
56
61
 
57
- A successful handler `LREM`s the element from `<queue>:processing`. A throwing handler requeues
58
- the envelope with `attempts + 1` (at-least-once) up to `maxTries`, then dead-letters to
59
- `<queue>.dlq` with a `dead_letter` block. Unknown-URN strategy is one of
60
- `fail` / `delete` / `release` / `dead_letter`. `poll()` and `handle(raw)` are exposed for testing.
62
+ A successful handler acknowledges the reservation. A throwing handler gives the envelope back with
63
+ `attempts + 1` (at-least-once) up to `maxTries`, then dead-letters to `<queue>.dlq` with a
64
+ `dead_letter` block. Unknown-URN strategy is one of `fail` / `delete` / `release` / `dead_letter`; the default `fail` reports the error and retries with `attempts + 1` up to `maxTries`, then dead-letters (`reason: "unknown_urn"`).
65
+ `poll()` and `handle(reservation)` are exposed for testing.
66
+
67
+ `blockTimeout` (seconds, default 5) is how long one `poll()` waits: a sleep-poll (100 ms, doubling,
68
+ capped at 1 s) in the legacy layout, `BLPOP` on `:notify` (at most 1 s per slice, migrating between
69
+ slices) in the zset layout. `0` or less means a single non-blocking attempt.
70
+
71
+ ## Zset layout, delays and recovery
72
+
73
+ ```ts
74
+ const publisher = RedisPublisher.create(client, "orders", { layout: "zset" });
75
+ await publisher.publish("urn:babel:orders:created", { order_id: 1 }, { delay: 30 }); // ZADD :delayed
76
+
77
+ const consumer = new RedisConsumer(client, "orders", handlers, {
78
+ layout: "zset",
79
+ retryAfter: 90, // seconds before an unacked reservation is recovered; null disables it
80
+ migrationBatchSize: -1, // max items moved per migrate (-1 = unlimited)
81
+ });
82
+ ```
83
+
84
+ - An immediate publish is `RPUSH queues:<q>` plus one `RPUSH queues:<q>:notify 1`; a delayed one is
85
+ `ZADD queues:<q>:delayed floor(now)+ceil(delay)` (no notify token).
86
+ - Every pop first migrates due `:delayed` items, then expired `:reserved` items (skipped when
87
+ `retryAfter` is `null`), to the end of the ready list. A recovery counts as an attempt.
88
+ - Reserve is one Lua script: it increments `attempts` in the stored reservation, while the handler
89
+ still sees the body you published. A body that is not JSON, or whose `attempts` is not a number
90
+ (poison), is dead-lettered to `<queue>.dlq` and removed from `:reserved`.
91
+ - A release is one Lua script (`ZREM :reserved` + `ZADD :delayed`) that raises `attempts` by one and
92
+ preserves every other key. Integers above 2^53 lose precision in that rewrite (`JSON.parse`).
93
+ - Cluster hash tags are the operator's responsibility (pass them in the queue name).
94
+ - The handler timeout SHOULD be shorter than `retryAfter`, otherwise a slow handler is redelivered
95
+ while still running. A negative `retryAfter` is rejected with a `BabelQueueError`.
96
+ - **No headers in the zset layout.** The Laravel wire has no place for the `__bq_frame` wrapper, so
97
+ `headers` (including `traceparent`) are dropped on publish and the handler's third argument is
98
+ always `{}` (A3). Use the legacy layout when trace-context propagation matters.
99
+
100
+ ## Legacy recovery
101
+
102
+ Each reservation also writes a member `<32 hex token>:<raw>` into the `<queue>:processing:deadlines`
103
+ sorted set (score `floor(now)+retryAfter`; `+inf` when `retryAfter` is `null`). A sweep before every
104
+ reserve moves expired entries back to the ready list byte-for-byte (no `attempts` change), so a
105
+ crashed consumer's message is not lost. An ack that arrives after the sweep touches nothing and logs
106
+ a "reservation expired" warning. Tokens come from `crypto.randomBytes`.
61
107
 
62
108
  ## Contract mapping (§1)
63
109
 
@@ -65,13 +111,39 @@ the envelope with `attempts + 1` (at-least-once) up to `maxTries`, then dead-let
65
111
  | :--- | :--- |
66
112
  | body | the list element — the canonical envelope JSON, **verbatim** (no wrapping) |
67
113
  | produce | `RPUSH <queue> <envelope>` |
68
- | reserve | `BRPOPLPUSH <queue> <queue>:processing` (crash-safe) |
69
- | ack | `LREM <queue>:processing 1 <element>` |
70
- | retry | requeue with `attempts + 1` (the body owns the count) |
114
+ | reserve | legacy: `LMOVE <queue> <queue>:processing LEFT RIGHT` + deadline; zset: the Laravel reserve script |
115
+ | ack | legacy: `ZREM` deadline, then `LREM <queue>:processing 1 <element>`; zset: `ZREM :reserved` |
116
+ | retry | give back with `attempts + 1` (the body owns the count) |
71
117
  | dead-letter | `RPUSH <queue>.dlq` + `dead_letter` block |
72
118
 
73
- The envelope is unchanged (`schema_version` stays `1`). The `ioredis` client is replaced with a
74
- fake in the unit suite — no Redis, no network.
119
+ The envelope is unchanged (`schema_version` stays `1`). The unit suite uses a fake client; the Lua
120
+ scripts and the conformance `redis_layout` cases are exercised against a real Redis
121
+ (`BABELQUEUE_REDIS_URL`, default `redis://127.0.0.1:6379`). With `BQ_REQUIRE_REDIS` set an
122
+ unreachable Redis fails the run; without it those tests are skipped.
123
+
124
+ When `BQ_CONFORMANCE_RESULTS` is set, the conformance run writes `results.redis.json` beside the
125
+ given path (the package slug is inserted so several packages sharing one value do not overwrite each
126
+ other).
127
+
128
+ ## OpenTelemetry tracing (ADR-0028)
129
+
130
+ Cross-hop **span** linkage rides on the out-of-band `HeaderCarrier` from
131
+ [`@babelqueue/core@^1.4.0`](https://www.npmjs.com/package/@babelqueue/core). Pass the carrier produced by
132
+ `@babelqueue/core/otel`'s `publish` to this adapter's `publish({ headers })`; it is carried on
133
+ a transport-owned `__bq_frame` JSON frame in the **legacy** layout (Redis lists have no native metadata channel; the `LREM` ack handle *is* the stored value). On consume, the consumer surfaces a delivered message's headers to the handler's third argument (and a `headersOf(...)` extractor reads them back),
134
+ so the core's `otel` `wrapHandler` starts the consumer span as a true **child** of the producer span.
135
+
136
+ A bare (pre-0028 / cross-version) value consumes with empty headers, and a header-less publish stays byte-identical.
137
+
138
+ ```ts
139
+ import { trace } from "@opentelemetry/api";
140
+ import { publish as tracedPublish } from "@babelqueue/core/otel";
141
+ import type { HeaderCarrier } from "@babelqueue/core";
142
+
143
+ const headers: HeaderCarrier = {};
144
+ await tracedPublish(trace.getTracer("orders"), urn, data,
145
+ () => adapterPublish(urn, data, { headers }), { headers });
146
+ ```
75
147
 
76
148
  ## License
77
149