@cosmicdrift/kumiko-server-runtime 0.365.0 → 0.366.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/boot/wait-for-database.d.ts +13 -0
- package/dist/boot/wait-for-database.js +64 -0
- package/dist/boot/worker-boot-core.js +15 -1
- package/dist/extra-routes-deps.d.ts +3 -0
- package/dist/run-prod-app.d.ts +1 -1
- package/dist/run-prod-app.js +8 -1
- package/dist/run-worker-app.d.ts +1 -1
- package/dist/run-worker-app.js +2 -0
- package/package.json +7 -7
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
export declare function isTransientConnectionError(error: unknown): boolean;
|
|
3
|
+
export declare const WAIT_FOR_DATABASE_INITIAL_BACKOFF_MS = 1000;
|
|
4
|
+
export declare const WAIT_FOR_DATABASE_MAX_BACKOFF_MS = 15000;
|
|
5
|
+
export declare const WAIT_FOR_DATABASE_DEADLINE_MS = 90000;
|
|
6
|
+
export type WaitForDatabaseOptions = {
|
|
7
|
+
readonly processName: string;
|
|
8
|
+
readonly log?: (line: string) => void;
|
|
9
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
10
|
+
readonly now?: () => number;
|
|
11
|
+
};
|
|
12
|
+
export declare function waitForDatabase(db: DbConnection, options: WaitForDatabaseOptions): Promise<void>;
|
|
13
|
+
export declare function waitUntilDatabaseReachable<T>(ping: () => Promise<T>, options: WaitForDatabaseOptions): Promise<T>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { extractPgError, tableExists } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
const TRANSIENT_CODES = new Set([
|
|
3
|
+
"53300", // too_many_connections
|
|
4
|
+
"57P03", // cannot_connect_now (startup / recovery)
|
|
5
|
+
"ECONNREFUSED",
|
|
6
|
+
"ECONNRESET",
|
|
7
|
+
"ETIMEDOUT",
|
|
8
|
+
"ENOTFOUND",
|
|
9
|
+
"EAI_AGAIN",
|
|
10
|
+
"CONNECTION_CLOSED",
|
|
11
|
+
"CONNECT_TIMEOUT",
|
|
12
|
+
"CONNECTION_DESTROYED",
|
|
13
|
+
"ERR_POSTGRES_CONNECTION_REFUSED", // Bun.SQL
|
|
14
|
+
"ERR_POSTGRES_CONNECTION_CLOSED", // Bun.SQL
|
|
15
|
+
"ERR_POSTGRES_CONNECTION_TIMEOUT", // Bun.SQL
|
|
16
|
+
"ERR_POSTGRES_CONNECTION_FAILED", // Bun.SQL
|
|
17
|
+
]);
|
|
18
|
+
const CONNECTION_EXCEPTION_CLASS = "08";
|
|
19
|
+
export function isTransientConnectionError(error) {
|
|
20
|
+
const code = extractPgError(error)?.code;
|
|
21
|
+
if (code === undefined)
|
|
22
|
+
return false;
|
|
23
|
+
return TRANSIENT_CODES.has(code) || code.startsWith(CONNECTION_EXCEPTION_CLASS);
|
|
24
|
+
}
|
|
25
|
+
export const WAIT_FOR_DATABASE_INITIAL_BACKOFF_MS = 1_000;
|
|
26
|
+
export const WAIT_FOR_DATABASE_MAX_BACKOFF_MS = 15_000;
|
|
27
|
+
// ponytail: the kubelet liveness probe restarts a pod without an open port after ~105s anyway,
|
|
28
|
+
// so waiting longer buys nothing. Upgrade: make this configurable via env once a deployment
|
|
29
|
+
// needs a longer outage window (e.g. a managed-DB failover that takes minutes).
|
|
30
|
+
export const WAIT_FOR_DATABASE_DEADLINE_MS = 90_000;
|
|
31
|
+
function logToConsole(line) {
|
|
32
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
33
|
+
console.log(line);
|
|
34
|
+
}
|
|
35
|
+
function sleepReal(ms) {
|
|
36
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
|
+
}
|
|
38
|
+
// Postgres can be briefly unreachable at boot (connection slots exhausted, failover, restart).
|
|
39
|
+
// Crashing makes the orchestrator restart-loop and adds connection churn; wait and retry instead.
|
|
40
|
+
export async function waitForDatabase(db, options) {
|
|
41
|
+
await waitUntilDatabaseReachable(() => tableExists(db, "pg_catalog.pg_class"), options);
|
|
42
|
+
}
|
|
43
|
+
export async function waitUntilDatabaseReachable(ping, options) {
|
|
44
|
+
const { processName, log = logToConsole, sleep = sleepReal, now = Date.now } = options;
|
|
45
|
+
const deadline = now() + WAIT_FOR_DATABASE_DEADLINE_MS;
|
|
46
|
+
let backoffMs = WAIT_FOR_DATABASE_INITIAL_BACKOFF_MS;
|
|
47
|
+
for (let attempt = 1;; attempt++) {
|
|
48
|
+
try {
|
|
49
|
+
return await ping();
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (!isTransientConnectionError(error))
|
|
53
|
+
throw error;
|
|
54
|
+
const remainingMs = deadline - now();
|
|
55
|
+
if (remainingMs <= 0)
|
|
56
|
+
throw error;
|
|
57
|
+
const waitMs = Math.min(backoffMs, remainingMs);
|
|
58
|
+
const code = extractPgError(error)?.code ?? "unknown";
|
|
59
|
+
log(`[${processName}] database not reachable (code ${code}, attempt ${attempt}), retrying in ${waitMs}ms…`);
|
|
60
|
+
await sleep(waitMs);
|
|
61
|
+
backoffMs = Math.min(backoffMs * 2, WAIT_FOR_DATABASE_MAX_BACKOFF_MS);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -20,6 +20,7 @@ import { requireEnv } from "../run-prod-app.js";
|
|
|
20
20
|
import { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext, } from "../run-prod-app-boot-context.js";
|
|
21
21
|
import { resolveBootCrypto } from "./boot-crypto.js";
|
|
22
22
|
import { jobRunLoggerCallbacks } from "./job-run-logger.js";
|
|
23
|
+
import { waitForDatabase, waitUntilDatabaseReachable } from "./wait-for-database.js";
|
|
23
24
|
import { assertWorkerMetricsOptions } from "./worker-metrics-server.js";
|
|
24
25
|
function isSecretsContext(value) {
|
|
25
26
|
if (typeof value !== "object" || value === null)
|
|
@@ -96,7 +97,10 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
96
97
|
}
|
|
97
98
|
// Before the connections, so an abort leaks nothing.
|
|
98
99
|
if (options.kms) {
|
|
99
|
-
const
|
|
100
|
+
const kms = options.kms;
|
|
101
|
+
const kmsHealth = await waitUntilDatabaseReachable(() => kms.health(), {
|
|
102
|
+
processName: processName,
|
|
103
|
+
});
|
|
100
104
|
if (!kmsHealth.ok) {
|
|
101
105
|
throw new Error(`[${processName}] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`);
|
|
102
106
|
}
|
|
@@ -108,6 +112,16 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
108
112
|
redisUrl,
|
|
109
113
|
clientOptions: redisClientOptionsFromEnv(envSource),
|
|
110
114
|
});
|
|
115
|
+
try {
|
|
116
|
+
await waitForDatabase(db, { processName });
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
await closeDb();
|
|
120
|
+
await closeOutsideTransactionDb();
|
|
121
|
+
redis.disconnect();
|
|
122
|
+
await cacheSync.close();
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
111
125
|
const resolvedEffectiveFeatures = options.effectiveFeatures ??
|
|
112
126
|
(await buildTierEffectiveFeatures(features, db, registry, cacheSync));
|
|
113
127
|
if (options.migrations !== false) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import type { Registry, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import type { MetricsHandle } from "@cosmicdrift/kumiko-framework/observability";
|
|
3
4
|
import type { Redis } from "ioredis";
|
|
4
5
|
export type { SystemDispatchArgs } from "@cosmicdrift/kumiko-framework/api";
|
|
5
6
|
export { makeDispatchSystemQuery, makeDispatchSystemWrite, } from "@cosmicdrift/kumiko-framework/api";
|
|
@@ -15,6 +16,8 @@ export type SystemWireDeps = {
|
|
|
15
16
|
/** Feature-registry — e.g. for plugin-lookups via
|
|
16
17
|
* `registry.getExtensionUsages("subscriptionProvider")`. */
|
|
17
18
|
readonly registry: Registry;
|
|
19
|
+
/** Same contract as `ctx.metricsFor`: unregistered metric names no-op. */
|
|
20
|
+
readonly metricsFor: (featureName: string) => MetricsHandle;
|
|
18
21
|
/** Writes through the /api/*-command-dispatcher (same idempotency/job
|
|
19
22
|
* hooks) as an auto-constructed SystemAdmin of the target tenant,
|
|
20
23
|
* WITHOUT the route's own access-check. Privilege-scope: SystemAdmin is
|
package/dist/run-prod-app.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { type AllInOneEntrypoint, type ApiEntrypoint } from "@cosmicdrift/kumiko
|
|
|
8
8
|
import { type ComposedEnvSchema, KumikoBootError } from "@cosmicdrift/kumiko-framework/env";
|
|
9
9
|
import type { ErrorDocsConfig } from "@cosmicdrift/kumiko-framework/errors";
|
|
10
10
|
import type { JobRunnerOptions } from "@cosmicdrift/kumiko-framework/jobs";
|
|
11
|
-
import type
|
|
11
|
+
import { type ObservabilityOptions, type ObservabilityProvider } from "@cosmicdrift/kumiko-framework/observability";
|
|
12
12
|
import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
13
13
|
import type { PageHeadMeta, PageHeadResolver, PageHeadSystemQuery } from "@cosmicdrift/kumiko-headless/apex";
|
|
14
14
|
import { Redis } from "ioredis";
|
package/dist/run-prod-app.js
CHANGED
|
@@ -58,6 +58,7 @@ import { renderDryRun } from "@cosmicdrift/kumiko-framework/env/dry-run";
|
|
|
58
58
|
import { createEsOperationsTable, createSeedMigrationContext, runPendingSeedMigrations, } from "@cosmicdrift/kumiko-framework/es-ops";
|
|
59
59
|
import { assertSeedModeNotInProduction } from "@cosmicdrift/kumiko-framework/event-store";
|
|
60
60
|
import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
|
|
61
|
+
import { createMetricsForFactory, } from "@cosmicdrift/kumiko-framework/observability";
|
|
61
62
|
import { createDispatcher, createEntityCache, createEventDedup, createIdempotencyGuard, flushEscapeHatchAudits, } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
62
63
|
import { createRedisCacheSyncBus, redisClientOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/redis";
|
|
63
64
|
import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
@@ -67,6 +68,7 @@ import { resolveBootCrypto } from "./boot/boot-crypto.js";
|
|
|
67
68
|
import { jobRunLoggerCallbacks } from "./boot/job-run-logger.js";
|
|
68
69
|
import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
|
|
69
70
|
import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
|
|
71
|
+
import { waitForDatabase, waitUntilDatabaseReachable } from "./boot/wait-for-database.js";
|
|
70
72
|
import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-serve-options.js";
|
|
71
73
|
import { buildComposeAuthOptions, composeFeatures } from "./compose-features.js";
|
|
72
74
|
import { makeDispatchSystemWrite } from "./extra-routes-deps.js";
|
|
@@ -336,7 +338,10 @@ export async function runProdApp(options) {
|
|
|
336
338
|
// against an unreachable key store — a silent start would answer every
|
|
337
339
|
// PII read with 503. Runs BEFORE connections so an abort leaks nothing.
|
|
338
340
|
if (options.kms) {
|
|
339
|
-
const
|
|
341
|
+
const kms = options.kms;
|
|
342
|
+
const kmsHealth = await waitUntilDatabaseReachable(() => kms.health(), {
|
|
343
|
+
processName: "runProdApp",
|
|
344
|
+
});
|
|
340
345
|
if (!kmsHealth.ok) {
|
|
341
346
|
throw new Error(`[runProdApp] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`);
|
|
342
347
|
}
|
|
@@ -344,6 +349,7 @@ export async function runProdApp(options) {
|
|
|
344
349
|
const { db, close: closeDb } = createDbConnection(databaseUrl, dbConnectionOptionsFromEnv(envSource));
|
|
345
350
|
const { db: outsideTransactionDb, close: closeOutsideTransactionDb } = createDbConnection(databaseUrl, outsideTransactionPoolOptionsFromEnv(envSource));
|
|
346
351
|
const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
|
|
352
|
+
await waitForDatabase(db, { processName: "runProdApp" });
|
|
347
353
|
// Sprint-8a Tier-Composition auto-wire: scan features for a
|
|
348
354
|
// tenantTierResolver-extension. If found AND user didn't supply own
|
|
349
355
|
// effectiveFeatures, build the resolver here (db + registry are
|
|
@@ -680,6 +686,7 @@ export async function runProdApp(options) {
|
|
|
680
686
|
db,
|
|
681
687
|
redis,
|
|
682
688
|
registry,
|
|
689
|
+
metricsFor: createMetricsForFactory(entrypoint.observability.meter),
|
|
683
690
|
dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
|
|
684
691
|
});
|
|
685
692
|
}
|
package/dist/run-worker-app.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
|
4
4
|
import type { EffectiveFeaturesResolver, FeatureDefinition, Registry, ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import type { WorkerEntrypoint } from "@cosmicdrift/kumiko-framework/entrypoint";
|
|
6
6
|
import type { JobRunnerOptions } from "@cosmicdrift/kumiko-framework/jobs";
|
|
7
|
-
import type
|
|
7
|
+
import { type ObservabilityOptions, type ObservabilityProvider } from "@cosmicdrift/kumiko-framework/observability";
|
|
8
8
|
import type { MasterKeyProvider, SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
9
9
|
import type { Redis } from "ioredis";
|
|
10
10
|
import { type WorkerMetricsOptions, type WorkerMetricsServer } from "./boot/worker-metrics-server.js";
|
package/dist/run-worker-app.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// Container/Coolify sets the same env vars as runProdApp:
|
|
16
16
|
// DATABASE_URL, REDIS_URL, JWT_SECRET — PORT is not needed.
|
|
17
17
|
import { assertSeedModeNotInProduction } from "@cosmicdrift/kumiko-framework/event-store";
|
|
18
|
+
import { createMetricsForFactory, } from "@cosmicdrift/kumiko-framework/observability";
|
|
18
19
|
import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
|
|
19
20
|
import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
|
|
20
21
|
import { bootWorkerProcess, resolveWorkerEnvSource } from "./boot/worker-boot-core.js";
|
|
@@ -70,6 +71,7 @@ export async function runWorkerApp(options) {
|
|
|
70
71
|
db,
|
|
71
72
|
redis,
|
|
72
73
|
registry,
|
|
74
|
+
metricsFor: createMetricsForFactory(entrypoint.observability.meter),
|
|
73
75
|
dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
|
|
74
76
|
lifecycle: entrypoint.lifecycle,
|
|
75
77
|
...(boot.secrets && { secrets: boot.secrets }),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-server-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.366.0",
|
|
4
4
|
"description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -84,12 +84,12 @@
|
|
|
84
84
|
}
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
88
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
89
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
90
|
-
"@cosmicdrift/kumiko-renderer-mail-html": "0.
|
|
91
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
92
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
87
|
+
"@cosmicdrift/kumiko-bundled-features": "0.366.0",
|
|
88
|
+
"@cosmicdrift/kumiko-framework": "0.366.0",
|
|
89
|
+
"@cosmicdrift/kumiko-headless": "0.366.0",
|
|
90
|
+
"@cosmicdrift/kumiko-renderer-mail-html": "0.366.0",
|
|
91
|
+
"@cosmicdrift/kumiko-renderer-web": "0.366.0",
|
|
92
|
+
"@cosmicdrift/kumiko-types": "0.366.0",
|
|
93
93
|
"bun-types": "^1.3.13",
|
|
94
94
|
"ioredis": "^5.10.1",
|
|
95
95
|
"temporal-polyfill": "^0.3.2"
|