@ultimat3/core 27.6.1 → 27.8.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/README.md +5 -2
- package/package.json +2 -2
- package/src/index.ts +8 -1
- package/src/lifecycle-readiness.ts +35 -10
- package/src/lifecycle.ts +11 -3
- package/src/outage-log.ts +53 -0
- package/src/single-flight.ts +18 -0
package/README.md
CHANGED
|
@@ -494,7 +494,10 @@ match with `sealAll()`; uniqueness cannot be held across keys.
|
|
|
494
494
|
**bound**. `ReadinessCheck` is `() => boolean` and must stay synchronous — a probe that awaits its
|
|
495
495
|
dependency turns a slow dependency into a wedged endpoint and then a restart loop; keep a boolean
|
|
496
496
|
fresh and let the check read it. It returns an unregister. `HealthReport.checks` is a map of name
|
|
497
|
-
→ `'ok' | 'failing'`, so "alert on check failures by check name" is writable.
|
|
497
|
+
→ `'ok' | 'degraded' | 'failing'`, so "alert on check failures by check name" is writable.
|
|
498
|
+
`registerReadinessCheck(name, check, { onFailure: 'degraded' })` is a dependency the role serves
|
|
499
|
+
without (the bus of a role that only publishes to it): its `false` is `'degraded'`, never a 503
|
|
500
|
+
on `/readyz` in either mode, and a 503 on `/readyz?deep=1`.
|
|
498
501
|
- **`HealthReport.registered` is the third state.** `checks: {}` reads identically for "every check
|
|
499
502
|
passed" and "nobody registered one", and an **empty registry is still ready** — reported, never
|
|
500
503
|
enforced, so a role with no dependency does not have to invent a check to boot. Read `registered`
|
|
@@ -728,7 +731,7 @@ nothing consulted it before deciding to try again. `As of 2026-08-23`.
|
|
|
728
731
|
| Export | The one answer | The question it settles |
|
|
729
732
|
|---|---|---|
|
|
730
733
|
| `backoffDelay({ attempt, base, max, factor?, curve?, jitter?, random? })` | one curve — `exponential \| linear \| fixed`, `full \| equal \| none` — 1-based `attempt`, clamped to `max` **before** jitter, rounded, and `0` rather than `NaN` | how long to wait. `random` is injectable, so a schedule is a unit test rather than a range |
|
|
731
|
-
| `singleFlight({ deadlineMs?, schedule? })` → `run(key, work, join?)`, `size` | N callers on one key are ONE run | who pays for a miss. Eviction is identity-checked, so a load that settles late never drops the load that replaced it; `deadlineMs` frees the KEY a wedged load would hold forever — it never cancels the work and never rejects a joiner |
|
|
734
|
+
| `singleFlight({ deadlineMs?, schedule? })` → `run(key, work, join?)`, `size`, `keys()`, `evict(key)` | N callers on one key are ONE run | who pays for a miss. Eviction is identity-checked, so a load that settles late never drops the load that replaced it; `deadlineMs` frees the KEY a wedged load would hold forever — it never cancels the work and never rejects a joiner. `evict(key)` does the same on demand, for a caller that learns the load in flight began before a write its result must reflect |
|
|
732
735
|
| `flightGate({ maxConcurrent, maxQueued }, { subject?, overflow? })` | one bound, one queue, one refusal | how many at once. Past the queue the answer is `X_FLIGHT_GATE_OVERLOADED` (503) and never a longer queue; the slot is HANDED to a waiter, never released and re-acquired. Both limits are screened at construction (`finiteCount`, 0 allowed); a width of 0 refuses every caller rather than queueing for a slot that never frees. `run(work, signal?)`: an abort while QUEUED removes the waiter and rejects with the abort's reason |
|
|
733
736
|
| `generationFence(subject)` → `generation()`, `bump()`, `guard(issued)` | whether an answer still applies | `X_SUPERSEDED` (499) and `isSuperseded(error)` — the piece nothing in the tree had. `guard` compares `!==`, never `<` |
|
|
734
737
|
| `isRetryableStatus(status)`, `RETRYABLE_STATUSES` | `>= 500`, plus 408, 409, 425, 429 | which HTTP answers are worth repeating |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.8.0",
|
|
4
4
|
"description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,6 +37,6 @@
|
|
|
37
37
|
"test": "bun test"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@ultimat3/schema": "27.
|
|
40
|
+
"@ultimat3/schema": "27.8.0"
|
|
41
41
|
}
|
|
42
42
|
}
|
package/src/index.ts
CHANGED
|
@@ -646,7 +646,11 @@ export {
|
|
|
646
646
|
READINESS_GRACE_DEFAULT_MS,
|
|
647
647
|
READINESS_GRACE_MAX_MS,
|
|
648
648
|
} from './lifecycle-grace';
|
|
649
|
-
export type {
|
|
649
|
+
export type {
|
|
650
|
+
ReadinessCheck,
|
|
651
|
+
ReadinessCheckOptions,
|
|
652
|
+
ReadinessStatus,
|
|
653
|
+
} from './lifecycle-readiness';
|
|
650
654
|
export type { SignalHandlerOptions } from './lifecycle-signals';
|
|
651
655
|
export { drainSignals, installSignalHandlers } from './lifecycle-signals';
|
|
652
656
|
export { isSelfOrigin, listeningOrigins, markListening, resetListeners } from './listeners';
|
|
@@ -675,6 +679,9 @@ export {
|
|
|
675
679
|
resetMeasurementActor,
|
|
676
680
|
} from './measurement-actor';
|
|
677
681
|
export { nearestName } from './nearest-name';
|
|
682
|
+
// An outage said on failures 1, 2, 4, 8, … and once when it ends.
|
|
683
|
+
export type { OutageLog, OutageLogOptions } from './outage-log';
|
|
684
|
+
export { isOutageMilestone, outageLog } from './outage-log';
|
|
678
685
|
/** The message pwa's `sw.js` posts and realtime's outbox listens for. */
|
|
679
686
|
export { OUTBOX_DRAIN_MESSAGE, type OutboxDrainMessage } from './outbox-drain';
|
|
680
687
|
/** The `<meta name>`s render writes and the page client, realtime and pwa read. */
|
|
@@ -4,7 +4,22 @@
|
|
|
4
4
|
|
|
5
5
|
import { UltimateError } from './errors';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* `'degraded'` is a failed check the role serves WITHOUT (`onFailure: 'degraded'`): reported by
|
|
9
|
+
* name, never a 503 on `/readyz` in either mode, and a 503 on `/readyz?deep=1` — the probe routes
|
|
10
|
+
* traffic on the first and a monitor alerts on the second.
|
|
11
|
+
*/
|
|
12
|
+
export type ReadinessStatus = 'ok' | 'degraded' | 'failing';
|
|
13
|
+
|
|
14
|
+
export interface ReadinessCheckOptions {
|
|
15
|
+
/**
|
|
16
|
+
* What a `false` (or a throw) from this check is. `'failing'`, the default, is a dependency the
|
|
17
|
+
* role cannot serve without. `'degraded'` is one it can — the bus of a role that only publishes
|
|
18
|
+
* to it: pulling every replica from the ingress for a dependency no served request waits on is
|
|
19
|
+
* the outage the probe exists to prevent.
|
|
20
|
+
*/
|
|
21
|
+
readonly onFailure?: 'failing' | 'degraded' | undefined;
|
|
22
|
+
}
|
|
8
23
|
|
|
9
24
|
/**
|
|
10
25
|
* Synchronous, and that is the design, not a limitation. **Do not widen this to
|
|
@@ -23,13 +38,22 @@ export type ReadinessStatus = 'ok' | 'failing';
|
|
|
23
38
|
*/
|
|
24
39
|
export type ReadinessCheck = () => boolean;
|
|
25
40
|
|
|
26
|
-
|
|
41
|
+
interface Registered {
|
|
42
|
+
readonly check: ReadinessCheck;
|
|
43
|
+
readonly onFailure: 'failing' | 'degraded';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const checks = new Map<string, Registered>();
|
|
27
47
|
|
|
28
48
|
/**
|
|
29
49
|
* Register a named readiness check. Returns its unregister — the same shape as `onShutdown`, and
|
|
30
50
|
* owned by whoever can be started twice, for the same reason.
|
|
31
51
|
*/
|
|
32
|
-
export function registerReadinessCheck(
|
|
52
|
+
export function registerReadinessCheck(
|
|
53
|
+
name: string,
|
|
54
|
+
check: ReadinessCheck,
|
|
55
|
+
options: ReadinessCheckOptions = {},
|
|
56
|
+
): () => void {
|
|
33
57
|
if (checks.has(name)) {
|
|
34
58
|
throw new UltimateError({
|
|
35
59
|
code: 'X_READINESS_CHECK_DUPLICATE',
|
|
@@ -38,9 +62,10 @@ export function registerReadinessCheck(name: string, check: ReadinessCheck): ()
|
|
|
38
62
|
meta: { name },
|
|
39
63
|
});
|
|
40
64
|
}
|
|
41
|
-
|
|
65
|
+
const registered: Registered = { check, onFailure: options.onFailure ?? 'failing' };
|
|
66
|
+
checks.set(name, registered);
|
|
42
67
|
return () => {
|
|
43
|
-
if (checks.get(name) ===
|
|
68
|
+
if (checks.get(name) === registered) checks.delete(name);
|
|
44
69
|
};
|
|
45
70
|
}
|
|
46
71
|
|
|
@@ -55,8 +80,8 @@ export function clearReadinessChecks(): void {
|
|
|
55
80
|
}
|
|
56
81
|
|
|
57
82
|
/**
|
|
58
|
-
* Every check, run now, by name. A check that throws is `failing`
|
|
59
|
-
* and is handed to `onThrow`, which `lifecycle.ts` routes through its total `report`.
|
|
83
|
+
* Every check, run now, by name. A check that throws is its `onFailure` (`failing` unless it was
|
|
84
|
+
* registered degradable) — never an unhandled error — and is handed to `onThrow`, which `lifecycle.ts` routes through its total `report`.
|
|
60
85
|
*
|
|
61
86
|
* Built through `Object.fromEntries`, never by assigning `results[name]`: assignment to the one
|
|
62
87
|
* name `__proto__` sets the PROTOTYPE instead of adding a key, so that check vanished from the
|
|
@@ -67,11 +92,11 @@ export function runReadinessChecks(
|
|
|
67
92
|
onThrow: (name: string, thrown: unknown) => void,
|
|
68
93
|
): Readonly<Record<string, ReadinessStatus>> {
|
|
69
94
|
const results: [string, ReadinessStatus][] = [];
|
|
70
|
-
for (const [name, check] of checks) {
|
|
95
|
+
for (const [name, { check, onFailure }] of checks) {
|
|
71
96
|
try {
|
|
72
|
-
results.push([name, check() ? 'ok' :
|
|
97
|
+
results.push([name, check() ? 'ok' : onFailure]);
|
|
73
98
|
} catch (thrown) {
|
|
74
|
-
results.push([name,
|
|
99
|
+
results.push([name, onFailure]);
|
|
75
100
|
onThrow(name, thrown);
|
|
76
101
|
}
|
|
77
102
|
}
|
package/src/lifecycle.ts
CHANGED
|
@@ -374,9 +374,15 @@ export function drain(signal = 'manual'): Promise<void> {
|
|
|
374
374
|
return drainPromise;
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
-
export function healthReport(
|
|
377
|
+
export function healthReport(
|
|
378
|
+
mode: ReadinessMode = readinessMode,
|
|
379
|
+
/** `/readyz?deep=1`: a `'degraded'` check is not ready either. Never the probe's reading. */
|
|
380
|
+
strict = false,
|
|
381
|
+
): HealthReport {
|
|
378
382
|
const checks = readinessChecks();
|
|
379
|
-
const dependencies = Object.values(checks).every(
|
|
383
|
+
const dependencies = Object.values(checks).every(
|
|
384
|
+
(status) => status === 'ok' || (!strict && status === 'degraded'),
|
|
385
|
+
);
|
|
380
386
|
return {
|
|
381
387
|
state,
|
|
382
388
|
// `ready` is the same predicate `/readyz` answers on, so a body and its status can never
|
|
@@ -405,9 +411,11 @@ export function healthzPayload(): HealthPayload {
|
|
|
405
411
|
/**
|
|
406
412
|
* Readiness: may this instance receive traffic? 503 while starting or draining, and — in the
|
|
407
413
|
* default `'dependencies'` mode, or always with `deep` (`/readyz?deep=1`) — while any check fails.
|
|
414
|
+
* A `'degraded'` check (`ReadinessCheckOptions.onFailure`) is a 503 under `deep` ALONE.
|
|
408
415
|
*/
|
|
409
416
|
export function readyzPayload(options: { readonly deep?: boolean } = {}): HealthPayload {
|
|
410
|
-
const
|
|
417
|
+
const deep = options.deep === true;
|
|
418
|
+
const body = healthReport(deep ? 'dependencies' : readinessMode, deep);
|
|
411
419
|
return { ok: body.ready, status: body.ready ? 200 : 503, body };
|
|
412
420
|
}
|
|
413
421
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Single responsibility: one condition that KEEPS failing, said rarely — on failures 1, 2, 4, 8, …
|
|
2
|
+
// and once more when it ends. An outage is one fact; a line per retry per pod for as long as it
|
|
3
|
+
// lasts is the fact buried under itself.
|
|
4
|
+
|
|
5
|
+
import { type LogFields, logger } from './logger';
|
|
6
|
+
|
|
7
|
+
export interface OutageLogOptions {
|
|
8
|
+
/** The line's message while the condition fails. Low cardinality: it is what a monitor groups on. */
|
|
9
|
+
readonly event: string;
|
|
10
|
+
/**
|
|
11
|
+
* `'warn'` for a process that is degraded and still serving (a publisher without its bus).
|
|
12
|
+
* `'error'` for one that cannot do its job (a `sync` node without its bus).
|
|
13
|
+
*/
|
|
14
|
+
readonly level: 'warn' | 'error';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface OutageLog {
|
|
18
|
+
/**
|
|
19
|
+
* One more failure. Written — with `failures`, the count since it last worked — on the 1st,
|
|
20
|
+
* 2nd, 4th, 8th, …; answers whether this one was, so a caller thins a second sink the same way.
|
|
21
|
+
*/
|
|
22
|
+
failed(fields?: LogFields): boolean;
|
|
23
|
+
/** It works again: ONE `info` line, `<event> recovered`, with `after`. Silent when nothing had failed. */
|
|
24
|
+
recovered(fields?: LogFields): void;
|
|
25
|
+
/** Failures since it last worked. */
|
|
26
|
+
readonly failures: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** True on 1, 2, 4, 8, …: the attempts an outage is said on. */
|
|
30
|
+
export function isOutageMilestone(failures: number): boolean {
|
|
31
|
+
return failures > 0 && (failures & (failures - 1)) === 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function outageLog(options: OutageLogOptions): OutageLog {
|
|
35
|
+
let failures = 0;
|
|
36
|
+
return {
|
|
37
|
+
get failures(): number {
|
|
38
|
+
return failures;
|
|
39
|
+
},
|
|
40
|
+
failed(fields?: LogFields): boolean {
|
|
41
|
+
failures += 1;
|
|
42
|
+
if (!isOutageMilestone(failures)) return false;
|
|
43
|
+
logger[options.level](options.event, { ...fields, failures });
|
|
44
|
+
return true;
|
|
45
|
+
},
|
|
46
|
+
recovered(fields?: LogFields): void {
|
|
47
|
+
if (failures === 0) return;
|
|
48
|
+
const after = failures;
|
|
49
|
+
failures = 0;
|
|
50
|
+
logger.info(`${options.event} recovered`, { ...fields, after });
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
package/src/single-flight.ts
CHANGED
|
@@ -40,6 +40,15 @@ export interface SingleFlight {
|
|
|
40
40
|
): Promise<T>;
|
|
41
41
|
/** In-flight loads right now. A number that does not fall back to `0` is a leak. */
|
|
42
42
|
readonly size: number;
|
|
43
|
+
/** The keys held by a load right now — what a caller that must `evict` by a rule walks. */
|
|
44
|
+
keys(): readonly string[];
|
|
45
|
+
/**
|
|
46
|
+
* Free `key` NOW, so the next `run` leads a fresh load instead of joining the one in flight.
|
|
47
|
+
* For a caller that learns the load in flight is already wrong — it began before a write its
|
|
48
|
+
* result must reflect. The load is not cancelled and its own callers still get its value; only
|
|
49
|
+
* later ones stop joining it. `false` when nothing held the key.
|
|
50
|
+
*/
|
|
51
|
+
evict(key: string): boolean;
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
/** The leader's promise, the box its merged context lives in, and its deadline's canceller. */
|
|
@@ -73,6 +82,15 @@ export function singleFlight(options?: SingleFlightOptions): SingleFlight {
|
|
|
73
82
|
return inflight.size;
|
|
74
83
|
},
|
|
75
84
|
|
|
85
|
+
keys: () => [...inflight.keys()],
|
|
86
|
+
|
|
87
|
+
evict(key: string): boolean {
|
|
88
|
+
const held = inflight.get(key);
|
|
89
|
+
if (held === undefined) return false;
|
|
90
|
+
held.cancelDeadline();
|
|
91
|
+
return inflight.delete(key);
|
|
92
|
+
},
|
|
93
|
+
|
|
76
94
|
run<T, C = undefined>(
|
|
77
95
|
key: string,
|
|
78
96
|
work: (shared: () => C | undefined) => Promise<T>,
|