@cotal-ai/core 0.54.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/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
@@ -1949,11 +1974,18 @@ export class CotalEndpoint extends EventEmitter {
1949
1974
  const caller = this.serviceCaller();
1950
1975
  const resolve = async (signal = opts.signal) => {
1951
1976
  signal?.throwIfAborted();
1952
- const cached = this.resolvedServices.get(endpoint);
1977
+ const cached = opts.instanceId === undefined ? this.resolvedServices.get(endpoint) : undefined;
1953
1978
  if (cached)
1954
1979
  return cached;
1955
- const svc = await resolveService(nc, this.space, endpoint, caller, { deadlineMs: opts.deadlineMs ?? 10_000, signal });
1956
- this.resolvedServices.set(endpoint, svc);
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);
1957
1989
  return svc;
1958
1990
  };
1959
1991
  const invokeOpts = { ...(opts.target ? { target: opts.target } : {}), ...(opts.deadlineMs !== undefined ? { deadlineMs: opts.deadlineMs } : {}) };
@@ -2591,7 +2623,7 @@ export class CotalEndpoint extends EventEmitter {
2591
2623
  }
2592
2624
  else {
2593
2625
  const closedEpoch = err.name === "ClosedConnectionError" || /^closed connection$/i.test(err.message);
2594
- const timeout = err.name === "TimeoutError" || /timeout/i.test(err.message);
2626
+ const timeout = isTimeoutError(err);
2595
2627
  const dyingEpochTimeout = timeout && (this.reconnecting || !this.nc || this.nc.isClosed());
2596
2628
  // Cleanup of an ordered consumer: a delete timeout means the broker did not answer in time,
2597
2629
  // not that the endpoint is unusable. The broker reaps an idle/ephemeral consumer anyway.
@@ -4673,15 +4705,20 @@ export class CotalEndpoint extends EventEmitter {
4673
4705
  return;
4674
4706
  if (!this.ownLifecycleUid)
4675
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);
4676
4709
  let consumer;
4677
4710
  try {
4678
- consumer = await this.js.consumers.get(dlvStream(this.space), dlvDurable(this.owner, this.actor, this.ownLifecycleUid));
4711
+ consumer = await this.js.consumers.get(dlvStream(this.space), durable);
4679
4712
  }
4680
4713
  catch (e) {
4681
4714
  if (isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
4682
4715
  return;
4683
4716
  throw e; // a denied bind is not proof Plane-3 is absent
4684
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.`));
4685
4722
  const msgs = await consumer.consume();
4686
4723
  this.streamMsgs.push(msgs);
4687
4724
  void (async () => {
@@ -5433,6 +5470,8 @@ export class CotalEndpoint extends EventEmitter {
5433
5470
  if (epoch === this.presenceEpoch && !this.stopped && !superseded) {
5434
5471
  this.presenceWriteFailingSince ??= Date.now();
5435
5472
  this.lastPresenceWriteError = e?.message ?? String(e);
5473
+ this.presenceWriteFailures++;
5474
+ this.escalatePresenceWriteFailureIfStuck();
5436
5475
  }
5437
5476
  throw e;
5438
5477
  }
@@ -5471,6 +5510,21 @@ export class CotalEndpoint extends EventEmitter {
5471
5510
  clearPresenceWriteFailure() {
5472
5511
  this.presenceWriteFailingSince = undefined;
5473
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));
5474
5528
  }
5475
5529
  /** #1356: the presence bucket has been refusing writes since this time, or `undefined` when the
5476
5530
  * last publish succeeded. Cleared by a successful write that is the latest evidence on its
@@ -5488,6 +5542,8 @@ export class CotalEndpoint extends EventEmitter {
5488
5542
  forMs: Date.now() - this.presenceWriteFailingSince,
5489
5543
  error: this.lastPresenceWriteError,
5490
5544
  bucket: presenceBucket(this.space),
5545
+ consecutiveFailures: this.presenceWriteFailures,
5546
+ stuck: this.presenceWriteEscalated,
5491
5547
  };
5492
5548
  }
5493
5549
  /** Bind a presence watch on the current connection. Resolves true when the watch was
@@ -6447,6 +6503,14 @@ export async function isReachable(servers = DEFAULT_SERVER, opts = {}) {
6447
6503
  return e instanceof AuthorizationError || e instanceof UserAuthenticationExpiredError;
6448
6504
  }
6449
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
+ }
6450
6514
  /** Like {@link isReachable}, but distinguishes "up but won't take these creds" from "nothing there".
6451
6515
  * `spawn` needs the difference: auth-required → name the trust dir + next step; unreachable → the
6452
6516
  * mesh is down (prune the stale entry, tell the user to `cotal up`). Pass `creds` to confirm a
@@ -6504,6 +6568,11 @@ function classifyProbeFailure(e, opts) {
6504
6568
  // The broker answered but rejected these creds (so it IS up) — auth-required, not stale-auth.
6505
6569
  if (e instanceof AuthorizationError)
6506
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" };
6507
6576
  return { ok: false, reason: "unreachable" };
6508
6577
  }
6509
6578
  //# sourceMappingURL=endpoint.js.map