@cotal-ai/delivery 0.48.2 → 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/delivery.d.ts +87 -1
- package/dist/delivery.d.ts.map +1 -1
- package/dist/delivery.js +783 -33
- package/dist/delivery.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/watchdog.d.ts +313 -0
- package/dist/watchdog.d.ts.map +1 -0
- package/dist/watchdog.js +374 -0
- package/dist/watchdog.js.map +1 -0
- package/package.json +3 -3
package/dist/watchdog.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The delivery daemon's two self-termination decisions, as PURE functions over evidence.
|
|
3
|
+
*
|
|
4
|
+
* Both decisions used to be taken inline from a wall clock, and a wall clock cannot tell "the
|
|
5
|
+
* broker is gone" from "this process did not get scheduled". On 2026-09-05 that cost Plane-3 nine
|
|
6
|
+
* minutes across two exits while `nats-server` had been up continuously for 7.3 days and was
|
|
7
|
+
* listening throughout; the real condition was load 311 on 12 cores (#1318). The detector failed in
|
|
8
|
+
* the direction of its own failure, and it did so exactly when delivery was most needed.
|
|
9
|
+
*
|
|
10
|
+
* Pulled out here for two reasons. The obvious one is that a decision expressed as a function of
|
|
11
|
+
* named evidence is readable. The load-bearing one is that it becomes GRADEABLE without a broker,
|
|
12
|
+
* a host under load, or a spawned process: every branch below is reachable from a literal, so each
|
|
13
|
+
* ACCEPTING branch can be paired with a REFUSING case that differs only in the evidence, and a
|
|
14
|
+
* mutation to any branch reddens a named cell rather than a timing-dependent end-to-end assertion.
|
|
15
|
+
*/
|
|
16
|
+
/** How often the broker watch is scheduled. The measured gap between two firings minus this is
|
|
17
|
+
* LOCAL SCHEDULER LAG: a fact about this process, not about the server. */
|
|
18
|
+
export const PROBE_INTERVAL_MS = 2000;
|
|
19
|
+
/** The deadline `isReachable` gives a non-websocket probe. Named here because the probe's own
|
|
20
|
+
* budget is the yardstick that makes a late answer legible as lateness. */
|
|
21
|
+
export const PROBE_BUDGET_MS = 1000;
|
|
22
|
+
/** How far past its own budget an answer may arrive and still be believed as a statement about the
|
|
23
|
+
* server. A probe that answers at its deadline is a normal timeout; one that answers at twice its
|
|
24
|
+
* deadline did not have that deadline enforced against the SERVER at all. It had it enforced
|
|
25
|
+
* against a process that was not running when it expired. Two rather than something larger because
|
|
26
|
+
* the measured case is 2554ms on a 1000ms budget (#1318's triage), and rather than something
|
|
27
|
+
* smaller because it must never reclassify an honest timeout on a merely busy host as starvation,
|
|
28
|
+
* which would blunt the true-positive path this repair is required to preserve. */
|
|
29
|
+
export const PROBE_LATE_FACTOR = 2;
|
|
30
|
+
/** How often the deschedule sampler wakes during a probe. Short enough to resolve the stalls that
|
|
31
|
+
* matter (tens of ms), long enough that the measurement is not itself a meaningful load. */
|
|
32
|
+
export const PROBE_SAMPLE_MS = 25;
|
|
33
|
+
/** How much of its promised budget the server must have been DENIED before a refusal stops counting
|
|
34
|
+
* as evidence about the server. Ordinary Node timer jitter measures tens of milliseconds on a
|
|
35
|
+
* perfectly healthy host, so any non-zero threshold would disqualify every honest budget-ended
|
|
36
|
+
* refusal and leave the daemon exiting only on its backstop. A server handed more than half the
|
|
37
|
+
* time it was promised and still refusing is refusing on its own account. */
|
|
38
|
+
export const STARVED_SHARE = 0.5;
|
|
39
|
+
/**
|
|
40
|
+
* Classify one probe result.
|
|
41
|
+
*
|
|
42
|
+
* `ok === undefined` is a probe that REJECTED rather than resolving: an unanswered question. It was
|
|
43
|
+
* previously swallowed by `.catch(() => {})`, so it neither refreshed the window nor evaluated
|
|
44
|
+
* anything, and silently aged the daemon toward an exit it had gathered no evidence for.
|
|
45
|
+
*/
|
|
46
|
+
export function classifyProbe(ok, elapsedMs, budgetMs = PROBE_BUDGET_MS, lateFactor = PROBE_LATE_FACTOR, descheduledDuringMs = 0) {
|
|
47
|
+
if (ok === undefined)
|
|
48
|
+
return { counts: "incomplete" };
|
|
49
|
+
// A POSITIVE IS BELIEVED HOWEVER LATE IT IS. A slow yes still required a server to say it, so
|
|
50
|
+
// lateness cannot turn it into anything weaker; and refusing late positives would make a starved
|
|
51
|
+
// daemon unable to ever clear its own window, which is the defect again with the sign flipped.
|
|
52
|
+
if (ok)
|
|
53
|
+
return { counts: "positive" };
|
|
54
|
+
const ceiling = budgetMs * lateFactor;
|
|
55
|
+
// (1) THE ANSWER ARRIVED FAR PAST ITS OWN DEADLINE. The deadline was enforced against this
|
|
56
|
+
// process, not against the server: a 1000ms budget that reports at 2554ms did not measure a
|
|
57
|
+
// server for 2554ms, it measured a process that could not get back on the CPU to stop waiting.
|
|
58
|
+
// This is the form the #1318 triage captured directly, and it needs no other instrumentation.
|
|
59
|
+
if (elapsedMs > ceiling)
|
|
60
|
+
return { counts: "starved", lateBy: elapsedMs - budgetMs };
|
|
61
|
+
// (2) THE DEADLINE EXPIRED WITHOUT THE SERVER EVER GETTING THE BUDGET IT WAS PROMISED. A refusal
|
|
62
|
+
// is only evidence if the server had the time to answer in. Subtracting the stretch this process
|
|
63
|
+
// spent OFF the runqueue leaves what the server actually had, and the two cases separate cleanly:
|
|
64
|
+
//
|
|
65
|
+
// - a genuinely dead port answers ECONNREFUSED in about a millisecond, so `elapsed` never
|
|
66
|
+
// reaches the budget at all and this clause does not apply. That is a prompt, honest negative
|
|
67
|
+
// and it must stay one, or the repair would buy availability by making a dead broker
|
|
68
|
+
// survivable, which is the failure mode worse than the defect.
|
|
69
|
+
// - a process getting short slices of CPU issues a connect, is descheduled, and its deadline
|
|
70
|
+
// timer fires the instant it is scheduled again. Wall-clock elapsed looks like a normal,
|
|
71
|
+
// prompt timeout; almost none of it was time the server was given. Judged by the clock alone
|
|
72
|
+
// this is indistinguishable from a dead server, which is exactly the confusion #1318 is about.
|
|
73
|
+
//
|
|
74
|
+
// THE MATERIAL-SHARE TEST IS WHAT KEEPS THIS FROM SWALLOWING EVERY REFUSAL, and it was a review
|
|
75
|
+
// finding: requiring merely `attributable < budget` meant ANY non-zero measurement disqualified an
|
|
76
|
+
// honest budget-ended refusal, and ordinary Node timer jitter on a healthy host measures 10-30ms.
|
|
77
|
+
// Every blackholed probe everywhere then read as starvation, the completed-negative count could
|
|
78
|
+
// never rise, and a genuinely dead broker would be exited only by the backstop, four times slower
|
|
79
|
+
// than the shipped window, with the wrong reason in the log. So the server must have been denied a
|
|
80
|
+
// MATERIAL share of its budget, not merely an instant of it. Half is the line: a server given more
|
|
81
|
+
// than half the time it was promised and still refusing is refusing on its own account.
|
|
82
|
+
//
|
|
83
|
+
// Clamped to the probe's own span so a bad measurement cannot manufacture credit.
|
|
84
|
+
const attributableMs = elapsedMs - Math.max(0, Math.min(descheduledDuringMs, elapsedMs));
|
|
85
|
+
if (elapsedMs >= budgetMs && attributableMs < budgetMs * STARVED_SHARE) {
|
|
86
|
+
return { counts: "starved", lateBy: elapsedMs - attributableMs };
|
|
87
|
+
}
|
|
88
|
+
return { counts: "negative" };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Measure how long this process spends OFF the runqueue across a span, by watching a short timer's
|
|
92
|
+
* own lateness.
|
|
93
|
+
*
|
|
94
|
+
* A timer asked to fire every `sampleMs` that instead fires `sampleMs + d` later reports `d` of
|
|
95
|
+
* delay this process could not avoid: the event loop was ready and the process was not running.
|
|
96
|
+
* Summed across a probe. That is the part of the probe's wall-clock that the server was never
|
|
97
|
+
* actually given, which is the difference between "the server did not answer in a second" and "a
|
|
98
|
+
* second passed, and the server had 40ms of it".
|
|
99
|
+
*
|
|
100
|
+
* This measures the same underlying condition as {@link LoopLagMeter} at a finer grain and over a
|
|
101
|
+
* bounded span, which is what lets a per-probe verdict use it. It deliberately reads only the
|
|
102
|
+
* clock: `/proc` sampling, `getrusage`, or cgroup pressure would all be sharper, and all of them are
|
|
103
|
+
* platform-specific in a way that would make this daemon behave differently on the hosts that most
|
|
104
|
+
* need it. A late timer is available everywhere the daemon runs.
|
|
105
|
+
*/
|
|
106
|
+
export class DescheduleSampler {
|
|
107
|
+
sampleMs;
|
|
108
|
+
accumulated = 0;
|
|
109
|
+
last = 0;
|
|
110
|
+
timer;
|
|
111
|
+
constructor(sampleMs = PROBE_SAMPLE_MS) {
|
|
112
|
+
this.sampleMs = sampleMs;
|
|
113
|
+
}
|
|
114
|
+
start(now = Date.now()) {
|
|
115
|
+
this.accumulated = 0;
|
|
116
|
+
this.last = now;
|
|
117
|
+
this.timer = setInterval(() => {
|
|
118
|
+
const t = Date.now();
|
|
119
|
+
this.accumulated += Math.max(0, t - this.last - this.sampleMs);
|
|
120
|
+
this.last = t;
|
|
121
|
+
}, this.sampleMs);
|
|
122
|
+
// Never hold the process open for a measurement: the daemon's lifetime is decided elsewhere.
|
|
123
|
+
this.timer.unref?.();
|
|
124
|
+
}
|
|
125
|
+
/** Stop sampling and return the total off-runqueue time observed. Charging the final partial gap
|
|
126
|
+
* matters: under heavy starvation the single longest stall is often the one still in progress
|
|
127
|
+
* when the probe resolves, and dropping it would undercount exactly the worst case. */
|
|
128
|
+
stop(now = Date.now()) {
|
|
129
|
+
if (this.timer !== undefined) {
|
|
130
|
+
clearInterval(this.timer);
|
|
131
|
+
this.timer = undefined;
|
|
132
|
+
}
|
|
133
|
+
this.accumulated += Math.max(0, now - this.last - this.sampleMs);
|
|
134
|
+
return this.accumulated;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Decide whether the broker is GONE, from evidence alone.
|
|
139
|
+
*
|
|
140
|
+
* EXIT REQUIRES BOTH CONJUNCTS, and each exists because the other cannot cover its case:
|
|
141
|
+
*
|
|
142
|
+
* 1. `completedNegatives >= requiredNegatives`, probes that actually RAN and actually said no.
|
|
143
|
+
* Without this, a window that expired while the process was descheduled is read as a server
|
|
144
|
+
* failure, which is the reported defect in its purest form.
|
|
145
|
+
* 2. `msSinceLastReachable - starvedMs > windowMs`, the UNSTARVED part of the window. Without
|
|
146
|
+
* this, a host that schedules the daemon just often enough to fire two probes into a
|
|
147
|
+
* momentarily-saturated loopback exits on two negatives that a healthy host would never have
|
|
148
|
+
* produced.
|
|
149
|
+
*
|
|
150
|
+
* THIS IS NOT A WIDER TIMEOUT. `windowMs` is unchanged from the shipped default; what changed is
|
|
151
|
+
* that the quantity compared against it is now evidence rather than the passage of time. A daemon
|
|
152
|
+
* whose broker is genuinely dead produces completed negatives as fast as it is scheduled, a dead
|
|
153
|
+
* port answers ECONNREFUSED immediately and a blackholed one answers inside the probe deadline ,
|
|
154
|
+
* AND loses its standing connection, so the true-positive path is not delayed by this at all, which
|
|
155
|
+
* is the property that separates a repair from a band-aid. A process so starved that it produces NO
|
|
156
|
+
* completed probe also cannot deliver anything, and ending it would not make Plane-3 more
|
|
157
|
+
* available; it would make the outage permanent for a stock install with no watchdog.
|
|
158
|
+
*/
|
|
159
|
+
export function brokerGoneVerdict(e) {
|
|
160
|
+
// Positive evidence inside the window outranks everything: nothing below can be true of a broker
|
|
161
|
+
// that answered us this recently.
|
|
162
|
+
if (e.msSinceLastReachable <= e.windowMs)
|
|
163
|
+
return { exit: false, reason: "reachable" };
|
|
164
|
+
// THE BACKSTOP IS ABSOLUTE, AND IT IS DECIDED FIRST because every clause below it is a reason to
|
|
165
|
+
// keep waiting. This ordering was a review finding, and it is the difference between a bound and
|
|
166
|
+
// a wish: two reviewers independently observed that an open transport ranked ABOVE the backstop,
|
|
167
|
+
// which made the backstop conditional on the very signal most likely to be stale.
|
|
168
|
+
//
|
|
169
|
+
// `transportConnected` is not an observation of the broker. It is this client's cached socket
|
|
170
|
+
// state, and it is only refreshed when nats.js decides the peer is gone. Under a SILENT death ,
|
|
171
|
+
// an OOM kill, a hypervisor pause, a firewall that starts dropping rather than refusing, anything
|
|
172
|
+
// that produces no FIN and no RST, the kernel keeps the connection ESTABLISHED and nats.js does
|
|
173
|
+
// not notice until its own ping cycle expires. With the shipped defaults that is roughly six
|
|
174
|
+
// minutes. Ranked above the backstop, a stale `true` suspended the exit for that entire window no
|
|
175
|
+
// matter how much elapsed time and how many completed refusals had piled up behind it, which is
|
|
176
|
+
// worse than the pre-fix behaviour in exactly the shape an operator would least expect.
|
|
177
|
+
//
|
|
178
|
+
// So the daemon is bounded unconditionally: past the backstop it exits, whatever it believes
|
|
179
|
+
// about its socket. That is the guarantee the coupling exists for, and a guarantee that any
|
|
180
|
+
// single stale flag can defer is not one.
|
|
181
|
+
if (e.msSinceLastReachable > e.backstopMs)
|
|
182
|
+
return { exit: true, reason: "backstop" };
|
|
183
|
+
// AN OPEN TRANSPORT IS ONGOING POSITIVE EVIDENCE, NOT AN EXCUSE FOR ITS ABSENCE, so inside the
|
|
184
|
+
// backstop it still outranks the starvation and evidence clauses below. This daemon's own
|
|
185
|
+
// connection to that same broker is up; it is serving on it; its lease renews across it. A fresh
|
|
186
|
+
// side-probe that cannot complete a handshake to an address we are CURRENTLY CONNECTED TO is a
|
|
187
|
+
// statement about this process's ability to open a new socket, not about the server.
|
|
188
|
+
//
|
|
189
|
+
// Inside the backstop this is still the strongest thing the daemon knows: when the broker really
|
|
190
|
+
// dies the flag goes false, the client detects the loss, the endpoint's status watcher turns it
|
|
191
|
+
// into `transport: connected=false`, and every clause below is live again from that instant. What
|
|
192
|
+
// the flag CANNOT do any more is defer the exit indefinitely while it is stale, because the bound
|
|
193
|
+
// above it has already been decided.
|
|
194
|
+
if (e.transportConnected)
|
|
195
|
+
return { exit: false, reason: "transport-live" };
|
|
196
|
+
// "I could not ask." Credit the lag this process MEASURED on itself before reading the clock as a
|
|
197
|
+
// statement about the server.
|
|
198
|
+
const unstarvedMs = e.msSinceLastReachable - e.starvedMs;
|
|
199
|
+
if (unstarvedMs <= e.windowMs)
|
|
200
|
+
return { exit: false, reason: "starved" };
|
|
201
|
+
// "I asked and was told no", but not yet often enough to be a verdict.
|
|
202
|
+
if (e.completedNegatives < e.requiredNegatives)
|
|
203
|
+
return { exit: false, reason: "insufficient-evidence" };
|
|
204
|
+
return { exit: true, reason: "broker-gone" };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Turn a lease reading into an action.
|
|
208
|
+
*
|
|
209
|
+
* A FAILED RENEW IS A QUESTION, NOT A VERDICT, the same split #1301 made for `down` and the
|
|
210
|
+
* manager's liveness lease already makes. The shipped code exited on ANY renew error, so the
|
|
211
|
+
* measured incident's `wrong last sequence: 0` (the key had EXPIRED during the stall, with nobody
|
|
212
|
+
* else holding it) read identically to a genuine takeover and ended a daemon that was still the
|
|
213
|
+
* only holder there was.
|
|
214
|
+
*
|
|
215
|
+
* The single-holder guarantee is preserved exactly, and by construction rather than by timing:
|
|
216
|
+
* `gone` recovers through an ATOMIC create, so if a replacement did take the slot first, the create
|
|
217
|
+
* fails and that is the genuine loss which exits. `taken` exits immediately. Only "the key is still
|
|
218
|
+
* ours" and "the broker could not be asked" keep serving, and neither of those is a second holder.
|
|
219
|
+
*/
|
|
220
|
+
export function leaseAction(reading) {
|
|
221
|
+
switch (reading.kind) {
|
|
222
|
+
// Our own key, at whatever revision the broker says: adopt it and carry on. Covers the renew
|
|
223
|
+
// whose write landed with only its acknowledgement lost.
|
|
224
|
+
case "held": return "keep-serving";
|
|
225
|
+
// Expired or released while we were still here. Put it back; the create arbitrates.
|
|
226
|
+
case "gone": return "reacquire";
|
|
227
|
+
// A different process holds this shard. Exit so the holder stays single.
|
|
228
|
+
case "taken": return "exit";
|
|
229
|
+
// Could not ask. An unanswerable question is not a negative answer; the broker watch above owns
|
|
230
|
+
// the "the server is gone" decision, and it decides on evidence.
|
|
231
|
+
case "unknown": return "keep-serving";
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* May this process hold Plane-3 bindings on the strength of this reading alone?
|
|
236
|
+
*
|
|
237
|
+
* SEPARATE FROM {@link leaseAction} BECAUSE THEY ANSWER DIFFERENT QUESTIONS, and conflating them was
|
|
238
|
+
* a review finding. `leaseAction` decides whether the PROCESS lives; this decides whether it may
|
|
239
|
+
* SERVE. They agree on `taken` and disagree everywhere else that matters:
|
|
240
|
+
*
|
|
241
|
+
* • `gone` is `reacquire`, the process lives, but it must NOT serve on it. The create has not
|
|
242
|
+
* been attempted yet, and a replacement may already hold the shard; serving through the
|
|
243
|
+
* arbitration is how two daemons end up on one durable.
|
|
244
|
+
* • `unknown` is `keep-serving` for the PROCESS, and a refusal here. Surviving an unanswerable
|
|
245
|
+
* broker is the entire point of #1318, but not being able to ask who owns the shard is not
|
|
246
|
+
* permission to keep acting on it. The daemon stays alive and stays quiet.
|
|
247
|
+
*
|
|
248
|
+
* So this is deliberately the STRICTER of the two: it says yes to exactly one reading, the one that
|
|
249
|
+
* carries positive proof of ownership from the broker. Winning the atomic create is the other way
|
|
250
|
+
* to earn it, and that is not a reading, which is why the daemon re-arms there separately.
|
|
251
|
+
*/
|
|
252
|
+
export function mayServeOn(reading) {
|
|
253
|
+
// The only reading that is itself proof: the broker was asked, it answered, and the holder it
|
|
254
|
+
// named is this process.
|
|
255
|
+
return reading.kind === "held";
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Measures how late this process's own timers are running.
|
|
259
|
+
*
|
|
260
|
+
* A `setInterval(f, 2000)` that fires 30 seconds after its predecessor did not observe a slow
|
|
261
|
+
* server; it observed a runqueue it was not on. Node hands us no such signal, but the gap between
|
|
262
|
+
* consecutive firings of a timer we own is a direct measurement of it, and it costs one
|
|
263
|
+
* subtraction. Excess over the nominal period is clamped at zero: a timer can fire late, never
|
|
264
|
+
* early, so a negative reading is clock adjustment rather than scheduling and must not CREDIT time
|
|
265
|
+
* the process actually had.
|
|
266
|
+
*/
|
|
267
|
+
export class LoopLagMeter {
|
|
268
|
+
intervalMs;
|
|
269
|
+
last;
|
|
270
|
+
accumulated = 0;
|
|
271
|
+
/** Disjoint, sorted [start, end] intervals of measured non-running time in the current window.
|
|
272
|
+
* Bounded: overlapping charges merge, and `reset` clears them on every positive. */
|
|
273
|
+
spans = [];
|
|
274
|
+
constructor(intervalMs = PROBE_INTERVAL_MS) {
|
|
275
|
+
this.intervalMs = intervalMs;
|
|
276
|
+
}
|
|
277
|
+
/** Record a firing at `now`; returns the lag this particular gap contributed.
|
|
278
|
+
*
|
|
279
|
+
* WHAT THE GAP IS CHARGED AS, since this is the half that is easy to misread: the excess of the
|
|
280
|
+
* observed gap over the nominal interval is charged as time THIS PROCESS WAS NOT RUNNING. It is a
|
|
281
|
+
* measurement of the local scheduler, never of the broker - a timer we own fired late, which says
|
|
282
|
+
* the runqueue did not reach us and says nothing at all about the server.
|
|
283
|
+
*
|
|
284
|
+
* It is also NOT the only such charge. {@link credit} adds lateness observed on a probe answer,
|
|
285
|
+
* and the two can describe the same stall or two adjacent ones; nothing in either number reveals
|
|
286
|
+
* which. Neither method deduplicates, deliberately, because suppressing a charge on the guess
|
|
287
|
+
* that it overlaps discards real stalls. The sum is bounded instead, at the point of comparison,
|
|
288
|
+
* by {@link starvedMsWithin}. */
|
|
289
|
+
tick(now) {
|
|
290
|
+
const previous = this.last;
|
|
291
|
+
this.last = now;
|
|
292
|
+
if (previous === undefined)
|
|
293
|
+
return 0; // first firing has no gap to measure
|
|
294
|
+
const lag = Math.max(0, now - previous - this.intervalMs);
|
|
295
|
+
// Charged as a DATED INTERVAL, not a scalar. The stall ended when this firing ran, so it
|
|
296
|
+
// occupied [now - lag, now]. Recording WHEN lets an overlapping probe charge be unioned with it
|
|
297
|
+
// instead of added to it, which is the whole of the double-count repair.
|
|
298
|
+
this.chargeSpan(now - lag, now);
|
|
299
|
+
return lag;
|
|
300
|
+
}
|
|
301
|
+
/** Add lag measured somewhere OTHER than the interval gap, in practice, a probe answer that
|
|
302
|
+
* arrived past its own deadline. Interval gaps alone miss the case where the process IS being
|
|
303
|
+
* scheduled often enough to fire the timer but not often enough to finish a handshake, which is
|
|
304
|
+
* the third of the issue's three mechanisms and the one that produces a completed `false` from a
|
|
305
|
+
* perfectly live server. Clamped at zero for the same reason `tick` is: a measurement that ran
|
|
306
|
+
* backwards must never credit time the process actually had. */
|
|
307
|
+
credit(lagMs, endedAt = Date.now()) {
|
|
308
|
+
// BOTH MEASUREMENTS ARE KEPT. An earlier repair here netted this against the most recent interval
|
|
309
|
+
// gap, on the theory that the two always observe ONE stall. They do not, and a reviewer produced
|
|
310
|
+
// the separating case: over a 4000ms span a timer can fire 2000ms late (a stall happening NOW)
|
|
311
|
+
// while a probe issued at the PREVIOUS tick answers 2500ms late (the stall before it). Netting
|
|
312
|
+
// credited 2500ms there and threw away a 2000ms stall the process had actually measured - which
|
|
313
|
+
// is the very mechanism this method exists for, a process scheduled often enough to fire a timer
|
|
314
|
+
// but not to finish a handshake. I measured my own netting against that case and it produced the
|
|
315
|
+
// lossy answer, so it is gone.
|
|
316
|
+
this.chargeSpan(endedAt - Math.max(0, lagMs), endedAt);
|
|
317
|
+
}
|
|
318
|
+
/** Charge [from, to] as time this process was not running, UNIONED with what is already charged.
|
|
319
|
+
*
|
|
320
|
+
* This is the double-count repair, and it is why both call sites can stay. A stall observed by
|
|
321
|
+
* the interval timer and by a probe answering across it is ONE interval of wall-clock time seen
|
|
322
|
+
* by two instruments. Summing scalars charges it twice (measured: 17s for a 10s stall). Summing
|
|
323
|
+
* INTERVALS cannot, because the union of overlapping intervals is their extent.
|
|
324
|
+
*
|
|
325
|
+
* It also keeps what netting threw away. Two ADJACENT stalls (a timer late by one, a probe from
|
|
326
|
+
* the previous tick late by another) do not overlap, so the union is their sum and both are
|
|
327
|
+
* charged in full. Overlap is decided by the timestamps rather than guessed from the magnitudes,
|
|
328
|
+
* which is the thing neither scalar arithmetic nor a span clamp can do. */
|
|
329
|
+
chargeSpan(from, to) {
|
|
330
|
+
if (!(to > from))
|
|
331
|
+
return; // zero or backwards: never credits time we had
|
|
332
|
+
const merged = [];
|
|
333
|
+
let lo = from, hi = to;
|
|
334
|
+
for (const [s, e] of this.spans) {
|
|
335
|
+
if (e < lo || s > hi) {
|
|
336
|
+
merged.push([s, e]);
|
|
337
|
+
continue;
|
|
338
|
+
} // disjoint: keep as-is
|
|
339
|
+
lo = Math.min(lo, s);
|
|
340
|
+
hi = Math.max(hi, e); // touching: absorb
|
|
341
|
+
}
|
|
342
|
+
merged.push([lo, hi]);
|
|
343
|
+
merged.sort((a, b) => a[0] - b[0]);
|
|
344
|
+
this.spans = merged;
|
|
345
|
+
this.accumulated = merged.reduce((n, [s, e]) => n + (e - s), 0);
|
|
346
|
+
}
|
|
347
|
+
/** Accumulated lag CLAMPED to the span it is about to be compared against.
|
|
348
|
+
*
|
|
349
|
+
* This is the honest way to stop double counting, and the reason the meter cannot do it by
|
|
350
|
+
* arithmetic alone: whether a tick charge and a probe charge describe the same stall or two
|
|
351
|
+
* adjacent ones is not knowable from the two numbers. What IS knowable is that time the process
|
|
352
|
+
* did not have can never exceed time that passed. Overlap is removed by that bound and nothing
|
|
353
|
+
* else is discarded, so the sum stays as informative as its parts allow.
|
|
354
|
+
*
|
|
355
|
+
* Load-bearing, not hygiene: `starvedMs` is SUBTRACTED from elapsed in {@link brokerGoneVerdict}.
|
|
356
|
+
* Unclamped, a stall charged twice drives unstarved time negative, and NO amount of real outage
|
|
357
|
+
* can then clear the evidence clause - a genuinely dead broker stops being detectable by evidence
|
|
358
|
+
* and survives to the backstop instead. Measured: a 30s stall moved an exit from 44s to 62s. */
|
|
359
|
+
starvedMsWithin(spanMs) {
|
|
360
|
+
return Math.min(this.accumulated, Math.max(0, spanMs));
|
|
361
|
+
}
|
|
362
|
+
/** Lag accumulated since the last {@link reset}. */
|
|
363
|
+
get starvedMs() {
|
|
364
|
+
return this.accumulated;
|
|
365
|
+
}
|
|
366
|
+
/** Called when positive evidence arrives: the window restarts, so its lag budget does too. The
|
|
367
|
+
* firing baseline is deliberately KEPT, so a stall spanning a reset is still measured. */
|
|
368
|
+
reset() {
|
|
369
|
+
this.accumulated = 0;
|
|
370
|
+
this.spans = []; // the intervals ARE the accumulator now; clearing one without the other
|
|
371
|
+
// would let a pre-reset stall be re-charged by a later overlapping probe.
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
//# sourceMappingURL=watchdog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"watchdog.js","sourceRoot":"","sources":["../src/watchdog.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;4EAC4E;AAC5E,MAAM,CAAC,MAAM,iBAAiB,GAAG,IAAI,CAAC;AAEtC;4EAC4E;AAC5E,MAAM,CAAC,MAAM,eAAe,GAAG,IAAI,CAAC;AAEpC;;;;;;oFAMoF;AACpF,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAC;AAEnC;6FAC6F;AAC7F,MAAM,CAAC,MAAM,eAAe,GAAG,EAAE,CAAC;AAElC;;;;8EAI8E;AAC9E,MAAM,CAAC,MAAM,aAAa,GAAG,GAAG,CAAC;AAyBjC;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAC3B,EAAuB,EACvB,SAAiB,EACjB,WAAmB,eAAe,EAClC,aAAqB,iBAAiB,EACtC,sBAA8B,CAAC;IAE/B,IAAI,EAAE,KAAK,SAAS;QAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,CAAC;IACtD,8FAA8F;IAC9F,iGAAiG;IACjG,+FAA+F;IAC/F,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IACtC,MAAM,OAAO,GAAG,QAAQ,GAAG,UAAU,CAAC;IACtC,2FAA2F;IAC3F,4FAA4F;IAC5F,+FAA+F;IAC/F,8FAA8F;IAC9F,IAAI,SAAS,GAAG,OAAO;QAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,GAAG,QAAQ,EAAE,CAAC;IACpF,iGAAiG;IACjG,iGAAiG;IACjG,kGAAkG;IAClG,EAAE;IACF,4FAA4F;IAC5F,kGAAkG;IAClG,yFAAyF;IACzF,mEAAmE;IACnE,+FAA+F;IAC/F,6FAA6F;IAC7F,iGAAiG;IACjG,mGAAmG;IACnG,EAAE;IACF,gGAAgG;IAChG,mGAAmG;IACnG,kGAAkG;IAClG,gGAAgG;IAChG,kGAAkG;IAClG,mGAAmG;IACnG,mGAAmG;IACnG,wFAAwF;IACxF,EAAE;IACF,kFAAkF;IAClF,MAAM,cAAc,GAAG,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,mBAAmB,EAAE,SAAS,CAAC,CAAC,CAAC;IACzF,IAAI,SAAS,IAAI,QAAQ,IAAI,cAAc,GAAG,QAAQ,GAAG,aAAa,EAAE,CAAC;QACvE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,GAAG,cAAc,EAAE,CAAC;IACnE,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;AAChC,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,OAAO,iBAAiB;IAKC;IAJrB,WAAW,GAAG,CAAC,CAAC;IAChB,IAAI,GAAG,CAAC,CAAC;IACT,KAAK,CAA6C;IAE1D,YAA6B,WAAmB,eAAe;QAAlC,aAAQ,GAAR,QAAQ,CAA0B;IAAG,CAAC;IAEnE,KAAK,CAAC,MAAc,IAAI,CAAC,GAAG,EAAE;QAC5B,IAAI,CAAC,WAAW,GAAG,CAAC,CAAC;QACrB,IAAI,CAAC,IAAI,GAAG,GAAG,CAAC;QAChB,IAAI,CAAC,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE;YAC5B,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;YACrB,IAAI,CAAC,WAAW,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,CAAC;YAC/D,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC;QAChB,CAAC,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC;QAClB,6FAA6F;QAC7F,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,EAAE,CAAC;IACvB,CAAC;IAED;;4FAEwF;IACxF,IAAI,CAAC,MAAc,IAAI,CAAC,GAAG,EAAE;QAC3B,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;YAAC,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YAAC,IAAI,CAAC,KAAK,GAAG,SAAS,CAAC;QAAC,CAAC;QACpF,IAAI,CAAC,WAAW,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,GAAG,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,CAAC;QACjE,OAAO,IAAI,CAAC,WAAW,CAAC;IAC1B,CAAC;CACF;AAgED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,iBAAiB,CAAC,CAAsB;IACtD,iGAAiG;IACjG,kCAAkC;IAClC,IAAI,CAAC,CAAC,oBAAoB,IAAI,CAAC,CAAC,QAAQ;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC;IACtF,iGAAiG;IACjG,iGAAiG;IACjG,iGAAiG;IACjG,kFAAkF;IAClF,EAAE;IACF,8FAA8F;IAC9F,gGAAgG;IAChG,kGAAkG;IAClG,gGAAgG;IAChG,6FAA6F;IAC7F,kGAAkG;IAClG,gGAAgG;IAChG,wFAAwF;IACxF,EAAE;IACF,6FAA6F;IAC7F,4FAA4F;IAC5F,0CAA0C;IAC1C,IAAI,CAAC,CAAC,oBAAoB,GAAG,CAAC,CAAC,UAAU;QAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IACrF,+FAA+F;IAC/F,0FAA0F;IAC1F,iGAAiG;IACjG,+FAA+F;IAC/F,qFAAqF;IACrF,EAAE;IACF,iGAAiG;IACjG,gGAAgG;IAChG,kGAAkG;IAClG,kGAAkG;IAClG,qCAAqC;IACrC,IAAI,CAAC,CAAC,kBAAkB;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAC3E,kGAAkG;IAClG,8BAA8B;IAC9B,MAAM,WAAW,GAAG,CAAC,CAAC,oBAAoB,GAAG,CAAC,CAAC,SAAS,CAAC;IACzD,IAAI,WAAW,IAAI,CAAC,CAAC,QAAQ;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IACzE,uEAAuE;IACvE,IAAI,CAAC,CAAC,kBAAkB,GAAG,CAAC,CAAC,iBAAiB;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,uBAAuB,EAAE,CAAC;IACxG,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC;AAC/C,CAAC;AAeD;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,WAAW,CAAC,OAAqB;IAC/C,QAAQ,OAAO,CAAC,IAAI,EAAE,CAAC;QACrB,6FAA6F;QAC7F,yDAAyD;QACzD,KAAK,MAAM,CAAC,CAAC,OAAO,cAAc,CAAC;QACnC,oFAAoF;QACpF,KAAK,MAAM,CAAC,CAAC,OAAO,WAAW,CAAC;QAChC,yEAAyE;QACzE,KAAK,OAAO,CAAC,CAAC,OAAO,MAAM,CAAC;QAC5B,gGAAgG;QAChG,iEAAiE;QACjE,KAAK,SAAS,CAAC,CAAC,OAAO,cAAc,CAAC;IACxC,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,UAAU,CAAC,OAAqB;IAC9C,8FAA8F;IAC9F,yBAAyB;IACzB,OAAO,OAAO,CAAC,IAAI,KAAK,MAAM,CAAC;AACjC,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,OAAO,YAAY;IAMM;IALrB,IAAI,CAAqB;IACzB,WAAW,GAAG,CAAC,CAAC;IACxB;yFACqF;IAC7E,KAAK,GAA4B,EAAE,CAAC;IAC5C,YAA6B,aAAqB,iBAAiB;QAAtC,eAAU,GAAV,UAAU,CAA4B;IAAG,CAAC;IAEvE;;;;;;;;;;;sCAWkC;IAClC,IAAI,CAAC,GAAW;QACd,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC;QAC3B,IAAI,CAAC,IAAI,GAAG,GAAG,CAAC;QAChB,IAAI,QAAQ,KAAK,SAAS;YAAE,OAAO,CAAC,CAAC,CAAC,qCAAqC;QAC3E,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,GAAG,QAAQ,GAAG,IAAI,CAAC,UAAU,CAAC,CAAC;QAC1D,yFAAyF;QACzF,gGAAgG;QAChG,yEAAyE;QACzE,IAAI,CAAC,UAAU,CAAC,GAAG,GAAG,GAAG,EAAE,GAAG,CAAC,CAAC;QAChC,OAAO,GAAG,CAAC;IACb,CAAC;IAED;;;;;qEAKiE;IACjE,MAAM,CAAC,KAAa,EAAE,UAAkB,IAAI,CAAC,GAAG,EAAE;QAChD,kGAAkG;QAClG,iGAAiG;QACjG,+FAA+F;QAC/F,+FAA+F;QAC/F,gGAAgG;QAChG,iGAAiG;QACjG,iGAAiG;QACjG,+BAA+B;QAC/B,IAAI,CAAC,UAAU,CAAC,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,OAAO,CAAC,CAAC;IACzD,CAAC;IAED;;;;;;;;;;gFAU4E;IACpE,UAAU,CAAC,IAAY,EAAE,EAAU;QACzC,IAAI,CAAC,CAAC,EAAE,GAAG,IAAI,CAAC;YAAE,OAAO,CAAuB,+CAA+C;QAC/F,MAAM,MAAM,GAA4B,EAAE,CAAC;QAC3C,IAAI,EAAE,GAAG,IAAI,EAAE,EAAE,GAAG,EAAE,CAAC;QACvB,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YAChC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;gBAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;gBAAC,SAAS;YAAC,CAAC,CAAG,uBAAuB;YAClF,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;YAAC,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAgB,mBAAmB;QAChF,CAAC;QACD,MAAM,CAAC,IAAI,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC;QACtB,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACnC,IAAI,CAAC,KAAK,GAAG,MAAM,CAAC;QACpB,IAAI,CAAC,WAAW,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAClE,CAAC;IAED;;;;;;;;;;;qGAWiG;IACjG,eAAe,CAAC,MAAc;QAC5B,OAAO,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;IACzD,CAAC;IAGD,oDAAoD;IACpD,IAAI,SAAS;QACX,OAAO,IAAI,CAAC,WAAW,CAAC;IAC1B,CAAC;IAED;+FAC2F;IAC3F,KAAK;QACH,IAAI,CAAC,WAAW,GAAG,CAAC,CAAC;QACrB,IAAI,CAAC,KAAK,GAAG,EAAE,CAAC,CAAG,wEAAwE;QACxE,0EAA0E;IAC/F,CAAC;CACF"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cotal-ai/delivery",
|
|
3
3
|
"description": "Cotal delivery daemon: the server-side Plane-3 durable backstop (fan-out writer + trusted reader + membership/ACL authority), a scoped least-privilege NATS client co-located with the broker.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.50.0",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@cotal-ai/core": "0.
|
|
22
|
-
"@cotal-ai/workspace": "0.
|
|
21
|
+
"@cotal-ai/core": "0.50.0",
|
|
22
|
+
"@cotal-ai/workspace": "0.50.0"
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
25
|
"dist"
|