fix(database): clamp pool bounds, guard redis probe, harden error page

Address review feedback on #744:
- clamp DATABASE_POOL_MAX to >= 1 and DATABASE_POOL_MIN to <= max so bad
  env values can not produce a pool that fails every acquire
- healthcheck redis probe checks client status before PING so commands are
  not queued indefinitely while redis is down (maxRetriesPerRequest is null)
- probe() clears its timeout timer once the check settles
- error.html shows only the status code, not the error message
- docs: correct SQLite default to kener.sqlite.db to match knexfile

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Raj Nandan Sharma
2026-06-06 21:24:03 +05:30
parent 638393efac
commit 508b08f8f3
4 changed files with 35 additions and 16 deletions
+6 -2
View File
@@ -25,9 +25,13 @@ const keepAliveEnabled = process.env.DATABASE_KEEPALIVE !== "false";
// ones that go stale and wedge the app until a manual restart // ones that go stale and wedge the app until a manual restart
// - 15s acquire/create timeouts: fail fast instead of hanging requests for // - 15s acquire/create timeouts: fail fast instead of hanging requests for
// knex's default 60s during a database outage // knex's default 60s during a database outage
// Tarn requires max >= 1 and min <= max; clamp so a bad env value can not
// produce a pool that fails every acquire
const poolMax = Math.max(1, intFromEnv("DATABASE_POOL_MAX", 10));
const poolMin = Math.min(intFromEnv("DATABASE_POOL_MIN", 0), poolMax);
const pool = { const pool = {
min: intFromEnv("DATABASE_POOL_MIN", 0), min: poolMin,
max: intFromEnv("DATABASE_POOL_MAX", 10), max: poolMax,
idleTimeoutMillis: intFromEnv("DATABASE_IDLE_TIMEOUT_MS", 30000), idleTimeoutMillis: intFromEnv("DATABASE_IDLE_TIMEOUT_MS", 30000),
createTimeoutMillis: intFromEnv("DATABASE_CREATE_TIMEOUT_MS", 15000), createTimeoutMillis: intFromEnv("DATABASE_CREATE_TIMEOUT_MS", 15000),
}; };
+17 -2
View File
@@ -24,14 +24,19 @@ async function start() {
// Caps a health probe at 2s so a wedged dependency can not hang the // Caps a health probe at 2s so a wedged dependency can not hang the
// endpoint. A probe is healthy unless it throws, times out, or resolves false. // endpoint. A probe is healthy unless it throws, times out, or resolves false.
const probe = async (check: () => Promise<unknown>): Promise<boolean> => { const probe = async (check: () => Promise<unknown>): Promise<boolean> => {
let timer: ReturnType<typeof setTimeout> | undefined;
try { try {
const result = await Promise.race([ const result = await Promise.race([
check(), check(),
new Promise((_, reject) => setTimeout(() => reject(new Error("health probe timeout")), 2000)), new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("health probe timeout")), 2000);
}),
]); ]);
return result !== false; return result !== false;
} catch { } catch {
return false; return false;
} finally {
clearTimeout(timer);
} }
}; };
@@ -39,7 +44,17 @@ async function start() {
// not bounce the app while a dependency is down (a restart can not fix a // not bounce the app while a dependency is down (a restart can not fix a
// dead database); pass ?strict=1 to get 503 when any component is down. // dead database); pass ?strict=1 to get 503 when any component is down.
app.get(base + "/healthcheck", async (req: any, res: any) => { app.get(base + "/healthcheck", async (req: any, res: any) => {
const [dbOk, redisOk] = await Promise.all([probe(() => dbInstance.ping()), probe(() => redisConnection().ping())]); const [dbOk, redisOk] = await Promise.all([
probe(() => dbInstance.ping()),
// Guard on status before PING: the shared ioredis client has
// maxRetriesPerRequest null, so commands sent while disconnected would
// queue forever and accumulate across healthcheck polls
probe(async () => {
const redis = redisConnection();
if (redis.status !== "ready") return false;
return await redis.ping();
}),
]);
const healthy = dbOk && redisOk; const healthy = dbOk && redisOk;
const strict = req.query.strict === "1"; const strict = req.query.strict === "1";
res.status(strict && !healthy ? 503 : 200).json({ res.status(strict && !healthy ? 503 : 200).json({
+1 -1
View File
@@ -67,7 +67,7 @@
<h1>This status page is temporarily unavailable</h1> <h1>This status page is temporarily unavailable</h1>
<p>We are having trouble serving this page right now. It usually resolves on its own.</p> <p>We are having trouble serving this page right now. It usually resolves on its own.</p>
<p>This page will retry automatically in 30 seconds.</p> <p>This page will retry automatically in 30 seconds.</p>
<div class="code">%sveltekit.status% · %sveltekit.error.message%</div> <div class="code">%sveltekit.status%</div>
</div> </div>
</body> </body>
</html> </html>
@@ -238,15 +238,15 @@ SMTP_SECURE=1
### Database Configuration {#database-configuration} ### Database Configuration {#database-configuration}
| Variable | Description | Default | | Variable | Description | Default |
| :---------------------------- | :----------------------------------------------------------- | :----------------------------- | | :---------------------------- | :----------------------------------------------------------- | :------------------------------------ |
| `DATABASE_URL` | Full database connection string | `sqlite://./database/kener.db` | | `DATABASE_URL` | Full database connection string | `sqlite://./database/kener.sqlite.db` |
| `DATABASE_POOL_MIN` | Minimum pool connections (PostgreSQL/MySQL) | `0` | | `DATABASE_POOL_MIN` | Minimum pool connections (PostgreSQL/MySQL) | `0` |
| `DATABASE_POOL_MAX` | Maximum pool connections (PostgreSQL/MySQL) | `10` | | `DATABASE_POOL_MAX` | Maximum pool connections (PostgreSQL/MySQL) | `10` |
| `DATABASE_ACQUIRE_TIMEOUT_MS` | Wait for a free connection before failing (PostgreSQL/MySQL) | `15000` | | `DATABASE_ACQUIRE_TIMEOUT_MS` | Wait for a free connection before failing (PostgreSQL/MySQL) | `15000` |
| `DATABASE_CREATE_TIMEOUT_MS` | Wait for a new connection before failing (PostgreSQL/MySQL) | `15000` | | `DATABASE_CREATE_TIMEOUT_MS` | Wait for a new connection before failing (PostgreSQL/MySQL) | `15000` |
| `DATABASE_IDLE_TIMEOUT_MS` | Idle time before a connection is closed (PostgreSQL/MySQL) | `30000` | | `DATABASE_IDLE_TIMEOUT_MS` | Idle time before a connection is closed (PostgreSQL/MySQL) | `30000` |
| `DATABASE_KEEPALIVE` | TCP keepalive on connections (PostgreSQL/MySQL) | `true` | | `DATABASE_KEEPALIVE` | TCP keepalive on connections (PostgreSQL/MySQL) | `true` |
**Supported Databases**: **Supported Databases**:
@@ -258,7 +258,7 @@ SMTP_SECURE=1
```bash ```bash
# SQLite (default) # SQLite (default)
DATABASE_URL=sqlite://./database/kener.db DATABASE_URL=sqlite://./database/kener.sqlite.db
# PostgreSQL # PostgreSQL
DATABASE_URL=postgresql://user:password@localhost:5432/kener DATABASE_URL=postgresql://user:password@localhost:5432/kener
@@ -484,7 +484,7 @@ Create a `.env` file in the project root:
```bash ```bash
# .env # .env
KENER_SECRET_KEY=dev-secret-key KENER_SECRET_KEY=dev-secret-key
DATABASE_URL=sqlite://./database/kener.db DATABASE_URL=sqlite://./database/kener.sqlite.db
# Custom variables # Custom variables
API_KEY=test-key-123 API_KEY=test-key-123