@cotal-ai/core 0.68.0 → 0.70.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/agent-file.d.ts.map +1 -1
- package/dist/agent-file.js +8 -6
- package/dist/agent-file.js.map +1 -1
- package/dist/auth-provider.d.ts +0 -1
- package/dist/auth-provider.d.ts.map +1 -1
- package/dist/connector-config.d.ts +4 -0
- package/dist/connector-config.d.ts.map +1 -1
- package/dist/connector-config.js +49 -0
- package/dist/connector-config.js.map +1 -1
- package/dist/contract-manifest.d.ts +20 -0
- package/dist/contract-manifest.d.ts.map +1 -0
- package/dist/contract-manifest.js +28 -0
- package/dist/contract-manifest.js.map +1 -0
- package/dist/endpoint-binding.d.ts.map +1 -1
- package/dist/endpoint-binding.js +5 -0
- package/dist/endpoint-binding.js.map +1 -1
- package/dist/endpoint-cluster.d.ts.map +1 -1
- package/dist/endpoint-cluster.js +9 -4
- package/dist/endpoint-cluster.js.map +1 -1
- package/dist/endpoint-contract-store.d.ts +2 -18
- package/dist/endpoint-contract-store.d.ts.map +1 -1
- package/dist/endpoint-contract-store.js +3 -19
- package/dist/endpoint-contract-store.js.map +1 -1
- package/dist/endpoint-envelope.d.ts +6 -2
- package/dist/endpoint-envelope.d.ts.map +1 -1
- package/dist/endpoint-envelope.js +19 -2
- package/dist/endpoint-envelope.js.map +1 -1
- package/dist/endpoint-error.d.ts +13 -0
- package/dist/endpoint-error.d.ts.map +1 -1
- package/dist/endpoint-error.js +15 -0
- package/dist/endpoint-error.js.map +1 -1
- package/dist/endpoint-invoke.d.ts +26 -0
- package/dist/endpoint-invoke.d.ts.map +1 -1
- package/dist/endpoint-invoke.js +60 -4
- package/dist/endpoint-invoke.js.map +1 -1
- package/dist/endpoint-serve-kv.d.ts +0 -1
- package/dist/endpoint-serve-kv.d.ts.map +1 -1
- package/dist/endpoint-serve-kv.js +0 -1
- package/dist/endpoint-serve-kv.js.map +1 -1
- package/dist/endpoint-serve.d.ts.map +1 -1
- package/dist/endpoint-serve.js +4 -3
- package/dist/endpoint-serve.js.map +1 -1
- package/dist/endpoint-service.d.ts +12 -6
- package/dist/endpoint-service.d.ts.map +1 -1
- package/dist/endpoint-service.js +29 -23
- package/dist/endpoint-service.js.map +1 -1
- package/dist/endpoint-verbs.d.ts.map +1 -1
- package/dist/endpoint-verbs.js +2 -1
- package/dist/endpoint-verbs.js.map +1 -1
- package/dist/endpoint.d.ts +31 -22
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +117 -90
- package/dist/endpoint.js.map +1 -1
- package/dist/evict.d.ts.map +1 -1
- package/dist/evict.js +2 -1
- package/dist/evict.js.map +1 -1
- package/dist/identity.d.ts +3 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +5 -0
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/kv-scan.d.ts +1 -3
- package/dist/kv-scan.d.ts.map +1 -1
- package/dist/kv-scan.js.map +1 -1
- package/dist/loopback.d.ts +8 -0
- package/dist/loopback.d.ts.map +1 -0
- package/dist/loopback.js +17 -0
- package/dist/loopback.js.map +1 -0
- package/dist/managed-handoff.d.ts.map +1 -1
- package/dist/managed-handoff.js +1 -18
- package/dist/managed-handoff.js.map +1 -1
- package/dist/presence-ages.d.ts +22 -0
- package/dist/presence-ages.d.ts.map +1 -0
- package/dist/presence-ages.js +33 -0
- package/dist/presence-ages.js.map +1 -0
- package/dist/provision.d.ts +23 -28
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +112 -126
- package/dist/provision.js.map +1 -1
- package/dist/remote-manager-authority.d.ts +30 -7
- package/dist/remote-manager-authority.d.ts.map +1 -1
- package/dist/remote-manager-authority.js +23 -15
- package/dist/remote-manager-authority.js.map +1 -1
- package/dist/resolve.d.ts +15 -2
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +15 -2
- package/dist/resolve.js.map +1 -1
- package/dist/run-journal.d.ts.map +1 -1
- package/dist/run-journal.js +17 -2
- package/dist/run-journal.js.map +1 -1
- package/dist/run-record.d.ts +6 -8
- package/dist/run-record.d.ts.map +1 -1
- package/dist/run-record.js.map +1 -1
- package/dist/schema-profile.d.ts +11 -0
- package/dist/schema-profile.d.ts.map +1 -1
- package/dist/schema-profile.js +13 -2
- package/dist/schema-profile.js.map +1 -1
- package/dist/secret-store.d.ts +6 -2
- package/dist/secret-store.d.ts.map +1 -1
- package/dist/secret-store.js +9 -6
- package/dist/secret-store.js.map +1 -1
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +0 -1
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +5 -0
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +11 -0
- package/dist/subjects.js.map +1 -1
- package/package.json +1 -1
package/dist/endpoint.js
CHANGED
|
@@ -9,7 +9,7 @@ import { requireBrokerFloor } from "./broker-floor.js";
|
|
|
9
9
|
import { inspectCredHealth } from "./provision.js";
|
|
10
10
|
import { parseSecretStoreIdentity, } from "./secret-store.js";
|
|
11
11
|
import { BIND_SPLIT_REISSUES, resolveService, invokeCommand, submitAndFollowGoal } from "./endpoint-invoke.js";
|
|
12
|
-
import { EpEnvelopeError, respondedButUnbound, replyRefusedBeforeEffect, EP_BIND_REFUSED } from "./endpoint-envelope.js";
|
|
12
|
+
import { EpEnvelopeError, respondedButUnbound, replyRefusedBeforeEffect, replyTargetUnmapped, EP_BIND_REFUSED } from "./endpoint-envelope.js";
|
|
13
13
|
import { isRepeatSafeCommand } from "./endpoint-grants.js";
|
|
14
14
|
import { assertIdToken, assertGeneration } from "./endpoint-subjects.js";
|
|
15
15
|
import { readAcceptedRow } from "./issued-authority.js";
|
|
@@ -249,9 +249,6 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
249
249
|
* EXPECTED async permission violation that joinChannel turns into a clean throw, so watchStatus
|
|
250
250
|
* suppresses it rather than surfacing a spurious connection error. */
|
|
251
251
|
confirmingChatSubs = new Set();
|
|
252
|
-
/** Delete subjects of this endpoint's own consumers whose refusal watchStatus has not consumed yet
|
|
253
|
-
* (see {@link deleteOwnConsumer}). */
|
|
254
|
-
ownConsumerDeletes = new Set();
|
|
255
252
|
/** `$JS.API.CONSUMER.DELETE.<stream>.<prefix>_` for each of this connection's own KV watches,
|
|
256
253
|
* running membership scans and running history reads (see {@link recordOwnWatchConsumer}). */
|
|
257
254
|
ownWatchDeletePrefixes = new Set();
|
|
@@ -366,8 +363,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
366
363
|
/** Active goal followers awaiting outcome, tracked so endpoint stop can cancel them immediately
|
|
367
364
|
* and connection replacement / reconnect can trigger reconciliation. */
|
|
368
365
|
activeGoalFollowers = new Set();
|
|
369
|
-
/** How many calls {@link invokeService} has silently recovered from a bind refusal (§13.2)
|
|
370
|
-
* class-queue splits this endpoint hit
|
|
366
|
+
/** How many calls {@link invokeService} has silently recovered from a bind refusal (§13.2) or a
|
|
367
|
+
* member holding no mapping for the target (§13.3) — the class-queue splits this endpoint hit
|
|
368
|
+
* and survived.
|
|
371
369
|
*
|
|
372
370
|
* Counted because it is recovered: handling the split is what makes it invisible, so this is the
|
|
373
371
|
* only evidence the split rate exists. Always on, never behind a flag — a counter you have to
|
|
@@ -1222,7 +1220,6 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1222
1220
|
this.chatSubs.clear();
|
|
1223
1221
|
this.chatSubDenied.clear();
|
|
1224
1222
|
this.confirmingChatSubs.clear();
|
|
1225
|
-
this.ownConsumerDeletes.clear();
|
|
1226
1223
|
this.ownWatchDeletePrefixes.clear();
|
|
1227
1224
|
this.roster.clear();
|
|
1228
1225
|
// #1356: the presence-refusal record is connection-scoped like everything else torn down here.
|
|
@@ -1911,8 +1908,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1911
1908
|
this.emit("error", new Error(`rejected ${service} request on ${m.subject}: reply target "${m.reply ?? "(none)"}" is not under the sender's own reply subtree`));
|
|
1912
1909
|
continue;
|
|
1913
1910
|
}
|
|
1914
|
-
let
|
|
1911
|
+
let body;
|
|
1915
1912
|
try {
|
|
1913
|
+
let reply;
|
|
1916
1914
|
const req = m.json();
|
|
1917
1915
|
// Authenticity guard (fail closed): control is the most privileged surface
|
|
1918
1916
|
// (start/stop). The sender is encoded in the subject (ctl.<svc>.<sender>), which
|
|
@@ -1927,15 +1925,25 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1927
1925
|
else {
|
|
1928
1926
|
reply = await handler(req);
|
|
1929
1927
|
}
|
|
1928
|
+
body = JSON.stringify(reply);
|
|
1930
1929
|
}
|
|
1931
1930
|
catch (e) {
|
|
1932
|
-
|
|
1931
|
+
body = JSON.stringify({ ok: false, error: e.message });
|
|
1933
1932
|
}
|
|
1934
1933
|
try {
|
|
1935
|
-
m.respond(
|
|
1934
|
+
m.respond(body);
|
|
1936
1935
|
}
|
|
1937
|
-
catch {
|
|
1938
|
-
|
|
1936
|
+
catch (e) {
|
|
1937
|
+
// `respond` signals a missing reply inbox by returning false; it throws when the reply exceeds the
|
|
1938
|
+
// broker's `max_payload`. Silence would leave the caller a bare timeout it cannot tell apart from
|
|
1939
|
+
// a dead service.
|
|
1940
|
+
const refused = { ok: false, error: `the ${service} reply is ${Buffer.byteLength(body)} bytes and was not published: ${e.message}` };
|
|
1941
|
+
try {
|
|
1942
|
+
m.respond(JSON.stringify(refused));
|
|
1943
|
+
}
|
|
1944
|
+
catch (err) {
|
|
1945
|
+
this.emit("error", new Error(`could not answer ${service} request on ${m.subject}: ${refused.error}, and the refusal failed too: ${err.message}`));
|
|
1946
|
+
}
|
|
1939
1947
|
}
|
|
1940
1948
|
}
|
|
1941
1949
|
})().catch((e) => this.emit("error", e));
|
|
@@ -2017,6 +2025,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2017
2025
|
return () => { reconnectHandler = undefined; };
|
|
2018
2026
|
},
|
|
2019
2027
|
signal: abortController.signal,
|
|
2028
|
+
prepare: opts.prepare,
|
|
2020
2029
|
});
|
|
2021
2030
|
}
|
|
2022
2031
|
finally {
|
|
@@ -2035,7 +2044,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2035
2044
|
* call re-issued for any command, up to {@link BIND_SPLIT_REISSUES} times while each re-issue
|
|
2036
2045
|
* is refused the same way. If a re-issue cannot be resolved, the refusal
|
|
2037
2046
|
* surfaces — still saying the command did not run — naming the resolve failure as why the
|
|
2038
|
-
* repair could not be attempted.
|
|
2047
|
+
* repair could not be attempted. An unpinned targeted call that reached a member holding no
|
|
2048
|
+
* mapping for its target ({@link replyTargetUnmapped}) is repaired the same way, and when the
|
|
2049
|
+
* re-issues run out, the latest such refusal is the one surfaced.
|
|
2039
2050
|
* - this CLIENT caught it on the reply ({@link respondedButUnbound}: a different instance,
|
|
2040
2051
|
* `failed-precondition`; the same instance at any other epoch, `expired`), which is what a
|
|
2041
2052
|
* responder too old to know the field produces. A live instance received and answered it, so
|
|
@@ -2079,10 +2090,22 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2079
2090
|
}
|
|
2080
2091
|
return invokeCommand(nc, this.space, service, command, args, { ...invokeOpts, signal });
|
|
2081
2092
|
};
|
|
2082
|
-
|
|
2093
|
+
// A `failed-precondition` from the resolve is its own refusal, raised before any command was
|
|
2094
|
+
// published, so resolving once more is a repair.
|
|
2095
|
+
const resolveFirst = async (signal = opts.signal) => {
|
|
2096
|
+
try {
|
|
2097
|
+
return await resolve(signal);
|
|
2098
|
+
}
|
|
2099
|
+
catch (e) {
|
|
2100
|
+
if (!(e instanceof EpEnvelopeError) || e.code !== "failed-precondition")
|
|
2101
|
+
throw e;
|
|
2102
|
+
return await resolve(signal);
|
|
2103
|
+
}
|
|
2104
|
+
};
|
|
2105
|
+
const doInvoke = async (service, signal = opts.signal) => {
|
|
2083
2106
|
signal?.throwIfAborted();
|
|
2084
2107
|
try {
|
|
2085
|
-
let r = await invokeResolved(
|
|
2108
|
+
let r = await invokeResolved(service, signal);
|
|
2086
2109
|
// THE RESPONDER FENCED IT (§13.2 `ai.cotal.ep.bind-refused`): a class member saw the call
|
|
2087
2110
|
// was bound to a different incarnation and refused BEFORE running the command.
|
|
2088
2111
|
//
|
|
@@ -2094,7 +2117,14 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2094
2117
|
// did not run, so each re-issue is a FIRST attempt, not a second. It repeats up to the bound
|
|
2095
2118
|
// the CLI uses, because the re-issue rides the same class queue and splits again at the same
|
|
2096
2119
|
// rate; repairing once left a quarter of all calls in a two-manager space failing (#443).
|
|
2097
|
-
|
|
2120
|
+
// A member that holds no mapping for the target also ran nothing, and on the class rail a
|
|
2121
|
+
// sibling may host it. A pinned call named its instance, so that refusal is its answer.
|
|
2122
|
+
// That member looked the target up, so when the re-issues run out its refusal is surfaced
|
|
2123
|
+
// in place of a trailing bind refusal, which says nothing about the target.
|
|
2124
|
+
const unmappedHere = (x) => opts.instanceId === undefined && replyTargetUnmapped(x.reply.error);
|
|
2125
|
+
let unmapped = unmappedHere(r) ? r : undefined;
|
|
2126
|
+
for (let reissues = 0; reissues < BIND_SPLIT_REISSUES && r.reply.ok === false
|
|
2127
|
+
&& (replyRefusedBeforeEffect(r.reply.error) || unmappedHere(r)); reissues++) {
|
|
2098
2128
|
// Counted before it is repaired: a recovery that leaves no trace takes the split rate with it.
|
|
2099
2129
|
this.splitsRecovered++;
|
|
2100
2130
|
// `boundTo` is the other half of `servedBy`: who the handle THOUGHT it was talking to,
|
|
@@ -2145,12 +2175,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2145
2175
|
"not-executed");
|
|
2146
2176
|
}
|
|
2147
2177
|
r = await invokeResolved(reissueTarget, signal);
|
|
2178
|
+
if (unmappedHere(r))
|
|
2179
|
+
unmapped = r;
|
|
2148
2180
|
}
|
|
2149
|
-
return r;
|
|
2181
|
+
return replyRefusedBeforeEffect(r.reply.error) ? unmapped ?? r : r;
|
|
2150
2182
|
}
|
|
2151
2183
|
catch (e) {
|
|
2152
|
-
if (!(e instanceof EpEnvelopeError))
|
|
2153
|
-
throw e;
|
|
2154
2184
|
// DO NOT auto-retry a command a responder already ANSWERED. This path covers the responders
|
|
2155
2185
|
// WITHOUT the fence above: they ignore `bind`, run the command, and the error is raised
|
|
2156
2186
|
// afterwards, so core cannot tell a repair from a duplicate and the allowlist is the only
|
|
@@ -2183,23 +2213,20 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2183
2213
|
throw e;
|
|
2184
2214
|
return await invokeResolved(await resolve(signal), signal);
|
|
2185
2215
|
}
|
|
2186
|
-
|
|
2187
|
-
// was published, so re-resolving once is a repair. The `replyRefusedBeforeEffect` half keeps
|
|
2188
|
-
// out the refusal THIS method raises after a failed re-issue: it carries that same code, and
|
|
2189
|
-
// would otherwise fall into the re-resolve below as a THIRD attempt at a command whose
|
|
2190
|
-
// second could not even be resolved. A marker means the disposition is already decided,
|
|
2191
|
-
// whichever code carries it.
|
|
2192
|
-
if (e.code !== "failed-precondition" || replyRefusedBeforeEffect(e.toEpError()))
|
|
2193
|
-
throw e;
|
|
2194
|
-
this.resolvedServices.delete(endpoint);
|
|
2195
|
-
return await invokeResolved(await resolve(signal), signal);
|
|
2216
|
+
throw e;
|
|
2196
2217
|
}
|
|
2197
2218
|
};
|
|
2198
2219
|
// P2 item 2 (2b): a goal-bearing command (spawn/launch) follows its acceptance to the terminal so
|
|
2199
2220
|
// the caller still returns on the real outcome (UX unchanged); every other command replies directly.
|
|
2200
2221
|
if (!opts.follow)
|
|
2201
|
-
return doInvoke();
|
|
2202
|
-
|
|
2222
|
+
return doInvoke(await resolveFirst());
|
|
2223
|
+
// Resolved before the submission starts: the follow reports an unmarked failure inside the
|
|
2224
|
+
// submission as a command that may have run, and the resolve publishes none of this call.
|
|
2225
|
+
let service;
|
|
2226
|
+
return this.followServiceGoal(endpoint, (signal) => doInvoke(service, signal), opts.deadlineMs ?? 10_000, {
|
|
2227
|
+
signal: opts.signal,
|
|
2228
|
+
prepare: async (signal) => { service = await resolveFirst(signal); },
|
|
2229
|
+
});
|
|
2203
2230
|
}
|
|
2204
2231
|
/** Send a durable-membership request to the SERVER-SIDE delivery daemon (`ctl.delivery`) and await its
|
|
2205
2232
|
* reply. Unlike {@link requestControl}, the reply rides a subject UNDER `ctl.delivery.<id>.>` (not the
|
|
@@ -2562,7 +2589,6 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2562
2589
|
let scanPrefix;
|
|
2563
2590
|
const entries = await liveKvEntries(kv, {
|
|
2564
2591
|
onConsumer: (info) => { scanPrefix = this.recordOwnWatchConsumer(info); },
|
|
2565
|
-
deleteOwnConsumer: (stream, name, del) => this.deleteOwnConsumer(stream, name, del),
|
|
2566
2592
|
}).finally(() => {
|
|
2567
2593
|
// The scan's own last delete goes out after nats.js closed its consumer, so every predecessor
|
|
2568
2594
|
// delete the library sent is answered ahead of it on this connection.
|
|
@@ -2644,15 +2670,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2644
2670
|
watch.consumer = consumer;
|
|
2645
2671
|
watch.consumerStream = info.stream_name;
|
|
2646
2672
|
watch.consumerName = info.name;
|
|
2647
|
-
let pending = info.num_pending;
|
|
2648
2673
|
let iter;
|
|
2649
2674
|
try {
|
|
2650
|
-
iter = await consumer.consume({ callback: (
|
|
2651
|
-
const isUpdate = pending === 0 || --pending === 0;
|
|
2652
|
-
const entry = kv.jmToWatchEntry(msg, isUpdate);
|
|
2675
|
+
iter = await consumer.consume({ callback: () => {
|
|
2653
2676
|
if (!watch.stopped && watch.consumer === consumer)
|
|
2654
2677
|
watch.onChange();
|
|
2655
|
-
void entry;
|
|
2656
2678
|
} });
|
|
2657
2679
|
}
|
|
2658
2680
|
catch (err) {
|
|
@@ -2694,31 +2716,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2694
2716
|
watch.resolveStop = undefined;
|
|
2695
2717
|
watch.rejectStop = undefined;
|
|
2696
2718
|
}
|
|
2697
|
-
/** Run one delete of a consumer this endpoint created. A profile without the delete row is refused
|
|
2698
|
-
* by design (#691), and nats.js reports a refused request twice: as the request's rejection, which
|
|
2699
|
-
* the caller handles, and on the connection status, which watchStatus would emit as an `error`.
|
|
2700
|
-
* A refused subject stays recorded until watchStatus drops that echo, since the two settle in
|
|
2701
|
-
* either order. */
|
|
2702
|
-
async deleteOwnConsumer(stream, name, del) {
|
|
2703
|
-
const subject = `$JS.API.CONSUMER.DELETE.${stream}.${name}`;
|
|
2704
|
-
this.ownConsumerDeletes.add(subject);
|
|
2705
|
-
let refused = false;
|
|
2706
|
-
try {
|
|
2707
|
-
return await del();
|
|
2708
|
-
}
|
|
2709
|
-
catch (err) {
|
|
2710
|
-
refused = isPublishPermissionDenied(err);
|
|
2711
|
-
throw err;
|
|
2712
|
-
}
|
|
2713
|
-
finally {
|
|
2714
|
-
if (!refused)
|
|
2715
|
-
this.ownConsumerDeletes.delete(subject);
|
|
2716
|
-
}
|
|
2717
|
-
}
|
|
2718
2719
|
/** Record the consumer behind one of this connection's own KV watches, membership scans or history
|
|
2719
2720
|
* reads, and return the delete-subject prefix it recorded. nats.js names it `<prefix>_<serial>` and,
|
|
2720
2721
|
* rebuilding it after a stall or a sequence gap, deletes the predecessor itself with no hook before
|
|
2721
|
-
* the send, so a profile without the delete row (#691) has that delete refused. A
|
|
2722
|
+
* the send, so a profile without the delete row (#691) has that delete refused. A stalled link can
|
|
2723
|
+
* time that delete out before its refusal arrives, and nats.js then hands the refusal to no request,
|
|
2724
|
+
* so {@link trackRequestDenials} cannot own it and the consumer's name has to. A watch's prefix
|
|
2722
2725
|
* stays recorded for the connection because a retired watch's last rebuild can be refused after
|
|
2723
2726
|
* the watch has stopped; {@link readMembership} and {@link drainWindow} retire theirs after their own
|
|
2724
2727
|
* last delete, which goes out once nats.js has stopped the consumer and so is answered after every
|
|
@@ -2737,9 +2740,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2737
2740
|
* the elevated profile, which holds no stream-wide CONSUMER.DELETE (#691): the broker reaps the
|
|
2738
2741
|
* consumer at its inactive threshold. */
|
|
2739
2742
|
async deleteReaderConsumer(consumer) {
|
|
2740
|
-
const { stream_name, name } = await consumer.info(true);
|
|
2741
2743
|
try {
|
|
2742
|
-
await
|
|
2744
|
+
await consumer.delete();
|
|
2743
2745
|
}
|
|
2744
2746
|
catch (e) {
|
|
2745
2747
|
if (!isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound) && !isPublishPermissionDenied(e))
|
|
@@ -2749,7 +2751,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2749
2751
|
/** Delete one membership-watch consumer, swallowing ONLY already-gone and a refused delete. */
|
|
2750
2752
|
async deleteMembershipConsumer(jsm, stream, name) {
|
|
2751
2753
|
try {
|
|
2752
|
-
return await
|
|
2754
|
+
return await jsm.consumers.delete(stream, name);
|
|
2753
2755
|
}
|
|
2754
2756
|
catch (err) {
|
|
2755
2757
|
if (membershipConsumerReleased(err))
|
|
@@ -2771,8 +2773,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2771
2773
|
catch { /* already closed */ }
|
|
2772
2774
|
if (consumer) {
|
|
2773
2775
|
try {
|
|
2774
|
-
const
|
|
2775
|
-
const deleted = await this.deleteOwnConsumer(stream_name, name, () => consumer.delete());
|
|
2776
|
+
const deleted = await consumer.delete();
|
|
2776
2777
|
if (deleted) {
|
|
2777
2778
|
watch.consumerStream = undefined;
|
|
2778
2779
|
watch.consumerName = undefined;
|
|
@@ -3271,15 +3272,18 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3271
3272
|
// ---- internals -----------------------------------------------------------
|
|
3272
3273
|
/**
|
|
3273
3274
|
* Surface the connection's async status errors on our `error` event. NATS reports
|
|
3274
|
-
* publish permission violations *only* here (subscription
|
|
3275
|
-
*
|
|
3276
|
-
* traffic and it just looks "absent".
|
|
3277
|
-
*
|
|
3275
|
+
* publish permission violations *only* here (subscription ones too), never on the
|
|
3276
|
+
* failing call — so without this an over-tight ACL silently drops the agent's
|
|
3277
|
+
* traffic and it just looks "absent". A denied request is the exception: nats.js also
|
|
3278
|
+
* rejects that request with the denial, so its caller owns it (see {@link trackRequestDenials}).
|
|
3279
|
+
* We annotate permission denials explicitly so a denial is never mistaken for absence
|
|
3280
|
+
* (which already has a benign cause: MCP reconnect).
|
|
3278
3281
|
*/
|
|
3279
3282
|
watchStatus() {
|
|
3280
3283
|
const nc = this.nc;
|
|
3281
3284
|
if (!nc)
|
|
3282
3285
|
return;
|
|
3286
|
+
const handedToRequest = this.trackRequestDenials(nc);
|
|
3283
3287
|
void (async () => {
|
|
3284
3288
|
for await (const s of nc.status()) {
|
|
3285
3289
|
// A rebuild can replace `this.nc` before the old iterator finishes. Late disconnect/close
|
|
@@ -3309,11 +3313,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3309
3313
|
// and turns into a clean throw — it is not a connection error to surface.
|
|
3310
3314
|
if (s.error instanceof PermissionViolationError && this.confirmingChatSubs.has(s.error.subject))
|
|
3311
3315
|
continue;
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
//
|
|
3316
|
+
if (s.error instanceof PermissionViolationError && handedToRequest.has(s.error))
|
|
3317
|
+
continue;
|
|
3318
|
+
// The predecessor nats.js deleted while rebuilding one of this connection's watches, scans or
|
|
3319
|
+
// history reads, whose refusal can arrive after that delete timed out.
|
|
3315
3320
|
if (s.error instanceof PermissionViolationError && s.error.operation === "publish"
|
|
3316
|
-
&&
|
|
3321
|
+
&& this.isOwnWatchDelete(s.error.subject))
|
|
3317
3322
|
continue;
|
|
3318
3323
|
this.emit("error", describeStatusError(s.error));
|
|
3319
3324
|
}
|
|
@@ -3337,6 +3342,29 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3337
3342
|
return;
|
|
3338
3343
|
this.emit("transport", { connected: true, server: nc.getServer() });
|
|
3339
3344
|
}
|
|
3345
|
+
/** Wrap `nc.request` to record each publish denial nats.js rejects one of this connection's requests
|
|
3346
|
+
* with. nats.js rejects the pending request on the denied subject with the denial and then dispatches
|
|
3347
|
+
* that same error instance on the connection status, so the caller that received it (an empty history
|
|
3348
|
+
* read, a refused consumer delete, nats.js's own delete of a rebuilt watch's predecessor) owns it and
|
|
3349
|
+
* the status loop skips it. nats.js settles the request before it dispatches the status and this
|
|
3350
|
+
* reaction sits directly on the promise it returns, so it runs before the status loop body, which is
|
|
3351
|
+
* further microtasks away behind the status iterator. Any other denial is a different instance and is
|
|
3352
|
+
* not recorded, including one on the same subject and one that arrives after its request timed out.
|
|
3353
|
+
* A refused request inbox is rejected into every pending request but breaks every later one, so it
|
|
3354
|
+
* is not recorded either. */
|
|
3355
|
+
trackRequestDenials(nc) {
|
|
3356
|
+
const handed = new WeakSet();
|
|
3357
|
+
const request = nc.request.bind(nc);
|
|
3358
|
+
nc.request = (subject, payload, opts) => {
|
|
3359
|
+
const reply = request(subject, payload, opts);
|
|
3360
|
+
reply.catch(({ cause }) => {
|
|
3361
|
+
if (cause instanceof PermissionViolationError && cause.operation === "publish")
|
|
3362
|
+
handed.add(cause);
|
|
3363
|
+
});
|
|
3364
|
+
return reply;
|
|
3365
|
+
};
|
|
3366
|
+
return handed;
|
|
3367
|
+
}
|
|
3340
3368
|
/** The error message for a guard that finds the endpoint unbound: "reconnecting" during a
|
|
3341
3369
|
* rebuild's null window OR an inter-retry backoff (so a concurrent op reports the real
|
|
3342
3370
|
* reason, not "not started" — `reestablishing` spans the whole retry loop incl. backoff),
|
|
@@ -4356,9 +4384,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4356
4384
|
const reachedStart = page.length <= limit;
|
|
4357
4385
|
const wanted = reachedStart ? page : page.slice(-limit);
|
|
4358
4386
|
// A page that cannot be SENT is not a page. The reply rides one NATS message, so `limit` alone is
|
|
4359
|
-
// the wrong bound: 200 large messages serialize past `max_payload`, `
|
|
4360
|
-
//
|
|
4361
|
-
// dead daemon. Measured, not predicted: 200 x ~6 KB timed out at 5s with the message "timeout".
|
|
4387
|
+
// the wrong bound: 200 large messages serialize past `max_payload`, and `serveControl` can then only
|
|
4388
|
+
// answer with a refusal in place of the page.
|
|
4362
4389
|
//
|
|
4363
4390
|
// So bound by BYTES too, keeping the NEWEST that fit — which needs no new vocabulary, because
|
|
4364
4391
|
// `complete: false` already means "older history remains behind this page". Trimming here is the
|
|
@@ -6041,28 +6068,29 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
6041
6068
|
stuck: this.presenceWriteEscalated,
|
|
6042
6069
|
};
|
|
6043
6070
|
}
|
|
6044
|
-
/** Bind a presence watch on the current connection. Resolves
|
|
6045
|
-
* installed,
|
|
6046
|
-
* bind's iterator is released here and nothing is installed, because the
|
|
6047
|
-
* for it is gone and the epoch that replaced it binds its own watch through
|
|
6071
|
+
/** Bind a presence watch on the current connection. Resolves the bound consumer's info when
|
|
6072
|
+
* the watch was installed, undefined when the endpoint stopped or rebuilt while the bind was
|
|
6073
|
+
* in flight: that bind's iterator is released here and nothing is installed, because the
|
|
6074
|
+
* epoch that asked for it is gone and the epoch that replaced it binds its own watch through
|
|
6048
6075
|
* {@link connectAndBind}. Without this fence a bind that completes after {@link stop} would
|
|
6049
6076
|
* resurrect a watch on a stopped endpoint, and one that completes after a rebuild would
|
|
6050
6077
|
* overwrite the fresh epoch's watch with a dead-connection iterator. */
|
|
6051
6078
|
async startPresenceWatch() {
|
|
6052
6079
|
if (!this.kv)
|
|
6053
|
-
return
|
|
6080
|
+
return undefined;
|
|
6054
6081
|
const epoch = this.presenceEpoch;
|
|
6055
6082
|
let hydrated;
|
|
6056
6083
|
this.presenceSnapshot = new Promise((resolve) => { hydrated = resolve; });
|
|
6057
6084
|
const iter = await this.kv.watch();
|
|
6058
|
-
|
|
6085
|
+
const info = await kvWatchConsumer(iter).info(true);
|
|
6086
|
+
this.recordOwnWatchConsumer(info);
|
|
6059
6087
|
if (epoch !== this.presenceEpoch) {
|
|
6060
6088
|
try {
|
|
6061
6089
|
iter.stop();
|
|
6062
6090
|
}
|
|
6063
6091
|
catch { /* its connection may already be gone */ }
|
|
6064
6092
|
hydrated();
|
|
6065
|
-
return
|
|
6093
|
+
return undefined;
|
|
6066
6094
|
}
|
|
6067
6095
|
this.presenceWatchIter = iter;
|
|
6068
6096
|
void (async () => {
|
|
@@ -6083,7 +6111,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
6083
6111
|
}
|
|
6084
6112
|
hydrated();
|
|
6085
6113
|
})().catch((e) => this.emit("error", e));
|
|
6086
|
-
return
|
|
6114
|
+
return info;
|
|
6087
6115
|
}
|
|
6088
6116
|
/**
|
|
6089
6117
|
* Replace a presence watch that has gone silent past TTL while the connection is up. The new
|
|
@@ -6111,11 +6139,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
6111
6139
|
// one a held link never answers must leave the old watch in place: on a plain stall that
|
|
6112
6140
|
// watch is the one that recovers by itself, and its replay is still guarded against
|
|
6113
6141
|
// expired PUTs. Only a successfully bound watch retires its predecessor.
|
|
6114
|
-
const
|
|
6142
|
+
const info = await this.startPresenceWatch();
|
|
6115
6143
|
// Retired mid-bind (stop or rebuild moved the epoch): the late iterator is already
|
|
6116
6144
|
// released and the old watch was torn down by whoever moved the epoch. Nothing to
|
|
6117
6145
|
// retire, nothing to report.
|
|
6118
|
-
if (!
|
|
6146
|
+
if (!info)
|
|
6119
6147
|
return;
|
|
6120
6148
|
if (old && old !== this.presenceWatchIter) {
|
|
6121
6149
|
try {
|
|
@@ -6124,11 +6152,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
6124
6152
|
catch { /* already closed with its consumer */ }
|
|
6125
6153
|
}
|
|
6126
6154
|
// A bucket with no keys replays nothing, so the new watch cannot refresh
|
|
6127
|
-
// `lastPresenceWatchAt` by delivering. It IS current knowledge: nobody is present.
|
|
6128
|
-
//
|
|
6129
|
-
// from the same `info(true)
|
|
6130
|
-
|
|
6131
|
-
if (pending === 0)
|
|
6155
|
+
// `lastPresenceWatchAt` by delivering. It IS current knowledge: nobody is present. The
|
|
6156
|
+
// bind's consumer info carries the initial pending count for that one fact; nats.js's KV
|
|
6157
|
+
// watch placed the isUpdate marker from the same `info(true)`.
|
|
6158
|
+
if (info.num_pending === 0)
|
|
6132
6159
|
await this.onPresenceBucketEmpty();
|
|
6133
6160
|
this.emit("warning", new Error(`presence watch silent for ${silentMs}ms with the connection up; rebound it from the bucket's current state`));
|
|
6134
6161
|
}
|