@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.cjs
CHANGED
|
@@ -64,6 +64,7 @@ __export(index_exports, {
|
|
|
64
64
|
JetstreamHeader: () => JetstreamHeader,
|
|
65
65
|
JetstreamHealthIndicator: () => JetstreamHealthIndicator,
|
|
66
66
|
JetstreamModule: () => JetstreamModule,
|
|
67
|
+
JetstreamProvisioningError: () => JetstreamProvisioningError,
|
|
67
68
|
JetstreamRecord: () => JetstreamRecord,
|
|
68
69
|
JetstreamRecordBuilder: () => JetstreamRecordBuilder,
|
|
69
70
|
JetstreamStrategy: () => JetstreamStrategy,
|
|
@@ -210,7 +211,7 @@ var DEFAULT_ORDERED_STREAM_CONFIG = {
|
|
|
210
211
|
};
|
|
211
212
|
var DEFAULT_DLQ_STREAM_CONFIG = {
|
|
212
213
|
...baseStreamConfig,
|
|
213
|
-
retention: import_jetstream.RetentionPolicy.
|
|
214
|
+
retention: import_jetstream.RetentionPolicy.Limits,
|
|
214
215
|
allow_rollup_hdrs: false,
|
|
215
216
|
max_consumers: 100,
|
|
216
217
|
max_msg_size: 10 * MB,
|
|
@@ -274,6 +275,7 @@ var RESERVED_HEADERS = /* @__PURE__ */ new Set([
|
|
|
274
275
|
"x-reply-to" /* ReplyTo */,
|
|
275
276
|
"x-error" /* Error */
|
|
276
277
|
]);
|
|
278
|
+
var NATS_CONTROL_HEADER_PREFIX = "nats-";
|
|
277
279
|
var internalName = (name) => `${name}__microservice`;
|
|
278
280
|
var buildSubject = (serviceName, kind, pattern) => `${internalName(serviceName)}.${kind}.${pattern}`;
|
|
279
281
|
var buildBroadcastSubject = (pattern) => `broadcast.${pattern}`;
|
|
@@ -326,6 +328,9 @@ var ATTR_JETSTREAM_RPC_REPLY_ERROR_CODE = "jetstream.rpc.reply.error.code";
|
|
|
326
328
|
var ATTR_JETSTREAM_PROVISIONING_ENTITY = "jetstream.provisioning.entity";
|
|
327
329
|
var ATTR_JETSTREAM_PROVISIONING_ACTION = "jetstream.provisioning.action";
|
|
328
330
|
var ATTR_JETSTREAM_PROVISIONING_NAME = "jetstream.provisioning.name";
|
|
331
|
+
var ATTR_JETSTREAM_PROVISIONING_MAX_BYTES = "jetstream.provisioning.max_bytes";
|
|
332
|
+
var ATTR_JETSTREAM_PROVISIONING_NUM_REPLICAS = "jetstream.provisioning.num_replicas";
|
|
333
|
+
var ATTR_JETSTREAM_PROVISIONING_RESERVATION = "jetstream.provisioning.reservation_bytes";
|
|
329
334
|
var ATTR_JETSTREAM_SELF_HEALING_REASON = "jetstream.self_healing.reason";
|
|
330
335
|
var ATTR_JETSTREAM_MIGRATION_REASON = "jetstream.migration.reason";
|
|
331
336
|
var ATTR_JETSTREAM_DEAD_LETTER_REASON = "jetstream.dead_letter.reason";
|
|
@@ -593,7 +598,7 @@ var extractContext = (ctx, carrier, getter) => import_api.propagation.extract(ct
|
|
|
593
598
|
|
|
594
599
|
// src/otel/tracer.ts
|
|
595
600
|
var import_api2 = require("@opentelemetry/api");
|
|
596
|
-
var PACKAGE_VERSION = true ? "2.
|
|
601
|
+
var PACKAGE_VERSION = true ? "2.12.1" : "0.0.0";
|
|
597
602
|
var getTracer = () => import_api2.trace.getTracer(TRACER_NAME, PACKAGE_VERSION);
|
|
598
603
|
|
|
599
604
|
// src/otel/carrier.ts
|
|
@@ -1182,7 +1187,10 @@ var withProvisioningSpan = (config, ctx, op) => wrapInfra(
|
|
|
1182
1187
|
{
|
|
1183
1188
|
[ATTR_JETSTREAM_PROVISIONING_ENTITY]: ctx.entity,
|
|
1184
1189
|
[ATTR_JETSTREAM_PROVISIONING_ACTION]: ctx.action,
|
|
1185
|
-
[ATTR_JETSTREAM_PROVISIONING_NAME]: ctx.name
|
|
1190
|
+
[ATTR_JETSTREAM_PROVISIONING_NAME]: ctx.name,
|
|
1191
|
+
[ATTR_JETSTREAM_PROVISIONING_MAX_BYTES]: ctx.maxBytes,
|
|
1192
|
+
[ATTR_JETSTREAM_PROVISIONING_NUM_REPLICAS]: ctx.numReplicas,
|
|
1193
|
+
[ATTR_JETSTREAM_PROVISIONING_RESERVATION]: ctx.reservation
|
|
1186
1194
|
},
|
|
1187
1195
|
op
|
|
1188
1196
|
);
|
|
@@ -1358,7 +1366,13 @@ var JetstreamRecordBuilder = class {
|
|
|
1358
1366
|
* lockstep. `RESERVED_HEADERS` is defined as an all-lowercase set.
|
|
1359
1367
|
*/
|
|
1360
1368
|
validateHeaderKey(key) {
|
|
1361
|
-
|
|
1369
|
+
const normalized = key.toLowerCase();
|
|
1370
|
+
if (normalized.startsWith(NATS_CONTROL_HEADER_PREFIX)) {
|
|
1371
|
+
throw new Error(
|
|
1372
|
+
`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.`
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
if (RESERVED_HEADERS.has(normalized)) {
|
|
1362
1376
|
throw new Error(
|
|
1363
1377
|
`Header "${key}" is reserved by the JetStream transport and cannot be set manually. Reserved headers: ${[...RESERVED_HEADERS].join(", ")}`
|
|
1364
1378
|
);
|
|
@@ -1504,13 +1518,18 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
|
|
|
1504
1518
|
async dispatchEvent(packet) {
|
|
1505
1519
|
if (!this.readyForPublish) await this.connect();
|
|
1506
1520
|
const { data, hdrs, messageId, schedule, ttl } = this.extractRecordData(packet.data);
|
|
1521
|
+
const publishKind = detectEventKind(packet.pattern);
|
|
1522
|
+
if (schedule && publishKind === "ordered" /* Ordered */) {
|
|
1523
|
+
throw new Error(
|
|
1524
|
+
`scheduleAt() is not supported for ordered events (pattern: ${packet.pattern}). Scheduled delivery is available for workqueue events and broadcasts.`
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1507
1527
|
const eventSubject = this.buildEventSubject(packet.pattern);
|
|
1508
1528
|
const publishSubject = schedule ? this.buildScheduleSubject(eventSubject) : eventSubject;
|
|
1509
1529
|
const msgHeaders = this.buildHeaders(hdrs, { subject: eventSubject });
|
|
1510
1530
|
const encoded = this.codec.encode(data);
|
|
1511
1531
|
const effectiveMsgId = messageId ?? import_nuid.nuid.next();
|
|
1512
1532
|
const record = packet.data instanceof JetstreamRecord ? packet.data : new JetstreamRecord(data, /* @__PURE__ */ new Map());
|
|
1513
|
-
const publishKind = detectEventKind(packet.pattern);
|
|
1514
1533
|
const declaredPattern = declaredEventPattern(packet.pattern);
|
|
1515
1534
|
const streamKind = eventStreamKind(publishKind);
|
|
1516
1535
|
const startedAt = performance.now();
|
|
@@ -1542,10 +1561,10 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
|
|
|
1542
1561
|
const ack2 = await this.connection.getJetStreamClient().publish(publishSubject, encoded, {
|
|
1543
1562
|
headers: msgHeaders,
|
|
1544
1563
|
msgID: effectiveMsgId,
|
|
1545
|
-
ttl,
|
|
1546
1564
|
schedule: {
|
|
1547
1565
|
specification: schedule.at,
|
|
1548
|
-
target: eventSubject
|
|
1566
|
+
target: eventSubject,
|
|
1567
|
+
ttl
|
|
1549
1568
|
}
|
|
1550
1569
|
});
|
|
1551
1570
|
warnIfDuplicate("scheduled", ack2);
|
|
@@ -1735,13 +1754,18 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
|
|
|
1735
1754
|
});
|
|
1736
1755
|
return;
|
|
1737
1756
|
}
|
|
1738
|
-
await import_api8.context.with(
|
|
1757
|
+
const ack = await import_api8.context.with(
|
|
1739
1758
|
spanHandle.activeContext,
|
|
1740
1759
|
() => this.connection.getJetStreamClient().publish(subject, encoded, {
|
|
1741
1760
|
headers: hdrs,
|
|
1742
1761
|
msgID: messageId ?? import_nuid.nuid.next()
|
|
1743
1762
|
})
|
|
1744
1763
|
);
|
|
1764
|
+
if (ack.duplicate) {
|
|
1765
|
+
throw new Error(
|
|
1766
|
+
`Duplicate RPC publish for ${subject}: the messageId was already used within the stream dedup window, so the reply belongs to the original request`
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1745
1769
|
this.reportPublished(declaredPattern, "cmd" /* Command */, startedAt, "success");
|
|
1746
1770
|
} catch (err) {
|
|
1747
1771
|
const existingTimeout = this.pendingTimeouts.get(correlationId);
|
|
@@ -1911,13 +1935,17 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
|
|
|
1911
1935
|
* uses a separate `_sch` namespace that is NOT matched by any consumer filter.
|
|
1912
1936
|
* NATS holds the message and publishes it to the target subject after the delay.
|
|
1913
1937
|
*
|
|
1938
|
+
* A unique per-message suffix is appended because the server stores schedules
|
|
1939
|
+
* as rollup messages — one active schedule per subject (ADR-51). Without it,
|
|
1940
|
+
* concurrent schedules of the same pattern would silently replace each other.
|
|
1941
|
+
*
|
|
1914
1942
|
* Examples:
|
|
1915
|
-
* - `{svc}__microservice.ev.order.reminder` → `{svc}__microservice._sch.order.reminder
|
|
1916
|
-
* - `broadcast.config.updated` → `broadcast._sch.config.updated
|
|
1943
|
+
* - `{svc}__microservice.ev.order.reminder` → `{svc}__microservice._sch.order.reminder.<nuid>`
|
|
1944
|
+
* - `broadcast.config.updated` → `broadcast._sch.config.updated.<nuid>`
|
|
1917
1945
|
*/
|
|
1918
1946
|
buildScheduleSubject(eventSubject) {
|
|
1919
1947
|
if (eventSubject.startsWith("broadcast.")) {
|
|
1920
|
-
return eventSubject.replace("broadcast.", "broadcast._sch.")
|
|
1948
|
+
return `${eventSubject.replace("broadcast.", "broadcast._sch.")}.${import_nuid.nuid.next()}`;
|
|
1921
1949
|
}
|
|
1922
1950
|
const targetPrefix = `${internalName(this.targetName)}.`;
|
|
1923
1951
|
if (!eventSubject.startsWith(targetPrefix)) {
|
|
@@ -1929,7 +1957,7 @@ var JetstreamClient = class extends import_microservices.ClientProxy {
|
|
|
1929
1957
|
throw new Error(`Event subject missing pattern segment: ${eventSubject}`);
|
|
1930
1958
|
}
|
|
1931
1959
|
const pattern = withoutPrefix.slice(dotIndex + 1);
|
|
1932
|
-
return `${targetPrefix}_sch.${pattern}`;
|
|
1960
|
+
return `${targetPrefix}_sch.${pattern}.${import_nuid.nuid.next()}`;
|
|
1933
1961
|
}
|
|
1934
1962
|
};
|
|
1935
1963
|
|
|
@@ -1941,6 +1969,7 @@ var JsonCodec = class {
|
|
|
1941
1969
|
return encoder.encode(JSON.stringify(data));
|
|
1942
1970
|
}
|
|
1943
1971
|
decode(data) {
|
|
1972
|
+
if (data.length === 0) return void 0;
|
|
1944
1973
|
return JSON.parse(decoder.decode(data));
|
|
1945
1974
|
}
|
|
1946
1975
|
};
|
|
@@ -1954,6 +1983,7 @@ var MsgpackCodec = class {
|
|
|
1954
1983
|
return this.packr.pack(data);
|
|
1955
1984
|
}
|
|
1956
1985
|
decode(data) {
|
|
1986
|
+
if (data.length === 0) return void 0;
|
|
1957
1987
|
return this.packr.unpack(data);
|
|
1958
1988
|
}
|
|
1959
1989
|
};
|
|
@@ -3024,43 +3054,11 @@ var JetstreamStrategy = class extends import_microservices2.Server {
|
|
|
3024
3054
|
* Called by NestJS when `connectMicroservice()` is used, or internally by the module.
|
|
3025
3055
|
*/
|
|
3026
3056
|
async listen(callback) {
|
|
3027
|
-
|
|
3028
|
-
this.
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
this.started = true;
|
|
3032
|
-
this.patternRegistry.registerHandlers(this.getHandlers());
|
|
3033
|
-
const { streams: streamKinds, durableConsumers: durableKinds } = this.resolveRequiredKinds();
|
|
3034
|
-
if (streamKinds.length > 0) {
|
|
3035
|
-
await this.streamProvider.ensureStreams(streamKinds);
|
|
3036
|
-
if (durableKinds.length > 0) {
|
|
3037
|
-
const consumers = await this.consumerProvider.ensureConsumers(durableKinds);
|
|
3038
|
-
this.populateAckWaitMap(consumers);
|
|
3039
|
-
this.eventRouter.updateMaxDeliverMap(this.buildMaxDeliverMap(consumers));
|
|
3040
|
-
this.messageProvider.start(consumers);
|
|
3041
|
-
}
|
|
3042
|
-
if (this.patternRegistry.hasOrderedHandlers()) {
|
|
3043
|
-
const orderedStreamName = this.streamProvider.getStreamName("ordered" /* Ordered */);
|
|
3044
|
-
await this.messageProvider.startOrdered(
|
|
3045
|
-
orderedStreamName,
|
|
3046
|
-
this.patternRegistry.getOrderedSubjects(),
|
|
3047
|
-
this.options.ordered
|
|
3048
|
-
);
|
|
3049
|
-
}
|
|
3050
|
-
if (this.patternRegistry.hasEventHandlers() || this.patternRegistry.hasBroadcastHandlers() || this.patternRegistry.hasOrderedHandlers()) {
|
|
3051
|
-
this.eventRouter.start();
|
|
3052
|
-
}
|
|
3053
|
-
if (isJetStreamRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
|
|
3054
|
-
await this.rpcRouter.start();
|
|
3055
|
-
}
|
|
3056
|
-
}
|
|
3057
|
-
if (isCoreRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
|
|
3058
|
-
await this.coreRpcServer.start();
|
|
3059
|
-
}
|
|
3060
|
-
if (this.metadataProvider && this.patternRegistry.hasMetadata()) {
|
|
3061
|
-
await this.metadataProvider.publish(this.patternRegistry.getMetadataEntries());
|
|
3057
|
+
try {
|
|
3058
|
+
await this.doListen(callback);
|
|
3059
|
+
} catch (err) {
|
|
3060
|
+
callback(err);
|
|
3062
3061
|
}
|
|
3063
|
-
callback();
|
|
3064
3062
|
}
|
|
3065
3063
|
/** Stop all consumers, routers, subscriptions, and metadata heartbeat. Called during shutdown. */
|
|
3066
3064
|
close() {
|
|
@@ -3119,6 +3117,33 @@ var JetstreamStrategy = class extends import_microservices2.Server {
|
|
|
3119
3117
|
getPatternRegistry() {
|
|
3120
3118
|
return this.patternRegistry;
|
|
3121
3119
|
}
|
|
3120
|
+
async doListen(callback) {
|
|
3121
|
+
if (this.started) {
|
|
3122
|
+
this.logger.warn("listen() called more than once \u2014 ignoring");
|
|
3123
|
+
return;
|
|
3124
|
+
}
|
|
3125
|
+
this.started = true;
|
|
3126
|
+
this.patternRegistry.registerHandlers(this.getHandlers());
|
|
3127
|
+
const { streams: streamKinds, durableConsumers: durableKinds } = this.resolveRequiredKinds();
|
|
3128
|
+
if (streamKinds.length > 0) {
|
|
3129
|
+
await this.streamProvider.ensureStreams(streamKinds);
|
|
3130
|
+
let consumers = null;
|
|
3131
|
+
if (durableKinds.length > 0) {
|
|
3132
|
+
consumers = await this.consumerProvider.ensureConsumers(durableKinds);
|
|
3133
|
+
this.populateAckWaitMap(consumers);
|
|
3134
|
+
this.eventRouter.updateMaxDeliverMap(this.buildMaxDeliverMap(consumers));
|
|
3135
|
+
}
|
|
3136
|
+
await this.startRouters();
|
|
3137
|
+
await this.startConsumption(consumers);
|
|
3138
|
+
}
|
|
3139
|
+
if (isCoreRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
|
|
3140
|
+
await this.coreRpcServer.start();
|
|
3141
|
+
}
|
|
3142
|
+
if (this.metadataProvider && this.patternRegistry.hasMetadata()) {
|
|
3143
|
+
await this.metadataProvider.publish(this.patternRegistry.getMetadataEntries());
|
|
3144
|
+
}
|
|
3145
|
+
callback();
|
|
3146
|
+
}
|
|
3122
3147
|
/** Determine which streams and durable consumers are needed. */
|
|
3123
3148
|
resolveRequiredKinds() {
|
|
3124
3149
|
const streams = [];
|
|
@@ -3140,7 +3165,29 @@ var JetstreamStrategy = class extends import_microservices2.Server {
|
|
|
3140
3165
|
}
|
|
3141
3166
|
return { streams, durableConsumers };
|
|
3142
3167
|
}
|
|
3143
|
-
/**
|
|
3168
|
+
/** Subscribe the event and RPC routers to the message subjects. */
|
|
3169
|
+
async startRouters() {
|
|
3170
|
+
if (this.patternRegistry.hasEventHandlers() || this.patternRegistry.hasBroadcastHandlers() || this.patternRegistry.hasOrderedHandlers()) {
|
|
3171
|
+
this.eventRouter.start();
|
|
3172
|
+
}
|
|
3173
|
+
if (isJetStreamRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
|
|
3174
|
+
await this.rpcRouter.start();
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
/** Begin durable and ordered consumption; routers must already be subscribed. */
|
|
3178
|
+
async startConsumption(consumers) {
|
|
3179
|
+
if (consumers !== null) {
|
|
3180
|
+
this.messageProvider.start(consumers);
|
|
3181
|
+
}
|
|
3182
|
+
if (this.patternRegistry.hasOrderedHandlers()) {
|
|
3183
|
+
const orderedStreamName = this.streamProvider.getStreamName("ordered" /* Ordered */);
|
|
3184
|
+
await this.messageProvider.startOrdered(
|
|
3185
|
+
orderedStreamName,
|
|
3186
|
+
this.patternRegistry.getOrderedSubjects(),
|
|
3187
|
+
this.options.ordered
|
|
3188
|
+
);
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3144
3191
|
populateAckWaitMap(consumers) {
|
|
3145
3192
|
for (const [kind, info] of consumers) {
|
|
3146
3193
|
if (info.config.ack_wait) {
|
|
@@ -3379,6 +3426,15 @@ var serializeError = (err) => {
|
|
|
3379
3426
|
return err;
|
|
3380
3427
|
};
|
|
3381
3428
|
|
|
3429
|
+
// src/utils/settle-quietly.ts
|
|
3430
|
+
var settleQuietly = (logger5, label, action) => {
|
|
3431
|
+
try {
|
|
3432
|
+
action();
|
|
3433
|
+
} catch (err) {
|
|
3434
|
+
logger5.error(label, err);
|
|
3435
|
+
}
|
|
3436
|
+
};
|
|
3437
|
+
|
|
3382
3438
|
// src/utils/unwrap-result.ts
|
|
3383
3439
|
var import_rxjs2 = require("rxjs");
|
|
3384
3440
|
var unwrapResult = (result) => {
|
|
@@ -3544,16 +3600,164 @@ var CoreRpcServer = class {
|
|
|
3544
3600
|
|
|
3545
3601
|
// src/server/infrastructure/stream.provider.ts
|
|
3546
3602
|
var import_common14 = require("@nestjs/common");
|
|
3547
|
-
var
|
|
3603
|
+
var import_jetstream19 = require("@nats-io/jetstream");
|
|
3548
3604
|
|
|
3549
3605
|
// src/server/infrastructure/nats-error-codes.ts
|
|
3550
3606
|
var NatsErrorCode = /* @__PURE__ */ ((NatsErrorCode2) => {
|
|
3551
3607
|
NatsErrorCode2[NatsErrorCode2["ConsumerNotFound"] = 10014] = "ConsumerNotFound";
|
|
3552
3608
|
NatsErrorCode2[NatsErrorCode2["ConsumerAlreadyExists"] = 10148] = "ConsumerAlreadyExists";
|
|
3553
3609
|
NatsErrorCode2[NatsErrorCode2["StreamNotFound"] = 10059] = "StreamNotFound";
|
|
3610
|
+
NatsErrorCode2[NatsErrorCode2["StorageResourcesExceeded"] = 10047] = "StorageResourcesExceeded";
|
|
3611
|
+
NatsErrorCode2[NatsErrorCode2["NoSuitablePeers"] = 10005] = "NoSuitablePeers";
|
|
3554
3612
|
return NatsErrorCode2;
|
|
3555
3613
|
})(NatsErrorCode || {});
|
|
3556
3614
|
|
|
3615
|
+
// src/server/infrastructure/provisioning-budget.ts
|
|
3616
|
+
var import_jetstream16 = require("@nats-io/jetstream");
|
|
3617
|
+
var GIB = 1024 ** 3;
|
|
3618
|
+
var fmt = (bytes) => `${(bytes / GIB).toFixed(2)} GiB`;
|
|
3619
|
+
var resolveTierBudget = (info, replicas) => {
|
|
3620
|
+
const tier = info.tiers?.[`R${replicas}`];
|
|
3621
|
+
const limits = tier?.limits ?? info.limits;
|
|
3622
|
+
return {
|
|
3623
|
+
maxStorage: limits?.max_storage ?? 0,
|
|
3624
|
+
reserved: tier?.reserved_storage ?? info.reserved_storage ?? 0,
|
|
3625
|
+
tiered: tier !== void 0
|
|
3626
|
+
};
|
|
3627
|
+
};
|
|
3628
|
+
var groupByReplicas = (reservations) => {
|
|
3629
|
+
const groups = /* @__PURE__ */ new Map();
|
|
3630
|
+
for (const r of reservations) {
|
|
3631
|
+
if (r.storage !== import_jetstream16.StorageType.File) continue;
|
|
3632
|
+
const prev = groups.get(r.numReplicas) ?? 0;
|
|
3633
|
+
groups.set(r.numReplicas, prev + r.maxBytes * r.numReplicas);
|
|
3634
|
+
}
|
|
3635
|
+
return groups;
|
|
3636
|
+
};
|
|
3637
|
+
var assertStorageBudget = async (jsm, serviceName, reservations, logger5) => {
|
|
3638
|
+
try {
|
|
3639
|
+
const info = await jsm.getAccountInfo();
|
|
3640
|
+
const groups = groupByReplicas(reservations);
|
|
3641
|
+
let limitNotSetWarned = false;
|
|
3642
|
+
let okReserved = 0;
|
|
3643
|
+
let anyWarned = false;
|
|
3644
|
+
for (const [replicas, incremental] of groups) {
|
|
3645
|
+
const { maxStorage, reserved, tiered } = resolveTierBudget(info, replicas);
|
|
3646
|
+
const tierNote = tiered ? ` (tier R${replicas})` : "";
|
|
3647
|
+
if (maxStorage <= 0) {
|
|
3648
|
+
if (!limitNotSetWarned) {
|
|
3649
|
+
limitNotSetWarned = true;
|
|
3650
|
+
logger5.warn(
|
|
3651
|
+
`Storage preflight for "${serviceName}": account file-storage limit not set (max_storage=${maxStorage}); the server max_file_store cannot be verified from the client.`
|
|
3652
|
+
);
|
|
3653
|
+
}
|
|
3654
|
+
continue;
|
|
3655
|
+
}
|
|
3656
|
+
const remaining = maxStorage - reserved;
|
|
3657
|
+
if (incremental > remaining) {
|
|
3658
|
+
anyWarned = true;
|
|
3659
|
+
logger5.warn(
|
|
3660
|
+
`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.`
|
|
3661
|
+
);
|
|
3662
|
+
continue;
|
|
3663
|
+
}
|
|
3664
|
+
okReserved += incremental;
|
|
3665
|
+
}
|
|
3666
|
+
if (!anyWarned && !limitNotSetWarned && okReserved > 0) {
|
|
3667
|
+
logger5.log(
|
|
3668
|
+
`Storage preflight for "${serviceName}" OK: reserving ~${fmt(okReserved)} across file-backed streams within account limits.`
|
|
3669
|
+
);
|
|
3670
|
+
}
|
|
3671
|
+
} catch (err) {
|
|
3672
|
+
logger5.debug(`Storage preflight skipped \u2014 account info unavailable: ${String(err)}`);
|
|
3673
|
+
}
|
|
3674
|
+
};
|
|
3675
|
+
|
|
3676
|
+
// src/server/infrastructure/provisioning-error.ts
|
|
3677
|
+
var REMEDIATION = {
|
|
3678
|
+
[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.",
|
|
3679
|
+
[10005 /* NoSuitablePeers */]: "Fewer healthy peers than `num_replicas`, or no peer has enough reserved storage headroom. Reduce replicas or add/repair cluster nodes."
|
|
3680
|
+
};
|
|
3681
|
+
var GENERIC_REMEDIATION = "Inspect the NATS server logs and JetStream account limits for the underlying cause.";
|
|
3682
|
+
var JetstreamProvisioningError = class _JetstreamProvisioningError extends Error {
|
|
3683
|
+
entity;
|
|
3684
|
+
target;
|
|
3685
|
+
kind;
|
|
3686
|
+
errCode;
|
|
3687
|
+
errDescription;
|
|
3688
|
+
remediation;
|
|
3689
|
+
maxBytes;
|
|
3690
|
+
numReplicas;
|
|
3691
|
+
reservation;
|
|
3692
|
+
constructor(fields) {
|
|
3693
|
+
const reservationNote = fields.reservation !== void 0 ? ` reservation=${fields.reservation}B (max_bytes=${fields.maxBytes}B \xD7 replicas=${fields.numReplicas}).` : "";
|
|
3694
|
+
super(
|
|
3695
|
+
`JetStream ${fields.entity} provisioning failed for "${fields.target}" (kind=${fields.kind}): ${fields.errDescription} [err_code=${fields.errCode}].${reservationNote} ${fields.remediation}`,
|
|
3696
|
+
{ cause: fields.cause }
|
|
3697
|
+
);
|
|
3698
|
+
this.name = "JetstreamProvisioningError";
|
|
3699
|
+
this.entity = fields.entity;
|
|
3700
|
+
this.target = fields.target;
|
|
3701
|
+
this.kind = fields.kind;
|
|
3702
|
+
this.errCode = fields.errCode;
|
|
3703
|
+
this.errDescription = fields.errDescription;
|
|
3704
|
+
this.remediation = fields.remediation;
|
|
3705
|
+
this.maxBytes = fields.maxBytes;
|
|
3706
|
+
this.numReplicas = fields.numReplicas;
|
|
3707
|
+
this.reservation = fields.reservation;
|
|
3708
|
+
Object.setPrototypeOf(this, _JetstreamProvisioningError.prototype);
|
|
3709
|
+
}
|
|
3710
|
+
};
|
|
3711
|
+
var mapProvisioningError = (err, ctx) => {
|
|
3712
|
+
const api = err.apiError();
|
|
3713
|
+
const remediation = REMEDIATION[api.err_code] ?? GENERIC_REMEDIATION;
|
|
3714
|
+
const reservation = ctx.maxBytes !== void 0 && ctx.numReplicas !== void 0 ? ctx.maxBytes * ctx.numReplicas : void 0;
|
|
3715
|
+
return new JetstreamProvisioningError({
|
|
3716
|
+
entity: ctx.entity,
|
|
3717
|
+
target: ctx.name,
|
|
3718
|
+
kind: ctx.kind,
|
|
3719
|
+
errCode: api.err_code,
|
|
3720
|
+
errDescription: api.description,
|
|
3721
|
+
remediation,
|
|
3722
|
+
maxBytes: ctx.maxBytes,
|
|
3723
|
+
numReplicas: ctx.numReplicas,
|
|
3724
|
+
reservation,
|
|
3725
|
+
cause: err
|
|
3726
|
+
});
|
|
3727
|
+
};
|
|
3728
|
+
|
|
3729
|
+
// src/server/infrastructure/provisioning-summary.ts
|
|
3730
|
+
var import_jetstream17 = require("@nats-io/jetstream");
|
|
3731
|
+
var GIB2 = 1024 ** 3;
|
|
3732
|
+
var NANOS_PER_SECOND = 1e9;
|
|
3733
|
+
var NANOS_PER_HOUR = 3600 * NANOS_PER_SECOND;
|
|
3734
|
+
var NANOS_PER_DAY = 86400 * NANOS_PER_SECOND;
|
|
3735
|
+
var formatBytes = (bytes) => {
|
|
3736
|
+
if (bytes <= 0) return "0 B";
|
|
3737
|
+
return `${(bytes / GIB2).toFixed(2)} GiB`;
|
|
3738
|
+
};
|
|
3739
|
+
var formatAge = (nanos) => {
|
|
3740
|
+
if (nanos <= 0) return "unlimited";
|
|
3741
|
+
if (nanos >= NANOS_PER_DAY) return `${(nanos / NANOS_PER_DAY).toFixed(1)}d`;
|
|
3742
|
+
if (nanos >= NANOS_PER_HOUR) return `${(nanos / NANOS_PER_HOUR).toFixed(1)}h`;
|
|
3743
|
+
return `${(nanos / NANOS_PER_SECOND).toFixed(0)}s`;
|
|
3744
|
+
};
|
|
3745
|
+
var formatProvisioningSummary = (serviceName, reservations) => {
|
|
3746
|
+
const lines = [`Provisioning ${reservations.length} stream(s) for "${serviceName}":`];
|
|
3747
|
+
let totalFileMaxBytes = 0;
|
|
3748
|
+
for (const r of reservations) {
|
|
3749
|
+
if (r.storage === import_jetstream17.StorageType.File) totalFileMaxBytes += r.maxBytes;
|
|
3750
|
+
const clusterReservation = r.maxBytes * r.numReplicas;
|
|
3751
|
+
lines.push(
|
|
3752
|
+
` \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)}`
|
|
3753
|
+
);
|
|
3754
|
+
}
|
|
3755
|
+
lines.push(
|
|
3756
|
+
` \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.`
|
|
3757
|
+
);
|
|
3758
|
+
return lines.join("\n");
|
|
3759
|
+
};
|
|
3760
|
+
|
|
3557
3761
|
// src/server/infrastructure/stream-config-diff.ts
|
|
3558
3762
|
var TRANSPORT_CONTROLLED_PROPERTIES = /* @__PURE__ */ new Set([
|
|
3559
3763
|
"retention"
|
|
@@ -3611,85 +3815,190 @@ var isEqual = (a, b) => {
|
|
|
3611
3815
|
|
|
3612
3816
|
// src/server/infrastructure/stream-migration.ts
|
|
3613
3817
|
var import_common13 = require("@nestjs/common");
|
|
3614
|
-
var
|
|
3818
|
+
var import_jetstream18 = require("@nats-io/jetstream");
|
|
3615
3819
|
var MIGRATION_BACKUP_SUFFIX = "__migration_backup";
|
|
3616
3820
|
var DEFAULT_SOURCING_TIMEOUT_MS = 3e4;
|
|
3617
3821
|
var SOURCING_POLL_INTERVAL_MS = 100;
|
|
3822
|
+
var DEFAULT_PEER_WAIT_MS = 6e4;
|
|
3823
|
+
var ACTIVE_MIGRATION_GRACE_MS = 9e4;
|
|
3824
|
+
var MIGRATION_STARTED_AT_KEY = "nestjs-jetstream-migration-started-at";
|
|
3618
3825
|
var StreamMigration = class {
|
|
3619
|
-
constructor(sourcingTimeoutMs = DEFAULT_SOURCING_TIMEOUT_MS) {
|
|
3826
|
+
constructor(sourcingTimeoutMs = DEFAULT_SOURCING_TIMEOUT_MS, peerWaitMs = DEFAULT_PEER_WAIT_MS) {
|
|
3620
3827
|
this.sourcingTimeoutMs = sourcingTimeoutMs;
|
|
3828
|
+
this.peerWaitMs = peerWaitMs;
|
|
3621
3829
|
}
|
|
3622
3830
|
logger = new import_common13.Logger("Jetstream:Stream");
|
|
3623
3831
|
async migrate(jsm, streamName2, newConfig) {
|
|
3624
3832
|
const backupName = `${streamName2}${MIGRATION_BACKUP_SUFFIX}`;
|
|
3625
3833
|
const startTime = Date.now();
|
|
3834
|
+
const peerFinished = await this.waitOutPeerMigration(jsm, backupName);
|
|
3626
3835
|
const currentInfo = await jsm.streams.info(streamName2);
|
|
3627
|
-
|
|
3628
|
-
|
|
3836
|
+
if (peerFinished && !compareStreamConfig(currentInfo.config, newConfig).hasImmutableChanges) {
|
|
3837
|
+
this.logger.log(`Stream ${streamName2}: migration completed by another instance`);
|
|
3838
|
+
await jsm.streams.update(streamName2, newConfig);
|
|
3839
|
+
return;
|
|
3840
|
+
}
|
|
3629
3841
|
this.logger.log(`Stream ${streamName2}: destructive migration started`);
|
|
3630
3842
|
let originalDeleted = false;
|
|
3843
|
+
let drainedCount = 0;
|
|
3631
3844
|
try {
|
|
3632
|
-
|
|
3633
|
-
|
|
3845
|
+
this.logger.log(` Phase 1/4: Quiescing ${streamName2} (publishes rejected during migration)`);
|
|
3846
|
+
await jsm.streams.update(streamName2, { ...currentInfo.config, subjects: [] });
|
|
3847
|
+
drainedCount = (await jsm.streams.info(streamName2)).state.messages;
|
|
3848
|
+
if (drainedCount > 0) {
|
|
3849
|
+
this.logger.log(` Phase 2/4: Backing up ${drainedCount} messages \u2192 ${backupName}`);
|
|
3634
3850
|
await jsm.streams.add({
|
|
3635
3851
|
...currentInfo.config,
|
|
3636
3852
|
name: backupName,
|
|
3637
3853
|
subjects: [],
|
|
3638
|
-
sources: [{ name: streamName2 }]
|
|
3854
|
+
sources: [{ name: streamName2 }],
|
|
3855
|
+
metadata: { [MIGRATION_STARTED_AT_KEY]: (/* @__PURE__ */ new Date()).toISOString() }
|
|
3639
3856
|
});
|
|
3640
|
-
await this.
|
|
3857
|
+
await this.waitForSourceDrained(jsm, backupName, streamName2, drainedCount);
|
|
3641
3858
|
}
|
|
3642
|
-
this.logger.log(` Phase
|
|
3859
|
+
this.logger.log(` Phase 3/4: Recreating ${streamName2} with the new config`);
|
|
3643
3860
|
await jsm.streams.delete(streamName2);
|
|
3644
3861
|
originalDeleted = true;
|
|
3645
|
-
this.logger.log(` Phase 3/4: Creating stream with new config`);
|
|
3646
3862
|
await jsm.streams.add(newConfig);
|
|
3647
|
-
if (
|
|
3648
|
-
|
|
3649
|
-
await
|
|
3650
|
-
this.logger.log(` Phase 4/4: Restoring ${messageCount} messages from backup`);
|
|
3651
|
-
await jsm.streams.update(streamName2, {
|
|
3652
|
-
...newConfig,
|
|
3653
|
-
sources: [{ name: backupName }]
|
|
3654
|
-
});
|
|
3655
|
-
await this.waitForSourcing(jsm, streamName2, messageCount);
|
|
3656
|
-
await jsm.streams.update(streamName2, { ...newConfig, sources: [] });
|
|
3657
|
-
await jsm.streams.delete(backupName);
|
|
3863
|
+
if (drainedCount > 0) {
|
|
3864
|
+
this.logger.log(` Phase 4/4: Restoring ${drainedCount} messages from backup`);
|
|
3865
|
+
await this.restoreFromBackup(jsm, streamName2, newConfig, backupName);
|
|
3658
3866
|
}
|
|
3659
3867
|
} catch (err) {
|
|
3660
|
-
if (originalDeleted
|
|
3868
|
+
if (originalDeleted) {
|
|
3661
3869
|
this.logger.error(
|
|
3662
|
-
`Migration failed after
|
|
3870
|
+
`Migration of ${streamName2} failed after the original was deleted. Backup ${backupName} preserved \u2014 restoration resumes on the next startup.`
|
|
3663
3871
|
);
|
|
3664
3872
|
} else {
|
|
3665
|
-
await this.
|
|
3873
|
+
await this.rollbackBeforeDelete(jsm, streamName2, currentInfo, backupName);
|
|
3666
3874
|
}
|
|
3667
3875
|
throw err;
|
|
3668
3876
|
}
|
|
3669
3877
|
const durationMs = Date.now() - startTime;
|
|
3670
3878
|
this.logger.log(
|
|
3671
|
-
`Stream ${streamName2}: migration complete (${
|
|
3879
|
+
`Stream ${streamName2}: migration complete (${drainedCount} messages preserved, took ${(durationMs / 1e3).toFixed(1)}s)`
|
|
3880
|
+
);
|
|
3881
|
+
}
|
|
3882
|
+
/**
|
|
3883
|
+
* Finish a migration a previous process left unfinished; a backup fresh
|
|
3884
|
+
* enough to belong to a live peer migration is left alone.
|
|
3885
|
+
*/
|
|
3886
|
+
async recoverInterrupted(jsm, streamName2, desiredConfig) {
|
|
3887
|
+
const backupName = `${streamName2}${MIGRATION_BACKUP_SUFFIX}`;
|
|
3888
|
+
const backupInfo = await this.tryInfo(jsm, backupName);
|
|
3889
|
+
if (backupInfo === null) return false;
|
|
3890
|
+
if (this.isPeerMigrationActive(backupInfo)) return false;
|
|
3891
|
+
const streamInfo = await this.tryInfo(jsm, streamName2);
|
|
3892
|
+
if (streamInfo === null) {
|
|
3893
|
+
this.logger.warn(`Stream ${streamName2}: resuming interrupted migration from ${backupName}`);
|
|
3894
|
+
await jsm.streams.add(desiredConfig);
|
|
3895
|
+
if (backupInfo.state.messages > 0) {
|
|
3896
|
+
await this.restoreFromBackup(jsm, streamName2, desiredConfig, backupName);
|
|
3897
|
+
} else {
|
|
3898
|
+
await jsm.streams.delete(backupName);
|
|
3899
|
+
}
|
|
3900
|
+
return true;
|
|
3901
|
+
}
|
|
3902
|
+
const hasBackupSource = (streamInfo.config.sources ?? []).some((s) => s.name === backupName);
|
|
3903
|
+
if (hasBackupSource) {
|
|
3904
|
+
this.logger.warn(`Stream ${streamName2}: finishing interrupted restore from ${backupName}`);
|
|
3905
|
+
await this.waitForSourceDrained(jsm, streamName2, backupName, backupInfo.state.messages);
|
|
3906
|
+
await jsm.streams.delete(backupName);
|
|
3907
|
+
await jsm.streams.update(streamName2, { ...streamInfo.config, sources: [] });
|
|
3908
|
+
return true;
|
|
3909
|
+
}
|
|
3910
|
+
if (backupInfo.state.messages === 0) {
|
|
3911
|
+
this.logger.warn(`Removing empty migration backup ${backupName}`);
|
|
3912
|
+
await jsm.streams.delete(backupName);
|
|
3913
|
+
return true;
|
|
3914
|
+
}
|
|
3915
|
+
this.logger.warn(
|
|
3916
|
+
`Stream ${streamName2}: restoring ${backupInfo.state.messages} messages from stale ${backupName}`
|
|
3917
|
+
);
|
|
3918
|
+
await this.restoreFromBackup(
|
|
3919
|
+
jsm,
|
|
3920
|
+
streamName2,
|
|
3921
|
+
{ ...streamInfo.config, name: streamName2, subjects: streamInfo.config.subjects },
|
|
3922
|
+
backupName
|
|
3672
3923
|
);
|
|
3924
|
+
return true;
|
|
3925
|
+
}
|
|
3926
|
+
/** Attach the backup as a source, drain it fully, then clean up. */
|
|
3927
|
+
async restoreFromBackup(jsm, streamName2, streamConfig, backupName) {
|
|
3928
|
+
const backupInfo = await jsm.streams.info(backupName);
|
|
3929
|
+
if ((backupInfo.config.sources ?? []).length > 0) {
|
|
3930
|
+
await jsm.streams.update(backupName, { ...backupInfo.config, sources: [] });
|
|
3931
|
+
}
|
|
3932
|
+
await jsm.streams.update(streamName2, { ...streamConfig, sources: [{ name: backupName }] });
|
|
3933
|
+
await this.waitForSourceDrained(jsm, streamName2, backupName, backupInfo.state.messages);
|
|
3934
|
+
await jsm.streams.delete(backupName);
|
|
3935
|
+
await jsm.streams.update(streamName2, { ...streamConfig, sources: [] });
|
|
3673
3936
|
}
|
|
3674
|
-
|
|
3937
|
+
/**
|
|
3938
|
+
* Lag-based drain check — live publishes cannot fake completion. A fresh
|
|
3939
|
+
* source reports lag 0 / active -1 before its first sync (NATS 2.12.6),
|
|
3940
|
+
* hence the active guard.
|
|
3941
|
+
*/
|
|
3942
|
+
async waitForSourceDrained(jsm, streamName2, sourceName, minimumMessages) {
|
|
3675
3943
|
const deadline = Date.now() + this.sourcingTimeoutMs;
|
|
3676
3944
|
while (Date.now() < deadline) {
|
|
3677
3945
|
const info = await jsm.streams.info(streamName2);
|
|
3678
|
-
|
|
3946
|
+
const source = (info.sources ?? []).find((s) => s.name === sourceName);
|
|
3947
|
+
if (source !== void 0 && source.active >= 0 && source.lag === 0 && info.state.messages >= minimumMessages) {
|
|
3948
|
+
return;
|
|
3949
|
+
}
|
|
3679
3950
|
await new Promise((r) => setTimeout(r, SOURCING_POLL_INTERVAL_MS));
|
|
3680
3951
|
}
|
|
3681
3952
|
throw new Error(
|
|
3682
|
-
`Stream sourcing timeout: ${
|
|
3953
|
+
`Stream sourcing timeout: ${sourceName} has not drained into ${streamName2} within ${this.sourcingTimeoutMs / 1e3}s. The backup is preserved; restoration resumes on the next startup.`
|
|
3683
3954
|
);
|
|
3684
3955
|
}
|
|
3685
|
-
|
|
3956
|
+
/**
|
|
3957
|
+
* A backup present at migrate() start is a live peer migration — wait it
|
|
3958
|
+
* out. Stale leftovers were already handled by recoverInterrupted().
|
|
3959
|
+
*/
|
|
3960
|
+
async waitOutPeerMigration(jsm, backupName) {
|
|
3961
|
+
if (await this.tryInfo(jsm, backupName) === null) return false;
|
|
3962
|
+
this.logger.warn(
|
|
3963
|
+
`Migration backup ${backupName} exists \u2014 another instance appears to be migrating; waiting`
|
|
3964
|
+
);
|
|
3965
|
+
const deadline = Date.now() + this.peerWaitMs;
|
|
3966
|
+
while (Date.now() < deadline) {
|
|
3967
|
+
if (await this.tryInfo(jsm, backupName) === null) return true;
|
|
3968
|
+
await new Promise((r) => setTimeout(r, SOURCING_POLL_INTERVAL_MS * 5));
|
|
3969
|
+
}
|
|
3970
|
+
throw new Error(
|
|
3971
|
+
`Migration backup ${backupName} did not clear within ${this.peerWaitMs / 1e3}s. If no other instance is migrating, recover or remove the backup manually.`
|
|
3972
|
+
);
|
|
3973
|
+
}
|
|
3974
|
+
/** Failure before the original was deleted: undo the quiesce, drop our backup. */
|
|
3975
|
+
async rollbackBeforeDelete(jsm, streamName2, originalInfo, backupName) {
|
|
3686
3976
|
try {
|
|
3687
|
-
await jsm.streams.
|
|
3688
|
-
|
|
3689
|
-
|
|
3977
|
+
await jsm.streams.update(streamName2, { ...originalInfo.config });
|
|
3978
|
+
const backupInfo = await this.tryInfo(jsm, backupName);
|
|
3979
|
+
if (backupInfo !== null) {
|
|
3980
|
+
await jsm.streams.delete(backupName);
|
|
3981
|
+
}
|
|
3982
|
+
} catch (rollbackErr) {
|
|
3983
|
+
this.logger.error(
|
|
3984
|
+
`Rollback of ${streamName2} after a failed migration also failed \u2014 the stream may be left quiesced:`,
|
|
3985
|
+
rollbackErr
|
|
3986
|
+
);
|
|
3987
|
+
}
|
|
3988
|
+
}
|
|
3989
|
+
isPeerMigrationActive(backupInfo) {
|
|
3990
|
+
const startedAt = backupInfo.config.metadata?.[MIGRATION_STARTED_AT_KEY];
|
|
3991
|
+
if (!startedAt) return false;
|
|
3992
|
+
const startedMs = Date.parse(startedAt);
|
|
3993
|
+
if (Number.isNaN(startedMs)) return false;
|
|
3994
|
+
return Date.now() - startedMs < ACTIVE_MIGRATION_GRACE_MS;
|
|
3995
|
+
}
|
|
3996
|
+
async tryInfo(jsm, name) {
|
|
3997
|
+
try {
|
|
3998
|
+
return await jsm.streams.info(name);
|
|
3690
3999
|
} catch (err) {
|
|
3691
|
-
if (err instanceof
|
|
3692
|
-
return;
|
|
4000
|
+
if (err instanceof import_jetstream18.JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
4001
|
+
return null;
|
|
3693
4002
|
}
|
|
3694
4003
|
throw err;
|
|
3695
4004
|
}
|
|
@@ -3697,6 +4006,17 @@ var StreamMigration = class {
|
|
|
3697
4006
|
};
|
|
3698
4007
|
|
|
3699
4008
|
// src/server/infrastructure/stream.provider.ts
|
|
4009
|
+
var subjectCovers = (broad, narrow) => {
|
|
4010
|
+
if (broad === narrow) return false;
|
|
4011
|
+
const broadTokens = broad.split(".");
|
|
4012
|
+
const narrowTokens = narrow.split(".");
|
|
4013
|
+
for (let i = 0; i < broadTokens.length; i += 1) {
|
|
4014
|
+
if (broadTokens[i] === ">") return i < narrowTokens.length;
|
|
4015
|
+
if (i >= narrowTokens.length || narrowTokens[i] === ">") return false;
|
|
4016
|
+
if (broadTokens[i] !== "*" && broadTokens[i] !== narrowTokens[i]) return false;
|
|
4017
|
+
}
|
|
4018
|
+
return broadTokens.length === narrowTokens.length;
|
|
4019
|
+
};
|
|
3700
4020
|
var StreamProvider = class {
|
|
3701
4021
|
constructor(options, connection) {
|
|
3702
4022
|
this.options = options;
|
|
@@ -3720,6 +4040,15 @@ var StreamProvider = class {
|
|
|
3720
4040
|
*/
|
|
3721
4041
|
async ensureStreams(kinds) {
|
|
3722
4042
|
const jsm = await this.connection.getJetStreamManager();
|
|
4043
|
+
const reservations = kinds.map((kind) => this.buildReservation(kind, this.buildConfig(kind)));
|
|
4044
|
+
if (this.options.dlq) {
|
|
4045
|
+
reservations.push(this.buildReservation("dlq", this.buildDlqConfig()));
|
|
4046
|
+
}
|
|
4047
|
+
this.logger.log(`
|
|
4048
|
+
${formatProvisioningSummary(this.options.name, reservations)}`);
|
|
4049
|
+
if (this.options.provisioning?.preflightStorageCheck) {
|
|
4050
|
+
await assertStorageBudget(jsm, this.options.name, reservations, this.logger);
|
|
4051
|
+
}
|
|
3723
4052
|
await Promise.all(kinds.map((kind) => this.ensureStream(jsm, kind)));
|
|
3724
4053
|
if (this.options.dlq) {
|
|
3725
4054
|
await this.ensureDlqStream(jsm);
|
|
@@ -3742,13 +4071,8 @@ var StreamProvider = class {
|
|
|
3742
4071
|
}
|
|
3743
4072
|
case "cmd" /* Command */:
|
|
3744
4073
|
return [`${name}.${"cmd" /* Command */}.>`];
|
|
3745
|
-
case "broadcast" /* Broadcast */:
|
|
3746
|
-
|
|
3747
|
-
if (this.isSchedulingEnabled(kind)) {
|
|
3748
|
-
subjects.push("broadcast._sch.>");
|
|
3749
|
-
}
|
|
3750
|
-
return subjects;
|
|
3751
|
-
}
|
|
4074
|
+
case "broadcast" /* Broadcast */:
|
|
4075
|
+
return ["broadcast.>"];
|
|
3752
4076
|
case "ordered" /* Ordered */:
|
|
3753
4077
|
return [`${name}.${"ordered" /* Ordered */}.>`];
|
|
3754
4078
|
}
|
|
@@ -3756,6 +4080,7 @@ var StreamProvider = class {
|
|
|
3756
4080
|
/** Ensure a single stream exists, creating or updating as needed. */
|
|
3757
4081
|
async ensureStream(jsm, kind) {
|
|
3758
4082
|
const config = this.buildConfig(kind);
|
|
4083
|
+
const ctx = this.errorContext(kind, config);
|
|
3759
4084
|
return withProvisioningSpan(
|
|
3760
4085
|
this.otel,
|
|
3761
4086
|
{
|
|
@@ -3763,17 +4088,21 @@ var StreamProvider = class {
|
|
|
3763
4088
|
endpoint: this.otelEndpoint,
|
|
3764
4089
|
entity: "stream",
|
|
3765
4090
|
name: config.name,
|
|
3766
|
-
action: "ensure"
|
|
4091
|
+
action: "ensure",
|
|
4092
|
+
maxBytes: ctx.maxBytes,
|
|
4093
|
+
numReplicas: ctx.numReplicas,
|
|
4094
|
+
reservation: ctx.maxBytes !== void 0 && ctx.numReplicas !== void 0 ? ctx.maxBytes * ctx.numReplicas : void 0
|
|
3767
4095
|
},
|
|
3768
4096
|
async () => {
|
|
3769
4097
|
this.logger.log(`Ensuring stream: ${config.name}`);
|
|
4098
|
+
await this.migration.recoverInterrupted(jsm, config.name, config);
|
|
3770
4099
|
try {
|
|
3771
4100
|
const currentInfo = await jsm.streams.info(config.name);
|
|
3772
|
-
return await this.handleExistingStream(jsm, currentInfo, config);
|
|
4101
|
+
return await this.handleExistingStream(jsm, currentInfo, config, ctx);
|
|
3773
4102
|
} catch (err) {
|
|
3774
|
-
if (err instanceof
|
|
4103
|
+
if (err instanceof import_jetstream19.JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
3775
4104
|
this.logger.log(`Creating stream: ${config.name}`);
|
|
3776
|
-
return await jsm.streams.add(config);
|
|
4105
|
+
return await this.runStreamOp(ctx, () => jsm.streams.add(config));
|
|
3777
4106
|
}
|
|
3778
4107
|
throw err;
|
|
3779
4108
|
}
|
|
@@ -3783,6 +4112,7 @@ var StreamProvider = class {
|
|
|
3783
4112
|
/** Ensure a dead-letter queue stream exists, creating or updating as needed. */
|
|
3784
4113
|
async ensureDlqStream(jsm) {
|
|
3785
4114
|
const config = this.buildDlqConfig();
|
|
4115
|
+
const ctx = this.errorContext("dlq", config);
|
|
3786
4116
|
return withProvisioningSpan(
|
|
3787
4117
|
this.otel,
|
|
3788
4118
|
{
|
|
@@ -3790,24 +4120,31 @@ var StreamProvider = class {
|
|
|
3790
4120
|
endpoint: this.otelEndpoint,
|
|
3791
4121
|
entity: "stream",
|
|
3792
4122
|
name: config.name,
|
|
3793
|
-
action: "ensure"
|
|
4123
|
+
action: "ensure",
|
|
4124
|
+
maxBytes: ctx.maxBytes,
|
|
4125
|
+
numReplicas: ctx.numReplicas,
|
|
4126
|
+
reservation: ctx.maxBytes !== void 0 && ctx.numReplicas !== void 0 ? ctx.maxBytes * ctx.numReplicas : void 0
|
|
3794
4127
|
},
|
|
3795
4128
|
async () => {
|
|
3796
4129
|
this.logger.log(`Ensuring DLQ stream: ${config.name}`);
|
|
3797
4130
|
try {
|
|
3798
4131
|
const currentInfo = await jsm.streams.info(config.name);
|
|
3799
|
-
return await this.handleExistingStream(jsm, currentInfo, config);
|
|
4132
|
+
return await this.handleExistingStream(jsm, currentInfo, config, ctx);
|
|
3800
4133
|
} catch (err) {
|
|
3801
|
-
if (err instanceof
|
|
4134
|
+
if (err instanceof import_jetstream19.JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
3802
4135
|
this.logger.log(`Creating DLQ stream: ${config.name}`);
|
|
3803
|
-
return await jsm.streams.add(config);
|
|
4136
|
+
return await this.runStreamOp(ctx, () => jsm.streams.add(config));
|
|
3804
4137
|
}
|
|
3805
4138
|
throw err;
|
|
3806
4139
|
}
|
|
3807
4140
|
}
|
|
3808
4141
|
);
|
|
3809
4142
|
}
|
|
3810
|
-
async handleExistingStream(jsm, currentInfo, config) {
|
|
4143
|
+
async handleExistingStream(jsm, currentInfo, config, ctx) {
|
|
4144
|
+
if (this.isSharedStream(config.name)) {
|
|
4145
|
+
const merged = [.../* @__PURE__ */ new Set([...config.subjects, ...currentInfo.config.subjects])];
|
|
4146
|
+
config.subjects = merged.filter((s) => !merged.some((other) => subjectCovers(other, s)));
|
|
4147
|
+
}
|
|
3811
4148
|
const diff = compareStreamConfig(currentInfo.config, config);
|
|
3812
4149
|
if (!diff.hasChanges) {
|
|
3813
4150
|
this.logger.debug(`Stream ${config.name}: no config changes`);
|
|
@@ -3822,7 +4159,7 @@ var StreamProvider = class {
|
|
|
3822
4159
|
}
|
|
3823
4160
|
if (!diff.hasImmutableChanges) {
|
|
3824
4161
|
this.logger.debug(`Stream exists, updating: ${config.name}`);
|
|
3825
|
-
return await jsm.streams.update(config.name, config);
|
|
4162
|
+
return await this.runStreamOp(ctx, () => jsm.streams.update(config.name, config));
|
|
3826
4163
|
}
|
|
3827
4164
|
if (!this.options.allowDestructiveMigration) {
|
|
3828
4165
|
this.logger.warn(
|
|
@@ -3830,10 +4167,15 @@ var StreamProvider = class {
|
|
|
3830
4167
|
);
|
|
3831
4168
|
if (diff.hasMutableChanges) {
|
|
3832
4169
|
const mutableConfig = this.buildMutableOnlyConfig(config, currentInfo.config, diff);
|
|
3833
|
-
return await jsm.streams.update(config.name, mutableConfig);
|
|
4170
|
+
return await this.runStreamOp(ctx, () => jsm.streams.update(config.name, mutableConfig));
|
|
3834
4171
|
}
|
|
3835
4172
|
return currentInfo;
|
|
3836
4173
|
}
|
|
4174
|
+
if (this.isSharedStream(config.name)) {
|
|
4175
|
+
throw new Error(
|
|
4176
|
+
`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.`
|
|
4177
|
+
);
|
|
4178
|
+
}
|
|
3837
4179
|
await withMigrationSpan(
|
|
3838
4180
|
this.otel,
|
|
3839
4181
|
{
|
|
@@ -3872,11 +4214,47 @@ var StreamProvider = class {
|
|
|
3872
4214
|
}
|
|
3873
4215
|
}
|
|
3874
4216
|
}
|
|
4217
|
+
buildReservation(kind, config) {
|
|
4218
|
+
const mb = config.max_bytes;
|
|
4219
|
+
return {
|
|
4220
|
+
kind,
|
|
4221
|
+
name: config.name,
|
|
4222
|
+
storage: config.storage ?? import_jetstream19.StorageType.File,
|
|
4223
|
+
numReplicas: config.num_replicas ?? 1,
|
|
4224
|
+
maxBytes: mb !== void 0 && mb >= 0 ? mb : 0,
|
|
4225
|
+
// NATS uses -1 for unlimited
|
|
4226
|
+
maxAge: config.max_age ?? 0,
|
|
4227
|
+
retention: config.retention ?? import_jetstream19.RetentionPolicy.Limits
|
|
4228
|
+
};
|
|
4229
|
+
}
|
|
4230
|
+
errorContext(kind, config) {
|
|
4231
|
+
return {
|
|
4232
|
+
entity: "stream",
|
|
4233
|
+
name: config.name,
|
|
4234
|
+
kind,
|
|
4235
|
+
maxBytes: config.max_bytes,
|
|
4236
|
+
numReplicas: config.num_replicas ?? 1
|
|
4237
|
+
};
|
|
4238
|
+
}
|
|
4239
|
+
async runStreamOp(ctx, op) {
|
|
4240
|
+
try {
|
|
4241
|
+
return await op();
|
|
4242
|
+
} catch (err) {
|
|
4243
|
+
if (err instanceof import_jetstream19.JetStreamApiError) {
|
|
4244
|
+
throw mapProvisioningError(err, ctx);
|
|
4245
|
+
}
|
|
4246
|
+
throw err;
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
/** The broadcast stream is global — every service in the cluster shares it. */
|
|
4250
|
+
isSharedStream(name) {
|
|
4251
|
+
return name === this.getStreamName("broadcast" /* Broadcast */);
|
|
4252
|
+
}
|
|
3875
4253
|
/** Build the full stream config by merging defaults with user overrides. */
|
|
3876
4254
|
buildConfig(kind) {
|
|
3877
4255
|
const name = this.getStreamName(kind);
|
|
3878
4256
|
const subjects = this.getSubjects(kind);
|
|
3879
|
-
const description = `JetStream ${kind} stream for ${this.options.name}`;
|
|
4257
|
+
const description = kind === "broadcast" /* Broadcast */ ? "JetStream broadcast stream (shared across services)" : `JetStream ${kind} stream for ${this.options.name}`;
|
|
3880
4258
|
const defaults = this.getDefaults(kind);
|
|
3881
4259
|
const overrides = this.getOverrides(kind);
|
|
3882
4260
|
return {
|
|
@@ -3962,7 +4340,7 @@ var StreamProvider = class {
|
|
|
3962
4340
|
|
|
3963
4341
|
// src/server/infrastructure/consumer.provider.ts
|
|
3964
4342
|
var import_common15 = require("@nestjs/common");
|
|
3965
|
-
var
|
|
4343
|
+
var import_jetstream21 = require("@nats-io/jetstream");
|
|
3966
4344
|
var ConsumerProvider = class {
|
|
3967
4345
|
constructor(options, connection, streamProvider, patternRegistry) {
|
|
3968
4346
|
this.options = options;
|
|
@@ -4018,15 +4396,16 @@ var ConsumerProvider = class {
|
|
|
4018
4396
|
},
|
|
4019
4397
|
async () => {
|
|
4020
4398
|
this.logger.log(`Ensuring consumer: ${name} on stream: ${stream}`);
|
|
4399
|
+
const ctx = { entity: "consumer", name, kind };
|
|
4021
4400
|
try {
|
|
4022
4401
|
await jsm.consumers.info(stream, name);
|
|
4023
4402
|
this.logger.debug(`Consumer exists, updating: ${name}`);
|
|
4024
|
-
return await jsm.consumers.update(stream, name, config);
|
|
4403
|
+
return await this.runConsumerOp(ctx, () => jsm.consumers.update(stream, name, config));
|
|
4025
4404
|
} catch (err) {
|
|
4026
|
-
if (!(err instanceof
|
|
4405
|
+
if (!(err instanceof import_jetstream21.JetStreamApiError) || err.apiError().err_code !== 10014 /* ConsumerNotFound */) {
|
|
4027
4406
|
throw err;
|
|
4028
4407
|
}
|
|
4029
|
-
return await this.createConsumer(jsm, stream, name, config);
|
|
4408
|
+
return await this.createConsumer(jsm, stream, name, kind, config);
|
|
4030
4409
|
}
|
|
4031
4410
|
}
|
|
4032
4411
|
);
|
|
@@ -4063,10 +4442,10 @@ var ConsumerProvider = class {
|
|
|
4063
4442
|
try {
|
|
4064
4443
|
return await jsm.consumers.info(stream, name);
|
|
4065
4444
|
} catch (err) {
|
|
4066
|
-
if (!(err instanceof
|
|
4445
|
+
if (!(err instanceof import_jetstream21.JetStreamApiError) || err.apiError().err_code !== 10014 /* ConsumerNotFound */) {
|
|
4067
4446
|
throw err;
|
|
4068
4447
|
}
|
|
4069
|
-
return await this.createConsumer(jsm, stream, name, config);
|
|
4448
|
+
return await this.createConsumer(jsm, stream, name, kind, config);
|
|
4070
4449
|
}
|
|
4071
4450
|
}
|
|
4072
4451
|
);
|
|
@@ -4084,7 +4463,7 @@ var ConsumerProvider = class {
|
|
|
4084
4463
|
`Stream ${stream} is being migrated (backup ${backupName} exists). Waiting for migration to complete before recovering consumer.`
|
|
4085
4464
|
);
|
|
4086
4465
|
} catch (err) {
|
|
4087
|
-
if (err instanceof
|
|
4466
|
+
if (err instanceof import_jetstream21.JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
4088
4467
|
return;
|
|
4089
4468
|
}
|
|
4090
4469
|
throw err;
|
|
@@ -4093,18 +4472,32 @@ var ConsumerProvider = class {
|
|
|
4093
4472
|
/**
|
|
4094
4473
|
* Create a consumer, handling the race where another pod creates it first.
|
|
4095
4474
|
*/
|
|
4096
|
-
async createConsumer(jsm, stream, name, config) {
|
|
4475
|
+
async createConsumer(jsm, stream, name, kind, config) {
|
|
4097
4476
|
this.logger.log(`Creating consumer: ${name}`);
|
|
4477
|
+
const ctx = { entity: "consumer", name, kind };
|
|
4098
4478
|
try {
|
|
4099
4479
|
return await jsm.consumers.add(stream, config);
|
|
4100
4480
|
} catch (addErr) {
|
|
4101
|
-
if (addErr instanceof
|
|
4481
|
+
if (addErr instanceof import_jetstream21.JetStreamApiError && addErr.apiError().err_code === 10148 /* ConsumerAlreadyExists */) {
|
|
4102
4482
|
this.logger.debug(`Consumer ${name} created by another pod, using existing`);
|
|
4103
4483
|
return await jsm.consumers.info(stream, name);
|
|
4104
4484
|
}
|
|
4485
|
+
if (addErr instanceof import_jetstream21.JetStreamApiError) {
|
|
4486
|
+
throw mapProvisioningError(addErr, ctx);
|
|
4487
|
+
}
|
|
4105
4488
|
throw addErr;
|
|
4106
4489
|
}
|
|
4107
4490
|
}
|
|
4491
|
+
async runConsumerOp(ctx, op) {
|
|
4492
|
+
try {
|
|
4493
|
+
return await op();
|
|
4494
|
+
} catch (err) {
|
|
4495
|
+
if (err instanceof import_jetstream21.JetStreamApiError) {
|
|
4496
|
+
throw mapProvisioningError(err, ctx);
|
|
4497
|
+
}
|
|
4498
|
+
throw err;
|
|
4499
|
+
}
|
|
4500
|
+
}
|
|
4108
4501
|
/** Build consumer config by merging defaults with user overrides. */
|
|
4109
4502
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- NATS API uses snake_case
|
|
4110
4503
|
buildConfig(kind) {
|
|
@@ -4186,7 +4579,7 @@ var ConsumerProvider = class {
|
|
|
4186
4579
|
|
|
4187
4580
|
// src/server/infrastructure/message.provider.ts
|
|
4188
4581
|
var import_common16 = require("@nestjs/common");
|
|
4189
|
-
var
|
|
4582
|
+
var import_jetstream23 = require("@nats-io/jetstream");
|
|
4190
4583
|
var import_rxjs3 = require("rxjs");
|
|
4191
4584
|
var MessageProvider = class {
|
|
4192
4585
|
constructor(connection, eventBus, consumeOptionsMap = /* @__PURE__ */ new Map(), consumerRecoveryFn) {
|
|
@@ -4247,7 +4640,7 @@ var MessageProvider = class {
|
|
|
4247
4640
|
*/
|
|
4248
4641
|
async startOrdered(streamName2, filterSubjects, orderedConfig) {
|
|
4249
4642
|
const consumerOpts = { filter_subjects: filterSubjects };
|
|
4250
|
-
if (orderedConfig?.deliverPolicy !== void 0 && orderedConfig.deliverPolicy !==
|
|
4643
|
+
if (orderedConfig?.deliverPolicy !== void 0 && orderedConfig.deliverPolicy !== import_jetstream23.DeliverPolicy.All) {
|
|
4251
4644
|
consumerOpts.deliver_policy = orderedConfig.deliverPolicy;
|
|
4252
4645
|
}
|
|
4253
4646
|
if (orderedConfig?.optStartSeq !== void 0) {
|
|
@@ -4556,6 +4949,7 @@ var MetadataProvider = class {
|
|
|
4556
4949
|
// src/server/routing/event.router.ts
|
|
4557
4950
|
var import_common18 = require("@nestjs/common");
|
|
4558
4951
|
var import_transport_node4 = require("@nats-io/transport-node");
|
|
4952
|
+
var DLQ_PUBLISH_ATTEMPTS = 3;
|
|
4559
4953
|
var eventConsumeKindFor = (kind) => {
|
|
4560
4954
|
if (kind === "broadcast" /* Broadcast */) return "broadcast" /* Broadcast */;
|
|
4561
4955
|
if (kind === "ordered" /* Ordered */) return "ordered" /* Ordered */;
|
|
@@ -4642,33 +5036,80 @@ var EventRouter = class {
|
|
|
4642
5036
|
return msg.info.deliveryCount >= maxDeliver;
|
|
4643
5037
|
};
|
|
4644
5038
|
const handleDeadLetter = hasDlqCheck ? (msg, data, err) => this.handleDeadLetter(msg, data, err) : null;
|
|
4645
|
-
const settleSuccess = (msg, ctx) => {
|
|
4646
|
-
if (ctx.shouldTerminate)
|
|
4647
|
-
|
|
4648
|
-
|
|
5039
|
+
const settleSuccess = (msg, ctx, data) => {
|
|
5040
|
+
if (ctx.shouldTerminate) {
|
|
5041
|
+
settleQuietly(logger5, `Failed to term ${msg.subject}:`, () => {
|
|
5042
|
+
msg.term(ctx.terminateReason);
|
|
5043
|
+
});
|
|
5044
|
+
return void 0;
|
|
5045
|
+
}
|
|
5046
|
+
if (ctx.shouldRetry) {
|
|
5047
|
+
if (handleDeadLetter !== null && isDeadLetter(msg)) {
|
|
5048
|
+
return handleDeadLetter(
|
|
5049
|
+
msg,
|
|
5050
|
+
data,
|
|
5051
|
+
new Error("Retry requested on the final delivery attempt")
|
|
5052
|
+
);
|
|
5053
|
+
}
|
|
5054
|
+
settleQuietly(logger5, `Failed to nak ${msg.subject}:`, () => {
|
|
5055
|
+
msg.nak(ctx.retryDelay);
|
|
5056
|
+
});
|
|
5057
|
+
return void 0;
|
|
5058
|
+
}
|
|
5059
|
+
settleQuietly(logger5, `Failed to ack ${msg.subject}:`, () => {
|
|
5060
|
+
msg.ack();
|
|
5061
|
+
});
|
|
5062
|
+
return void 0;
|
|
4649
5063
|
};
|
|
4650
5064
|
const settleFailure = async (msg, data, err) => {
|
|
4651
5065
|
if (handleDeadLetter !== null && isDeadLetter(msg)) {
|
|
4652
5066
|
await handleDeadLetter(msg, data, err);
|
|
4653
5067
|
return;
|
|
4654
5068
|
}
|
|
4655
|
-
msg.
|
|
5069
|
+
settleQuietly(logger5, `Failed to nak ${msg.subject}:`, () => {
|
|
5070
|
+
msg.nak();
|
|
5071
|
+
});
|
|
5072
|
+
};
|
|
5073
|
+
const captureUnroutable = (capture, msg, err) => {
|
|
5074
|
+
let data;
|
|
5075
|
+
try {
|
|
5076
|
+
data = codec.decode(msg.data);
|
|
5077
|
+
} catch {
|
|
5078
|
+
data = void 0;
|
|
5079
|
+
}
|
|
5080
|
+
return capture(msg, data, err).catch((captureErr) => {
|
|
5081
|
+
logger5.error(`Dead-letter capture failed for unroutable ${msg.subject}:`, captureErr);
|
|
5082
|
+
});
|
|
4656
5083
|
};
|
|
4657
5084
|
const resolveEvent = (msg) => {
|
|
4658
5085
|
const subject = msg.subject;
|
|
4659
5086
|
try {
|
|
4660
5087
|
const handler = patternRegistry.getHandler(subject);
|
|
4661
5088
|
if (!handler) {
|
|
4662
|
-
msg.term(`No handler for event: ${subject}`);
|
|
4663
5089
|
logger5.error(`No handler for subject: ${subject}`);
|
|
5090
|
+
if (handleDeadLetter !== null) {
|
|
5091
|
+
return captureUnroutable(
|
|
5092
|
+
handleDeadLetter,
|
|
5093
|
+
msg,
|
|
5094
|
+
new Error(`No handler for event: ${subject}`)
|
|
5095
|
+
);
|
|
5096
|
+
}
|
|
5097
|
+
msg.term(`No handler for event: ${subject}`);
|
|
4664
5098
|
return null;
|
|
4665
5099
|
}
|
|
4666
5100
|
let data;
|
|
4667
5101
|
try {
|
|
4668
5102
|
data = codec.decode(msg.data);
|
|
4669
5103
|
} catch (err) {
|
|
4670
|
-
msg.term("Decode error");
|
|
4671
5104
|
logger5.error(`Decode error for ${subject}:`, err);
|
|
5105
|
+
if (handleDeadLetter !== null) {
|
|
5106
|
+
return captureUnroutable(
|
|
5107
|
+
handleDeadLetter,
|
|
5108
|
+
msg,
|
|
5109
|
+
new Error(`Decode error: ${err instanceof Error ? err.message : String(err)}`)
|
|
5110
|
+
);
|
|
5111
|
+
}
|
|
5112
|
+
msg.term("Decode error");
|
|
4672
5113
|
return null;
|
|
4673
5114
|
}
|
|
4674
5115
|
eventBus.emitMessageRouted(subject, "event" /* Event */);
|
|
@@ -4691,6 +5132,7 @@ var EventRouter = class {
|
|
|
4691
5132
|
const handleSafe = (msg) => {
|
|
4692
5133
|
const resolved = resolveEvent(msg);
|
|
4693
5134
|
if (resolved === null) return void 0;
|
|
5135
|
+
if (isPromiseLike2(resolved)) return resolved;
|
|
4694
5136
|
const { handler, data } = resolved;
|
|
4695
5137
|
const ctx = new RpcContext([msg]);
|
|
4696
5138
|
const stopAckExtension = hasAckExtension ? startAckExtensionTimer(msg, ackExtensionInterval) : null;
|
|
@@ -4723,16 +5165,24 @@ var EventRouter = class {
|
|
|
4723
5165
|
});
|
|
4724
5166
|
}
|
|
4725
5167
|
if (!isPromiseLike2(pending)) {
|
|
4726
|
-
settleSuccess(msg, ctx);
|
|
5168
|
+
const settled = settleSuccess(msg, ctx, data);
|
|
4727
5169
|
reportHandlerCompleted(msg, startedAt, statusForContext(ctx));
|
|
4728
|
-
if (
|
|
4729
|
-
|
|
5170
|
+
if (settled === void 0) {
|
|
5171
|
+
if (stopAckExtension !== null) stopAckExtension();
|
|
5172
|
+
return void 0;
|
|
5173
|
+
}
|
|
5174
|
+
return settled.finally(() => {
|
|
5175
|
+
if (stopAckExtension !== null) stopAckExtension();
|
|
5176
|
+
});
|
|
4730
5177
|
}
|
|
4731
5178
|
return pending.then(
|
|
4732
|
-
() => {
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
5179
|
+
async () => {
|
|
5180
|
+
try {
|
|
5181
|
+
await settleSuccess(msg, ctx, data);
|
|
5182
|
+
reportHandlerCompleted(msg, startedAt, statusForContext(ctx));
|
|
5183
|
+
} finally {
|
|
5184
|
+
if (stopAckExtension !== null) stopAckExtension();
|
|
5185
|
+
}
|
|
4736
5186
|
},
|
|
4737
5187
|
async (err) => {
|
|
4738
5188
|
eventBus.emit(
|
|
@@ -4826,14 +5276,28 @@ var EventRouter = class {
|
|
|
4826
5276
|
active--;
|
|
4827
5277
|
drainBacklog();
|
|
4828
5278
|
};
|
|
5279
|
+
const routeSafely = (msg) => {
|
|
5280
|
+
try {
|
|
5281
|
+
return route(msg);
|
|
5282
|
+
} catch (err) {
|
|
5283
|
+
logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
|
|
5284
|
+
return void 0;
|
|
5285
|
+
}
|
|
5286
|
+
};
|
|
5287
|
+
const trackAsync = (result, msg) => {
|
|
5288
|
+
void result.catch((err) => {
|
|
5289
|
+
logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
|
|
5290
|
+
}).finally(onAsyncDone);
|
|
5291
|
+
};
|
|
4829
5292
|
const drainBacklog = () => {
|
|
4830
5293
|
while (active < maxActive) {
|
|
4831
5294
|
const next = backlog.shift();
|
|
4832
5295
|
if (next === void 0) return;
|
|
5296
|
+
next.stopAckExtension?.();
|
|
4833
5297
|
active++;
|
|
4834
|
-
const result =
|
|
5298
|
+
const result = routeSafely(next.msg);
|
|
4835
5299
|
if (result !== void 0) {
|
|
4836
|
-
|
|
5300
|
+
trackAsync(result, next.msg);
|
|
4837
5301
|
} else {
|
|
4838
5302
|
active--;
|
|
4839
5303
|
}
|
|
@@ -4843,7 +5307,10 @@ var EventRouter = class {
|
|
|
4843
5307
|
const subscription = stream$.subscribe({
|
|
4844
5308
|
next: (msg) => {
|
|
4845
5309
|
if (active >= maxActive) {
|
|
4846
|
-
backlog.push(
|
|
5310
|
+
backlog.push({
|
|
5311
|
+
msg,
|
|
5312
|
+
stopAckExtension: hasAckExtension ? startAckExtensionTimer(msg, ackExtensionInterval) : null
|
|
5313
|
+
});
|
|
4847
5314
|
if (!backlogWarned && backlog.length >= backlogWarnThreshold) {
|
|
4848
5315
|
backlogWarned = true;
|
|
4849
5316
|
logger5.warn(
|
|
@@ -4853,9 +5320,9 @@ var EventRouter = class {
|
|
|
4853
5320
|
return;
|
|
4854
5321
|
}
|
|
4855
5322
|
active++;
|
|
4856
|
-
const result =
|
|
5323
|
+
const result = routeSafely(msg);
|
|
4857
5324
|
if (result !== void 0) {
|
|
4858
|
-
|
|
5325
|
+
trackAsync(result, msg);
|
|
4859
5326
|
} else {
|
|
4860
5327
|
active--;
|
|
4861
5328
|
if (backlog.length > 0) drainBacklog();
|
|
@@ -4865,6 +5332,12 @@ var EventRouter = class {
|
|
|
4865
5332
|
logger5.error(`Stream error in ${kind} router`, err);
|
|
4866
5333
|
}
|
|
4867
5334
|
});
|
|
5335
|
+
subscription.add(() => {
|
|
5336
|
+
for (const queued of backlog) {
|
|
5337
|
+
queued.stopAckExtension?.();
|
|
5338
|
+
}
|
|
5339
|
+
backlog.length = 0;
|
|
5340
|
+
});
|
|
4868
5341
|
this.subscriptions.push(subscription);
|
|
4869
5342
|
}
|
|
4870
5343
|
getConcurrency(kind) {
|
|
@@ -4878,27 +5351,72 @@ var EventRouter = class {
|
|
|
4878
5351
|
return void 0;
|
|
4879
5352
|
}
|
|
4880
5353
|
/**
|
|
4881
|
-
* Last
|
|
4882
|
-
*
|
|
4883
|
-
* cycle. Used when DLQ stream isn't configured, or when publishing to it
|
|
4884
|
-
* failed and we still have to surface the message somewhere observable.
|
|
5354
|
+
* Last resort: invoke onDeadLetter, then term on success. On failure the
|
|
5355
|
+
* message is nak'd — never redelivered past max_deliver, but preserved.
|
|
4885
5356
|
*/
|
|
4886
5357
|
async fallbackToOnDeadLetterCallback(info, msg) {
|
|
4887
|
-
|
|
4888
|
-
|
|
5358
|
+
const onDeadLetter = this.deadLetterConfig?.onDeadLetter;
|
|
5359
|
+
if (!onDeadLetter) {
|
|
5360
|
+
this.logger.error(
|
|
5361
|
+
`Dead letter for ${msg.subject} could not be captured (DLQ publish failed, no onDeadLetter callback) \u2014 leaving the message in the stream`
|
|
5362
|
+
);
|
|
5363
|
+
settleQuietly(this.logger, `Failed to nak ${msg.subject}:`, () => {
|
|
5364
|
+
msg.nak();
|
|
5365
|
+
});
|
|
4889
5366
|
return;
|
|
4890
5367
|
}
|
|
4891
5368
|
try {
|
|
4892
|
-
await
|
|
4893
|
-
|
|
5369
|
+
await onDeadLetter(info);
|
|
5370
|
+
settleQuietly(this.logger, `Failed to term ${msg.subject}:`, () => {
|
|
5371
|
+
msg.term("Dead letter processed via fallback callback");
|
|
5372
|
+
});
|
|
4894
5373
|
} catch (hookErr) {
|
|
4895
5374
|
this.logger.error(
|
|
4896
|
-
`Fallback onDeadLetter callback failed for ${msg.subject}
|
|
5375
|
+
`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:`,
|
|
4897
5376
|
hookErr
|
|
4898
5377
|
);
|
|
4899
|
-
msg.
|
|
5378
|
+
settleQuietly(this.logger, `Failed to nak ${msg.subject}:`, () => {
|
|
5379
|
+
msg.nak();
|
|
5380
|
+
});
|
|
4900
5381
|
}
|
|
4901
5382
|
}
|
|
5383
|
+
/**
|
|
5384
|
+
* Copy headers for the DLQ republish, dropping NATS control headers — a
|
|
5385
|
+
* copied Nats-TTL would expire the DLQ entry, Nats-Msg-Id trips dedup.
|
|
5386
|
+
*/
|
|
5387
|
+
buildDlqHeaders(msg) {
|
|
5388
|
+
const hdrs = (0, import_transport_node4.headers)();
|
|
5389
|
+
if (!msg.headers) return hdrs;
|
|
5390
|
+
for (const [k, v] of msg.headers) {
|
|
5391
|
+
if (k.toLowerCase().startsWith(NATS_CONTROL_HEADER_PREFIX)) continue;
|
|
5392
|
+
for (const val of v) {
|
|
5393
|
+
hdrs.append(k, val);
|
|
5394
|
+
}
|
|
5395
|
+
}
|
|
5396
|
+
return hdrs;
|
|
5397
|
+
}
|
|
5398
|
+
/**
|
|
5399
|
+
* Past max_deliver the server never redelivers, so these in-process attempts
|
|
5400
|
+
* are the only second chance a dead letter gets. No artificial delay — an
|
|
5401
|
+
* unreachable broker already spaces attempts via its own request timeout.
|
|
5402
|
+
*/
|
|
5403
|
+
async publishToDlqWithRetry(connection, subject, data, headers2) {
|
|
5404
|
+
let lastErr;
|
|
5405
|
+
for (let attempt = 1; attempt <= DLQ_PUBLISH_ATTEMPTS; attempt += 1) {
|
|
5406
|
+
try {
|
|
5407
|
+
await connection.getJetStreamClient().publish(subject, data, { headers: headers2 });
|
|
5408
|
+
return;
|
|
5409
|
+
} catch (err) {
|
|
5410
|
+
lastErr = err;
|
|
5411
|
+
if (attempt < DLQ_PUBLISH_ATTEMPTS) {
|
|
5412
|
+
this.logger.warn(
|
|
5413
|
+
`DLQ publish attempt ${attempt}/${DLQ_PUBLISH_ATTEMPTS} failed for ${subject}, retrying`
|
|
5414
|
+
);
|
|
5415
|
+
}
|
|
5416
|
+
}
|
|
5417
|
+
}
|
|
5418
|
+
throw lastErr;
|
|
5419
|
+
}
|
|
4902
5420
|
/**
|
|
4903
5421
|
* Publish a dead letter to the configured Dead-Letter Queue (DLQ) stream.
|
|
4904
5422
|
*
|
|
@@ -4917,14 +5435,7 @@ var EventRouter = class {
|
|
|
4917
5435
|
return;
|
|
4918
5436
|
}
|
|
4919
5437
|
const destinationSubject = dlqStreamName(serviceName);
|
|
4920
|
-
const hdrs =
|
|
4921
|
-
if (msg.headers) {
|
|
4922
|
-
for (const [k, v] of msg.headers) {
|
|
4923
|
-
for (const val of v) {
|
|
4924
|
-
hdrs.append(k, val);
|
|
4925
|
-
}
|
|
4926
|
-
}
|
|
4927
|
-
}
|
|
5438
|
+
const hdrs = this.buildDlqHeaders(msg);
|
|
4928
5439
|
let reason = String(error);
|
|
4929
5440
|
if (error instanceof Error) {
|
|
4930
5441
|
reason = error.message;
|
|
@@ -4937,8 +5448,7 @@ var EventRouter = class {
|
|
|
4937
5448
|
hdrs.set("x-failed-at" /* FailedAt */, (/* @__PURE__ */ new Date()).toISOString());
|
|
4938
5449
|
hdrs.set("x-delivery-count" /* DeliveryCount */, msg.info.deliveryCount.toString());
|
|
4939
5450
|
try {
|
|
4940
|
-
|
|
4941
|
-
await js.publish(destinationSubject, msg.data, { headers: hdrs });
|
|
5451
|
+
await this.publishToDlqWithRetry(this.connection, destinationSubject, msg.data, hdrs);
|
|
4942
5452
|
this.logger.log(`Message sent to DLQ: ${msg.subject}`);
|
|
4943
5453
|
if (this.deadLetterConfig?.onDeadLetter) {
|
|
4944
5454
|
try {
|
|
@@ -4950,7 +5460,9 @@ var EventRouter = class {
|
|
|
4950
5460
|
);
|
|
4951
5461
|
}
|
|
4952
5462
|
}
|
|
4953
|
-
|
|
5463
|
+
settleQuietly(this.logger, `Failed to term ${msg.subject}:`, () => {
|
|
5464
|
+
msg.term("Moved to DLQ stream");
|
|
5465
|
+
});
|
|
4954
5466
|
} catch (publishErr) {
|
|
4955
5467
|
this.logger.error(`Failed to publish to DLQ for ${msg.subject}:`, publishErr);
|
|
4956
5468
|
await this.fallbackToOnDeadLetterCallback(info, msg);
|
|
@@ -5144,7 +5656,9 @@ var RpcRouter = class {
|
|
|
5144
5656
|
`rpc-handler:${subject}`
|
|
5145
5657
|
);
|
|
5146
5658
|
publishErrorReply(replyTo, correlationId, subject, err);
|
|
5147
|
-
|
|
5659
|
+
settleQuietly(logger5, `Failed to term ${subject}:`, () => {
|
|
5660
|
+
msg.term(`Handler error: ${subject}`);
|
|
5661
|
+
});
|
|
5148
5662
|
};
|
|
5149
5663
|
const abortController = new AbortController();
|
|
5150
5664
|
let pending;
|
|
@@ -5172,7 +5686,9 @@ var RpcRouter = class {
|
|
|
5172
5686
|
}
|
|
5173
5687
|
if (!isPromiseLike2(pending)) {
|
|
5174
5688
|
if (stopAckExtension !== null) stopAckExtension();
|
|
5175
|
-
|
|
5689
|
+
settleQuietly(logger5, `Failed to ack ${subject}:`, () => {
|
|
5690
|
+
msg.ack();
|
|
5691
|
+
});
|
|
5176
5692
|
publishReply(replyTo, correlationId, pending);
|
|
5177
5693
|
reportHandlerCompleted(msg, startedAt, "success");
|
|
5178
5694
|
return void 0;
|
|
@@ -5184,7 +5700,9 @@ var RpcRouter = class {
|
|
|
5184
5700
|
if (stopAckExtension !== null) stopAckExtension();
|
|
5185
5701
|
abortController.abort();
|
|
5186
5702
|
emitRpcTimeout(subject, correlationId);
|
|
5187
|
-
|
|
5703
|
+
settleQuietly(logger5, `Failed to term ${subject}:`, () => {
|
|
5704
|
+
msg.term("Handler timeout");
|
|
5705
|
+
});
|
|
5188
5706
|
reportHandlerCompleted(msg, startedAt, "terminated");
|
|
5189
5707
|
}, timeout);
|
|
5190
5708
|
return pending.then(
|
|
@@ -5193,7 +5711,9 @@ var RpcRouter = class {
|
|
|
5193
5711
|
settled = true;
|
|
5194
5712
|
clearTimeout(timeoutId);
|
|
5195
5713
|
if (stopAckExtension !== null) stopAckExtension();
|
|
5196
|
-
|
|
5714
|
+
settleQuietly(logger5, `Failed to ack ${subject}:`, () => {
|
|
5715
|
+
msg.ack();
|
|
5716
|
+
});
|
|
5197
5717
|
publishReply(replyTo, correlationId, result);
|
|
5198
5718
|
reportHandlerCompleted(msg, startedAt, "success");
|
|
5199
5719
|
},
|
|
@@ -5215,14 +5735,28 @@ var RpcRouter = class {
|
|
|
5215
5735
|
active--;
|
|
5216
5736
|
drainBacklog();
|
|
5217
5737
|
};
|
|
5738
|
+
const routeSafely = (msg) => {
|
|
5739
|
+
try {
|
|
5740
|
+
return handleSafe(msg);
|
|
5741
|
+
} catch (err) {
|
|
5742
|
+
logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
|
|
5743
|
+
return void 0;
|
|
5744
|
+
}
|
|
5745
|
+
};
|
|
5746
|
+
const trackAsync = (result, msg) => {
|
|
5747
|
+
void result.catch((err) => {
|
|
5748
|
+
logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
|
|
5749
|
+
}).finally(onAsyncDone);
|
|
5750
|
+
};
|
|
5218
5751
|
const drainBacklog = () => {
|
|
5219
5752
|
while (active < maxActive) {
|
|
5220
5753
|
const next = backlog.shift();
|
|
5221
5754
|
if (next === void 0) return;
|
|
5755
|
+
next.stopAckExtension?.();
|
|
5222
5756
|
active++;
|
|
5223
|
-
const result =
|
|
5757
|
+
const result = routeSafely(next.msg);
|
|
5224
5758
|
if (result !== void 0) {
|
|
5225
|
-
|
|
5759
|
+
trackAsync(result, next.msg);
|
|
5226
5760
|
} else {
|
|
5227
5761
|
active--;
|
|
5228
5762
|
}
|
|
@@ -5232,7 +5766,10 @@ var RpcRouter = class {
|
|
|
5232
5766
|
this.subscription = this.messageProvider.commands$.subscribe({
|
|
5233
5767
|
next: (msg) => {
|
|
5234
5768
|
if (active >= maxActive) {
|
|
5235
|
-
backlog.push(
|
|
5769
|
+
backlog.push({
|
|
5770
|
+
msg,
|
|
5771
|
+
stopAckExtension: hasAckExtension ? startAckExtensionTimer(msg, ackExtensionInterval) : null
|
|
5772
|
+
});
|
|
5236
5773
|
if (!backlogWarned && backlog.length >= backlogWarnThreshold) {
|
|
5237
5774
|
backlogWarned = true;
|
|
5238
5775
|
logger5.warn(
|
|
@@ -5242,9 +5779,9 @@ var RpcRouter = class {
|
|
|
5242
5779
|
return;
|
|
5243
5780
|
}
|
|
5244
5781
|
active++;
|
|
5245
|
-
const result =
|
|
5782
|
+
const result = routeSafely(msg);
|
|
5246
5783
|
if (result !== void 0) {
|
|
5247
|
-
|
|
5784
|
+
trackAsync(result, msg);
|
|
5248
5785
|
} else {
|
|
5249
5786
|
active--;
|
|
5250
5787
|
if (backlog.length > 0) drainBacklog();
|
|
@@ -5254,6 +5791,12 @@ var RpcRouter = class {
|
|
|
5254
5791
|
logger5.error("Stream error in RPC router", err);
|
|
5255
5792
|
}
|
|
5256
5793
|
});
|
|
5794
|
+
this.subscription.add(() => {
|
|
5795
|
+
for (const queued of backlog) {
|
|
5796
|
+
queued.stopAckExtension?.();
|
|
5797
|
+
}
|
|
5798
|
+
backlog.length = 0;
|
|
5799
|
+
});
|
|
5257
5800
|
}
|
|
5258
5801
|
/** Stop routing and unsubscribe. */
|
|
5259
5802
|
destroy() {
|
|
@@ -5536,7 +6079,7 @@ var JetstreamModule = class {
|
|
|
5536
6079
|
],
|
|
5537
6080
|
useFactory: (options, messageProvider, patternRegistry, codec, eventBus, ackWaitMap, connection) => {
|
|
5538
6081
|
if (options.consumer === false) return null;
|
|
5539
|
-
const deadLetterConfig = options.onDeadLetter ? {
|
|
6082
|
+
const deadLetterConfig = options.onDeadLetter || options.dlq ? {
|
|
5540
6083
|
maxDeliverByStream: /* @__PURE__ */ new Map(),
|
|
5541
6084
|
onDeadLetter: options.onDeadLetter
|
|
5542
6085
|
} : void 0;
|
|
@@ -5737,6 +6280,7 @@ JetstreamModule = __decorateClass([
|
|
|
5737
6280
|
JetstreamHeader,
|
|
5738
6281
|
JetstreamHealthIndicator,
|
|
5739
6282
|
JetstreamModule,
|
|
6283
|
+
JetstreamProvisioningError,
|
|
5740
6284
|
JetstreamRecord,
|
|
5741
6285
|
JetstreamRecordBuilder,
|
|
5742
6286
|
JetstreamStrategy,
|