@cotal-ai/core 0.53.0 → 0.55.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/auth-provider.d.ts +26 -1
- package/dist/auth-provider.d.ts.map +1 -1
- package/dist/auth-provider.js.map +1 -1
- package/dist/connector.d.ts +7 -1
- package/dist/connector.d.ts.map +1 -1
- package/dist/connector.js.map +1 -1
- package/dist/endpoint-grants.d.ts +7 -2
- package/dist/endpoint-grants.d.ts.map +1 -1
- package/dist/endpoint-grants.js +11 -4
- package/dist/endpoint-grants.js.map +1 -1
- package/dist/endpoint-invoke.d.ts +22 -11
- package/dist/endpoint-invoke.d.ts.map +1 -1
- package/dist/endpoint-invoke.js +255 -85
- package/dist/endpoint-invoke.js.map +1 -1
- package/dist/endpoint-service.d.ts +12 -0
- package/dist/endpoint-service.d.ts.map +1 -1
- package/dist/endpoint-service.js +1 -1
- package/dist/endpoint-service.js.map +1 -1
- package/dist/endpoint-verbs.d.ts +1 -0
- package/dist/endpoint-verbs.d.ts.map +1 -1
- package/dist/endpoint-verbs.js +14 -6
- package/dist/endpoint-verbs.js.map +1 -1
- package/dist/endpoint.d.ts +69 -3
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +217 -17
- package/dist/endpoint.js.map +1 -1
- package/dist/provision.d.ts +26 -1
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +116 -11
- package/dist/provision.js.map +1 -1
- package/dist/remote-manager-authority.d.ts +111 -0
- package/dist/remote-manager-authority.d.ts.map +1 -1
- package/dist/remote-manager-authority.js +175 -1
- package/dist/remote-manager-authority.js.map +1 -1
- package/dist/resolve.d.ts +13 -0
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +16 -0
- package/dist/resolve.js.map +1 -1
- package/dist/schema-profile.d.ts.map +1 -1
- package/dist/schema-profile.js +87 -8
- package/dist/schema-profile.js.map +1 -1
- package/dist/secret-store.d.ts +39 -0
- package/dist/secret-store.d.ts.map +1 -1
- package/dist/secret-store.js +24 -0
- package/dist/secret-store.js.map +1 -1
- package/dist/streams.d.ts +21 -21
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +52 -29
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +18 -0
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +21 -0
- package/dist/subjects.js.map +1 -1
- package/package.json +1 -1
package/dist/endpoint.js
CHANGED
|
@@ -48,6 +48,27 @@ export const MULTI_FILTER_BATCH = 1_000;
|
|
|
48
48
|
* issuing one read per channel: a space large enough to need batches must not get the fan-out back
|
|
49
49
|
* under another name. */
|
|
50
50
|
export const MULTI_FILTER_READ_CONCURRENCY = 4;
|
|
51
|
+
/** A presence bucket that has refused consecutive writes for at least one full presence TTL.
|
|
52
|
+
* The condition is non-transient: ordinary heartbeat retry has already failed for the whole
|
|
53
|
+
* liveness window, so callers should report the roster as last-known until a write succeeds. */
|
|
54
|
+
export class PresenceWriteStuckError extends Error {
|
|
55
|
+
bucket;
|
|
56
|
+
since;
|
|
57
|
+
consecutiveFailures;
|
|
58
|
+
ttlMs;
|
|
59
|
+
lastError;
|
|
60
|
+
code = "presence-write-stuck";
|
|
61
|
+
transient = false;
|
|
62
|
+
constructor(bucket, since, consecutiveFailures, ttlMs, lastError) {
|
|
63
|
+
super(`presence writes to bucket ${JSON.stringify(bucket)} have failed ${consecutiveFailures} consecutive times for at least one TTL (${ttlMs}ms); the presence view is not live until a write succeeds or the broker store is repaired${lastError ? ` (last refusal: ${lastError})` : ""}`);
|
|
64
|
+
this.bucket = bucket;
|
|
65
|
+
this.since = since;
|
|
66
|
+
this.consecutiveFailures = consecutiveFailures;
|
|
67
|
+
this.ttlMs = ttlMs;
|
|
68
|
+
this.lastError = lastError;
|
|
69
|
+
this.name = "PresenceWriteStuckError";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
51
72
|
/**
|
|
52
73
|
* Events: "message" (CotalMessage), "presence" (PresenceEvent), "roster" (Presence[]), "error" (Error),
|
|
53
74
|
* "connection" ({ connected: boolean }) — true on every successful (re)bind (initial start, manual
|
|
@@ -225,6 +246,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
225
246
|
presenceWriteFailingSince;
|
|
226
247
|
/** #1356: the broker's last refusal message, kept alongside the start time for diagnosis. */
|
|
227
248
|
lastPresenceWriteError;
|
|
249
|
+
/** Consecutive latest-evidence refusals on the current connection epoch. A success resets it. */
|
|
250
|
+
presenceWriteFailures = 0;
|
|
251
|
+
/** One named non-transient warning per failed run. A success re-arms it. */
|
|
252
|
+
presenceWriteEscalated = false;
|
|
228
253
|
/** #1461: monotonic per-put generation on the presence path, so a settle can tell whether it is
|
|
229
254
|
* the latest evidence on its epoch. {@link publishPresence} snapshots it at put start and every
|
|
230
255
|
* settle records its generation here: a settle is the latest evidence only while no put that
|
|
@@ -310,6 +335,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
310
335
|
/** Per-endpoint-name {@link resolveService} cache for {@link invokeService} — dropped on a
|
|
311
336
|
* `failed-precondition` currency refusal (the described incarnation was superseded). */
|
|
312
337
|
resolvedServices = new Map();
|
|
338
|
+
/** Active goal followers awaiting outcome, tracked so endpoint stop can cancel them immediately
|
|
339
|
+
* and connection replacement / reconnect can trigger reconciliation. */
|
|
340
|
+
activeGoalFollowers = new Set();
|
|
313
341
|
/** How many calls {@link invokeService} has silently recovered from a bind refusal (§13.2) — the
|
|
314
342
|
* class-queue splits this endpoint hit and survived.
|
|
315
343
|
*
|
|
@@ -1083,6 +1111,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1083
1111
|
if (this.stopped)
|
|
1084
1112
|
return;
|
|
1085
1113
|
this.emit("connection", { connected: true });
|
|
1114
|
+
if (this.nc) {
|
|
1115
|
+
for (const follower of this.activeGoalFollowers) {
|
|
1116
|
+
follower.reconnected(this.nc);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1086
1119
|
}
|
|
1087
1120
|
/** Tear down everything {@link connectAndBind} (re)creates, so a rebind can't leak a
|
|
1088
1121
|
* second heartbeat, double-pump a consumer, or keep stale roster ghosts. Caller-owned
|
|
@@ -1446,6 +1479,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1446
1479
|
if (this.nc)
|
|
1447
1480
|
this.disableLibraryReconnect(this.nc);
|
|
1448
1481
|
this.stopped = true;
|
|
1482
|
+
for (const follower of this.activeGoalFollowers) {
|
|
1483
|
+
follower.cancel();
|
|
1484
|
+
}
|
|
1485
|
+
this.activeGoalFollowers.clear();
|
|
1449
1486
|
this.presenceEpoch++;
|
|
1450
1487
|
this.presenceRebind = undefined;
|
|
1451
1488
|
// Wake a reestablishLoop sitting in backoff so it sees `stopped` and exits instead of
|
|
@@ -1862,6 +1899,55 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1862
1899
|
throw new Error("invokeService: the issued generation is not yet discovered on this connection");
|
|
1863
1900
|
return { ...triple, generation: this.issuedGeneration };
|
|
1864
1901
|
}
|
|
1902
|
+
/** Follow this caller's goal on its standing connection, subscribing before submission.
|
|
1903
|
+
* A submitter may use a separate short-lived control credential for the same caller triple;
|
|
1904
|
+
* the accepted action's readiness budget can outlive that credential. This endpoint retains
|
|
1905
|
+
* its existing credential renewal and caller-scoped progress grants. */
|
|
1906
|
+
async followServiceGoal(endpoint, submit, deadlineMs = 10_000, opts = {}) {
|
|
1907
|
+
if (!this.nc)
|
|
1908
|
+
throw new Error(this.notLiveMsg());
|
|
1909
|
+
if (this.stopped)
|
|
1910
|
+
throw new Error("endpoint stopped - cannot follow goal");
|
|
1911
|
+
const abortController = new AbortController();
|
|
1912
|
+
const onAbort = () => abortController.abort();
|
|
1913
|
+
if (opts.signal?.aborted)
|
|
1914
|
+
onAbort();
|
|
1915
|
+
else
|
|
1916
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1917
|
+
let reconnectHandler;
|
|
1918
|
+
const entry = {
|
|
1919
|
+
cancel: () => abortController.abort(),
|
|
1920
|
+
reconnected: (nc) => reconnectHandler?.(nc),
|
|
1921
|
+
};
|
|
1922
|
+
this.activeGoalFollowers.add(entry);
|
|
1923
|
+
try {
|
|
1924
|
+
const reconcile = opts.reconcile ?? (async (goalId, _attributed, context) => {
|
|
1925
|
+
const current = this.serviceCaller();
|
|
1926
|
+
if (current.owner !== context.caller.owner || current.actor !== context.caller.actor || current.uid !== context.caller.uid)
|
|
1927
|
+
throw new Error("the renewed goal reader no longer has the accepting caller's full identity");
|
|
1928
|
+
// Static callers retain their authorized class route; knowing an instance id grants no rights.
|
|
1929
|
+
const q = await this.invokeService(endpoint, "goal-result", { goalId }, {
|
|
1930
|
+
deadlineMs: Math.min(deadlineMs, context.deadlineMs), signal: context.signal,
|
|
1931
|
+
});
|
|
1932
|
+
if (q.reply.ok !== true)
|
|
1933
|
+
throw new Error(q.reply.error?.message ?? "goal-result query refused");
|
|
1934
|
+
return q.reply.data;
|
|
1935
|
+
});
|
|
1936
|
+
return await submitAndFollowGoal(this.nc, this.space, endpoint, this.serviceCaller(), deadlineMs, submit, {
|
|
1937
|
+
reconcile,
|
|
1938
|
+
currentNc: () => this.nc,
|
|
1939
|
+
onReconnect: (cb) => {
|
|
1940
|
+
reconnectHandler = cb;
|
|
1941
|
+
return () => { reconnectHandler = undefined; };
|
|
1942
|
+
},
|
|
1943
|
+
signal: abortController.signal,
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
finally {
|
|
1947
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
1948
|
+
this.activeGoalFollowers.delete(entry);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1865
1951
|
/** GENERIC v0.4 service invoke over this endpoint's own connection (P2 item 1, 1c.2b): resolve
|
|
1866
1952
|
* the named endpoint's registered surface — describe, §13.7 store fetch, digest-verified
|
|
1867
1953
|
* recompile ({@link resolveService}; cached per endpoint name) — and invoke one command. The
|
|
@@ -1886,18 +1972,40 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1886
1972
|
throw new Error(this.notLiveMsg());
|
|
1887
1973
|
const nc = this.nc;
|
|
1888
1974
|
const caller = this.serviceCaller();
|
|
1889
|
-
const resolve = async () => {
|
|
1890
|
-
|
|
1975
|
+
const resolve = async (signal = opts.signal) => {
|
|
1976
|
+
signal?.throwIfAborted();
|
|
1977
|
+
const cached = opts.instanceId === undefined ? this.resolvedServices.get(endpoint) : undefined;
|
|
1891
1978
|
if (cached)
|
|
1892
1979
|
return cached;
|
|
1893
|
-
const svc = await resolveService(nc, this.space, endpoint, caller, {
|
|
1894
|
-
|
|
1980
|
+
const svc = await resolveService(nc, this.space, endpoint, caller, {
|
|
1981
|
+
deadlineMs: opts.deadlineMs ?? 10_000,
|
|
1982
|
+
signal,
|
|
1983
|
+
...(opts.instanceId !== undefined ? { instanceId: opts.instanceId } : {}),
|
|
1984
|
+
});
|
|
1985
|
+
// A pinned resolve never enters the endpoint-only class cache. Otherwise a later unpinned call
|
|
1986
|
+
// could silently inherit that instance, or a different pin could reuse the wrong manager.
|
|
1987
|
+
if (opts.instanceId === undefined)
|
|
1988
|
+
this.resolvedServices.set(endpoint, svc);
|
|
1895
1989
|
return svc;
|
|
1896
1990
|
};
|
|
1897
1991
|
const invokeOpts = { ...(opts.target ? { target: opts.target } : {}), ...(opts.deadlineMs !== undefined ? { deadlineMs: opts.deadlineMs } : {}) };
|
|
1898
|
-
const
|
|
1992
|
+
const invokeResolved = (service, signal) => {
|
|
1993
|
+
signal?.throwIfAborted();
|
|
1994
|
+
if (opts.follow && !service.commands.has("goal-result")) {
|
|
1995
|
+
return Promise.resolve({
|
|
1996
|
+
reply: { v: 1, id: randomUUID(), ok: false, error: {
|
|
1997
|
+
code: "failed-precondition", outcome: "not-executed",
|
|
1998
|
+
message: `endpoint "${endpoint}" does not support "goal-result"; upgrade manager to enable durable goal following (SPEC 13.6)`,
|
|
1999
|
+
} },
|
|
2000
|
+
responder: { endpoint, instanceId: service.responder.instanceId, epoch: service.responder.epoch },
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
return invokeCommand(nc, this.space, service, command, args, { ...invokeOpts, signal });
|
|
2004
|
+
};
|
|
2005
|
+
const doInvoke = async (signal = opts.signal) => {
|
|
2006
|
+
signal?.throwIfAborted();
|
|
1899
2007
|
try {
|
|
1900
|
-
const r = await
|
|
2008
|
+
const r = await invokeResolved(await resolve(signal), signal);
|
|
1901
2009
|
// THE RESPONDER FENCED IT (§13.2 `ai.cotal.ep.bind-refused`): a class member saw the call
|
|
1902
2010
|
// was bound to a different incarnation and refused BEFORE running the command.
|
|
1903
2011
|
//
|
|
@@ -1945,7 +2053,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1945
2053
|
// the fence's marker does: a responder answered and the effect may have landed.
|
|
1946
2054
|
let reissueTarget;
|
|
1947
2055
|
try {
|
|
1948
|
-
reissueTarget = await resolve();
|
|
2056
|
+
reissueTarget = await resolve(signal);
|
|
2057
|
+
if (opts.follow && !reissueTarget.commands.has("goal-result")) {
|
|
2058
|
+
throw new Error(`endpoint "${endpoint}" does not support "goal-result"; upgrade manager to enable durable goal following (SPEC 13.6)`);
|
|
2059
|
+
}
|
|
1949
2060
|
}
|
|
1950
2061
|
catch (reissue) {
|
|
1951
2062
|
throw new EpEnvelopeError(refusalCode, `${endpoint}.${command} WAS NOT RUN - the incarnation that received it refused it before any effect, and the re-issue could not be resolved: ${reissue instanceof Error ? reissue.message : String(reissue)}. Re-resolve and re-issue when the endpoint is reachable (SPEC 13.2)`, r.reply.error?.details,
|
|
@@ -1955,7 +2066,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1955
2066
|
// re-issue never went out. Prose is for the reader; this is for the machine.
|
|
1956
2067
|
"not-executed");
|
|
1957
2068
|
}
|
|
1958
|
-
return await
|
|
2069
|
+
return await invokeResolved(reissueTarget, signal);
|
|
1959
2070
|
}
|
|
1960
2071
|
return r;
|
|
1961
2072
|
}
|
|
@@ -1992,7 +2103,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1992
2103
|
this.resolvedServices.delete(endpoint);
|
|
1993
2104
|
if (!isRepeatSafeCommand(endpoint, command))
|
|
1994
2105
|
throw e;
|
|
1995
|
-
return await
|
|
2106
|
+
return await invokeResolved(await resolve(signal), signal);
|
|
1996
2107
|
}
|
|
1997
2108
|
// An UNMARKED `failed-precondition` is the resolve's own refusal, raised before any command
|
|
1998
2109
|
// was published, so re-resolving once is a repair. The `replyRefusedBeforeEffect` half keeps
|
|
@@ -2003,14 +2114,14 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2003
2114
|
if (e.code !== "failed-precondition" || replyRefusedBeforeEffect(e.toEpError()))
|
|
2004
2115
|
throw e;
|
|
2005
2116
|
this.resolvedServices.delete(endpoint);
|
|
2006
|
-
return await
|
|
2117
|
+
return await invokeResolved(await resolve(signal), signal);
|
|
2007
2118
|
}
|
|
2008
2119
|
};
|
|
2009
2120
|
// P2 item 2 (2b): a goal-bearing command (spawn/launch) follows its acceptance to the terminal so
|
|
2010
2121
|
// the caller still returns on the real outcome (UX unchanged); every other command replies directly.
|
|
2011
2122
|
if (!opts.follow)
|
|
2012
2123
|
return doInvoke();
|
|
2013
|
-
return
|
|
2124
|
+
return this.followServiceGoal(endpoint, doInvoke, opts.deadlineMs ?? 10_000, { signal: opts.signal });
|
|
2014
2125
|
}
|
|
2015
2126
|
/** Send a durable-membership request to the SERVER-SIDE delivery daemon (`ctl.delivery`) and await its
|
|
2016
2127
|
* reply. Unlike {@link requestControl}, the reply rides a subject UNDER `ctl.delivery.<id>.>` (not the
|
|
@@ -2512,7 +2623,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2512
2623
|
}
|
|
2513
2624
|
else {
|
|
2514
2625
|
const closedEpoch = err.name === "ClosedConnectionError" || /^closed connection$/i.test(err.message);
|
|
2515
|
-
const timeout =
|
|
2626
|
+
const timeout = isTimeoutError(err);
|
|
2516
2627
|
const dyingEpochTimeout = timeout && (this.reconnecting || !this.nc || this.nc.isClosed());
|
|
2517
2628
|
// Cleanup of an ordered consumer: a delete timeout means the broker did not answer in time,
|
|
2518
2629
|
// not that the endpoint is unusable. The broker reaps an idle/ephemeral consumer anyway.
|
|
@@ -3034,6 +3145,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3034
3145
|
if (s.type === "reconnect") {
|
|
3035
3146
|
this.armAuthExpiryReconnectFence(nc);
|
|
3036
3147
|
this.emit("transport", { connected: true, server: s.server });
|
|
3148
|
+
for (const follower of this.activeGoalFollowers) {
|
|
3149
|
+
follower.reconnected(nc);
|
|
3150
|
+
}
|
|
3037
3151
|
continue;
|
|
3038
3152
|
}
|
|
3039
3153
|
if (s.type === "close") {
|
|
@@ -3395,12 +3509,41 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3395
3509
|
}
|
|
3396
3510
|
})().catch((e) => this.emit("error", e));
|
|
3397
3511
|
}
|
|
3512
|
+
/** The principal recorded as holding a shard's delivery lease, read by THIS process from the row,
|
|
3513
|
+
* or `undefined` when no row is live or the read failed.
|
|
3514
|
+
*
|
|
3515
|
+
* THE POINT IS WHO ESTABLISHES THE FACT. Everything a requester learns from a request/reply rail
|
|
3516
|
+
* is something a responder chose to send, and the delivery-admin rail is queue-grouped, so any
|
|
3517
|
+
* process permitted to serve it decides what arrives. The lease row is the other kind of fact: a
|
|
3518
|
+
* KV key whose only writer is the `delivery` credential's own `lease.*` grant, read here under
|
|
3519
|
+
* THIS endpoint's credential, which no reply on any rail participates in. A caller comparing an
|
|
3520
|
+
* answerer against the holder needs that, rather than the answerer's word for which one it is.
|
|
3521
|
+
*
|
|
3522
|
+
* `undefined` is a genuine unknown and is NEVER a statement that nobody holds the shard: a read
|
|
3523
|
+
* that throws (a denied or unreadable bucket, a broker that will not answer) must not collapse
|
|
3524
|
+
* into "nothing is there". Callers must treat it as a failure to determine (#1694). */
|
|
3525
|
+
async deliveryLeaseHolder(shardIndex) {
|
|
3526
|
+
try {
|
|
3527
|
+
return (await this.readDeliveryLeaseEntry(shardIndex))?.info.holder;
|
|
3528
|
+
}
|
|
3529
|
+
catch {
|
|
3530
|
+
return undefined;
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3398
3533
|
/** The lease row AND the KV revision it is at. The revision is the CAS token every renew and the
|
|
3399
3534
|
* CAS release are argued against, so a caller re-establishing ownership after a failed renew
|
|
3400
3535
|
* needs the BROKER's sequence, not the one it last cached: a renew can fail with its write
|
|
3401
3536
|
* already applied (a lost reply, a reconnect mid-request), which leaves the cached revision one
|
|
3402
3537
|
* behind forever and every subsequent CAS refused over a sequence this process itself moved ,
|
|
3403
|
-
* read as somebody else's takeover, which is the #1318 misreading in a second costume.
|
|
3538
|
+
* read as somebody else's takeover, which is the #1318 misreading in a second costume.
|
|
3539
|
+
*
|
|
3540
|
+
* ABSENT and UNREADABLE are different facts and this method keeps them apart (#1694): `undefined`
|
|
3541
|
+
* means NO row is live (no key, or DEL/PURGE), while a key that EXISTS but does not parse as a
|
|
3542
|
+
* {@link DeliveryLeaseInfo} THROWS. Folding the parse failure into `undefined` made a live-but-
|
|
3543
|
+
* corrupt row read as "nothing there", and the store-identity challenge's absence settlement
|
|
3544
|
+
* then certified a remint over a row it could not read. A caller that wants the old swallow
|
|
3545
|
+
* (an owner testing "is this provably MINE") still gets its safe answer: an unparseable row is
|
|
3546
|
+
* not provably anyone's, and catching here reads as `false` exactly as before. */
|
|
3404
3547
|
async readDeliveryLeaseEntry(shardIndex) {
|
|
3405
3548
|
const e = await (await this.deliveryRegistry()).get(leaseKey(shardIndex));
|
|
3406
3549
|
if (!e || e.operation === "DEL" || e.operation === "PURGE")
|
|
@@ -3408,8 +3551,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3408
3551
|
try {
|
|
3409
3552
|
return { info: e.json(), revision: e.revision };
|
|
3410
3553
|
}
|
|
3411
|
-
catch {
|
|
3412
|
-
|
|
3554
|
+
catch (err) {
|
|
3555
|
+
throw new Error(`delivery lease row "${leaseKey(shardIndex)}" exists but does not parse as a lease record (${err.message}) - it is unreadable, not absent`);
|
|
3413
3556
|
}
|
|
3414
3557
|
}
|
|
3415
3558
|
/** Ensure + bind the manager singleton-lease bucket. Mirrors the presence-bucket pattern (connectAndBind):
|
|
@@ -4281,8 +4424,28 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4281
4424
|
return { ok: false, error: "reloadStoreIdentity: this daemon did not name the SecretStore it reloads from" };
|
|
4282
4425
|
try {
|
|
4283
4426
|
const identity = this.plane3.reloadStoreIdentity();
|
|
4427
|
+
// WHO IS ANSWERING is part of the answer. This rail is queue-grouped, so the broker hands
|
|
4428
|
+
// the request to any bound responder, and every process with a `delivery` credential for
|
|
4429
|
+
// the space can bind it, while only the DELIVERY LEASE HOLDER actually reloads the standing
|
|
4430
|
+
// credentials. A bare store identity therefore lets a second responder's store stand in for
|
|
4431
|
+
// the reloading process's, and the manager's remint decision is then made about a process
|
|
4432
|
+
// that reloads nothing. So the reply carries this responder's wire identity and whether it
|
|
4433
|
+
// holds the lease, and the caller requires the binding (#1694).
|
|
4434
|
+
//
|
|
4435
|
+
// The lease read is the LIVE row, not a cached claim: a holder that lost the shard must not
|
|
4436
|
+
// keep asserting it. A read that fails is reported as `false` rather than thrown, because
|
|
4437
|
+
// "I cannot prove I hold the shard" is what a non-holder's answer says, and it sends the
|
|
4438
|
+
// caller down the same fail-closed path rather than inventing an authority.
|
|
4439
|
+
let holdsDeliveryLease = false;
|
|
4440
|
+
try {
|
|
4441
|
+
const own = await this.readDeliveryLeaseEntry(0);
|
|
4442
|
+
holdsDeliveryLease = own !== undefined && this.ownsDeliveryLease(own.info);
|
|
4443
|
+
}
|
|
4444
|
+
catch {
|
|
4445
|
+
holdsDeliveryLease = false;
|
|
4446
|
+
}
|
|
4284
4447
|
// Round-trip through the closed parser so a hook cannot smuggle extra fields onto the rail.
|
|
4285
|
-
return { ok: true, data: parseSecretStoreIdentity(identity) };
|
|
4448
|
+
return { ok: true, data: { identity: parseSecretStoreIdentity(identity), responder: this.card.id, holdsDeliveryLease } };
|
|
4286
4449
|
}
|
|
4287
4450
|
catch (e) {
|
|
4288
4451
|
return { ok: false, error: e.message };
|
|
@@ -4542,15 +4705,20 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4542
4705
|
return;
|
|
4543
4706
|
if (!this.ownLifecycleUid)
|
|
4544
4707
|
return; // no lifecycle uid — never provisioned for Plane-3 (its durable is lifecycle-keyed)
|
|
4708
|
+
const durable = dlvDurable(this.owner, this.actor, this.ownLifecycleUid);
|
|
4545
4709
|
let consumer;
|
|
4546
4710
|
try {
|
|
4547
|
-
consumer = await this.js.consumers.get(dlvStream(this.space),
|
|
4711
|
+
consumer = await this.js.consumers.get(dlvStream(this.space), durable);
|
|
4548
4712
|
}
|
|
4549
4713
|
catch (e) {
|
|
4550
4714
|
if (isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
|
|
4551
4715
|
return;
|
|
4552
4716
|
throw e; // a denied bind is not proof Plane-3 is absent
|
|
4553
4717
|
}
|
|
4718
|
+
const lease = await this.readDeliveryLease(0);
|
|
4719
|
+
const liveDeliveryPlane = lease?.ready === true;
|
|
4720
|
+
if (!liveDeliveryPlane)
|
|
4721
|
+
this.emit("warning", new Error(`delivery durable "${durable}" for space "${this.space}" bound while the plane this connection reaches has no ready delivery lease, so the durable delivers nothing until a delivery daemon serves this plane. If a daemon serves this space on another plane, reconnect against it, which re-binds the durable there.`));
|
|
4554
4722
|
const msgs = await consumer.consume();
|
|
4555
4723
|
this.streamMsgs.push(msgs);
|
|
4556
4724
|
void (async () => {
|
|
@@ -5302,6 +5470,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5302
5470
|
if (epoch === this.presenceEpoch && !this.stopped && !superseded) {
|
|
5303
5471
|
this.presenceWriteFailingSince ??= Date.now();
|
|
5304
5472
|
this.lastPresenceWriteError = e?.message ?? String(e);
|
|
5473
|
+
this.presenceWriteFailures++;
|
|
5474
|
+
this.escalatePresenceWriteFailureIfStuck();
|
|
5305
5475
|
}
|
|
5306
5476
|
throw e;
|
|
5307
5477
|
}
|
|
@@ -5340,6 +5510,21 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5340
5510
|
clearPresenceWriteFailure() {
|
|
5341
5511
|
this.presenceWriteFailingSince = undefined;
|
|
5342
5512
|
this.lastPresenceWriteError = undefined;
|
|
5513
|
+
this.presenceWriteFailures = 0;
|
|
5514
|
+
this.presenceWriteEscalated = false;
|
|
5515
|
+
}
|
|
5516
|
+
/** A heartbeat retry remains recoverable inside one TTL. Once two or more consecutive writes have
|
|
5517
|
+
* failed across the full window, ordinary retry has exhausted the roster's liveness budget: raise
|
|
5518
|
+
* one named, non-transient warning. Later failures keep the duration/count current without flooding
|
|
5519
|
+
* the operator log; the next successful write clears and re-arms the condition. */
|
|
5520
|
+
escalatePresenceWriteFailureIfStuck() {
|
|
5521
|
+
if (this.presenceWriteEscalated ||
|
|
5522
|
+
this.presenceWriteFailingSince === undefined ||
|
|
5523
|
+
this.presenceWriteFailures < 2 ||
|
|
5524
|
+
Date.now() - this.presenceWriteFailingSince < this.ttlMs)
|
|
5525
|
+
return;
|
|
5526
|
+
this.presenceWriteEscalated = true;
|
|
5527
|
+
this.emitRecoverable(new PresenceWriteStuckError(presenceBucket(this.space), this.presenceWriteFailingSince, this.presenceWriteFailures, this.ttlMs, this.lastPresenceWriteError));
|
|
5343
5528
|
}
|
|
5344
5529
|
/** #1356: the presence bucket has been refusing writes since this time, or `undefined` when the
|
|
5345
5530
|
* last publish succeeded. Cleared by a successful write that is the latest evidence on its
|
|
@@ -5357,6 +5542,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
5357
5542
|
forMs: Date.now() - this.presenceWriteFailingSince,
|
|
5358
5543
|
error: this.lastPresenceWriteError,
|
|
5359
5544
|
bucket: presenceBucket(this.space),
|
|
5545
|
+
consecutiveFailures: this.presenceWriteFailures,
|
|
5546
|
+
stuck: this.presenceWriteEscalated,
|
|
5360
5547
|
};
|
|
5361
5548
|
}
|
|
5362
5549
|
/** Bind a presence watch on the current connection. Resolves true when the watch was
|
|
@@ -6316,6 +6503,14 @@ export async function isReachable(servers = DEFAULT_SERVER, opts = {}) {
|
|
|
6316
6503
|
return e instanceof AuthorizationError || e instanceof UserAuthenticationExpiredError;
|
|
6317
6504
|
}
|
|
6318
6505
|
}
|
|
6506
|
+
/** True when `err` is a dial/consumer-op timeout rather than a real refusal — the one shared test
|
|
6507
|
+
* for "the operation ran out of its own budget", used both by {@link classifyProbeFailure} (#851:
|
|
6508
|
+
* a probe timeout must never collapse into `unreachable`, which a TLS-required target then
|
|
6509
|
+
* misreads as a trust failure) and by {@link Endpoint#disarmMembershipWatch}'s consumer-delete
|
|
6510
|
+
* cleanup, which predates it. */
|
|
6511
|
+
function isTimeoutError(err) {
|
|
6512
|
+
return err instanceof Error && (err.name === "TimeoutError" || /timeout/i.test(err.message));
|
|
6513
|
+
}
|
|
6319
6514
|
/** Like {@link isReachable}, but distinguishes "up but won't take these creds" from "nothing there".
|
|
6320
6515
|
* `spawn` needs the difference: auth-required → name the trust dir + next step; unreachable → the
|
|
6321
6516
|
* mesh is down (prune the stale entry, tell the user to `cotal up`). Pass `creds` to confirm a
|
|
@@ -6373,6 +6568,11 @@ function classifyProbeFailure(e, opts) {
|
|
|
6373
6568
|
// The broker answered but rejected these creds (so it IS up) — auth-required, not stale-auth.
|
|
6374
6569
|
if (e instanceof AuthorizationError)
|
|
6375
6570
|
return { ok: false, reason: "auth-required" };
|
|
6571
|
+
// A dial that ran out of its own budget is neither a refusal nor a dead broker — it is latency.
|
|
6572
|
+
// `e` is undefined when the tcpDialable gate refused before any connect() attempt; that path has
|
|
6573
|
+
// no timeout to inspect and must stay `unreachable` (nothing answered at all).
|
|
6574
|
+
if (e !== undefined && isTimeoutError(e))
|
|
6575
|
+
return { ok: false, reason: "timeout" };
|
|
6376
6576
|
return { ok: false, reason: "unreachable" };
|
|
6377
6577
|
}
|
|
6378
6578
|
//# sourceMappingURL=endpoint.js.map
|