@horizon-republic/nestjs-jetstream 2.11.0 → 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 +725 -168
- package/dist/index.d.cts +95 -22
- package/dist/index.d.ts +95 -22
- package/dist/index.js +720 -158
- package/package.json +16 -13
package/dist/index.js
CHANGED
|
@@ -144,7 +144,7 @@ var DEFAULT_ORDERED_STREAM_CONFIG = {
|
|
|
144
144
|
};
|
|
145
145
|
var DEFAULT_DLQ_STREAM_CONFIG = {
|
|
146
146
|
...baseStreamConfig,
|
|
147
|
-
retention: RetentionPolicy.
|
|
147
|
+
retention: RetentionPolicy.Limits,
|
|
148
148
|
allow_rollup_hdrs: false,
|
|
149
149
|
max_consumers: 100,
|
|
150
150
|
max_msg_size: 10 * MB,
|
|
@@ -208,6 +208,7 @@ var RESERVED_HEADERS = /* @__PURE__ */ new Set([
|
|
|
208
208
|
"x-reply-to" /* ReplyTo */,
|
|
209
209
|
"x-error" /* Error */
|
|
210
210
|
]);
|
|
211
|
+
var NATS_CONTROL_HEADER_PREFIX = "nats-";
|
|
211
212
|
var internalName = (name) => `${name}__microservice`;
|
|
212
213
|
var buildSubject = (serviceName, kind, pattern) => `${internalName(serviceName)}.${kind}.${pattern}`;
|
|
213
214
|
var buildBroadcastSubject = (pattern) => `broadcast.${pattern}`;
|
|
@@ -260,6 +261,9 @@ var ATTR_JETSTREAM_RPC_REPLY_ERROR_CODE = "jetstream.rpc.reply.error.code";
|
|
|
260
261
|
var ATTR_JETSTREAM_PROVISIONING_ENTITY = "jetstream.provisioning.entity";
|
|
261
262
|
var ATTR_JETSTREAM_PROVISIONING_ACTION = "jetstream.provisioning.action";
|
|
262
263
|
var ATTR_JETSTREAM_PROVISIONING_NAME = "jetstream.provisioning.name";
|
|
264
|
+
var ATTR_JETSTREAM_PROVISIONING_MAX_BYTES = "jetstream.provisioning.max_bytes";
|
|
265
|
+
var ATTR_JETSTREAM_PROVISIONING_NUM_REPLICAS = "jetstream.provisioning.num_replicas";
|
|
266
|
+
var ATTR_JETSTREAM_PROVISIONING_RESERVATION = "jetstream.provisioning.reservation_bytes";
|
|
263
267
|
var ATTR_JETSTREAM_SELF_HEALING_REASON = "jetstream.self_healing.reason";
|
|
264
268
|
var ATTR_JETSTREAM_MIGRATION_REASON = "jetstream.migration.reason";
|
|
265
269
|
var ATTR_JETSTREAM_DEAD_LETTER_REASON = "jetstream.dead_letter.reason";
|
|
@@ -446,6 +450,8 @@ var resolveCaptureBody = (option) => {
|
|
|
446
450
|
};
|
|
447
451
|
};
|
|
448
452
|
var resolveOtelOptions = (options = {}) => {
|
|
453
|
+
if (options === true) options = {};
|
|
454
|
+
if (options === false) options = { enabled: false };
|
|
449
455
|
return {
|
|
450
456
|
enabled: options.enabled ?? true,
|
|
451
457
|
traces: expandTracesOption(options.traces),
|
|
@@ -527,7 +533,7 @@ var extractContext = (ctx, carrier, getter) => propagation.extract(ctx, carrier,
|
|
|
527
533
|
|
|
528
534
|
// src/otel/tracer.ts
|
|
529
535
|
import { trace } from "@opentelemetry/api";
|
|
530
|
-
var PACKAGE_VERSION = true ? "2.
|
|
536
|
+
var PACKAGE_VERSION = true ? "2.12.0" : "0.0.0";
|
|
531
537
|
var getTracer = () => trace.getTracer(TRACER_NAME, PACKAGE_VERSION);
|
|
532
538
|
|
|
533
539
|
// src/otel/carrier.ts
|
|
@@ -1127,7 +1133,10 @@ var withProvisioningSpan = (config, ctx, op) => wrapInfra(
|
|
|
1127
1133
|
{
|
|
1128
1134
|
[ATTR_JETSTREAM_PROVISIONING_ENTITY]: ctx.entity,
|
|
1129
1135
|
[ATTR_JETSTREAM_PROVISIONING_ACTION]: ctx.action,
|
|
1130
|
-
[ATTR_JETSTREAM_PROVISIONING_NAME]: ctx.name
|
|
1136
|
+
[ATTR_JETSTREAM_PROVISIONING_NAME]: ctx.name,
|
|
1137
|
+
[ATTR_JETSTREAM_PROVISIONING_MAX_BYTES]: ctx.maxBytes,
|
|
1138
|
+
[ATTR_JETSTREAM_PROVISIONING_NUM_REPLICAS]: ctx.numReplicas,
|
|
1139
|
+
[ATTR_JETSTREAM_PROVISIONING_RESERVATION]: ctx.reservation
|
|
1131
1140
|
},
|
|
1132
1141
|
op
|
|
1133
1142
|
);
|
|
@@ -1303,7 +1312,13 @@ var JetstreamRecordBuilder = class {
|
|
|
1303
1312
|
* lockstep. `RESERVED_HEADERS` is defined as an all-lowercase set.
|
|
1304
1313
|
*/
|
|
1305
1314
|
validateHeaderKey(key) {
|
|
1306
|
-
|
|
1315
|
+
const normalized = key.toLowerCase();
|
|
1316
|
+
if (normalized.startsWith(NATS_CONTROL_HEADER_PREFIX)) {
|
|
1317
|
+
throw new Error(
|
|
1318
|
+
`Header "${key}" is reserved for the NATS server and cannot be set manually. Use setMessageId() for deduplication, ttl() for per-message expiry, and scheduleAt() for delayed delivery.`
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
if (RESERVED_HEADERS.has(normalized)) {
|
|
1307
1322
|
throw new Error(
|
|
1308
1323
|
`Header "${key}" is reserved by the JetStream transport and cannot be set manually. Reserved headers: ${[...RESERVED_HEADERS].join(", ")}`
|
|
1309
1324
|
);
|
|
@@ -1449,13 +1464,18 @@ var JetstreamClient = class extends ClientProxy {
|
|
|
1449
1464
|
async dispatchEvent(packet) {
|
|
1450
1465
|
if (!this.readyForPublish) await this.connect();
|
|
1451
1466
|
const { data, hdrs, messageId, schedule, ttl } = this.extractRecordData(packet.data);
|
|
1467
|
+
const publishKind = detectEventKind(packet.pattern);
|
|
1468
|
+
if (schedule && publishKind === "ordered" /* Ordered */) {
|
|
1469
|
+
throw new Error(
|
|
1470
|
+
`scheduleAt() is not supported for ordered events (pattern: ${packet.pattern}). Scheduled delivery is available for workqueue events and broadcasts.`
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1452
1473
|
const eventSubject = this.buildEventSubject(packet.pattern);
|
|
1453
1474
|
const publishSubject = schedule ? this.buildScheduleSubject(eventSubject) : eventSubject;
|
|
1454
1475
|
const msgHeaders = this.buildHeaders(hdrs, { subject: eventSubject });
|
|
1455
1476
|
const encoded = this.codec.encode(data);
|
|
1456
1477
|
const effectiveMsgId = messageId ?? nuid.next();
|
|
1457
1478
|
const record = packet.data instanceof JetstreamRecord ? packet.data : new JetstreamRecord(data, /* @__PURE__ */ new Map());
|
|
1458
|
-
const publishKind = detectEventKind(packet.pattern);
|
|
1459
1479
|
const declaredPattern = declaredEventPattern(packet.pattern);
|
|
1460
1480
|
const streamKind = eventStreamKind(publishKind);
|
|
1461
1481
|
const startedAt = performance.now();
|
|
@@ -1487,10 +1507,10 @@ var JetstreamClient = class extends ClientProxy {
|
|
|
1487
1507
|
const ack2 = await this.connection.getJetStreamClient().publish(publishSubject, encoded, {
|
|
1488
1508
|
headers: msgHeaders,
|
|
1489
1509
|
msgID: effectiveMsgId,
|
|
1490
|
-
ttl,
|
|
1491
1510
|
schedule: {
|
|
1492
1511
|
specification: schedule.at,
|
|
1493
|
-
target: eventSubject
|
|
1512
|
+
target: eventSubject,
|
|
1513
|
+
ttl
|
|
1494
1514
|
}
|
|
1495
1515
|
});
|
|
1496
1516
|
warnIfDuplicate("scheduled", ack2);
|
|
@@ -1680,13 +1700,18 @@ var JetstreamClient = class extends ClientProxy {
|
|
|
1680
1700
|
});
|
|
1681
1701
|
return;
|
|
1682
1702
|
}
|
|
1683
|
-
await context6.with(
|
|
1703
|
+
const ack = await context6.with(
|
|
1684
1704
|
spanHandle.activeContext,
|
|
1685
1705
|
() => this.connection.getJetStreamClient().publish(subject, encoded, {
|
|
1686
1706
|
headers: hdrs,
|
|
1687
1707
|
msgID: messageId ?? nuid.next()
|
|
1688
1708
|
})
|
|
1689
1709
|
);
|
|
1710
|
+
if (ack.duplicate) {
|
|
1711
|
+
throw new Error(
|
|
1712
|
+
`Duplicate RPC publish for ${subject}: the messageId was already used within the stream dedup window, so the reply belongs to the original request`
|
|
1713
|
+
);
|
|
1714
|
+
}
|
|
1690
1715
|
this.reportPublished(declaredPattern, "cmd" /* Command */, startedAt, "success");
|
|
1691
1716
|
} catch (err) {
|
|
1692
1717
|
const existingTimeout = this.pendingTimeouts.get(correlationId);
|
|
@@ -1856,13 +1881,17 @@ var JetstreamClient = class extends ClientProxy {
|
|
|
1856
1881
|
* uses a separate `_sch` namespace that is NOT matched by any consumer filter.
|
|
1857
1882
|
* NATS holds the message and publishes it to the target subject after the delay.
|
|
1858
1883
|
*
|
|
1884
|
+
* A unique per-message suffix is appended because the server stores schedules
|
|
1885
|
+
* as rollup messages — one active schedule per subject (ADR-51). Without it,
|
|
1886
|
+
* concurrent schedules of the same pattern would silently replace each other.
|
|
1887
|
+
*
|
|
1859
1888
|
* Examples:
|
|
1860
|
-
* - `{svc}__microservice.ev.order.reminder` → `{svc}__microservice._sch.order.reminder
|
|
1861
|
-
* - `broadcast.config.updated` → `broadcast._sch.config.updated
|
|
1889
|
+
* - `{svc}__microservice.ev.order.reminder` → `{svc}__microservice._sch.order.reminder.<nuid>`
|
|
1890
|
+
* - `broadcast.config.updated` → `broadcast._sch.config.updated.<nuid>`
|
|
1862
1891
|
*/
|
|
1863
1892
|
buildScheduleSubject(eventSubject) {
|
|
1864
1893
|
if (eventSubject.startsWith("broadcast.")) {
|
|
1865
|
-
return eventSubject.replace("broadcast.", "broadcast._sch.")
|
|
1894
|
+
return `${eventSubject.replace("broadcast.", "broadcast._sch.")}.${nuid.next()}`;
|
|
1866
1895
|
}
|
|
1867
1896
|
const targetPrefix = `${internalName(this.targetName)}.`;
|
|
1868
1897
|
if (!eventSubject.startsWith(targetPrefix)) {
|
|
@@ -1874,7 +1903,7 @@ var JetstreamClient = class extends ClientProxy {
|
|
|
1874
1903
|
throw new Error(`Event subject missing pattern segment: ${eventSubject}`);
|
|
1875
1904
|
}
|
|
1876
1905
|
const pattern = withoutPrefix.slice(dotIndex + 1);
|
|
1877
|
-
return `${targetPrefix}_sch.${pattern}`;
|
|
1906
|
+
return `${targetPrefix}_sch.${pattern}.${nuid.next()}`;
|
|
1878
1907
|
}
|
|
1879
1908
|
};
|
|
1880
1909
|
|
|
@@ -1886,6 +1915,7 @@ var JsonCodec = class {
|
|
|
1886
1915
|
return encoder.encode(JSON.stringify(data));
|
|
1887
1916
|
}
|
|
1888
1917
|
decode(data) {
|
|
1918
|
+
if (data.length === 0) return void 0;
|
|
1889
1919
|
return JSON.parse(decoder.decode(data));
|
|
1890
1920
|
}
|
|
1891
1921
|
};
|
|
@@ -1899,6 +1929,7 @@ var MsgpackCodec = class {
|
|
|
1899
1929
|
return this.packr.pack(data);
|
|
1900
1930
|
}
|
|
1901
1931
|
decode(data) {
|
|
1932
|
+
if (data.length === 0) return void 0;
|
|
1902
1933
|
return this.packr.unpack(data);
|
|
1903
1934
|
}
|
|
1904
1935
|
};
|
|
@@ -2708,6 +2739,7 @@ var JetstreamMetricsService = class {
|
|
|
2708
2739
|
activeServers = /* @__PURE__ */ new Set();
|
|
2709
2740
|
async onApplicationBootstrap() {
|
|
2710
2741
|
if (this.metrics !== null) return;
|
|
2742
|
+
if (!this.options.metrics || !this.config || !this.promClient) return;
|
|
2711
2743
|
if (!this.config.register) {
|
|
2712
2744
|
throw new Error(
|
|
2713
2745
|
"JetstreamMetricsService requires a prom-client Registry \u2014 none was resolved by JetstreamMetricsModule."
|
|
@@ -2733,7 +2765,7 @@ var JetstreamMetricsService = class {
|
|
|
2733
2765
|
}
|
|
2734
2766
|
/** @internal Visible for tests. `0` disables polling. */
|
|
2735
2767
|
getEffectivePollInterval() {
|
|
2736
|
-
return this.config
|
|
2768
|
+
return this.config?.pollInterval ?? DEFAULT_POLL_INTERVAL_MS;
|
|
2737
2769
|
}
|
|
2738
2770
|
/**
|
|
2739
2771
|
* NATS connects during early bootstrap, before this service subscribes to
|
|
@@ -2899,28 +2931,29 @@ var normalizeMetricsConfig = (option, promClient) => {
|
|
|
2899
2931
|
};
|
|
2900
2932
|
};
|
|
2901
2933
|
var JetstreamMetricsModule = class {
|
|
2902
|
-
static forFeature(
|
|
2903
|
-
if (!metricsOption) {
|
|
2904
|
-
return { module: JetstreamMetricsModule, providers: [], exports: [] };
|
|
2905
|
-
}
|
|
2934
|
+
static forFeature() {
|
|
2906
2935
|
const promClientProvider = {
|
|
2907
2936
|
provide: JETSTREAM_METRICS_PROM_CLIENT,
|
|
2908
|
-
|
|
2937
|
+
inject: [JETSTREAM_OPTIONS],
|
|
2938
|
+
useFactory: async (opts) => {
|
|
2939
|
+
if (!opts.metrics) return null;
|
|
2909
2940
|
const mod = await resolvePromClient();
|
|
2910
2941
|
return { Counter: mod.Counter, Histogram: mod.Histogram, Gauge: mod.Gauge };
|
|
2911
2942
|
}
|
|
2912
2943
|
};
|
|
2913
2944
|
const configProvider = {
|
|
2914
2945
|
provide: JETSTREAM_METRICS_CONFIG,
|
|
2915
|
-
|
|
2946
|
+
inject: [JETSTREAM_OPTIONS],
|
|
2947
|
+
useFactory: async (opts) => {
|
|
2948
|
+
if (!opts.metrics) return null;
|
|
2916
2949
|
const mod = await resolvePromClient();
|
|
2917
|
-
return normalizeMetricsConfig(
|
|
2950
|
+
return normalizeMetricsConfig(opts.metrics, mod);
|
|
2918
2951
|
}
|
|
2919
2952
|
};
|
|
2920
2953
|
const registryProvider = {
|
|
2921
2954
|
provide: JETSTREAM_METRICS_REGISTRY,
|
|
2922
2955
|
inject: [JETSTREAM_METRICS_CONFIG],
|
|
2923
|
-
useFactory: (cfg) => cfg
|
|
2956
|
+
useFactory: (cfg) => cfg?.register ?? null
|
|
2924
2957
|
};
|
|
2925
2958
|
const serviceProvider = {
|
|
2926
2959
|
provide: JetstreamMetricsService,
|
|
@@ -2977,43 +3010,11 @@ var JetstreamStrategy = class extends Server {
|
|
|
2977
3010
|
* Called by NestJS when `connectMicroservice()` is used, or internally by the module.
|
|
2978
3011
|
*/
|
|
2979
3012
|
async listen(callback) {
|
|
2980
|
-
|
|
2981
|
-
this.
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
this.started = true;
|
|
2985
|
-
this.patternRegistry.registerHandlers(this.getHandlers());
|
|
2986
|
-
const { streams: streamKinds, durableConsumers: durableKinds } = this.resolveRequiredKinds();
|
|
2987
|
-
if (streamKinds.length > 0) {
|
|
2988
|
-
await this.streamProvider.ensureStreams(streamKinds);
|
|
2989
|
-
if (durableKinds.length > 0) {
|
|
2990
|
-
const consumers = await this.consumerProvider.ensureConsumers(durableKinds);
|
|
2991
|
-
this.populateAckWaitMap(consumers);
|
|
2992
|
-
this.eventRouter.updateMaxDeliverMap(this.buildMaxDeliverMap(consumers));
|
|
2993
|
-
this.messageProvider.start(consumers);
|
|
2994
|
-
}
|
|
2995
|
-
if (this.patternRegistry.hasOrderedHandlers()) {
|
|
2996
|
-
const orderedStreamName = this.streamProvider.getStreamName("ordered" /* Ordered */);
|
|
2997
|
-
await this.messageProvider.startOrdered(
|
|
2998
|
-
orderedStreamName,
|
|
2999
|
-
this.patternRegistry.getOrderedSubjects(),
|
|
3000
|
-
this.options.ordered
|
|
3001
|
-
);
|
|
3002
|
-
}
|
|
3003
|
-
if (this.patternRegistry.hasEventHandlers() || this.patternRegistry.hasBroadcastHandlers() || this.patternRegistry.hasOrderedHandlers()) {
|
|
3004
|
-
this.eventRouter.start();
|
|
3005
|
-
}
|
|
3006
|
-
if (isJetStreamRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
|
|
3007
|
-
await this.rpcRouter.start();
|
|
3008
|
-
}
|
|
3009
|
-
}
|
|
3010
|
-
if (isCoreRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
|
|
3011
|
-
await this.coreRpcServer.start();
|
|
3012
|
-
}
|
|
3013
|
-
if (this.metadataProvider && this.patternRegistry.hasMetadata()) {
|
|
3014
|
-
await this.metadataProvider.publish(this.patternRegistry.getMetadataEntries());
|
|
3013
|
+
try {
|
|
3014
|
+
await this.doListen(callback);
|
|
3015
|
+
} catch (err) {
|
|
3016
|
+
callback(err);
|
|
3015
3017
|
}
|
|
3016
|
-
callback();
|
|
3017
3018
|
}
|
|
3018
3019
|
/** Stop all consumers, routers, subscriptions, and metadata heartbeat. Called during shutdown. */
|
|
3019
3020
|
close() {
|
|
@@ -3072,6 +3073,33 @@ var JetstreamStrategy = class extends Server {
|
|
|
3072
3073
|
getPatternRegistry() {
|
|
3073
3074
|
return this.patternRegistry;
|
|
3074
3075
|
}
|
|
3076
|
+
async doListen(callback) {
|
|
3077
|
+
if (this.started) {
|
|
3078
|
+
this.logger.warn("listen() called more than once \u2014 ignoring");
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
this.started = true;
|
|
3082
|
+
this.patternRegistry.registerHandlers(this.getHandlers());
|
|
3083
|
+
const { streams: streamKinds, durableConsumers: durableKinds } = this.resolveRequiredKinds();
|
|
3084
|
+
if (streamKinds.length > 0) {
|
|
3085
|
+
await this.streamProvider.ensureStreams(streamKinds);
|
|
3086
|
+
let consumers = null;
|
|
3087
|
+
if (durableKinds.length > 0) {
|
|
3088
|
+
consumers = await this.consumerProvider.ensureConsumers(durableKinds);
|
|
3089
|
+
this.populateAckWaitMap(consumers);
|
|
3090
|
+
this.eventRouter.updateMaxDeliverMap(this.buildMaxDeliverMap(consumers));
|
|
3091
|
+
}
|
|
3092
|
+
await this.startRouters();
|
|
3093
|
+
await this.startConsumption(consumers);
|
|
3094
|
+
}
|
|
3095
|
+
if (isCoreRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
|
|
3096
|
+
await this.coreRpcServer.start();
|
|
3097
|
+
}
|
|
3098
|
+
if (this.metadataProvider && this.patternRegistry.hasMetadata()) {
|
|
3099
|
+
await this.metadataProvider.publish(this.patternRegistry.getMetadataEntries());
|
|
3100
|
+
}
|
|
3101
|
+
callback();
|
|
3102
|
+
}
|
|
3075
3103
|
/** Determine which streams and durable consumers are needed. */
|
|
3076
3104
|
resolveRequiredKinds() {
|
|
3077
3105
|
const streams = [];
|
|
@@ -3093,7 +3121,29 @@ var JetstreamStrategy = class extends Server {
|
|
|
3093
3121
|
}
|
|
3094
3122
|
return { streams, durableConsumers };
|
|
3095
3123
|
}
|
|
3096
|
-
/**
|
|
3124
|
+
/** Subscribe the event and RPC routers to the message subjects. */
|
|
3125
|
+
async startRouters() {
|
|
3126
|
+
if (this.patternRegistry.hasEventHandlers() || this.patternRegistry.hasBroadcastHandlers() || this.patternRegistry.hasOrderedHandlers()) {
|
|
3127
|
+
this.eventRouter.start();
|
|
3128
|
+
}
|
|
3129
|
+
if (isJetStreamRpcMode(this.options.rpc) && this.patternRegistry.hasRpcHandlers()) {
|
|
3130
|
+
await this.rpcRouter.start();
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
/** Begin durable and ordered consumption; routers must already be subscribed. */
|
|
3134
|
+
async startConsumption(consumers) {
|
|
3135
|
+
if (consumers !== null) {
|
|
3136
|
+
this.messageProvider.start(consumers);
|
|
3137
|
+
}
|
|
3138
|
+
if (this.patternRegistry.hasOrderedHandlers()) {
|
|
3139
|
+
const orderedStreamName = this.streamProvider.getStreamName("ordered" /* Ordered */);
|
|
3140
|
+
await this.messageProvider.startOrdered(
|
|
3141
|
+
orderedStreamName,
|
|
3142
|
+
this.patternRegistry.getOrderedSubjects(),
|
|
3143
|
+
this.options.ordered
|
|
3144
|
+
);
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3097
3147
|
populateAckWaitMap(consumers) {
|
|
3098
3148
|
for (const [kind, info] of consumers) {
|
|
3099
3149
|
if (info.config.ack_wait) {
|
|
@@ -3332,6 +3382,15 @@ var serializeError = (err) => {
|
|
|
3332
3382
|
return err;
|
|
3333
3383
|
};
|
|
3334
3384
|
|
|
3385
|
+
// src/utils/settle-quietly.ts
|
|
3386
|
+
var settleQuietly = (logger5, label, action) => {
|
|
3387
|
+
try {
|
|
3388
|
+
action();
|
|
3389
|
+
} catch (err) {
|
|
3390
|
+
logger5.error(label, err);
|
|
3391
|
+
}
|
|
3392
|
+
};
|
|
3393
|
+
|
|
3335
3394
|
// src/utils/unwrap-result.ts
|
|
3336
3395
|
import { isObservable } from "rxjs";
|
|
3337
3396
|
var unwrapResult = (result) => {
|
|
@@ -3497,16 +3556,168 @@ var CoreRpcServer = class {
|
|
|
3497
3556
|
|
|
3498
3557
|
// src/server/infrastructure/stream.provider.ts
|
|
3499
3558
|
import { Logger as Logger13 } from "@nestjs/common";
|
|
3500
|
-
import {
|
|
3559
|
+
import {
|
|
3560
|
+
JetStreamApiError as JetStreamApiError2,
|
|
3561
|
+
RetentionPolicy as RetentionPolicy2,
|
|
3562
|
+
StorageType as StorageType4
|
|
3563
|
+
} from "@nats-io/jetstream";
|
|
3501
3564
|
|
|
3502
3565
|
// src/server/infrastructure/nats-error-codes.ts
|
|
3503
3566
|
var NatsErrorCode = /* @__PURE__ */ ((NatsErrorCode2) => {
|
|
3504
3567
|
NatsErrorCode2[NatsErrorCode2["ConsumerNotFound"] = 10014] = "ConsumerNotFound";
|
|
3505
3568
|
NatsErrorCode2[NatsErrorCode2["ConsumerAlreadyExists"] = 10148] = "ConsumerAlreadyExists";
|
|
3506
3569
|
NatsErrorCode2[NatsErrorCode2["StreamNotFound"] = 10059] = "StreamNotFound";
|
|
3570
|
+
NatsErrorCode2[NatsErrorCode2["StorageResourcesExceeded"] = 10047] = "StorageResourcesExceeded";
|
|
3571
|
+
NatsErrorCode2[NatsErrorCode2["NoSuitablePeers"] = 10005] = "NoSuitablePeers";
|
|
3507
3572
|
return NatsErrorCode2;
|
|
3508
3573
|
})(NatsErrorCode || {});
|
|
3509
3574
|
|
|
3575
|
+
// src/server/infrastructure/provisioning-budget.ts
|
|
3576
|
+
import { StorageType as StorageType2 } from "@nats-io/jetstream";
|
|
3577
|
+
var GIB = 1024 ** 3;
|
|
3578
|
+
var fmt = (bytes) => `${(bytes / GIB).toFixed(2)} GiB`;
|
|
3579
|
+
var resolveTierBudget = (info, replicas) => {
|
|
3580
|
+
const tier = info.tiers?.[`R${replicas}`];
|
|
3581
|
+
const limits = tier?.limits ?? info.limits;
|
|
3582
|
+
return {
|
|
3583
|
+
maxStorage: limits?.max_storage ?? 0,
|
|
3584
|
+
reserved: tier?.reserved_storage ?? info.reserved_storage ?? 0,
|
|
3585
|
+
tiered: tier !== void 0
|
|
3586
|
+
};
|
|
3587
|
+
};
|
|
3588
|
+
var groupByReplicas = (reservations) => {
|
|
3589
|
+
const groups = /* @__PURE__ */ new Map();
|
|
3590
|
+
for (const r of reservations) {
|
|
3591
|
+
if (r.storage !== StorageType2.File) continue;
|
|
3592
|
+
const prev = groups.get(r.numReplicas) ?? 0;
|
|
3593
|
+
groups.set(r.numReplicas, prev + r.maxBytes * r.numReplicas);
|
|
3594
|
+
}
|
|
3595
|
+
return groups;
|
|
3596
|
+
};
|
|
3597
|
+
var assertStorageBudget = async (jsm, serviceName, reservations, logger5) => {
|
|
3598
|
+
try {
|
|
3599
|
+
const info = await jsm.getAccountInfo();
|
|
3600
|
+
const groups = groupByReplicas(reservations);
|
|
3601
|
+
let limitNotSetWarned = false;
|
|
3602
|
+
let okReserved = 0;
|
|
3603
|
+
let anyWarned = false;
|
|
3604
|
+
for (const [replicas, incremental] of groups) {
|
|
3605
|
+
const { maxStorage, reserved, tiered } = resolveTierBudget(info, replicas);
|
|
3606
|
+
const tierNote = tiered ? ` (tier R${replicas})` : "";
|
|
3607
|
+
if (maxStorage <= 0) {
|
|
3608
|
+
if (!limitNotSetWarned) {
|
|
3609
|
+
limitNotSetWarned = true;
|
|
3610
|
+
logger5.warn(
|
|
3611
|
+
`Storage preflight for "${serviceName}": account file-storage limit not set (max_storage=${maxStorage}); the server max_file_store cannot be verified from the client.`
|
|
3612
|
+
);
|
|
3613
|
+
}
|
|
3614
|
+
continue;
|
|
3615
|
+
}
|
|
3616
|
+
const remaining = maxStorage - reserved;
|
|
3617
|
+
if (incremental > remaining) {
|
|
3618
|
+
anyWarned = true;
|
|
3619
|
+
logger5.warn(
|
|
3620
|
+
`Storage preflight for "${serviceName}"${tierNote}: needs ~${fmt(incremental)} but only ~${fmt(remaining)} remains (reserved ${fmt(reserved)} / limit ${fmt(maxStorage)}). Provisioning will likely fail with insufficient storage. Lower max_bytes/num_replicas, or raise the account/server storage limit.`
|
|
3621
|
+
);
|
|
3622
|
+
continue;
|
|
3623
|
+
}
|
|
3624
|
+
okReserved += incremental;
|
|
3625
|
+
}
|
|
3626
|
+
if (!anyWarned && !limitNotSetWarned && okReserved > 0) {
|
|
3627
|
+
logger5.log(
|
|
3628
|
+
`Storage preflight for "${serviceName}" OK: reserving ~${fmt(okReserved)} across file-backed streams within account limits.`
|
|
3629
|
+
);
|
|
3630
|
+
}
|
|
3631
|
+
} catch (err) {
|
|
3632
|
+
logger5.debug(`Storage preflight skipped \u2014 account info unavailable: ${String(err)}`);
|
|
3633
|
+
}
|
|
3634
|
+
};
|
|
3635
|
+
|
|
3636
|
+
// src/server/infrastructure/provisioning-error.ts
|
|
3637
|
+
var REMEDIATION = {
|
|
3638
|
+
[10047 /* StorageResourcesExceeded */]: "Aggregate stream reservation exceeds the server `max_file_store` (or account `max_storage`). Lower `max_bytes`/`num_replicas` for this service, or raise `max_file_store` on the NATS servers.",
|
|
3639
|
+
[10005 /* NoSuitablePeers */]: "Fewer healthy peers than `num_replicas`, or no peer has enough reserved storage headroom. Reduce replicas or add/repair cluster nodes."
|
|
3640
|
+
};
|
|
3641
|
+
var GENERIC_REMEDIATION = "Inspect the NATS server logs and JetStream account limits for the underlying cause.";
|
|
3642
|
+
var JetstreamProvisioningError = class _JetstreamProvisioningError extends Error {
|
|
3643
|
+
entity;
|
|
3644
|
+
target;
|
|
3645
|
+
kind;
|
|
3646
|
+
errCode;
|
|
3647
|
+
errDescription;
|
|
3648
|
+
remediation;
|
|
3649
|
+
maxBytes;
|
|
3650
|
+
numReplicas;
|
|
3651
|
+
reservation;
|
|
3652
|
+
constructor(fields) {
|
|
3653
|
+
const reservationNote = fields.reservation !== void 0 ? ` reservation=${fields.reservation}B (max_bytes=${fields.maxBytes}B \xD7 replicas=${fields.numReplicas}).` : "";
|
|
3654
|
+
super(
|
|
3655
|
+
`JetStream ${fields.entity} provisioning failed for "${fields.target}" (kind=${fields.kind}): ${fields.errDescription} [err_code=${fields.errCode}].${reservationNote} ${fields.remediation}`,
|
|
3656
|
+
{ cause: fields.cause }
|
|
3657
|
+
);
|
|
3658
|
+
this.name = "JetstreamProvisioningError";
|
|
3659
|
+
this.entity = fields.entity;
|
|
3660
|
+
this.target = fields.target;
|
|
3661
|
+
this.kind = fields.kind;
|
|
3662
|
+
this.errCode = fields.errCode;
|
|
3663
|
+
this.errDescription = fields.errDescription;
|
|
3664
|
+
this.remediation = fields.remediation;
|
|
3665
|
+
this.maxBytes = fields.maxBytes;
|
|
3666
|
+
this.numReplicas = fields.numReplicas;
|
|
3667
|
+
this.reservation = fields.reservation;
|
|
3668
|
+
Object.setPrototypeOf(this, _JetstreamProvisioningError.prototype);
|
|
3669
|
+
}
|
|
3670
|
+
};
|
|
3671
|
+
var mapProvisioningError = (err, ctx) => {
|
|
3672
|
+
const api = err.apiError();
|
|
3673
|
+
const remediation = REMEDIATION[api.err_code] ?? GENERIC_REMEDIATION;
|
|
3674
|
+
const reservation = ctx.maxBytes !== void 0 && ctx.numReplicas !== void 0 ? ctx.maxBytes * ctx.numReplicas : void 0;
|
|
3675
|
+
return new JetstreamProvisioningError({
|
|
3676
|
+
entity: ctx.entity,
|
|
3677
|
+
target: ctx.name,
|
|
3678
|
+
kind: ctx.kind,
|
|
3679
|
+
errCode: api.err_code,
|
|
3680
|
+
errDescription: api.description,
|
|
3681
|
+
remediation,
|
|
3682
|
+
maxBytes: ctx.maxBytes,
|
|
3683
|
+
numReplicas: ctx.numReplicas,
|
|
3684
|
+
reservation,
|
|
3685
|
+
cause: err
|
|
3686
|
+
});
|
|
3687
|
+
};
|
|
3688
|
+
|
|
3689
|
+
// src/server/infrastructure/provisioning-summary.ts
|
|
3690
|
+
import { StorageType as StorageType3 } from "@nats-io/jetstream";
|
|
3691
|
+
var GIB2 = 1024 ** 3;
|
|
3692
|
+
var NANOS_PER_SECOND = 1e9;
|
|
3693
|
+
var NANOS_PER_HOUR = 3600 * NANOS_PER_SECOND;
|
|
3694
|
+
var NANOS_PER_DAY = 86400 * NANOS_PER_SECOND;
|
|
3695
|
+
var formatBytes = (bytes) => {
|
|
3696
|
+
if (bytes <= 0) return "0 B";
|
|
3697
|
+
return `${(bytes / GIB2).toFixed(2)} GiB`;
|
|
3698
|
+
};
|
|
3699
|
+
var formatAge = (nanos) => {
|
|
3700
|
+
if (nanos <= 0) return "unlimited";
|
|
3701
|
+
if (nanos >= NANOS_PER_DAY) return `${(nanos / NANOS_PER_DAY).toFixed(1)}d`;
|
|
3702
|
+
if (nanos >= NANOS_PER_HOUR) return `${(nanos / NANOS_PER_HOUR).toFixed(1)}h`;
|
|
3703
|
+
return `${(nanos / NANOS_PER_SECOND).toFixed(0)}s`;
|
|
3704
|
+
};
|
|
3705
|
+
var formatProvisioningSummary = (serviceName, reservations) => {
|
|
3706
|
+
const lines = [`Provisioning ${reservations.length} stream(s) for "${serviceName}":`];
|
|
3707
|
+
let totalFileMaxBytes = 0;
|
|
3708
|
+
for (const r of reservations) {
|
|
3709
|
+
if (r.storage === StorageType3.File) totalFileMaxBytes += r.maxBytes;
|
|
3710
|
+
const clusterReservation = r.maxBytes * r.numReplicas;
|
|
3711
|
+
lines.push(
|
|
3712
|
+
` \u2022 ${r.name} [${r.kind}] storage=${r.storage} replicas=${r.numReplicas} max_bytes=${formatBytes(r.maxBytes)} max_age=${formatAge(r.maxAge)} retention=${r.retention} \u2192 cluster reservation ${formatBytes(clusterReservation)}`
|
|
3713
|
+
);
|
|
3714
|
+
}
|
|
3715
|
+
lines.push(
|
|
3716
|
+
` \u03A3 per-node file-backed footprint \u2248 ${formatBytes(totalFileMaxBytes)} (sum of max_bytes; worst case replicas = nodes). Ensure the NATS server max_file_store accommodates the sum across ALL services.`
|
|
3717
|
+
);
|
|
3718
|
+
return lines.join("\n");
|
|
3719
|
+
};
|
|
3720
|
+
|
|
3510
3721
|
// src/server/infrastructure/stream-config-diff.ts
|
|
3511
3722
|
var TRANSPORT_CONTROLLED_PROPERTIES = /* @__PURE__ */ new Set([
|
|
3512
3723
|
"retention"
|
|
@@ -3564,85 +3775,201 @@ var isEqual = (a, b) => {
|
|
|
3564
3775
|
|
|
3565
3776
|
// src/server/infrastructure/stream-migration.ts
|
|
3566
3777
|
import { Logger as Logger12 } from "@nestjs/common";
|
|
3567
|
-
import {
|
|
3778
|
+
import {
|
|
3779
|
+
JetStreamApiError
|
|
3780
|
+
} from "@nats-io/jetstream";
|
|
3568
3781
|
var MIGRATION_BACKUP_SUFFIX = "__migration_backup";
|
|
3569
3782
|
var DEFAULT_SOURCING_TIMEOUT_MS = 3e4;
|
|
3570
3783
|
var SOURCING_POLL_INTERVAL_MS = 100;
|
|
3784
|
+
var DEFAULT_PEER_WAIT_MS = 6e4;
|
|
3785
|
+
var ACTIVE_MIGRATION_GRACE_MS = 9e4;
|
|
3786
|
+
var MIGRATION_STARTED_AT_KEY = "nestjs-jetstream-migration-started-at";
|
|
3571
3787
|
var StreamMigration = class {
|
|
3572
|
-
constructor(sourcingTimeoutMs = DEFAULT_SOURCING_TIMEOUT_MS) {
|
|
3788
|
+
constructor(sourcingTimeoutMs = DEFAULT_SOURCING_TIMEOUT_MS, peerWaitMs = DEFAULT_PEER_WAIT_MS) {
|
|
3573
3789
|
this.sourcingTimeoutMs = sourcingTimeoutMs;
|
|
3790
|
+
this.peerWaitMs = peerWaitMs;
|
|
3574
3791
|
}
|
|
3575
3792
|
logger = new Logger12("Jetstream:Stream");
|
|
3576
3793
|
async migrate(jsm, streamName2, newConfig) {
|
|
3577
3794
|
const backupName = `${streamName2}${MIGRATION_BACKUP_SUFFIX}`;
|
|
3578
3795
|
const startTime = Date.now();
|
|
3796
|
+
const peerFinished = await this.waitOutPeerMigration(jsm, backupName);
|
|
3579
3797
|
const currentInfo = await jsm.streams.info(streamName2);
|
|
3580
|
-
|
|
3581
|
-
|
|
3798
|
+
if (peerFinished && !compareStreamConfig(currentInfo.config, newConfig).hasImmutableChanges) {
|
|
3799
|
+
this.logger.log(`Stream ${streamName2}: migration completed by another instance`);
|
|
3800
|
+
await jsm.streams.update(streamName2, newConfig);
|
|
3801
|
+
return;
|
|
3802
|
+
}
|
|
3582
3803
|
this.logger.log(`Stream ${streamName2}: destructive migration started`);
|
|
3583
3804
|
let originalDeleted = false;
|
|
3805
|
+
let drainedCount = 0;
|
|
3584
3806
|
try {
|
|
3585
|
-
|
|
3586
|
-
|
|
3807
|
+
this.logger.log(` Phase 1/4: Quiescing ${streamName2} (publishes rejected during migration)`);
|
|
3808
|
+
await jsm.streams.update(streamName2, { ...currentInfo.config, subjects: [] });
|
|
3809
|
+
drainedCount = (await jsm.streams.info(streamName2)).state.messages;
|
|
3810
|
+
if (drainedCount > 0) {
|
|
3811
|
+
this.logger.log(` Phase 2/4: Backing up ${drainedCount} messages \u2192 ${backupName}`);
|
|
3587
3812
|
await jsm.streams.add({
|
|
3588
3813
|
...currentInfo.config,
|
|
3589
3814
|
name: backupName,
|
|
3590
3815
|
subjects: [],
|
|
3591
|
-
sources: [{ name: streamName2 }]
|
|
3816
|
+
sources: [{ name: streamName2 }],
|
|
3817
|
+
metadata: { [MIGRATION_STARTED_AT_KEY]: (/* @__PURE__ */ new Date()).toISOString() }
|
|
3592
3818
|
});
|
|
3593
|
-
await this.
|
|
3819
|
+
await this.waitForSourceDrained(jsm, backupName, streamName2, drainedCount);
|
|
3594
3820
|
}
|
|
3595
|
-
this.logger.log(` Phase
|
|
3821
|
+
this.logger.log(` Phase 3/4: Recreating ${streamName2} with the new config`);
|
|
3596
3822
|
await jsm.streams.delete(streamName2);
|
|
3597
3823
|
originalDeleted = true;
|
|
3598
|
-
this.logger.log(` Phase 3/4: Creating stream with new config`);
|
|
3599
3824
|
await jsm.streams.add(newConfig);
|
|
3600
|
-
if (
|
|
3601
|
-
|
|
3602
|
-
await
|
|
3603
|
-
this.logger.log(` Phase 4/4: Restoring ${messageCount} messages from backup`);
|
|
3604
|
-
await jsm.streams.update(streamName2, {
|
|
3605
|
-
...newConfig,
|
|
3606
|
-
sources: [{ name: backupName }]
|
|
3607
|
-
});
|
|
3608
|
-
await this.waitForSourcing(jsm, streamName2, messageCount);
|
|
3609
|
-
await jsm.streams.update(streamName2, { ...newConfig, sources: [] });
|
|
3610
|
-
await jsm.streams.delete(backupName);
|
|
3825
|
+
if (drainedCount > 0) {
|
|
3826
|
+
this.logger.log(` Phase 4/4: Restoring ${drainedCount} messages from backup`);
|
|
3827
|
+
await this.restoreFromBackup(jsm, streamName2, newConfig, backupName);
|
|
3611
3828
|
}
|
|
3612
3829
|
} catch (err) {
|
|
3613
|
-
if (originalDeleted
|
|
3830
|
+
if (originalDeleted) {
|
|
3614
3831
|
this.logger.error(
|
|
3615
|
-
`Migration failed after
|
|
3832
|
+
`Migration of ${streamName2} failed after the original was deleted. Backup ${backupName} preserved \u2014 restoration resumes on the next startup.`
|
|
3616
3833
|
);
|
|
3617
3834
|
} else {
|
|
3618
|
-
await this.
|
|
3835
|
+
await this.rollbackBeforeDelete(jsm, streamName2, currentInfo, backupName);
|
|
3619
3836
|
}
|
|
3620
3837
|
throw err;
|
|
3621
3838
|
}
|
|
3622
3839
|
const durationMs = Date.now() - startTime;
|
|
3623
3840
|
this.logger.log(
|
|
3624
|
-
`Stream ${streamName2}: migration complete (${
|
|
3841
|
+
`Stream ${streamName2}: migration complete (${drainedCount} messages preserved, took ${(durationMs / 1e3).toFixed(1)}s)`
|
|
3842
|
+
);
|
|
3843
|
+
}
|
|
3844
|
+
/**
|
|
3845
|
+
* Detect and finish a migration that a previous process left unfinished.
|
|
3846
|
+
* Safe against concurrent instances: a backup fresh enough to belong to a
|
|
3847
|
+
* live migration is left alone.
|
|
3848
|
+
*
|
|
3849
|
+
* @returns true when recovery work was performed.
|
|
3850
|
+
*/
|
|
3851
|
+
async recoverInterrupted(jsm, streamName2, desiredConfig) {
|
|
3852
|
+
const backupName = `${streamName2}${MIGRATION_BACKUP_SUFFIX}`;
|
|
3853
|
+
const backupInfo = await this.tryInfo(jsm, backupName);
|
|
3854
|
+
if (backupInfo === null) return false;
|
|
3855
|
+
if (this.isPeerMigrationActive(backupInfo)) return false;
|
|
3856
|
+
const streamInfo = await this.tryInfo(jsm, streamName2);
|
|
3857
|
+
if (streamInfo === null) {
|
|
3858
|
+
this.logger.warn(`Stream ${streamName2}: resuming interrupted migration from ${backupName}`);
|
|
3859
|
+
await jsm.streams.add(desiredConfig);
|
|
3860
|
+
if (backupInfo.state.messages > 0) {
|
|
3861
|
+
await this.restoreFromBackup(jsm, streamName2, desiredConfig, backupName);
|
|
3862
|
+
} else {
|
|
3863
|
+
await jsm.streams.delete(backupName);
|
|
3864
|
+
}
|
|
3865
|
+
return true;
|
|
3866
|
+
}
|
|
3867
|
+
const hasBackupSource = (streamInfo.config.sources ?? []).some((s) => s.name === backupName);
|
|
3868
|
+
if (hasBackupSource) {
|
|
3869
|
+
this.logger.warn(`Stream ${streamName2}: finishing interrupted restore from ${backupName}`);
|
|
3870
|
+
await this.waitForSourceDrained(jsm, streamName2, backupName, backupInfo.state.messages);
|
|
3871
|
+
await jsm.streams.delete(backupName);
|
|
3872
|
+
await jsm.streams.update(streamName2, { ...streamInfo.config, sources: [] });
|
|
3873
|
+
return true;
|
|
3874
|
+
}
|
|
3875
|
+
if (backupInfo.state.messages === 0) {
|
|
3876
|
+
this.logger.warn(`Removing empty migration backup ${backupName}`);
|
|
3877
|
+
await jsm.streams.delete(backupName);
|
|
3878
|
+
return true;
|
|
3879
|
+
}
|
|
3880
|
+
this.logger.warn(
|
|
3881
|
+
`Stream ${streamName2}: restoring ${backupInfo.state.messages} messages from stale ${backupName}`
|
|
3625
3882
|
);
|
|
3883
|
+
await this.restoreFromBackup(
|
|
3884
|
+
jsm,
|
|
3885
|
+
streamName2,
|
|
3886
|
+
{ ...streamInfo.config, name: streamName2, subjects: streamInfo.config.subjects },
|
|
3887
|
+
backupName
|
|
3888
|
+
);
|
|
3889
|
+
return true;
|
|
3890
|
+
}
|
|
3891
|
+
/** Attach the backup as a source, drain it fully, then clean up. */
|
|
3892
|
+
async restoreFromBackup(jsm, streamName2, streamConfig, backupName) {
|
|
3893
|
+
const backupInfo = await jsm.streams.info(backupName);
|
|
3894
|
+
if ((backupInfo.config.sources ?? []).length > 0) {
|
|
3895
|
+
await jsm.streams.update(backupName, { ...backupInfo.config, sources: [] });
|
|
3896
|
+
}
|
|
3897
|
+
await jsm.streams.update(streamName2, { ...streamConfig, sources: [{ name: backupName }] });
|
|
3898
|
+
await this.waitForSourceDrained(jsm, streamName2, backupName, backupInfo.state.messages);
|
|
3899
|
+
await jsm.streams.delete(backupName);
|
|
3900
|
+
await jsm.streams.update(streamName2, { ...streamConfig, sources: [] });
|
|
3626
3901
|
}
|
|
3627
|
-
|
|
3902
|
+
/**
|
|
3903
|
+
* Wait until `sourceName` is fully drained into `streamName`. Lag-based, so
|
|
3904
|
+
* concurrent live publishes to the target cannot fake completion the way a
|
|
3905
|
+
* bare message-count comparison could. A freshly attached source reports
|
|
3906
|
+
* `lag: 0, active: -1` before its first sync — `active >= 0` filters that
|
|
3907
|
+
* false positive out (verified against NATS 2.12.6).
|
|
3908
|
+
*/
|
|
3909
|
+
async waitForSourceDrained(jsm, streamName2, sourceName, minimumMessages) {
|
|
3628
3910
|
const deadline = Date.now() + this.sourcingTimeoutMs;
|
|
3629
3911
|
while (Date.now() < deadline) {
|
|
3630
3912
|
const info = await jsm.streams.info(streamName2);
|
|
3631
|
-
|
|
3913
|
+
const source = (info.sources ?? []).find((s) => s.name === sourceName);
|
|
3914
|
+
if (source !== void 0 && source.active >= 0 && source.lag === 0 && info.state.messages >= minimumMessages) {
|
|
3915
|
+
return;
|
|
3916
|
+
}
|
|
3632
3917
|
await new Promise((r) => setTimeout(r, SOURCING_POLL_INTERVAL_MS));
|
|
3633
3918
|
}
|
|
3634
3919
|
throw new Error(
|
|
3635
|
-
`Stream sourcing timeout: ${
|
|
3920
|
+
`Stream sourcing timeout: ${sourceName} has not drained into ${streamName2} within ${this.sourcingTimeoutMs / 1e3}s. The backup is preserved; restoration resumes on the next startup.`
|
|
3636
3921
|
);
|
|
3637
3922
|
}
|
|
3638
|
-
|
|
3923
|
+
/**
|
|
3924
|
+
* A backup already present when migrate() begins belongs to another
|
|
3925
|
+
* instance migrating right now (rolling deploy) — wait for it to finish.
|
|
3926
|
+
* Stale leftovers are handled by recoverInterrupted() before migrate() runs,
|
|
3927
|
+
* so a timeout here means something is genuinely stuck.
|
|
3928
|
+
*
|
|
3929
|
+
* @returns true when a peer's backup was observed and cleared.
|
|
3930
|
+
*/
|
|
3931
|
+
async waitOutPeerMigration(jsm, backupName) {
|
|
3932
|
+
if (await this.tryInfo(jsm, backupName) === null) return false;
|
|
3933
|
+
this.logger.warn(
|
|
3934
|
+
`Migration backup ${backupName} exists \u2014 another instance appears to be migrating; waiting`
|
|
3935
|
+
);
|
|
3936
|
+
const deadline = Date.now() + this.peerWaitMs;
|
|
3937
|
+
while (Date.now() < deadline) {
|
|
3938
|
+
if (await this.tryInfo(jsm, backupName) === null) return true;
|
|
3939
|
+
await new Promise((r) => setTimeout(r, SOURCING_POLL_INTERVAL_MS * 5));
|
|
3940
|
+
}
|
|
3941
|
+
throw new Error(
|
|
3942
|
+
`Migration backup ${backupName} did not clear within ${this.peerWaitMs / 1e3}s. If no other instance is migrating, recover or remove the backup manually.`
|
|
3943
|
+
);
|
|
3944
|
+
}
|
|
3945
|
+
/** Failure before the original was deleted: undo the quiesce, drop our backup. */
|
|
3946
|
+
async rollbackBeforeDelete(jsm, streamName2, originalInfo, backupName) {
|
|
3639
3947
|
try {
|
|
3640
|
-
await jsm.streams.
|
|
3641
|
-
|
|
3642
|
-
|
|
3948
|
+
await jsm.streams.update(streamName2, { ...originalInfo.config });
|
|
3949
|
+
const backupInfo = await this.tryInfo(jsm, backupName);
|
|
3950
|
+
if (backupInfo !== null) {
|
|
3951
|
+
await jsm.streams.delete(backupName);
|
|
3952
|
+
}
|
|
3953
|
+
} catch (rollbackErr) {
|
|
3954
|
+
this.logger.error(
|
|
3955
|
+
`Rollback of ${streamName2} after a failed migration also failed \u2014 the stream may be left quiesced:`,
|
|
3956
|
+
rollbackErr
|
|
3957
|
+
);
|
|
3958
|
+
}
|
|
3959
|
+
}
|
|
3960
|
+
isPeerMigrationActive(backupInfo) {
|
|
3961
|
+
const startedAt = backupInfo.config.metadata?.[MIGRATION_STARTED_AT_KEY];
|
|
3962
|
+
if (!startedAt) return false;
|
|
3963
|
+
const startedMs = Date.parse(startedAt);
|
|
3964
|
+
if (Number.isNaN(startedMs)) return false;
|
|
3965
|
+
return Date.now() - startedMs < ACTIVE_MIGRATION_GRACE_MS;
|
|
3966
|
+
}
|
|
3967
|
+
async tryInfo(jsm, name) {
|
|
3968
|
+
try {
|
|
3969
|
+
return await jsm.streams.info(name);
|
|
3643
3970
|
} catch (err) {
|
|
3644
3971
|
if (err instanceof JetStreamApiError && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
3645
|
-
return;
|
|
3972
|
+
return null;
|
|
3646
3973
|
}
|
|
3647
3974
|
throw err;
|
|
3648
3975
|
}
|
|
@@ -3673,6 +4000,15 @@ var StreamProvider = class {
|
|
|
3673
4000
|
*/
|
|
3674
4001
|
async ensureStreams(kinds) {
|
|
3675
4002
|
const jsm = await this.connection.getJetStreamManager();
|
|
4003
|
+
const reservations = kinds.map((kind) => this.buildReservation(kind, this.buildConfig(kind)));
|
|
4004
|
+
if (this.options.dlq) {
|
|
4005
|
+
reservations.push(this.buildReservation("dlq", this.buildDlqConfig()));
|
|
4006
|
+
}
|
|
4007
|
+
this.logger.log(`
|
|
4008
|
+
${formatProvisioningSummary(this.options.name, reservations)}`);
|
|
4009
|
+
if (this.options.provisioning?.preflightStorageCheck) {
|
|
4010
|
+
await assertStorageBudget(jsm, this.options.name, reservations, this.logger);
|
|
4011
|
+
}
|
|
3676
4012
|
await Promise.all(kinds.map((kind) => this.ensureStream(jsm, kind)));
|
|
3677
4013
|
if (this.options.dlq) {
|
|
3678
4014
|
await this.ensureDlqStream(jsm);
|
|
@@ -3709,6 +4045,7 @@ var StreamProvider = class {
|
|
|
3709
4045
|
/** Ensure a single stream exists, creating or updating as needed. */
|
|
3710
4046
|
async ensureStream(jsm, kind) {
|
|
3711
4047
|
const config = this.buildConfig(kind);
|
|
4048
|
+
const ctx = this.errorContext(kind, config);
|
|
3712
4049
|
return withProvisioningSpan(
|
|
3713
4050
|
this.otel,
|
|
3714
4051
|
{
|
|
@@ -3716,17 +4053,21 @@ var StreamProvider = class {
|
|
|
3716
4053
|
endpoint: this.otelEndpoint,
|
|
3717
4054
|
entity: "stream",
|
|
3718
4055
|
name: config.name,
|
|
3719
|
-
action: "ensure"
|
|
4056
|
+
action: "ensure",
|
|
4057
|
+
maxBytes: ctx.maxBytes,
|
|
4058
|
+
numReplicas: ctx.numReplicas,
|
|
4059
|
+
reservation: ctx.maxBytes !== void 0 && ctx.numReplicas !== void 0 ? ctx.maxBytes * ctx.numReplicas : void 0
|
|
3720
4060
|
},
|
|
3721
4061
|
async () => {
|
|
3722
4062
|
this.logger.log(`Ensuring stream: ${config.name}`);
|
|
4063
|
+
await this.migration.recoverInterrupted(jsm, config.name, config);
|
|
3723
4064
|
try {
|
|
3724
4065
|
const currentInfo = await jsm.streams.info(config.name);
|
|
3725
|
-
return await this.handleExistingStream(jsm, currentInfo, config);
|
|
4066
|
+
return await this.handleExistingStream(jsm, currentInfo, config, ctx);
|
|
3726
4067
|
} catch (err) {
|
|
3727
4068
|
if (err instanceof JetStreamApiError2 && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
3728
4069
|
this.logger.log(`Creating stream: ${config.name}`);
|
|
3729
|
-
return await jsm.streams.add(config);
|
|
4070
|
+
return await this.runStreamOp(ctx, () => jsm.streams.add(config));
|
|
3730
4071
|
}
|
|
3731
4072
|
throw err;
|
|
3732
4073
|
}
|
|
@@ -3736,6 +4077,7 @@ var StreamProvider = class {
|
|
|
3736
4077
|
/** Ensure a dead-letter queue stream exists, creating or updating as needed. */
|
|
3737
4078
|
async ensureDlqStream(jsm) {
|
|
3738
4079
|
const config = this.buildDlqConfig();
|
|
4080
|
+
const ctx = this.errorContext("dlq", config);
|
|
3739
4081
|
return withProvisioningSpan(
|
|
3740
4082
|
this.otel,
|
|
3741
4083
|
{
|
|
@@ -3743,24 +4085,30 @@ var StreamProvider = class {
|
|
|
3743
4085
|
endpoint: this.otelEndpoint,
|
|
3744
4086
|
entity: "stream",
|
|
3745
4087
|
name: config.name,
|
|
3746
|
-
action: "ensure"
|
|
4088
|
+
action: "ensure",
|
|
4089
|
+
maxBytes: ctx.maxBytes,
|
|
4090
|
+
numReplicas: ctx.numReplicas,
|
|
4091
|
+
reservation: ctx.maxBytes !== void 0 && ctx.numReplicas !== void 0 ? ctx.maxBytes * ctx.numReplicas : void 0
|
|
3747
4092
|
},
|
|
3748
4093
|
async () => {
|
|
3749
4094
|
this.logger.log(`Ensuring DLQ stream: ${config.name}`);
|
|
3750
4095
|
try {
|
|
3751
4096
|
const currentInfo = await jsm.streams.info(config.name);
|
|
3752
|
-
return await this.handleExistingStream(jsm, currentInfo, config);
|
|
4097
|
+
return await this.handleExistingStream(jsm, currentInfo, config, ctx);
|
|
3753
4098
|
} catch (err) {
|
|
3754
4099
|
if (err instanceof JetStreamApiError2 && err.apiError().err_code === 10059 /* StreamNotFound */) {
|
|
3755
4100
|
this.logger.log(`Creating DLQ stream: ${config.name}`);
|
|
3756
|
-
return await jsm.streams.add(config);
|
|
4101
|
+
return await this.runStreamOp(ctx, () => jsm.streams.add(config));
|
|
3757
4102
|
}
|
|
3758
4103
|
throw err;
|
|
3759
4104
|
}
|
|
3760
4105
|
}
|
|
3761
4106
|
);
|
|
3762
4107
|
}
|
|
3763
|
-
async handleExistingStream(jsm, currentInfo, config) {
|
|
4108
|
+
async handleExistingStream(jsm, currentInfo, config, ctx) {
|
|
4109
|
+
if (this.isSharedStream(config.name)) {
|
|
4110
|
+
config.subjects = [.../* @__PURE__ */ new Set([...config.subjects, ...currentInfo.config.subjects])];
|
|
4111
|
+
}
|
|
3764
4112
|
const diff = compareStreamConfig(currentInfo.config, config);
|
|
3765
4113
|
if (!diff.hasChanges) {
|
|
3766
4114
|
this.logger.debug(`Stream ${config.name}: no config changes`);
|
|
@@ -3775,7 +4123,7 @@ var StreamProvider = class {
|
|
|
3775
4123
|
}
|
|
3776
4124
|
if (!diff.hasImmutableChanges) {
|
|
3777
4125
|
this.logger.debug(`Stream exists, updating: ${config.name}`);
|
|
3778
|
-
return await jsm.streams.update(config.name, config);
|
|
4126
|
+
return await this.runStreamOp(ctx, () => jsm.streams.update(config.name, config));
|
|
3779
4127
|
}
|
|
3780
4128
|
if (!this.options.allowDestructiveMigration) {
|
|
3781
4129
|
this.logger.warn(
|
|
@@ -3783,10 +4131,15 @@ var StreamProvider = class {
|
|
|
3783
4131
|
);
|
|
3784
4132
|
if (diff.hasMutableChanges) {
|
|
3785
4133
|
const mutableConfig = this.buildMutableOnlyConfig(config, currentInfo.config, diff);
|
|
3786
|
-
return await jsm.streams.update(config.name, mutableConfig);
|
|
4134
|
+
return await this.runStreamOp(ctx, () => jsm.streams.update(config.name, mutableConfig));
|
|
3787
4135
|
}
|
|
3788
4136
|
return currentInfo;
|
|
3789
4137
|
}
|
|
4138
|
+
if (this.isSharedStream(config.name)) {
|
|
4139
|
+
throw new Error(
|
|
4140
|
+
`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.`
|
|
4141
|
+
);
|
|
4142
|
+
}
|
|
3790
4143
|
await withMigrationSpan(
|
|
3791
4144
|
this.otel,
|
|
3792
4145
|
{
|
|
@@ -3825,11 +4178,47 @@ var StreamProvider = class {
|
|
|
3825
4178
|
}
|
|
3826
4179
|
}
|
|
3827
4180
|
}
|
|
4181
|
+
buildReservation(kind, config) {
|
|
4182
|
+
const mb = config.max_bytes;
|
|
4183
|
+
return {
|
|
4184
|
+
kind,
|
|
4185
|
+
name: config.name,
|
|
4186
|
+
storage: config.storage ?? StorageType4.File,
|
|
4187
|
+
numReplicas: config.num_replicas ?? 1,
|
|
4188
|
+
maxBytes: mb !== void 0 && mb >= 0 ? mb : 0,
|
|
4189
|
+
// NATS uses -1 for unlimited
|
|
4190
|
+
maxAge: config.max_age ?? 0,
|
|
4191
|
+
retention: config.retention ?? RetentionPolicy2.Limits
|
|
4192
|
+
};
|
|
4193
|
+
}
|
|
4194
|
+
errorContext(kind, config) {
|
|
4195
|
+
return {
|
|
4196
|
+
entity: "stream",
|
|
4197
|
+
name: config.name,
|
|
4198
|
+
kind,
|
|
4199
|
+
maxBytes: config.max_bytes,
|
|
4200
|
+
numReplicas: config.num_replicas ?? 1
|
|
4201
|
+
};
|
|
4202
|
+
}
|
|
4203
|
+
async runStreamOp(ctx, op) {
|
|
4204
|
+
try {
|
|
4205
|
+
return await op();
|
|
4206
|
+
} catch (err) {
|
|
4207
|
+
if (err instanceof JetStreamApiError2) {
|
|
4208
|
+
throw mapProvisioningError(err, ctx);
|
|
4209
|
+
}
|
|
4210
|
+
throw err;
|
|
4211
|
+
}
|
|
4212
|
+
}
|
|
4213
|
+
/** The broadcast stream is global — every service in the cluster shares it. */
|
|
4214
|
+
isSharedStream(name) {
|
|
4215
|
+
return name === this.getStreamName("broadcast" /* Broadcast */);
|
|
4216
|
+
}
|
|
3828
4217
|
/** Build the full stream config by merging defaults with user overrides. */
|
|
3829
4218
|
buildConfig(kind) {
|
|
3830
4219
|
const name = this.getStreamName(kind);
|
|
3831
4220
|
const subjects = this.getSubjects(kind);
|
|
3832
|
-
const description = `JetStream ${kind} stream for ${this.options.name}`;
|
|
4221
|
+
const description = kind === "broadcast" /* Broadcast */ ? "JetStream broadcast stream (shared across services)" : `JetStream ${kind} stream for ${this.options.name}`;
|
|
3833
4222
|
const defaults = this.getDefaults(kind);
|
|
3834
4223
|
const overrides = this.getOverrides(kind);
|
|
3835
4224
|
return {
|
|
@@ -3971,15 +4360,16 @@ var ConsumerProvider = class {
|
|
|
3971
4360
|
},
|
|
3972
4361
|
async () => {
|
|
3973
4362
|
this.logger.log(`Ensuring consumer: ${name} on stream: ${stream}`);
|
|
4363
|
+
const ctx = { entity: "consumer", name, kind };
|
|
3974
4364
|
try {
|
|
3975
4365
|
await jsm.consumers.info(stream, name);
|
|
3976
4366
|
this.logger.debug(`Consumer exists, updating: ${name}`);
|
|
3977
|
-
return await jsm.consumers.update(stream, name, config);
|
|
4367
|
+
return await this.runConsumerOp(ctx, () => jsm.consumers.update(stream, name, config));
|
|
3978
4368
|
} catch (err) {
|
|
3979
4369
|
if (!(err instanceof JetStreamApiError3) || err.apiError().err_code !== 10014 /* ConsumerNotFound */) {
|
|
3980
4370
|
throw err;
|
|
3981
4371
|
}
|
|
3982
|
-
return await this.createConsumer(jsm, stream, name, config);
|
|
4372
|
+
return await this.createConsumer(jsm, stream, name, kind, config);
|
|
3983
4373
|
}
|
|
3984
4374
|
}
|
|
3985
4375
|
);
|
|
@@ -4019,7 +4409,7 @@ var ConsumerProvider = class {
|
|
|
4019
4409
|
if (!(err instanceof JetStreamApiError3) || err.apiError().err_code !== 10014 /* ConsumerNotFound */) {
|
|
4020
4410
|
throw err;
|
|
4021
4411
|
}
|
|
4022
|
-
return await this.createConsumer(jsm, stream, name, config);
|
|
4412
|
+
return await this.createConsumer(jsm, stream, name, kind, config);
|
|
4023
4413
|
}
|
|
4024
4414
|
}
|
|
4025
4415
|
);
|
|
@@ -4046,8 +4436,9 @@ var ConsumerProvider = class {
|
|
|
4046
4436
|
/**
|
|
4047
4437
|
* Create a consumer, handling the race where another pod creates it first.
|
|
4048
4438
|
*/
|
|
4049
|
-
async createConsumer(jsm, stream, name, config) {
|
|
4439
|
+
async createConsumer(jsm, stream, name, kind, config) {
|
|
4050
4440
|
this.logger.log(`Creating consumer: ${name}`);
|
|
4441
|
+
const ctx = { entity: "consumer", name, kind };
|
|
4051
4442
|
try {
|
|
4052
4443
|
return await jsm.consumers.add(stream, config);
|
|
4053
4444
|
} catch (addErr) {
|
|
@@ -4055,9 +4446,22 @@ var ConsumerProvider = class {
|
|
|
4055
4446
|
this.logger.debug(`Consumer ${name} created by another pod, using existing`);
|
|
4056
4447
|
return await jsm.consumers.info(stream, name);
|
|
4057
4448
|
}
|
|
4449
|
+
if (addErr instanceof JetStreamApiError3) {
|
|
4450
|
+
throw mapProvisioningError(addErr, ctx);
|
|
4451
|
+
}
|
|
4058
4452
|
throw addErr;
|
|
4059
4453
|
}
|
|
4060
4454
|
}
|
|
4455
|
+
async runConsumerOp(ctx, op) {
|
|
4456
|
+
try {
|
|
4457
|
+
return await op();
|
|
4458
|
+
} catch (err) {
|
|
4459
|
+
if (err instanceof JetStreamApiError3) {
|
|
4460
|
+
throw mapProvisioningError(err, ctx);
|
|
4461
|
+
}
|
|
4462
|
+
throw err;
|
|
4463
|
+
}
|
|
4464
|
+
}
|
|
4061
4465
|
/** Build consumer config by merging defaults with user overrides. */
|
|
4062
4466
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- NATS API uses snake_case
|
|
4063
4467
|
buildConfig(kind) {
|
|
@@ -4518,6 +4922,7 @@ var MetadataProvider = class {
|
|
|
4518
4922
|
// src/server/routing/event.router.ts
|
|
4519
4923
|
import { Logger as Logger17 } from "@nestjs/common";
|
|
4520
4924
|
import { headers as natsHeaders3 } from "@nats-io/transport-node";
|
|
4925
|
+
var DLQ_PUBLISH_ATTEMPTS = 3;
|
|
4521
4926
|
var eventConsumeKindFor = (kind) => {
|
|
4522
4927
|
if (kind === "broadcast" /* Broadcast */) return "broadcast" /* Broadcast */;
|
|
4523
4928
|
if (kind === "ordered" /* Ordered */) return "ordered" /* Ordered */;
|
|
@@ -4604,33 +5009,80 @@ var EventRouter = class {
|
|
|
4604
5009
|
return msg.info.deliveryCount >= maxDeliver;
|
|
4605
5010
|
};
|
|
4606
5011
|
const handleDeadLetter = hasDlqCheck ? (msg, data, err) => this.handleDeadLetter(msg, data, err) : null;
|
|
4607
|
-
const settleSuccess = (msg, ctx) => {
|
|
4608
|
-
if (ctx.shouldTerminate)
|
|
4609
|
-
|
|
4610
|
-
|
|
5012
|
+
const settleSuccess = (msg, ctx, data) => {
|
|
5013
|
+
if (ctx.shouldTerminate) {
|
|
5014
|
+
settleQuietly(logger5, `Failed to term ${msg.subject}:`, () => {
|
|
5015
|
+
msg.term(ctx.terminateReason);
|
|
5016
|
+
});
|
|
5017
|
+
return void 0;
|
|
5018
|
+
}
|
|
5019
|
+
if (ctx.shouldRetry) {
|
|
5020
|
+
if (handleDeadLetter !== null && isDeadLetter(msg)) {
|
|
5021
|
+
return handleDeadLetter(
|
|
5022
|
+
msg,
|
|
5023
|
+
data,
|
|
5024
|
+
new Error("Retry requested on the final delivery attempt")
|
|
5025
|
+
);
|
|
5026
|
+
}
|
|
5027
|
+
settleQuietly(logger5, `Failed to nak ${msg.subject}:`, () => {
|
|
5028
|
+
msg.nak(ctx.retryDelay);
|
|
5029
|
+
});
|
|
5030
|
+
return void 0;
|
|
5031
|
+
}
|
|
5032
|
+
settleQuietly(logger5, `Failed to ack ${msg.subject}:`, () => {
|
|
5033
|
+
msg.ack();
|
|
5034
|
+
});
|
|
5035
|
+
return void 0;
|
|
4611
5036
|
};
|
|
4612
5037
|
const settleFailure = async (msg, data, err) => {
|
|
4613
5038
|
if (handleDeadLetter !== null && isDeadLetter(msg)) {
|
|
4614
5039
|
await handleDeadLetter(msg, data, err);
|
|
4615
5040
|
return;
|
|
4616
5041
|
}
|
|
4617
|
-
msg.
|
|
5042
|
+
settleQuietly(logger5, `Failed to nak ${msg.subject}:`, () => {
|
|
5043
|
+
msg.nak();
|
|
5044
|
+
});
|
|
5045
|
+
};
|
|
5046
|
+
const captureUnroutable = (capture, msg, err) => {
|
|
5047
|
+
let data;
|
|
5048
|
+
try {
|
|
5049
|
+
data = codec.decode(msg.data);
|
|
5050
|
+
} catch {
|
|
5051
|
+
data = void 0;
|
|
5052
|
+
}
|
|
5053
|
+
return capture(msg, data, err).catch((captureErr) => {
|
|
5054
|
+
logger5.error(`Dead-letter capture failed for unroutable ${msg.subject}:`, captureErr);
|
|
5055
|
+
});
|
|
4618
5056
|
};
|
|
4619
5057
|
const resolveEvent = (msg) => {
|
|
4620
5058
|
const subject = msg.subject;
|
|
4621
5059
|
try {
|
|
4622
5060
|
const handler = patternRegistry.getHandler(subject);
|
|
4623
5061
|
if (!handler) {
|
|
4624
|
-
msg.term(`No handler for event: ${subject}`);
|
|
4625
5062
|
logger5.error(`No handler for subject: ${subject}`);
|
|
5063
|
+
if (handleDeadLetter !== null) {
|
|
5064
|
+
return captureUnroutable(
|
|
5065
|
+
handleDeadLetter,
|
|
5066
|
+
msg,
|
|
5067
|
+
new Error(`No handler for event: ${subject}`)
|
|
5068
|
+
);
|
|
5069
|
+
}
|
|
5070
|
+
msg.term(`No handler for event: ${subject}`);
|
|
4626
5071
|
return null;
|
|
4627
5072
|
}
|
|
4628
5073
|
let data;
|
|
4629
5074
|
try {
|
|
4630
5075
|
data = codec.decode(msg.data);
|
|
4631
5076
|
} catch (err) {
|
|
4632
|
-
msg.term("Decode error");
|
|
4633
5077
|
logger5.error(`Decode error for ${subject}:`, err);
|
|
5078
|
+
if (handleDeadLetter !== null) {
|
|
5079
|
+
return captureUnroutable(
|
|
5080
|
+
handleDeadLetter,
|
|
5081
|
+
msg,
|
|
5082
|
+
new Error(`Decode error: ${err instanceof Error ? err.message : String(err)}`)
|
|
5083
|
+
);
|
|
5084
|
+
}
|
|
5085
|
+
msg.term("Decode error");
|
|
4634
5086
|
return null;
|
|
4635
5087
|
}
|
|
4636
5088
|
eventBus.emitMessageRouted(subject, "event" /* Event */);
|
|
@@ -4653,6 +5105,7 @@ var EventRouter = class {
|
|
|
4653
5105
|
const handleSafe = (msg) => {
|
|
4654
5106
|
const resolved = resolveEvent(msg);
|
|
4655
5107
|
if (resolved === null) return void 0;
|
|
5108
|
+
if (isPromiseLike2(resolved)) return resolved;
|
|
4656
5109
|
const { handler, data } = resolved;
|
|
4657
5110
|
const ctx = new RpcContext([msg]);
|
|
4658
5111
|
const stopAckExtension = hasAckExtension ? startAckExtensionTimer(msg, ackExtensionInterval) : null;
|
|
@@ -4685,16 +5138,24 @@ var EventRouter = class {
|
|
|
4685
5138
|
});
|
|
4686
5139
|
}
|
|
4687
5140
|
if (!isPromiseLike2(pending)) {
|
|
4688
|
-
settleSuccess(msg, ctx);
|
|
5141
|
+
const settled = settleSuccess(msg, ctx, data);
|
|
4689
5142
|
reportHandlerCompleted(msg, startedAt, statusForContext(ctx));
|
|
4690
|
-
if (
|
|
4691
|
-
|
|
5143
|
+
if (settled === void 0) {
|
|
5144
|
+
if (stopAckExtension !== null) stopAckExtension();
|
|
5145
|
+
return void 0;
|
|
5146
|
+
}
|
|
5147
|
+
return settled.finally(() => {
|
|
5148
|
+
if (stopAckExtension !== null) stopAckExtension();
|
|
5149
|
+
});
|
|
4692
5150
|
}
|
|
4693
5151
|
return pending.then(
|
|
4694
|
-
() => {
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
5152
|
+
async () => {
|
|
5153
|
+
try {
|
|
5154
|
+
await settleSuccess(msg, ctx, data);
|
|
5155
|
+
reportHandlerCompleted(msg, startedAt, statusForContext(ctx));
|
|
5156
|
+
} finally {
|
|
5157
|
+
if (stopAckExtension !== null) stopAckExtension();
|
|
5158
|
+
}
|
|
4698
5159
|
},
|
|
4699
5160
|
async (err) => {
|
|
4700
5161
|
eventBus.emit(
|
|
@@ -4788,14 +5249,28 @@ var EventRouter = class {
|
|
|
4788
5249
|
active--;
|
|
4789
5250
|
drainBacklog();
|
|
4790
5251
|
};
|
|
5252
|
+
const routeSafely = (msg) => {
|
|
5253
|
+
try {
|
|
5254
|
+
return route(msg);
|
|
5255
|
+
} catch (err) {
|
|
5256
|
+
logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
|
|
5257
|
+
return void 0;
|
|
5258
|
+
}
|
|
5259
|
+
};
|
|
5260
|
+
const trackAsync = (result, msg) => {
|
|
5261
|
+
void result.catch((err) => {
|
|
5262
|
+
logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
|
|
5263
|
+
}).finally(onAsyncDone);
|
|
5264
|
+
};
|
|
4791
5265
|
const drainBacklog = () => {
|
|
4792
5266
|
while (active < maxActive) {
|
|
4793
5267
|
const next = backlog.shift();
|
|
4794
5268
|
if (next === void 0) return;
|
|
5269
|
+
next.stopAckExtension?.();
|
|
4795
5270
|
active++;
|
|
4796
|
-
const result =
|
|
5271
|
+
const result = routeSafely(next.msg);
|
|
4797
5272
|
if (result !== void 0) {
|
|
4798
|
-
|
|
5273
|
+
trackAsync(result, next.msg);
|
|
4799
5274
|
} else {
|
|
4800
5275
|
active--;
|
|
4801
5276
|
}
|
|
@@ -4805,7 +5280,10 @@ var EventRouter = class {
|
|
|
4805
5280
|
const subscription = stream$.subscribe({
|
|
4806
5281
|
next: (msg) => {
|
|
4807
5282
|
if (active >= maxActive) {
|
|
4808
|
-
backlog.push(
|
|
5283
|
+
backlog.push({
|
|
5284
|
+
msg,
|
|
5285
|
+
stopAckExtension: hasAckExtension ? startAckExtensionTimer(msg, ackExtensionInterval) : null
|
|
5286
|
+
});
|
|
4809
5287
|
if (!backlogWarned && backlog.length >= backlogWarnThreshold) {
|
|
4810
5288
|
backlogWarned = true;
|
|
4811
5289
|
logger5.warn(
|
|
@@ -4815,9 +5293,9 @@ var EventRouter = class {
|
|
|
4815
5293
|
return;
|
|
4816
5294
|
}
|
|
4817
5295
|
active++;
|
|
4818
|
-
const result =
|
|
5296
|
+
const result = routeSafely(msg);
|
|
4819
5297
|
if (result !== void 0) {
|
|
4820
|
-
|
|
5298
|
+
trackAsync(result, msg);
|
|
4821
5299
|
} else {
|
|
4822
5300
|
active--;
|
|
4823
5301
|
if (backlog.length > 0) drainBacklog();
|
|
@@ -4827,6 +5305,12 @@ var EventRouter = class {
|
|
|
4827
5305
|
logger5.error(`Stream error in ${kind} router`, err);
|
|
4828
5306
|
}
|
|
4829
5307
|
});
|
|
5308
|
+
subscription.add(() => {
|
|
5309
|
+
for (const queued of backlog) {
|
|
5310
|
+
queued.stopAckExtension?.();
|
|
5311
|
+
}
|
|
5312
|
+
backlog.length = 0;
|
|
5313
|
+
});
|
|
4830
5314
|
this.subscriptions.push(subscription);
|
|
4831
5315
|
}
|
|
4832
5316
|
getConcurrency(kind) {
|
|
@@ -4841,26 +5325,79 @@ var EventRouter = class {
|
|
|
4841
5325
|
}
|
|
4842
5326
|
/**
|
|
4843
5327
|
* Last-resort path for a dead letter: invoke `onDeadLetter`, then `term` on
|
|
4844
|
-
* success
|
|
4845
|
-
*
|
|
4846
|
-
*
|
|
5328
|
+
* success. On failure the message is nak'd to release it, but the server
|
|
5329
|
+
* never redelivers past `max_deliver` — it stays in the stream for manual
|
|
5330
|
+
* recovery. Used when the DLQ stream isn't configured, or when publishing
|
|
5331
|
+
* to it failed and we still have to surface the message somewhere.
|
|
4847
5332
|
*/
|
|
4848
5333
|
async fallbackToOnDeadLetterCallback(info, msg) {
|
|
4849
|
-
|
|
4850
|
-
|
|
5334
|
+
const onDeadLetter = this.deadLetterConfig?.onDeadLetter;
|
|
5335
|
+
if (!onDeadLetter) {
|
|
5336
|
+
this.logger.error(
|
|
5337
|
+
`Dead letter for ${msg.subject} could not be captured (DLQ publish failed, no onDeadLetter callback) \u2014 leaving the message in the stream`
|
|
5338
|
+
);
|
|
5339
|
+
settleQuietly(this.logger, `Failed to nak ${msg.subject}:`, () => {
|
|
5340
|
+
msg.nak();
|
|
5341
|
+
});
|
|
4851
5342
|
return;
|
|
4852
5343
|
}
|
|
4853
5344
|
try {
|
|
4854
|
-
await
|
|
4855
|
-
|
|
5345
|
+
await onDeadLetter(info);
|
|
5346
|
+
settleQuietly(this.logger, `Failed to term ${msg.subject}:`, () => {
|
|
5347
|
+
msg.term("Dead letter processed via fallback callback");
|
|
5348
|
+
});
|
|
4856
5349
|
} catch (hookErr) {
|
|
4857
5350
|
this.logger.error(
|
|
4858
|
-
`Fallback onDeadLetter callback failed for ${msg.subject}
|
|
5351
|
+
`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:`,
|
|
4859
5352
|
hookErr
|
|
4860
5353
|
);
|
|
4861
|
-
msg.
|
|
5354
|
+
settleQuietly(this.logger, `Failed to nak ${msg.subject}:`, () => {
|
|
5355
|
+
msg.nak();
|
|
5356
|
+
});
|
|
4862
5357
|
}
|
|
4863
5358
|
}
|
|
5359
|
+
/**
|
|
5360
|
+
* Copy the original message headers for the DLQ republish, dropping NATS
|
|
5361
|
+
* server control headers: a copied Nats-TTL expires the DLQ entry (or gets
|
|
5362
|
+
* the publish rejected when the DLQ stream has no allow_msg_ttl), a copied
|
|
5363
|
+
* Nats-Msg-Id collides with the DLQ dedup window.
|
|
5364
|
+
*/
|
|
5365
|
+
buildDlqHeaders(msg) {
|
|
5366
|
+
const hdrs = natsHeaders3();
|
|
5367
|
+
if (!msg.headers) return hdrs;
|
|
5368
|
+
for (const [k, v] of msg.headers) {
|
|
5369
|
+
if (k.toLowerCase().startsWith(NATS_CONTROL_HEADER_PREFIX)) continue;
|
|
5370
|
+
for (const val of v) {
|
|
5371
|
+
hdrs.append(k, val);
|
|
5372
|
+
}
|
|
5373
|
+
}
|
|
5374
|
+
return hdrs;
|
|
5375
|
+
}
|
|
5376
|
+
/**
|
|
5377
|
+
* Attempt the DLQ publish up to {@link DLQ_PUBLISH_ATTEMPTS} times.
|
|
5378
|
+
*
|
|
5379
|
+
* Past `max_deliver` the server never redelivers, so an in-process retry is
|
|
5380
|
+
* the only second chance a dead letter gets. There is no artificial delay
|
|
5381
|
+
* between attempts: when the broker is unreachable each publish already
|
|
5382
|
+
* spends its own request timeout, which spaces the attempts naturally.
|
|
5383
|
+
*/
|
|
5384
|
+
async publishToDlqWithRetry(connection, subject, data, headers2) {
|
|
5385
|
+
let lastErr;
|
|
5386
|
+
for (let attempt = 1; attempt <= DLQ_PUBLISH_ATTEMPTS; attempt += 1) {
|
|
5387
|
+
try {
|
|
5388
|
+
await connection.getJetStreamClient().publish(subject, data, { headers: headers2 });
|
|
5389
|
+
return;
|
|
5390
|
+
} catch (err) {
|
|
5391
|
+
lastErr = err;
|
|
5392
|
+
if (attempt < DLQ_PUBLISH_ATTEMPTS) {
|
|
5393
|
+
this.logger.warn(
|
|
5394
|
+
`DLQ publish attempt ${attempt}/${DLQ_PUBLISH_ATTEMPTS} failed for ${subject}, retrying`
|
|
5395
|
+
);
|
|
5396
|
+
}
|
|
5397
|
+
}
|
|
5398
|
+
}
|
|
5399
|
+
throw lastErr;
|
|
5400
|
+
}
|
|
4864
5401
|
/**
|
|
4865
5402
|
* Publish a dead letter to the configured Dead-Letter Queue (DLQ) stream.
|
|
4866
5403
|
*
|
|
@@ -4879,14 +5416,7 @@ var EventRouter = class {
|
|
|
4879
5416
|
return;
|
|
4880
5417
|
}
|
|
4881
5418
|
const destinationSubject = dlqStreamName(serviceName);
|
|
4882
|
-
const hdrs =
|
|
4883
|
-
if (msg.headers) {
|
|
4884
|
-
for (const [k, v] of msg.headers) {
|
|
4885
|
-
for (const val of v) {
|
|
4886
|
-
hdrs.append(k, val);
|
|
4887
|
-
}
|
|
4888
|
-
}
|
|
4889
|
-
}
|
|
5419
|
+
const hdrs = this.buildDlqHeaders(msg);
|
|
4890
5420
|
let reason = String(error);
|
|
4891
5421
|
if (error instanceof Error) {
|
|
4892
5422
|
reason = error.message;
|
|
@@ -4899,8 +5429,7 @@ var EventRouter = class {
|
|
|
4899
5429
|
hdrs.set("x-failed-at" /* FailedAt */, (/* @__PURE__ */ new Date()).toISOString());
|
|
4900
5430
|
hdrs.set("x-delivery-count" /* DeliveryCount */, msg.info.deliveryCount.toString());
|
|
4901
5431
|
try {
|
|
4902
|
-
|
|
4903
|
-
await js.publish(destinationSubject, msg.data, { headers: hdrs });
|
|
5432
|
+
await this.publishToDlqWithRetry(this.connection, destinationSubject, msg.data, hdrs);
|
|
4904
5433
|
this.logger.log(`Message sent to DLQ: ${msg.subject}`);
|
|
4905
5434
|
if (this.deadLetterConfig?.onDeadLetter) {
|
|
4906
5435
|
try {
|
|
@@ -4912,7 +5441,9 @@ var EventRouter = class {
|
|
|
4912
5441
|
);
|
|
4913
5442
|
}
|
|
4914
5443
|
}
|
|
4915
|
-
|
|
5444
|
+
settleQuietly(this.logger, `Failed to term ${msg.subject}:`, () => {
|
|
5445
|
+
msg.term("Moved to DLQ stream");
|
|
5446
|
+
});
|
|
4916
5447
|
} catch (publishErr) {
|
|
4917
5448
|
this.logger.error(`Failed to publish to DLQ for ${msg.subject}:`, publishErr);
|
|
4918
5449
|
await this.fallbackToOnDeadLetterCallback(info, msg);
|
|
@@ -5106,7 +5637,9 @@ var RpcRouter = class {
|
|
|
5106
5637
|
`rpc-handler:${subject}`
|
|
5107
5638
|
);
|
|
5108
5639
|
publishErrorReply(replyTo, correlationId, subject, err);
|
|
5109
|
-
|
|
5640
|
+
settleQuietly(logger5, `Failed to term ${subject}:`, () => {
|
|
5641
|
+
msg.term(`Handler error: ${subject}`);
|
|
5642
|
+
});
|
|
5110
5643
|
};
|
|
5111
5644
|
const abortController = new AbortController();
|
|
5112
5645
|
let pending;
|
|
@@ -5134,7 +5667,9 @@ var RpcRouter = class {
|
|
|
5134
5667
|
}
|
|
5135
5668
|
if (!isPromiseLike2(pending)) {
|
|
5136
5669
|
if (stopAckExtension !== null) stopAckExtension();
|
|
5137
|
-
|
|
5670
|
+
settleQuietly(logger5, `Failed to ack ${subject}:`, () => {
|
|
5671
|
+
msg.ack();
|
|
5672
|
+
});
|
|
5138
5673
|
publishReply(replyTo, correlationId, pending);
|
|
5139
5674
|
reportHandlerCompleted(msg, startedAt, "success");
|
|
5140
5675
|
return void 0;
|
|
@@ -5146,7 +5681,9 @@ var RpcRouter = class {
|
|
|
5146
5681
|
if (stopAckExtension !== null) stopAckExtension();
|
|
5147
5682
|
abortController.abort();
|
|
5148
5683
|
emitRpcTimeout(subject, correlationId);
|
|
5149
|
-
|
|
5684
|
+
settleQuietly(logger5, `Failed to term ${subject}:`, () => {
|
|
5685
|
+
msg.term("Handler timeout");
|
|
5686
|
+
});
|
|
5150
5687
|
reportHandlerCompleted(msg, startedAt, "terminated");
|
|
5151
5688
|
}, timeout);
|
|
5152
5689
|
return pending.then(
|
|
@@ -5155,7 +5692,9 @@ var RpcRouter = class {
|
|
|
5155
5692
|
settled = true;
|
|
5156
5693
|
clearTimeout(timeoutId);
|
|
5157
5694
|
if (stopAckExtension !== null) stopAckExtension();
|
|
5158
|
-
|
|
5695
|
+
settleQuietly(logger5, `Failed to ack ${subject}:`, () => {
|
|
5696
|
+
msg.ack();
|
|
5697
|
+
});
|
|
5159
5698
|
publishReply(replyTo, correlationId, result);
|
|
5160
5699
|
reportHandlerCompleted(msg, startedAt, "success");
|
|
5161
5700
|
},
|
|
@@ -5177,14 +5716,28 @@ var RpcRouter = class {
|
|
|
5177
5716
|
active--;
|
|
5178
5717
|
drainBacklog();
|
|
5179
5718
|
};
|
|
5719
|
+
const routeSafely = (msg) => {
|
|
5720
|
+
try {
|
|
5721
|
+
return handleSafe(msg);
|
|
5722
|
+
} catch (err) {
|
|
5723
|
+
logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
|
|
5724
|
+
return void 0;
|
|
5725
|
+
}
|
|
5726
|
+
};
|
|
5727
|
+
const trackAsync = (result, msg) => {
|
|
5728
|
+
void result.catch((err) => {
|
|
5729
|
+
logger5.error(`Unexpected routing failure for ${msg.subject}:`, err);
|
|
5730
|
+
}).finally(onAsyncDone);
|
|
5731
|
+
};
|
|
5180
5732
|
const drainBacklog = () => {
|
|
5181
5733
|
while (active < maxActive) {
|
|
5182
5734
|
const next = backlog.shift();
|
|
5183
5735
|
if (next === void 0) return;
|
|
5736
|
+
next.stopAckExtension?.();
|
|
5184
5737
|
active++;
|
|
5185
|
-
const result =
|
|
5738
|
+
const result = routeSafely(next.msg);
|
|
5186
5739
|
if (result !== void 0) {
|
|
5187
|
-
|
|
5740
|
+
trackAsync(result, next.msg);
|
|
5188
5741
|
} else {
|
|
5189
5742
|
active--;
|
|
5190
5743
|
}
|
|
@@ -5194,7 +5747,10 @@ var RpcRouter = class {
|
|
|
5194
5747
|
this.subscription = this.messageProvider.commands$.subscribe({
|
|
5195
5748
|
next: (msg) => {
|
|
5196
5749
|
if (active >= maxActive) {
|
|
5197
|
-
backlog.push(
|
|
5750
|
+
backlog.push({
|
|
5751
|
+
msg,
|
|
5752
|
+
stopAckExtension: hasAckExtension ? startAckExtensionTimer(msg, ackExtensionInterval) : null
|
|
5753
|
+
});
|
|
5198
5754
|
if (!backlogWarned && backlog.length >= backlogWarnThreshold) {
|
|
5199
5755
|
backlogWarned = true;
|
|
5200
5756
|
logger5.warn(
|
|
@@ -5204,9 +5760,9 @@ var RpcRouter = class {
|
|
|
5204
5760
|
return;
|
|
5205
5761
|
}
|
|
5206
5762
|
active++;
|
|
5207
|
-
const result =
|
|
5763
|
+
const result = routeSafely(msg);
|
|
5208
5764
|
if (result !== void 0) {
|
|
5209
|
-
|
|
5765
|
+
trackAsync(result, msg);
|
|
5210
5766
|
} else {
|
|
5211
5767
|
active--;
|
|
5212
5768
|
if (backlog.length > 0) drainBacklog();
|
|
@@ -5216,6 +5772,12 @@ var RpcRouter = class {
|
|
|
5216
5772
|
logger5.error("Stream error in RPC router", err);
|
|
5217
5773
|
}
|
|
5218
5774
|
});
|
|
5775
|
+
this.subscription.add(() => {
|
|
5776
|
+
for (const queued of backlog) {
|
|
5777
|
+
queued.stopAckExtension?.();
|
|
5778
|
+
}
|
|
5779
|
+
backlog.length = 0;
|
|
5780
|
+
});
|
|
5219
5781
|
}
|
|
5220
5782
|
/** Stop routing and unsubscribe. */
|
|
5221
5783
|
destroy() {
|
|
@@ -5286,7 +5848,7 @@ var JetstreamModule = class {
|
|
|
5286
5848
|
return {
|
|
5287
5849
|
module: JetstreamModule,
|
|
5288
5850
|
global: true,
|
|
5289
|
-
imports:
|
|
5851
|
+
imports: [JetstreamMetricsModule.forFeature()],
|
|
5290
5852
|
providers,
|
|
5291
5853
|
exports: [
|
|
5292
5854
|
JETSTREAM_CONNECTION,
|
|
@@ -5312,11 +5874,10 @@ var JetstreamModule = class {
|
|
|
5312
5874
|
static forRootAsync(asyncOptions) {
|
|
5313
5875
|
const asyncProviders = this.createAsyncOptionsProvider(asyncOptions);
|
|
5314
5876
|
const coreProviders = this.createCoreDependentProviders();
|
|
5315
|
-
const metricsImports = asyncOptions.metrics ? [JetstreamMetricsModule.forFeature(asyncOptions.metrics)] : [];
|
|
5316
5877
|
return {
|
|
5317
5878
|
module: JetstreamModule,
|
|
5318
5879
|
global: true,
|
|
5319
|
-
imports: [...asyncOptions.imports ?? [],
|
|
5880
|
+
imports: [...asyncOptions.imports ?? [], JetstreamMetricsModule.forFeature()],
|
|
5320
5881
|
providers: [...asyncProviders, ...coreProviders],
|
|
5321
5882
|
exports: [
|
|
5322
5883
|
JETSTREAM_CONNECTION,
|
|
@@ -5499,7 +6060,7 @@ var JetstreamModule = class {
|
|
|
5499
6060
|
],
|
|
5500
6061
|
useFactory: (options, messageProvider, patternRegistry, codec, eventBus, ackWaitMap, connection) => {
|
|
5501
6062
|
if (options.consumer === false) return null;
|
|
5502
|
-
const deadLetterConfig = options.onDeadLetter ? {
|
|
6063
|
+
const deadLetterConfig = options.onDeadLetter || options.dlq ? {
|
|
5503
6064
|
maxDeliverByStream: /* @__PURE__ */ new Map(),
|
|
5504
6065
|
onDeadLetter: options.onDeadLetter
|
|
5505
6066
|
} : void 0;
|
|
@@ -5699,6 +6260,7 @@ export {
|
|
|
5699
6260
|
JetstreamHeader,
|
|
5700
6261
|
JetstreamHealthIndicator,
|
|
5701
6262
|
JetstreamModule,
|
|
6263
|
+
JetstreamProvisioningError,
|
|
5702
6264
|
JetstreamRecord,
|
|
5703
6265
|
JetstreamRecordBuilder,
|
|
5704
6266
|
JetstreamStrategy,
|