@ultimat3/cli 27.10.3 → 27.11.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/package.json +28 -28
- package/src/framework-schema-apply.ts +36 -5
- package/src/framework-schema.ts +2 -2
- package/src/hold.ts +27 -2
- package/src/runtime-cache.ts +36 -3
- package/src/runtime-jobs-retention.ts +69 -0
- package/src/runtime-purge.ts +19 -5
- package/src/runtime-queue.ts +16 -7
- package/src/runtime-services.ts +2 -0
- package/src/schema-errors.ts +7 -4
- package/src/serve.ts +3 -0
- package/src/verify-deadline.ts +50 -11
- package/src/verify-stalled.ts +43 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.11.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -39,33 +39,33 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@babel/core": "^8.0.1",
|
|
42
|
-
"@ultimat3/action": "27.
|
|
43
|
-
"@ultimat3/admin": "27.
|
|
44
|
-
"@ultimat3/ai": "27.
|
|
45
|
-
"@ultimat3/auth": "27.
|
|
46
|
-
"@ultimat3/cache": "27.
|
|
47
|
-
"@ultimat3/core": "27.
|
|
48
|
-
"@ultimat3/db": "27.
|
|
49
|
-
"@ultimat3/entity": "27.
|
|
50
|
-
"@ultimat3/flags": "27.
|
|
51
|
-
"@ultimat3/http": "27.
|
|
52
|
-
"@ultimat3/i18n": "27.
|
|
53
|
-
"@ultimat3/jobs": "27.
|
|
54
|
-
"@ultimat3/mail": "27.
|
|
55
|
-
"@ultimat3/manifest": "27.
|
|
56
|
-
"@ultimat3/mcp": "27.
|
|
57
|
-
"@ultimat3/money": "27.
|
|
58
|
-
"@ultimat3/notify": "27.
|
|
59
|
-
"@ultimat3/policy": "27.
|
|
60
|
-
"@ultimat3/pwa": "27.
|
|
61
|
-
"@ultimat3/query": "27.
|
|
62
|
-
"@ultimat3/realtime": "27.
|
|
63
|
-
"@ultimat3/render": "27.
|
|
64
|
-
"@ultimat3/schema": "27.
|
|
65
|
-
"@ultimat3/seo": "27.
|
|
66
|
-
"@ultimat3/storage": "27.
|
|
67
|
-
"@ultimat3/testing": "27.
|
|
68
|
-
"@ultimat3/time": "27.
|
|
42
|
+
"@ultimat3/action": "27.11.0",
|
|
43
|
+
"@ultimat3/admin": "27.11.0",
|
|
44
|
+
"@ultimat3/ai": "27.11.0",
|
|
45
|
+
"@ultimat3/auth": "27.11.0",
|
|
46
|
+
"@ultimat3/cache": "27.11.0",
|
|
47
|
+
"@ultimat3/core": "27.11.0",
|
|
48
|
+
"@ultimat3/db": "27.11.0",
|
|
49
|
+
"@ultimat3/entity": "27.11.0",
|
|
50
|
+
"@ultimat3/flags": "27.11.0",
|
|
51
|
+
"@ultimat3/http": "27.11.0",
|
|
52
|
+
"@ultimat3/i18n": "27.11.0",
|
|
53
|
+
"@ultimat3/jobs": "27.11.0",
|
|
54
|
+
"@ultimat3/mail": "27.11.0",
|
|
55
|
+
"@ultimat3/manifest": "27.11.0",
|
|
56
|
+
"@ultimat3/mcp": "27.11.0",
|
|
57
|
+
"@ultimat3/money": "27.11.0",
|
|
58
|
+
"@ultimat3/notify": "27.11.0",
|
|
59
|
+
"@ultimat3/policy": "27.11.0",
|
|
60
|
+
"@ultimat3/pwa": "27.11.0",
|
|
61
|
+
"@ultimat3/query": "27.11.0",
|
|
62
|
+
"@ultimat3/realtime": "27.11.0",
|
|
63
|
+
"@ultimat3/render": "27.11.0",
|
|
64
|
+
"@ultimat3/schema": "27.11.0",
|
|
65
|
+
"@ultimat3/seo": "27.11.0",
|
|
66
|
+
"@ultimat3/storage": "27.11.0",
|
|
67
|
+
"@ultimat3/testing": "27.11.0",
|
|
68
|
+
"@ultimat3/time": "27.11.0",
|
|
69
69
|
"babel-preset-solid": "^1.9.15"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// DDL in ONE transaction behind the migration advisory lock with a bounded `lock_timeout`, then
|
|
1
|
+
// The three ways a boot meets the framework schema (plan 101, s1-con #5): `applyLockedSchema` runs
|
|
2
|
+
// the DDL in ONE transaction behind the migration advisory lock with a bounded `lock_timeout`, then
|
|
3
3
|
// stamps the build; `verifySchema` runs no DDL and refuses a database this build was never applied
|
|
4
|
-
// to
|
|
4
|
+
// to; `ensureSchema` reads the stamp and applies only when this build is absent from it. Which boot
|
|
5
|
+
// takes which is `runtime-queue.ts`'s `SchemaMode`.
|
|
5
6
|
|
|
6
7
|
import { frameworkVersion, logger } from '@ultimat3/core';
|
|
7
8
|
import type { DbClient } from '@ultimat3/db';
|
|
@@ -70,9 +71,39 @@ export async function verifySchema(client: DbClient): Promise<void> {
|
|
|
70
71
|
const mine = thisBuild();
|
|
71
72
|
const verdict = stampVerdict(parseStamp(await readStamp(client)), mine);
|
|
72
73
|
if (!verdict.applied) throw new FrameworkSchemaUnappliedError({ mine, newest: verdict.newest });
|
|
73
|
-
|
|
74
|
+
reportSkew(mine, verdict.skew);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A CLI command's boot — `x db backfill`, `x jobs`, `x auth`: no DDL when this build's stamp is
|
|
79
|
+
* already there, the locked apply when it is not.
|
|
80
|
+
*
|
|
81
|
+
* The stamp is read FIRST because re-applying idempotent DDL is not idempotent for every role.
|
|
82
|
+
* Postgres checks ownership before existence, so `create index if not exists` on a table the role
|
|
83
|
+
* does not own is `42501 must be owner` even when the index is there. A production runtime role
|
|
84
|
+
* holds DML only, and the PostSync backfill Job ran as it: every deploy's `x db backfill --all
|
|
85
|
+
* --write` died in the jobs entry and no sweep was ever enqueued. When the stamp is absent the
|
|
86
|
+
* apply still runs, so a fresh dev database needs no separate step — and a role that cannot apply
|
|
87
|
+
* it is refused with `X_FRAMEWORK_SCHEMA_FAILED`, whose fix is the migrate step.
|
|
88
|
+
*/
|
|
89
|
+
export async function ensureSchema(client: DbClient): Promise<void> {
|
|
90
|
+
const mine = thisBuild();
|
|
91
|
+
const verdict = stampVerdict(parseStamp(await readStamp(client)), mine);
|
|
92
|
+
if (!verdict.applied) {
|
|
93
|
+
await applyLockedSchema(client);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
reportSkew(mine, verdict.skew);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A fleet straddling a major is reported once and served — refusing it would crash-loop the old
|
|
101
|
+
* pods of every rolling deploy across a breaking release.
|
|
102
|
+
*/
|
|
103
|
+
function reportSkew(mine: StampEntry, skew: StampEntry | undefined): void {
|
|
104
|
+
if (skew === undefined) return;
|
|
74
105
|
logger.warn('ultimate framework major skew', {
|
|
75
|
-
cause: `this process runs ${mine.version} and the database's framework schema was last applied by ${
|
|
106
|
+
cause: `this process runs ${mine.version} and the database's framework schema was last applied by ${skew.version}: a fleet across a major boundary is mid-rollout or half rolled back`,
|
|
76
107
|
fix: 'kubectl rollout status deployment --selector app.kubernetes.io/component=web',
|
|
77
108
|
});
|
|
78
109
|
}
|
package/src/framework-schema.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The framework's OWN tables, as one table: which package declares each, which relations it
|
|
2
2
|
// creates, and the DDL. One list, read by the one applier, so "installed in dev and not in
|
|
3
|
-
// production" is not a state this framework can be in
|
|
4
|
-
//
|
|
3
|
+
// production" is not a state this framework can be in: `ROLE=migrate` applies it and stamps the
|
|
4
|
+
// build, and every other boot path meets it through `startQueue`'s `SchemaMode`.
|
|
5
5
|
//
|
|
6
6
|
// A framework table an app has to install by hand is a table that will be missing in production on
|
|
7
7
|
// the one code path that needs it, and it surfaces as a Postgres `42P01` from inside a worker.
|
package/src/hold.ts
CHANGED
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
// before the exit code. Ctrl-C then takes core's own three-phase drain (stop accepting, finish
|
|
4
4
|
// in-flight, close) instead of killing a query mid-round-trip.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
drain,
|
|
8
|
+
installSignalHandlers,
|
|
9
|
+
isDraining,
|
|
10
|
+
isUltimateError,
|
|
11
|
+
logger,
|
|
12
|
+
onShutdown,
|
|
13
|
+
systemClock,
|
|
14
|
+
} from '@ultimat3/core';
|
|
7
15
|
|
|
8
16
|
export interface HoldOptions {
|
|
9
17
|
/**
|
|
@@ -129,7 +137,24 @@ export async function holdWhileBooting<T extends { stop(): Promise<void> }>(
|
|
|
129
137
|
try {
|
|
130
138
|
app = await boot();
|
|
131
139
|
} catch (error) {
|
|
132
|
-
|
|
140
|
+
// A boot that failed BECAUSE a drain began under it — a rollout superseding a pod still
|
|
141
|
+
// booting, whose server then met `markReady()`'s `X_LIFECYCLE_DRAINED` — is a pod doing what
|
|
142
|
+
// it was told: finish the drain, say so once at info, exit 0. It printed a stack trace and
|
|
143
|
+
// exited non-zero, twice a week in production, paging on a normal rollout. A failure with no
|
|
144
|
+
// drain under way is still the boot's own refusal, rethrown untouched.
|
|
145
|
+
if (isDraining() && options.exit !== undefined) {
|
|
146
|
+
await drain();
|
|
147
|
+
armed.disarm();
|
|
148
|
+
logger.info('ultimate boot interrupted by shutdown', {
|
|
149
|
+
name,
|
|
150
|
+
code: isUltimateError(error) ? error.code : 'uncoded',
|
|
151
|
+
});
|
|
152
|
+
options.exit(0);
|
|
153
|
+
} else {
|
|
154
|
+
armed.disarm();
|
|
155
|
+
}
|
|
156
|
+
// Unreachable past a real `process.exit`; a test's `exit` spy returns, and the caller still
|
|
157
|
+
// sees the boot's error rather than an app that never booted.
|
|
133
158
|
throw error;
|
|
134
159
|
}
|
|
135
160
|
booted = app;
|
package/src/runtime-cache.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type { AppConfig, CacheTierName } from '@ultimat3/core';
|
|
|
23
23
|
import {
|
|
24
24
|
backoffDelay,
|
|
25
25
|
defineConfig,
|
|
26
|
+
finiteCount,
|
|
26
27
|
isOutageMilestone,
|
|
27
28
|
logger,
|
|
28
29
|
renderThrowable,
|
|
@@ -63,8 +64,23 @@ export interface CacheTiersOptions {
|
|
|
63
64
|
readonly tiers: readonly CacheTierName[];
|
|
64
65
|
/** The wait before re-subscribing after `attempt` failures (1-based). Injected by a test. */
|
|
65
66
|
readonly subscribeRetryMs?: ((attempt: number) => number) | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* How long the boot subscribe may be refused before a refusal is worth a warning. Default
|
|
69
|
+
* `FIRST_DIAL_GRACE_MS`; a test of an OUTAGE passes 0.
|
|
70
|
+
*/
|
|
71
|
+
readonly firstDialGraceMs?: number | undefined;
|
|
66
72
|
}
|
|
67
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The boot subscribe races the bus's FIRST dial, and loses it on almost every pod: in production
|
|
76
|
+
* every pod warned on attempts 1–2 and was subscribed within 1.3 s — 345 lines in three weeks of
|
|
77
|
+
* alerting noise. Refusals inside this window are not reported; a bus still refusing after it is
|
|
78
|
+
* an outage and warns as before (attempt milestones, then `recovered` at info). 10 s is several
|
|
79
|
+
* times the slowest first dial measured, and far under any window in which a missed peer bust
|
|
80
|
+
* matters: the process is flushed (`flushDeaf`) when the subscribe lands either way.
|
|
81
|
+
*/
|
|
82
|
+
export const FIRST_DIAL_GRACE_MS = 10_000;
|
|
83
|
+
|
|
68
84
|
/** 1 s doubling to 30 s, full jitter: a bus that is down is retried without a fleet in lockstep. */
|
|
69
85
|
const defaultSubscribeRetryMs = (attempt: number): number =>
|
|
70
86
|
backoffDelay({ attempt, base: 1_000, max: 30_000, jitter: 'full' });
|
|
@@ -163,6 +179,13 @@ function warnUnnamed(options: CacheTiersOptions): void {
|
|
|
163
179
|
* suppresses it, and `emit` is not a public parameter.
|
|
164
180
|
*/
|
|
165
181
|
export function startCacheTiers(options: CacheTiersOptions): () => Promise<void> {
|
|
182
|
+
// Every option checked before anything is registered: a refusal after `registerTier` or
|
|
183
|
+
// `registerInvalidationBroadcast` would leave process-global state installed with no release.
|
|
184
|
+
const graceMs = finiteCount(
|
|
185
|
+
'startCacheTiers',
|
|
186
|
+
'firstDialGraceMs',
|
|
187
|
+
options.firstDialGraceMs ?? FIRST_DIAL_GRACE_MS,
|
|
188
|
+
);
|
|
166
189
|
// Built before ANY of them is registered: a refusal halfway through a list would leave the
|
|
167
190
|
// process-global registry holding the rungs that came first, with no release returned to drop
|
|
168
191
|
// them. Registration order does not decide read order — `sortTiers` does, by `CACHE_TIERS`.
|
|
@@ -276,6 +299,9 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
|
|
|
276
299
|
const flushDeaf = (source: string): void => {
|
|
277
300
|
void flushProcessTiers(source);
|
|
278
301
|
};
|
|
302
|
+
const bootedAt = performance.now();
|
|
303
|
+
/** Whether an operator has been told of this outage — what makes `recovered` worth a line. */
|
|
304
|
+
let warned = false;
|
|
279
305
|
const subscribing = (async (): Promise<TransportSubscription | undefined> => {
|
|
280
306
|
for (let attempt = 1; !stopped; attempt += 1) {
|
|
281
307
|
try {
|
|
@@ -287,15 +313,22 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
|
|
|
287
313
|
);
|
|
288
314
|
subscribed = true;
|
|
289
315
|
if (attempt > 1) {
|
|
290
|
-
// The outage's one closing line, beside the thinned warnings it ends
|
|
291
|
-
|
|
316
|
+
// The outage's one closing line, beside the thinned warnings it ends — and only then: a
|
|
317
|
+
// first dial that landed inside its window had no outage to close.
|
|
318
|
+
if (warned) {
|
|
319
|
+
logger.info('cache.broadcast.subscribe-failed recovered', { after: attempt - 1 });
|
|
320
|
+
}
|
|
292
321
|
flushDeaf('cache.bus-subscribed');
|
|
293
322
|
}
|
|
294
323
|
return subscription;
|
|
295
324
|
} catch (error) {
|
|
296
325
|
// Attempts 1, 2, 4, 8, …: the dial's own thinning (`NatsTransport`), so an outage is not
|
|
297
326
|
// one line per retry per pod for as long as it lasts.
|
|
298
|
-
|
|
327
|
+
// Silent inside the first dial's window. Past it, the FIRST refusal warns whatever its
|
|
328
|
+
// attempt number, then the milestones thin the rest.
|
|
329
|
+
const inGrace = performance.now() - bootedAt < graceMs;
|
|
330
|
+
if (!inGrace && (!warned || isOutageMilestone(attempt))) {
|
|
331
|
+
warned = true;
|
|
299
332
|
logger.warn('cache.broadcast.subscribe-failed', {
|
|
300
333
|
error: broadcastErrorText(error),
|
|
301
334
|
attempt,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// `jobs.doneRetentionMs`, `jobs.deadRetentionMs` and `jobs.outboxRetentionMs`, out of the config
|
|
2
|
+
// `startServices` loads once (`app-config-load.ts`), as the windows the `x_jobs` and `x_outbox`
|
|
3
|
+
// purge targets are built with. The `notify` windows' sibling (`runtime-notify-retention.ts`), with
|
|
4
|
+
// one difference: these DEFAULT, because a finished job is bookkeeping and an unread message is not.
|
|
5
|
+
|
|
6
|
+
import type { AppConfig, PgExecutor } from '@ultimat3/core';
|
|
7
|
+
import {
|
|
8
|
+
finiteCount,
|
|
9
|
+
JOBS_DEAD_RETENTION_MS_DEFAULT,
|
|
10
|
+
JOBS_DONE_RETENTION_MS_DEFAULT,
|
|
11
|
+
JOBS_OUTBOX_RETENTION_MS_DEFAULT,
|
|
12
|
+
} from '@ultimat3/core';
|
|
13
|
+
import type { PurgeTarget } from '@ultimat3/jobs';
|
|
14
|
+
import { jobsPurgeTarget, outboxPurgeTarget } from '@ultimat3/jobs';
|
|
15
|
+
|
|
16
|
+
export interface JobsRetention {
|
|
17
|
+
readonly doneRetentionMs: number;
|
|
18
|
+
readonly deadRetentionMs: number;
|
|
19
|
+
readonly outboxRetentionMs: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SUBJECT = 'jobsRetentionOf';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The app's windows, or core's defaults for a boot with no config file or a hand-built
|
|
26
|
+
* `AppConfig` that left a key out (`JobsConfig` declares the three optional) — the same constants
|
|
27
|
+
* `defineConfig` fills in, so an app without `app.config.ts` keeps finished jobs exactly as long as
|
|
28
|
+
* one that never wrote the keys. Core's validator refuses a bad window in a loaded file; this
|
|
29
|
+
* screen is the layered form for the config that never met it.
|
|
30
|
+
*/
|
|
31
|
+
export function jobsRetentionOf(config: AppConfig | undefined): JobsRetention {
|
|
32
|
+
const jobs = config?.jobs;
|
|
33
|
+
return {
|
|
34
|
+
doneRetentionMs: finiteCount(
|
|
35
|
+
SUBJECT,
|
|
36
|
+
'jobs.doneRetentionMs',
|
|
37
|
+
jobs?.doneRetentionMs ?? JOBS_DONE_RETENTION_MS_DEFAULT,
|
|
38
|
+
1,
|
|
39
|
+
),
|
|
40
|
+
deadRetentionMs: finiteCount(
|
|
41
|
+
SUBJECT,
|
|
42
|
+
'jobs.deadRetentionMs',
|
|
43
|
+
jobs?.deadRetentionMs ?? JOBS_DEAD_RETENTION_MS_DEFAULT,
|
|
44
|
+
1,
|
|
45
|
+
),
|
|
46
|
+
outboxRetentionMs: finiteCount(
|
|
47
|
+
SUBJECT,
|
|
48
|
+
'jobs.outboxRetentionMs',
|
|
49
|
+
jobs?.outboxRetentionMs ?? JOBS_OUTBOX_RETENTION_MS_DEFAULT,
|
|
50
|
+
1,
|
|
51
|
+
),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The two queue-table targets `installRetentionSweep` takes, over the boot's own executor. */
|
|
56
|
+
export function jobsRetentionTargets(
|
|
57
|
+
executor: PgExecutor,
|
|
58
|
+
config: AppConfig | undefined,
|
|
59
|
+
): { readonly jobs: PurgeTarget; readonly outbox: PurgeTarget } {
|
|
60
|
+
const windows = jobsRetentionOf(config);
|
|
61
|
+
return {
|
|
62
|
+
jobs: jobsPurgeTarget({
|
|
63
|
+
executor,
|
|
64
|
+
doneRetentionMs: windows.doneRetentionMs,
|
|
65
|
+
deadRetentionMs: windows.deadRetentionMs,
|
|
66
|
+
}),
|
|
67
|
+
outbox: outboxPurgeTarget({ executor, publishedRetentionMs: windows.outboxRetentionMs }),
|
|
68
|
+
};
|
|
69
|
+
}
|
package/src/runtime-purge.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// The retention sweep this boot owns: the framework tables that grow with traffic, the `purge()`
|
|
2
2
|
// job that empties them and the `task` that fires it hourly.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// `
|
|
7
|
-
// after the notify tables joined the boot's DDL, which is how
|
|
8
|
-
// framework table nothing swept.
|
|
4
|
+
// NINE TARGETS, `As of 2026-10-17` — `x_idempotency`, `x_rate_limit`, the `x_auth` limiter's
|
|
5
|
+
// table, `x_notify_deliveries`, `x_notify_inbox`, `x_notify_digests`, `x_job_events`, `x_outbox`
|
|
6
|
+
// (published rows) and `x_jobs` (finished rows, with their `x_job_steps`). Counted nowhere in prose but here: this header said
|
|
7
|
+
// "three" for two releases after the notify tables joined the boot's DDL, which is how
|
|
8
|
+
// `x_notify_inbox` became the one framework table nothing swept — and `x_jobs` was the next one.
|
|
9
9
|
//
|
|
10
10
|
// WHY here and not in the packages that own the tables: `postgresIdempotencyStore` (tier 3),
|
|
11
11
|
// `postgresRateLimitStore` (tier 2) and `postgresAuthLimiter` (tier 2) cannot see each other and
|
|
@@ -58,6 +58,15 @@ export interface RetentionStores {
|
|
|
58
58
|
* growing for the life of the deployment — which it did until this member existed.
|
|
59
59
|
*/
|
|
60
60
|
readonly events: EventBus;
|
|
61
|
+
/**
|
|
62
|
+
* Finished jobs — `jobsPurgeTarget` over this boot's executor, with the app's
|
|
63
|
+
* `jobs.doneRetentionMs` / `jobs.deadRetentionMs`. REQUIRED for `events`' reason: until it
|
|
64
|
+
* existed `x_jobs` and `x_job_steps` kept every job ever run, `mail.send`'s recipient and body
|
|
65
|
+
* among them.
|
|
66
|
+
*/
|
|
67
|
+
readonly jobs: PurgeTarget;
|
|
68
|
+
/** Published outbox rows — `outboxPurgeTarget` at `jobs.outboxRetentionMs`. REQUIRED, as `jobs`. */
|
|
69
|
+
readonly outbox: PurgeTarget;
|
|
61
70
|
/**
|
|
62
71
|
* The app's own answer to "how long is an unread message kept", read off the config
|
|
63
72
|
* `startServices` loads (`inboxRetentionOf`). Absent windows mean the inbox is never swept,
|
|
@@ -143,6 +152,11 @@ function retentionTargets(stores: RetentionStores): readonly PurgeTarget[] {
|
|
|
143
152
|
...notifyTargets(stores.inboxRetention ?? NO_INBOX_RETENTION),
|
|
144
153
|
// Its own clock, and so no `nowMs`: every instant in that table is the database's.
|
|
145
154
|
eventsPurgeTarget(stores.events),
|
|
155
|
+
// Last: the two widest tables, batched, so every small sweep above has run before they spend
|
|
156
|
+
// the pass. Either order is safe — the outbox sweep takes only PUBLISHED rows, and the `x_jobs`
|
|
157
|
+
// sweep vetoes only a job whose outbox row is still unpublished.
|
|
158
|
+
stores.outbox,
|
|
159
|
+
stores.jobs,
|
|
146
160
|
];
|
|
147
161
|
}
|
|
148
162
|
|
package/src/runtime-queue.ts
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
setJobDriver,
|
|
36
36
|
setJobsFacade,
|
|
37
37
|
} from '@ultimat3/jobs';
|
|
38
|
-
import { applyLockedSchema, verifySchema } from './framework-schema-apply';
|
|
38
|
+
import { applyLockedSchema, ensureSchema, verifySchema } from './framework-schema-apply';
|
|
39
39
|
import { appExtensions } from './migration-extensions';
|
|
40
40
|
import type { DevServices } from './runtime-bindings';
|
|
41
41
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
@@ -124,15 +124,20 @@ export function startDb(services: DevServices, env: ReplicaEnv): StartedDb {
|
|
|
124
124
|
* schema — the same reason `SQL_JOBS_TABLE` is applied by the boot.
|
|
125
125
|
*/
|
|
126
126
|
/**
|
|
127
|
-
* `'apply'` — `ROLE=migrate`, `x dev` and
|
|
128
|
-
* lock with a bounded `lock_timeout` and stamps the build. `'verify'` — a serving role on
|
|
129
|
-
* external database — runs none: an `alter table` queued behind one open transaction stalled
|
|
130
|
-
* enqueue in the fleet on each pod start, and racing `create table`s were a first-deploy
|
|
127
|
+
* `'apply'` — `ROLE=migrate`, `x db migrate`, `x dev` and `x mcp serve` — runs the DDL under the
|
|
128
|
+
* migration lock with a bounded `lock_timeout` and stamps the build. `'verify'` — a serving role on
|
|
129
|
+
* an external database — runs none: an `alter table` queued behind one open transaction stalled
|
|
130
|
+
* every enqueue in the fleet on each pod start, and racing `create table`s were a first-deploy
|
|
131
|
+
* `23505`. `'ensure'` — every other CLI command, `x db backfill` and `x jobs` among them — runs
|
|
132
|
+
* none when this build's stamp is present and applies otherwise: those commands run as the
|
|
133
|
+
* runtime role in production, which owns no framework table and so cannot re-run even
|
|
134
|
+
* `if not exists` DDL (`framework-schema-apply.ts`).
|
|
131
135
|
*/
|
|
132
|
-
export type SchemaMode = 'apply' | 'verify';
|
|
136
|
+
export type SchemaMode = 'apply' | 'ensure' | 'verify';
|
|
133
137
|
|
|
134
138
|
async function applySchema(client: DevDbClient, mode: SchemaMode): Promise<void> {
|
|
135
139
|
if (mode === 'verify') await verifySchema(client);
|
|
140
|
+
else if (mode === 'ensure') await ensureSchema(client);
|
|
136
141
|
else await applyLockedSchema(client);
|
|
137
142
|
}
|
|
138
143
|
/**
|
|
@@ -248,7 +253,11 @@ export async function startQueue(
|
|
|
248
253
|
* this file reads it: see `startDb` for what two readers of one question cost.
|
|
249
254
|
*/
|
|
250
255
|
env: ReplicaEnv = process.env,
|
|
251
|
-
|
|
256
|
+
/**
|
|
257
|
+
* `'ensure'` unless the caller IS the step that applies: a command that only reads or enqueues
|
|
258
|
+
* must not need the privileges of the migrate role (`SchemaMode`).
|
|
259
|
+
*/
|
|
260
|
+
schema: SchemaMode = 'ensure',
|
|
252
261
|
): Promise<RunningQueue> {
|
|
253
262
|
const { client: db, replica } = startDb(services, env);
|
|
254
263
|
try {
|
package/src/runtime-services.ts
CHANGED
|
@@ -37,6 +37,7 @@ import type { DevServices, Env } from './runtime-bindings';
|
|
|
37
37
|
import { selectBus } from './runtime-bus';
|
|
38
38
|
import { cacheTiersOf, startCacheTiers } from './runtime-cache';
|
|
39
39
|
import { type WorkerConfig, workerConfigOf } from './runtime-jobs';
|
|
40
|
+
import { jobsRetentionTargets } from './runtime-jobs-retention';
|
|
40
41
|
import { selectAppMailDriver } from './runtime-mail';
|
|
41
42
|
import { inboxRetentionOf } from './runtime-notify-retention';
|
|
42
43
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
@@ -403,6 +404,7 @@ export async function startServices(
|
|
|
403
404
|
idempotency: queue.idempotency,
|
|
404
405
|
rateLimit: rateLimitStore,
|
|
405
406
|
events,
|
|
407
|
+
...jobsRetentionTargets(executor, config),
|
|
406
408
|
inboxRetention: inboxRetentionOf(config),
|
|
407
409
|
}),
|
|
408
410
|
);
|
package/src/schema-errors.ts
CHANGED
|
@@ -18,10 +18,13 @@ export class FrameworkSchemaFailedError extends UltimateError {
|
|
|
18
18
|
super({
|
|
19
19
|
code: 'X_FRAMEWORK_SCHEMA_FAILED',
|
|
20
20
|
cause: `${input.pkg} could not create ${input.tables.join(', ')}: ${renderThrowable(input.cause)}`,
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
21
|
+
// The migrate step, as the role that owns the schema. The common cause is `42501`: a runtime
|
|
22
|
+
// role with DML only — no `create` on `public`, and not the owner of an existing framework
|
|
23
|
+
// table, which Postgres demands even of `create index if not exists`. Granting that role
|
|
24
|
+
// `create` (this line's old advice) cures the first and never the second, and widens the
|
|
25
|
+
// runtime role besides. The rarer cause, a relation of that name with another shape, is
|
|
26
|
+
// named in `cause` and reported again by the same command.
|
|
27
|
+
fix: 'x db migrate # with DATABASE_URL naming the role that owns schema public, as ROLE=migrate runs',
|
|
25
28
|
meta: { pkg: input.pkg, tables: [...input.tables] },
|
|
26
29
|
});
|
|
27
30
|
}
|
package/src/serve.ts
CHANGED
|
@@ -48,10 +48,13 @@ export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve-t
|
|
|
48
48
|
* is what fails on it.
|
|
49
49
|
*/
|
|
50
50
|
export async function runMigrations(options: ServeOptions): Promise<MigratedApp> {
|
|
51
|
+
// `'apply'`, never the stamp-first default: this IS the step that applies, and an operator
|
|
52
|
+
// re-running it is how a dropped framework table comes back.
|
|
51
53
|
const queue = await startQueue(
|
|
52
54
|
resolveServices(options.root, options.env),
|
|
53
55
|
options.runtime,
|
|
54
56
|
options.env,
|
|
57
|
+
'apply',
|
|
55
58
|
);
|
|
56
59
|
try {
|
|
57
60
|
const migrations = await readMigrations(options.root);
|
package/src/verify-deadline.ts
CHANGED
|
@@ -87,12 +87,28 @@ export interface InFlightRun {
|
|
|
87
87
|
readonly command: readonly string[];
|
|
88
88
|
/** Everything it had printed before the kill; absent when it did not settle after it. */
|
|
89
89
|
readonly output?: string;
|
|
90
|
+
/** How long it had been running when the deadline passed. */
|
|
91
|
+
readonly runningMs?: number;
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The runs a step had already seen settle when its deadline passed. A batched step (a coverage
|
|
96
|
+
* run's slices) that finished ninety of them did not hang — it outgrew its deadline — and a
|
|
97
|
+
* finding that cannot tell the two apart sends its reader hunting a stuck file that is not there.
|
|
98
|
+
* A `type`: it rides in `Finding.meta`, which an interface is not assignable to.
|
|
99
|
+
*/
|
|
100
|
+
export type StepProgress = {
|
|
101
|
+
readonly runs: number;
|
|
102
|
+
/** The longest any of them took, as its runner reported it. */
|
|
103
|
+
readonly slowestMs: number;
|
|
104
|
+
};
|
|
105
|
+
|
|
92
106
|
/** What a step was doing when its deadline passed — `verify-stalled.ts` turns it into the finding. */
|
|
93
107
|
export interface StepExpiry {
|
|
94
108
|
readonly killed: readonly KilledProcess[];
|
|
95
109
|
readonly inFlight: readonly InFlightRun[];
|
|
110
|
+
/** Absent is none: a step that never started a child, or a fixture that does not say. */
|
|
111
|
+
readonly finished?: StepProgress;
|
|
96
112
|
}
|
|
97
113
|
|
|
98
114
|
/** A killed child's pipes close with it; this is only the bound on a runner that is not `exec`. */
|
|
@@ -138,7 +154,10 @@ export function guardStep(
|
|
|
138
154
|
): StepGuard {
|
|
139
155
|
let expired = false;
|
|
140
156
|
// Keyed by the promise itself: two batches of one step may run the very same argv.
|
|
141
|
-
const inFlight = new Map<Promise<ExecResult>,
|
|
157
|
+
const inFlight = new Map<Promise<ExecResult>, Waiting>();
|
|
158
|
+
// Settled runs, counted only before the deadline: a kill settles the rest, and those are not
|
|
159
|
+
// progress.
|
|
160
|
+
let finished = { runs: 0, slowestMs: 0 };
|
|
142
161
|
// Every child's pid, recorded the moment it exists and dropped when its run settles (a settled
|
|
143
162
|
// pid may be reused). Windows reads no process's environment, so this is the step's only handle.
|
|
144
163
|
const spawned = new Map<number, readonly string[]>();
|
|
@@ -169,9 +188,16 @@ export function guardStep(
|
|
|
169
188
|
options.onSpawn?.(spawnedPid);
|
|
170
189
|
},
|
|
171
190
|
});
|
|
172
|
-
inFlight.set(run, command);
|
|
191
|
+
inFlight.set(run, { command, startedAt: performance.now() });
|
|
173
192
|
try {
|
|
174
|
-
|
|
193
|
+
const result = await run;
|
|
194
|
+
if (!expired) {
|
|
195
|
+
finished = {
|
|
196
|
+
runs: finished.runs + 1,
|
|
197
|
+
slowestMs: Math.max(finished.slowestMs, result.durationMs),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
175
201
|
} finally {
|
|
176
202
|
inFlight.delete(run);
|
|
177
203
|
if (pid !== undefined) spawned.delete(pid);
|
|
@@ -179,11 +205,16 @@ export function guardStep(
|
|
|
179
205
|
},
|
|
180
206
|
async expire(): Promise<StepExpiry> {
|
|
181
207
|
expired = true;
|
|
208
|
+
const progress = finished.runs === 0 ? {} : { finished };
|
|
182
209
|
// Before the kill: a killed child settles its runner call, which takes it off the map.
|
|
183
|
-
const
|
|
210
|
+
const at = performance.now();
|
|
211
|
+
const waiting = [...inFlight].map(
|
|
212
|
+
([run, { command, startedAt }]) =>
|
|
213
|
+
[run, command, Math.round(at - startedAt)] as const satisfies WaitingRun,
|
|
214
|
+
);
|
|
184
215
|
if (windows) {
|
|
185
216
|
const killed = await killTrees(spawned, platform.killTree ?? taskkillTree);
|
|
186
|
-
return { killed, inFlight: await settleAll(waiting) };
|
|
217
|
+
return { killed, inFlight: await settleAll(waiting), ...progress };
|
|
187
218
|
}
|
|
188
219
|
const workers = await killTagged(tag, undefined, isTestWorker);
|
|
189
220
|
if (workers.length > 0) {
|
|
@@ -193,19 +224,27 @@ export function guardStep(
|
|
|
193
224
|
const killed = [...workers, ...rest.filter((one) => workers.every((w) => w.pid !== one.pid))];
|
|
194
225
|
// After it: the kill closed the child's pipes, so its call now resolves with everything it
|
|
195
226
|
// had printed — for `bun test`, the crash line of each file a worker was holding.
|
|
196
|
-
return { killed, inFlight: await settleAll(waiting) };
|
|
227
|
+
return { killed, inFlight: await settleAll(waiting), ...progress };
|
|
197
228
|
},
|
|
198
229
|
};
|
|
199
230
|
}
|
|
200
231
|
|
|
232
|
+
interface Waiting {
|
|
233
|
+
readonly command: readonly string[];
|
|
234
|
+
readonly startedAt: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** A run the deadline found in flight: its call, its argv, and how long it had been running. */
|
|
238
|
+
type WaitingRun = readonly [Promise<ExecResult>, readonly string[], number];
|
|
239
|
+
|
|
201
240
|
/** What each child had printed, once the kill closed its pipes — or just its argv past `SETTLE_MS`. */
|
|
202
|
-
function settleAll(
|
|
203
|
-
waiting: readonly (readonly [Promise<ExecResult>, readonly string[]])[],
|
|
204
|
-
): Promise<readonly InFlightRun[]> {
|
|
241
|
+
function settleAll(waiting: readonly WaitingRun[]): Promise<readonly InFlightRun[]> {
|
|
205
242
|
return Promise.all(
|
|
206
|
-
waiting.map(async ([run, command]): Promise<InFlightRun> => {
|
|
243
|
+
waiting.map(async ([run, command, runningMs]): Promise<InFlightRun> => {
|
|
207
244
|
const raced = await raceDeadline(run.then(execOutput), SETTLE_MS).catch(() => undefined);
|
|
208
|
-
return raced === undefined || raced.timedOut
|
|
245
|
+
return raced === undefined || raced.timedOut
|
|
246
|
+
? { command, runningMs }
|
|
247
|
+
: { command, output: raced.value, runningMs };
|
|
209
248
|
}),
|
|
210
249
|
);
|
|
211
250
|
}
|
package/src/verify-stalled.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
6
6
|
import type { Finding } from './output';
|
|
7
7
|
import { quoteArg } from './shell-quote';
|
|
8
|
-
import type { InFlightRun, StepExpiry } from './verify-deadline';
|
|
8
|
+
import type { InFlightRun, StepExpiry, StepProgress } from './verify-deadline';
|
|
9
9
|
import { STEP_TIMEOUT_FIELD } from './verify-deadline';
|
|
10
10
|
import type { VerifyStepName } from './verify-step';
|
|
11
11
|
|
|
@@ -16,12 +16,14 @@ import type { VerifyStepName } from './verify-step';
|
|
|
16
16
|
export type StalledRun = {
|
|
17
17
|
/** The argv without its file list: `bun test --parallel=3`. */
|
|
18
18
|
readonly command: string;
|
|
19
|
-
/** Files the run was handed; 0 for a serial run
|
|
19
|
+
/** Files the run was handed by name; 0 for a serial run that selects by filter. */
|
|
20
20
|
readonly files: number;
|
|
21
21
|
/** `--parallel`'s width, and 1 for a serial run. */
|
|
22
22
|
readonly workers: number;
|
|
23
23
|
/** The files that were still running, as bun named them. Empty when it named none. */
|
|
24
24
|
readonly stuck: readonly string[];
|
|
25
|
+
/** How long it had been running when it was stopped. */
|
|
26
|
+
readonly runningMs?: number;
|
|
25
27
|
};
|
|
26
28
|
|
|
27
29
|
/** `X_VERIFY_STEP_TIMEOUT`'s `meta`. A `type`: an interface is not assignable to `Finding.meta`. */
|
|
@@ -30,6 +32,8 @@ export type StepTimeoutMeta = {
|
|
|
30
32
|
readonly deadlineMs: number;
|
|
31
33
|
readonly killed: readonly { readonly pid: number; readonly command: string }[];
|
|
32
34
|
readonly inFlight: readonly StalledRun[];
|
|
35
|
+
/** The runs that had finished before the deadline — the step's progress. */
|
|
36
|
+
readonly finished: StepProgress;
|
|
33
37
|
};
|
|
34
38
|
|
|
35
39
|
/**
|
|
@@ -59,15 +63,18 @@ export function stalledRun(run: InFlightRun): StalledRun | undefined {
|
|
|
59
63
|
const width = rest.find((arg) => arg.startsWith(PARALLEL));
|
|
60
64
|
const flags = rest.filter((arg) => arg.startsWith('--') && !arg.startsWith('--timings'));
|
|
61
65
|
const command = ['bun', 'test', ...flags].join(' ');
|
|
66
|
+
const age = run.runningMs === undefined ? {} : { runningMs: run.runningMs };
|
|
62
67
|
if (width === undefined) {
|
|
63
68
|
const last = lines.findLastIndex((line) => FILE_LINE.test(line));
|
|
64
69
|
const started = last < 0 ? undefined : FILE_LINE.exec(lines[last] ?? '')?.[1];
|
|
65
70
|
const failed = lines.slice(last + 1).some((line) => line.startsWith('(fail)'));
|
|
66
71
|
return {
|
|
67
72
|
command,
|
|
68
|
-
files
|
|
73
|
+
// A coverage slice names its files; a serial suite selects by a filter that is not a path.
|
|
74
|
+
files: rest.filter((arg) => TEST_FILE.test(arg)).length,
|
|
69
75
|
workers: 1,
|
|
70
76
|
stuck: started === undefined || failed ? [] : [started],
|
|
77
|
+
...age,
|
|
71
78
|
};
|
|
72
79
|
}
|
|
73
80
|
const files = rest.filter((arg) => !arg.startsWith('--'));
|
|
@@ -81,9 +88,32 @@ export function stalledRun(run: InFlightRun): StalledRun | undefined {
|
|
|
81
88
|
files: files.length,
|
|
82
89
|
workers: Math.max(1, Number(width.slice(PARALLEL.length)) || 1),
|
|
83
90
|
stuck: [...new Set(crashed)],
|
|
91
|
+
...age,
|
|
84
92
|
};
|
|
85
93
|
}
|
|
86
94
|
|
|
95
|
+
const TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A batched step that had finished runs, none of those still in flight older than twice the
|
|
99
|
+
* slowest that finished, was making progress when its deadline passed: it outgrew the deadline
|
|
100
|
+
* at this width, and no file is stuck. Anything older than that is read as the hang it may be.
|
|
101
|
+
*/
|
|
102
|
+
const PROGRESS_SLACK = 2;
|
|
103
|
+
|
|
104
|
+
export function outgrewDeadline(runs: readonly StalledRun[], expiry: StepExpiry): boolean {
|
|
105
|
+
const finished = expiry.finished;
|
|
106
|
+
if (finished === undefined || finished.runs === 0) return false;
|
|
107
|
+
if (runs.some((run) => run.stuck.length > 0)) return false;
|
|
108
|
+
const bound = finished.slowestMs * PROGRESS_SLACK;
|
|
109
|
+
return expiry.inFlight.every((run) => run.runningMs !== undefined && run.runningMs <= bound);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const seconds = (ms: number): string => String(Math.round(ms / 1000));
|
|
113
|
+
|
|
114
|
+
const oldest = (expiry: StepExpiry): number =>
|
|
115
|
+
Math.max(0, ...expiry.inFlight.map((run) => run.runningMs ?? 0));
|
|
116
|
+
|
|
87
117
|
/** How many paths a `cause:` spells out — the rest are in `meta`. */
|
|
88
118
|
const NAMED_IN_CAUSE = 5;
|
|
89
119
|
|
|
@@ -99,6 +129,10 @@ export function stalledOutput(expiry: StepExpiry): string | undefined {
|
|
|
99
129
|
}
|
|
100
130
|
|
|
101
131
|
const inFlightClause = (runs: readonly StalledRun[], expiry: StepExpiry): string => {
|
|
132
|
+
if (outgrewDeadline(runs, expiry)) {
|
|
133
|
+
const finished = expiry.finished as StepProgress;
|
|
134
|
+
return `; it was not hung: ${String(finished.runs)} run(s) it started had finished (the slowest in ${seconds(finished.slowestMs)} s) and the longest still running had been going ${seconds(oldest(expiry))} s — the step needs longer than its deadline at this width`;
|
|
135
|
+
}
|
|
102
136
|
const files = runs.flatMap((run) => run.stuck);
|
|
103
137
|
const named = runs.find((run) => run.stuck.length > 0);
|
|
104
138
|
if (named !== undefined) {
|
|
@@ -110,7 +144,7 @@ const inFlightClause = (runs: readonly StalledRun[], expiry: StepExpiry): string
|
|
|
110
144
|
if (waiting === undefined) return '';
|
|
111
145
|
return runs[0] === undefined
|
|
112
146
|
? `; it was waiting on: ${waiting.command.slice(0, 4).join(' ')}`
|
|
113
|
-
: `; its running "${runs[0].command}" named no file still in flight, so the run itself did not exit`;
|
|
147
|
+
: `; its running "${runs[0].command}" named no file still in flight, so the run itself did not exit${waiting.runningMs === undefined ? '' : ` after ${seconds(waiting.runningMs)} s`}`;
|
|
114
148
|
};
|
|
115
149
|
|
|
116
150
|
/**
|
|
@@ -137,12 +171,15 @@ export const stepTimeoutFinding = (
|
|
|
137
171
|
deadlineMs: ms,
|
|
138
172
|
killed: expiry.killed.map((one) => ({ pid: one.pid, command: one.command })),
|
|
139
173
|
inFlight: runs,
|
|
174
|
+
finished: expiry.finished ?? { runs: 0, slowestMs: 0 },
|
|
140
175
|
};
|
|
176
|
+
const wider = `${command} --only ${step} --workers <n> --json`;
|
|
141
177
|
return {
|
|
142
178
|
code: 'X_VERIFY_STEP_TIMEOUT',
|
|
143
179
|
cause: `step "${step}" did not finish within its ${String(ms)} ms deadline, so it was stopped and ${String(expiry.killed.length)} process(es) it had started were killed${inFlightClause(runs, expiry)}`,
|
|
144
|
-
fix:
|
|
145
|
-
|
|
180
|
+
fix: outgrewDeadline(runs, expiry)
|
|
181
|
+
? `${wider} # it outgrew its deadline at this width, so run it wider; or raise the deadline in x.verify.json — add "${STEP_TIMEOUT_FIELD}": { "${step}": <milliseconds> } above the ${String(ms)} it was given`
|
|
182
|
+
: stuck.length > 0
|
|
146
183
|
? `${alone} # what had not finished, alone; the whole step: ${rerun}; ${longer}`
|
|
147
184
|
: `${rerun} # reproduce it alone; ${longer}`,
|
|
148
185
|
docs: ERROR_DOCS_URL,
|