@cotal-ai/core 0.49.0 → 0.50.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/endpoint-envelope.d.ts +1 -1
- package/dist/endpoint-envelope.d.ts.map +1 -1
- package/dist/endpoint-envelope.js +1 -1
- package/dist/endpoint-envelope.js.map +1 -1
- package/dist/endpoint-error.d.ts +10 -1
- package/dist/endpoint-error.d.ts.map +1 -1
- package/dist/endpoint-error.js +12 -0
- package/dist/endpoint-error.js.map +1 -1
- package/dist/endpoint-invoke.d.ts.map +1 -1
- package/dist/endpoint-invoke.js +7 -2
- package/dist/endpoint-invoke.js.map +1 -1
- package/dist/endpoint-verbs.js +1 -1
- package/dist/endpoint-verbs.js.map +1 -1
- package/dist/endpoint.d.ts +156 -5
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +363 -16
- package/dist/endpoint.js.map +1 -1
- package/dist/lease.d.ts +22 -0
- package/dist/lease.d.ts.map +1 -1
- package/dist/lease.js +14 -1
- package/dist/lease.js.map +1 -1
- package/dist/membership-feed.d.ts +24 -1
- package/dist/membership-feed.d.ts.map +1 -1
- package/dist/membership-feed.js +61 -3
- package/dist/membership-feed.js.map +1 -1
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +12 -1
- package/dist/provision.js.map +1 -1
- package/dist/run-driver-grants.d.ts +10 -0
- package/dist/run-driver-grants.d.ts.map +1 -1
- package/dist/run-driver-grants.js +14 -0
- package/dist/run-driver-grants.js.map +1 -1
- package/dist/run-host.d.ts +2 -1
- package/dist/run-host.d.ts.map +1 -1
- package/dist/run-journal.d.ts +4 -0
- package/dist/run-journal.d.ts.map +1 -1
- package/dist/run-journal.js +80 -17
- package/dist/run-journal.js.map +1 -1
- package/dist/secret-store.d.ts +11 -5
- package/dist/secret-store.d.ts.map +1 -1
- package/dist/secret-store.js +16 -9
- package/dist/secret-store.js.map +1 -1
- package/dist/subjects.d.ts +8 -0
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +8 -0
- package/dist/subjects.js.map +1 -1
- package/package.json +1 -1
package/dist/endpoint.js
CHANGED
|
@@ -23,7 +23,7 @@ import { openMembersRegistry, commitMember, tombstoneMember, activateMember, rea
|
|
|
23
23
|
import { openAclRegistry, readAcl, readAclForAlias, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
|
|
24
24
|
import { openDeliveryRegistry } from "./lease.js";
|
|
25
25
|
import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, } from "./channels.js";
|
|
26
|
-
import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_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";
|
|
26
|
+
import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, 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";
|
|
27
27
|
export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
|
|
28
28
|
const PLANE3_FRAME_HEADER = "Cotal-Delivery-Frame";
|
|
29
29
|
/** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
|
|
@@ -147,6 +147,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
147
147
|
aclKv;
|
|
148
148
|
deliveryKv;
|
|
149
149
|
managerLeaseKv;
|
|
150
|
+
/** Our revision of the per-space daemon-credential renewal lease (#1634), or undefined when we do
|
|
151
|
+
* not hold it. Cleared with the bound KV handle: a revision from a dead connection is not a lease
|
|
152
|
+
* we can prove we still hold. */
|
|
153
|
+
daemonRenewalLeaseRevision;
|
|
150
154
|
membershipFeedKv;
|
|
151
155
|
/** Caller-owned membership watches survive a connection rebuild as INTENT. Their iterators are
|
|
152
156
|
* connection-scoped and are stopped/re-created around the epoch swap. */
|
|
@@ -158,6 +162,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
158
162
|
/** When set, this endpoint hosts the Plane-3 fan-out writer + trusted reader (the server-side delivery
|
|
159
163
|
* daemon). `aclFor` maps an owner id to its current read ACL (`allowSubscribe`) for the reader's
|
|
160
164
|
* re-authorization — read FRESH per entry from the durable ACL registry KV, hence async. */
|
|
165
|
+
/** True once {@link quiescePlane3} has stopped serving this shard pending an ownership answer.
|
|
166
|
+
* Guards {@link armPlane3} so a RECONNECT cannot silently resume serving mid-question. */
|
|
167
|
+
plane3Quiesced = false;
|
|
161
168
|
plane3;
|
|
162
169
|
/** Live local cache of the channel registry (key = channel token), kept by a KV watch. */
|
|
163
170
|
channelConfigs = new Map();
|
|
@@ -476,8 +483,44 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
476
483
|
const claims = decodeBearerPrincipal(bearer);
|
|
477
484
|
if (claims.owner !== this.owner || claims.actor !== this.actor)
|
|
478
485
|
throw new Error(`bearer source returned principal ${claims.owner}.${claims.actor}, expected ${this.owner}.${this.actor}`);
|
|
486
|
+
// Two refusals, answering different questions, and both of them are about protecting what is
|
|
487
|
+
// ALREADY held. So both are scoped to there BEING something held, which is the same set as
|
|
488
|
+
// `!initial` but names the thing the rules actually depend on: on the first fetch there is no
|
|
489
|
+
// cache to lose, `start()` has to come up on whatever the source has, and the pre-dial guard
|
|
490
|
+
// in {@link bindConnection} is what speaks for a dead first token.
|
|
491
|
+
const expiryMs = bearerExpiryMs(bearer);
|
|
492
|
+
const held = this.currentBearer;
|
|
493
|
+
if (held !== undefined) {
|
|
494
|
+
// ALREADY DEAD, whatever else is true of it. Advancement is deliberately not part of this
|
|
495
|
+
// test: a candidate expiring at now-5s does advance a held one that died at now-60s, so an
|
|
496
|
+
// advance-only rule adopts it - overwriting the cache with material nothing can dial,
|
|
497
|
+
// skipping the recoverable warning because the fetch did not throw, and arming the next
|
|
498
|
+
// read off a non-positive delay. The creds path draws its line on expiry alone too, in
|
|
499
|
+
// {@link presentableCreds} (#1572).
|
|
500
|
+
if (expiryMs <= Date.now())
|
|
501
|
+
throw new Error("the bearer source returned a token that has already expired - nothing adopted");
|
|
502
|
+
// THE SAME BYTES BACK AGAIN from a source whose token cannot carry the next cycle: the
|
|
503
|
+
// delay it arms is non-positive, `armBearerRefresh` floors that to 5s, and the next read
|
|
504
|
+
// returns that same token - a 5s loop against the auth service for the rest of its life,
|
|
505
|
+
// with BEARER_RETRY_MS bypassed because the fetch did not FAIL. It succeeded and returned
|
|
506
|
+
// nothing new (#1561).
|
|
507
|
+
//
|
|
508
|
+
// The test is byte identity, not expiry, and not advancement. `exp` carries one second of
|
|
509
|
+
// resolution, so a key rotation re-signing the same claims under a new key - and a healthy
|
|
510
|
+
// short-TTL source read twice inside one wall-clock second - both hand back a token whose
|
|
511
|
+
// `exp` has not moved. That is genuinely re-issued material, which an advancement test
|
|
512
|
+
// refuses and this one adopts. It is the question the creds path asks with
|
|
513
|
+
// `credsFingerprint`: did the source re-issue ANYTHING (#1572).
|
|
514
|
+
if (bearer === held && expiryMs - Date.now() - CotalEndpoint.BEARER_REFRESH_MARGIN_MS <= 0)
|
|
515
|
+
throw new Error("the bearer source re-served the token already held and it cannot carry another cycle (the auth service has not issued a fresh one) - nothing adopted");
|
|
516
|
+
}
|
|
479
517
|
this.currentBearer = bearer;
|
|
480
|
-
|
|
518
|
+
// A non-positive delay is NOT clamped to BEARER_RETRY_MS. A deployment whose whole token TTL
|
|
519
|
+
// sits inside the margin is legitimate - `expiry-renewal` mints 5s bearers against the 60s
|
|
520
|
+
// margin - and for it the 5s floor IS the renewal cadence. Backing that source off to 15s
|
|
521
|
+
// leaves a 5s token dead for two thirds of every cycle. What made #1561 a pointless loop was
|
|
522
|
+
// the source returning nothing new, which is refused above, not the cadence itself.
|
|
523
|
+
this.armBearerRefresh(expiryMs - Date.now() - CotalEndpoint.BEARER_REFRESH_MARGIN_MS);
|
|
481
524
|
}
|
|
482
525
|
catch (e) {
|
|
483
526
|
if (initial)
|
|
@@ -538,6 +581,35 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
538
581
|
: "this endpoint was constructed with an empty creds string - not dialing without auth material (an empty credential is not anonymous access)");
|
|
539
582
|
return CotalEndpoint.presentableCreds(this.currentCreds, { renewable: Boolean(this.credsSource) });
|
|
540
583
|
}
|
|
584
|
+
/** Refuses a fetched generation there is nothing to renew FROM, so the caller's failure posture
|
|
585
|
+
* ({@link CREDS_RETRY_MS}) applies instead of the renewal schedule (issue #1523).
|
|
586
|
+
*
|
|
587
|
+
* A source that hands back the SAME generation past its own renewal point has not re-signed yet:
|
|
588
|
+
* that is a missed remint, not a candidate. Adopting it re-arms from a non-positive delay, which
|
|
589
|
+
* {@link armCredsRefresh} floors to 1s, which fetches again, which is the same generation — a 1s
|
|
590
|
+
* read loop against a store that is already having a bad day, for the JWT's remaining 25% of life,
|
|
591
|
+
* with the 60s backoff that exists for exactly this bypassed because the fetch did not FAIL.
|
|
592
|
+
*
|
|
593
|
+
* The membership feed's rw-cred renewal already refuses it this way (`membership-feed.ts`,
|
|
594
|
+
* `adoptRwCreds`: "the rw source still holds the previous generation past its renewal point");
|
|
595
|
+
* this is the same rule on the endpoint's `delivery.creds` seam, so the two renewal paths answer
|
|
596
|
+
* a dead source identically.
|
|
597
|
+
*
|
|
598
|
+
* An ALREADY-EXPIRED generation is refused whatever it is: the delay is non-positive for it too,
|
|
599
|
+
* but it is dead rather than merely due, so it is named separately and refused even when the
|
|
600
|
+
* source keeps returning a different one. */
|
|
601
|
+
static assertRenewableGeneration(candidate, current, delayMs) {
|
|
602
|
+
const { exp } = credsClaims(candidate);
|
|
603
|
+
if (typeof exp === "number" && exp * 1000 <= Date.now())
|
|
604
|
+
throw new Error("the creds source returned an already-expired credential (its `exp` is in the past) - nothing adopted; the renewal owner has not re-signed it, or the store is serving a stale generation");
|
|
605
|
+
// Compared by GENERATION, not by envelope. `EndpointOptions.creds` takes opaque file content and
|
|
606
|
+
// promises no canonical whitespace: `jwtFromCreds` pads with `\s*` and trims, so a store, editor or
|
|
607
|
+
// filesystem round trip that adds a newline re-serves the SAME credential in bytes `===` calls
|
|
608
|
+
// different - and the refusal would be skipped for the one case it exists to catch. The envelope
|
|
609
|
+
// also carries the nkey seed, which is why `credsFingerprint` hashes the JWT instead.
|
|
610
|
+
if (current !== undefined && credsFingerprint(candidate) === credsFingerprint(current) && delayMs <= 0)
|
|
611
|
+
throw new Error("the creds source still holds the previous generation past its renewal point (the renewal owner has not re-signed it) - nothing adopted");
|
|
612
|
+
}
|
|
541
613
|
/** The disposable-preflight connect bound for the EXPLICIT reload proof (D5 class-2 adoption). A
|
|
542
614
|
* rogue or unreachable candidate must resolve well UNDER the manager's delivery-admin request
|
|
543
615
|
* bound, so this stays a few seconds and never blocks the responder. */
|
|
@@ -563,8 +635,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
563
635
|
const id = idFromCreds(creds);
|
|
564
636
|
if (id !== this.connId)
|
|
565
637
|
throw new Error(`creds source returned identity ${id}, expected ${this.connId} - renewal may not swap the connection's nkey`);
|
|
638
|
+
// Before the commit, so a refusal leaves the cache holding the last generation that was renewable
|
|
639
|
+
// from (and, on the initial fetch, fails loud rather than dialling with dead material).
|
|
640
|
+
const delay = credsRenewalDelayMs(creds);
|
|
641
|
+
CotalEndpoint.assertRenewableGeneration(creds, this.currentCreds, delay);
|
|
566
642
|
this.currentCreds = creds;
|
|
567
|
-
this.armCredsRefresh(
|
|
643
|
+
this.armCredsRefresh(delay);
|
|
568
644
|
const { iat, exp } = credsClaims(creds);
|
|
569
645
|
return { iat, exp };
|
|
570
646
|
}
|
|
@@ -711,6 +787,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
711
787
|
// lacking a numeric `exp`, and that throw must not leave currentCreds flipped to a candidate the
|
|
712
788
|
// authenticator would present on the next reconnect (a post-preflight validation failure is a no-op).
|
|
713
789
|
const delay = credsRenewalDelayMs(candidate);
|
|
790
|
+
CotalEndpoint.assertRenewableGeneration(candidate, this.currentCreds, delay);
|
|
714
791
|
this.currentCreds = candidate;
|
|
715
792
|
this.armCredsRefresh(delay);
|
|
716
793
|
return credsClaims(candidate);
|
|
@@ -1085,6 +1162,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1085
1162
|
this.membershipFeedKv = undefined;
|
|
1086
1163
|
this.deliveryKv = undefined;
|
|
1087
1164
|
this.managerLeaseKv = undefined;
|
|
1165
|
+
this.daemonRenewalLeaseRevision = undefined;
|
|
1088
1166
|
// Handles that armDeliveryControl created on a previous connection: null them so a rebind
|
|
1089
1167
|
// does not carry a dead protocol's subs forward. Best-effort unsubscribe first (the sub is
|
|
1090
1168
|
// dead with its connection either way; unsubscribing an already-dead sub is a noop).
|
|
@@ -1234,6 +1312,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1234
1312
|
// The manager's liveness-lease handle too: left bound to the old connection, every renew and
|
|
1235
1313
|
// re-read after a reconnect times out, and the manager reports its lease unknown for good.
|
|
1236
1314
|
this.managerLeaseKv = undefined;
|
|
1315
|
+
this.daemonRenewalLeaseRevision = undefined;
|
|
1237
1316
|
// This is an application-requested epoch teardown, not a transient nats.js blip. The old
|
|
1238
1317
|
// status iterator is now stale by construction and its close is epoch-dropped, so this line is
|
|
1239
1318
|
// the authoritative raw-liveness edge for the no-nc window until the new watcher seeds true.
|
|
@@ -3115,8 +3194,25 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3115
3194
|
this.deliveryKv ??= await openDeliveryRegistry(this.nc, this.space);
|
|
3116
3195
|
return this.deliveryKv;
|
|
3117
3196
|
}
|
|
3197
|
+
/** This endpoint instance's LEASE INCARNATION: which run of this principal a lease row was written
|
|
3198
|
+
* by. Minted per construction and never re-derived, because the credential cannot supply it, the
|
|
3199
|
+
* daemon's cred is a file on disk that every restart re-reads, so `card.id` is stable across
|
|
3200
|
+
* processes by design. See {@link DeliveryLeaseInfo.incarnation}. */
|
|
3201
|
+
leaseIncarnation = randomUUID();
|
|
3118
3202
|
encodeLease(ready) {
|
|
3119
|
-
return new TextEncoder().encode(JSON.stringify({ holder: this.card.id, since: Date.now(), ready }));
|
|
3203
|
+
return new TextEncoder().encode(JSON.stringify({ holder: this.card.id, incarnation: this.leaseIncarnation, since: Date.now(), ready }));
|
|
3204
|
+
}
|
|
3205
|
+
/** Is this shard's lease row one THIS ENDPOINT INSTANCE wrote? The question a daemon whose renew
|
|
3206
|
+
* just failed has to answer before it decides whether it still owns the shard.
|
|
3207
|
+
*
|
|
3208
|
+
* Both halves are required. `holder` alone is not sufficient (a successor daemon re-reading the
|
|
3209
|
+
* same creds file presents the same principal, so its row would read as ours), and `incarnation`
|
|
3210
|
+
* alone is not sufficient either, it is a bare uuid with no claim to the principal, so a row
|
|
3211
|
+
* bearing ours but a foreign holder is not something we should ever adopt. A row with NO
|
|
3212
|
+
* incarnation predates the field and cannot be proven ours, which is the safe reading: it leads
|
|
3213
|
+
* to the takeover path rather than to serving on someone else's claim. */
|
|
3214
|
+
ownsDeliveryLease(info) {
|
|
3215
|
+
return info.holder === this.card.id && info.incarnation !== undefined && info.incarnation === this.leaseIncarnation;
|
|
3120
3216
|
}
|
|
3121
3217
|
/** Acquire the single-flight delivery lease for a shard via an ATOMIC CAS create, marked NOT-ready.
|
|
3122
3218
|
* THROWS if a live lease exists — a loud refusal-to-bind (the daemon exits), never a retry, so two
|
|
@@ -3132,6 +3228,18 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3132
3228
|
async markDeliveryLeaseReady(shardIndex, revision) {
|
|
3133
3229
|
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(true), revision);
|
|
3134
3230
|
}
|
|
3231
|
+
/** Flip the held lease back to NOT-ready, the counterpart to {@link markDeliveryLeaseReady}, for a
|
|
3232
|
+
* holder that has UNBOUND its loops and control responder but has not given up the shard.
|
|
3233
|
+
*
|
|
3234
|
+
* `ready` is a claim about the RESPONDER, not about the row's existence: `ensureDelivery` waits on
|
|
3235
|
+
* it and the channel-health surface reports it. A daemon that goes quiet to re-check its ownership
|
|
3236
|
+
* still holds the key, so without this the space would be told a responder is up while nothing is
|
|
3237
|
+
* bound, a readiness lie of exactly the kind #1318 is about, just pointed the other way. Keeping
|
|
3238
|
+
* the row (rather than deleting it) is deliberate: the shard is still claimed, so no third daemon
|
|
3239
|
+
* should be invited in; what is being withdrawn is only the claim to be answering. */
|
|
3240
|
+
async markDeliveryLeaseNotReady(shardIndex, revision) {
|
|
3241
|
+
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(false), revision);
|
|
3242
|
+
}
|
|
3135
3243
|
/** Renew the held lease (CAS `kv.update` against `revision`, keeping `ready:true`) to refresh it before
|
|
3136
3244
|
* the bucket TTL expires it. Returns the new revision. Throws if the revision moved (lost the lease —
|
|
3137
3245
|
* the daemon should exit). */
|
|
@@ -3139,25 +3247,54 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3139
3247
|
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(true), revision);
|
|
3140
3248
|
}
|
|
3141
3249
|
/** Release the held lease on clean shutdown so a replacement daemon re-acquires immediately (best
|
|
3142
|
-
* effort
|
|
3143
|
-
|
|
3250
|
+
* effort, a crash just lets the bucket TTL expire it).
|
|
3251
|
+
*
|
|
3252
|
+
* THE REVISION IS WHAT MAKES THIS A RELEASE RATHER THAN A DELETE. An unconditional delete removes
|
|
3253
|
+
* whatever row is there, and by shutdown time the row is not necessarily still ours: the exit
|
|
3254
|
+
* paths that matter most are precisely the ones where another daemon has taken the shard, so the
|
|
3255
|
+
* departing process would delete the REPLACEMENT's lease on its way out and leave the shard with
|
|
3256
|
+
* no holder at all. Passing the revision this endpoint last owned makes the delete a compare-and-
|
|
3257
|
+
* swap (`previousSeq` becomes JetStream's `ExpectedLastSubjectSequence`), so a row that has moved
|
|
3258
|
+
* on is left alone.
|
|
3259
|
+
*
|
|
3260
|
+
* THE ARGUMENT IS REQUIRED, AND EXPLICITLY NULLABLE RATHER THAN OPTIONAL. `undefined` means "this
|
|
3261
|
+
* process no longer holds a revision it can argue for", which is the takeover paths' honest
|
|
3262
|
+
* answer and correctly releases nothing, the bucket TTL is the crash-safe authority and expires
|
|
3263
|
+
* a genuinely stale row. But if that were the DEFAULT, every existing `releaseDeliveryLease(0)`
|
|
3264
|
+
* call site would keep compiling and silently stop releasing: the same omission hole
|
|
3265
|
+
* `standaloneConnectOpts` closed by deleting its `= {}`. Measured, not theorised, this landed as
|
|
3266
|
+
* a red in `smoke:delivery-lease`, where a caller that genuinely held the lease released nothing
|
|
3267
|
+
* and the next acquire was refused. A caller must now say which it means. */
|
|
3268
|
+
async releaseDeliveryLease(shardIndex, revision) {
|
|
3269
|
+
if (revision === undefined)
|
|
3270
|
+
return;
|
|
3144
3271
|
try {
|
|
3145
|
-
await (await this.deliveryRegistry()).delete(leaseKey(shardIndex));
|
|
3272
|
+
await (await this.deliveryRegistry()).delete(leaseKey(shardIndex), { previousSeq: revision });
|
|
3146
3273
|
}
|
|
3147
3274
|
catch {
|
|
3148
3275
|
// Intentionally best-effort for EVERY failure: the lease TTL is the crash-safe release authority,
|
|
3149
|
-
// and clean shutdown must continue even when the broker is already gone or draining.
|
|
3276
|
+
// and clean shutdown must continue even when the broker is already gone or draining. A refused
|
|
3277
|
+
// CAS lands here too, which is the correct outcome, someone else owns the row.
|
|
3150
3278
|
}
|
|
3151
3279
|
}
|
|
3152
3280
|
/** Read a shard's delivery lease (the daemon-availability signal), or `undefined` if none is live.
|
|
3153
3281
|
* READ-ONLY surface — drives Component 6's `cotal_channels` delivery-health field (an agent reads it
|
|
3154
3282
|
* under its own cred, which holds lease-bucket read but no write). */
|
|
3155
3283
|
async readDeliveryLease(shardIndex) {
|
|
3284
|
+
return (await this.readDeliveryLeaseEntry(shardIndex))?.info;
|
|
3285
|
+
}
|
|
3286
|
+
/** The lease row AND the KV revision it is at. The revision is the CAS token every renew and the
|
|
3287
|
+
* CAS release are argued against, so a caller re-establishing ownership after a failed renew
|
|
3288
|
+
* needs the BROKER's sequence, not the one it last cached: a renew can fail with its write
|
|
3289
|
+
* already applied (a lost reply, a reconnect mid-request), which leaves the cached revision one
|
|
3290
|
+
* behind forever and every subsequent CAS refused over a sequence this process itself moved ,
|
|
3291
|
+
* read as somebody else's takeover, which is the #1318 misreading in a second costume. */
|
|
3292
|
+
async readDeliveryLeaseEntry(shardIndex) {
|
|
3156
3293
|
const e = await (await this.deliveryRegistry()).get(leaseKey(shardIndex));
|
|
3157
3294
|
if (!e || e.operation === "DEL" || e.operation === "PURGE")
|
|
3158
3295
|
return undefined;
|
|
3159
3296
|
try {
|
|
3160
|
-
return e.json();
|
|
3297
|
+
return { info: e.json(), revision: e.revision };
|
|
3161
3298
|
}
|
|
3162
3299
|
catch {
|
|
3163
3300
|
return undefined;
|
|
@@ -3198,6 +3335,53 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3198
3335
|
}
|
|
3199
3336
|
return this.managerLeaseKv;
|
|
3200
3337
|
}
|
|
3338
|
+
/** Take or keep the per-SPACE daemon-credential renewal lease (#1634), returning whether THIS
|
|
3339
|
+
* instance now holds it. One atomic CAS `create` per pass: it succeeds for whoever arrives first
|
|
3340
|
+
* and throws for everyone else, so exactly one manager remints even when several share the
|
|
3341
|
+
* daemon's store. The holder re-`update`s its own key by revision, which both keeps it and proves
|
|
3342
|
+
* it never lost it. Losing the CAS is an ordinary outcome (a peer holds it) and returns false; it
|
|
3343
|
+
* never fails a start. A crashed holder's key TTL-expires with the bucket, so the next pass hands
|
|
3344
|
+
* the lease to a survivor with no operator step. */
|
|
3345
|
+
async holdDaemonRenewalLease(instanceId) {
|
|
3346
|
+
const kv = await this.managerLeaseRegistry();
|
|
3347
|
+
const held = this.daemonRenewalLeaseRevision;
|
|
3348
|
+
if (held !== undefined) {
|
|
3349
|
+
try {
|
|
3350
|
+
this.daemonRenewalLeaseRevision = await kv.update(MANAGER_RENEWAL_LEASE_KEY, this.encodeDaemonRenewalLease(instanceId), held);
|
|
3351
|
+
return true;
|
|
3352
|
+
}
|
|
3353
|
+
catch {
|
|
3354
|
+
// The revision moved (our key TTL-expired and a peer took it). Re-contend below rather than
|
|
3355
|
+
// keep reminting on a lease we no longer hold.
|
|
3356
|
+
this.daemonRenewalLeaseRevision = undefined;
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
try {
|
|
3360
|
+
this.daemonRenewalLeaseRevision = await kv.create(MANAGER_RENEWAL_LEASE_KEY, this.encodeDaemonRenewalLease(instanceId));
|
|
3361
|
+
return true;
|
|
3362
|
+
}
|
|
3363
|
+
catch {
|
|
3364
|
+
return false; // another manager holds it: exactly one owner is the point
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
/** Release the renewal lease on a clean stop so a peer takes over at once rather than at the TTL.
|
|
3368
|
+
* CAS-guarded, so a lease we already lost is never deleted out from under its new holder. */
|
|
3369
|
+
async releaseDaemonRenewalLease() {
|
|
3370
|
+
const held = this.daemonRenewalLeaseRevision;
|
|
3371
|
+
this.daemonRenewalLeaseRevision = undefined;
|
|
3372
|
+
if (held === undefined)
|
|
3373
|
+
return;
|
|
3374
|
+
try {
|
|
3375
|
+
await (await this.managerLeaseRegistry()).delete(MANAGER_RENEWAL_LEASE_KEY, { previousSeq: held });
|
|
3376
|
+
}
|
|
3377
|
+
catch {
|
|
3378
|
+
// Best-effort, like releaseManagerLease: a moved revision means it is not ours, and a broker
|
|
3379
|
+
// failure is recovered by the bucket TTL. Shutdown must not claim deletion.
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
encodeDaemonRenewalLease(instanceId) {
|
|
3383
|
+
return new TextEncoder().encode(JSON.stringify({ instanceId, since: Date.now() }));
|
|
3384
|
+
}
|
|
3201
3385
|
encodeManagerLease(info) {
|
|
3202
3386
|
return new TextEncoder().encode(JSON.stringify(info));
|
|
3203
3387
|
}
|
|
@@ -3529,7 +3713,28 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3529
3713
|
* id is the authenticated subject sender ({@link serveControl} fail-closes on a mismatch). Validation
|
|
3530
3714
|
* is against the durable ACL registry — the SAME KV the reader re-auths against (single source of
|
|
3531
3715
|
* truth, no in-memory ledger to drift). */
|
|
3716
|
+
/** Whether an ALREADY-DISPATCHED unit of Plane-3 work may still take effect.
|
|
3717
|
+
*
|
|
3718
|
+
* Unsubscribing stops NEW work; it cannot recall work already in flight. A handler that entered
|
|
3719
|
+
* before {@link quiescePlane3} and awaited broker I/O inside it resumes AFTER the freeze, and a
|
|
3720
|
+
* reviewer traced the ordering that makes that a split rather than a latency blip: the loser
|
|
3721
|
+
* accepts a unit and awaits, the loser is descheduled, the successor acquires the shard and flips
|
|
3722
|
+
* its lease READY, then the loser resumes and answers or acks for a shard it no longer holds.
|
|
3723
|
+
* Subscription counts and parked-pull readings cannot see it, because the effect is the reply and
|
|
3724
|
+
* the ack rather than the binding.
|
|
3725
|
+
*
|
|
3726
|
+
* So each work path re-asks HERE, at the point of effect, after its awaits and before it acts.
|
|
3727
|
+
* The lease keeps one ROW, not one SERVER; this is the half that keeps one server. An unacked
|
|
3728
|
+
* message is not lost by refusing: a stopped consumer redelivers it to whoever holds the shard
|
|
3729
|
+
* next, which is the same property quiescing already relies on. */
|
|
3730
|
+
plane3MayAct() {
|
|
3731
|
+
return !this.plane3Quiesced;
|
|
3732
|
+
}
|
|
3532
3733
|
async handleDeliveryControl(req) {
|
|
3734
|
+
// FENCE: entered before a quiesce, resuming after it. Answering now would put a second server on
|
|
3735
|
+
// this shard's control rail while the winner is already READY.
|
|
3736
|
+
if (!this.plane3MayAct())
|
|
3737
|
+
return { ok: false, error: "delivery: this daemon is not serving this shard (it is re-checking ownership); retry" };
|
|
3533
3738
|
const caller = req.from.id;
|
|
3534
3739
|
const args = req.args ?? {};
|
|
3535
3740
|
if (req.op === "durableJoin")
|
|
@@ -3548,11 +3753,18 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3548
3753
|
catch (e) {
|
|
3549
3754
|
return { ok: false, error: e.message };
|
|
3550
3755
|
}
|
|
3551
|
-
|
|
3756
|
+
const memberships = await this.ownerMemberships(caller, uid);
|
|
3757
|
+
// FENCE AFTER THE AWAIT, which is the one that matters. The entry check above cannot catch the
|
|
3758
|
+
// case this exists for: a request admitted WHILE SERVING, then descheduled inside its broker
|
|
3759
|
+
// read, resuming after a successor has taken the shard and reached READY. Re-ask at the point
|
|
3760
|
+
// of effect, so the answer is never served by a daemon that has stood down.
|
|
3761
|
+
if (!this.plane3MayAct())
|
|
3762
|
+
return { ok: false, error: "delivery: this daemon stopped serving this shard while the request was in flight; retry" };
|
|
3763
|
+
return { ok: true, data: { memberships } };
|
|
3552
3764
|
}
|
|
3553
3765
|
return { ok: false, error: `op "${req.op}" not supported on the delivery control service` };
|
|
3554
3766
|
}
|
|
3555
|
-
/** Validate the channel ARG shape only
|
|
3767
|
+
/** Validate the channel ARG shape only: non-blank, valid, concrete (NO ACL check, that is op-specific).
|
|
3556
3768
|
* Returns the channel on success or a ControlReply error to short-circuit. */
|
|
3557
3769
|
checkDurableChannelArg(args, op) {
|
|
3558
3770
|
const channel = typeof args.channel === "string" ? args.channel.trim() : "";
|
|
@@ -3735,6 +3947,85 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3735
3947
|
const max = this.nc?.info?.max_payload ?? 1_048_576;
|
|
3736
3948
|
return Math.max(1, Math.floor(max * 0.9));
|
|
3737
3949
|
}
|
|
3950
|
+
/** Stop serving Plane-3 WITHOUT tearing down the connection, so a daemon that has just learned its
|
|
3951
|
+
* lease may no longer be its own can stop acting on the shard while it finds out for certain.
|
|
3952
|
+
*
|
|
3953
|
+
* A COMPARE-AND-SWAP KEEPS ONE LEASE ROW; IT DOES NOT KEEP ONE SERVER. That distinction is the
|
|
3954
|
+
* reason this exists, and it was a review finding. When a renew fails, the daemon re-reads the
|
|
3955
|
+
* key and may then re-acquire it, and across that read-then-create it was still consuming the
|
|
3956
|
+
* fan-out durable, still running the inbox reader, and still answering ctl.delivery. If a
|
|
3957
|
+
* replacement acquired the shard in that window, both processes served the same durables until
|
|
3958
|
+
* the loser's create was refused and its teardown finished. The old code did not have this
|
|
3959
|
+
* window, because it began shutting down on the first renew failure; treating that failure as a
|
|
3960
|
+
* question instead of a verdict is right, but asking the question while still serving is not.
|
|
3961
|
+
*
|
|
3962
|
+
* So the daemon goes quiet FIRST and re-arms only once it has proof: `held` on a re-read, or a
|
|
3963
|
+
* won atomic create. `unknown` stays quiet, the whole point is that not being able to ask is not
|
|
3964
|
+
* permission to keep acting. Quiescing costs delivery latency for a few seconds; the alternative
|
|
3965
|
+
* costs a SPLIT durable, which is a correctness failure rather than an availability one.
|
|
3966
|
+
*
|
|
3967
|
+
* Deliberately not `stop()`: the connection, the lease KV handles and the control rails must stay
|
|
3968
|
+
* up, because the daemon still has to ask the broker who owns the shard. */
|
|
3969
|
+
async quiescePlane3() {
|
|
3970
|
+
this.plane3Quiesced = true;
|
|
3971
|
+
if (this.deliveryServeSub) {
|
|
3972
|
+
try {
|
|
3973
|
+
this.deliveryServeSub.unsubscribe();
|
|
3974
|
+
}
|
|
3975
|
+
catch { /* already dead */ }
|
|
3976
|
+
this.deliveryServeSub = undefined;
|
|
3977
|
+
}
|
|
3978
|
+
if (this.deliveryAdminServeSub) {
|
|
3979
|
+
try {
|
|
3980
|
+
this.deliveryAdminServeSub.unsubscribe();
|
|
3981
|
+
}
|
|
3982
|
+
catch { /* already dead */ }
|
|
3983
|
+
this.deliveryAdminServeSub = undefined;
|
|
3984
|
+
}
|
|
3985
|
+
// Stopping the consumers ends the `for await` loops that drive fan-out and the reader. In-flight
|
|
3986
|
+
// messages are NOT acked by a stopped consumer, so they redeliver to whoever holds the shard
|
|
3987
|
+
// next: quiescing loses no message, it only stops this process from claiming them.
|
|
3988
|
+
for (const msgs of this.streamMsgs.splice(0)) {
|
|
3989
|
+
try {
|
|
3990
|
+
msgs.stop();
|
|
3991
|
+
}
|
|
3992
|
+
catch { /* already draining */ }
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3995
|
+
/** Whether Plane-3 is currently quiesced: unbound, and refusing to re-bind until ownership is proven.
|
|
3996
|
+
* Read-only. The flag is the daemon's own answer to "am I serving this shard", so a cell that asserts
|
|
3997
|
+
* a recovery worked can check the endpoint's state rather than inferring it from a log line. */
|
|
3998
|
+
plane3IsQuiesced() {
|
|
3999
|
+
return this.plane3Quiesced;
|
|
4000
|
+
}
|
|
4001
|
+
/** Resume serving Plane-3 after {@link quiescePlane3}, once ownership has been re-established.
|
|
4002
|
+
* Idempotent, and a no-op when the daemon was never quiesced.
|
|
4003
|
+
*
|
|
4004
|
+
* The flag is cleared ONLY after every binding is up, and restored if any of them throws. Clearing
|
|
4005
|
+
* it first looks equivalent and is not: `armPlane3` binds in four stages (`manager`, the two control
|
|
4006
|
+
* responders, the fan-out consumer, the reader), so a failure at any one leaves the endpoint recorded
|
|
4007
|
+
* as un-quiesced while some of those are missing. From there every later `rearmPlane3` returns at the
|
|
4008
|
+
* `!plane3Quiesced` guard WITHOUT attempting to bind, and the caller goes on to flip the lease READY.
|
|
4009
|
+
* That is a readiness lie surviving a transient broker error, the daemon claims a responder it does
|
|
4010
|
+
* not have, which is the #1318 outage wearing the readiness flag instead of the exit path. */
|
|
4011
|
+
async rearmPlane3() {
|
|
4012
|
+
if (!this.plane3Quiesced)
|
|
4013
|
+
return;
|
|
4014
|
+
// `armPlane3` refuses while quiesced (a reconnect must not resume mid-question), so the flag is
|
|
4015
|
+
// lowered for the attempt and RAISED AGAIN on failure. A partial bind leaves the endpoint quiesced,
|
|
4016
|
+
// which is the honest state: not serving, and a later retry will attempt the whole sequence again.
|
|
4017
|
+
this.plane3Quiesced = false;
|
|
4018
|
+
try {
|
|
4019
|
+
await this.armPlane3();
|
|
4020
|
+
}
|
|
4021
|
+
catch (e) {
|
|
4022
|
+
this.plane3Quiesced = true;
|
|
4023
|
+
// Drop whatever DID bind. Leaving half-bound responders up would answer control requests for a
|
|
4024
|
+
// shard this process has just declared itself not to be serving.
|
|
4025
|
+
this.quiescePlane3();
|
|
4026
|
+
throw e;
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
3738
4029
|
/** (Re)bind the Plane-3 fan-out writer + trusted reader. Idempotent — the durables resume from their
|
|
3739
4030
|
* cursor. Called by {@link startPlane3} once AND by {@link connectAndBind} on every (re)connect, so
|
|
3740
4031
|
* the delivery daemon's reconnect RE-ARMS the backstop + the ctl.delivery responder. Without this, a broker blip would silently kill
|
|
@@ -3743,6 +4034,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3743
4034
|
async armPlane3() {
|
|
3744
4035
|
if (!this.plane3 || !this.js)
|
|
3745
4036
|
return;
|
|
4037
|
+
// A quiesced endpoint must not be re-armed by a RECONNECT: the reconnect path calls this too,
|
|
4038
|
+
// and silently resuming there would restore exactly the double-serving this guards against
|
|
4039
|
+
// while the ownership question is still open.
|
|
4040
|
+
if (this.plane3Quiesced)
|
|
4041
|
+
return;
|
|
3746
4042
|
await this.manager(); // the manager runs consume:false, so this.jsm is lazy — ensure it
|
|
3747
4043
|
this.armDeliveryControl();
|
|
3748
4044
|
await this.runFanout();
|
|
@@ -3783,6 +4079,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3783
4079
|
* feed's rw connection, and reply with proof (identities + the adopted JWT windows) — or a
|
|
3784
4080
|
* structured failure (e.g. the file was never re-signed), never a silent partial. */
|
|
3785
4081
|
async handleDeliveryAdmin(req) {
|
|
4082
|
+
// FENCE: same reason as the runtime rail above.
|
|
4083
|
+
if (!this.plane3MayAct())
|
|
4084
|
+
return { ok: false, error: "delivery: this daemon is not serving this shard (it is re-checking ownership); retry" };
|
|
3786
4085
|
if (req.op === "reloadCreds") {
|
|
3787
4086
|
// The renewal owner's EXPECTED-generation tokens (SHA-256 of each JWT it re-signed), per
|
|
3788
4087
|
// component. A missing entry means "no expectation" (the passive backstop still adopts).
|
|
@@ -3911,6 +4210,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3911
4210
|
* members within interval; `live` channel → `@mention` targets authorized to read it (ACL only).
|
|
3912
4211
|
* Members KV is scanned FRESH per message (no cache — red-team BLOCKER-1 catch-up correctness). */
|
|
3913
4212
|
async fanOutMessage(m) {
|
|
4213
|
+
// FENCE, and NOT followed by an ack: this unit was dispatched before the quiesce and is resuming
|
|
4214
|
+
// after it, so writing the fan-out now would put entries in member inboxes on behalf of a shard
|
|
4215
|
+
// this daemon no longer serves. Returning WITHOUT acking is the whole point - a stopped consumer
|
|
4216
|
+
// redelivers to whoever holds the shard next, so refusing here loses no message, while acking it
|
|
4217
|
+
// would consume the successor's work.
|
|
4218
|
+
if (!this.plane3MayAct())
|
|
4219
|
+
return;
|
|
3914
4220
|
const parsed = parseSubject(m.subject);
|
|
3915
4221
|
if (!parsed || parsed.kind !== "chat") {
|
|
3916
4222
|
m.ack();
|
|
@@ -3931,6 +4237,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3931
4237
|
} // authenticity (owner must be a real principal, not an old-shape alias)
|
|
3932
4238
|
const seq = m.seq;
|
|
3933
4239
|
const normalizedMsg = authenticatedChannelMessage(msg, channel);
|
|
4240
|
+
// SECOND FENCE, AT THE COMMIT POINT. The entry check above cannot cover this unit's own awaits:
|
|
4241
|
+
// the class read and the member scan below both hit the broker, and a daemon descheduled inside
|
|
4242
|
+
// them can resume after a successor owns the shard. Re-ask immediately before the first WRITE,
|
|
4243
|
+
// and again return without acking so the entry redelivers rather than being consumed here.
|
|
4244
|
+
if (!this.plane3MayAct())
|
|
4245
|
+
return;
|
|
3934
4246
|
if ((await this.deliveryClassFresh(channel)) === "durable") {
|
|
3935
4247
|
for (const rec of await listMembers(await this.membersRegistry(), { channel })) {
|
|
3936
4248
|
if (rec.owner === msg.from.id)
|
|
@@ -3941,6 +4253,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3941
4253
|
// retired lifecycle's inbox, never the alias's new occupant (SPEC 13.1 cross-plane scoping).
|
|
3942
4254
|
// Store the AUTHENTICATED-channel copy (main's normalization): the durable frame validates
|
|
3943
4255
|
// msg.channel === frame.channel, and payload to/toService are stripped.
|
|
4256
|
+
// Per-copy, because the scan above and every publish below is a broker round trip: a fan-out
|
|
4257
|
+
// to fifty members can straddle a quiesce in its middle. Stopping part-way is safe where
|
|
4258
|
+
// stopping late is not - the entry is never acked here, so the successor redelivers it and
|
|
4259
|
+
// re-sends the whole set; publishDinbox is idempotent per (owner, lifecycle, msgID).
|
|
4260
|
+
if (!this.plane3MayAct())
|
|
4261
|
+
return;
|
|
3944
4262
|
await this.publishDinbox(rec.owner, rec.lifecycleUid, { msg: normalizedMsg, channel, seq, reason: "durable-channel", generation: rec.generation });
|
|
3945
4263
|
}
|
|
3946
4264
|
}
|
|
@@ -3962,9 +4280,16 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3962
4280
|
}
|
|
3963
4281
|
if (!row || !channelInAllow(row.allowSubscribe, channel))
|
|
3964
4282
|
continue; // @mention can't bypass the read ACL
|
|
4283
|
+
if (!this.plane3MayAct())
|
|
4284
|
+
return; // same rule on the live-mention path
|
|
3965
4285
|
await this.publishDinbox(owner, row.lifecycleUid, { msg: normalizedMsg, channel, seq, reason: "live-mention", generation: 0 });
|
|
3966
4286
|
}
|
|
3967
4287
|
}
|
|
4288
|
+
// FINAL FENCE, BEFORE THE ACK ITSELF, which is the real commit: acking tells the stream this
|
|
4289
|
+
// shard's work is done. A daemon that stopped serving mid-fan-out must not make that claim, or
|
|
4290
|
+
// the successor never sees the entry and the message is lost outright rather than duplicated.
|
|
4291
|
+
if (!this.plane3MayAct())
|
|
4292
|
+
return;
|
|
3968
4293
|
m.ack();
|
|
3969
4294
|
}
|
|
3970
4295
|
/** Trusted-reader loop: bind the single privileged `reader` durable over `dinbox.>` and re-authorize
|
|
@@ -3998,6 +4323,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3998
4323
|
* revoked/narrowed ACL or out-of-interval seq; on transfer success, ack the mixed entry (durability
|
|
3999
4324
|
* has moved to DLV — an §8 equivalent per-member at-least-once mechanism). The agent acks DLV. */
|
|
4000
4325
|
async readerHandle(m) {
|
|
4326
|
+
// FENCE, same rule as the fan-out writer: no effect and no ack once this daemon has stopped
|
|
4327
|
+
// serving the shard. The entry redelivers to the holder rather than being consumed here.
|
|
4328
|
+
if (!this.plane3MayAct())
|
|
4329
|
+
return;
|
|
4001
4330
|
const pr = parseDinboxPrincipal(m.subject);
|
|
4002
4331
|
if (!pr) {
|
|
4003
4332
|
m.ack();
|
|
@@ -4047,6 +4376,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4047
4376
|
return;
|
|
4048
4377
|
}
|
|
4049
4378
|
}
|
|
4379
|
+
// COMMIT-POINT FENCE. Everything above this line is broker I/O - the ACL read and the member
|
|
4380
|
+
// re-read - so the entry check at the top of the handler is stale by now if this unit was
|
|
4381
|
+
// descheduled inside either. The transfer below is the effect that must not happen on behalf of
|
|
4382
|
+
// a shard we no longer serve; returning without acking leaves it for the holder.
|
|
4383
|
+
if (!this.plane3MayAct())
|
|
4384
|
+
return;
|
|
4050
4385
|
try {
|
|
4051
4386
|
// DLV has no original chat subject, so preserve the channel the trusted fan-out reader derived
|
|
4052
4387
|
// from CHAT. Never let the publisher-controlled payload label choose connector attention.
|
|
@@ -4077,6 +4412,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4077
4412
|
m.nak(2000);
|
|
4078
4413
|
return;
|
|
4079
4414
|
}
|
|
4415
|
+
// The ack is the last commit: the transfer above already happened, so the only question left is
|
|
4416
|
+
// whether THIS daemon may retire the source entry. If it stopped serving mid-transfer, leave the
|
|
4417
|
+
// entry pending - the successor re-transfers and stream-wide dedupe on the msgID collapses the
|
|
4418
|
+
// duplicate, which is the recoverable direction. Acking here is the unrecoverable one.
|
|
4419
|
+
if (!this.plane3MayAct())
|
|
4420
|
+
return;
|
|
4080
4421
|
m.ack();
|
|
4081
4422
|
}
|
|
4082
4423
|
/** Agent-side: bind + pump our pre-created Plane-3 DELIVER durable (`dlv_<id>`). Every message here is
|
|
@@ -5031,9 +5372,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5031
5372
|
* every window, one consumer create and one warning per TTL for as long as the mesh was empty.
|
|
5032
5373
|
*
|
|
5033
5374
|
* A REGISTERING observer (the manager) is itself one of the keys that should be there. An
|
|
5034
|
-
* empty bucket under it means the bucket was wiped since its last heartbeat (the
|
|
5375
|
+
* empty bucket under it means the bucket was wiped since its last heartbeat (the stream
|
|
5035
5376
|
* recreation), and the same wipe took every peer's record: their absence says the bucket is
|
|
5036
|
-
* new, not that they left.
|
|
5377
|
+
* new, not that they left. a reviewer reproduced the previous behaviour at default timing:
|
|
5037
5378
|
* the rebind landed ~0.9s after the recreation, the observer marked every peer AND ITSELF
|
|
5038
5379
|
* offline, and held the view current for up to one heartbeat, a false verdict `cotal ps`
|
|
5039
5380
|
* would print as `mesh offline`. So a registering observer re-publishes its own record NOW,
|
|
@@ -5186,7 +5527,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5186
5527
|
if (this.lastPresenceWatchAt !== 0 && now - this.lastPresenceWatchAt > this.ttlMs) {
|
|
5187
5528
|
this.emitPresenceViewIfChanged();
|
|
5188
5529
|
// Staying stale is the right verdict for a held link (#1045), and the wrong END STATE when
|
|
5189
|
-
// the transport is up and the watch's own consumer is what died. Measured on
|
|
5530
|
+
// the transport is up and the watch's own consumer is what died. Measured on a live deployment
|
|
5190
5531
|
// 2026-09-09: the presence stream was deleted and recreated, its sequence restarted, and
|
|
5191
5532
|
// every observer's ORDERED consumer re-created itself at the OLD start sequence (nats.js
|
|
5192
5533
|
// 3.4.0 resets from its cursor). The broker kept sending idle heartbeats, so the client
|
|
@@ -5508,8 +5849,14 @@ export function wsServers(servers) {
|
|
|
5508
5849
|
* probe dials; a ws(s) broker is by definition published through an HTTPS edge (CDN tunnel,
|
|
5509
5850
|
* reverse proxy), where TLS + upgrade + INFO + the auth round-trip routinely exceeds 1s cold —
|
|
5510
5851
|
* measured ~60% spurious "not reachable" against a Cloudflare-fronted broker. Callers passing an
|
|
5511
|
-
* explicit `timeoutMs` are untouched.
|
|
5512
|
-
|
|
5852
|
+
* explicit `timeoutMs` are untouched.
|
|
5853
|
+
*
|
|
5854
|
+
* EXPORTED because a caller that judges a probe by WHEN it answered has to compare against the
|
|
5855
|
+
* deadline this function actually handed the probe. The delivery watchdog does exactly that, and
|
|
5856
|
+
* hardcoding 1000 there silently misread every ws broker: honest refusals arrive at 2-5s, past a
|
|
5857
|
+
* budget that was never theirs, and would be classified as this process's starvation rather than
|
|
5858
|
+
* the server's refusal. The budget and the judgment must come from one place. */
|
|
5859
|
+
export function defaultProbeTimeoutMs(servers) {
|
|
5513
5860
|
return wsServers(servers) ? 5000 : 1000;
|
|
5514
5861
|
}
|
|
5515
5862
|
/** Pick the dial function by SCHEME: `ws://`/`wss://` servers go through nats-core's
|