@horizon-republic/nestjs-jetstream 2.11.1 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +710 -156
- package/dist/index.d.cts +91 -12
- package/dist/index.d.ts +91 -12
- package/dist/index.js +705 -146
- 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.0" : "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,199 @@ 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
|
+
* Detect and finish a migration that a previous process left unfinished.
|
|
3884
|
+
* Safe against concurrent instances: a backup fresh enough to belong to a
|
|
3885
|
+
* live migration is left alone.
|
|
3886
|
+
*
|
|
3887
|
+
* @returns true when recovery work was performed.
|
|
3888
|
+
*/
|
|
3889
|
+
async recoverInterrupted(jsm, streamName2, desiredConfig) {
|
|
3890
|
+
const backupName = `${streamName2}${MIGRATION_BACKUP_SUFFIX}`;
|
|
3891
|
+
const backupInfo = await this.tryInfo(jsm, backupName);
|
|
3892
|
+
if (backupInfo === null) return false;
|
|
3893
|
+
if (this.isPeerMigrationActive(backupInfo)) return false;
|
|
3894
|
+
const streamInfo = await this.tryInfo(jsm, streamName2);
|
|
3895
|
+
if (streamInfo === null) {
|
|
3896
|
+
this.logger.warn(`Stream ${streamName2}: resuming interrupted migration from ${backupName}`);
|
|
3897
|
+
await jsm.streams.add(desiredConfig);
|
|
3898
|
+
if (backupInfo.state.messages > 0) {
|
|
3899
|
+
await this.restoreFromBackup(jsm, streamName2, desiredConfig, backupName);
|
|
3900
|
+
} else {
|
|
3901
|
+
await jsm.streams.delete(backupName);
|
|
3902
|
+
}
|
|
3903
|
+
return true;
|
|
3904
|
+
}
|
|
3905
|
+
const hasBackupSource = (streamInfo.config.sources ?? []).some((s) => s.name === backupName);
|
|
3906
|
+
if (hasBackupSource) {
|
|
3907
|
+
this.logger.warn(`Stream ${streamName2}: finishing interrupted restore from ${backupName}`);
|
|
3908
|
+
await this.waitForSourceDrained(jsm, streamName2, backupName, backupInfo.state.messages);
|
|
3909
|
+
await jsm.streams.delete(backupName);
|
|
3910
|
+
await jsm.streams.update(streamName2, { ...streamInfo.config, sources: [] });
|
|
3911
|
+
return true;
|
|
3912
|
+
}
|
|
3913
|
+
if (backupInfo.state.messages === 0) {
|
|
3914
|
+
this.logger.warn(`Removing empty migration backup ${backupName}`);
|
|
3915
|
+
await jsm.streams.delete(backupName);
|
|
3916
|
+
return true;
|
|
3917
|
+
}
|
|
3918
|
+
this.logger.warn(
|
|
3919
|
+
`Stream ${streamName2}: restoring ${backupInfo.state.messages} messages from stale ${backupName}`
|
|
3920
|
+
);
|
|
3921
|
+
await this.restoreFromBackup(
|
|
3922
|
+
jsm,
|
|
3923
|
+
streamName2,
|
|
3924
|
+
{ ...streamInfo.config, name: streamName2, subjects: streamInfo.config.subjects },
|
|
3925
|
+
backupName
|
|
3672
3926
|
);
|
|
3927
|
+
return true;
|
|
3928
|
+
}
|
|
3929
|
+
/** Attach the backup as a source, drain it fully, then clean up. */
|
|
3930
|
+
async restoreFromBackup(jsm, streamName2, streamConfig, backupName) {
|
|
3931
|
+
const backupInfo = await jsm.streams.info(backupName);
|
|
3932
|
+
if ((backupInfo.config.sources ?? []).length > 0) {
|
|
3933
|
+
await jsm.streams.update(backupName, { ...backupInfo.config, sources: [] });
|
|
3934
|
+
}
|
|
3935
|
+
await jsm.streams.update(streamName2, { ...streamConfig, sources: [{ name: backupName }] });
|
|
3936
|
+
await this.waitForSourceDrained(jsm, streamName2, backupName, backupInfo.state.messages);
|
|
3937
|
+
await jsm.streams.delete(backupName);
|
|
3938
|
+
await jsm.streams.update(streamName2, { ...streamConfig, sources: [] });
|
|
3673
3939
|
}
|
|
3674
|
-
|
|
3940
|
+
/**
|
|
3941
|
+
* Wait until `sourceName` is fully drained into `streamName`. Lag-based, so
|
|
3942
|
+
* concurrent live publishes to the target cannot fake completion the way a
|
|
3943
|
+
* bare message-count comparison could. A freshly attached source reports
|
|
3944
|
+
* `lag: 0, active: -1` before its first sync — `active >= 0` filters that
|
|
3945
|
+
* false positive out (verified against NATS 2.12.6).
|
|
3946
|
+
*/
|
|
3947
|
+
async waitForSourceDrained(jsm, streamName2, sourceName, minimumMessages) {
|
|
3675
3948
|
const deadline = Date.now() + this.sourcingTimeoutMs;
|
|
3676
3949
|
while (Date.now() < deadline) {
|
|
3677
3950
|
const info = await jsm.streams.info(streamName2);
|
|
3678
|
-
|
|
3951
|
+
const source = (info.sources ?? []).find((s) => s.name === sourceName);
|
|
3952
|
+
if (source !== void 0 && source.active >= 0 && source.lag === 0 && info.state.messages >= minimumMessages) {
|
|
3953
|
+
return;
|
|
3954
|
+
}
|
|
3679
3955
|
await new Promise((r) => setTimeout(r, SOURCING_POLL_INTERVAL_MS));
|
|
3680
3956
|
}
|
|
3681
3957
|
throw new Error(
|
|
3682
|
-
`Stream sourcing timeout: ${
|
|
3958
|
+
`Stream sourcing timeout: ${sourceName} has not drained into ${streamName2} within ${this.sourcingTimeoutMs / 1e3}s. The backup is preserved; restoration resumes on the next startup.`
|
|
3959
|
+
);
|
|
3960
|
+
}
|
|
3961
|
+
/**
|
|
3962
|
+
* A backup already present when migrate() begins belongs to another
|
|
3963
|
+
* instance migrating right now (rolling deploy) — wait for it to finish.
|
|
3964
|
+
* Stale leftovers are handled by recoverInterrupted() before migrate() runs,
|
|
3965
|
+
* so a timeout here means something is genuinely stuck.
|
|
3966
|
+
*
|
|
3967
|
+
* @returns true when a peer's backup was observed and cleared.
|
|
3968
|
+
*/
|
|
3969
|
+
async waitOutPeerMigration(jsm, backupName) {
|
|
3970
|
+
if (await this.tryInfo(jsm, backupName) === null) return false;
|
|
3971
|
+
this.logger.warn(
|
|
3972
|
+
`Migration backup ${backupName} exists \u2014 another instance appears to be migrating; waiting`
|
|
3973
|
+
);
|
|
3974
|
+
const deadline = Date.now() + this.peerWaitMs;
|
|
3975
|
+
while (Date.now() < deadline) {
|
|
3976
|
+
if (await this.tryInfo(jsm, backupName) === null) return true;
|
|
3977
|
+
await new Promise((r) => setTimeout(r, SOURCING_POLL_INTERVAL_MS * 5));
|
|
3978
|
+
}
|
|
3979
|
+
throw new Error(
|
|
3980
|
+
`Migration backup ${backupName} did not clear within ${this.peerWaitMs / 1e3}s. If no other instance is migrating, recover or remove the backup manually.`
|
|
3683
3981
|
);
|
|
3684
3982
|
}
|
|
3685
|
-
|
|
3983
|
+
/** Failure before the original was deleted: undo the quiesce, drop our backup. */
|
|
3984
|
+
async rollbackBeforeDelete(jsm, streamName2, originalInfo, backupName) {
|
|
3686
3985
|
try {
|
|
3687
|
-
await jsm.streams.
|
|
3688
|
-
|
|
3689
|
-
|
|
3986
|
+
await jsm.streams.update(streamName2, { ...originalInfo.config });
|
|
3987
|
+
const backupInfo = await this.tryInfo(jsm, backupName);
|
|
3988
|
+
if (backupInfo !== null) {
|
|
3989
|
+
await jsm.streams.delete(backupName);
|
|
3990
|
+
}
|
|
3991
|
+
} catch (rollbackErr) {
|
|
3992
|
+
this.logger.error(
|
|
3993
|
+
`Rollback of ${streamName2} after a failed migration also failed \u2014 the stream may be left quiesced:`,
|
|
3994
|
+
rollbackErr
|
|
3995
|
+
);
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
isPeerMigrationActive(backupInfo) {
|
|
3999
|
+
const startedAt = backupInfo.config.metadata?.[MIGRATION_STARTED_AT_KEY];
|
|
4000
|
+
if (!startedAt) return false;
|
|
4001
|
+
const startedMs = Date.parse(startedAt);
|
|
4002
|
+
if (Number.isNaN(startedMs)) return false;
|
|
4003
|
+
return Date.now() - startedMs < ACTIVE_MIGRATION_GRACE_MS;
|
|
4004
|
+
}
|
|
4005
|
+
async tryInfo(jsm, name) {
|
|
4006
|
+
try {
|
|
4007
|
+
return await jsm.streams.info(name);
|
|
3690
4008
|
} catch (err) {
|
|
3691
|
-
if (err instanceof
|
|
3692
|
-
return;
|
|
4009
|
+
if (err instanceof import_jetstream18.JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
4010
|
+
return null;
|
|
3693
4011
|
}
|
|
3694
4012
|
throw err;
|
|
3695
4013
|
}
|
|
@@ -3720,6 +4038,15 @@ var StreamProvider = class {
|
|
|
3720
4038
|
*/
|
|
3721
4039
|
async ensureStreams(kinds) {
|
|
3722
4040
|
const jsm = await this.connection.getJetStreamManager();
|
|
4041
|
+
const reservations = kinds.map((kind) => this.buildReservation(kind, this.buildConfig(kind)));
|
|
4042
|
+
if (this.options.dlq) {
|
|
4043
|
+
reservations.push(this.buildReservation("dlq", this.buildDlqConfig()));
|
|
4044
|
+
}
|
|
4045
|
+
this.logger.log(`
|
|
4046
|
+
${formatProvisioningSummary(this.options.name, reservations)}`);
|
|
4047
|
+
if (this.options.provisioning?.preflightStorageCheck) {
|
|
4048
|
+
await assertStorageBudget(jsm, this.options.name, reservations, this.logger);
|
|
4049
|
+
}
|
|
3723
4050
|
await Promise.all(kinds.map((kind) => this.ensureStream(jsm, kind)));
|
|
3724
4051
|
if (this.options.dlq) {
|
|
3725
4052
|
await this.ensureDlqStream(jsm);
|
|
@@ -3756,6 +4083,7 @@ var StreamProvider = class {
|
|
|
3756
4083
|
/** Ensure a single stream exists, creating or updating as needed. */
|
|
3757
4084
|
async ensureStream(jsm, kind) {
|
|
3758
4085
|
const config = this.buildConfig(kind);
|
|
4086
|
+
const ctx = this.errorContext(kind, config);
|
|
3759
4087
|
return withProvisioningSpan(
|
|
3760
4088
|
this.otel,
|
|
3761
4089
|
{
|
|
@@ -3763,17 +4091,21 @@ var StreamProvider = class {
|
|
|
3763
4091
|
endpoint: this.otelEndpoint,
|
|
3764
4092
|
entity: "stream",
|
|
3765
4093
|
name: config.name,
|
|
3766
|
-
action: "ensure"
|
|
4094
|
+
action: "ensure",
|
|
4095
|
+
maxBytes: ctx.maxBytes,
|
|
4096
|
+
numReplicas: ctx.numReplicas,
|
|
4097
|
+
reservation: ctx.maxBytes !== void 0 && ctx.numReplicas !== void 0 ? ctx.maxBytes * ctx.numReplicas : void 0
|
|
3767
4098
|
},
|
|
3768
4099
|
async () => {
|
|
3769
4100
|
this.logger.log(`Ensuring stream: ${config.name}`);
|
|
4101
|
+
await this.migration.recoverInterrupted(jsm, config.name, config);
|
|
3770
4102
|
try {
|
|
3771
4103
|
const currentInfo = await jsm.streams.info(config.name);
|
|
3772
|
-
return await this.handleExistingStream(jsm, currentInfo, config);
|
|
4104
|
+
return await this.handleExistingStream(jsm, currentInfo, config, ctx);
|
|
3773
4105
|
} catch (err) {
|
|
3774
|
-
if (err instanceof
|
|
4106
|
+
if (err instanceof import_jetstream19.JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
3775
4107
|
this.logger.log(`Creating stream: ${config.name}`);
|
|
3776
|
-
return await jsm.streams.add(config);
|
|
4108
|
+
return await this.runStreamOp(ctx, () => jsm.streams.add(config));
|
|
3777
4109
|
}
|
|
3778
4110
|
throw err;
|
|
3779
4111
|
}
|
|
@@ -3783,6 +4115,7 @@ var StreamProvider = class {
|
|
|
3783
4115
|
/** Ensure a dead-letter queue stream exists, creating or updating as needed. */
|
|
3784
4116
|
async ensureDlqStream(jsm) {
|
|
3785
4117
|
const config = this.buildDlqConfig();
|
|
4118
|
+
const ctx = this.errorContext("dlq", config);
|
|
3786
4119
|
return withProvisioningSpan(
|
|
3787
4120
|
this.otel,
|
|
3788
4121
|
{
|
|
@@ -3790,24 +4123,30 @@ var StreamProvider = class {
|
|
|
3790
4123
|
endpoint: this.otelEndpoint,
|
|
3791
4124
|
entity: "stream",
|
|
3792
4125
|
name: config.name,
|
|
3793
|
-
action: "ensure"
|
|
4126
|
+
action: "ensure",
|
|
4127
|
+
maxBytes: ctx.maxBytes,
|
|
4128
|
+
numReplicas: ctx.numReplicas,
|
|
4129
|
+
reservation: ctx.maxBytes !== void 0 && ctx.numReplicas !== void 0 ? ctx.maxBytes * ctx.numReplicas : void 0
|
|
3794
4130
|
},
|
|
3795
4131
|
async () => {
|
|
3796
4132
|
this.logger.log(`Ensuring DLQ stream: ${config.name}`);
|
|
3797
4133
|
try {
|
|
3798
4134
|
const currentInfo = await jsm.streams.info(config.name);
|
|
3799
|
-
return await this.handleExistingStream(jsm, currentInfo, config);
|
|
4135
|
+
return await this.handleExistingStream(jsm, currentInfo, config, ctx);
|
|
3800
4136
|
} catch (err) {
|
|
3801
|
-
if (err instanceof
|
|
4137
|
+
if (err instanceof import_jetstream19.JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
3802
4138
|
this.logger.log(`Creating DLQ stream: ${config.name}`);
|
|
3803
|
-
return await jsm.streams.add(config);
|
|
4139
|
+
return await this.runStreamOp(ctx, () => jsm.streams.add(config));
|
|
3804
4140
|
}
|
|
3805
4141
|
throw err;
|
|
3806
4142
|
}
|
|
3807
4143
|
}
|
|
3808
4144
|
);
|
|
3809
4145
|
}
|
|
3810
|
-
async handleExistingStream(jsm, currentInfo, config) {
|
|
4146
|
+
async handleExistingStream(jsm, currentInfo, config, ctx) {
|
|
4147
|
+
if (this.isSharedStream(config.name)) {
|
|
4148
|
+
config.subjects = [.../* @__PURE__ */ new Set([...config.subjects, ...currentInfo.config.subjects])];
|
|
4149
|
+
}
|
|
3811
4150
|
const diff = compareStreamConfig(currentInfo.config, config);
|
|
3812
4151
|
if (!diff.hasChanges) {
|
|
3813
4152
|
this.logger.debug(`Stream ${config.name}: no config changes`);
|
|
@@ -3822,7 +4161,7 @@ var StreamProvider = class {
|
|
|
3822
4161
|
}
|
|
3823
4162
|
if (!diff.hasImmutableChanges) {
|
|
3824
4163
|
this.logger.debug(`Stream exists, updating: ${config.name}`);
|
|
3825
|
-
return await jsm.streams.update(config.name, config);
|
|
4164
|
+
return await this.runStreamOp(ctx, () => jsm.streams.update(config.name, config));
|
|
3826
4165
|
}
|
|
3827
4166
|
if (!this.options.allowDestructiveMigration) {
|
|
3828
4167
|
this.logger.warn(
|
|
@@ -3830,10 +4169,15 @@ var StreamProvider = class {
|
|
|
3830
4169
|
);
|
|
3831
4170
|
if (diff.hasMutableChanges) {
|
|
3832
4171
|
const mutableConfig = this.buildMutableOnlyConfig(config, currentInfo.config, diff);
|
|
3833
|
-
return await jsm.streams.update(config.name, mutableConfig);
|
|
4172
|
+
return await this.runStreamOp(ctx, () => jsm.streams.update(config.name, mutableConfig));
|
|
3834
4173
|
}
|
|
3835
4174
|
return currentInfo;
|
|
3836
4175
|
}
|
|
4176
|
+
if (this.isSharedStream(config.name)) {
|
|
4177
|
+
throw new Error(
|
|
4178
|
+
`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.`
|
|
4179
|
+
);
|
|
4180
|
+
}
|
|
3837
4181
|
await withMigrationSpan(
|
|
3838
4182
|
this.otel,
|
|
3839
4183
|
{
|
|
@@ -3872,11 +4216,47 @@ var StreamProvider = class {
|
|
|
3872
4216
|
}
|
|
3873
4217
|
}
|
|
3874
4218
|
}
|
|
4219
|
+
buildReservation(kind, config) {
|
|
4220
|
+
const mb = config.max_bytes;
|
|
4221
|
+
return {
|
|
4222
|
+
kind,
|
|
4223
|
+
name: config.name,
|
|
4224
|
+
storage: config.storage ?? import_jetstream19.StorageType.File,
|
|
4225
|
+
numReplicas: config.num_replicas ?? 1,
|
|
4226
|
+
maxBytes: mb !== void 0 && mb >= 0 ? mb : 0,
|
|
4227
|
+
// NATS uses -1 for unlimited
|
|
4228
|
+
maxAge: config.max_age ?? 0,
|
|
4229
|
+
retention: config.retention ?? import_jetstream19.RetentionPolicy.Limits
|
|
4230
|
+
};
|
|
4231
|
+
}
|
|
4232
|
+
errorContext(kind, config) {
|
|
4233
|
+
return {
|
|
4234
|
+
entity: "stream",
|
|
4235
|
+
name: config.name,
|
|
4236
|
+
kind,
|
|
4237
|
+
maxBytes: config.max_bytes,
|
|
4238
|
+
numReplicas: config.num_replicas ?? 1
|
|
4239
|
+
};
|
|
4240
|
+
}
|
|
4241
|
+
async runStreamOp(ctx, op) {
|
|
4242
|
+
try {
|
|
4243
|
+
return await op();
|
|
4244
|
+
} catch (err) {
|
|
4245
|
+
if (err instanceof import_jetstream19.JetStreamApiError) {
|
|
4246
|
+
throw mapProvisioningError(err, ctx);
|
|
4247
|
+
}
|
|
4248
|
+
throw err;
|
|
4249
|
+
}
|
|
4250
|
+
}
|
|
4251
|
+
/** The broadcast stream is global — every service in the cluster shares it. */
|
|
4252
|
+
isSharedStream(name) {
|
|
4253
|
+
return name === this.getStreamName("broadcast" /* Broadcast */);
|
|
4254
|
+
}
|
|
3875
4255
|
/** Build the full stream config by merging defaults with user overrides. */
|
|
3876
4256
|
buildConfig(kind) {
|
|
3877
4257
|
const name = this.getStreamName(kind);
|
|
3878
4258
|
const subjects = this.getSubjects(kind);
|
|
3879
|
-
const description = `JetStream ${kind} stream for ${this.options.name}`;
|
|
4259
|
+
const description = kind === "broadcast" /* Broadcast */ ? "JetStream broadcast stream (shared across services)" : `JetStream ${kind} stream for ${this.options.name}`;
|
|
3880
4260
|
const defaults = this.getDefaults(kind);
|
|
3881
4261
|
const overrides = this.getOverrides(kind);
|
|
3882
4262
|
return {
|
|
@@ -3962,7 +4342,7 @@ var StreamProvider = class {
|
|
|
3962
4342
|
|
|
3963
4343
|
// src/server/infrastructure/consumer.provider.ts
|
|
3964
4344
|
var import_common15 = require("@nestjs/common");
|
|
3965
|
-
var
|
|
4345
|
+
var import_jetstream21 = require("@nats-io/jetstream");
|
|
3966
4346
|
var ConsumerProvider = class {
|
|
3967
4347
|
constructor(options, connection, streamProvider, patternRegistry) {
|
|
3968
4348
|
this.options = options;
|
|
@@ -4018,15 +4398,16 @@ var ConsumerProvider = class {
|
|
|
4018
4398
|
},
|
|
4019
4399
|
async () => {
|
|
4020
4400
|
this.logger.log(`Ensuring consumer: ${name} on stream: ${stream}`);
|
|
4401
|
+
const ctx = { entity: "consumer", name, kind };
|
|
4021
4402
|
try {
|
|
4022
4403
|
await jsm.consumers.info(stream, name);
|
|
4023
4404
|
this.logger.debug(`Consumer exists, updating: ${name}`);
|
|
4024
|
-
return await jsm.consumers.update(stream, name, config);
|
|
4405
|
+
return await this.runConsumerOp(ctx, () => jsm.consumers.update(stream, name, config));
|
|
4025
4406
|
} catch (err) {
|
|
4026
|
-
if (!(err instanceof
|
|
4407
|
+
if (!(err instanceof import_jetstream21.JetStreamApiError) || err.apiError().err_code !== 10014 /* ConsumerNotFound */) {
|
|
4027
4408
|
throw err;
|
|
4028
4409
|
}
|
|
4029
|
-
return await this.createConsumer(jsm, stream, name, config);
|
|
4410
|
+
return await this.createConsumer(jsm, stream, name, kind, config);
|
|
4030
4411
|
}
|
|
4031
4412
|
}
|
|
4032
4413
|
);
|
|
@@ -4063,10 +4444,10 @@ var ConsumerProvider = class {
|
|
|
4063
4444
|
try {
|
|
4064
4445
|
return await jsm.consumers.info(stream, name);
|
|
4065
4446
|
} catch (err) {
|
|
4066
|
-
if (!(err instanceof
|
|
4447
|
+
if (!(err instanceof import_jetstream21.JetStreamApiError) || err.apiError().err_code !== 10014 /* ConsumerNotFound */) {
|
|
4067
4448
|
throw err;
|
|
4068
4449
|
}
|
|
4069
|
-
return await this.createConsumer(jsm, stream, name, config);
|
|
4450
|
+
return await this.createConsumer(jsm, stream, name, kind, config);
|
|
4070
4451
|
}
|
|
4071
4452
|
}
|
|
4072
4453
|
);
|
|
@@ -4084,7 +4465,7 @@ var ConsumerProvider = class {
|
|
|
4084
4465
|
`Stream ${stream} is being migrated (backup ${backupName} exists). Waiting for migration to complete before recovering consumer.`
|
|
4085
4466
|
);
|
|
4086
4467
|
} catch (err) {
|
|
4087
|
-
if (err instanceof
|
|
4468
|
+
if (err instanceof import_jetstream21.JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
4088
4469
|
return;
|
|
4089
4470
|
}
|
|
4090
4471
|
throw err;
|
|
@@ -4093,18 +4474,32 @@ var ConsumerProvider = class {
|
|
|
4093
4474
|
/**
|
|
4094
4475
|
* Create a consumer, handling the race where another pod creates it first.
|
|
4095
4476
|
*/
|
|
4096
|
-
async createConsumer(jsm, stream, name, config) {
|
|
4477
|
+
async createConsumer(jsm, stream, name, kind, config) {
|
|
4097
4478
|
this.logger.log(`Creating consumer: ${name}`);
|
|
4479
|
+
const ctx = { entity: "consumer", name, kind };
|
|
4098
4480
|
try {
|
|
4099
4481
|
return await jsm.consumers.add(stream, config);
|
|
4100
4482
|
} catch (addErr) {
|
|
4101
|
-
if (addErr instanceof
|
|
4483
|
+
if (addErr instanceof import_jetstream21.JetStreamApiError && addErr.apiError().err_code === 10148 /* ConsumerAlreadyExists */) {
|
|
4102
4484
|
this.logger.debug(`Consumer ${name} created by another pod, using existing`);
|
|
4103
4485
|
return await jsm.consumers.info(stream, name);
|
|
4104
4486
|
}
|
|
4487
|
+
if (addErr instanceof import_jetstream21.JetStreamApiError) {
|
|
4488
|
+
throw mapProvisioningError(addErr, ctx);
|
|
4489
|
+
}
|
|
4105
4490
|
throw addErr;
|
|
4106
4491
|
}
|
|
4107
4492
|
}
|
|
4493
|
+
async runConsumerOp(ctx, op) {
|
|
4494
|
+
try {
|
|
4495
|
+
return await op();
|
|
4496
|
+
} catch (err) {
|
|
4497
|
+
if (err instanceof import_jetstream21.JetStreamApiError) {
|
|
4498
|
+
throw mapProvisioningError(err, ctx);
|
|
4499
|
+
}
|
|
4500
|
+
throw err;
|
|
4501
|
+
}
|
|
4502
|
+
}
|
|
4108
4503
|
/** Build consumer config by merging defaults with user overrides. */
|
|
4109
4504
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- NATS API uses snake_case
|
|
4110
4505
|
buildConfig(kind) {
|
|
@@ -4186,7 +4581,7 @@ var ConsumerProvider = class {
|
|
|
4186
4581
|
|
|
4187
4582
|
// src/server/infrastructure/message.provider.ts
|
|
4188
4583
|
var import_common16 = require("@nestjs/common");
|
|
4189
|
-
var
|
|
4584
|
+
var import_jetstream23 = require("@nats-io/jetstream");
|
|
4190
4585
|
var import_rxjs3 = require("rxjs");
|
|
4191
4586
|
var MessageProvider = class {
|
|
4192
4587
|
constructor(connection, eventBus, consumeOptionsMap = /* @__PURE__ */ new Map(), consumerRecoveryFn) {
|
|
@@ -4247,7 +4642,7 @@ var MessageProvider = class {
|
|
|
4247
4642
|
*/
|
|
4248
4643
|
async startOrdered(streamName2, filterSubjects, orderedConfig) {
|
|
4249
4644
|
const consumerOpts = { filter_subjects: filterSubjects };
|
|
4250
|
-
if (orderedConfig?.deliverPolicy !== void 0 && orderedConfig.deliverPolicy !==
|
|
4645
|
+
if (orderedConfig?.deliverPolicy !== void 0 && orderedConfig.deliverPolicy !== import_jetstream23.DeliverPolicy.All) {
|
|
4251
4646
|
consumerOpts.deliver_policy = orderedConfig.deliverPolicy;
|
|
4252
4647
|
}
|
|
4253
4648
|
if (orderedConfig?.optStartSeq !== void 0) {
|
|
@@ -4556,6 +4951,7 @@ var MetadataProvider = class {
|
|
|
4556
4951
|
// src/server/routing/event.router.ts
|
|
4557
4952
|
var import_common18 = require("@nestjs/common");
|
|
4558
4953
|
var import_transport_node4 = require("@nats-io/transport-node");
|
|
4954
|
+
var DLQ_PUBLISH_ATTEMPTS = 3;
|
|
4559
4955
|
var eventConsumeKindFor = (kind) => {
|
|
4560
4956
|
if (kind === "broadcast" /* Broadcast */) return "broadcast" /* Broadcast */;
|
|
4561
4957
|
if (kind === "ordered" /* Ordered */) return "ordered" /* Ordered */;
|
|
@@ -4642,33 +5038,80 @@ var EventRouter = class {
|
|
|
4642
5038
|
return msg.info.deliveryCount >= maxDeliver;
|
|
4643
5039
|
};
|
|
4644
5040
|
const handleDeadLetter = hasDlqCheck ? (msg, data, err) => this.handleDeadLetter(msg, data, err) : null;
|
|
4645
|
-
const settleSuccess = (msg, ctx) => {
|
|
4646
|
-
if (ctx.shouldTerminate)
|
|
4647
|
-
|
|
4648
|
-
|
|
5041
|
+
const settleSuccess = (msg, ctx, data) => {
|
|
5042
|
+
if (ctx.shouldTerminate) {
|
|
5043
|
+
settleQuietly(logger5, `Failed to term ${msg.subject}:`, () => {
|
|
5044
|
+
msg.term(ctx.terminateReason);
|
|
5045
|
+
});
|
|
5046
|
+
return void 0;
|
|
5047
|
+
}
|
|
5048
|
+
if (ctx.shouldRetry) {
|
|
5049
|
+
if (handleDeadLetter !== null && isDeadLetter(msg)) {
|
|
5050
|
+
return handleDeadLetter(
|
|
5051
|
+
msg,
|
|
5052
|
+
data,
|
|
5053
|
+
new Error("Retry requested on the final delivery attempt")
|
|
5054
|
+
);
|
|
5055
|
+
}
|
|
5056
|
+
settleQuietly(logger5, `Failed to nak ${msg.subject}:`, () => {
|
|
5057
|
+
msg.nak(ctx.retryDelay);
|
|
5058
|
+
});
|
|
5059
|
+
return void 0;
|
|
5060
|
+
}
|
|
5061
|
+
settleQuietly(logger5, `Failed to ack ${msg.subject}:`, () => {
|
|
5062
|
+
msg.ack();
|
|
5063
|
+
});
|
|
5064
|
+
return void 0;
|
|
4649
5065
|
};
|
|
4650
5066
|
const settleFailure = async (msg, data, err) => {
|
|
4651
5067
|
if (handleDeadLetter !== null && isDeadLetter(msg)) {
|
|
4652
5068
|
await handleDeadLetter(msg, data, err);
|
|
4653
5069
|
return;
|
|
4654
5070
|
}
|
|
4655
|
-
msg.
|
|
5071
|
+
settleQuietly(logger5, `Failed to nak ${msg.subject}:`, () => {
|
|
5072
|
+
msg.nak();
|
|
5073
|
+
});
|
|
5074
|
+
};
|
|
5075
|
+
const captureUnroutable = (capture, msg, err) => {
|
|
5076
|
+
let data;
|
|
5077
|
+
try {
|
|
5078
|
+
data = codec.decode(msg.data);
|
|
5079
|
+
} catch {
|
|
5080
|
+
data = void 0;
|
|
5081
|
+
}
|
|
5082
|
+
return capture(msg, data, err).catch((captureErr) => {
|
|
5083
|
+
logger5.error(`Dead-letter capture failed for unroutable ${msg.subject}:`, captureErr);
|
|
5084
|
+
});
|
|
4656
5085
|
};
|
|
4657
5086
|
const resolveEvent = (msg) => {
|
|
4658
5087
|
const subject = msg.subject;
|
|
4659
5088
|
try {
|
|
4660
5089
|
const handler = patternRegistry.getHandler(subject);
|
|
4661
5090
|
if (!handler) {
|
|
4662
|
-
msg.term(`No handler for event: ${subject}`);
|
|
4663
5091
|
logger5.error(`No handler for subject: ${subject}`);
|
|
5092
|
+
if (handleDeadLetter !== null) {
|
|
5093
|
+
return captureUnroutable(
|
|
5094
|
+
handleDeadLetter,
|
|
5095
|
+
msg,
|
|
5096
|
+
new Error(`No handler for event: ${subject}`)
|
|
5097
|
+
);
|
|
5098
|
+
}
|
|
5099
|
+
msg.term(`No handler for event: ${subject}`);
|
|
4664
5100
|
return null;
|
|
4665
5101
|
}
|
|
4666
5102
|
let data;
|
|
4667
5103
|
try {
|
|
4668
5104
|
data = codec.decode(msg.data);
|
|
4669
5105
|
} catch (err) {
|
|
4670
|
-
msg.term("Decode error");
|
|
4671
5106
|
logger5.error(`Decode error for ${subject}:`, err);
|
|
5107
|
+
if (handleDeadLetter !== null) {
|
|
5108
|
+
return captureUnroutable(
|
|
5109
|
+
handleDeadLetter,
|
|
5110
|
+
msg,
|
|
5111
|
+
new Error(`Decode error: ${err instanceof Error ? err.message : String(err)}`)
|
|
5112
|
+
);
|
|
5113
|
+
}
|
|
5114
|
+
msg.term("Decode error");
|
|
4672
5115
|
return null;
|
|
4673
5116
|
}
|
|
4674
5117
|
eventBus.emitMessageRouted(subject, "event" /* Event */);
|
|
@@ -4691,6 +5134,7 @@ var EventRouter = class {
|
|
|
4691
5134
|
const handleSafe = (msg) => {
|
|
4692
5135
|
const resolved = resolveEvent(msg);
|
|
4693
5136
|
if (resolved === null) return void 0;
|
|
5137
|
+
if (isPromiseLike2(resolved)) return resolved;
|
|
4694
5138
|
const { handler, data } = resolved;
|
|
4695
5139
|
const ctx = new RpcContext([msg]);
|
|
4696
5140
|
const stopAckExtension = hasAckExtension ? startAckExtensionTimer(msg, ackExtensionInterval) : null;
|
|
@@ -4723,16 +5167,24 @@ var EventRouter = class {
|
|
|
4723
5167
|
});
|
|
4724
5168
|
}
|
|
4725
5169
|
if (!isPromiseLike2(pending)) {
|
|
4726
|
-
settleSuccess(msg, ctx);
|
|
5170
|
+
const settled = settleSuccess(msg, ctx, data);
|
|
4727
5171
|
reportHandlerCompleted(msg, startedAt, statusForContext(ctx));
|
|
4728
|
-
if (
|
|
4729
|
-
|
|
5172
|
+
if (settled === void 0) {
|
|
5173
|
+
if (stopAckExtension !== null) stopAckExtension();
|
|
5174
|
+
return void 0;
|
|
5175
|
+
}
|
|
5176
|
+
return settled.finally(() => {
|
|
5177
|
+
if (stopAckExtension !== null) stopAckExtension();
|
|
5178
|
+
});
|
|
4730
5179
|
}
|
|
4731
5180
|
return pending.then(
|
|
4732
|
-
() => {
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
5181
|
+
async () => {
|
|
5182
|
+
try {
|
|
5183
|
+
await settleSuccess(msg, ctx, data);
|
|
5184
|
+
reportHandlerCompleted(msg, startedAt, statusForContext(ctx));
|
|
5185
|
+
} finally {
|
|
5186
|
+
if (stopAckExtension !== null) stopAckExtension();
|
|
5187
|
+
}
|
|
4736
5188
|
},
|
|
4737
5189
|
async (err) => {
|
|
4738
5190
|
eventBus.emit(
|
|
@@ -4826,14 +5278,28 @@ var EventRouter = class {
|
|
|
4826
5278
|
active--;
|
|
4827
5279
|
drainBacklog();
|
|
4828
5280
|
};
|
|
5281
|
+
const routeSafely = (msg) => {
|
|
5282
|
+
try {
|
|
5283
|
+
return route(msg);
|
|
5284
|
+
} catch (err) {
|
|
5285
|
+
logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
|
|
5286
|
+
return void 0;
|
|
5287
|
+
}
|
|
5288
|
+
};
|
|
5289
|
+
const trackAsync = (result, msg) => {
|
|
5290
|
+
void result.catch((err) => {
|
|
5291
|
+
logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
|
|
5292
|
+
}).finally(onAsyncDone);
|
|
5293
|
+
};
|
|
4829
5294
|
const drainBacklog = () => {
|
|
4830
5295
|
while (active < maxActive) {
|
|
4831
5296
|
const next = backlog.shift();
|
|
4832
5297
|
if (next === void 0) return;
|
|
5298
|
+
next.stopAckExtension?.();
|
|
4833
5299
|
active++;
|
|
4834
|
-
const result =
|
|
5300
|
+
const result = routeSafely(next.msg);
|
|
4835
5301
|
if (result !== void 0) {
|
|
4836
|
-
|
|
5302
|
+
trackAsync(result, next.msg);
|
|
4837
5303
|
} else {
|
|
4838
5304
|
active--;
|
|
4839
5305
|
}
|
|
@@ -4843,7 +5309,10 @@ var EventRouter = class {
|
|
|
4843
5309
|
const subscription = stream$.subscribe({
|
|
4844
5310
|
next: (msg) => {
|
|
4845
5311
|
if (active >= maxActive) {
|
|
4846
|
-
backlog.push(
|
|
5312
|
+
backlog.push({
|
|
5313
|
+
msg,
|
|
5314
|
+
stopAckExtension: hasAckExtension ? startAckExtensionTimer(msg, ackExtensionInterval) : null
|
|
5315
|
+
});
|
|
4847
5316
|
if (!backlogWarned && backlog.length >= backlogWarnThreshold) {
|
|
4848
5317
|
backlogWarned = true;
|
|
4849
5318
|
logger5.warn(
|
|
@@ -4853,9 +5322,9 @@ var EventRouter = class {
|
|
|
4853
5322
|
return;
|
|
4854
5323
|
}
|
|
4855
5324
|
active++;
|
|
4856
|
-
const result =
|
|
5325
|
+
const result = routeSafely(msg);
|
|
4857
5326
|
if (result !== void 0) {
|
|
4858
|
-
|
|
5327
|
+
trackAsync(result, msg);
|
|
4859
5328
|
} else {
|
|
4860
5329
|
active--;
|
|
4861
5330
|
if (backlog.length > 0) drainBacklog();
|
|
@@ -4865,6 +5334,12 @@ var EventRouter = class {
|
|
|
4865
5334
|
logger5.error(`Stream error in ${kind} router`, err);
|
|
4866
5335
|
}
|
|
4867
5336
|
});
|
|
5337
|
+
subscription.add(() => {
|
|
5338
|
+
for (const queued of backlog) {
|
|
5339
|
+
queued.stopAckExtension?.();
|
|
5340
|
+
}
|
|
5341
|
+
backlog.length = 0;
|
|
5342
|
+
});
|
|
4868
5343
|
this.subscriptions.push(subscription);
|
|
4869
5344
|
}
|
|
4870
5345
|
getConcurrency(kind) {
|
|
@@ -4879,25 +5354,78 @@ var EventRouter = class {
|
|
|
4879
5354
|
}
|
|
4880
5355
|
/**
|
|
4881
5356
|
* Last-resort path for a dead letter: invoke `onDeadLetter`, then `term` on
|
|
4882
|
-
* success
|
|
4883
|
-
*
|
|
4884
|
-
*
|
|
5357
|
+
* success. On failure the message is nak'd to release it, but the server
|
|
5358
|
+
* never redelivers past `max_deliver` — it stays in the stream for manual
|
|
5359
|
+
* recovery. Used when the DLQ stream isn't configured, or when publishing
|
|
5360
|
+
* to it failed and we still have to surface the message somewhere.
|
|
4885
5361
|
*/
|
|
4886
5362
|
async fallbackToOnDeadLetterCallback(info, msg) {
|
|
4887
|
-
|
|
4888
|
-
|
|
5363
|
+
const onDeadLetter = this.deadLetterConfig?.onDeadLetter;
|
|
5364
|
+
if (!onDeadLetter) {
|
|
5365
|
+
this.logger.error(
|
|
5366
|
+
`Dead letter for ${msg.subject} could not be captured (DLQ publish failed, no onDeadLetter callback) \u2014 leaving the message in the stream`
|
|
5367
|
+
);
|
|
5368
|
+
settleQuietly(this.logger, `Failed to nak ${msg.subject}:`, () => {
|
|
5369
|
+
msg.nak();
|
|
5370
|
+
});
|
|
4889
5371
|
return;
|
|
4890
5372
|
}
|
|
4891
5373
|
try {
|
|
4892
|
-
await
|
|
4893
|
-
|
|
5374
|
+
await onDeadLetter(info);
|
|
5375
|
+
settleQuietly(this.logger, `Failed to term ${msg.subject}:`, () => {
|
|
5376
|
+
msg.term("Dead letter processed via fallback callback");
|
|
5377
|
+
});
|
|
4894
5378
|
} catch (hookErr) {
|
|
4895
5379
|
this.logger.error(
|
|
4896
|
-
`Fallback onDeadLetter callback failed for ${msg.subject}
|
|
5380
|
+
`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
5381
|
hookErr
|
|
4898
5382
|
);
|
|
4899
|
-
msg.
|
|
5383
|
+
settleQuietly(this.logger, `Failed to nak ${msg.subject}:`, () => {
|
|
5384
|
+
msg.nak();
|
|
5385
|
+
});
|
|
5386
|
+
}
|
|
5387
|
+
}
|
|
5388
|
+
/**
|
|
5389
|
+
* Copy the original message headers for the DLQ republish, dropping NATS
|
|
5390
|
+
* server control headers: a copied Nats-TTL expires the DLQ entry (or gets
|
|
5391
|
+
* the publish rejected when the DLQ stream has no allow_msg_ttl), a copied
|
|
5392
|
+
* Nats-Msg-Id collides with the DLQ dedup window.
|
|
5393
|
+
*/
|
|
5394
|
+
buildDlqHeaders(msg) {
|
|
5395
|
+
const hdrs = (0, import_transport_node4.headers)();
|
|
5396
|
+
if (!msg.headers) return hdrs;
|
|
5397
|
+
for (const [k, v] of msg.headers) {
|
|
5398
|
+
if (k.toLowerCase().startsWith(NATS_CONTROL_HEADER_PREFIX)) continue;
|
|
5399
|
+
for (const val of v) {
|
|
5400
|
+
hdrs.append(k, val);
|
|
5401
|
+
}
|
|
5402
|
+
}
|
|
5403
|
+
return hdrs;
|
|
5404
|
+
}
|
|
5405
|
+
/**
|
|
5406
|
+
* Attempt the DLQ publish up to {@link DLQ_PUBLISH_ATTEMPTS} times.
|
|
5407
|
+
*
|
|
5408
|
+
* Past `max_deliver` the server never redelivers, so an in-process retry is
|
|
5409
|
+
* the only second chance a dead letter gets. There is no artificial delay
|
|
5410
|
+
* between attempts: when the broker is unreachable each publish already
|
|
5411
|
+
* spends its own request timeout, which spaces the attempts naturally.
|
|
5412
|
+
*/
|
|
5413
|
+
async publishToDlqWithRetry(connection, subject, data, headers2) {
|
|
5414
|
+
let lastErr;
|
|
5415
|
+
for (let attempt = 1; attempt <= DLQ_PUBLISH_ATTEMPTS; attempt += 1) {
|
|
5416
|
+
try {
|
|
5417
|
+
await connection.getJetStreamClient().publish(subject, data, { headers: headers2 });
|
|
5418
|
+
return;
|
|
5419
|
+
} catch (err) {
|
|
5420
|
+
lastErr = err;
|
|
5421
|
+
if (attempt < DLQ_PUBLISH_ATTEMPTS) {
|
|
5422
|
+
this.logger.warn(
|
|
5423
|
+
`DLQ publish attempt ${attempt}/${DLQ_PUBLISH_ATTEMPTS} failed for ${subject}, retrying`
|
|
5424
|
+
);
|
|
5425
|
+
}
|
|
5426
|
+
}
|
|
4900
5427
|
}
|
|
5428
|
+
throw lastErr;
|
|
4901
5429
|
}
|
|
4902
5430
|
/**
|
|
4903
5431
|
* Publish a dead letter to the configured Dead-Letter Queue (DLQ) stream.
|
|
@@ -4917,14 +5445,7 @@ var EventRouter = class {
|
|
|
4917
5445
|
return;
|
|
4918
5446
|
}
|
|
4919
5447
|
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
|
-
}
|
|
5448
|
+
const hdrs = this.buildDlqHeaders(msg);
|
|
4928
5449
|
let reason = String(error);
|
|
4929
5450
|
if (error instanceof Error) {
|
|
4930
5451
|
reason = error.message;
|
|
@@ -4937,8 +5458,7 @@ var EventRouter = class {
|
|
|
4937
5458
|
hdrs.set("x-failed-at" /* FailedAt */, (/* @__PURE__ */ new Date()).toISOString());
|
|
4938
5459
|
hdrs.set("x-delivery-count" /* DeliveryCount */, msg.info.deliveryCount.toString());
|
|
4939
5460
|
try {
|
|
4940
|
-
|
|
4941
|
-
await js.publish(destinationSubject, msg.data, { headers: hdrs });
|
|
5461
|
+
await this.publishToDlqWithRetry(this.connection, destinationSubject, msg.data, hdrs);
|
|
4942
5462
|
this.logger.log(`Message sent to DLQ: ${msg.subject}`);
|
|
4943
5463
|
if (this.deadLetterConfig?.onDeadLetter) {
|
|
4944
5464
|
try {
|
|
@@ -4950,7 +5470,9 @@ var EventRouter = class {
|
|
|
4950
5470
|
);
|
|
4951
5471
|
}
|
|
4952
5472
|
}
|
|
4953
|
-
|
|
5473
|
+
settleQuietly(this.logger, `Failed to term ${msg.subject}:`, () => {
|
|
5474
|
+
msg.term("Moved to DLQ stream");
|
|
5475
|
+
});
|
|
4954
5476
|
} catch (publishErr) {
|
|
4955
5477
|
this.logger.error(`Failed to publish to DLQ for ${msg.subject}:`, publishErr);
|
|
4956
5478
|
await this.fallbackToOnDeadLetterCallback(info, msg);
|
|
@@ -5144,7 +5666,9 @@ var RpcRouter = class {
|
|
|
5144
5666
|
`rpc-handler:${subject}`
|
|
5145
5667
|
);
|
|
5146
5668
|
publishErrorReply(replyTo, correlationId, subject, err);
|
|
5147
|
-
|
|
5669
|
+
settleQuietly(logger5, `Failed to term ${subject}:`, () => {
|
|
5670
|
+
msg.term(`Handler error: ${subject}`);
|
|
5671
|
+
});
|
|
5148
5672
|
};
|
|
5149
5673
|
const abortController = new AbortController();
|
|
5150
5674
|
let pending;
|
|
@@ -5172,7 +5696,9 @@ var RpcRouter = class {
|
|
|
5172
5696
|
}
|
|
5173
5697
|
if (!isPromiseLike2(pending)) {
|
|
5174
5698
|
if (stopAckExtension !== null) stopAckExtension();
|
|
5175
|
-
|
|
5699
|
+
settleQuietly(logger5, `Failed to ack ${subject}:`, () => {
|
|
5700
|
+
msg.ack();
|
|
5701
|
+
});
|
|
5176
5702
|
publishReply(replyTo, correlationId, pending);
|
|
5177
5703
|
reportHandlerCompleted(msg, startedAt, "success");
|
|
5178
5704
|
return void 0;
|
|
@@ -5184,7 +5710,9 @@ var RpcRouter = class {
|
|
|
5184
5710
|
if (stopAckExtension !== null) stopAckExtension();
|
|
5185
5711
|
abortController.abort();
|
|
5186
5712
|
emitRpcTimeout(subject, correlationId);
|
|
5187
|
-
|
|
5713
|
+
settleQuietly(logger5, `Failed to term ${subject}:`, () => {
|
|
5714
|
+
msg.term("Handler timeout");
|
|
5715
|
+
});
|
|
5188
5716
|
reportHandlerCompleted(msg, startedAt, "terminated");
|
|
5189
5717
|
}, timeout);
|
|
5190
5718
|
return pending.then(
|
|
@@ -5193,7 +5721,9 @@ var RpcRouter = class {
|
|
|
5193
5721
|
settled = true;
|
|
5194
5722
|
clearTimeout(timeoutId);
|
|
5195
5723
|
if (stopAckExtension !== null) stopAckExtension();
|
|
5196
|
-
|
|
5724
|
+
settleQuietly(logger5, `Failed to ack ${subject}:`, () => {
|
|
5725
|
+
msg.ack();
|
|
5726
|
+
});
|
|
5197
5727
|
publishReply(replyTo, correlationId, result);
|
|
5198
5728
|
reportHandlerCompleted(msg, startedAt, "success");
|
|
5199
5729
|
},
|
|
@@ -5215,14 +5745,28 @@ var RpcRouter = class {
|
|
|
5215
5745
|
active--;
|
|
5216
5746
|
drainBacklog();
|
|
5217
5747
|
};
|
|
5748
|
+
const routeSafely = (msg) => {
|
|
5749
|
+
try {
|
|
5750
|
+
return handleSafe(msg);
|
|
5751
|
+
} catch (err) {
|
|
5752
|
+
logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
|
|
5753
|
+
return void 0;
|
|
5754
|
+
}
|
|
5755
|
+
};
|
|
5756
|
+
const trackAsync = (result, msg) => {
|
|
5757
|
+
void result.catch((err) => {
|
|
5758
|
+
logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
|
|
5759
|
+
}).finally(onAsyncDone);
|
|
5760
|
+
};
|
|
5218
5761
|
const drainBacklog = () => {
|
|
5219
5762
|
while (active < maxActive) {
|
|
5220
5763
|
const next = backlog.shift();
|
|
5221
5764
|
if (next === void 0) return;
|
|
5765
|
+
next.stopAckExtension?.();
|
|
5222
5766
|
active++;
|
|
5223
|
-
const result =
|
|
5767
|
+
const result = routeSafely(next.msg);
|
|
5224
5768
|
if (result !== void 0) {
|
|
5225
|
-
|
|
5769
|
+
trackAsync(result, next.msg);
|
|
5226
5770
|
} else {
|
|
5227
5771
|
active--;
|
|
5228
5772
|
}
|
|
@@ -5232,7 +5776,10 @@ var RpcRouter = class {
|
|
|
5232
5776
|
this.subscription = this.messageProvider.commands$.subscribe({
|
|
5233
5777
|
next: (msg) => {
|
|
5234
5778
|
if (active >= maxActive) {
|
|
5235
|
-
backlog.push(
|
|
5779
|
+
backlog.push({
|
|
5780
|
+
msg,
|
|
5781
|
+
stopAckExtension: hasAckExtension ? startAckExtensionTimer(msg, ackExtensionInterval) : null
|
|
5782
|
+
});
|
|
5236
5783
|
if (!backlogWarned && backlog.length >= backlogWarnThreshold) {
|
|
5237
5784
|
backlogWarned = true;
|
|
5238
5785
|
logger5.warn(
|
|
@@ -5242,9 +5789,9 @@ var RpcRouter = class {
|
|
|
5242
5789
|
return;
|
|
5243
5790
|
}
|
|
5244
5791
|
active++;
|
|
5245
|
-
const result =
|
|
5792
|
+
const result = routeSafely(msg);
|
|
5246
5793
|
if (result !== void 0) {
|
|
5247
|
-
|
|
5794
|
+
trackAsync(result, msg);
|
|
5248
5795
|
} else {
|
|
5249
5796
|
active--;
|
|
5250
5797
|
if (backlog.length > 0) drainBacklog();
|
|
@@ -5254,6 +5801,12 @@ var RpcRouter = class {
|
|
|
5254
5801
|
logger5.error("Stream error in RPC router", err);
|
|
5255
5802
|
}
|
|
5256
5803
|
});
|
|
5804
|
+
this.subscription.add(() => {
|
|
5805
|
+
for (const queued of backlog) {
|
|
5806
|
+
queued.stopAckExtension?.();
|
|
5807
|
+
}
|
|
5808
|
+
backlog.length = 0;
|
|
5809
|
+
});
|
|
5257
5810
|
}
|
|
5258
5811
|
/** Stop routing and unsubscribe. */
|
|
5259
5812
|
destroy() {
|
|
@@ -5536,7 +6089,7 @@ var JetstreamModule = class {
|
|
|
5536
6089
|
],
|
|
5537
6090
|
useFactory: (options, messageProvider, patternRegistry, codec, eventBus, ackWaitMap, connection) => {
|
|
5538
6091
|
if (options.consumer === false) return null;
|
|
5539
|
-
const deadLetterConfig = options.onDeadLetter ? {
|
|
6092
|
+
const deadLetterConfig = options.onDeadLetter || options.dlq ? {
|
|
5540
6093
|
maxDeliverByStream: /* @__PURE__ */ new Map(),
|
|
5541
6094
|
onDeadLetter: options.onDeadLetter
|
|
5542
6095
|
} : void 0;
|
|
@@ -5737,6 +6290,7 @@ JetstreamModule = __decorateClass([
|
|
|
5737
6290
|
JetstreamHeader,
|
|
5738
6291
|
JetstreamHealthIndicator,
|
|
5739
6292
|
JetstreamModule,
|
|
6293
|
+
JetstreamProvisioningError,
|
|
5740
6294
|
JetstreamRecord,
|
|
5741
6295
|
JetstreamRecordBuilder,
|
|
5742
6296
|
JetstreamStrategy,
|