mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(api): wait for Postgres and Redis at startup instead of crash-looping (#537)
Briefly retry Postgres and Redis connectivity at boot (waitForService, DB_STARTUP_TIMEOUT_MS) so an ordered-but-not-yet-ready dependency recovers cleanly instead of crash-looping.
This commit is contained in:
@@ -67,6 +67,10 @@ APP_NAME=snapotter
|
|||||||
DATABASE_URL=postgres://snapotter:snapotter@localhost:5432/snapotter
|
DATABASE_URL=postgres://snapotter:snapotter@localhost:5432/snapotter
|
||||||
# Redis connection (required; this default matches docker-compose.dev.yml)
|
# Redis connection (required; this default matches docker-compose.dev.yml)
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
|
# Startup grace: wait this long (ms) for Postgres/Redis to accept connections
|
||||||
|
# before failing. Lets a dependency that is still booting recover instead of
|
||||||
|
# crash-looping. 0 = try once, fail fast.
|
||||||
|
DB_STARTUP_TIMEOUT_MS=30000
|
||||||
|
|
||||||
# Job spine tuning
|
# Job spine tuning
|
||||||
SYNC_WAIT_MS=8000 # sync-response window (ms) before returning 202
|
SYNC_WAIT_MS=8000 # sync-response window (ms) before returning 202
|
||||||
|
|||||||
+34
-3
@@ -38,6 +38,7 @@ import { purgeOcrRuntimeDownloads, runOcrRuntimeMaintenance } from "./lib/ocr-ru
|
|||||||
import { getSettingString } from "./lib/settings-helpers.js";
|
import { getSettingString } from "./lib/settings-helpers.js";
|
||||||
import { assertStorageWritable } from "./lib/storage-writable.js";
|
import { assertStorageWritable } from "./lib/storage-writable.js";
|
||||||
import { gatherSystemProperties } from "./lib/system-info.js";
|
import { gatherSystemProperties } from "./lib/system-info.js";
|
||||||
|
import { waitForService } from "./lib/wait-for-service.js";
|
||||||
import { requirePermission } from "./permissions.js";
|
import { requirePermission } from "./permissions.js";
|
||||||
import {
|
import {
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
@@ -77,8 +78,22 @@ import { registerToolRoutes } from "./routes/tools/index.js";
|
|||||||
import { userFileRoutes } from "./routes/user-files.js";
|
import { userFileRoutes } from "./routes/user-files.js";
|
||||||
import { shutdownTracing } from "./tracing.js";
|
import { shutdownTracing } from "./tracing.js";
|
||||||
|
|
||||||
// Run before anything else
|
// Run before anything else. Wait briefly for Postgres to accept connections: on a
|
||||||
|
// fresh boot it may still be starting (Compose without a healthcheck gate, or a
|
||||||
|
// systemd unit ordered after Debian's no-op `postgresql.service` umbrella rather
|
||||||
|
// than the real cluster), and a short retry turns a crash-loop into a clean start.
|
||||||
try {
|
try {
|
||||||
|
await waitForService(() => db.execute(sql`SELECT 1`).then(() => undefined), {
|
||||||
|
timeoutMs: env.DB_STARTUP_TIMEOUT_MS,
|
||||||
|
intervalMs: 1_000,
|
||||||
|
onRetry: (attempt) => {
|
||||||
|
if (attempt === 1) {
|
||||||
|
console.log(
|
||||||
|
`Waiting up to ${Math.round(env.DB_STARTUP_TIMEOUT_MS / 1000)}s for Postgres to accept connections...`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const safeUrl = env.DATABASE_URL.replace(/:\/\/[^@]*@/, "://***@");
|
const safeUrl = env.DATABASE_URL.replace(/:\/\/[^@]*@/, "://***@");
|
||||||
@@ -90,9 +105,25 @@ try {
|
|||||||
}
|
}
|
||||||
console.log("Database initialized");
|
console.log("Database initialized");
|
||||||
|
|
||||||
// Verify Redis is reachable (required for BullMQ job queues)
|
// Verify Redis is reachable (required for BullMQ job queues). Same brief wait as
|
||||||
|
// Postgres so an ordered-but-not-ready Redis recovers instead of crash-looping.
|
||||||
try {
|
try {
|
||||||
await pingRedis();
|
await waitForService(
|
||||||
|
async () => {
|
||||||
|
if (!(await pingRedis())) throw new Error("Redis did not answer PONG");
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timeoutMs: env.DB_STARTUP_TIMEOUT_MS,
|
||||||
|
intervalMs: 1_000,
|
||||||
|
onRetry: (attempt) => {
|
||||||
|
if (attempt === 1) {
|
||||||
|
console.log(
|
||||||
|
`Waiting up to ${Math.round(env.DB_STARTUP_TIMEOUT_MS / 1000)}s for Redis to accept connections...`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const safeUrl = env.REDIS_URL.replace(/:\/\/[^@]*@/, "://***@");
|
const safeUrl = env.REDIS_URL.replace(/:\/\/[^@]*@/, "://***@");
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ const envSchema = z
|
|||||||
RATE_LIMIT_PER_MIN: z.coerce.number().default(1000),
|
RATE_LIMIT_PER_MIN: z.coerce.number().default(1000),
|
||||||
API_KEYS_RATE_LIMIT_PER_MIN: z.coerce.number().default(30),
|
API_KEYS_RATE_LIMIT_PER_MIN: z.coerce.number().default(30),
|
||||||
DATABASE_URL: z.string().default("postgres://snapotter:snapotter@localhost:5432/snapotter"),
|
DATABASE_URL: z.string().default("postgres://snapotter:snapotter@localhost:5432/snapotter"),
|
||||||
|
// How long to wait for Postgres/Redis to accept connections at startup before
|
||||||
|
// giving up. A dependency ordered but not yet ready (fresh boot) recovers within
|
||||||
|
// this window instead of crash-looping. 0 = try once, fail fast.
|
||||||
|
DB_STARTUP_TIMEOUT_MS: z.coerce.number().default(30_000),
|
||||||
SQLITE_MIGRATE_PATH: z.string().default(""),
|
SQLITE_MIGRATE_PATH: z.string().default(""),
|
||||||
DATA_DIR: z.string().default("./data"),
|
DATA_DIR: z.string().default("./data"),
|
||||||
FILES_STORAGE_PATH: z.string().default("./data/files"),
|
FILES_STORAGE_PATH: z.string().default("./data/files"),
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
export interface WaitForServiceOptions {
|
||||||
|
/** Give up (and rethrow the last probe error) after this many milliseconds. */
|
||||||
|
timeoutMs: number;
|
||||||
|
/** Delay between probe attempts. */
|
||||||
|
intervalMs: number;
|
||||||
|
/** Called after each failed attempt, before the next sleep. */
|
||||||
|
onRetry?: (attempt: number, err: unknown) => void;
|
||||||
|
/** Injectable clock (tests); defaults to Date.now. */
|
||||||
|
now?: () => number;
|
||||||
|
/** Injectable sleep (tests); defaults to a real timer. */
|
||||||
|
sleep?: (ms: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realSleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll `probe` until it resolves without throwing, or `timeoutMs` elapses.
|
||||||
|
*
|
||||||
|
* A startup dependency (Postgres, Redis) is often reachable only a few seconds
|
||||||
|
* after the app process starts: Docker Compose without a healthcheck gate, or a
|
||||||
|
* native systemd unit ordered after Debian's no-op `postgresql.service` umbrella
|
||||||
|
* rather than the real cluster. Retrying briefly turns a crash-loop into a clean
|
||||||
|
* boot; a genuinely-absent dependency still surfaces the real error on timeout.
|
||||||
|
*/
|
||||||
|
export async function waitForService(
|
||||||
|
probe: () => Promise<void>,
|
||||||
|
options: WaitForServiceOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const { timeoutMs, intervalMs, onRetry } = options;
|
||||||
|
const now = options.now ?? Date.now;
|
||||||
|
const sleep = options.sleep ?? realSleep;
|
||||||
|
|
||||||
|
const deadline = now() + timeoutMs;
|
||||||
|
let attempt = 0;
|
||||||
|
for (;;) {
|
||||||
|
attempt++;
|
||||||
|
try {
|
||||||
|
await probe();
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (now() >= deadline) throw err;
|
||||||
|
onRetry?.(attempt, err);
|
||||||
|
await sleep(intervalMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { waitForService } from "../../apps/api/src/lib/wait-for-service.js";
|
||||||
|
|
||||||
|
// Deterministic fake clock: `sleep` advances virtual time and `now` reads it,
|
||||||
|
// so we can assert retry timing without real timers slowing the suite down.
|
||||||
|
function fakeClock() {
|
||||||
|
let ms = 0;
|
||||||
|
return {
|
||||||
|
now: () => ms,
|
||||||
|
sleep: async (delta: number) => {
|
||||||
|
ms += delta;
|
||||||
|
},
|
||||||
|
elapsed: () => ms,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("waitForService", () => {
|
||||||
|
it("resolves immediately when the probe succeeds on the first attempt", async () => {
|
||||||
|
const clock = fakeClock();
|
||||||
|
let attempts = 0;
|
||||||
|
await waitForService(
|
||||||
|
async () => {
|
||||||
|
attempts++;
|
||||||
|
},
|
||||||
|
{ timeoutMs: 30_000, intervalMs: 1_000, now: clock.now, sleep: clock.sleep },
|
||||||
|
);
|
||||||
|
expect(attempts).toBe(1);
|
||||||
|
expect(clock.elapsed()).toBe(0); // never slept
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries until the probe succeeds, then resolves", async () => {
|
||||||
|
const clock = fakeClock();
|
||||||
|
let attempts = 0;
|
||||||
|
await waitForService(
|
||||||
|
async () => {
|
||||||
|
attempts++;
|
||||||
|
if (attempts < 3) throw new Error("not ready");
|
||||||
|
},
|
||||||
|
{ timeoutMs: 30_000, intervalMs: 1_000, now: clock.now, sleep: clock.sleep },
|
||||||
|
);
|
||||||
|
expect(attempts).toBe(3);
|
||||||
|
expect(clock.elapsed()).toBe(2_000); // slept twice between three attempts
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects with the last probe error once the timeout elapses", async () => {
|
||||||
|
const clock = fakeClock();
|
||||||
|
let attempts = 0;
|
||||||
|
await expect(
|
||||||
|
waitForService(
|
||||||
|
async () => {
|
||||||
|
attempts++;
|
||||||
|
throw new Error(`refused #${attempts}`);
|
||||||
|
},
|
||||||
|
{ timeoutMs: 3_000, intervalMs: 1_000, now: clock.now, sleep: clock.sleep },
|
||||||
|
),
|
||||||
|
).rejects.toThrow("refused #4"); // attempts fire at t=0,1000,2000,3000
|
||||||
|
expect(attempts).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports each failed attempt through the onRetry callback", async () => {
|
||||||
|
const clock = fakeClock();
|
||||||
|
let attempts = 0;
|
||||||
|
const retried: number[] = [];
|
||||||
|
await waitForService(
|
||||||
|
async () => {
|
||||||
|
attempts++;
|
||||||
|
if (attempts < 3) throw new Error("not ready");
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
intervalMs: 1_000,
|
||||||
|
now: clock.now,
|
||||||
|
sleep: clock.sleep,
|
||||||
|
onRetry: (attempt) => retried.push(attempt),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(retried).toEqual([1, 2]); // two failures before the third-attempt success
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user