@ultimat3/core 25.1.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 +16 -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 +18 -1
- package/src/lifecycle.ts +19 -0
- package/src/log-tee.ts +73 -0
- package/src/logger.ts +8 -0
- package/src/probe-database-sweep.ts +68 -0
- package/src/probe-database.ts +44 -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
|
|
@@ -496,6 +501,16 @@ match with `sealAll()`; uniqueness cannot be held across keys.
|
|
|
496
501
|
before trusting an empty `checks`.
|
|
497
502
|
- Anything that opens a socket calls `markListening(server.url.origin)` and releases it on close.
|
|
498
503
|
That is what tells the sealed test network a loopback request is this process, not egress.
|
|
504
|
+
- **A live suite's throwaway database is named by `probeDatabaseName(prefix)`** — test support,
|
|
505
|
+
here because tier 0 is the one tier every package with a live suite can import. Prefix, pid,
|
|
506
|
+
creation second (base 36), eight random hex characters, at most `PROBE_DATABASE_NAME_MAX` (63) bytes, valid unquoted: a
|
|
507
|
+
fixed name let two runs against one server drop each other's database.
|
|
508
|
+
`bun run probe-databases` refuses `create database` on any other name (`X_PROBE_DATABASE_FIXED`).
|
|
509
|
+
A killed run never reaches its `afterAll`, so call `sweepProbeDatabases(executor, PROBE_DB)`
|
|
510
|
+
before the `create`: it drops the same prefix's probe databases whose pid is dead on this host
|
|
511
|
+
AND which no backend is connected to AND whose creation second, carried in the name, is older than
|
|
512
|
+
`PROBE_DATABASE_MIN_AGE_MS` (10 minutes) — a concurrent run on any host is left alone, even between
|
|
513
|
+
its `create` and its first connection.
|
|
499
514
|
|
|
500
515
|
## Metrics: same seam as tracing, one signal over
|
|
501
516
|
|
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';
|
|
@@ -676,6 +684,15 @@ export {
|
|
|
676
684
|
} from './page-meta';
|
|
677
685
|
/** The structural Postgres seam http, auth, action and jobs share without a `@ultimat3/db` edge. */
|
|
678
686
|
export type { PgExecutor } from './pg-executor';
|
|
687
|
+
/** Test support: the one name a live suite gives the database it creates (`probe-databases`). */
|
|
688
|
+
export type { ProbeDatabaseEntropy } from './probe-database';
|
|
689
|
+
export { PROBE_DATABASE_NAME_MAX, probeDatabaseName } from './probe-database';
|
|
690
|
+
export type { ProbeDatabaseSweepOptions } from './probe-database-sweep';
|
|
691
|
+
export {
|
|
692
|
+
PROBE_DATABASE_MIN_AGE_MS,
|
|
693
|
+
probeDatabaseAlive,
|
|
694
|
+
sweepProbeDatabases,
|
|
695
|
+
} from './probe-database-sweep';
|
|
679
696
|
export type { ProcessMetricsOptions, ProcessReading } from './process-metrics';
|
|
680
697
|
export { readProcess, resetProcessMetrics, startProcessMetrics } from './process-metrics';
|
|
681
698
|
export { hasPublicCause, registerPublicCause, resetPublicCauses } from './public-cause';
|
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;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Test support: drop the probe databases a KILLED run of the same suite left behind. A per-run name
|
|
2
|
+
// (`probe-database.ts`) is never reused, so nothing else would ever drop it — the fixed name it
|
|
3
|
+
// replaced was dropped by the next run. Only a database whose run is provably over goes: its pid
|
|
4
|
+
// dead on this host, no backend connected, AND older than `PROBE_DATABASE_MIN_AGE_MS` — a run on
|
|
5
|
+
// another host that has created its database and not yet connected reads dead and idle here.
|
|
6
|
+
|
|
7
|
+
import { stringField } from './error-render';
|
|
8
|
+
import type { PgExecutor } from './pg-executor';
|
|
9
|
+
|
|
10
|
+
/** `<prefix>_<pid>_<seconds, base 36>_<8 hex>` — exactly what `probeDatabaseName` returns. */
|
|
11
|
+
const PROBE_SHAPE = /^([a-z_][a-z0-9_]*)_(\d+)_([0-9a-z]+)_[0-9a-f]{8}$/;
|
|
12
|
+
|
|
13
|
+
/** A leftover younger than this is left alone: no suite takes ten minutes to make its first query. */
|
|
14
|
+
export const PROBE_DATABASE_MIN_AGE_MS = 10 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
export interface ProbeDatabaseSweepOptions {
|
|
17
|
+
/** Whether a pid is a live process on this host. Defaults to `probeDatabaseAlive`. */
|
|
18
|
+
readonly isAlive?: ((pid: number) => boolean) | undefined;
|
|
19
|
+
/** The clock a leftover's age is read against. Defaults to `Date.now`. */
|
|
20
|
+
readonly now?: (() => number) | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `kill(pid, 0)` delivers nothing and answers whether the process exists: `EPERM` is a process
|
|
25
|
+
* someone else owns, which is alive. Another host's pid reads as dead here, which is why the sweep
|
|
26
|
+
* also demands that nothing is connected.
|
|
27
|
+
*/
|
|
28
|
+
export function probeDatabaseAlive(pid: number): boolean {
|
|
29
|
+
try {
|
|
30
|
+
process.kill(pid, 0);
|
|
31
|
+
return true;
|
|
32
|
+
} catch (thrown) {
|
|
33
|
+
return stringField(thrown, 'code') === 'EPERM';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Drop every database sharing `probeName`'s prefix whose run is over, never `probeName` itself.
|
|
39
|
+
* Call it beside the `create database`; it answers the names it dropped. A name not shaped like
|
|
40
|
+
* `probeDatabaseName`'s answer has no siblings to find, and nothing is sent.
|
|
41
|
+
*/
|
|
42
|
+
export async function sweepProbeDatabases(
|
|
43
|
+
executor: PgExecutor,
|
|
44
|
+
probeName: string,
|
|
45
|
+
options: ProbeDatabaseSweepOptions = {},
|
|
46
|
+
): Promise<readonly string[]> {
|
|
47
|
+
const prefix = PROBE_SHAPE.exec(probeName)?.[1];
|
|
48
|
+
if (prefix === undefined) return [];
|
|
49
|
+
const isAlive = options.isAlive ?? probeDatabaseAlive;
|
|
50
|
+
const now = (options.now ?? Date.now)();
|
|
51
|
+
const rows = await executor.query<{ datname: string; backends: number | string }>(
|
|
52
|
+
`select d.datname, (select count(*) from pg_stat_activity a where a.datname = d.datname) as backends
|
|
53
|
+
from pg_database d where d.datname like $1`,
|
|
54
|
+
[`${prefix.replaceAll('_', '\\_')}\\_%`],
|
|
55
|
+
);
|
|
56
|
+
const swept: string[] = [];
|
|
57
|
+
for (const row of rows) {
|
|
58
|
+
const shape = PROBE_SHAPE.exec(row.datname);
|
|
59
|
+
if (row.datname === probeName || shape?.[1] !== prefix) continue;
|
|
60
|
+
if (Number(row.backends) > 0 || isAlive(Number(shape[2]))) continue;
|
|
61
|
+
const createdMs = Number.parseInt(shape[3] ?? '', 36) * 1000;
|
|
62
|
+
if (!Number.isFinite(createdMs) || now - createdMs < PROBE_DATABASE_MIN_AGE_MS) continue;
|
|
63
|
+
// The name matched `PROBE_SHAPE`, so it is `[a-z0-9_]` only — quoting it needs no escape.
|
|
64
|
+
await executor.query(`drop database if exists "${row.datname}" with (force)`, []);
|
|
65
|
+
swept.push(row.datname);
|
|
66
|
+
}
|
|
67
|
+
return swept;
|
|
68
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Test support: the ONE name a live or contract suite gives the database it creates and drops —
|
|
2
|
+
// prefix, pid, creation second (base 36), eight random hex characters. A fixed name let two runs against one server drop each
|
|
3
|
+
// other's database mid-suite (#705). Here, at tier 0, because every package with such a suite can
|
|
4
|
+
// import core and `@ultimat3/testing` (tier 5) is out of reach below it. `bun run probe-databases`.
|
|
5
|
+
|
|
6
|
+
/** Postgres's NAMEDATALEN - 1: a longer identifier is silently truncated, so two names could meet. */
|
|
7
|
+
export const PROBE_DATABASE_NAME_MAX = 63;
|
|
8
|
+
|
|
9
|
+
const RANDOM_HEX = 8;
|
|
10
|
+
|
|
11
|
+
/** Where a probe name's uniqueness comes from — injectable so a test can pin the answer. */
|
|
12
|
+
export interface ProbeDatabaseEntropy {
|
|
13
|
+
/** Defaults to `process.pid`: two concurrent runs are two processes. */
|
|
14
|
+
readonly pid?: number | undefined;
|
|
15
|
+
/** Defaults to a fresh `crypto.randomUUID()`: two suites in one process differ by this. */
|
|
16
|
+
readonly random?: string | undefined;
|
|
17
|
+
/** Defaults to `Date.now()`: the creation second the sweep ages a leftover by. */
|
|
18
|
+
readonly now?: number | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Lowercase `[a-z0-9_]`, starting with a letter or `_` — an identifier no statement must quote. */
|
|
22
|
+
function identifierPrefix(prefix: string): string {
|
|
23
|
+
const folded = prefix.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
24
|
+
if (folded === '') return 'x_probe';
|
|
25
|
+
return /^[a-z_]/.test(folded) ? folded : `x_${folded}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A database name unique to this call, valid unquoted, at most 63 bytes — truncation takes the
|
|
30
|
+
* prefix, never the suffix. The creation second is in the name because `pg_database` records none,
|
|
31
|
+
* and `sweepProbeDatabases` must not drop a run's database in the moment between its `create` and
|
|
32
|
+
* its first connection (`<prefix>_<pid>_<seconds, base 36>_<8 hex>`).
|
|
33
|
+
*/
|
|
34
|
+
export function probeDatabaseName(prefix: string, entropy: ProbeDatabaseEntropy = {}): string {
|
|
35
|
+
const pid = Math.trunc(Math.abs(entropy.pid ?? process.pid));
|
|
36
|
+
const hex = (entropy.random ?? crypto.randomUUID())
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/[^0-9a-f]/g, '')
|
|
39
|
+
.slice(0, RANDOM_HEX)
|
|
40
|
+
.padEnd(RANDOM_HEX, '0');
|
|
41
|
+
const seconds = Math.floor(Math.max(0, entropy.now ?? Date.now()) / 1000).toString(36);
|
|
42
|
+
const suffix = `_${String(pid)}_${seconds}_${hex}`;
|
|
43
|
+
return `${identifierPrefix(prefix).slice(0, PROBE_DATABASE_NAME_MAX - suffix.length)}${suffix}`;
|
|
44
|
+
}
|