@cotal-ai/core 0.86.0 → 0.87.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/browser-control.d.ts +53 -1
- package/dist/browser-control.d.ts.map +1 -1
- package/dist/browser-control.js +102 -1
- package/dist/browser-control.js.map +1 -1
- package/dist/channels.d.ts +4 -0
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +6 -2
- package/dist/channels.js.map +1 -1
- package/dist/endpoint-action.d.ts +2 -2
- package/dist/endpoint-action.d.ts.map +1 -1
- package/dist/endpoint-action.js +4 -28
- package/dist/endpoint-action.js.map +1 -1
- package/dist/endpoint-goal-follow.d.ts +37 -0
- package/dist/endpoint-goal-follow.d.ts.map +1 -0
- package/dist/endpoint-goal-follow.js +328 -0
- package/dist/endpoint-goal-follow.js.map +1 -0
- package/dist/endpoint-grants.d.ts +0 -4
- package/dist/endpoint-grants.d.ts.map +1 -1
- package/dist/endpoint-grants.js +1 -7
- package/dist/endpoint-grants.js.map +1 -1
- package/dist/endpoint-invoke.d.ts +4 -27
- package/dist/endpoint-invoke.d.ts.map +1 -1
- package/dist/endpoint-invoke.js +6 -307
- package/dist/endpoint-invoke.js.map +1 -1
- package/dist/endpoint-signing.d.ts +1 -1
- package/dist/endpoint-signing.d.ts.map +1 -1
- package/dist/endpoint-signing.js +1 -1
- package/dist/endpoint-signing.js.map +1 -1
- package/dist/endpoint-subjects.d.ts +4 -0
- package/dist/endpoint-subjects.d.ts.map +1 -1
- package/dist/endpoint-subjects.js +6 -0
- package/dist/endpoint-subjects.js.map +1 -1
- package/dist/endpoint.d.ts +78 -13
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +247 -43
- package/dist/endpoint.js.map +1 -1
- package/dist/goal-result-fact.d.ts +13 -0
- package/dist/goal-result-fact.d.ts.map +1 -0
- package/dist/goal-result-fact.js +40 -0
- package/dist/goal-result-fact.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/jetstream-missing.d.ts +4 -0
- package/dist/jetstream-missing.d.ts.map +1 -1
- package/dist/jetstream-missing.js +6 -0
- package/dist/jetstream-missing.js.map +1 -1
- package/dist/kv-scan.d.ts +15 -0
- package/dist/kv-scan.d.ts.map +1 -1
- package/dist/kv-scan.js +32 -0
- package/dist/kv-scan.js.map +1 -1
- package/dist/launch-material.d.ts.map +1 -1
- package/dist/launch-material.js +6 -2
- package/dist/launch-material.js.map +1 -1
- package/dist/lease.d.ts +50 -0
- package/dist/lease.d.ts.map +1 -1
- package/dist/lease.js +71 -0
- package/dist/lease.js.map +1 -1
- package/dist/private-tool-results.d.ts +253 -0
- package/dist/private-tool-results.d.ts.map +1 -0
- package/dist/private-tool-results.js +141 -0
- package/dist/private-tool-results.js.map +1 -0
- package/dist/provision.d.ts +5 -0
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +32 -14
- package/dist/provision.js.map +1 -1
- package/dist/remote-manager-authority.d.ts +17 -0
- package/dist/remote-manager-authority.d.ts.map +1 -1
- package/dist/remote-manager-authority.js +40 -1
- package/dist/remote-manager-authority.js.map +1 -1
- package/dist/run-journal.d.ts.map +1 -1
- package/dist/run-journal.js +1 -6
- package/dist/run-journal.js.map +1 -1
- package/dist/subjects.d.ts +17 -0
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +29 -0
- package/dist/subjects.js.map +1 -1
- package/package.json +1 -1
package/dist/endpoint.js
CHANGED
|
@@ -14,8 +14,8 @@ import { EpEnvelopeError, respondedButUnbound, replyRefusedBeforeEffect, replyTa
|
|
|
14
14
|
import { isRepeatSafeCommand } from "./endpoint-grants.js";
|
|
15
15
|
import { assertIdToken, assertGeneration } from "./endpoint-subjects.js";
|
|
16
16
|
import { readAcceptedRow } from "./issued-authority.js";
|
|
17
|
-
import { liveKvEntries } from "./kv-scan.js";
|
|
18
|
-
import { isJetStreamMissing } from "./jetstream-missing.js";
|
|
17
|
+
import { lastKvEntries, liveKvEntries } from "./kv-scan.js";
|
|
18
|
+
import { isConsumerAlreadyExists, isConsumerNotFound, isJetStreamMissing } from "./jetstream-missing.js";
|
|
19
19
|
import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
|
|
20
20
|
import { assertValidName } from "./resolve.js";
|
|
21
21
|
import { EVICT_PRINCIPALS_MAX } from "./evict.js";
|
|
@@ -28,7 +28,7 @@ import { openMembersRegistry, commitMember, tombstoneMember, activateMember, rea
|
|
|
28
28
|
import { openAclRegistry, readAcl, readAclForAlias, aclRetired, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
|
|
29
29
|
import { openDeliveryRegistry } from "./lease.js";
|
|
30
30
|
import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, decodeChannelEntry, } from "./channels.js";
|
|
31
|
-
import { anycastSubject, assertValidRole, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, livenessSubject, livenessServeFilter, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, MANAGER_RENEWAL_LEASE_KEY, managerLeaseKey, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, assertLifecycleToken, mintLifecycleUid, lifecycleNameKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
|
|
31
|
+
import { anycastSubject, assertValidRole, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, historyReaderConsumer, calloutReaderId, HISTORY_READERS, controlServiceSubject, livenessSubject, livenessServeFilter, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, MANAGER_RENEWAL_LEASE_KEY, managerLeaseKey, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, assertLifecycleToken, mintLifecycleUid, lifecycleNameKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
|
|
32
32
|
import { isPermissionDenied } from "./permission-denied.js";
|
|
33
33
|
export { isPermissionDenied };
|
|
34
34
|
export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
|
|
@@ -55,6 +55,11 @@ export const MULTI_FILTER_BATCH = 1_000;
|
|
|
55
55
|
* issuing one read per channel: a space large enough to need batches must not get the fan-out back
|
|
56
56
|
* under another name. */
|
|
57
57
|
export const MULTI_FILTER_READ_CONCURRENCY = 4;
|
|
58
|
+
/** A history reader's inactive threshold. It bounds only a reader whose delete never ran (its link
|
|
59
|
+
* dropped, or its process stopped), because every read deletes its readers when it ends. Five
|
|
60
|
+
* minutes is what the nats.js ordered consumers it replaces carried, and it keeps a reader alive
|
|
61
|
+
* across the gap between pulls on a slow link. */
|
|
62
|
+
const HISTORY_READER_INACTIVE_MS = 5 * 60_000;
|
|
58
63
|
/** A presence bucket that has refused consecutive writes for at least one full presence TTL.
|
|
59
64
|
* The condition is non-transient: ordinary heartbeat retry has already failed for the whole
|
|
60
65
|
* liveness window, so callers should report the roster as last-known until a write succeeds. */
|
|
@@ -161,6 +166,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
161
166
|
doRegister;
|
|
162
167
|
doWatch;
|
|
163
168
|
doWatchChannels;
|
|
169
|
+
readOnce;
|
|
164
170
|
doConsume;
|
|
165
171
|
ackWaitMs;
|
|
166
172
|
inactiveThresholdMs;
|
|
@@ -262,9 +268,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
262
268
|
* EXPECTED async permission violation that joinChannel turns into a clean throw, so watchStatus
|
|
263
269
|
* suppresses it rather than surfacing a spurious connection error. */
|
|
264
270
|
confirmingChatSubs = new Set();
|
|
265
|
-
/** `$JS.API.CONSUMER.DELETE.<stream>.<prefix>_` for each of this connection's own KV watches
|
|
266
|
-
* running membership scans
|
|
271
|
+
/** `$JS.API.CONSUMER.DELETE.<stream>.<prefix>_` for each of this connection's own KV watches and
|
|
272
|
+
* running membership scans (see {@link recordOwnWatchConsumer}). */
|
|
267
273
|
ownWatchDeletePrefixes = new Set();
|
|
274
|
+
/** Per stream, the history-reader names of this connection's current reader id and the reads
|
|
275
|
+
* waiting for one (see {@link acquireHistoryReader}). */
|
|
276
|
+
historyReaderSlots = new Map();
|
|
268
277
|
/** True until the first successful connect completes its boot backfill — distinguishes first-connect
|
|
269
278
|
* (backfill the boot channels' history) from a reconnect (reopen the core-subs, no re-backfill).
|
|
270
279
|
* Persists across reconnect (NOT connection-scoped). Replaces the legacy chat-durable consumed-cursor
|
|
@@ -547,6 +556,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
547
556
|
this.doWatch = opts.watchPresence ?? true;
|
|
548
557
|
this.doWatchChannels = opts.watchChannels ?? true;
|
|
549
558
|
this.doConsume = opts.consume ?? true;
|
|
559
|
+
this.readOnce = opts.readOnce ?? false;
|
|
560
|
+
if (this.readOnce && (this.doConsume || this.doRegister))
|
|
561
|
+
throw new Error("EndpointOptions.readOnce requires consume and registerPresence false: a one-time read cannot keep a consumer's channel cache or its own presence current");
|
|
550
562
|
// Seed the presence mirror so file-default channel modes are visible from the first publish
|
|
551
563
|
// (not only after the first runtime toggle). Mirror only — delivery reads the connector's state.
|
|
552
564
|
this.channelModes = opts.channelModes && Object.keys(opts.channelModes).length ? opts.channelModes : undefined;
|
|
@@ -1082,7 +1094,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1082
1094
|
? await kvm.open(presenceBucket(this.space))
|
|
1083
1095
|
: await kvm.create(presenceBucket(this.space), { ttl: this.ttlMs, storage: PRESENCE_STORAGE });
|
|
1084
1096
|
}
|
|
1085
|
-
if (this.doWatch)
|
|
1097
|
+
if (this.doWatch && this.readOnce)
|
|
1098
|
+
await this.readPresenceOnce();
|
|
1099
|
+
else if (this.doWatch) {
|
|
1086
1100
|
await this.startPresenceWatch();
|
|
1087
1101
|
this.sweepTimer = setInterval(() => this.sweep(), Math.max(500, Math.floor(this.ttlMs / 3)));
|
|
1088
1102
|
}
|
|
@@ -1095,7 +1109,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1095
1109
|
if (watchChannels || this.doConsume) {
|
|
1096
1110
|
this.channelKv = await openChannelRegistry(this.nc, this.space, { create: !this.authed });
|
|
1097
1111
|
if (watchChannels)
|
|
1098
|
-
await this.startChannelWatch();
|
|
1112
|
+
await (this.readOnce ? this.readChannelsOnce() : this.startChannelWatch());
|
|
1099
1113
|
}
|
|
1100
1114
|
// Rebind the daemon's lease-loss watch onto the fresh connection (#1596). The intent survived
|
|
1101
1115
|
// clearConnectionScoped; a bind that completes after a stop or another rebuild finds the epoch
|
|
@@ -2774,16 +2788,16 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2774
2788
|
watch.resolveStop = undefined;
|
|
2775
2789
|
watch.rejectStop = undefined;
|
|
2776
2790
|
}
|
|
2777
|
-
/** Record the consumer behind one of this connection's own KV watches
|
|
2778
|
-
*
|
|
2791
|
+
/** Record the consumer behind one of this connection's own KV watches or membership scans, and
|
|
2792
|
+
* return the delete-subject prefix it recorded. nats.js names it `<prefix>_<serial>` and,
|
|
2779
2793
|
* rebuilding it after a stall or a sequence gap, deletes the predecessor itself with no hook before
|
|
2780
2794
|
* the send, so a profile without the delete row (#691) has that delete refused. A stalled link can
|
|
2781
2795
|
* time that delete out before its refusal arrives, and nats.js then hands the refusal to no request,
|
|
2782
2796
|
* so {@link trackRequestDenials} cannot own it and the consumer's name has to. A watch's prefix
|
|
2783
2797
|
* stays recorded for the connection because a retired watch's last rebuild can be refused after
|
|
2784
|
-
* the watch has stopped; {@link readMembership}
|
|
2785
|
-
*
|
|
2786
|
-
*
|
|
2798
|
+
* the watch has stopped; {@link readMembership} retires its scan's after its own last delete,
|
|
2799
|
+
* which goes out once nats.js has stopped the consumer and so is answered after every predecessor
|
|
2800
|
+
* delete. */
|
|
2787
2801
|
recordOwnWatchConsumer({ stream_name, name }) {
|
|
2788
2802
|
const prefix = `$JS.API.CONSUMER.DELETE.${stream_name}.${name.slice(0, name.lastIndexOf("_") + 1)}`;
|
|
2789
2803
|
this.ownWatchDeletePrefixes.add(prefix);
|
|
@@ -2794,9 +2808,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2794
2808
|
const cut = subject.lastIndexOf("_") + 1;
|
|
2795
2809
|
return /^\d+$/.test(subject.slice(cut)) && this.ownWatchDeletePrefixes.has(subject.slice(0, cut));
|
|
2796
2810
|
}
|
|
2797
|
-
/** Delete one of this connection's own
|
|
2798
|
-
*
|
|
2799
|
-
*
|
|
2811
|
+
/** Delete one of this connection's own KV-watch consumers. A refused delete is the designed
|
|
2812
|
+
* outcome for a profile without a stream-wide CONSUMER.DELETE (#691): the broker reaps the
|
|
2813
|
+
* consumer at its inactive threshold. */
|
|
2800
2814
|
async deleteOwnConsumer(consumer) {
|
|
2801
2815
|
try {
|
|
2802
2816
|
await consumer.delete();
|
|
@@ -3090,8 +3104,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3090
3104
|
throw new Error(`history limit must be a whole number of messages this server can count exactly, received ${limit}`);
|
|
3091
3105
|
if (limit <= 0)
|
|
3092
3106
|
return [];
|
|
3093
|
-
const
|
|
3107
|
+
const nc = this.nc;
|
|
3108
|
+
let reader;
|
|
3094
3109
|
try {
|
|
3110
|
+
reader = await this.acquireHistoryReader(nc, this.jsm ?? (this.jsm = await jetstreamManager(nc)), stream, signal);
|
|
3095
3111
|
// THE EXACT CEILING, on the already-granted surface. A one-shot consumer with
|
|
3096
3112
|
// `DeliverPolicy.Last` plus this subject's filter reports the newest MATCHING sequence, and its
|
|
3097
3113
|
// `num_pending` of zero means the channel is genuinely empty. Using the STREAM's last sequence
|
|
@@ -3102,7 +3118,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3102
3118
|
// Deliberately NOT `getMessage({ last_by_subj })`, which would be the obvious way to ask: it
|
|
3103
3119
|
// needs `$JS.API.STREAM.MSG.GET`, which read credentials do not hold. That grant hole already
|
|
3104
3120
|
// shipped once from this function and turned every non-admin history read into an empty list.
|
|
3105
|
-
const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(
|
|
3121
|
+
const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(reader, subjects, signal);
|
|
3106
3122
|
if (ceiling < 1)
|
|
3107
3123
|
return [];
|
|
3108
3124
|
// Widen from the exact ceiling until a window holds a full page, or until the window IS the
|
|
@@ -3144,11 +3160,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3144
3160
|
for (;;) {
|
|
3145
3161
|
signal?.throwIfAborted();
|
|
3146
3162
|
const start = Math.max(floor, ceiling - span + 1);
|
|
3147
|
-
const page = await this.drainWindow(
|
|
3163
|
+
const page = await this.drainWindow(reader, subjects, start, ceiling, limit, signal);
|
|
3148
3164
|
if (page.length >= limit)
|
|
3149
3165
|
return page.slice(-limit);
|
|
3150
3166
|
if (!floorKnown) {
|
|
3151
|
-
floor = Math.max(1, await this.firstMatchingSeq(
|
|
3167
|
+
floor = Math.max(1, await this.firstMatchingSeq(reader, subjects, signal));
|
|
3152
3168
|
floorKnown = true;
|
|
3153
3169
|
}
|
|
3154
3170
|
if (start <= floor)
|
|
@@ -3178,13 +3194,175 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3178
3194
|
return [];
|
|
3179
3195
|
throw e;
|
|
3180
3196
|
}
|
|
3197
|
+
finally {
|
|
3198
|
+
if (reader)
|
|
3199
|
+
this.releaseHistoryReader(reader);
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
/** This connection's history-reader id, the key of its credential's reader deletes
|
|
3203
|
+
* ({@link historyReaderConsumer}). A static credential's is its connId, the nkey it signs with.
|
|
3204
|
+
* Under the auth callout the connId is the inbox nonce this client chose, so the callout keys the
|
|
3205
|
+
* deletes on {@link calloutReaderId} instead: the server's id and the client id it assigned this
|
|
3206
|
+
* connection, which the client reads from the server's INFO and does not choose. */
|
|
3207
|
+
historyReaderId(nc) {
|
|
3208
|
+
if (!this.userMode)
|
|
3209
|
+
return this.connId;
|
|
3210
|
+
const info = nc.info;
|
|
3211
|
+
if (!info)
|
|
3212
|
+
throw new Error("history: the server has not sent its INFO, so this connection has no history reader id yet");
|
|
3213
|
+
return calloutReaderId(info.server_id, info.client_id);
|
|
3214
|
+
}
|
|
3215
|
+
/** Take one of this connection's history-reader names on `stream` ({@link historyReaderConsumer})
|
|
3216
|
+
* for one read. The read holds the name for its whole walk and creates and deletes a consumer
|
|
3217
|
+
* under it at each step, so this connection keeps at most {@link HISTORY_READERS} reader consumers
|
|
3218
|
+
* on a stream (#4123). A read past that many waits for a name, and `signal` ends the wait. A
|
|
3219
|
+
* reconnect under the auth callout changes the reader id, and reads from then on take the new
|
|
3220
|
+
* id's names. */
|
|
3221
|
+
async acquireHistoryReader(nc, jsm, stream, signal) {
|
|
3222
|
+
for (;;) {
|
|
3223
|
+
signal?.throwIfAborted();
|
|
3224
|
+
const readerId = this.historyReaderId(nc);
|
|
3225
|
+
let pool = this.historyReaderSlots.get(stream);
|
|
3226
|
+
if (pool?.readerId !== readerId) {
|
|
3227
|
+
pool = { readerId, free: Array.from({ length: HISTORY_READERS }, (_, i) => HISTORY_READERS - 1 - i), held: 0, waiting: [], refusedUntil: 0 };
|
|
3228
|
+
this.historyReaderSlots.set(stream, pool);
|
|
3229
|
+
}
|
|
3230
|
+
// A read here would leave one more reader it may not delete, so it fails before creating one.
|
|
3231
|
+
if (pool.refusedUntil > Date.now())
|
|
3232
|
+
throw this.readerDeleteRefused(stream);
|
|
3233
|
+
const slot = pool.free.pop();
|
|
3234
|
+
if (slot !== undefined) {
|
|
3235
|
+
pool.held++;
|
|
3236
|
+
return { nc, js: jetstream(nc), jsm, stream, pool, slot, name: historyReaderConsumer(readerId, slot), held: true, left: false };
|
|
3237
|
+
}
|
|
3238
|
+
// No read here holds a name, so none will come back from one: every name is set aside (see
|
|
3239
|
+
// openHistoryReader and closeHistoryReader). Waiting would hang until those expire.
|
|
3240
|
+
if (pool.held === 0)
|
|
3241
|
+
throw new Error(`history: every history reader name this connection has on ${stream} is set aside, held by a consumer it did not create (another process using the same credential, or one that stopped mid-read) or by a reader it could not confirm deleted; they expire ${HISTORY_READER_INACTIVE_MS / 60_000} minutes after their last use`);
|
|
3242
|
+
const waitOn = pool;
|
|
3243
|
+
await new Promise((resolve, reject) => {
|
|
3244
|
+
const wake = () => { signal?.removeEventListener("abort", stop); resolve(); };
|
|
3245
|
+
const stop = () => { waitOn.waiting.splice(waitOn.waiting.indexOf(wake), 1); reject(abortReason(signal)); };
|
|
3246
|
+
waitOn.waiting.push(wake);
|
|
3247
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
3248
|
+
});
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
/** Give a read's history-reader name back and wake one read waiting for a name. A name whose
|
|
3252
|
+
* delete failed is set aside instead. */
|
|
3253
|
+
releaseHistoryReader(reader) {
|
|
3254
|
+
if (!reader.held)
|
|
3255
|
+
return;
|
|
3256
|
+
reader.held = false;
|
|
3257
|
+
const pool = reader.pool;
|
|
3258
|
+
pool.held--;
|
|
3259
|
+
if (!reader.left) {
|
|
3260
|
+
pool.free.push(reader.slot);
|
|
3261
|
+
pool.waiting.shift()?.();
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
this.setAsideHistoryReader(pool, reader.slot);
|
|
3265
|
+
// Every waiter checks again, so one that no running read can now serve fails instead of hanging.
|
|
3266
|
+
if (pool.held === 0)
|
|
3267
|
+
for (const wake of pool.waiting.splice(0))
|
|
3268
|
+
wake();
|
|
3269
|
+
}
|
|
3270
|
+
/** Keep `slot` out of use until a consumer under it would have expired, then free it. */
|
|
3271
|
+
setAsideHistoryReader(pool, slot) {
|
|
3272
|
+
setTimeout(() => { pool.free.push(slot); pool.waiting.shift()?.(); }, HISTORY_READER_INACTIVE_MS).unref();
|
|
3273
|
+
}
|
|
3274
|
+
/** Create the reader consumer for one step of a read, under the read's name. It is a plain pull
|
|
3275
|
+
* consumer: an nats.js ordered consumer renames itself on every rebuild, and the observer and admin
|
|
3276
|
+
* credentials may delete only these exact names.
|
|
3277
|
+
*
|
|
3278
|
+
* Every create carries a fresh description. NATS answers a create whose config equals the existing
|
|
3279
|
+
* consumer's with that consumer, delivery state and all (measured on nats-server 2.15), and a read
|
|
3280
|
+
* repeats the same config on the same name. Without the description a reader left under the name
|
|
3281
|
+
* would come back as if new: a `DeliverPolicy.Last` reader that already delivered its message
|
|
3282
|
+
* reports nothing pending, which reads as an empty channel. With it, a create on a name in use is
|
|
3283
|
+
* refused, and a consumer that does not carry this step's description is never used.
|
|
3284
|
+
*
|
|
3285
|
+
* A consumer already under the name is never deleted, whoever left it: another holder of this
|
|
3286
|
+
* credential, a process that stopped mid-read, or this endpoint when a delete's answer was lost and
|
|
3287
|
+
* the name was taken again since. The read moves to a free name, and the taken one stays out of use
|
|
3288
|
+
* until such a consumer would have expired. */
|
|
3289
|
+
async openHistoryReader(reader, config) {
|
|
3290
|
+
const pool = reader.pool;
|
|
3291
|
+
for (;;) {
|
|
3292
|
+
// A reconnect under the auth callout moved this connection to new reader names, and its
|
|
3293
|
+
// credential may no longer delete this one. Creating under it would leave the reader behind.
|
|
3294
|
+
if (this.historyReaderId(reader.nc) !== pool.readerId)
|
|
3295
|
+
throw new Error(`history: the connection was re-established during the read on ${reader.stream}, so its history reader names changed; the read is not served`);
|
|
3296
|
+
const description = `cotal history read ${randomUUID()}`;
|
|
3297
|
+
let info;
|
|
3298
|
+
try {
|
|
3299
|
+
info = await reader.jsm.consumers.add(reader.stream, {
|
|
3300
|
+
...config,
|
|
3301
|
+
name: reader.name,
|
|
3302
|
+
description,
|
|
3303
|
+
ack_policy: AckPolicy.None,
|
|
3304
|
+
max_deliver: 1,
|
|
3305
|
+
mem_storage: true,
|
|
3306
|
+
num_replicas: 1,
|
|
3307
|
+
inactive_threshold: nanos(HISTORY_READER_INACTIVE_MS),
|
|
3308
|
+
});
|
|
3309
|
+
}
|
|
3310
|
+
catch (e) {
|
|
3311
|
+
if (!isConsumerAlreadyExists(e))
|
|
3312
|
+
throw e;
|
|
3313
|
+
}
|
|
3314
|
+
if (info?.config.description === description)
|
|
3315
|
+
return reader.js.consumers.getConsumerFromInfo(info);
|
|
3316
|
+
this.setAsideHistoryReader(pool, reader.slot);
|
|
3317
|
+
const next = pool.free.pop();
|
|
3318
|
+
if (next === undefined) {
|
|
3319
|
+
// The read ends holding no name. Every waiter checks again, so one that no running read can
|
|
3320
|
+
// now serve fails instead of hanging.
|
|
3321
|
+
reader.held = false;
|
|
3322
|
+
pool.held--;
|
|
3323
|
+
for (const wake of pool.waiting.splice(0))
|
|
3324
|
+
wake();
|
|
3325
|
+
throw new Error(`history: reader ${reader.name} on ${reader.stream} is held by a consumer this read did not create, and no other reader name is free`);
|
|
3326
|
+
}
|
|
3327
|
+
reader.slot = next;
|
|
3328
|
+
reader.name = historyReaderConsumer(pool.readerId, next);
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
/** Delete the read's reader consumer; already gone is the same outcome. A failed delete may still
|
|
3332
|
+
* have run with its answer lost, and the name may be taken again before this endpoint could tell,
|
|
3333
|
+
* so the name is set aside when the read gives it back rather than deleted again later. A refused
|
|
3334
|
+
* delete means the credential was minted before the observer and admin profiles held their reader
|
|
3335
|
+
* deletes. Each read would then leave a reader for the inactive threshold, which is how a polling
|
|
3336
|
+
* dashboard filled the stream's consumer limit (#4123), so the read fails and says to remint
|
|
3337
|
+
* rather than leak quietly, and reads on the stream fail at once until that reader expires. */
|
|
3338
|
+
async closeHistoryReader(reader) {
|
|
3339
|
+
try {
|
|
3340
|
+
await reader.jsm.consumers.delete(reader.stream, reader.name);
|
|
3341
|
+
}
|
|
3342
|
+
catch (e) {
|
|
3343
|
+
if (isConsumerNotFound(e))
|
|
3344
|
+
return;
|
|
3345
|
+
reader.left = true;
|
|
3346
|
+
// No `cause`: a permission-shaped cause would let streamHistory read this as the admin-only
|
|
3347
|
+
// DM denial and return an empty page.
|
|
3348
|
+
if (isPublishPermissionDenied(e)) {
|
|
3349
|
+
if (this.historyReaderId(reader.nc) !== reader.pool.readerId)
|
|
3350
|
+
throw new Error(`history: the connection was re-established during the read on ${reader.stream}, so it may no longer delete its reader ${reader.name}, which expires on its own; the read is not served`);
|
|
3351
|
+
reader.pool.refusedUntil = Date.now() + HISTORY_READER_INACTIVE_MS;
|
|
3352
|
+
throw this.readerDeleteRefused(reader.stream);
|
|
3353
|
+
}
|
|
3354
|
+
throw e;
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
readerDeleteRefused(stream) {
|
|
3358
|
+
return new Error(`history: this credential may not delete its own history readers on ${stream}; it predates the observer and admin reader-delete grant, so remint it`);
|
|
3181
3359
|
}
|
|
3182
3360
|
/** The oldest stream sequence matching `subject`, or 0 when the subject has no messages.
|
|
3183
3361
|
* Mirror of {@link lastMatchingSeq}: same CREATE/INFO/NEXT/DELETE surface, `DeliverPolicy.All`
|
|
3184
3362
|
* instead of `Last`, first delivered seq instead of last. Read credentials already hold this. */
|
|
3185
|
-
async firstMatchingSeq(
|
|
3363
|
+
async firstMatchingSeq(reader, subjects, signal) {
|
|
3186
3364
|
signal?.throwIfAborted();
|
|
3187
|
-
const consumer = await
|
|
3365
|
+
const consumer = await this.openHistoryReader(reader, {
|
|
3188
3366
|
filter_subjects: subjects,
|
|
3189
3367
|
deliver_policy: DeliverPolicy.All,
|
|
3190
3368
|
});
|
|
@@ -3207,18 +3385,18 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3207
3385
|
throw new Error(`history: the broker reported messages on ${subjectLabel(subjects)} but delivered none - the read was cut short, not empty`);
|
|
3208
3386
|
}
|
|
3209
3387
|
finally {
|
|
3210
|
-
await this.
|
|
3388
|
+
await this.closeHistoryReader(reader);
|
|
3211
3389
|
}
|
|
3212
3390
|
}
|
|
3213
3391
|
/** The newest stream sequence matching `subject`, or 0 when the subject has no messages.
|
|
3214
3392
|
*
|
|
3215
|
-
* One
|
|
3393
|
+
* One reader consumer at `DeliverPolicy.Last` with this subject's filter: its `num_pending`
|
|
3216
3394
|
* (available from the create, before anything is delivered) is 0 for an empty subject, and
|
|
3217
3395
|
* otherwise one message carries the sequence. Same CREATE/INFO/NEXT/DELETE surface `drainWindow`
|
|
3218
3396
|
* already uses, so no broker authority is added. */
|
|
3219
|
-
async lastMatchingSeq(
|
|
3397
|
+
async lastMatchingSeq(reader, subjects, signal) {
|
|
3220
3398
|
signal?.throwIfAborted();
|
|
3221
|
-
const consumer = await
|
|
3399
|
+
const consumer = await this.openHistoryReader(reader, {
|
|
3222
3400
|
filter_subjects: subjects,
|
|
3223
3401
|
deliver_policy: DeliverPolicy.Last,
|
|
3224
3402
|
});
|
|
@@ -3247,19 +3425,21 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3247
3425
|
throw new Error(`history: the broker reported messages on ${subjectLabel(subjects)} but delivered none - the read was cut short, not empty`);
|
|
3248
3426
|
}
|
|
3249
3427
|
finally {
|
|
3250
|
-
await this.
|
|
3428
|
+
await this.closeHistoryReader(reader);
|
|
3251
3429
|
}
|
|
3252
3430
|
}
|
|
3253
3431
|
/** Drain every message matching `subject` with sequence in `[start, ceiling]`, oldest-first.
|
|
3254
|
-
* One
|
|
3432
|
+
* One reader consumer, one batched pull — `AckPolicy.None`, so no per-message ack
|
|
3255
3433
|
* round trip. Fetches exactly the pending count so it returns as soon as the window is
|
|
3256
3434
|
* delivered rather than blocking for the pull's full expiry. */
|
|
3257
|
-
async drainWindow(
|
|
3435
|
+
async drainWindow(reader, subjects, start, ceiling, limit, signal) {
|
|
3258
3436
|
signal?.throwIfAborted();
|
|
3259
3437
|
const out = [];
|
|
3260
|
-
const consumer = await
|
|
3261
|
-
|
|
3262
|
-
|
|
3438
|
+
const consumer = await this.openHistoryReader(reader, {
|
|
3439
|
+
filter_subjects: subjects,
|
|
3440
|
+
deliver_policy: DeliverPolicy.StartSequence,
|
|
3441
|
+
opt_start_seq: start,
|
|
3442
|
+
});
|
|
3263
3443
|
try {
|
|
3264
3444
|
// A freshly created consumer already carries its ConsumerInfo, so read the CACHED copy: the
|
|
3265
3445
|
// explicit uncached `info()` this used to call was a round trip for data we already had.
|
|
@@ -3285,6 +3465,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3285
3465
|
signal?.throwIfAborted();
|
|
3286
3466
|
for await (const m of iter) {
|
|
3287
3467
|
delivered++;
|
|
3468
|
+
// A plain consumer does not rebuild itself the way an ordered one did, so a gap in its
|
|
3469
|
+
// delivery sequence is a message this page would silently lack.
|
|
3470
|
+
if (m.info.deliverySequence !== delivered)
|
|
3471
|
+
throw new Error(`history: delivery ${m.info.deliverySequence} arrived where ${delivered} was due on ${subjectLabel(subjects)} - the window has a gap, so the page is not served`);
|
|
3288
3472
|
if (m.seq >= ceiling) { // reached the page's upper bound
|
|
3289
3473
|
if (m.seq === ceiling) {
|
|
3290
3474
|
const msg = historyMessageFromDelivery(m);
|
|
@@ -3315,16 +3499,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3315
3499
|
return out;
|
|
3316
3500
|
}
|
|
3317
3501
|
finally {
|
|
3318
|
-
// DELETE THE
|
|
3319
|
-
//
|
|
3320
|
-
//
|
|
3321
|
-
//
|
|
3322
|
-
//
|
|
3323
|
-
|
|
3324
|
-
// resulting resource exhaustion would land in streamHistory's catch and read as empty history.
|
|
3325
|
-
// The observer and admin profiles may not delete (#691), so the dashboard's readers do live out
|
|
3326
|
-
// that threshold; only a profile holding the delete row reclaims them here.
|
|
3327
|
-
await this.deleteOwnConsumer(consumer).finally(() => { this.ownWatchDeletePrefixes.delete(readerPrefix); });
|
|
3502
|
+
// DELETE THE READER. Left alone it would stay for its inactive threshold, and the widening
|
|
3503
|
+
// search can run several steps per call while a polling dashboard repeats every call on each
|
|
3504
|
+
// poll. The observer and admin profiles used to hold no delete at all (#691), so a dashboard
|
|
3505
|
+
// polling every ten seconds filled the stream's consumer limit and every later read failed
|
|
3506
|
+
// (#4123). Each reader now goes when its step ends.
|
|
3507
|
+
await this.closeHistoryReader(reader);
|
|
3328
3508
|
}
|
|
3329
3509
|
}
|
|
3330
3510
|
// ---- internals -----------------------------------------------------------
|
|
@@ -3945,12 +4125,15 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3945
4125
|
* `undefined` means the key IS NOT THERE — a definite absence, established by a completed read.
|
|
3946
4126
|
* A read that could not be completed THROWS instead, so a caller can tell "it is gone" from "I could
|
|
3947
4127
|
* not find out". That distinction is the whole point of the method: a renew that got no answer has
|
|
3948
|
-
* proved nothing, and only a definite answer here may be acted on.
|
|
4128
|
+
* proved nothing, and only a definite answer here may be acted on.
|
|
4129
|
+
*
|
|
4130
|
+
* `created` is the broker's write time of the row (epoch ms), which is what the bucket TTL counts
|
|
4131
|
+
* from. */
|
|
3949
4132
|
async readOwnManagerLease(instanceId) {
|
|
3950
4133
|
const e = await (await this.managerLeaseRegistry()).get(managerLeaseKey(instanceId));
|
|
3951
4134
|
if (!e || e.operation !== "PUT")
|
|
3952
4135
|
return undefined;
|
|
3953
|
-
return { info: JSON.parse(new TextDecoder().decode(e.value)), revision: e.revision };
|
|
4136
|
+
return { info: JSON.parse(new TextDecoder().decode(e.value)), revision: e.revision, created: e.created.getTime() };
|
|
3954
4137
|
}
|
|
3955
4138
|
/** Release THIS instance's key on clean shutdown so a same-id restart re-acquires immediately. CAS-guarded
|
|
3956
4139
|
* by `revision`: if we already LOST it (renew gap) the stored revision has moved, the conditional delete
|
|
@@ -6196,6 +6379,20 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
6196
6379
|
})().catch((e) => this.emit("error", e));
|
|
6197
6380
|
return info;
|
|
6198
6381
|
}
|
|
6382
|
+
/** {@link EndpointOptions.readOnce}: fill the roster from one read of the presence bucket. Stale
|
|
6383
|
+
* and offline records resolve as they do in a watch's initial replay. */
|
|
6384
|
+
async readPresenceOnce() {
|
|
6385
|
+
if (!this.kv)
|
|
6386
|
+
return;
|
|
6387
|
+
for (const e of await lastKvEntries(this.kv))
|
|
6388
|
+
this.handleKvEntry(e);
|
|
6389
|
+
this.presenceSnapshotPopulated = true;
|
|
6390
|
+
// No record reached the roster (an empty bucket, or only the TTL canary): nobody is present.
|
|
6391
|
+
if (this.lastPresenceWatchAt === 0)
|
|
6392
|
+
this.markPresenceBucketEmpty();
|
|
6393
|
+
else
|
|
6394
|
+
this.emitPresenceViewIfChanged();
|
|
6395
|
+
}
|
|
6199
6396
|
/**
|
|
6200
6397
|
* Replace a presence watch that has gone silent past TTL while the connection is up. The new
|
|
6201
6398
|
* ordered consumer starts from the bucket's current last-per-subject state, so a peer that is
|
|
@@ -6270,6 +6467,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
6270
6467
|
this.handleChannelEntry(e);
|
|
6271
6468
|
})().catch((e) => this.emit("error", e));
|
|
6272
6469
|
}
|
|
6470
|
+
/** {@link EndpointOptions.readOnce}: fill the channel cache from one read of the registry. */
|
|
6471
|
+
async readChannelsOnce() {
|
|
6472
|
+
if (!this.channelKv)
|
|
6473
|
+
return;
|
|
6474
|
+
for (const e of await lastKvEntries(this.channelKv))
|
|
6475
|
+
this.handleChannelEntry(e);
|
|
6476
|
+
}
|
|
6273
6477
|
handleChannelEntry(e) {
|
|
6274
6478
|
// Cleared before the decode, so an entry that does not decode resolves to the default as the
|
|
6275
6479
|
// fresh registry reads do.
|