diff --git a/internal/db/runtime-store.test.ts b/internal/db/runtime-store.test.ts index 429f6762..0b623cfe 100644 --- a/internal/db/runtime-store.test.ts +++ b/internal/db/runtime-store.test.ts @@ -328,6 +328,56 @@ test("configured postgres runtime consumes pg pool idle errors as degraded readi assert.equal(JSON.stringify(warnings).includes("fixture secret host"), false); }); +test("configured postgres runtime only resets pg pool once while reset is in flight", async () => { + let releaseReset; + let endCalls = 0; + const warnings = []; + const store = createConfiguredCloudRuntimeStore({ + env: { + HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres", + HWLAB_CLOUD_RUNTIME_DURABLE: "true", + HWLAB_CLOUD_DB_POOL_RESET_COOLDOWN_MS: "5000" + }, + dbUrl: "postgres://hwlab_user:fixture-pass@db.example.test:5432/hwlab?sslmode=require", + logger: { warn(entry) { warnings.push(entry); } }, + pgModuleLoader: async () => ({ + Pool: class FakePool { + constructor() { + this.totalCount = 16; + this.idleCount = 0; + this.waitingCount = 4; + } + + async query() { + return { rows: [] }; + } + + async end() { + endCalls += 1; + await new Promise((resolve) => { releaseReset = resolve; }); + } + } + }) + }); + + await store.readiness(); + const classified = { + blocker: RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED, + connection: { queryResult: "connect_timeout", errorCode: "ETIMEDOUT" } + }; + + assert.equal(store.resetPostgresPoolAfterTransientFailure(classified, { attempt: 1, maxAttempts: 5, retrying: true }), true); + const activePool = { totalCount: 16, idleCount: 0, waitingCount: 6, async end() { endCalls += 1; } }; + store.pool = activePool; + assert.equal(store.resetPostgresPoolAfterTransientFailure(classified, { attempt: 2, maxAttempts: 5, retrying: true }), false); + assert.equal(store.pool, activePool); + await Promise.resolve(); + assert.equal(endCalls, 1); + assert.equal(warnings.some((entry) => entry.event === "postgres_runtime_pool_reset_skipped" && entry.reason === "in_flight"), true); + releaseReset?.(); + await Promise.resolve(); +}); + test("configured postgres runtime classifies pg_hba no-encryption rejection as SSL before auth", async () => { const store = createConfiguredCloudRuntimeStore({ env: { @@ -1070,7 +1120,8 @@ test("configured postgres runtime writes Workbench facts without re-running bloc const store = createConfiguredCloudRuntimeStore({ env: { HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres", - HWLAB_CLOUD_RUNTIME_DURABLE: "true" + HWLAB_CLOUD_RUNTIME_DURABLE: "true", + HWLAB_CLOUD_DB_QUERY_RETRY_MAX_ATTEMPTS: "1" }, dbUrl: "postgres://hwlab_redacted@db.example.invalid:5432/hwlab", queryClient, @@ -1079,7 +1130,9 @@ test("configured postgres runtime writes Workbench facts without re-running bloc const readiness = await store.readiness(); assert.equal(readiness.blocker, RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED); - assert.equal(readiness.connection.queryResult, "query_blocked"); + assert.equal(readiness.connection.queryResult, "too_many_connections"); + assert.equal(readiness.retryable, true); + assert.equal(readiness.transient, true); queryClient.calls.length = 0; const write = await store.writeWorkbenchFacts({ diff --git a/internal/db/runtime-store.ts b/internal/db/runtime-store.ts index 88eaf41a..226aae21 100644 --- a/internal/db/runtime-store.ts +++ b/internal/db/runtime-store.ts @@ -139,9 +139,11 @@ const RUNTIME_DB_QUERY_TIMEOUT_MS_ENV = "HWLAB_CLOUD_DB_QUERY_TIMEOUT_MS"; const RUNTIME_DB_QUERY_RETRY_MAX_ATTEMPTS_ENV = "HWLAB_CLOUD_DB_QUERY_RETRY_MAX_ATTEMPTS"; const RUNTIME_DB_QUERY_RETRY_INITIAL_DELAY_MS_ENV = "HWLAB_CLOUD_DB_QUERY_RETRY_INITIAL_DELAY_MS"; const RUNTIME_DB_QUERY_RETRY_MAX_DELAY_MS_ENV = "HWLAB_CLOUD_DB_QUERY_RETRY_MAX_DELAY_MS"; +const RUNTIME_DB_POOL_RESET_COOLDOWN_MS_ENV = "HWLAB_CLOUD_DB_POOL_RESET_COOLDOWN_MS"; const DEFAULT_RUNTIME_DB_QUERY_RETRY_MAX_ATTEMPTS = 5; const DEFAULT_RUNTIME_DB_QUERY_RETRY_INITIAL_DELAY_MS = 250; const DEFAULT_RUNTIME_DB_QUERY_RETRY_MAX_DELAY_MS = 5_000; +const DEFAULT_RUNTIME_DB_POOL_RESET_COOLDOWN_MS = 10_000; function normalizePositiveInteger(value, fallback) { const number = Number(value); @@ -826,6 +828,8 @@ export class PostgresCloudRuntimeStore { this.pgModuleLoader = pgModuleLoader; this.logger = logger; this.pool = null; + this.postgresPoolResetInFlight = null; + this.postgresPoolResetLastAtMs = 0; this.runtimeReadIndexesReady = false; this.runtimeReadIndexesReadyPromise = null; this.memory = new CloudRuntimeStore({ now }); @@ -1941,7 +1945,43 @@ export class PostgresCloudRuntimeStore { if (classified?.connection?.queryResult !== "connect_timeout") return false; const pool = this.pool; if (!pool) return false; + const now = Date.now(); + const cooldownMs = this.runtimePoolResetCooldownMs(); + if (this.postgresPoolResetInFlight) { + this.logger?.warn?.({ + event: "postgres_runtime_pool_reset_skipped", + reason: "in_flight", + blocker: classified.blocker, + queryResult: classified.connection?.queryResult ?? "query_blocked", + errorCode: classified.connection?.errorCode ?? "UNKNOWN", + retryAttempt: Number.isFinite(Number(retry.attempt)) ? Number(retry.attempt) : null, + retryMaxAttempts: Number.isFinite(Number(retry.maxAttempts)) ? Number(retry.maxAttempts) : null, + cooldownMs, + poolStats: postgresPoolStats(pool), + valuesRedacted: true, + endpointRedacted: true + }); + return false; + } + if (now - this.postgresPoolResetLastAtMs < cooldownMs) { + this.logger?.warn?.({ + event: "postgres_runtime_pool_reset_skipped", + reason: "cooldown", + blocker: classified.blocker, + queryResult: classified.connection?.queryResult ?? "query_blocked", + errorCode: classified.connection?.errorCode ?? "UNKNOWN", + retryAttempt: Number.isFinite(Number(retry.attempt)) ? Number(retry.attempt) : null, + retryMaxAttempts: Number.isFinite(Number(retry.maxAttempts)) ? Number(retry.maxAttempts) : null, + cooldownMs, + poolResetLastAtMs: this.postgresPoolResetLastAtMs, + poolStats: postgresPoolStats(pool), + valuesRedacted: true, + endpointRedacted: true + }); + return false; + } this.pool = null; + this.postgresPoolResetLastAtMs = now; this.logger?.warn?.({ event: "postgres_runtime_pool_reset_after_transient_failure", blocker: classified.blocker, @@ -1950,22 +1990,32 @@ export class PostgresCloudRuntimeStore { retryAttempt: Number.isFinite(Number(retry.attempt)) ? Number(retry.attempt) : null, retryMaxAttempts: Number.isFinite(Number(retry.maxAttempts)) ? Number(retry.maxAttempts) : null, retrying: retry.retrying === true, + cooldownMs, + poolStats: postgresPoolStats(pool), valuesRedacted: true, endpointRedacted: true }); if (typeof pool.end === "function") { - Promise.resolve() + this.postgresPoolResetInFlight = Promise.resolve() .then(() => pool.end()) .catch((error) => this.logger?.warn?.({ event: "postgres_runtime_pool_reset_failed", errorCode: error?.code ?? "UNKNOWN", valuesRedacted: true, endpointRedacted: true - })); + })) + .finally(() => { + this.postgresPoolResetInFlight = null; + }); } return true; } + runtimePoolResetCooldownMs() { + const env = this.env ?? {}; + return normalizePositiveInteger(env[RUNTIME_DB_POOL_RESET_COOLDOWN_MS_ENV], DEFAULT_RUNTIME_DB_POOL_RESET_COOLDOWN_MS); + } + async query(sql, params = []) { const retry = this.runtimeQueryRetryPolicy(); for (let attempt = 1; attempt <= retry.maxAttempts; attempt += 1) { @@ -2094,7 +2144,7 @@ export class PostgresCloudRuntimeStore { }); } - blockedSummary({ blocker, reason, schema, migration, connection, gates }) { + blockedSummary({ blocker, reason, schema, migration, connection, gates, retryable, transient, retryAfterMs }) { const blockedSchema = schema ?? { ready: false, checked: false, @@ -2111,6 +2161,9 @@ export class PostgresCloudRuntimeStore { status: "blocked", blocker, reason, + retryable: retryable === true ? true : undefined, + transient: transient === true ? true : undefined, + retryAfterMs: Number.isFinite(Number(retryAfterMs)) ? Math.max(0, Math.trunc(Number(retryAfterMs))) : undefined, liveRuntimeEvidence: false, fixtureEvidence: false, connection: { @@ -3167,6 +3220,20 @@ export function classifyRuntimeDbError(error) { } }; } + if (code === "53300") { + return { + blocker: RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED, + reason: "Postgres runtime adapter reached the database, but the server has exhausted available connection slots", + retryable: true, + transient: true, + retryAfterMs: 2000, + connection: { + queryAttempted: true, + queryResult: "too_many_connections", + errorCode: code + } + }; + } return { blocker: RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED, reason: "Postgres runtime adapter could not complete a live runtime schema query", @@ -3467,6 +3534,16 @@ function parseJsonColumn(value, fallback) { } } +function postgresPoolStats(pool) { + if (!pool || typeof pool !== "object") return null; + return { + totalCount: Number.isFinite(Number(pool.totalCount)) ? Number(pool.totalCount) : null, + idleCount: Number.isFinite(Number(pool.idleCount)) ? Number(pool.idleCount) : null, + waitingCount: Number.isFinite(Number(pool.waitingCount)) ? Number(pool.waitingCount) : null, + valuesRedacted: true + }; +} + function toCountKey(table) { if (table === "users") return "users"; if (table === "user_sessions") return "userSessions"; diff --git a/web/hwlab-cloud-web/src/components/common/ApiErrorDiagnostic.vue b/web/hwlab-cloud-web/src/components/common/ApiErrorDiagnostic.vue index d0343267..a39a625a 100644 --- a/web/hwlab-cloud-web/src/components/common/ApiErrorDiagnostic.vue +++ b/web/hwlab-cloud-web/src/components/common/ApiErrorDiagnostic.vue @@ -40,8 +40,9 @@ const facts = computed(() => { ].filter((item): item is { label: string; value: string } => Boolean(item)); }); const primaryTraceFact = computed(() => facts.value.find((item) => item.label === "trace_id") ?? null); +const primaryTraceId = computed(() => primaryTraceFact.value?.value ?? null); const summaryText = computed(() => { - const parts = [props.showMessage && message.value ? message.value : null, primaryTraceFact.value ? `trace_id=${primaryTraceFact.value.value}` : null].filter(Boolean); + const parts = [props.showMessage && message.value ? message.value : null].filter(Boolean); return parts.join(" ") || (props.title ? "" : "诊断详情"); }); const hasContent = computed(() => Boolean(message.value || facts.value.length)); @@ -102,6 +103,10 @@ function copyDiagnostic(): void { +
+ Trace ID
+ {{ primaryTraceId }}
+
{{ mutationError }}
-{{ launchError }}
+{{ launchBlocker }}