@voltro/plugin-queue 0.53.0 → 0.55.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
@@ -154,6 +154,8 @@ export declare interface QueueIncomingMessage {
154
154
  /** Per-process counters, keyed by topic — the inspect endpoint reads them. */
155
155
  export declare const queueMetrics: () => QueueMetricsSlot;
156
156
 
157
+ /** Per-topic counters the inspect endpoint renders. Cumulative for the life of
158
+ * THIS process — the dashboard's live view, not the alerting source. */
157
159
  declare interface QueueMetricsSlot {
158
160
  consumed: Record<string, number>;
159
161
  dlq: Record<string, number>;
package/dist/index.js CHANGED
@@ -1,27 +1,62 @@
1
- import { Context as e, Effect as t, Layer as n, Schema as r } from "effect";
2
- import { definePlugin as i } from "@voltro/protocol";
3
- import { Kafka as a, logLevel as o } from "kafkajs";
1
+ import { Cause as e, Context as t, Effect as n, Exit as r, Layer as i, Metric as a, Schema as o } from "effect";
2
+ import { definePlugin as s } from "@voltro/protocol";
3
+ import { Kafka as c, logLevel as l } from "kafkajs";
4
+ import { externalSpanFromTraceparent as u, withServerTracer as d } from "@voltro/runtime";
4
5
  //#region src/consumer.ts
5
- var s = Symbol.for("@voltro/plugin-queue:consumers"), c = () => {
6
+ var f = Symbol.for("@voltro/plugin-queue:consumers"), p = () => {
6
7
  let e = globalThis;
7
- return e[s] ??= /* @__PURE__ */ new Map();
8
- }, l = (e) => {
8
+ return e[f] ??= /* @__PURE__ */ new Map();
9
+ }, m = (e) => {
9
10
  if (e.topic.trim() === "") throw Error("defineQueueConsumer: `topic` must be a non-empty name");
10
11
  if (e.maxAttempts !== void 0 && e.maxAttempts < 1) throw Error(`defineQueueConsumer("${e.topic}"): maxAttempts must be >= 1`);
11
- let t = `${e.topic}|${e.groupId ?? ""}`, n = c().get(t);
12
+ let t = `${e.topic}|${e.groupId ?? ""}`, n = p().get(t);
12
13
  if (n !== void 0 && n !== e) throw Error(`defineQueueConsumer: topic '${e.topic}'${e.groupId ? ` group '${e.groupId}'` : ""} is declared twice — two consumers in one group on one topic would split its partitions unpredictably. Use a distinct groupId for an independent consumer.`);
13
- return c().set(t, e), e;
14
- }, u = () => [...c().values()], d = () => {
15
- c().clear();
16
- }, f = (e) => {
14
+ return p().set(t, e), e;
15
+ }, h = () => [...p().values()], g = () => {
16
+ p().clear();
17
+ }, _ = Symbol.for("@voltro/plugin-queue:metrics"), v = () => {
18
+ let e = globalThis;
19
+ return e[_] ??= {
20
+ consumed: {},
21
+ dlq: {},
22
+ retried: {},
23
+ lastError: {},
24
+ produced: {}
25
+ };
26
+ }, y = (e, t, n = 1) => {
27
+ e[t] = (e[t] ?? 0) + n;
28
+ }, b = a.counter("voltro_queue_consumed_total", { description: "Messages a consumer finished with, by topic and outcome (ok | dead-lettered)." }), x = a.counter("voltro_queue_retries_total", { description: "In-process handler retries, by topic. A retry BLOCKS its partition." }), S = a.counter("voltro_queue_produced_total", { description: "Messages produced through the outbox bridge, by topic." }), C = a.gauge("voltro_queue_lag_messages", { description: "Messages between the offset just picked up and the partition high watermark, by topic and partition. Sampled at pickup; holds its last value for an idle or revoked partition." }), w = [], T = /* @__PURE__ */ new Map(), E = /* @__PURE__ */ new Map(), D = /* @__PURE__ */ new Map(), O = /* @__PURE__ */ new Map(), k = (e, t, n) => {
29
+ let r = e.get(t);
30
+ if (r !== void 0) return r;
31
+ let i = n();
32
+ return e.set(t, i), i;
33
+ }, A = (e) => {
34
+ y(v().consumed, e), k(T, `ok\u0000${e}`, () => a.tagged(a.tagged(b, "topic", e), "outcome", "ok")).unsafeUpdate(1, w);
35
+ }, j = (e, t) => {
36
+ let n = v();
37
+ y(n.dlq, e), n.lastError[e] = t.slice(0, 500), k(T, `dlq\u0000${e}`, () => a.tagged(a.tagged(b, "topic", e), "outcome", "dead-lettered")).unsafeUpdate(1, w);
38
+ }, M = (e) => {
39
+ y(v().retried, e), k(E, e, () => a.tagged(x, "topic", e)).unsafeUpdate(1, w);
40
+ }, N = (e, t) => {
41
+ t <= 0 || (y(v().produced, e, t), k(D, e, () => a.tagged(S, "topic", e)).unsafeUpdate(t, w));
42
+ }, P = (e, t, n, r) => {
43
+ let i;
44
+ try {
45
+ let e = BigInt(n) - BigInt(r) - 1n;
46
+ i = e < 0n ? 0 : Number(e);
47
+ } catch {
48
+ return;
49
+ }
50
+ k(O, `${e}\u0000${t}`, () => a.tagged(a.tagged(C, "topic", e), "partition", String(t))).unsafeUpdate(i, w);
51
+ }, F = (e) => {
17
52
  let t = {};
18
53
  for (let [n, r] of Object.entries(e ?? {})) r != null && (t[n] = Buffer.isBuffer(r) ? r.toString("utf8") : String(r));
19
54
  return t;
20
- }, p = (e) => {
21
- let t = new a({
55
+ }, I = (e) => {
56
+ let t = new c({
22
57
  clientId: e.clientId ?? "voltro",
23
58
  brokers: [...e.brokers],
24
- logLevel: o.ERROR,
59
+ logLevel: l.ERROR,
25
60
  ...e.ssl === void 0 ? {} : { ssl: e.ssl },
26
61
  ...e.sasl === void 0 ? {} : { sasl: e.sasl }
27
62
  }), n = null, r = /* @__PURE__ */ new Set();
@@ -78,10 +113,10 @@ var s = Symbol.for("@voltro/plugin-queue:consumers"), c = () => {
78
113
  offset: s.offset,
79
114
  key: s.key === null ? null : s.key.toString("utf8"),
80
115
  value: s.value === null ? null : s.value.toString("utf8"),
81
- headers: f(s.headers),
116
+ headers: F(s.headers),
82
117
  timestamp: s.timestamp
83
118
  };
84
- await n.onMessage(c, {
119
+ P(e.topic, e.partition, e.highWatermark, s.offset), await n.onMessage(c, {
85
120
  heartbeat: i,
86
121
  isStale: o
87
122
  }), t(s.offset), await r();
@@ -92,123 +127,134 @@ var s = Symbol.for("@voltro/plugin-queue:consumers"), c = () => {
92
127
  } };
93
128
  }
94
129
  };
95
- }, m = Symbol.for("@voltro/plugin-queue:metrics"), h = () => {
96
- let e = globalThis;
97
- return e[m] ??= {
98
- consumed: {},
99
- dlq: {},
100
- retried: {},
101
- lastError: {},
102
- produced: {}
103
- };
104
- }, g = (e, t) => {
105
- e[t] = (e[t] ?? 0) + 1;
106
- }, _ = (e) => new Promise((t) => setTimeout(t, e)), v = async (e) => {
107
- let { provider: t, log: n } = e, i = h(), a = { current: !1 }, o = /* @__PURE__ */ new Set(), s = async (a) => {
108
- let o = a.groupId ?? `${e.appName}.${a.topic}`, s = a.dlqTopic ?? `${a.topic}.dlq`, c = a.maxAttempts ?? 3, l = r.decodeUnknownEither(a.schema), u = async (e, r, o) => {
109
- g(i.dlq, a.topic), i.lastError[a.topic] = `${e}: ${r}`.slice(0, 500), n.warn("queue: dead-lettered", {
110
- topic: a.topic,
111
- dlqTopic: s,
130
+ }, L = "queue.consume", R = (e, t) => (r) => {
131
+ let i = u(e.traceparent);
132
+ return r.pipe(n.tap((e) => t === void 0 ? n.void : n.annotateCurrentSpan(t(e))), n.withSpan(L, {
133
+ attributes: {
134
+ "messaging.system": e.system,
135
+ "messaging.operation": "process",
136
+ "messaging.destination.name": e.topic,
137
+ "messaging.consumer.group.name": e.groupId,
138
+ "messaging.destination.partition.id": e.partition,
139
+ "messaging.message.offset": e.offset
140
+ },
141
+ ...i ? { parent: i } : {}
142
+ }));
143
+ }, z = async (t, i, a) => {
144
+ let o, s = await n.runPromiseExit(n.tryPromise({
145
+ try: i,
146
+ catch: (e) => (o = { value: e }, e)
147
+ }).pipe(R(t, a), d));
148
+ if (r.isSuccess(s)) return s.value;
149
+ throw o === void 0 ? e.squash(s.cause) : o.value;
150
+ }, B = (e) => new Promise((t) => setTimeout(t, e)), V = async (e) => {
151
+ let { provider: t, log: n } = e, r = { current: !1 }, i = /* @__PURE__ */ new Set(), a = async (r) => {
152
+ let i = r.groupId ?? `${e.appName}.${r.topic}`, a = r.dlqTopic ?? `${r.topic}.dlq`, s = r.maxAttempts ?? 3, c = o.decodeUnknownEither(r.schema), l = async (e, i, o) => {
153
+ j(r.topic, `${e}: ${i}`), n.warn("queue: dead-lettered", {
154
+ topic: r.topic,
155
+ dlqTopic: a,
112
156
  reason: e,
113
157
  partition: o.partition,
114
158
  offset: o.offset
115
- }), await t.produce(s, [{
159
+ }), await t.produce(a, [{
116
160
  ...o.key === null ? {} : { key: o.key },
117
161
  value: o.value ?? "",
118
162
  headers: {
119
163
  ...o.headers,
120
164
  "x-voltro-dlq-reason": e,
121
- "x-voltro-dlq-detail": r.slice(0, 500),
165
+ "x-voltro-dlq-detail": i.slice(0, 500),
122
166
  "x-voltro-dlq-source-topic": o.topic,
123
167
  "x-voltro-dlq-source-partition": String(o.partition),
124
168
  "x-voltro-dlq-source-offset": o.offset
125
169
  }
126
170
  }]);
127
- }, d = await t.consume({
128
- topic: a.topic,
129
- groupId: o,
130
- ...a.fromBeginning === void 0 ? {} : { fromBeginning: a.fromBeginning },
131
- onMessage: async (t, r) => {
132
- let o;
133
- try {
134
- o = t.value === null ? null : JSON.parse(t.value);
135
- } catch (e) {
136
- await u("decode-failed", `invalid JSON: ${e instanceof Error ? e.message : String(e)}`, t);
137
- return;
138
- }
139
- let s = l(o);
140
- if (s._tag === "Left") {
141
- await u("decode-failed", String(s.left), t);
142
- return;
143
- }
144
- for (let o = 1; o <= c; o++) {
145
- if (r.isStale()) return;
171
+ }, u = await t.consume({
172
+ topic: r.topic,
173
+ groupId: i,
174
+ ...r.fromBeginning === void 0 ? {} : { fromBeginning: r.fromBeginning },
175
+ onMessage: async (a, o) => {
176
+ await z({
177
+ system: t.name,
178
+ topic: r.topic,
179
+ groupId: i,
180
+ partition: a.partition,
181
+ offset: a.offset,
182
+ traceparent: a.headers.traceparent
183
+ }, async () => {
184
+ let t;
146
185
  try {
147
- await a.handler(s.right, {
148
- topic: t.topic,
149
- partition: t.partition,
150
- offset: t.offset,
151
- key: t.key,
152
- headers: t.headers,
153
- attempt: o,
154
- ...t.headers.traceparent === void 0 ? {} : { traceparent: t.headers.traceparent },
155
- get store() {
156
- let t = e.store();
157
- if (t === null) throw Error("queue: store not bound yet — the retry loop absorbs a message arriving before boot finished");
158
- return t;
159
- }
160
- }), g(i.consumed, a.topic);
161
- return;
162
- } catch (s) {
163
- let l = s instanceof Error ? s.message : String(s);
164
- if (o >= c) {
165
- await u("handler-failed", l, t);
166
- return;
186
+ t = a.value === null ? null : JSON.parse(a.value);
187
+ } catch (e) {
188
+ return await l("decode-failed", `invalid JSON: ${e instanceof Error ? e.message : String(e)}`, a), "dead-lettered";
189
+ }
190
+ let i = c(t);
191
+ if (i._tag === "Left") return await l("decode-failed", String(i.left), a), "dead-lettered";
192
+ for (let t = 1; t <= s; t++) {
193
+ if (o.isStale()) return "stale";
194
+ try {
195
+ return await r.handler(i.right, {
196
+ topic: a.topic,
197
+ partition: a.partition,
198
+ offset: a.offset,
199
+ key: a.key,
200
+ headers: a.headers,
201
+ attempt: t,
202
+ ...a.headers.traceparent === void 0 ? {} : { traceparent: a.headers.traceparent },
203
+ get store() {
204
+ let t = e.store();
205
+ if (t === null) throw Error("queue: store not bound yet — the retry loop absorbs a message arriving before boot finished");
206
+ return t;
207
+ }
208
+ }), A(r.topic), "ok";
209
+ } catch (i) {
210
+ let c = i instanceof Error ? i.message : String(i);
211
+ if (t >= s) return await l("handler-failed", c, a), "dead-lettered";
212
+ M(r.topic), n.warn("queue: handler failed, retrying", {
213
+ topic: r.topic,
214
+ attempt: t,
215
+ maxAttempts: s,
216
+ detail: c
217
+ }), await o.heartbeat().catch(() => {}), await B((e.retryBaseMs ?? 1e3) * 2 ** (t - 1));
167
218
  }
168
- g(i.retried, a.topic), n.warn("queue: handler failed, retrying", {
169
- topic: a.topic,
170
- attempt: o,
171
- maxAttempts: c,
172
- detail: l
173
- }), await r.heartbeat().catch(() => {}), await _((e.retryBaseMs ?? 1e3) * 2 ** (o - 1));
174
219
  }
175
- }
220
+ return "stale";
221
+ }, (e) => ({ "voltro.queue.outcome": e }));
176
222
  }
177
223
  });
178
224
  return n.info("queue: consumer started", {
179
- topic: a.topic,
180
- groupId: o,
181
- dlqTopic: s
182
- }), d;
183
- }, c = e.consumers.map((e) => {
184
- let t = { handle: null }, r = () => {
185
- s(e).then((e) => {
186
- if (a.current) {
225
+ topic: r.topic,
226
+ groupId: i,
227
+ dlqTopic: a
228
+ }), u;
229
+ }, s = e.consumers.map((e) => {
230
+ let t = { handle: null }, o = () => {
231
+ a(e).then((e) => {
232
+ if (r.current) {
187
233
  e.stop();
188
234
  return;
189
235
  }
190
236
  t.handle = e;
191
237
  }).catch((t) => {
192
- if (a.current) return;
238
+ if (r.current) return;
193
239
  n.warn("queue: consumer start failed — retrying in 5s", {
194
240
  topic: e.topic,
195
241
  detail: t instanceof Error ? t.message : String(t)
196
242
  });
197
- let i = setTimeout(() => {
198
- o.delete(i), r();
243
+ let a = setTimeout(() => {
244
+ i.delete(a), o();
199
245
  }, 5e3);
200
- o.add(i);
246
+ i.add(a);
201
247
  });
202
248
  };
203
- return r(), t;
249
+ return o(), t;
204
250
  });
205
251
  return { stop: async () => {
206
- a.current = !0;
207
- for (let e of o) clearTimeout(e);
208
- o.clear(), await Promise.all(c.map((e) => e.handle?.stop() ?? Promise.resolve()));
252
+ r.current = !0;
253
+ for (let e of i) clearTimeout(e);
254
+ i.clear(), await Promise.all(s.map((e) => e.handle?.stop() ?? Promise.resolve()));
209
255
  } };
210
- }, y = (e) => {
211
- let t = () => e.provider ?? C();
256
+ }, H = (e) => {
257
+ let t = () => e.provider ?? K();
212
258
  return {
213
259
  name: `kafka(${e.topic})`,
214
260
  ...e.provider !== void 0 && e.provider.hosts[0] !== void 0 ? { outboundHost: e.provider.hosts[0] } : {},
@@ -225,64 +271,62 @@ var s = Symbol.for("@voltro/plugin-queue:consumers"), c = () => {
225
271
  })));
226
272
  }
227
273
  };
228
- }, b = class extends e.Tag("@voltro/plugin-queue/QueueService")() {}, x = Symbol.for("@voltro/plugin-queue:active-provider"), S = () => {
274
+ }, U = class extends t.Tag("@voltro/plugin-queue/QueueService")() {}, W = Symbol.for("@voltro/plugin-queue:active-provider"), G = () => {
229
275
  let e = globalThis;
230
- return e[x] ??= { current: null };
231
- }, C = () => {
232
- let e = S().current;
276
+ return e[W] ??= { current: null };
277
+ }, K = () => {
278
+ let e = G().current;
233
279
  if (e === null) throw Error("[voltro] no active queue provider — is queuePlugin({...}) in this app's plugins? The outbox handler and kafkaSink need the plugin's connected provider.");
234
280
  return e;
235
- }, w = () => ({
281
+ }, q = () => ({
236
282
  effect: "queue.produce",
237
283
  maxAttempts: 8,
238
284
  handler: async ({ payload: e }) => {
239
285
  let t = e.topic, n = e.messages;
240
286
  if (typeof t != "string" || !Array.isArray(n)) throw Error("queue.produce payload must be { topic: string, messages: QueueOutgoingMessage[] }");
241
- await C().produce(t, n);
242
- let r = h();
243
- r.produced[t] = (r.produced[t] ?? 0) + n.length;
287
+ await K().produce(t, n), N(t, n.length);
244
288
  }
245
- }), T = (e) => {
246
- let r = e.name ?? "queue", a = p(e), o = ["inspect:read", ...a.hosts.map((e) => `network:outbound:${e}`)], s = null, c = { current: null };
247
- return i({
248
- name: r,
289
+ }), J = (e) => {
290
+ let t = e.name ?? "queue", r = I(e), a = ["inspect:read", ...r.hosts.map((e) => `network:outbound:${e}`)], o = null, c = { current: null };
291
+ return s({
292
+ name: t,
249
293
  baseName: "queue",
250
294
  description: "Queue interop — consume and produce against an existing Kafka: Schema-decoded consumers (at-least-once, serial per partition, retry + dead-letter), batched producing via a handler service or transactionally through the outbox, and a kafkaSink for plugin-cdc-out.",
251
- permissions: o,
252
- services: n.succeed(b, { produce: (e, t) => a.produce(e, t) }),
295
+ permissions: a,
296
+ services: i.succeed(U, { produce: (e, t) => r.produce(e, t) }),
253
297
  bindDataStore: (e) => {
254
298
  c.current = e;
255
299
  },
256
- onActivate: (n) => t.tryPromise(async () => {
257
- await a.connect(), S().current = a;
258
- let t = u();
259
- s = await v({
260
- provider: a,
261
- consumers: t,
262
- appName: e.groupPrefix ?? n.app.name,
263
- log: n.logger,
300
+ onActivate: (t) => n.tryPromise(async () => {
301
+ await r.connect(), G().current = r;
302
+ let n = h();
303
+ o = await V({
304
+ provider: r,
305
+ consumers: n,
306
+ appName: e.groupPrefix ?? t.app.name,
307
+ log: t.logger,
264
308
  store: () => c.current,
265
309
  ...e.retryBaseMs === void 0 ? {} : { retryBaseMs: e.retryBaseMs }
266
- }), n.logger.info("queue: connected", {
267
- brokers: a.hosts,
268
- consumers: t.length
310
+ }), t.logger.info("queue: connected", {
311
+ brokers: r.hosts,
312
+ consumers: n.length
269
313
  });
270
314
  }),
271
- onDeactivate: () => t.tryPromise(async () => {
272
- await s?.stop(), s = null, S().current === a && (S().current = null), await a.disconnect();
315
+ onDeactivate: () => n.tryPromise(async () => {
316
+ await o?.stop(), o = null, G().current === r && (G().current = null), await r.disconnect();
273
317
  }),
274
318
  inspectEndpoints: [{
275
319
  method: "GET",
276
320
  path: "/consumers",
277
321
  description: "Registered consumers + per-topic consumed/retried/dead-lettered/produced counters (this replica).",
278
- handler: () => t.sync(() => {
279
- let e = h();
322
+ handler: () => n.sync(() => {
323
+ let e = v();
280
324
  return {
281
325
  kind: "json",
282
326
  data: {
283
- connected: S().current !== null,
284
- brokers: a.hosts,
285
- consumers: u().map((t) => ({
327
+ connected: G().current !== null,
328
+ brokers: r.hosts,
329
+ consumers: h().map((t) => ({
286
330
  topic: t.topic,
287
331
  groupId: t.groupId ?? null,
288
332
  dlqTopic: t.dlqTopic ?? `${t.topic}.dlq`,
@@ -300,4 +344,4 @@ var s = Symbol.for("@voltro/plugin-queue:consumers"), c = () => {
300
344
  });
301
345
  };
302
346
  //#endregion
303
- export { b as QueueService, C as activeQueueProvider, l as defineQueueConsumer, p as kafkaProvider, y as kafkaSink, h as queueMetrics, w as queueOutboxHandler, T as queuePlugin, u as registeredQueueConsumers, d as resetQueueConsumersForTest, v as startQueueConsumers };
347
+ export { U as QueueService, K as activeQueueProvider, m as defineQueueConsumer, I as kafkaProvider, H as kafkaSink, v as queueMetrics, q as queueOutboxHandler, J as queuePlugin, h as registeredQueueConsumers, g as resetQueueConsumersForTest, V as startQueueConsumers };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-queue",
3
- "version": "0.53.0",
3
+ "version": "0.55.0",
4
4
  "description": "Queue interop — consume and produce against an existing Kafka: Schema-decoded consumers via defineQueueConsumer (*.consumer.ts; at-least-once, serial per partition, retry + dead-letter), batched producing via QueueService or transactionally through the outbox (queueOutboxHandler), and a kafkaSink for plugin-cdc-out.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -33,9 +33,10 @@
33
33
  "node": ">=24.0.0"
34
34
  },
35
35
  "dependencies": {
36
- "@voltro/protocol": "0.53.0",
36
+ "@voltro/protocol": "0.55.0",
37
37
  "kafkajs": "^2.2.4",
38
- "@voltro/database": "0.53.0"
38
+ "@voltro/database": "0.55.0",
39
+ "@voltro/runtime": "0.55.0"
39
40
  },
40
41
  "peerDependencies": {
41
42
  "effect": "^3.22.0"