@ultimat3/core 25.2.0 → 26.0.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 +6 -1
- package/package.json +2 -2
- package/src/config-defaults.ts +6 -1
- package/src/config-health.ts +9 -1
- package/src/drain-deadline.ts +29 -3
- package/src/index.ts +9 -1
- package/src/lifecycle.ts +19 -0
- package/src/log-tee.ts +73 -0
- package/src/logger.ts +8 -0
package/README.md
CHANGED
|
@@ -55,6 +55,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
55
55
|
| `Clock` — the only source of "now" | `clock.ts` |
|
|
56
56
|
| UUIDv7, nanoid, branded ids | `ids.ts` |
|
|
57
57
|
| structured JSON logging + redaction; `setLogSink(sink)` — the test seam that sends every default-writer line to a sink instead of the process's streams (a test preload drops them; a test asserting on the process logger collects them) | `logger.ts` |
|
|
58
|
+
| `addLogSink(sink)` — the supported tee: every default-writer line, after redaction, to `sink` **beside** the streams (or a `setLogSink` seam), never instead; returns the unsubscribe. A sink that throws is skipped and reported once (`log.sink_failed`); a line a sink logs is not teed back | `log-tee.ts` |
|
|
58
59
|
| OTel-shaped spans, always on, no-op by default | `telemetry.ts` |
|
|
59
60
|
| the sampling decision, and `OTEL_TRACES_SAMPLER*` | `sampler.ts` |
|
|
60
61
|
| OTLP/HTTP JSON: endpoint, headers, value encoding | `otlp.ts` |
|
|
@@ -70,7 +71,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
|
|
|
70
71
|
| graceful drain, `/healthz`, `/readyz` | `lifecycle.ts` |
|
|
71
72
|
| what a health endpoint tells whom — `healthBody(report, role, detailed)`, `healthPeerListed(peers, address)`, `DEFAULT_HEALTH_DETAIL_PEERS`; the one rule `@ultimat3/http` and the sync node's own listener both call | `health-disclosure.ts` |
|
|
72
73
|
| the readiness grace between `/readyz` → 503 and the listener closing (`drain.readinessGraceMs`) | `lifecycle-grace.ts` |
|
|
73
|
-
| the drain budget's default and domain (`drain.deadlineMs`, 25 s, 1–3600000 ms) — `DRAIN_DEADLINE_DEFAULT_MS`, `DRAIN_DEADLINE_MAX_MS` | `drain-deadline.ts` |
|
|
74
|
+
| the drain budget's default and domain (`drain.deadlineMs`, 25 s, 1–3600000 ms; the worker's optional `drain.workerDeadlineMs`, 1–86400000 ms) — `DRAIN_DEADLINE_DEFAULT_MS`, `DRAIN_DEADLINE_MAX_MS`, `WORKER_DRAIN_DEADLINE_MAX_MS` | `drain-deadline.ts` |
|
|
74
75
|
| `jobs.concurrency`'s default and domain — one slot count for every queue a worker serves, or a table per queue (`{ banks: 4, 'banks-long': 2 }`; a queue the table does not name runs at the default). `JOBS_CONCURRENCY_DEFAULT` (8), `type JobsConcurrency` | `config-jobs.ts` |
|
|
75
76
|
| SIGTERM/SIGINT → the one drain; on Windows also SIGHUP (console close) and SIGBREAK (Ctrl-Break) — `drainSignals(platform)` | `lifecycle-signals.ts` |
|
|
76
77
|
| is this directory inside a `bun build --compile` binary? `isCompiledBundle(import.meta.dir)` — `/$bunfs/` and Windows' `B:\~BUN\` | `bunfs.ts` |
|
|
@@ -485,6 +486,10 @@ match with `sealAll()`; uniqueness cannot be held across keys.
|
|
|
485
486
|
once has to keep it: a discarded one is a hook per `start()`, each retaining the resource it
|
|
486
487
|
was going to drain, and the next drain runs every one of them against a torn-down copy.
|
|
487
488
|
`shutdownHookCount()` is the test-only probe that makes the leak assertable.
|
|
489
|
+
- `isRetiring()` is true from the moment a worker's retire begins (SIGUSR2 in production, `x dev`'s
|
|
490
|
+
restart) for the rest of the process — the retire finishes every held job BEFORE the drain, so
|
|
491
|
+
`isDraining()` is still false the whole time. Read it where the app reports its own state (a
|
|
492
|
+
heartbeat). `markRetiring()` is the retire's own call; there is no un-retire.
|
|
488
493
|
- `registerReadinessCheck(name, check)` is what makes `/readyz` mean **usable** rather than
|
|
489
494
|
**bound**. `ReadinessCheck` is `() => boolean` and must stay synchronous — a probe that awaits its
|
|
490
495
|
dependency turns a slow dependency into a wedged endpoint and then a restart loop; keep a boolean
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "26.0.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": "
|
|
40
|
+
"@ultimat3/schema": "26.0.0"
|
|
41
41
|
}
|
|
42
42
|
}
|
package/src/config-defaults.ts
CHANGED
|
@@ -47,7 +47,12 @@ export function configDefaults(name: string): Omit<AppConfig, Sectioned> {
|
|
|
47
47
|
notify: { inboxReadRetentionMs: undefined, inboxUnreadRetentionMs: undefined },
|
|
48
48
|
ai: { mcp: { expose: true } },
|
|
49
49
|
// Read from the process env when the config is DEFINED — the same env the drain will run in.
|
|
50
|
-
drain: {
|
|
50
|
+
drain: {
|
|
51
|
+
readinessGraceMs: defaultReadinessGraceMs(),
|
|
52
|
+
deadlineMs: DRAIN_DEADLINE_DEFAULT_MS,
|
|
53
|
+
// Unset: the worker drains on `deadlineMs`, like every role.
|
|
54
|
+
workerDeadlineMs: undefined,
|
|
55
|
+
},
|
|
51
56
|
health: { readiness: 'dependencies' },
|
|
52
57
|
};
|
|
53
58
|
}
|
package/src/config-health.ts
CHANGED
|
@@ -40,9 +40,17 @@ export interface DrainConfig {
|
|
|
40
40
|
* The drain budget: how long a SIGTERM'd process has to finish what it holds — in-flight requests,
|
|
41
41
|
* a running job — after the grace, before the lifecycle abandons the rest. Applied to EVERY role,
|
|
42
42
|
* so this is the knob that gives a long job room to finish on a deploy. Default 25000. A whole
|
|
43
|
-
* number, 1–3600000. The ONE drain budget, the web role's included
|
|
43
|
+
* number, 1–3600000. The ONE drain budget, the web role's included — except the worker's when
|
|
44
|
+
* `workerDeadlineMs` is set.
|
|
44
45
|
*/
|
|
45
46
|
readonly deadlineMs: number;
|
|
47
|
+
/**
|
|
48
|
+
* `ROLE=worker`'s drain budget, in place of `deadlineMs`, for a job that must finish rather than
|
|
49
|
+
* be cut off and replayed and may run past the hour `deadlineMs` is capped at. It also bounds a
|
|
50
|
+
* retire (SIGUSR2) that a SIGTERM lands in. Unset (the default): the worker drains on
|
|
51
|
+
* `deadlineMs`. A whole number, 1–86400000. The chart's worker grace period is derived from it.
|
|
52
|
+
*/
|
|
53
|
+
readonly workerDeadlineMs: number | undefined;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
/** Why a value is not a readiness mode, or `undefined` when it is one. */
|
package/src/drain-deadline.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Single responsibility: the drain budget's default and its domain — `drain.deadlineMs`
|
|
2
|
-
// `app.config.ts`. One module because the config validator, the lifecycle's own default and the
|
|
1
|
+
// Single responsibility: the drain budget's default and its domain — `drain.deadlineMs` and the
|
|
2
|
+
// worker's `drain.workerDeadlineMs` in `app.config.ts`. One module because the config validator, the lifecycle's own default and the
|
|
3
3
|
// chart's grace period must all mean the same number.
|
|
4
4
|
|
|
5
5
|
import { countIssue } from './config-count';
|
|
@@ -19,7 +19,15 @@ export const DRAIN_DEADLINE_DEFAULT_MS = 25_000;
|
|
|
19
19
|
*/
|
|
20
20
|
export const DRAIN_DEADLINE_MAX_MS = 3_600_000;
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* A day: `drain.workerDeadlineMs`'s ceiling. The worker is the one role whose in-flight unit — a
|
|
24
|
+
* job that must not run twice, a bank login — can outlast the hour, and a retired worker
|
|
25
|
+
* (`SIGUSR2`) is bound by this budget when a SIGTERM lands mid-retire.
|
|
26
|
+
*/
|
|
27
|
+
export const WORKER_DRAIN_DEADLINE_MAX_MS = 86_400_000;
|
|
28
|
+
|
|
22
29
|
const DEADLINE_KEY = 'drain.deadlineMs';
|
|
30
|
+
const WORKER_DEADLINE_KEY = 'drain.workerDeadlineMs';
|
|
23
31
|
|
|
24
32
|
/**
|
|
25
33
|
* Why a value is not a drain budget, or `undefined` when it is one. A whole number of milliseconds
|
|
@@ -34,10 +42,28 @@ export function drainDeadlineIssue(value: unknown): string | undefined {
|
|
|
34
42
|
: undefined;
|
|
35
43
|
}
|
|
36
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Why a value is not the worker's drain budget, or `undefined` when it is one (or is unset — the
|
|
47
|
+
* worker then drains on `drain.deadlineMs`). A whole number in `1 ≤ v ≤ 86400000`.
|
|
48
|
+
*/
|
|
49
|
+
export function workerDrainDeadlineIssue(value: unknown): string | undefined {
|
|
50
|
+
if (value === undefined) return undefined;
|
|
51
|
+
const count = countIssue(WORKER_DEADLINE_KEY, value, 1);
|
|
52
|
+
if (count !== undefined) return count;
|
|
53
|
+
return (value as number) > WORKER_DRAIN_DEADLINE_MAX_MS
|
|
54
|
+
? `${WORKER_DEADLINE_KEY} must be at most ${WORKER_DRAIN_DEADLINE_MAX_MS} milliseconds (a day) — a job longer than that belongs in steps, which replay instead of re-running`
|
|
55
|
+
: undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
37
58
|
/** The `drain` section's issues, one per key, for `defineConfig`'s validator. */
|
|
38
59
|
export function drainIssues(drain: {
|
|
39
60
|
readonly readinessGraceMs: unknown;
|
|
40
61
|
readonly deadlineMs: unknown;
|
|
62
|
+
readonly workerDeadlineMs?: unknown;
|
|
41
63
|
}): readonly (string | undefined)[] {
|
|
42
|
-
return [
|
|
64
|
+
return [
|
|
65
|
+
readinessGraceIssue(drain.readinessGraceMs),
|
|
66
|
+
drainDeadlineIssue(drain.deadlineMs),
|
|
67
|
+
workerDrainDeadlineIssue(drain.workerDeadlineMs),
|
|
68
|
+
];
|
|
43
69
|
}
|
package/src/index.ts
CHANGED
|
@@ -225,7 +225,11 @@ export {
|
|
|
225
225
|
CursorSecretDevError,
|
|
226
226
|
devSecretsRefused,
|
|
227
227
|
} from './dev-secrets';
|
|
228
|
-
export {
|
|
228
|
+
export {
|
|
229
|
+
DRAIN_DEADLINE_DEFAULT_MS,
|
|
230
|
+
DRAIN_DEADLINE_MAX_MS,
|
|
231
|
+
WORKER_DRAIN_DEADLINE_MAX_MS,
|
|
232
|
+
} from './drain-deadline';
|
|
229
233
|
export type {
|
|
230
234
|
Env,
|
|
231
235
|
EnvBooleanVar,
|
|
@@ -613,8 +617,10 @@ export {
|
|
|
613
617
|
idleWaiterCount,
|
|
614
618
|
inflightCount,
|
|
615
619
|
isDraining,
|
|
620
|
+
isRetiring,
|
|
616
621
|
lifecycleState,
|
|
617
622
|
markReady,
|
|
623
|
+
markRetiring,
|
|
618
624
|
onShutdown,
|
|
619
625
|
readinessCheckCount,
|
|
620
626
|
readinessChecks,
|
|
@@ -638,6 +644,8 @@ export type { Direction } from './locale-direction';
|
|
|
638
644
|
export { directionOf, isRtl } from './locale-direction';
|
|
639
645
|
export type { LocalePathSplit } from './locale-path';
|
|
640
646
|
export { localeSegment, localizePath, splitLocalePath } from './locale-path';
|
|
647
|
+
// The supported tee: every default-writer line, after redaction, beside the streams.
|
|
648
|
+
export { addLogSink } from './log-tee';
|
|
641
649
|
// The process logger's test seam, beside nothing it groups with: where a default-writer line goes.
|
|
642
650
|
export type { LogSink } from './logger';
|
|
643
651
|
export { setLogSink } from './logger';
|
package/src/lifecycle.ts
CHANGED
|
@@ -61,6 +61,8 @@ let readinessMode: ReadinessMode = 'dependencies';
|
|
|
61
61
|
let clock: Clock = systemClock;
|
|
62
62
|
let log: Logger = rootLogger;
|
|
63
63
|
let state: HealthState = 'starting';
|
|
64
|
+
/** One-way: set by a worker's retire, never cleared but by `resetLifecycle()`. */
|
|
65
|
+
let retiring = false;
|
|
64
66
|
let startedAtMono = clock.monotonic();
|
|
65
67
|
let inflight = 0;
|
|
66
68
|
/** Bumped by `resetLifecycle()`: a `beginWork()` finisher only ever counts down its own lifetime. */
|
|
@@ -215,6 +217,22 @@ export function isDraining(): boolean {
|
|
|
215
217
|
return state === 'draining' || state === 'stopped';
|
|
216
218
|
}
|
|
217
219
|
|
|
220
|
+
/**
|
|
221
|
+
* True from the moment a worker's retire begins (SIGUSR2, `@ultimat3/cli`'s `serve-retire.ts`) for
|
|
222
|
+
* the rest of the process. The retire stops claiming and finishes every held job BEFORE the drain,
|
|
223
|
+
* so `isDraining()` stays false the whole time — this is how the app's own code (a heartbeat, a
|
|
224
|
+
* status row) tells "finishing up, about to exit" from "serving". Not a drain: `/readyz` and new
|
|
225
|
+
* work are untouched.
|
|
226
|
+
*/
|
|
227
|
+
export function isRetiring(): boolean {
|
|
228
|
+
return retiring;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The retire's own call, made before it stops the worker. Idempotent; there is no un-retire. */
|
|
232
|
+
export function markRetiring(): void {
|
|
233
|
+
retiring = true;
|
|
234
|
+
}
|
|
235
|
+
|
|
218
236
|
function waitForIdle(timeoutMs: number): Promise<boolean> {
|
|
219
237
|
if (inflight === 0) return Promise.resolve(true);
|
|
220
238
|
return new Promise<boolean>((resolve) => {
|
|
@@ -401,6 +419,7 @@ export function resetLifecycle(): void {
|
|
|
401
419
|
clock = systemClock;
|
|
402
420
|
log = rootLogger;
|
|
403
421
|
state = 'starting';
|
|
422
|
+
retiring = false;
|
|
404
423
|
startedAtMono = clock.monotonic();
|
|
405
424
|
inflight = 0;
|
|
406
425
|
lifetime += 1;
|
package/src/log-tee.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Single responsibility: `addLogSink` — the supported tee of every default-writer log line, beside
|
|
2
|
+
// the process's streams and never instead of them (`setLogSink` is the test seam that replaces).
|
|
3
|
+
// Total: a sink that throws or logs cannot cost the line, the other sinks, or the process.
|
|
4
|
+
|
|
5
|
+
import { systemClock } from './clock';
|
|
6
|
+
import { renderThrowable } from './error-render';
|
|
7
|
+
import type { LogLevel, LogSink } from './logger';
|
|
8
|
+
|
|
9
|
+
let sinks: readonly LogSink[] = [];
|
|
10
|
+
/** Set while sinks run: a line a sink logs reaches the streams, never the tee it came from. */
|
|
11
|
+
let teeing = false;
|
|
12
|
+
/** Each sink says it failed ONCE — a sink that throws on every line would double the log. */
|
|
13
|
+
const reported = new WeakSet<LogSink>();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tee every line the process logger writes — the complete JSON line, AFTER redaction, and its
|
|
17
|
+
* level — to `sink`, in addition to stdout/stderr (or a `setLogSink` test sink). Returns the
|
|
18
|
+
* unsubscribe; calling it twice is a no-op.
|
|
19
|
+
*
|
|
20
|
+
* The population is `setLogSink`'s: every logger without its own `writer` — `logger`, every
|
|
21
|
+
* `child()` of it, `ctx.logger`. Sinks run synchronously, in the order added, after the line is
|
|
22
|
+
* written; one added while a line is being teed sees the next line, not that one. A sink that
|
|
23
|
+
* throws is skipped for that line and reported once as `log.sink_failed` on the streams; a line a
|
|
24
|
+
* sink logs itself is written but not teed back into the sinks.
|
|
25
|
+
*/
|
|
26
|
+
export function addLogSink(sink: LogSink): () => void {
|
|
27
|
+
sinks = [...sinks, sink];
|
|
28
|
+
return () => {
|
|
29
|
+
sinks = sinks.filter((candidate) => candidate !== sink);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** How many sinks are teeing now — a count that climbs across a test is an unsubscribe missed. */
|
|
34
|
+
export function logTeeCount(): number {
|
|
35
|
+
return sinks.length;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Called by the logger's default writer after `write` has put the line on the streams. */
|
|
39
|
+
export function teeLogLine(line: string, level: LogLevel, write: LogSink): void {
|
|
40
|
+
if (teeing || sinks.length === 0) return;
|
|
41
|
+
teeing = true;
|
|
42
|
+
try {
|
|
43
|
+
for (const sink of sinks) {
|
|
44
|
+
try {
|
|
45
|
+
sink(line, level);
|
|
46
|
+
} catch (thrown) {
|
|
47
|
+
reportOnce(sink, thrown, write);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} finally {
|
|
51
|
+
teeing = false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Straight to the streams, never through the logger: the logger is what called the sink. */
|
|
56
|
+
function reportOnce(sink: LogSink, thrown: unknown, write: LogSink): void {
|
|
57
|
+
if (reported.has(sink)) return;
|
|
58
|
+
reported.add(sink);
|
|
59
|
+
try {
|
|
60
|
+
write(
|
|
61
|
+
JSON.stringify({
|
|
62
|
+
ts: systemClock.now().toISOString(),
|
|
63
|
+
level: 'warn',
|
|
64
|
+
msg: 'log.sink_failed',
|
|
65
|
+
cause: `a log sink added with addLogSink threw ${renderThrowable(thrown)}; it is skipped for that line and still called for the next — this is reported once per sink`,
|
|
66
|
+
fix: 'addLogSink((line, level) => { try { store(line, level); } catch { /* drop it: a sink must not throw */ } })',
|
|
67
|
+
}),
|
|
68
|
+
'warn',
|
|
69
|
+
);
|
|
70
|
+
} catch {
|
|
71
|
+
// The streams themselves are gone; there is nowhere left to say it.
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/logger.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { assert } from './assert';
|
|
|
5
5
|
import { type Clock, systemClock } from './clock';
|
|
6
6
|
import { renderCauseValue } from './error-render';
|
|
7
7
|
import { isUltimateError } from './errors';
|
|
8
|
+
import { teeLogLine } from './log-tee';
|
|
8
9
|
import { isSecret, REDACTED } from './secret';
|
|
9
10
|
|
|
10
11
|
// Re-exported, not redefined: `secret.ts` owns the placeholder because a `Secret` has to render
|
|
@@ -219,6 +220,13 @@ export function setLogSink(sink: LogSink | undefined): LogSink | undefined {
|
|
|
219
220
|
* its log stream, and where there is a `process` this writes to the fd as it always did.
|
|
220
221
|
*/
|
|
221
222
|
function defaultWriter(line: string, level: LogLevel): void {
|
|
223
|
+
streamWriter(line, level);
|
|
224
|
+
// After the streams, so a sink can never cost a line its stdout (`addLogSink`, `log-tee.ts`).
|
|
225
|
+
teeLogLine(line, level, streamWriter);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The process's streams, or the `setLogSink` seam standing in for them. */
|
|
229
|
+
function streamWriter(line: string, level: LogLevel): void {
|
|
222
230
|
if (logSink !== undefined) {
|
|
223
231
|
logSink(line, level);
|
|
224
232
|
return;
|