@cotal-ai/core 0.115.4 → 0.116.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channels.d.ts +18 -2
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +39 -18
- package/dist/channels.js.map +1 -1
- package/dist/endpoint-action.d.ts +2 -1
- package/dist/endpoint-action.d.ts.map +1 -1
- package/dist/endpoint-action.js +9 -6
- package/dist/endpoint-action.js.map +1 -1
- package/dist/endpoint-checkpoint.d.ts.map +1 -1
- package/dist/endpoint-checkpoint.js +5 -2
- package/dist/endpoint-checkpoint.js.map +1 -1
- package/dist/endpoint-contract-store.d.ts.map +1 -1
- package/dist/endpoint-contract-store.js +2 -1
- package/dist/endpoint-contract-store.js.map +1 -1
- package/dist/endpoint-receipt.d.ts.map +1 -1
- package/dist/endpoint-receipt.js +2 -2
- package/dist/endpoint-receipt.js.map +1 -1
- package/dist/endpoint-work.d.ts.map +1 -1
- package/dist/endpoint-work.js +3 -5
- package/dist/endpoint-work.js.map +1 -1
- package/dist/endpoint.d.ts +51 -14
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +202 -78
- package/dist/endpoint.js.map +1 -1
- package/dist/manager-caller.d.ts +1 -0
- package/dist/manager-caller.d.ts.map +1 -1
- package/dist/manager-caller.js +3 -1
- package/dist/manager-caller.js.map +1 -1
- package/dist/peer-label.d.ts +11 -0
- package/dist/peer-label.d.ts.map +1 -1
- package/dist/peer-label.js +11 -0
- package/dist/peer-label.js.map +1 -1
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +12 -0
- package/dist/provision.js.map +1 -1
- package/dist/resolve.d.ts +1 -0
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +3 -1
- package/dist/resolve.js.map +1 -1
- package/dist/run-driver-grants.d.ts.map +1 -1
- package/dist/run-driver-grants.js +4 -1
- package/dist/run-driver-grants.js.map +1 -1
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +2 -2
- package/dist/streams.js.map +1 -1
- package/dist/types.d.ts +20 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/endpoint.js
CHANGED
|
@@ -162,6 +162,31 @@ export function fitHistoryPage(items, budget) {
|
|
|
162
162
|
}
|
|
163
163
|
return first === items.length ? [] : items.slice(first);
|
|
164
164
|
}
|
|
165
|
+
/** One feed entry, decoded the way {@link CotalEndpoint.readMembership} decodes it: the reserved
|
|
166
|
+
* heartbeat key carries `asOf`, every other key is one agent's record, and an undecodable record
|
|
167
|
+
* is a member change with no record (never a crash in the consumer callback). */
|
|
168
|
+
function membershipChange(e, update) {
|
|
169
|
+
const operation = e.operation;
|
|
170
|
+
if (e.key === MEMBERSHIP_FEED_KEY) {
|
|
171
|
+
let asOf;
|
|
172
|
+
if (operation === "PUT") {
|
|
173
|
+
try {
|
|
174
|
+
asOf = e.json().observedAt;
|
|
175
|
+
}
|
|
176
|
+
catch { /* heartbeat garbled; leave undefined */ }
|
|
177
|
+
}
|
|
178
|
+
return { type: "heartbeat", revision: e.revision, update, asOf };
|
|
179
|
+
}
|
|
180
|
+
let member;
|
|
181
|
+
if (operation === "PUT") {
|
|
182
|
+
try {
|
|
183
|
+
const rec = e.json();
|
|
184
|
+
member = { id: e.key, live: rec.live ?? [], durable: rec.durable ?? [], observedAt: rec.observedAt };
|
|
185
|
+
}
|
|
186
|
+
catch { /* undecodable: delivered as a change with no record */ }
|
|
187
|
+
}
|
|
188
|
+
return { type: "member", id: e.key, operation, revision: e.revision, update, member };
|
|
189
|
+
}
|
|
165
190
|
export class CotalEndpoint extends EventEmitter {
|
|
166
191
|
card;
|
|
167
192
|
space;
|
|
@@ -226,6 +251,15 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
226
251
|
* `DrainingConnectionError` out of `reset()` on a timer nothing awaits. */
|
|
227
252
|
presenceWatchIter;
|
|
228
253
|
channelWatchIter;
|
|
254
|
+
/** Consumer deletes of retired presence and channel-registry watches still in flight. A stop
|
|
255
|
+
* alone leaves each watch's ordered consumer on the broker for its five-minute inactive
|
|
256
|
+
* threshold (#3914), so {@link stop} and a live-epoch rebuild await these before they close the
|
|
257
|
+
* connection that carries them, as they do for the lease watch. */
|
|
258
|
+
kvWatchDeletes = new Set();
|
|
259
|
+
/** Presence and channel-registry watch binds still awaiting the broker. One that lands after
|
|
260
|
+
* {@link stop} or a rebuild moved the epoch retires its watch and starts that delete itself, so
|
|
261
|
+
* the same two paths wait for these first. */
|
|
262
|
+
kvWatchBinds = new Set();
|
|
229
263
|
/** The daemon's lease-loss watch (#1596): the INTENT that survives a connection rebuild, plus the
|
|
230
264
|
* live iterator. See {@link watchDeliveryLease}. */
|
|
231
265
|
leaseWatchIntent;
|
|
@@ -1413,19 +1447,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1413
1447
|
this.streamMsgs.length = 0;
|
|
1414
1448
|
this.presenceEpoch++;
|
|
1415
1449
|
this.presenceRebind = undefined;
|
|
1416
|
-
|
|
1417
|
-
this.presenceWatchIter
|
|
1418
|
-
}
|
|
1419
|
-
catch {
|
|
1420
|
-
/* already closed with the connection */
|
|
1421
|
-
}
|
|
1450
|
+
if (this.presenceWatchIter)
|
|
1451
|
+
this.retireKvWatch(this.presenceWatchIter);
|
|
1422
1452
|
this.presenceWatchIter = undefined;
|
|
1423
|
-
|
|
1424
|
-
this.channelWatchIter
|
|
1425
|
-
}
|
|
1426
|
-
catch {
|
|
1427
|
-
/* already closed with the connection */
|
|
1428
|
-
}
|
|
1453
|
+
if (this.channelWatchIter)
|
|
1454
|
+
this.retireKvWatch(this.channelWatchIter);
|
|
1429
1455
|
this.channelWatchIter = undefined;
|
|
1430
1456
|
this.stopDeliveryLeaseWatch();
|
|
1431
1457
|
for (const sub of this.chatSubs.values()) {
|
|
@@ -1635,9 +1661,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1635
1661
|
try {
|
|
1636
1662
|
this.clearConnectionScoped();
|
|
1637
1663
|
// Manual reconnect still has a live old epoch: complete broker-consumer cleanup before close.
|
|
1638
|
-
// Terminal self-heal has an already-closed epoch: disarm retains stream/name for fresh cleanup
|
|
1664
|
+
// Terminal self-heal has an already-closed epoch: disarm retains stream/name for fresh cleanup,
|
|
1665
|
+
// and the KV watch consumers that closed with it are left to the broker's inactive threshold.
|
|
1639
1666
|
if (oldNc && !oldNc.isClosed())
|
|
1640
|
-
await Promise.all([...[...this.membershipFeedWatches].map((watch) => watch.arm), this.settleDeliveryLeaseWatches()]);
|
|
1667
|
+
await Promise.all([...[...this.membershipFeedWatches].map((watch) => watch.arm), this.settleDeliveryLeaseWatches(), this.settleKvWatches()]);
|
|
1641
1668
|
this.nc = undefined;
|
|
1642
1669
|
this.js = undefined;
|
|
1643
1670
|
this.jsm = undefined;
|
|
@@ -1818,19 +1845,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1818
1845
|
/* already closed */
|
|
1819
1846
|
}
|
|
1820
1847
|
}
|
|
1821
|
-
|
|
1822
|
-
this.presenceWatchIter
|
|
1823
|
-
}
|
|
1824
|
-
catch {
|
|
1825
|
-
/* already closed */
|
|
1826
|
-
}
|
|
1848
|
+
if (this.presenceWatchIter)
|
|
1849
|
+
this.retireKvWatch(this.presenceWatchIter);
|
|
1827
1850
|
this.presenceWatchIter = undefined;
|
|
1828
|
-
|
|
1829
|
-
this.channelWatchIter
|
|
1830
|
-
}
|
|
1831
|
-
catch {
|
|
1832
|
-
/* already closed */
|
|
1833
|
-
}
|
|
1851
|
+
if (this.channelWatchIter)
|
|
1852
|
+
this.retireKvWatch(this.channelWatchIter);
|
|
1834
1853
|
this.channelWatchIter = undefined;
|
|
1835
1854
|
this.stopDeliveryLeaseWatch();
|
|
1836
1855
|
try {
|
|
@@ -1842,7 +1861,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1842
1861
|
catch {
|
|
1843
1862
|
/* best-effort graceful leave */
|
|
1844
1863
|
}
|
|
1845
|
-
await this.settleDeliveryLeaseWatches();
|
|
1864
|
+
await Promise.all([this.settleDeliveryLeaseWatches(), this.settleKvWatches()]);
|
|
1846
1865
|
// DM-only: the pull above is stopped, so no new copy reaches the handler. Let a running handler
|
|
1847
1866
|
// settle while the connection is still open, so its ack can reach the broker; close either way.
|
|
1848
1867
|
let dmOnlyUnsettled = false;
|
|
@@ -2320,18 +2339,20 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2320
2339
|
}
|
|
2321
2340
|
}
|
|
2322
2341
|
/** The resolved, digest-verified command surface of `endpoint` as this connection's caller sees
|
|
2323
|
-
* it
|
|
2324
|
-
*
|
|
2342
|
+
* it. Unpinned calls share {@link invokeService}'s per-name cache, and `refresh` drops that entry.
|
|
2343
|
+
* `instanceId` selects one instance without reading or populating the class cache, as a pinned
|
|
2344
|
+
* invoke does. The caller's existing credential must permit that route. */
|
|
2325
2345
|
async describeService(endpoint, opts = {}) {
|
|
2326
2346
|
if (!this.nc)
|
|
2327
2347
|
throw new Error(this.notLiveMsg());
|
|
2328
2348
|
if (opts.refresh)
|
|
2329
2349
|
this.resolvedServices.delete(endpoint);
|
|
2330
|
-
const cached = this.resolvedServices.get(endpoint);
|
|
2350
|
+
const cached = opts.instanceId === undefined ? this.resolvedServices.get(endpoint) : undefined;
|
|
2331
2351
|
if (cached)
|
|
2332
2352
|
return cached;
|
|
2333
|
-
const svc = await resolveService(this.nc, this.space, endpoint, this.serviceCaller(), { deadlineMs: opts.deadlineMs ?? 10_000, signal: opts.signal });
|
|
2334
|
-
|
|
2353
|
+
const svc = await resolveService(this.nc, this.space, endpoint, this.serviceCaller(), { deadlineMs: opts.deadlineMs ?? 10_000, signal: opts.signal, ...(opts.instanceId !== undefined ? { instanceId: opts.instanceId } : {}) });
|
|
2354
|
+
if (opts.instanceId === undefined)
|
|
2355
|
+
this.resolvedServices.set(endpoint, svc);
|
|
2335
2356
|
return svc;
|
|
2336
2357
|
}
|
|
2337
2358
|
/** GENERIC v0.4 service invoke over this endpoint's own connection: resolve
|
|
@@ -3016,19 +3037,20 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3016
3037
|
const kv = await this.membershipFeedRegistry();
|
|
3017
3038
|
const members = [];
|
|
3018
3039
|
let asOf;
|
|
3019
|
-
//
|
|
3020
|
-
//
|
|
3021
|
-
//
|
|
3022
|
-
//
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
if (
|
|
3030
|
-
|
|
3031
|
-
|
|
3040
|
+
// Paged batched Direct Gets, as the on-demand presence read: a few round trips regardless of N,
|
|
3041
|
+
// a short page refused rather than reported as the whole feed, and no consumer bound. The scan
|
|
3042
|
+
// this replaced bound an ordered consumer per read whose delete the observer and admin profiles
|
|
3043
|
+
// may not send (#691), so every dashboard poll left a consumer to the broker's inactive threshold
|
|
3044
|
+
// and a refused `CONSUMER.DELETE` line in its log (#4123).
|
|
3045
|
+
let entries;
|
|
3046
|
+
try {
|
|
3047
|
+
entries = await lastKvEntries(kv);
|
|
3048
|
+
}
|
|
3049
|
+
catch (e) {
|
|
3050
|
+
if (!isPublishPermissionDenied(e))
|
|
3051
|
+
throw e;
|
|
3052
|
+
throw new Error(`membership read refused for ${this.card.id}: this credential may not publish $JS.API.DIRECT.GET.KV_${membershipBucket(this.space)}, the membership feed read (SPEC Appendix B). A credential minted by an older issuer can lack this grant even when newly minted; upgrade the manager or auth service that issues credentials, then mint a new credential. Reusing credentials or reconnecting does not add the grant.`, { cause: e });
|
|
3053
|
+
}
|
|
3032
3054
|
for (const e of entries) {
|
|
3033
3055
|
if (e.key === MEMBERSHIP_FEED_KEY) {
|
|
3034
3056
|
try {
|
|
@@ -3045,10 +3067,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3045
3067
|
}
|
|
3046
3068
|
return { asOf, members };
|
|
3047
3069
|
}
|
|
3048
|
-
/** Watch the membership feed for changes (admin/observer): `onChange`
|
|
3049
|
-
*
|
|
3050
|
-
*
|
|
3051
|
-
*
|
|
3070
|
+
/** Watch the membership feed for changes (admin/observer): `onChange` receives every KV entry as a
|
|
3071
|
+
* {@link MembershipChange} — the decoded member record (or its deletion), or the feed heartbeat's
|
|
3072
|
+
* `asOf` — so a caller keeps its snapshot current without re-reading the bucket; the entries replayed
|
|
3073
|
+
* when the watch (re)arms carry `update: false` and end with a `replayed` change. The async stop
|
|
3074
|
+
* handle resolves only after its ordered broker consumer is deleted. Best-effort: a feed the cred
|
|
3075
|
+
* can't read (or absent) surfaces as an `error` event and the dashboard keeps its last snapshot. */
|
|
3052
3076
|
async watchMembership(onChange, onClosed) {
|
|
3053
3077
|
if (this.stopped)
|
|
3054
3078
|
throw new Error("endpoint stopped - cannot watch membership");
|
|
@@ -3102,11 +3126,20 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3102
3126
|
watch.consumer = consumer;
|
|
3103
3127
|
watch.consumerStream = info.stream_name;
|
|
3104
3128
|
watch.consumerName = info.name;
|
|
3129
|
+
// The replay is the bucket's last value per key, bound at `info`; it ends with the entry that
|
|
3130
|
+
// leaves nothing pending (the same rule as nats.js `watch()`'s `initializedFn`).
|
|
3131
|
+
let replaying = info.num_pending > 0;
|
|
3105
3132
|
let iter;
|
|
3106
3133
|
try {
|
|
3107
|
-
iter = await consumer.consume({ callback: () => {
|
|
3108
|
-
if (
|
|
3109
|
-
|
|
3134
|
+
iter = await consumer.consume({ callback: (jm) => {
|
|
3135
|
+
if (watch.stopped || watch.consumer !== consumer)
|
|
3136
|
+
return;
|
|
3137
|
+
const entry = kv.jmToWatchEntry(jm, !replaying);
|
|
3138
|
+
watch.onChange(membershipChange(entry, !replaying));
|
|
3139
|
+
if (replaying && jm.info.pending === 0) {
|
|
3140
|
+
replaying = false;
|
|
3141
|
+
watch.onChange({ type: "replayed", revision: entry.revision });
|
|
3142
|
+
}
|
|
3110
3143
|
} });
|
|
3111
3144
|
}
|
|
3112
3145
|
catch (err) {
|
|
@@ -3118,6 +3151,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3118
3151
|
await this.disarmMembershipWatch(watch);
|
|
3119
3152
|
return;
|
|
3120
3153
|
}
|
|
3154
|
+
if (!replaying)
|
|
3155
|
+
watch.onChange({ type: "replayed", revision: 0 }); // an empty feed replays nothing
|
|
3121
3156
|
iter.closed().then(() => {
|
|
3122
3157
|
if (watch.stopped || watch.consumer !== consumer)
|
|
3123
3158
|
return;
|
|
@@ -4363,6 +4398,52 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4363
4398
|
await Promise.allSettled(this.leaseWatchBinds);
|
|
4364
4399
|
await Promise.all(this.leaseWatchDeletes);
|
|
4365
4400
|
}
|
|
4401
|
+
/** Stop a presence or channel-registry watch and delete its ordered consumer, which the stop
|
|
4402
|
+
* alone leaves on the broker until its five-minute inactive threshold (#3914). A connection
|
|
4403
|
+
* that closed on its own cannot carry the delete, so that consumer is left to the threshold. A
|
|
4404
|
+
* failed delete is a warning: the broker still reaps the consumer, and a rejected {@link stop}
|
|
4405
|
+
* would read as unproved consumer custody. */
|
|
4406
|
+
retireKvWatch(iter) {
|
|
4407
|
+
try {
|
|
4408
|
+
iter.stop();
|
|
4409
|
+
}
|
|
4410
|
+
catch { /* already closed with its connection */ }
|
|
4411
|
+
if (!this.nc || this.nc.isClosed())
|
|
4412
|
+
return;
|
|
4413
|
+
const deleting = this.deleteOwnConsumer(kvWatchConsumer(iter)).catch((e) => this.emitRecoverable(new Error(`a retired KV watch consumer was not deleted (${e.message}); the broker reaps it at its inactive threshold`)));
|
|
4414
|
+
this.kvWatchDeletes.add(deleting);
|
|
4415
|
+
void deleting.then(() => this.kvWatchDeletes.delete(deleting));
|
|
4416
|
+
}
|
|
4417
|
+
/** Wait for the presence and channel-registry watch binds still in flight, then for every retired
|
|
4418
|
+
* watch's consumer delete. {@link stop} and a live-epoch rebuild run this before they close the
|
|
4419
|
+
* connection those deletes need (#3914). */
|
|
4420
|
+
async settleKvWatches() {
|
|
4421
|
+
await Promise.allSettled(this.kvWatchBinds);
|
|
4422
|
+
await Promise.all(this.kvWatchDeletes);
|
|
4423
|
+
}
|
|
4424
|
+
/** Bind a watch on `kv` for the current epoch and record its consumer as this connection's own.
|
|
4425
|
+
* A bind that {@link stop} or a rebuild overtook retires its late watch, consumer delete
|
|
4426
|
+
* included, and resolves undefined: the epoch that asked for it is gone, and the one that
|
|
4427
|
+
* replaced it binds its own through {@link connectAndBind}. A stopped endpoint binds nothing,
|
|
4428
|
+
* because its teardown may already have settled the flights it waits for. */
|
|
4429
|
+
bindKvWatch(kv) {
|
|
4430
|
+
if (this.stopped)
|
|
4431
|
+
return Promise.resolve(undefined);
|
|
4432
|
+
const epoch = this.presenceEpoch;
|
|
4433
|
+
const binding = (async () => {
|
|
4434
|
+
const iter = await kv.watch();
|
|
4435
|
+
const info = await kvWatchConsumer(iter).info(true);
|
|
4436
|
+
this.recordOwnWatchConsumer(info);
|
|
4437
|
+
if (epoch === this.presenceEpoch && !this.stopped)
|
|
4438
|
+
return { iter, info };
|
|
4439
|
+
this.retireKvWatch(iter);
|
|
4440
|
+
return undefined;
|
|
4441
|
+
})();
|
|
4442
|
+
const settled = () => { this.kvWatchBinds.delete(binding); };
|
|
4443
|
+
this.kvWatchBinds.add(binding);
|
|
4444
|
+
void binding.then(settled, settled);
|
|
4445
|
+
return binding;
|
|
4446
|
+
}
|
|
4366
4447
|
/** Bind the lease watch on the current connection. A bind that lands after a newer watch replaced
|
|
4367
4448
|
* its intent, or after a stop or connection teardown moved the epoch, retires its watch instead of
|
|
4368
4449
|
* installing it: the epoch it was bound for is gone, and a rebuilt one binds its own. */
|
|
@@ -5021,6 +5102,18 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5021
5102
|
return { ok: false, error: e.message };
|
|
5022
5103
|
}
|
|
5023
5104
|
}
|
|
5105
|
+
/** Whether `lifecycleUid`'s exact ACL row still allows reads, so an inventory of it came from a
|
|
5106
|
+
* caller that did not fence. A row narrowed to no reads, or retired or absent, is fenced. A read
|
|
5107
|
+
* that fails answers true: the caller keeps its in-memory mark, which only ever refuses. */
|
|
5108
|
+
async lifecycleAclUnfenced(principal, lifecycleUid) {
|
|
5109
|
+
try {
|
|
5110
|
+
const row = await readAcl(await this.aclRegistry(), principal, lifecycleUid);
|
|
5111
|
+
return row !== undefined && row.record.allowSubscribe.length > 0;
|
|
5112
|
+
}
|
|
5113
|
+
catch {
|
|
5114
|
+
return true;
|
|
5115
|
+
}
|
|
5116
|
+
}
|
|
5024
5117
|
/** Run one durable join or leave that writes `principal`'s rows of `lifecycleUid`, refused once that
|
|
5025
5118
|
* lifecycle's inventory has been asked for ({@link retiringLifecycles}). The check and the
|
|
5026
5119
|
* registration have no await between them, so an inventory either finds this write running and
|
|
@@ -5643,7 +5736,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5643
5736
|
for (const earlier of [...marked])
|
|
5644
5737
|
if (earlier !== uid && await aclRetired(await this.aclRegistry(), principal, earlier).catch(() => false))
|
|
5645
5738
|
marked.delete(earlier);
|
|
5646
|
-
|
|
5739
|
+
// A mark is kept only for an inventory whose caller did not fence (#4996): a lifecycle whose ACL
|
|
5740
|
+
// row is narrowed to no reads, or already retired, needs none, since every join re-reads that
|
|
5741
|
+
// row around its write. A read that fails proves nothing, so the mark is kept, which only refuses.
|
|
5742
|
+
if (await this.lifecycleAclUnfenced(principal, uid))
|
|
5743
|
+
marked.add(uid);
|
|
5744
|
+
else if (marked.size === 0)
|
|
5745
|
+
this.retiringLifecycles.delete(principal);
|
|
5647
5746
|
await Promise.allSettled(this.membershipWrites.get(`${principal}/${uid}`) ?? []);
|
|
5648
5747
|
try {
|
|
5649
5748
|
const channels = await listLifecycleMemberChannels(await this.membersRegistry(), principal, uid);
|
|
@@ -5859,6 +5958,20 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5859
5958
|
})().catch((e) => { if (!this.stopped)
|
|
5860
5959
|
this.emit("error", e); });
|
|
5861
5960
|
}
|
|
5961
|
+
/** Remove a retired lifecycle's member row for `channel`, when one exists. Best effort: the row
|
|
5962
|
+
* authorizes nothing (its ACL row is retired), and the next entry fan-out writes against it
|
|
5963
|
+
* brings the reader back here. */
|
|
5964
|
+
async removeRetiredMemberRow(channel, owner, lifecycleUid) {
|
|
5965
|
+
try {
|
|
5966
|
+
const kv = await this.membersRegistry();
|
|
5967
|
+
if (await readMember(kv, channel, owner, lifecycleUid) === undefined)
|
|
5968
|
+
return;
|
|
5969
|
+
await deleteMember(kv, channel, owner, lifecycleUid);
|
|
5970
|
+
}
|
|
5971
|
+
catch (e) {
|
|
5972
|
+
this.emit("warning", new Error(`plane-3 reader: could not remove retired lifecycle ${owner}.${lifecycleUid}'s "${channel}" member row: ${e.message}`));
|
|
5973
|
+
}
|
|
5974
|
+
}
|
|
5862
5975
|
/** Re-authorize ONE mixed-inbox entry and transfer it to the owner's DELIVER store. Deny (drop) on a
|
|
5863
5976
|
* revoked/narrowed ACL or out-of-interval seq; on transfer success, ack the mixed entry (durability
|
|
5864
5977
|
* has moved to DLV — an §8 equivalent per-member at-least-once mechanism). The agent acks DLV. */
|
|
@@ -5924,6 +6037,15 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5924
6037
|
m.nak(2000);
|
|
5925
6038
|
return;
|
|
5926
6039
|
}
|
|
6040
|
+
// The member row that addressed this entry is one a daemon left behind when it died between
|
|
6041
|
+
// its create and its post-write ACL read (#4994). It authorizes nothing, but fan-out copies
|
|
6042
|
+
// every later message on the channel into this inbox until it goes, and this reader then
|
|
6043
|
+
// removes each copy. Remove the row where that cost is paid, by its exact key, only when it
|
|
6044
|
+
// exists: a purge of an absent key would write a marker per dropped entry.
|
|
6045
|
+
if (entry.reason === "durable-channel")
|
|
6046
|
+
await this.removeRetiredMemberRow(entry.channel, owner, pr.lifecycleUid);
|
|
6047
|
+
if (!this.plane3MayAct())
|
|
6048
|
+
return;
|
|
5927
6049
|
m.ack();
|
|
5928
6050
|
return;
|
|
5929
6051
|
}
|
|
@@ -6991,28 +7113,22 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
6991
7113
|
}
|
|
6992
7114
|
/** Bind a presence watch on the current connection. Resolves the bound consumer's info when
|
|
6993
7115
|
* the watch was installed, undefined when the endpoint stopped or rebuilt while the bind was
|
|
6994
|
-
* in flight: that bind's
|
|
6995
|
-
* epoch that asked for it is gone and the epoch that replaced it binds its own
|
|
6996
|
-
* {@link connectAndBind}. Without this fence a bind that completes after
|
|
6997
|
-
* resurrect a watch on a stopped endpoint, and one that completes after a
|
|
6998
|
-
* overwrite the fresh epoch's watch with a dead-connection iterator. */
|
|
7116
|
+
* in flight: that bind's watch is retired by {@link bindKvWatch} and nothing is installed,
|
|
7117
|
+
* because the epoch that asked for it is gone and the epoch that replaced it binds its own
|
|
7118
|
+
* watch through {@link connectAndBind}. Without this fence a bind that completes after
|
|
7119
|
+
* {@link stop} would resurrect a watch on a stopped endpoint, and one that completes after a
|
|
7120
|
+
* rebuild would overwrite the fresh epoch's watch with a dead-connection iterator. */
|
|
6999
7121
|
async startPresenceWatch() {
|
|
7000
7122
|
if (!this.kv)
|
|
7001
7123
|
return undefined;
|
|
7002
|
-
const epoch = this.presenceEpoch;
|
|
7003
7124
|
let hydrated;
|
|
7004
7125
|
this.presenceSnapshot = new Promise((resolve) => { hydrated = resolve; });
|
|
7005
|
-
const
|
|
7006
|
-
|
|
7007
|
-
this.recordOwnWatchConsumer(info);
|
|
7008
|
-
if (epoch !== this.presenceEpoch) {
|
|
7009
|
-
try {
|
|
7010
|
-
iter.stop();
|
|
7011
|
-
}
|
|
7012
|
-
catch { /* its connection may already be gone */ }
|
|
7126
|
+
const bound = await this.bindKvWatch(this.kv);
|
|
7127
|
+
if (!bound) {
|
|
7013
7128
|
hydrated();
|
|
7014
7129
|
return undefined;
|
|
7015
7130
|
}
|
|
7131
|
+
const { iter, info } = bound;
|
|
7016
7132
|
this.presenceWatchIter = iter;
|
|
7017
7133
|
// A bucket with no keys replays nothing, so no entry carries the end-of-replay marker. For an
|
|
7018
7134
|
// observer that does not register, that empty replay is the whole snapshot: nobody is present.
|
|
@@ -7064,7 +7180,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
7064
7180
|
* next sweep exactly as if the watch had never stalled. Rate-limited to one attempt per TTL
|
|
7065
7181
|
* per observer, never overlapping, never on a stopped or rebuilding endpoint (those own their
|
|
7066
7182
|
* watch through {@link connectAndBind}). A stop or rebuild that lands while the bind is in
|
|
7067
|
-
* flight retires it: {@link startPresenceWatch}
|
|
7183
|
+
* flight retires it: {@link startPresenceWatch} retires the late watch and reports
|
|
7068
7184
|
* nothing, since the epoch that was silent no longer exists. A failed bind is reported and
|
|
7069
7185
|
* the view stays stale.
|
|
7070
7186
|
*/
|
|
@@ -7084,17 +7200,15 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
7084
7200
|
// watch is the one that recovers by itself, and its replay is still guarded against
|
|
7085
7201
|
// expired PUTs. Only a successfully bound watch retires its predecessor.
|
|
7086
7202
|
const info = await this.startPresenceWatch();
|
|
7087
|
-
// Retired mid-bind (stop or rebuild moved the epoch): the late
|
|
7088
|
-
//
|
|
7203
|
+
// Retired mid-bind (stop or rebuild moved the epoch): the late watch is already
|
|
7204
|
+
// retired and the old watch was torn down by whoever moved the epoch. Nothing to
|
|
7089
7205
|
// retire, nothing to report.
|
|
7090
7206
|
if (!info)
|
|
7091
7207
|
return;
|
|
7092
|
-
|
|
7093
|
-
|
|
7094
|
-
|
|
7095
|
-
|
|
7096
|
-
catch { /* already closed with its consumer */ }
|
|
7097
|
-
}
|
|
7208
|
+
// The predecessor's consumer goes with it: left alone, every rebind of a silent bucket
|
|
7209
|
+
// would add one idle consumer to the presence stream per TTL (#3914).
|
|
7210
|
+
if (old && old !== this.presenceWatchIter)
|
|
7211
|
+
this.retireKvWatch(old);
|
|
7098
7212
|
// A bucket with no keys replays nothing, so the new watch cannot refresh
|
|
7099
7213
|
// `lastPresenceWatchAt` by delivering. It IS current knowledge: nobody is present. The
|
|
7100
7214
|
// bind's consumer info carries the initial pending count for that one fact; nats.js's KV
|
|
@@ -7123,8 +7237,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
7123
7237
|
async startChannelWatch() {
|
|
7124
7238
|
if (!this.channelKv)
|
|
7125
7239
|
return;
|
|
7126
|
-
const
|
|
7127
|
-
|
|
7240
|
+
const bound = await this.bindKvWatch(this.channelKv);
|
|
7241
|
+
if (!bound)
|
|
7242
|
+
return;
|
|
7243
|
+
const { iter } = bound;
|
|
7128
7244
|
this.channelWatchIter = iter;
|
|
7129
7245
|
void (async () => {
|
|
7130
7246
|
for await (const e of iter)
|
|
@@ -7408,6 +7524,8 @@ function authenticatedMessage(msg, parsed) {
|
|
|
7408
7524
|
return authenticatedChannelMessage(msg, parsed.rest);
|
|
7409
7525
|
if (parsed.kind === "inst")
|
|
7410
7526
|
return authenticatedDmMessage(msg, parsed.rest);
|
|
7527
|
+
if (parsed.kind === "svc")
|
|
7528
|
+
return authenticatedAnycastMessage(msg, parsed.rest);
|
|
7411
7529
|
return msg;
|
|
7412
7530
|
}
|
|
7413
7531
|
function authenticatedChannelMessage(msg, channel) {
|
|
@@ -7422,6 +7540,12 @@ function authenticatedDmMessage(msg, to) {
|
|
|
7422
7540
|
const { channel: _channel, toService: _toService, ...base } = msg;
|
|
7423
7541
|
return { ...base, to };
|
|
7424
7542
|
}
|
|
7543
|
+
function authenticatedAnycastMessage(msg, toService) {
|
|
7544
|
+
if (msg.toService === toService && msg.to === undefined && msg.channel === undefined)
|
|
7545
|
+
return msg;
|
|
7546
|
+
const { to: _to, channel: _channel, ...base } = msg;
|
|
7547
|
+
return { ...base, toService };
|
|
7548
|
+
}
|
|
7425
7549
|
/** The ack wait the broker reports for a bound consumer: how long a copy it delivers stays with
|
|
7426
7550
|
* its reader without an ack. */
|
|
7427
7551
|
async function ackWaitOf(consumer) {
|