@horizon-republic/nestjs-jetstream 2.11.1 → 2.12.1
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.cjs +708 -164
- package/dist/index.d.cts +84 -13
- package/dist/index.d.ts +84 -13
- package/dist/index.js +703 -154
- package/package.json +16 -13
package/dist/index.js
CHANGED
|
@@ -144,7 +144,7 @@ var DEFAULT_ORDERED_STREAM_CONFIG = {
|
|
|
144
144
|
};
|
|
145
145
|
var DEFAULT_DLQ_STREAM_CONFIG = {
|
|
146
146
|
...baseStreamConfig,
|
|
147
|
-
retention: RetentionPolicy.
|
|
147
|
+
retention: RetentionPolicy.Limits,
|
|
148
148
|
allow_rollup_hdrs: false,
|
|
149
149
|
max_consumers: 100,
|
|
150
150
|
max_msg_size: 10 * MB,
|
|
@@ -208,6 +208,7 @@ var RESERVED_HEADERS = /* @__PURE__ */ new Set([
|
|
|
208
208
|
"x-reply-to" /* ReplyTo */,
|
|
209
209
|
"x-error" /* Error */
|
|
210
210
|
]);
|
|
211
|
+
var NATS_CONTROL_HEADER_PREFIX = "nats-";
|
|
211
212
|
var internalName = (name) => `${name}__microservice`;
|
|
212
213
|
var buildSubject = (serviceName, kind, pattern) => `${internalName(serviceName)}.${kind}.${pattern}`;
|
|
213
214
|
var buildBroadcastSubject = (pattern) => `broadcast.${pattern}`;
|
|
@@ -260,6 +261,9 @@ var ATTR_JETSTREAM_RPC_REPLY_ERROR_CODE = "jetstream.rpc.reply.error.code";
|
|
|
260
261
|
var ATTR_JETSTREAM_PROVISIONING_ENTITY = "jetstream.provisioning.entity";
|
|
261
262
|
var ATTR_JETSTREAM_PROVISIONING_ACTION = "jetstream.provisioning.action";
|
|
262
263
|
var ATTR_JETSTREAM_PROVISIONING_NAME = "jetstream.provisioning.name";
|
|
264
|
+
var ATTR_JETSTREAM_PROVISIONING_MAX_BYTES = "jetstream.provisioning.max_bytes";
|
|
265
|
+
var ATTR_JETSTREAM_PROVISIONING_NUM_REPLICAS = "jetstream.provisioning.num_replicas";
|
|
266
|
+
var ATTR_JETSTREAM_PROVISIONING_RESERVATION = "jetstream.provisioning.reservation_bytes";
|
|
263
267
|
var ATTR_JETSTREAM_SELF_HEALING_REASON = "jetstream.self_healing.reason";
|
|
264
268
|
var ATTR_JETSTREAM_MIGRATION_REASON = "jetstream.migration.reason";
|
|
265
269
|
var ATTR_JETSTREAM_DEAD_LETTER_REASON = "jetstream.dead_letter.reason";
|
|
@@ -529,7 +533,7 @@ var extractContext = (ctx, carrier, getter) => propagation.extract(ctx, carrier,
|
|
|
529
533
|
|
|
530
534
|
// src/otel/tracer.ts
|
|
531
535
|
import { trace } from "@opentelemetry/api";
|
|
532
|
-
var PACKAGE_VERSION = true ? "2.
|
|
536
|
+
var PACKAGE_VERSION = true ? "2.12.1" : "0.0.0";
|
|
533
537
|
var getTracer = () => trace.getTracer(TRACER_NAME, PACKAGE_VERSION);
|
|
534
538
|
|
|
535
539
|
// src/otel/carrier.ts
|
|
@@ -1129,7 +1133,10 @@ var withProvisioningSpan = (config, ctx, op) => wrapInfra(
|
|
|
1129
1133
|
{
|
|
1130
1134
|
[ATTR_JETSTREAM_PROVISIONING_ENTITY]: ctx.entity,
|
|
1131
1135
|
[ATTR_JETSTREAM_PROVISIONING_ACTION]: ctx.action,
|
|
1132
|
-
[ATTR_JETSTREAM_PROVISIONING_NAME]: ctx.name
|
|
1136
|
+
[ATTR_JETSTREAM_PROVISIONING_NAME]: ctx.name,
|
|
1137
|
+
[ATTR_JETSTREAM_PROVISIONING_MAX_BYTES]: ctx.maxBytes,
|
|
1138
|
+
[ATTR_JETSTREAM_PROVISIONING_NUM_REPLICAS]: ctx.numReplicas,
|
|
1139
|
+
[ATTR_JETSTREAM_PROVISIONING_RESERVATION]: ctx.reservation
|
|
1133
1140
|
},
|
|
1134
1141
|
op
|
|
1135
1142
|
);
|
|
@@ -1305,7 +1312,13 @@ var JetstreamRecordBuilder = class {
|
|
|
1305
1312
|
* lockstep. `RESERVED_HEADERS` is defined as an all-lowercase set.
|
|
1306
1313
|
*/
|
|
1307
1314
|
validateHeaderKey(key) {
|
|
1308
|
-
|
|
1315
|
+
const normalized = key.toLowerCase();
|
|
1316
|
+
if (normalized.startsWith(NATS_CONTROL_HEADER_PREFIX)) {
|
|
1317
|
+
throw new Error(
|
|
1318
|
+
`Header "${key}" is reserved for the NATS server and cannot be set manually. Use setMessageId() for deduplication, ttl() for per-message expiry, and scheduleAt() for delayed delivery.`
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
if (RESERVED_HEADERS.has(normalized)) {
|
|
1309
1322
|
throw new Error(
|
|
1310
1323
|
`Header "${key}" is reserved by the JetStream transport and cannot be set manually. Reserved headers: ${[...RESERVED_HEADERS].join(", ")}`
|
|
1311
1324
|
);
|
|
@@ -1451,13 +1464,18 @@ var JetstreamClient = class extends ClientProxy {
|
|
|
1451
1464
|
async dispatchEvent(packet) {
|
|
1452
1465
|
if (!this.readyForPublish) await this.connect();
|
|
1453
1466
|
const { data, hdrs, messageId, schedule, ttl } = this.extractRecordData(packet.data);
|
|
1467
|
+
const publishKind = detectEventKind(packet.pattern);
|
|
1468
|
+
if (schedule && publishKind === "ordered" /* Ordered */) {
|
|
1469
|
+
throw new Error(
|
|
1470
|
+
`scheduleAt() is not supported for ordered events (pattern: ${packet.pattern}). Scheduled delivery is available for workqueue events and broadcasts.`
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1454
1473
|
const eventSubject = this.buildEventSubject(packet.pattern);
|
|
1455
1474
|
const publishSubject = schedule ? this.buildScheduleSubject(eventSubject) : eventSubject;
|
|
1456
1475
|
const msgHeaders = this.buildHeaders(hdrs, { subject: eventSubject });
|
|
1457
1476
|
const encoded = this.codec.encode(data);
|
|
1458
1477
|
const effectiveMsgId = messageId ?? nuid.next();
|
|
1459
1478
|
const record = packet.data instanceof JetstreamRecord ? packet.data : new JetstreamRecord(data, /* @__PURE__ */ new Map());
|
|
1460
|
-
const publishKind = detectEventKind(packet.pattern);
|
|
1461
1479
|
const declaredPattern = declaredEventPattern(packet.pattern);
|
|
1462
1480
|
const streamKind = eventStreamKind(publishKind);
|
|
1463
1481
|
const startedAt = performance.now();
|
|
@@ -1489,10 +1507,10 @@ var JetstreamClient = class extends ClientProxy {
|
|
|
1489
1507
|
const ack2 = await this.connection.getJetStreamClient().publish(publishSubject, encoded, {
|
|
1490
1508
|
headers: msgHeaders,
|
|
1491
1509
|
msgID: effectiveMsgId,
|
|
1492
|
-
ttl,
|
|
1493
1510
|
schedule: {
|
|
1494
1511
|
specification: schedule.at,
|
|
1495
|
-
target: eventSubject
|
|
1512
|
+
target: eventSubject,
|
|
1513
|
+
ttl
|
|
1496
1514
|
}
|
|
1497
1515
|
});
|
|
1498
1516
|
warnIfDuplicate("scheduled", ack2);
|
|
@@ -1682,13 +1700,18 @@ var JetstreamClient = class extends ClientProxy {
|
|
|
1682
1700
|
});
|
|
1683
1701
|
return;
|
|
1684
1702
|
}
|
|
1685
|
-
await context6.with(
|
|
1703
|
+
const ack = await context6.with(
|
|
1686
1704
|
spanHandle.activeContext,
|
|
1687
1705
|
() => this.connection.getJetStreamClient().publish(subject, encoded, {
|
|
1688
1706
|
headers: hdrs,
|
|
1689
1707
|
msgID: messageId ?? nuid.next()
|
|
1690
1708
|
})
|
|
1691
1709
|
);
|
|
1710
|
+
if (ack.duplicate) {
|
|
1711
|
+
throw new Error(
|
|
1712
|
+
`Duplicate RPC publish for ${subject}: the messageId was already used within the stream dedup window, so the reply belongs to the original request`
|
|
1713
|
+
);
|
|
1714
|
+
}
|
|
1692
1715
|
this.reportPublished(declaredPattern, "cmd" /* Command */, startedAt, "success");
|
|
1693
1716
|
} catch (err) {
|
|
1694
1717
|
const existingTimeout = this.pendingTimeouts.get(correlationId);
|
|
@@ -1858,13 +1881,17 @@ var JetstreamClient = class extends ClientProxy {
|
|
|
1858
1881
|
* uses a separate `_sch` namespace that is NOT matched by any consumer filter.
|
|
1859
1882
|
* NATS holds the message and publishes it to the target subject after the delay.
|
|
1860
1883
|
*
|
|
1884
|
+
* A unique per-message suffix is appended because the server stores schedules
|
|
1885
|
+
* as rollup messages — one active schedule per subject (ADR-51). Without it,
|
|
1886
|
+
* concurrent schedules of the same pattern would silently replace each other.
|
|
1887
|
+
*
|
|
1861
1888
|
* Examples:
|
|
1862
|
-
* - `{svc}__microservice.ev.order.reminder` → `{svc}__microservice._sch.order.reminder
|
|
1863
|
-
* - `broadcast.config.updated` → `broadcast._sch.config.updated
|
|
1889
|
+
* - `{svc}__microservice.ev.order.reminder` → `{svc}__microservice._sch.order.reminder.<nuid>`
|
|
1890
|
+
* - `broadcast.config.updated` → `broadcast._sch.config.updated.<nuid>`
|
|
1864
1891
|
*/
|
|
1865
1892
|
buildScheduleSubject(eventSubject) {
|
|
1866
1893
|
if (eventSubject.startsWith("broadcast.")) {
|
|
1867
|
-
return eventSubject.replace("broadcast.", "broadcast._sch.")
|
|
1894
|
+
return `${eventSubject.replace("broadcast.", "broadcast._sch.")}.${nuid.next()}`;
|
|
1868
1895
|
}
|
|
1869
1896
|
const targetPrefix = `${internalName(this.targetName)}.`;
|
|
1870
1897
|
if (!eventSubject.startsWith(targetPrefix)) {
|
|
@@ -1876,7 +1903,7 @@ var JetstreamClient = class extends ClientProxy {
|
|
|
1876
1903
|
throw new Error(`Event subject missing pattern segment: ${eventSubject}`);
|
|
1877
1904
|
}
|
|
1878
1905
|
const pattern = withoutPrefix.slice(dotIndex + 1);
|
|
1879
|
-
return `${targetPrefix}_sch.${pattern}`;
|
|
1906
|
+
return `${targetPrefix}_sch.${pattern}.${nuid.next()}`;
|
|
1880
1907
|
}
|
|
1881
1908
|
};
|
|
1882
1909
|
|
|
@@ -1888,6 +1915,7 @@ var JsonCodec = class {
|
|
|
1888
1915
|
return encoder.encode(JSON.stringify(data));
|
|
1889
1916
|
}
|
|
1890
1917
|
decode(data) {
|
|
1918
|
+
if (data.length === 0) return void 0;
|
|
1891
1919
|
return JSON.parse(decoder.decode(data));
|
|
1892
1920
|
}
|
|
1893
1921
|
};
|
|
@@ -1901,6 +1929,7 @@ var MsgpackCodec = class {
|
|
|
1901
1929
|
return this.packr.pack(data);
|
|
1902
1930
|
}
|
|
1903
1931
|
decode(data) {
|
|
1932
|
+
if (data.length === 0) return void 0;
|
|
1904
1933
|
return this.packr.unpack(data);
|
|
1905
1934
|
}
|
|
1906
1935
|
};
|
|
@@ -2981,43 +3010,11 @@ var JetstreamStrategy = class extends Server {
|
|
|
2981
3010
|
* Called by NestJS when `connectMicroservice()` is used, or internally by the module.
|
|
2982
3011
|
*/
|
|
2983
3012
|
async listen(callback) {
|
|
2984
|
-
|
|
2985
|
-
this.
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
this.started = true;
|
|
2989
|
-
this.patternRegistry.registerHandlers(this.getHandlers());
|
|
2990
|
-
const { streams: streamKinds, durableConsumers: durableKinds } = this.resolveRequiredKinds();
|
|
2991
|
-
if (streamKinds.length > 0) {
|
|
2992
|
-
await this.streamProvider.ensureStreams(streamKinds);
|
|
2993
|
-
if (durableKinds.length > 0) {
|
|
2994
|
-
const consumers = await this.consumerProvider.ensureConsumers(durableKinds);
|
|
2995
|
-
this.populateAckWaitMap(consumers);
|
|
2996
|
-
this.eventRouter.updateMaxDeliverMap(this.buildMaxDeliverMap(consumers));
|
|
2997
|
-
this.messageProvider.start(consumers);
|
|
2998
|
-
}
|
|
2999
|
-
if (this.patternRegistry.hasOrderedHandlers()) {
|
|
3000
|
-
const orderedStreamName = this.streamProvider.getStreamName("ordered" /* Ordered */);
|
|
3001
|
-
await this.messageProvider.startOrdered(
|
|
3002
|
-
orderedStreamName,
|
|
3003
|
-
this.patternRegistry.getOrderedSubjects(),
|
|
3004
|
-
this.options.ordered
|
|
3005
|
-
);
|
|
3006
|
-
}
|
|
3007
|
-
if (this.patternRegistry.hasEventHandlers() || this.patternRegistry.hasBroadcastHandlers() || this.patternRegistry.hasOrderedHandlers()) {
|
|
3008
|
-
this.eventRouter.start();
|
|
3009
|
-
}
|
|
3010
|
-
if (isJetStreamRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
|
|
3011
|
-
await this.rpcRouter.start();
|
|
3012
|
-
}
|
|
3013
|
-
}
|
|
3014
|
-
if (isCoreRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
|
|
3015
|
-
await this.coreRpcServer.start();
|
|
3016
|
-
}
|
|
3017
|
-
if (this.metadataProvider && this.patternRegistry.hasMetadata()) {
|
|
3018
|
-
await this.metadataProvider.publish(this.patternRegistry.getMetadataEntries());
|
|
3013
|
+
try {
|
|
3014
|
+
await this.doListen(callback);
|
|
3015
|
+
} catch (err) {
|
|
3016
|
+
callback(err);
|
|
3019
3017
|
}
|
|
3020
|
-
callback();
|
|
3021
3018
|
}
|
|
3022
3019
|
/** Stop all consumers, routers, subscriptions, and metadata heartbeat. Called during shutdown. */
|
|
3023
3020
|
close() {
|
|
@@ -3076,6 +3073,33 @@ var JetstreamStrategy = class extends Server {
|
|
|
3076
3073
|
getPatternRegistry() {
|
|
3077
3074
|
return this.patternRegistry;
|
|
3078
3075
|
}
|
|
3076
|
+
async doListen(callback) {
|
|
3077
|
+
if (this.started) {
|
|
3078
|
+
this.logger.warn("listen() called more than once \u2014 ignoring");
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
this.started = true;
|
|
3082
|
+
this.patternRegistry.registerHandlers(this.getHandlers());
|
|
3083
|
+
const { streams: streamKinds, durableConsumers: durableKinds } = this.resolveRequiredKinds();
|
|
3084
|
+
if (streamKinds.length > 0) {
|
|
3085
|
+
await this.streamProvider.ensureStreams(streamKinds);
|
|
3086
|
+
let consumers = null;
|
|
3087
|
+
if (durableKinds.length > 0) {
|
|
3088
|
+
consumers = await this.consumerProvider.ensureConsumers(durableKinds);
|
|
3089
|
+
this.populateAckWaitMap(consumers);
|
|
3090
|
+
this.eventRouter.updateMaxDeliverMap(this.buildMaxDeliverMap(consumers));
|
|
3091
|
+
}
|
|
3092
|
+
await this.startRouters();
|
|
3093
|
+
await this.startConsumption(consumers);
|
|
3094
|
+
}
|
|
3095
|
+
if (isCoreRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
|
|
3096
|
+
await this.coreRpcServer.start();
|
|
3097
|
+
}
|
|
3098
|
+
if (this.metadataProvider && this.patternRegistry.hasMetadata()) {
|
|
3099
|
+
await this.metadataProvider.publish(this.patternRegistry.getMetadataEntries());
|
|
3100
|
+
}
|
|
3101
|
+
callback();
|
|
3102
|
+
}
|
|
3079
3103
|
/** Determine which streams and durable consumers are needed. */
|
|
3080
3104
|
resolveRequiredKinds() {
|
|
3081
3105
|
const streams = [];
|
|
@@ -3097,7 +3121,29 @@ var JetstreamStrategy = class extends Server {
|
|
|
3097
3121
|
}
|
|
3098
3122
|
return { streams, durableConsumers };
|
|
3099
3123
|
}
|
|
3100
|
-
/**
|
|
3124
|
+
/** Subscribe the event and RPC routers to the message subjects. */
|
|
3125
|
+
async startRouters() {
|
|
3126
|
+
if (this.patternRegistry.hasEventHandlers() || this.patternRegistry.hasBroadcastHandlers() || this.patternRegistry.hasOrderedHandlers()) {
|
|
3127
|
+
this.eventRouter.start();
|
|
3128
|
+
}
|
|
3129
|
+
if (isJetStreamRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
|
|
3130
|
+
await this.rpcRouter.start();
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
/** Begin durable and ordered consumption; routers must already be subscribed. */
|
|
3134
|
+
async startConsumption(consumers) {
|
|
3135
|
+
if (consumers !== null) {
|
|
3136
|
+
this.messageProvider.start(consumers);
|
|
3137
|
+
}
|
|
3138
|
+
if (this.patternRegistry.hasOrderedHandlers()) {
|
|
3139
|
+
const orderedStreamName = this.streamProvider.getStreamName("ordered" /* Ordered */);
|
|
3140
|
+
await this.messageProvider.startOrdered(
|
|
3141
|
+
orderedStreamName,
|
|
3142
|
+
this.patternRegistry.getOrderedSubjects(),
|
|
3143
|
+
this.options.ordered
|
|
3144
|
+
);
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3101
3147
|
populateAckWaitMap(consumers) {
|
|
3102
3148
|
for (const [kind, info] of consumers) {
|
|
3103
3149
|
if (info.config.ack_wait) {
|
|
@@ -3336,6 +3382,15 @@ var serializeError = (err) => {
|
|
|
3336
3382
|
return err;
|
|
3337
3383
|
};
|
|
3338
3384
|
|
|
3385
|
+
// src/utils/settle-quietly.ts
|
|
3386
|
+
var settleQuietly = (logger5, label, action) => {
|
|
3387
|
+
try {
|
|
3388
|
+
action();
|
|
3389
|
+
} catch (err) {
|
|
3390
|
+
logger5.error(label, err);
|
|
3391
|
+
}
|
|
3392
|
+
};
|
|
3393
|
+
|
|
3339
3394
|
// src/utils/unwrap-result.ts
|
|
3340
3395
|
import { isObservable } from "rxjs";
|
|
3341
3396
|
var unwrapResult = (result) => {
|
|
@@ -3501,16 +3556,168 @@ var CoreRpcServer = class {
|
|
|
3501
3556
|
|
|
3502
3557
|
// src/server/infrastructure/stream.provider.ts
|
|
3503
3558
|
import { Logger as Logger13 } from "@nestjs/common";
|
|
3504
|
-
import {
|
|
3559
|
+
import {
|
|
3560
|
+
JetStreamApiError as JetStreamApiError2,
|
|
3561
|
+
RetentionPolicy as RetentionPolicy2,
|
|
3562
|
+
StorageType as StorageType4
|
|
3563
|
+
} from "@nats-io/jetstream";
|
|
3505
3564
|
|
|
3506
3565
|
// src/server/infrastructure/nats-error-codes.ts
|
|
3507
3566
|
var NatsErrorCode = /* @__PURE__ */ ((NatsErrorCode2) => {
|
|
3508
3567
|
NatsErrorCode2[NatsErrorCode2["ConsumerNotFound"] = 10014] = "ConsumerNotFound";
|
|
3509
3568
|
NatsErrorCode2[NatsErrorCode2["ConsumerAlreadyExists"] = 10148] = "ConsumerAlreadyExists";
|
|
3510
3569
|
NatsErrorCode2[NatsErrorCode2["StreamNotFound"] = 10059] = "StreamNotFound";
|
|
3570
|
+
NatsErrorCode2[NatsErrorCode2["StorageResourcesExceeded"] = 10047] = "StorageResourcesExceeded";
|
|
3571
|
+
NatsErrorCode2[NatsErrorCode2["NoSuitablePeers"] = 10005] = "NoSuitablePeers";
|
|
3511
3572
|
return NatsErrorCode2;
|
|
3512
3573
|
})(NatsErrorCode || {});
|
|
3513
3574
|
|
|
3575
|
+
// src/server/infrastructure/provisioning-budget.ts
|
|
3576
|
+
import { StorageType as StorageType2 } from "@nats-io/jetstream";
|
|
3577
|
+
var GIB = 1024 ** 3;
|
|
3578
|
+
var fmt = (bytes) => `${(bytes / GIB).toFixed(2)} GiB`;
|
|
3579
|
+
var resolveTierBudget = (info, replicas) => {
|
|
3580
|
+
const tier = info.tiers?.[`R${replicas}`];
|
|
3581
|
+
const limits = tier?.limits ?? info.limits;
|
|
3582
|
+
return {
|
|
3583
|
+
maxStorage: limits?.max_storage ?? 0,
|
|
3584
|
+
reserved: tier?.reserved_storage ?? info.reserved_storage ?? 0,
|
|
3585
|
+
tiered: tier !== void 0
|
|
3586
|
+
};
|
|
3587
|
+
};
|
|
3588
|
+
var groupByReplicas = (reservations) => {
|
|
3589
|
+
const groups = /* @__PURE__ */ new Map();
|
|
3590
|
+
for (const r of reservations) {
|
|
3591
|
+
if (r.storage !== StorageType2.File) continue;
|
|
3592
|
+
const prev = groups.get(r.numReplicas) ?? 0;
|
|
3593
|
+
groups.set(r.numReplicas, prev + r.maxBytes * r.numReplicas);
|
|
3594
|
+
}
|
|
3595
|
+
return groups;
|
|
3596
|
+
};
|
|
3597
|
+
var assertStorageBudget = async (jsm, serviceName, reservations, logger5) => {
|
|
3598
|
+
try {
|
|
3599
|
+
const info = await jsm.getAccountInfo();
|
|
3600
|
+
const groups = groupByReplicas(reservations);
|
|
3601
|
+
let limitNotSetWarned = false;
|
|
3602
|
+
let okReserved = 0;
|
|
3603
|
+
let anyWarned = false;
|
|
3604
|
+
for (const [replicas, incremental] of groups) {
|
|
3605
|
+
const { maxStorage, reserved, tiered } = resolveTierBudget(info, replicas);
|
|
3606
|
+
const tierNote = tiered ? ` (tier R${replicas})` : "";
|
|
3607
|
+
if (maxStorage <= 0) {
|
|
3608
|
+
if (!limitNotSetWarned) {
|
|
3609
|
+
limitNotSetWarned = true;
|
|
3610
|
+
logger5.warn(
|
|
3611
|
+
`Storage preflight for "${serviceName}": account file-storage limit not set (max_storage=${maxStorage}); the server max_file_store cannot be verified from the client.`
|
|
3612
|
+
);
|
|
3613
|
+
}
|
|
3614
|
+
continue;
|
|
3615
|
+
}
|
|
3616
|
+
const remaining = maxStorage - reserved;
|
|
3617
|
+
if (incremental > remaining) {
|
|
3618
|
+
anyWarned = true;
|
|
3619
|
+
logger5.warn(
|
|
3620
|
+
`Storage preflight for "${serviceName}"${tierNote}: needs ~${fmt(incremental)} but only ~${fmt(remaining)} remains (reserved ${fmt(reserved)} / limit ${fmt(maxStorage)}). Provisioning will likely fail with insufficient storage. Lower max_bytes/num_replicas, or raise the account/server storage limit.`
|
|
3621
|
+
);
|
|
3622
|
+
continue;
|
|
3623
|
+
}
|
|
3624
|
+
okReserved += incremental;
|
|
3625
|
+
}
|
|
3626
|
+
if (!anyWarned && !limitNotSetWarned && okReserved > 0) {
|
|
3627
|
+
logger5.log(
|
|
3628
|
+
`Storage preflight for "${serviceName}" OK: reserving ~${fmt(okReserved)} across file-backed streams within account limits.`
|
|
3629
|
+
);
|
|
3630
|
+
}
|
|
3631
|
+
} catch (err) {
|
|
3632
|
+
logger5.debug(`Storage preflight skipped \u2014 account info unavailable: ${String(err)}`);
|
|
3633
|
+
}
|
|
3634
|
+
};
|
|
3635
|
+
|
|
3636
|
+
// src/server/infrastructure/provisioning-error.ts
|
|
3637
|
+
var REMEDIATION = {
|
|
3638
|
+
[10047 /* StorageResourcesExceeded */]: "Aggregate stream reservation exceeds the server `max_file_store` (or account `max_storage`). Lower `max_bytes`/`num_replicas` for this service, or raise `max_file_store` on the NATS servers.",
|
|
3639
|
+
[10005 /* NoSuitablePeers */]: "Fewer healthy peers than `num_replicas`, or no peer has enough reserved storage headroom. Reduce replicas or add/repair cluster nodes."
|
|
3640
|
+
};
|
|
3641
|
+
var GENERIC_REMEDIATION = "Inspect the NATS server logs and JetStream account limits for the underlying cause.";
|
|
3642
|
+
var JetstreamProvisioningError = class _JetstreamProvisioningError extends Error {
|
|
3643
|
+
entity;
|
|
3644
|
+
target;
|
|
3645
|
+
kind;
|
|
3646
|
+
errCode;
|
|
3647
|
+
errDescription;
|
|
3648
|
+
remediation;
|
|
3649
|
+
maxBytes;
|
|
3650
|
+
numReplicas;
|
|
3651
|
+
reservation;
|
|
3652
|
+
constructor(fields) {
|
|
3653
|
+
const reservationNote = fields.reservation !== void 0 ? ` reservation=${fields.reservation}B (max_bytes=${fields.maxBytes}B \xD7 replicas=${fields.numReplicas}).` : "";
|
|
3654
|
+
super(
|
|
3655
|
+
`JetStream ${fields.entity} provisioning failed for "${fields.target}" (kind=${fields.kind}): ${fields.errDescription} [err_code=${fields.errCode}].${reservationNote} ${fields.remediation}`,
|
|
3656
|
+
{ cause: fields.cause }
|
|
3657
|
+
);
|
|
3658
|
+
this.name = "JetstreamProvisioningError";
|
|
3659
|
+
this.entity = fields.entity;
|
|
3660
|
+
this.target = fields.target;
|
|
3661
|
+
this.kind = fields.kind;
|
|
3662
|
+
this.errCode = fields.errCode;
|
|
3663
|
+
this.errDescription = fields.errDescription;
|
|
3664
|
+
this.remediation = fields.remediation;
|
|
3665
|
+
this.maxBytes = fields.maxBytes;
|
|
3666
|
+
this.numReplicas = fields.numReplicas;
|
|
3667
|
+
this.reservation = fields.reservation;
|
|
3668
|
+
Object.setPrototypeOf(this, _JetstreamProvisioningError.prototype);
|
|
3669
|
+
}
|
|
3670
|
+
};
|
|
3671
|
+
var mapProvisioningError = (err, ctx) => {
|
|
3672
|
+
const api = err.apiError();
|
|
3673
|
+
const remediation = REMEDIATION[api.err_code] ?? GENERIC_REMEDIATION;
|
|
3674
|
+
const reservation = ctx.maxBytes !== void 0 && ctx.numReplicas !== void 0 ? ctx.maxBytes * ctx.numReplicas : void 0;
|
|
3675
|
+
return new JetstreamProvisioningError({
|
|
3676
|
+
entity: ctx.entity,
|
|
3677
|
+
target: ctx.name,
|
|
3678
|
+
kind: ctx.kind,
|
|
3679
|
+
errCode: api.err_code,
|
|
3680
|
+
errDescription: api.description,
|
|
3681
|
+
remediation,
|
|
3682
|
+
maxBytes: ctx.maxBytes,
|
|
3683
|
+
numReplicas: ctx.numReplicas,
|
|
3684
|
+
reservation,
|
|
3685
|
+
cause: err
|
|
3686
|
+
});
|
|
3687
|
+
};
|
|
3688
|
+
|
|
3689
|
+
// src/server/infrastructure/provisioning-summary.ts
|
|
3690
|
+
import { StorageType as StorageType3 } from "@nats-io/jetstream";
|
|
3691
|
+
var GIB2 = 1024 ** 3;
|
|
3692
|
+
var NANOS_PER_SECOND = 1e9;
|
|
3693
|
+
var NANOS_PER_HOUR = 3600 * NANOS_PER_SECOND;
|
|
3694
|
+
var NANOS_PER_DAY = 86400 * NANOS_PER_SECOND;
|
|
3695
|
+
var formatBytes = (bytes) => {
|
|
3696
|
+
if (bytes <= 0) return "0 B";
|
|
3697
|
+
return `${(bytes / GIB2).toFixed(2)} GiB`;
|
|
3698
|
+
};
|
|
3699
|
+
var formatAge = (nanos) => {
|
|
3700
|
+
if (nanos <= 0) return "unlimited";
|
|
3701
|
+
if (nanos >= NANOS_PER_DAY) return `${(nanos / NANOS_PER_DAY).toFixed(1)}d`;
|
|
3702
|
+
if (nanos >= NANOS_PER_HOUR) return `${(nanos / NANOS_PER_HOUR).toFixed(1)}h`;
|
|
3703
|
+
return `${(nanos / NANOS_PER_SECOND).toFixed(0)}s`;
|
|
3704
|
+
};
|
|
3705
|
+
var formatProvisioningSummary = (serviceName, reservations) => {
|
|
3706
|
+
const lines = [`Provisioning ${reservations.length} stream(s) for "${serviceName}":`];
|
|
3707
|
+
let totalFileMaxBytes = 0;
|
|
3708
|
+
for (const r of reservations) {
|
|
3709
|
+
if (r.storage === StorageType3.File) totalFileMaxBytes += r.maxBytes;
|
|
3710
|
+
const clusterReservation = r.maxBytes * r.numReplicas;
|
|
3711
|
+
lines.push(
|
|
3712
|
+
` \u2022 ${r.name} [${r.kind}] storage=${r.storage} replicas=${r.numReplicas} max_bytes=${formatBytes(r.maxBytes)} max_age=${formatAge(r.maxAge)} retention=${r.retention} \u2192 cluster reservation ${formatBytes(clusterReservation)}`
|
|
3713
|
+
);
|
|
3714
|
+
}
|
|
3715
|
+
lines.push(
|
|
3716
|
+
` \u03A3 per-node file-backed footprint \u2248 ${formatBytes(totalFileMaxBytes)} (sum of max_bytes; worst case replicas = nodes). Ensure the NATS server max_file_store accommodates the sum across ALL services.`
|
|
3717
|
+
);
|
|
3718
|
+
return lines.join("\n");
|
|
3719
|
+
};
|
|
3720
|
+
|
|
3514
3721
|
// src/server/infrastructure/stream-config-diff.ts
|
|
3515
3722
|
var TRANSPORT_CONTROLLED_PROPERTIES = /* @__PURE__ */ new Set([
|
|
3516
3723
|
"retention"
|
|
@@ -3568,85 +3775,192 @@ var isEqual = (a, b) => {
|
|
|
3568
3775
|
|
|
3569
3776
|
// src/server/infrastructure/stream-migration.ts
|
|
3570
3777
|
import { Logger as Logger12 } from "@nestjs/common";
|
|
3571
|
-
import {
|
|
3778
|
+
import {
|
|
3779
|
+
JetStreamApiError
|
|
3780
|
+
} from "@nats-io/jetstream";
|
|
3572
3781
|
var MIGRATION_BACKUP_SUFFIX = "__migration_backup";
|
|
3573
3782
|
var DEFAULT_SOURCING_TIMEOUT_MS = 3e4;
|
|
3574
3783
|
var SOURCING_POLL_INTERVAL_MS = 100;
|
|
3784
|
+
var DEFAULT_PEER_WAIT_MS = 6e4;
|
|
3785
|
+
var ACTIVE_MIGRATION_GRACE_MS = 9e4;
|
|
3786
|
+
var MIGRATION_STARTED_AT_KEY = "nestjs-jetstream-migration-started-at";
|
|
3575
3787
|
var StreamMigration = class {
|
|
3576
|
-
constructor(sourcingTimeoutMs = DEFAULT_SOURCING_TIMEOUT_MS) {
|
|
3788
|
+
constructor(sourcingTimeoutMs = DEFAULT_SOURCING_TIMEOUT_MS, peerWaitMs = DEFAULT_PEER_WAIT_MS) {
|
|
3577
3789
|
this.sourcingTimeoutMs = sourcingTimeoutMs;
|
|
3790
|
+
this.peerWaitMs = peerWaitMs;
|
|
3578
3791
|
}
|
|
3579
3792
|
logger = new Logger12("Jetstream:Stream");
|
|
3580
3793
|
async migrate(jsm, streamName2, newConfig) {
|
|
3581
3794
|
const backupName = `${streamName2}${MIGRATION_BACKUP_SUFFIX}`;
|
|
3582
3795
|
const startTime = Date.now();
|
|
3796
|
+
const peerFinished = await this.waitOutPeerMigration(jsm, backupName);
|
|
3583
3797
|
const currentInfo = await jsm.streams.info(streamName2);
|
|
3584
|
-
|
|
3585
|
-
|
|
3798
|
+
if (peerFinished && !compareStreamConfig(currentInfo.config, newConfig).hasImmutableChanges) {
|
|
3799
|
+
this.logger.log(`Stream ${streamName2}: migration completed by another instance`);
|
|
3800
|
+
await jsm.streams.update(streamName2, newConfig);
|
|
3801
|
+
return;
|
|
3802
|
+
}
|
|
3586
3803
|
this.logger.log(`Stream ${streamName2}: destructive migration started`);
|
|
3587
3804
|
let originalDeleted = false;
|
|
3805
|
+
let drainedCount = 0;
|
|
3588
3806
|
try {
|
|
3589
|
-
|
|
3590
|
-
|
|
3807
|
+
this.logger.log(` Phase 1/4: Quiescing ${streamName2} (publishes rejected during migration)`);
|
|
3808
|
+
await jsm.streams.update(streamName2, { ...currentInfo.config, subjects: [] });
|
|
3809
|
+
drainedCount = (await jsm.streams.info(streamName2)).state.messages;
|
|
3810
|
+
if (drainedCount > 0) {
|
|
3811
|
+
this.logger.log(` Phase 2/4: Backing up ${drainedCount} messages \u2192 ${backupName}`);
|
|
3591
3812
|
await jsm.streams.add({
|
|
3592
3813
|
...currentInfo.config,
|
|
3593
3814
|
name: backupName,
|
|
3594
3815
|
subjects: [],
|
|
3595
|
-
sources: [{ name: streamName2 }]
|
|
3816
|
+
sources: [{ name: streamName2 }],
|
|
3817
|
+
metadata: { [MIGRATION_STARTED_AT_KEY]: (/* @__PURE__ */ new Date()).toISOString() }
|
|
3596
3818
|
});
|
|
3597
|
-
await this.
|
|
3819
|
+
await this.waitForSourceDrained(jsm, backupName, streamName2, drainedCount);
|
|
3598
3820
|
}
|
|
3599
|
-
this.logger.log(` Phase
|
|
3821
|
+
this.logger.log(` Phase 3/4: Recreating ${streamName2} with the new config`);
|
|
3600
3822
|
await jsm.streams.delete(streamName2);
|
|
3601
3823
|
originalDeleted = true;
|
|
3602
|
-
this.logger.log(` Phase 3/4: Creating stream with new config`);
|
|
3603
3824
|
await jsm.streams.add(newConfig);
|
|
3604
|
-
if (
|
|
3605
|
-
|
|
3606
|
-
await
|
|
3607
|
-
this.logger.log(` Phase 4/4: Restoring ${messageCount} messages from backup`);
|
|
3608
|
-
await jsm.streams.update(streamName2, {
|
|
3609
|
-
...newConfig,
|
|
3610
|
-
sources: [{ name: backupName }]
|
|
3611
|
-
});
|
|
3612
|
-
await this.waitForSourcing(jsm, streamName2, messageCount);
|
|
3613
|
-
await jsm.streams.update(streamName2, { ...newConfig, sources: [] });
|
|
3614
|
-
await jsm.streams.delete(backupName);
|
|
3825
|
+
if (drainedCount > 0) {
|
|
3826
|
+
this.logger.log(` Phase 4/4: Restoring ${drainedCount} messages from backup`);
|
|
3827
|
+
await this.restoreFromBackup(jsm, streamName2, newConfig, backupName);
|
|
3615
3828
|
}
|
|
3616
3829
|
} catch (err) {
|
|
3617
|
-
if (originalDeleted
|
|
3830
|
+
if (originalDeleted) {
|
|
3618
3831
|
this.logger.error(
|
|
3619
|
-
`Migration failed after
|
|
3832
|
+
`Migration of ${streamName2} failed after the original was deleted. Backup ${backupName} preserved \u2014 restoration resumes on the next startup.`
|
|
3620
3833
|
);
|
|
3621
3834
|
} else {
|
|
3622
|
-
await this.
|
|
3835
|
+
await this.rollbackBeforeDelete(jsm, streamName2, currentInfo, backupName);
|
|
3623
3836
|
}
|
|
3624
3837
|
throw err;
|
|
3625
3838
|
}
|
|
3626
3839
|
const durationMs = Date.now() - startTime;
|
|
3627
3840
|
this.logger.log(
|
|
3628
|
-
`Stream ${streamName2}: migration complete (${
|
|
3841
|
+
`Stream ${streamName2}: migration complete (${drainedCount} messages preserved, took ${(durationMs / 1e3).toFixed(1)}s)`
|
|
3842
|
+
);
|
|
3843
|
+
}
|
|
3844
|
+
/**
|
|
3845
|
+
* Finish a migration a previous process left unfinished; a backup fresh
|
|
3846
|
+
* enough to belong to a live peer migration is left alone.
|
|
3847
|
+
*/
|
|
3848
|
+
async recoverInterrupted(jsm, streamName2, desiredConfig) {
|
|
3849
|
+
const backupName = `${streamName2}${MIGRATION_BACKUP_SUFFIX}`;
|
|
3850
|
+
const backupInfo = await this.tryInfo(jsm, backupName);
|
|
3851
|
+
if (backupInfo === null) return false;
|
|
3852
|
+
if (this.isPeerMigrationActive(backupInfo)) return false;
|
|
3853
|
+
const streamInfo = await this.tryInfo(jsm, streamName2);
|
|
3854
|
+
if (streamInfo === null) {
|
|
3855
|
+
this.logger.warn(`Stream ${streamName2}: resuming interrupted migration from ${backupName}`);
|
|
3856
|
+
await jsm.streams.add(desiredConfig);
|
|
3857
|
+
if (backupInfo.state.messages > 0) {
|
|
3858
|
+
await this.restoreFromBackup(jsm, streamName2, desiredConfig, backupName);
|
|
3859
|
+
} else {
|
|
3860
|
+
await jsm.streams.delete(backupName);
|
|
3861
|
+
}
|
|
3862
|
+
return true;
|
|
3863
|
+
}
|
|
3864
|
+
const hasBackupSource = (streamInfo.config.sources ?? []).some((s) => s.name === backupName);
|
|
3865
|
+
if (hasBackupSource) {
|
|
3866
|
+
this.logger.warn(`Stream ${streamName2}: finishing interrupted restore from ${backupName}`);
|
|
3867
|
+
await this.waitForSourceDrained(jsm, streamName2, backupName, backupInfo.state.messages);
|
|
3868
|
+
await jsm.streams.delete(backupName);
|
|
3869
|
+
await jsm.streams.update(streamName2, { ...streamInfo.config, sources: [] });
|
|
3870
|
+
return true;
|
|
3871
|
+
}
|
|
3872
|
+
if (backupInfo.state.messages === 0) {
|
|
3873
|
+
this.logger.warn(`Removing empty migration backup ${backupName}`);
|
|
3874
|
+
await jsm.streams.delete(backupName);
|
|
3875
|
+
return true;
|
|
3876
|
+
}
|
|
3877
|
+
this.logger.warn(
|
|
3878
|
+
`Stream ${streamName2}: restoring ${backupInfo.state.messages} messages from stale ${backupName}`
|
|
3879
|
+
);
|
|
3880
|
+
await this.restoreFromBackup(
|
|
3881
|
+
jsm,
|
|
3882
|
+
streamName2,
|
|
3883
|
+
{ ...streamInfo.config, name: streamName2, subjects: streamInfo.config.subjects },
|
|
3884
|
+
backupName
|
|
3629
3885
|
);
|
|
3886
|
+
return true;
|
|
3630
3887
|
}
|
|
3631
|
-
|
|
3888
|
+
/** Attach the backup as a source, drain it fully, then clean up. */
|
|
3889
|
+
async restoreFromBackup(jsm, streamName2, streamConfig, backupName) {
|
|
3890
|
+
const backupInfo = await jsm.streams.info(backupName);
|
|
3891
|
+
if ((backupInfo.config.sources ?? []).length > 0) {
|
|
3892
|
+
await jsm.streams.update(backupName, { ...backupInfo.config, sources: [] });
|
|
3893
|
+
}
|
|
3894
|
+
await jsm.streams.update(streamName2, { ...streamConfig, sources: [{ name: backupName }] });
|
|
3895
|
+
await this.waitForSourceDrained(jsm, streamName2, backupName, backupInfo.state.messages);
|
|
3896
|
+
await jsm.streams.delete(backupName);
|
|
3897
|
+
await jsm.streams.update(streamName2, { ...streamConfig, sources: [] });
|
|
3898
|
+
}
|
|
3899
|
+
/**
|
|
3900
|
+
* Lag-based drain check — live publishes cannot fake completion. A fresh
|
|
3901
|
+
* source reports lag 0 / active -1 before its first sync (NATS 2.12.6),
|
|
3902
|
+
* hence the active guard.
|
|
3903
|
+
*/
|
|
3904
|
+
async waitForSourceDrained(jsm, streamName2, sourceName, minimumMessages) {
|
|
3632
3905
|
const deadline = Date.now() + this.sourcingTimeoutMs;
|
|
3633
3906
|
while (Date.now() < deadline) {
|
|
3634
3907
|
const info = await jsm.streams.info(streamName2);
|
|
3635
|
-
|
|
3908
|
+
const source = (info.sources ?? []).find((s) => s.name === sourceName);
|
|
3909
|
+
if (source !== void 0 && source.active >= 0 && source.lag === 0 && info.state.messages >= minimumMessages) {
|
|
3910
|
+
return;
|
|
3911
|
+
}
|
|
3636
3912
|
await new Promise((r) => setTimeout(r, SOURCING_POLL_INTERVAL_MS));
|
|
3637
3913
|
}
|
|
3638
3914
|
throw new Error(
|
|
3639
|
-
`Stream sourcing timeout: ${
|
|
3915
|
+
`Stream sourcing timeout: ${sourceName} has not drained into ${streamName2} within ${this.sourcingTimeoutMs / 1e3}s. The backup is preserved; restoration resumes on the next startup.`
|
|
3916
|
+
);
|
|
3917
|
+
}
|
|
3918
|
+
/**
|
|
3919
|
+
* A backup present at migrate() start is a live peer migration — wait it
|
|
3920
|
+
* out. Stale leftovers were already handled by recoverInterrupted().
|
|
3921
|
+
*/
|
|
3922
|
+
async waitOutPeerMigration(jsm, backupName) {
|
|
3923
|
+
if (await this.tryInfo(jsm, backupName) === null) return false;
|
|
3924
|
+
this.logger.warn(
|
|
3925
|
+
`Migration backup ${backupName} exists \u2014 another instance appears to be migrating; waiting`
|
|
3926
|
+
);
|
|
3927
|
+
const deadline = Date.now() + this.peerWaitMs;
|
|
3928
|
+
while (Date.now() < deadline) {
|
|
3929
|
+
if (await this.tryInfo(jsm, backupName) === null) return true;
|
|
3930
|
+
await new Promise((r) => setTimeout(r, SOURCING_POLL_INTERVAL_MS * 5));
|
|
3931
|
+
}
|
|
3932
|
+
throw new Error(
|
|
3933
|
+
`Migration backup ${backupName} did not clear within ${this.peerWaitMs / 1e3}s. If no other instance is migrating, recover or remove the backup manually.`
|
|
3640
3934
|
);
|
|
3641
3935
|
}
|
|
3642
|
-
|
|
3936
|
+
/** Failure before the original was deleted: undo the quiesce, drop our backup. */
|
|
3937
|
+
async rollbackBeforeDelete(jsm, streamName2, originalInfo, backupName) {
|
|
3643
3938
|
try {
|
|
3644
|
-
await jsm.streams.
|
|
3645
|
-
|
|
3646
|
-
|
|
3939
|
+
await jsm.streams.update(streamName2, { ...originalInfo.config });
|
|
3940
|
+
const backupInfo = await this.tryInfo(jsm, backupName);
|
|
3941
|
+
if (backupInfo !== null) {
|
|
3942
|
+
await jsm.streams.delete(backupName);
|
|
3943
|
+
}
|
|
3944
|
+
} catch (rollbackErr) {
|
|
3945
|
+
this.logger.error(
|
|
3946
|
+
`Rollback of ${streamName2} after a failed migration also failed \u2014 the stream may be left quiesced:`,
|
|
3947
|
+
rollbackErr
|
|
3948
|
+
);
|
|
3949
|
+
}
|
|
3950
|
+
}
|
|
3951
|
+
isPeerMigrationActive(backupInfo) {
|
|
3952
|
+
const startedAt = backupInfo.config.metadata?.[MIGRATION_STARTED_AT_KEY];
|
|
3953
|
+
if (!startedAt) return false;
|
|
3954
|
+
const startedMs = Date.parse(startedAt);
|
|
3955
|
+
if (Number.isNaN(startedMs)) return false;
|
|
3956
|
+
return Date.now() - startedMs < ACTIVE_MIGRATION_GRACE_MS;
|
|
3957
|
+
}
|
|
3958
|
+
async tryInfo(jsm, name) {
|
|
3959
|
+
try {
|
|
3960
|
+
return await jsm.streams.info(name);
|
|
3647
3961
|
} catch (err) {
|
|
3648
3962
|
if (err instanceof JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
3649
|
-
return;
|
|
3963
|
+
return null;
|
|
3650
3964
|
}
|
|
3651
3965
|
throw err;
|
|
3652
3966
|
}
|
|
@@ -3654,6 +3968,17 @@ var StreamMigration = class {
|
|
|
3654
3968
|
};
|
|
3655
3969
|
|
|
3656
3970
|
// src/server/infrastructure/stream.provider.ts
|
|
3971
|
+
var subjectCovers = (broad, narrow) => {
|
|
3972
|
+
if (broad === narrow) return false;
|
|
3973
|
+
const broadTokens = broad.split(".");
|
|
3974
|
+
const narrowTokens = narrow.split(".");
|
|
3975
|
+
for (let i = 0; i < broadTokens.length; i += 1) {
|
|
3976
|
+
if (broadTokens[i] === ">") return i < narrowTokens.length;
|
|
3977
|
+
if (i >= narrowTokens.length || narrowTokens[i] === ">") return false;
|
|
3978
|
+
if (broadTokens[i] !== "*" && broadTokens[i] !== narrowTokens[i]) return false;
|
|
3979
|
+
}
|
|
3980
|
+
return broadTokens.length === narrowTokens.length;
|
|
3981
|
+
};
|
|
3657
3982
|
var StreamProvider = class {
|
|
3658
3983
|
constructor(options, connection) {
|
|
3659
3984
|
this.options = options;
|
|
@@ -3677,6 +4002,15 @@ var StreamProvider = class {
|
|
|
3677
4002
|
*/
|
|
3678
4003
|
async ensureStreams(kinds) {
|
|
3679
4004
|
const jsm = await this.connection.getJetStreamManager();
|
|
4005
|
+
const reservations = kinds.map((kind) => this.buildReservation(kind, this.buildConfig(kind)));
|
|
4006
|
+
if (this.options.dlq) {
|
|
4007
|
+
reservations.push(this.buildReservation("dlq", this.buildDlqConfig()));
|
|
4008
|
+
}
|
|
4009
|
+
this.logger.log(`
|
|
4010
|
+
${formatProvisioningSummary(this.options.name, reservations)}`);
|
|
4011
|
+
if (this.options.provisioning?.preflightStorageCheck) {
|
|
4012
|
+
await assertStorageBudget(jsm, this.options.name, reservations, this.logger);
|
|
4013
|
+
}
|
|
3680
4014
|
await Promise.all(kinds.map((kind) => this.ensureStream(jsm, kind)));
|
|
3681
4015
|
if (this.options.dlq) {
|
|
3682
4016
|
await this.ensureDlqStream(jsm);
|
|
@@ -3699,13 +4033,8 @@ var StreamProvider = class {
|
|
|
3699
4033
|
}
|
|
3700
4034
|
case "cmd" /* Command */:
|
|
3701
4035
|
return [`${name}.${"cmd" /* Command */}.>`];
|
|
3702
|
-
case "broadcast" /* Broadcast */:
|
|
3703
|
-
|
|
3704
|
-
if (this.isSchedulingEnabled(kind)) {
|
|
3705
|
-
subjects.push("broadcast._sch.>");
|
|
3706
|
-
}
|
|
3707
|
-
return subjects;
|
|
3708
|
-
}
|
|
4036
|
+
case "broadcast" /* Broadcast */:
|
|
4037
|
+
return ["broadcast.>"];
|
|
3709
4038
|
case "ordered" /* Ordered */:
|
|
3710
4039
|
return [`${name}.${"ordered" /* Ordered */}.>`];
|
|
3711
4040
|
}
|
|
@@ -3713,6 +4042,7 @@ var StreamProvider = class {
|
|
|
3713
4042
|
/** Ensure a single stream exists, creating or updating as needed. */
|
|
3714
4043
|
async ensureStream(jsm, kind) {
|
|
3715
4044
|
const config = this.buildConfig(kind);
|
|
4045
|
+
const ctx = this.errorContext(kind, config);
|
|
3716
4046
|
return withProvisioningSpan(
|
|
3717
4047
|
this.otel,
|
|
3718
4048
|
{
|
|
@@ -3720,17 +4050,21 @@ var StreamProvider = class {
|
|
|
3720
4050
|
endpoint: this.otelEndpoint,
|
|
3721
4051
|
entity: "stream",
|
|
3722
4052
|
name: config.name,
|
|
3723
|
-
action: "ensure"
|
|
4053
|
+
action: "ensure",
|
|
4054
|
+
maxBytes: ctx.maxBytes,
|
|
4055
|
+
numReplicas: ctx.numReplicas,
|
|
4056
|
+
reservation: ctx.maxBytes !== void 0 && ctx.numReplicas !== void 0 ? ctx.maxBytes * ctx.numReplicas : void 0
|
|
3724
4057
|
},
|
|
3725
4058
|
async () => {
|
|
3726
4059
|
this.logger.log(`Ensuring stream: ${config.name}`);
|
|
4060
|
+
await this.migration.recoverInterrupted(jsm, config.name, config);
|
|
3727
4061
|
try {
|
|
3728
4062
|
const currentInfo = await jsm.streams.info(config.name);
|
|
3729
|
-
return await this.handleExistingStream(jsm, currentInfo, config);
|
|
4063
|
+
return await this.handleExistingStream(jsm, currentInfo, config, ctx);
|
|
3730
4064
|
} catch (err) {
|
|
3731
4065
|
if (err instanceof JetStreamApiError2 && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
3732
4066
|
this.logger.log(`Creating stream: ${config.name}`);
|
|
3733
|
-
return await jsm.streams.add(config);
|
|
4067
|
+
return await this.runStreamOp(ctx, () => jsm.streams.add(config));
|
|
3734
4068
|
}
|
|
3735
4069
|
throw err;
|
|
3736
4070
|
}
|
|
@@ -3740,6 +4074,7 @@ var StreamProvider = class {
|
|
|
3740
4074
|
/** Ensure a dead-letter queue stream exists, creating or updating as needed. */
|
|
3741
4075
|
async ensureDlqStream(jsm) {
|
|
3742
4076
|
const config = this.buildDlqConfig();
|
|
4077
|
+
const ctx = this.errorContext("dlq", config);
|
|
3743
4078
|
return withProvisioningSpan(
|
|
3744
4079
|
this.otel,
|
|
3745
4080
|
{
|
|
@@ -3747,24 +4082,31 @@ var StreamProvider = class {
|
|
|
3747
4082
|
endpoint: this.otelEndpoint,
|
|
3748
4083
|
entity: "stream",
|
|
3749
4084
|
name: config.name,
|
|
3750
|
-
action: "ensure"
|
|
4085
|
+
action: "ensure",
|
|
4086
|
+
maxBytes: ctx.maxBytes,
|
|
4087
|
+
numReplicas: ctx.numReplicas,
|
|
4088
|
+
reservation: ctx.maxBytes !== void 0 && ctx.numReplicas !== void 0 ? ctx.maxBytes * ctx.numReplicas : void 0
|
|
3751
4089
|
},
|
|
3752
4090
|
async () => {
|
|
3753
4091
|
this.logger.log(`Ensuring DLQ stream: ${config.name}`);
|
|
3754
4092
|
try {
|
|
3755
4093
|
const currentInfo = await jsm.streams.info(config.name);
|
|
3756
|
-
return await this.handleExistingStream(jsm, currentInfo, config);
|
|
4094
|
+
return await this.handleExistingStream(jsm, currentInfo, config, ctx);
|
|
3757
4095
|
} catch (err) {
|
|
3758
4096
|
if (err instanceof JetStreamApiError2 && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
3759
4097
|
this.logger.log(`Creating DLQ stream: ${config.name}`);
|
|
3760
|
-
return await jsm.streams.add(config);
|
|
4098
|
+
return await this.runStreamOp(ctx, () => jsm.streams.add(config));
|
|
3761
4099
|
}
|
|
3762
4100
|
throw err;
|
|
3763
4101
|
}
|
|
3764
4102
|
}
|
|
3765
4103
|
);
|
|
3766
4104
|
}
|
|
3767
|
-
async handleExistingStream(jsm, currentInfo, config) {
|
|
4105
|
+
async handleExistingStream(jsm, currentInfo, config, ctx) {
|
|
4106
|
+
if (this.isSharedStream(config.name)) {
|
|
4107
|
+
const merged = [.../* @__PURE__ */ new Set([...config.subjects, ...currentInfo.config.subjects])];
|
|
4108
|
+
config.subjects = merged.filter((s) => !merged.some((other) => subjectCovers(other, s)));
|
|
4109
|
+
}
|
|
3768
4110
|
const diff = compareStreamConfig(currentInfo.config, config);
|
|
3769
4111
|
if (!diff.hasChanges) {
|
|
3770
4112
|
this.logger.debug(`Stream ${config.name}: no config changes`);
|
|
@@ -3779,7 +4121,7 @@ var StreamProvider = class {
|
|
|
3779
4121
|
}
|
|
3780
4122
|
if (!diff.hasImmutableChanges) {
|
|
3781
4123
|
this.logger.debug(`Stream exists, updating: ${config.name}`);
|
|
3782
|
-
return await jsm.streams.update(config.name, config);
|
|
4124
|
+
return await this.runStreamOp(ctx, () => jsm.streams.update(config.name, config));
|
|
3783
4125
|
}
|
|
3784
4126
|
if (!this.options.allowDestructiveMigration) {
|
|
3785
4127
|
this.logger.warn(
|
|
@@ -3787,10 +4129,15 @@ var StreamProvider = class {
|
|
|
3787
4129
|
);
|
|
3788
4130
|
if (diff.hasMutableChanges) {
|
|
3789
4131
|
const mutableConfig = this.buildMutableOnlyConfig(config, currentInfo.config, diff);
|
|
3790
|
-
return await jsm.streams.update(config.name, mutableConfig);
|
|
4132
|
+
return await this.runStreamOp(ctx, () => jsm.streams.update(config.name, mutableConfig));
|
|
3791
4133
|
}
|
|
3792
4134
|
return currentInfo;
|
|
3793
4135
|
}
|
|
4136
|
+
if (this.isSharedStream(config.name)) {
|
|
4137
|
+
throw new Error(
|
|
4138
|
+
`Stream ${config.name} is shared across services and cannot be destructively migrated: recreating it would delete every other service's durable broadcast consumers and replay retained history to them. Coordinate a manual migration instead.`
|
|
4139
|
+
);
|
|
4140
|
+
}
|
|
3794
4141
|
await withMigrationSpan(
|
|
3795
4142
|
this.otel,
|
|
3796
4143
|
{
|
|
@@ -3829,11 +4176,47 @@ var StreamProvider = class {
|
|
|
3829
4176
|
}
|
|
3830
4177
|
}
|
|
3831
4178
|
}
|
|
4179
|
+
buildReservation(kind, config) {
|
|
4180
|
+
const mb = config.max_bytes;
|
|
4181
|
+
return {
|
|
4182
|
+
kind,
|
|
4183
|
+
name: config.name,
|
|
4184
|
+
storage: config.storage ?? StorageType4.File,
|
|
4185
|
+
numReplicas: config.num_replicas ?? 1,
|
|
4186
|
+
maxBytes: mb !== void 0 && mb >= 0 ? mb : 0,
|
|
4187
|
+
// NATS uses -1 for unlimited
|
|
4188
|
+
maxAge: config.max_age ?? 0,
|
|
4189
|
+
retention: config.retention ?? RetentionPolicy2.Limits
|
|
4190
|
+
};
|
|
4191
|
+
}
|
|
4192
|
+
errorContext(kind, config) {
|
|
4193
|
+
return {
|
|
4194
|
+
entity: "stream",
|
|
4195
|
+
name: config.name,
|
|
4196
|
+
kind,
|
|
4197
|
+
maxBytes: config.max_bytes,
|
|
4198
|
+
numReplicas: config.num_replicas ?? 1
|
|
4199
|
+
};
|
|
4200
|
+
}
|
|
4201
|
+
async runStreamOp(ctx, op) {
|
|
4202
|
+
try {
|
|
4203
|
+
return await op();
|
|
4204
|
+
} catch (err) {
|
|
4205
|
+
if (err instanceof JetStreamApiError2) {
|
|
4206
|
+
throw mapProvisioningError(err, ctx);
|
|
4207
|
+
}
|
|
4208
|
+
throw err;
|
|
4209
|
+
}
|
|
4210
|
+
}
|
|
4211
|
+
/** The broadcast stream is global — every service in the cluster shares it. */
|
|
4212
|
+
isSharedStream(name) {
|
|
4213
|
+
return name === this.getStreamName("broadcast" /* Broadcast */);
|
|
4214
|
+
}
|
|
3832
4215
|
/** Build the full stream config by merging defaults with user overrides. */
|
|
3833
4216
|
buildConfig(kind) {
|
|
3834
4217
|
const name = this.getStreamName(kind);
|
|
3835
4218
|
const subjects = this.getSubjects(kind);
|
|
3836
|
-
const description = `JetStream ${kind} stream for ${this.options.name}`;
|
|
4219
|
+
const description = kind === "broadcast" /* Broadcast */ ? "JetStream broadcast stream (shared across services)" : `JetStream ${kind} stream for ${this.options.name}`;
|
|
3837
4220
|
const defaults = this.getDefaults(kind);
|
|
3838
4221
|
const overrides = this.getOverrides(kind);
|
|
3839
4222
|
return {
|
|
@@ -3975,15 +4358,16 @@ var ConsumerProvider = class {
|
|
|
3975
4358
|
},
|
|
3976
4359
|
async () => {
|
|
3977
4360
|
this.logger.log(`Ensuring consumer: ${name} on stream: ${stream}`);
|
|
4361
|
+
const ctx = { entity: "consumer", name, kind };
|
|
3978
4362
|
try {
|
|
3979
4363
|
await jsm.consumers.info(stream, name);
|
|
3980
4364
|
this.logger.debug(`Consumer exists, updating: ${name}`);
|
|
3981
|
-
return await jsm.consumers.update(stream, name, config);
|
|
4365
|
+
return await this.runConsumerOp(ctx, () => jsm.consumers.update(stream, name, config));
|
|
3982
4366
|
} catch (err) {
|
|
3983
4367
|
if (!(err instanceof JetStreamApiError3) || err.apiError().err_code !== 10014 /* ConsumerNotFound */) {
|
|
3984
4368
|
throw err;
|
|
3985
4369
|
}
|
|
3986
|
-
return await this.createConsumer(jsm, stream, name, config);
|
|
4370
|
+
return await this.createConsumer(jsm, stream, name, kind, config);
|
|
3987
4371
|
}
|
|
3988
4372
|
}
|
|
3989
4373
|
);
|
|
@@ -4023,7 +4407,7 @@ var ConsumerProvider = class {
|
|
|
4023
4407
|
if (!(err instanceof JetStreamApiError3) || err.apiError().err_code !== 10014 /* ConsumerNotFound */) {
|
|
4024
4408
|
throw err;
|
|
4025
4409
|
}
|
|
4026
|
-
return await this.createConsumer(jsm, stream, name, config);
|
|
4410
|
+
return await this.createConsumer(jsm, stream, name, kind, config);
|
|
4027
4411
|
}
|
|
4028
4412
|
}
|
|
4029
4413
|
);
|
|
@@ -4050,8 +4434,9 @@ var ConsumerProvider = class {
|
|
|
4050
4434
|
/**
|
|
4051
4435
|
* Create a consumer, handling the race where another pod creates it first.
|
|
4052
4436
|
*/
|
|
4053
|
-
async createConsumer(jsm, stream, name, config) {
|
|
4437
|
+
async createConsumer(jsm, stream, name, kind, config) {
|
|
4054
4438
|
this.logger.log(`Creating consumer: ${name}`);
|
|
4439
|
+
const ctx = { entity: "consumer", name, kind };
|
|
4055
4440
|
try {
|
|
4056
4441
|
return await jsm.consumers.add(stream, config);
|
|
4057
4442
|
} catch (addErr) {
|
|
@@ -4059,9 +4444,22 @@ var ConsumerProvider = class {
|
|
|
4059
4444
|
this.logger.debug(`Consumer ${name} created by another pod, using existing`);
|
|
4060
4445
|
return await jsm.consumers.info(stream, name);
|
|
4061
4446
|
}
|
|
4447
|
+
if (addErr instanceof JetStreamApiError3) {
|
|
4448
|
+
throw mapProvisioningError(addErr, ctx);
|
|
4449
|
+
}
|
|
4062
4450
|
throw addErr;
|
|
4063
4451
|
}
|
|
4064
4452
|
}
|
|
4453
|
+
async runConsumerOp(ctx, op) {
|
|
4454
|
+
try {
|
|
4455
|
+
return await op();
|
|
4456
|
+
} catch (err) {
|
|
4457
|
+
if (err instanceof JetStreamApiError3) {
|
|
4458
|
+
throw mapProvisioningError(err, ctx);
|
|
4459
|
+
}
|
|
4460
|
+
throw err;
|
|
4461
|
+
}
|
|
4462
|
+
}
|
|
4065
4463
|
/** Build consumer config by merging defaults with user overrides. */
|
|
4066
4464
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- NATS API uses snake_case
|
|
4067
4465
|
buildConfig(kind) {
|
|
@@ -4522,6 +4920,7 @@ var MetadataProvider = class {
|
|
|
4522
4920
|
// src/server/routing/event.router.ts
|
|
4523
4921
|
import { Logger as Logger17 } from "@nestjs/common";
|
|
4524
4922
|
import { headers as natsHeaders3 } from "@nats-io/transport-node";
|
|
4923
|
+
var DLQ_PUBLISH_ATTEMPTS = 3;
|
|
4525
4924
|
var eventConsumeKindFor = (kind) => {
|
|
4526
4925
|
if (kind === "broadcast" /* Broadcast */) return "broadcast" /* Broadcast */;
|
|
4527
4926
|
if (kind === "ordered" /* Ordered */) return "ordered" /* Ordered */;
|
|
@@ -4608,33 +5007,80 @@ var EventRouter = class {
|
|
|
4608
5007
|
return msg.info.deliveryCount >= maxDeliver;
|
|
4609
5008
|
};
|
|
4610
5009
|
const handleDeadLetter = hasDlqCheck ? (msg, data, err) => this.handleDeadLetter(msg, data, err) : null;
|
|
4611
|
-
const settleSuccess = (msg, ctx) => {
|
|
4612
|
-
if (ctx.shouldTerminate)
|
|
4613
|
-
|
|
4614
|
-
|
|
5010
|
+
const settleSuccess = (msg, ctx, data) => {
|
|
5011
|
+
if (ctx.shouldTerminate) {
|
|
5012
|
+
settleQuietly(logger5, `Failed to term ${msg.subject}:`, () => {
|
|
5013
|
+
msg.term(ctx.terminateReason);
|
|
5014
|
+
});
|
|
5015
|
+
return void 0;
|
|
5016
|
+
}
|
|
5017
|
+
if (ctx.shouldRetry) {
|
|
5018
|
+
if (handleDeadLetter !== null && isDeadLetter(msg)) {
|
|
5019
|
+
return handleDeadLetter(
|
|
5020
|
+
msg,
|
|
5021
|
+
data,
|
|
5022
|
+
new Error("Retry requested on the final delivery attempt")
|
|
5023
|
+
);
|
|
5024
|
+
}
|
|
5025
|
+
settleQuietly(logger5, `Failed to nak ${msg.subject}:`, () => {
|
|
5026
|
+
msg.nak(ctx.retryDelay);
|
|
5027
|
+
});
|
|
5028
|
+
return void 0;
|
|
5029
|
+
}
|
|
5030
|
+
settleQuietly(logger5, `Failed to ack ${msg.subject}:`, () => {
|
|
5031
|
+
msg.ack();
|
|
5032
|
+
});
|
|
5033
|
+
return void 0;
|
|
4615
5034
|
};
|
|
4616
5035
|
const settleFailure = async (msg, data, err) => {
|
|
4617
5036
|
if (handleDeadLetter !== null && isDeadLetter(msg)) {
|
|
4618
5037
|
await handleDeadLetter(msg, data, err);
|
|
4619
5038
|
return;
|
|
4620
5039
|
}
|
|
4621
|
-
msg.
|
|
5040
|
+
settleQuietly(logger5, `Failed to nak ${msg.subject}:`, () => {
|
|
5041
|
+
msg.nak();
|
|
5042
|
+
});
|
|
5043
|
+
};
|
|
5044
|
+
const captureUnroutable = (capture, msg, err) => {
|
|
5045
|
+
let data;
|
|
5046
|
+
try {
|
|
5047
|
+
data = codec.decode(msg.data);
|
|
5048
|
+
} catch {
|
|
5049
|
+
data = void 0;
|
|
5050
|
+
}
|
|
5051
|
+
return capture(msg, data, err).catch((captureErr) => {
|
|
5052
|
+
logger5.error(`Dead-letter capture failed for unroutable ${msg.subject}:`, captureErr);
|
|
5053
|
+
});
|
|
4622
5054
|
};
|
|
4623
5055
|
const resolveEvent = (msg) => {
|
|
4624
5056
|
const subject = msg.subject;
|
|
4625
5057
|
try {
|
|
4626
5058
|
const handler = patternRegistry.getHandler(subject);
|
|
4627
5059
|
if (!handler) {
|
|
4628
|
-
msg.term(`No handler for event: ${subject}`);
|
|
4629
5060
|
logger5.error(`No handler for subject: ${subject}`);
|
|
5061
|
+
if (handleDeadLetter !== null) {
|
|
5062
|
+
return captureUnroutable(
|
|
5063
|
+
handleDeadLetter,
|
|
5064
|
+
msg,
|
|
5065
|
+
new Error(`No handler for event: ${subject}`)
|
|
5066
|
+
);
|
|
5067
|
+
}
|
|
5068
|
+
msg.term(`No handler for event: ${subject}`);
|
|
4630
5069
|
return null;
|
|
4631
5070
|
}
|
|
4632
5071
|
let data;
|
|
4633
5072
|
try {
|
|
4634
5073
|
data = codec.decode(msg.data);
|
|
4635
5074
|
} catch (err) {
|
|
4636
|
-
msg.term("Decode error");
|
|
4637
5075
|
logger5.error(`Decode error for ${subject}:`, err);
|
|
5076
|
+
if (handleDeadLetter !== null) {
|
|
5077
|
+
return captureUnroutable(
|
|
5078
|
+
handleDeadLetter,
|
|
5079
|
+
msg,
|
|
5080
|
+
new Error(`Decode error: ${err instanceof Error ? err.message : String(err)}`)
|
|
5081
|
+
);
|
|
5082
|
+
}
|
|
5083
|
+
msg.term("Decode error");
|
|
4638
5084
|
return null;
|
|
4639
5085
|
}
|
|
4640
5086
|
eventBus.emitMessageRouted(subject, "event" /* Event */);
|
|
@@ -4657,6 +5103,7 @@ var EventRouter = class {
|
|
|
4657
5103
|
const handleSafe = (msg) => {
|
|
4658
5104
|
const resolved = resolveEvent(msg);
|
|
4659
5105
|
if (resolved === null) return void 0;
|
|
5106
|
+
if (isPromiseLike2(resolved)) return resolved;
|
|
4660
5107
|
const { handler, data } = resolved;
|
|
4661
5108
|
const ctx = new RpcContext([msg]);
|
|
4662
5109
|
const stopAckExtension = hasAckExtension ? startAckExtensionTimer(msg, ackExtensionInterval) : null;
|
|
@@ -4689,16 +5136,24 @@ var EventRouter = class {
|
|
|
4689
5136
|
});
|
|
4690
5137
|
}
|
|
4691
5138
|
if (!isPromiseLike2(pending)) {
|
|
4692
|
-
settleSuccess(msg, ctx);
|
|
5139
|
+
const settled = settleSuccess(msg, ctx, data);
|
|
4693
5140
|
reportHandlerCompleted(msg, startedAt, statusForContext(ctx));
|
|
4694
|
-
if (
|
|
4695
|
-
|
|
5141
|
+
if (settled === void 0) {
|
|
5142
|
+
if (stopAckExtension !== null) stopAckExtension();
|
|
5143
|
+
return void 0;
|
|
5144
|
+
}
|
|
5145
|
+
return settled.finally(() => {
|
|
5146
|
+
if (stopAckExtension !== null) stopAckExtension();
|
|
5147
|
+
});
|
|
4696
5148
|
}
|
|
4697
5149
|
return pending.then(
|
|
4698
|
-
() => {
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
5150
|
+
async () => {
|
|
5151
|
+
try {
|
|
5152
|
+
await settleSuccess(msg, ctx, data);
|
|
5153
|
+
reportHandlerCompleted(msg, startedAt, statusForContext(ctx));
|
|
5154
|
+
} finally {
|
|
5155
|
+
if (stopAckExtension !== null) stopAckExtension();
|
|
5156
|
+
}
|
|
4702
5157
|
},
|
|
4703
5158
|
async (err) => {
|
|
4704
5159
|
eventBus.emit(
|
|
@@ -4792,14 +5247,28 @@ var EventRouter = class {
|
|
|
4792
5247
|
active--;
|
|
4793
5248
|
drainBacklog();
|
|
4794
5249
|
};
|
|
5250
|
+
const routeSafely = (msg) => {
|
|
5251
|
+
try {
|
|
5252
|
+
return route(msg);
|
|
5253
|
+
} catch (err) {
|
|
5254
|
+
logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
|
|
5255
|
+
return void 0;
|
|
5256
|
+
}
|
|
5257
|
+
};
|
|
5258
|
+
const trackAsync = (result, msg) => {
|
|
5259
|
+
void result.catch((err) => {
|
|
5260
|
+
logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
|
|
5261
|
+
}).finally(onAsyncDone);
|
|
5262
|
+
};
|
|
4795
5263
|
const drainBacklog = () => {
|
|
4796
5264
|
while (active < maxActive) {
|
|
4797
5265
|
const next = backlog.shift();
|
|
4798
5266
|
if (next === void 0) return;
|
|
5267
|
+
next.stopAckExtension?.();
|
|
4799
5268
|
active++;
|
|
4800
|
-
const result =
|
|
5269
|
+
const result = routeSafely(next.msg);
|
|
4801
5270
|
if (result !== void 0) {
|
|
4802
|
-
|
|
5271
|
+
trackAsync(result, next.msg);
|
|
4803
5272
|
} else {
|
|
4804
5273
|
active--;
|
|
4805
5274
|
}
|
|
@@ -4809,7 +5278,10 @@ var EventRouter = class {
|
|
|
4809
5278
|
const subscription = stream$.subscribe({
|
|
4810
5279
|
next: (msg) => {
|
|
4811
5280
|
if (active >= maxActive) {
|
|
4812
|
-
backlog.push(
|
|
5281
|
+
backlog.push({
|
|
5282
|
+
msg,
|
|
5283
|
+
stopAckExtension: hasAckExtension ? startAckExtensionTimer(msg, ackExtensionInterval) : null
|
|
5284
|
+
});
|
|
4813
5285
|
if (!backlogWarned && backlog.length >= backlogWarnThreshold) {
|
|
4814
5286
|
backlogWarned = true;
|
|
4815
5287
|
logger5.warn(
|
|
@@ -4819,9 +5291,9 @@ var EventRouter = class {
|
|
|
4819
5291
|
return;
|
|
4820
5292
|
}
|
|
4821
5293
|
active++;
|
|
4822
|
-
const result =
|
|
5294
|
+
const result = routeSafely(msg);
|
|
4823
5295
|
if (result !== void 0) {
|
|
4824
|
-
|
|
5296
|
+
trackAsync(result, msg);
|
|
4825
5297
|
} else {
|
|
4826
5298
|
active--;
|
|
4827
5299
|
if (backlog.length > 0) drainBacklog();
|
|
@@ -4831,6 +5303,12 @@ var EventRouter = class {
|
|
|
4831
5303
|
logger5.error(`Stream error in ${kind} router`, err);
|
|
4832
5304
|
}
|
|
4833
5305
|
});
|
|
5306
|
+
subscription.add(() => {
|
|
5307
|
+
for (const queued of backlog) {
|
|
5308
|
+
queued.stopAckExtension?.();
|
|
5309
|
+
}
|
|
5310
|
+
backlog.length = 0;
|
|
5311
|
+
});
|
|
4834
5312
|
this.subscriptions.push(subscription);
|
|
4835
5313
|
}
|
|
4836
5314
|
getConcurrency(kind) {
|
|
@@ -4844,26 +5322,71 @@ var EventRouter = class {
|
|
|
4844
5322
|
return void 0;
|
|
4845
5323
|
}
|
|
4846
5324
|
/**
|
|
4847
|
-
* Last
|
|
4848
|
-
*
|
|
4849
|
-
* cycle. Used when DLQ stream isn't configured, or when publishing to it
|
|
4850
|
-
* failed and we still have to surface the message somewhere observable.
|
|
5325
|
+
* Last resort: invoke onDeadLetter, then term on success. On failure the
|
|
5326
|
+
* message is nak'd — never redelivered past max_deliver, but preserved.
|
|
4851
5327
|
*/
|
|
4852
5328
|
async fallbackToOnDeadLetterCallback(info, msg) {
|
|
4853
|
-
|
|
4854
|
-
|
|
5329
|
+
const onDeadLetter = this.deadLetterConfig?.onDeadLetter;
|
|
5330
|
+
if (!onDeadLetter) {
|
|
5331
|
+
this.logger.error(
|
|
5332
|
+
`Dead letter for ${msg.subject} could not be captured (DLQ publish failed, no onDeadLetter callback) \u2014 leaving the message in the stream`
|
|
5333
|
+
);
|
|
5334
|
+
settleQuietly(this.logger, `Failed to nak ${msg.subject}:`, () => {
|
|
5335
|
+
msg.nak();
|
|
5336
|
+
});
|
|
4855
5337
|
return;
|
|
4856
5338
|
}
|
|
4857
5339
|
try {
|
|
4858
|
-
await
|
|
4859
|
-
|
|
5340
|
+
await onDeadLetter(info);
|
|
5341
|
+
settleQuietly(this.logger, `Failed to term ${msg.subject}:`, () => {
|
|
5342
|
+
msg.term("Dead letter processed via fallback callback");
|
|
5343
|
+
});
|
|
4860
5344
|
} catch (hookErr) {
|
|
4861
5345
|
this.logger.error(
|
|
4862
|
-
`Fallback onDeadLetter callback failed for ${msg.subject}
|
|
5346
|
+
`Fallback onDeadLetter callback failed for ${msg.subject} \u2014 the message stays in the stream and will not be redelivered (max_deliver exhausted); recover it manually:`,
|
|
4863
5347
|
hookErr
|
|
4864
5348
|
);
|
|
4865
|
-
msg.
|
|
5349
|
+
settleQuietly(this.logger, `Failed to nak ${msg.subject}:`, () => {
|
|
5350
|
+
msg.nak();
|
|
5351
|
+
});
|
|
5352
|
+
}
|
|
5353
|
+
}
|
|
5354
|
+
/**
|
|
5355
|
+
* Copy headers for the DLQ republish, dropping NATS control headers — a
|
|
5356
|
+
* copied Nats-TTL would expire the DLQ entry, Nats-Msg-Id trips dedup.
|
|
5357
|
+
*/
|
|
5358
|
+
buildDlqHeaders(msg) {
|
|
5359
|
+
const hdrs = natsHeaders3();
|
|
5360
|
+
if (!msg.headers) return hdrs;
|
|
5361
|
+
for (const [k, v] of msg.headers) {
|
|
5362
|
+
if (k.toLowerCase().startsWith(NATS_CONTROL_HEADER_PREFIX)) continue;
|
|
5363
|
+
for (const val of v) {
|
|
5364
|
+
hdrs.append(k, val);
|
|
5365
|
+
}
|
|
5366
|
+
}
|
|
5367
|
+
return hdrs;
|
|
5368
|
+
}
|
|
5369
|
+
/**
|
|
5370
|
+
* Past max_deliver the server never redelivers, so these in-process attempts
|
|
5371
|
+
* are the only second chance a dead letter gets. No artificial delay — an
|
|
5372
|
+
* unreachable broker already spaces attempts via its own request timeout.
|
|
5373
|
+
*/
|
|
5374
|
+
async publishToDlqWithRetry(connection, subject, data, headers2) {
|
|
5375
|
+
let lastErr;
|
|
5376
|
+
for (let attempt = 1; attempt <= DLQ_PUBLISH_ATTEMPTS; attempt += 1) {
|
|
5377
|
+
try {
|
|
5378
|
+
await connection.getJetStreamClient().publish(subject, data, { headers: headers2 });
|
|
5379
|
+
return;
|
|
5380
|
+
} catch (err) {
|
|
5381
|
+
lastErr = err;
|
|
5382
|
+
if (attempt < DLQ_PUBLISH_ATTEMPTS) {
|
|
5383
|
+
this.logger.warn(
|
|
5384
|
+
`DLQ publish attempt ${attempt}/${DLQ_PUBLISH_ATTEMPTS} failed for ${subject}, retrying`
|
|
5385
|
+
);
|
|
5386
|
+
}
|
|
5387
|
+
}
|
|
4866
5388
|
}
|
|
5389
|
+
throw lastErr;
|
|
4867
5390
|
}
|
|
4868
5391
|
/**
|
|
4869
5392
|
* Publish a dead letter to the configured Dead-Letter Queue (DLQ) stream.
|
|
@@ -4883,14 +5406,7 @@ var EventRouter = class {
|
|
|
4883
5406
|
return;
|
|
4884
5407
|
}
|
|
4885
5408
|
const destinationSubject = dlqStreamName(serviceName);
|
|
4886
|
-
const hdrs =
|
|
4887
|
-
if (msg.headers) {
|
|
4888
|
-
for (const [k, v] of msg.headers) {
|
|
4889
|
-
for (const val of v) {
|
|
4890
|
-
hdrs.append(k, val);
|
|
4891
|
-
}
|
|
4892
|
-
}
|
|
4893
|
-
}
|
|
5409
|
+
const hdrs = this.buildDlqHeaders(msg);
|
|
4894
5410
|
let reason = String(error);
|
|
4895
5411
|
if (error instanceof Error) {
|
|
4896
5412
|
reason = error.message;
|
|
@@ -4903,8 +5419,7 @@ var EventRouter = class {
|
|
|
4903
5419
|
hdrs.set("x-failed-at" /* FailedAt */, (/* @__PURE__ */ new Date()).toISOString());
|
|
4904
5420
|
hdrs.set("x-delivery-count" /* DeliveryCount */, msg.info.deliveryCount.toString());
|
|
4905
5421
|
try {
|
|
4906
|
-
|
|
4907
|
-
await js.publish(destinationSubject, msg.data, { headers: hdrs });
|
|
5422
|
+
await this.publishToDlqWithRetry(this.connection, destinationSubject, msg.data, hdrs);
|
|
4908
5423
|
this.logger.log(`Message sent to DLQ: ${msg.subject}`);
|
|
4909
5424
|
if (this.deadLetterConfig?.onDeadLetter) {
|
|
4910
5425
|
try {
|
|
@@ -4916,7 +5431,9 @@ var EventRouter = class {
|
|
|
4916
5431
|
);
|
|
4917
5432
|
}
|
|
4918
5433
|
}
|
|
4919
|
-
|
|
5434
|
+
settleQuietly(this.logger, `Failed to term ${msg.subject}:`, () => {
|
|
5435
|
+
msg.term("Moved to DLQ stream");
|
|
5436
|
+
});
|
|
4920
5437
|
} catch (publishErr) {
|
|
4921
5438
|
this.logger.error(`Failed to publish to DLQ for ${msg.subject}:`, publishErr);
|
|
4922
5439
|
await this.fallbackToOnDeadLetterCallback(info, msg);
|
|
@@ -5110,7 +5627,9 @@ var RpcRouter = class {
|
|
|
5110
5627
|
`rpc-handler:${subject}`
|
|
5111
5628
|
);
|
|
5112
5629
|
publishErrorReply(replyTo, correlationId, subject, err);
|
|
5113
|
-
|
|
5630
|
+
settleQuietly(logger5, `Failed to term ${subject}:`, () => {
|
|
5631
|
+
msg.term(`Handler error: ${subject}`);
|
|
5632
|
+
});
|
|
5114
5633
|
};
|
|
5115
5634
|
const abortController = new AbortController();
|
|
5116
5635
|
let pending;
|
|
@@ -5138,7 +5657,9 @@ var RpcRouter = class {
|
|
|
5138
5657
|
}
|
|
5139
5658
|
if (!isPromiseLike2(pending)) {
|
|
5140
5659
|
if (stopAckExtension !== null) stopAckExtension();
|
|
5141
|
-
|
|
5660
|
+
settleQuietly(logger5, `Failed to ack ${subject}:`, () => {
|
|
5661
|
+
msg.ack();
|
|
5662
|
+
});
|
|
5142
5663
|
publishReply(replyTo, correlationId, pending);
|
|
5143
5664
|
reportHandlerCompleted(msg, startedAt, "success");
|
|
5144
5665
|
return void 0;
|
|
@@ -5150,7 +5671,9 @@ var RpcRouter = class {
|
|
|
5150
5671
|
if (stopAckExtension !== null) stopAckExtension();
|
|
5151
5672
|
abortController.abort();
|
|
5152
5673
|
emitRpcTimeout(subject, correlationId);
|
|
5153
|
-
|
|
5674
|
+
settleQuietly(logger5, `Failed to term ${subject}:`, () => {
|
|
5675
|
+
msg.term("Handler timeout");
|
|
5676
|
+
});
|
|
5154
5677
|
reportHandlerCompleted(msg, startedAt, "terminated");
|
|
5155
5678
|
}, timeout);
|
|
5156
5679
|
return pending.then(
|
|
@@ -5159,7 +5682,9 @@ var RpcRouter = class {
|
|
|
5159
5682
|
settled = true;
|
|
5160
5683
|
clearTimeout(timeoutId);
|
|
5161
5684
|
if (stopAckExtension !== null) stopAckExtension();
|
|
5162
|
-
|
|
5685
|
+
settleQuietly(logger5, `Failed to ack ${subject}:`, () => {
|
|
5686
|
+
msg.ack();
|
|
5687
|
+
});
|
|
5163
5688
|
publishReply(replyTo, correlationId, result);
|
|
5164
5689
|
reportHandlerCompleted(msg, startedAt, "success");
|
|
5165
5690
|
},
|
|
@@ -5181,14 +5706,28 @@ var RpcRouter = class {
|
|
|
5181
5706
|
active--;
|
|
5182
5707
|
drainBacklog();
|
|
5183
5708
|
};
|
|
5709
|
+
const routeSafely = (msg) => {
|
|
5710
|
+
try {
|
|
5711
|
+
return handleSafe(msg);
|
|
5712
|
+
} catch (err) {
|
|
5713
|
+
logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
|
|
5714
|
+
return void 0;
|
|
5715
|
+
}
|
|
5716
|
+
};
|
|
5717
|
+
const trackAsync = (result, msg) => {
|
|
5718
|
+
void result.catch((err) => {
|
|
5719
|
+
logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
|
|
5720
|
+
}).finally(onAsyncDone);
|
|
5721
|
+
};
|
|
5184
5722
|
const drainBacklog = () => {
|
|
5185
5723
|
while (active < maxActive) {
|
|
5186
5724
|
const next = backlog.shift();
|
|
5187
5725
|
if (next === void 0) return;
|
|
5726
|
+
next.stopAckExtension?.();
|
|
5188
5727
|
active++;
|
|
5189
|
-
const result =
|
|
5728
|
+
const result = routeSafely(next.msg);
|
|
5190
5729
|
if (result !== void 0) {
|
|
5191
|
-
|
|
5730
|
+
trackAsync(result, next.msg);
|
|
5192
5731
|
} else {
|
|
5193
5732
|
active--;
|
|
5194
5733
|
}
|
|
@@ -5198,7 +5737,10 @@ var RpcRouter = class {
|
|
|
5198
5737
|
this.subscription = this.messageProvider.commands$.subscribe({
|
|
5199
5738
|
next: (msg) => {
|
|
5200
5739
|
if (active >= maxActive) {
|
|
5201
|
-
backlog.push(
|
|
5740
|
+
backlog.push({
|
|
5741
|
+
msg,
|
|
5742
|
+
stopAckExtension: hasAckExtension ? startAckExtensionTimer(msg, ackExtensionInterval) : null
|
|
5743
|
+
});
|
|
5202
5744
|
if (!backlogWarned && backlog.length >= backlogWarnThreshold) {
|
|
5203
5745
|
backlogWarned = true;
|
|
5204
5746
|
logger5.warn(
|
|
@@ -5208,9 +5750,9 @@ var RpcRouter = class {
|
|
|
5208
5750
|
return;
|
|
5209
5751
|
}
|
|
5210
5752
|
active++;
|
|
5211
|
-
const result =
|
|
5753
|
+
const result = routeSafely(msg);
|
|
5212
5754
|
if (result !== void 0) {
|
|
5213
|
-
|
|
5755
|
+
trackAsync(result, msg);
|
|
5214
5756
|
} else {
|
|
5215
5757
|
active--;
|
|
5216
5758
|
if (backlog.length > 0) drainBacklog();
|
|
@@ -5220,6 +5762,12 @@ var RpcRouter = class {
|
|
|
5220
5762
|
logger5.error("Stream error in RPC router", err);
|
|
5221
5763
|
}
|
|
5222
5764
|
});
|
|
5765
|
+
this.subscription.add(() => {
|
|
5766
|
+
for (const queued of backlog) {
|
|
5767
|
+
queued.stopAckExtension?.();
|
|
5768
|
+
}
|
|
5769
|
+
backlog.length = 0;
|
|
5770
|
+
});
|
|
5223
5771
|
}
|
|
5224
5772
|
/** Stop routing and unsubscribe. */
|
|
5225
5773
|
destroy() {
|
|
@@ -5502,7 +6050,7 @@ var JetstreamModule = class {
|
|
|
5502
6050
|
],
|
|
5503
6051
|
useFactory: (options, messageProvider, patternRegistry, codec, eventBus, ackWaitMap, connection) => {
|
|
5504
6052
|
if (options.consumer === false) return null;
|
|
5505
|
-
const deadLetterConfig = options.onDeadLetter ? {
|
|
6053
|
+
const deadLetterConfig = options.onDeadLetter || options.dlq ? {
|
|
5506
6054
|
maxDeliverByStream: /* @__PURE__ */ new Map(),
|
|
5507
6055
|
onDeadLetter: options.onDeadLetter
|
|
5508
6056
|
} : void 0;
|
|
@@ -5702,6 +6250,7 @@ export {
|
|
|
5702
6250
|
JetstreamHeader,
|
|
5703
6251
|
JetstreamHealthIndicator,
|
|
5704
6252
|
JetstreamModule,
|
|
6253
|
+
JetstreamProvisioningError,
|
|
5705
6254
|
JetstreamRecord,
|
|
5706
6255
|
JetstreamRecordBuilder,
|
|
5707
6256
|
JetstreamStrategy,
|