Files
website/server/src/utils/db.js
wtclaude 32ed8e4411
All checks were successful
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 1m34s
PR Checks / bot-install (pull_request) Successful in 8m45s
fix(test): stop the suite reaching a real database, and make it exit
`npm test` never terminated. Twenty-two test files omitted the two lines that
point the pool at a dead port, so utils/db.js -- which builds its mariadb pool at
require time and calls dotenv.config() itself -- picked up server/.env and opened
five live connections to the developer's MariaDB. The tests still passed, because
they stub their models and never issue a query; the only symptoms were a process
that never exited and five connections held for as long as it lived. Thirty
stranded workers is 150 connections, which is the whole server's limit, and that
is the "too many connections" this workspace has hit before.

The convention was right and only ever as good as the next test file's memory of
it, so it moves into the harness: test/_setup.js is loaded with --require by the
npm script, ahead of the test file it hosts, which is the only moment early
enough to matter. It pins the dead port -- dotenv does not overwrite an existing
variable, so an explicit DB_PORT= still wins for anyone who wants a live database
-- and closes the pool after the file's tests, so the process exits at once
instead of waiting out the driver's connect retries. The per-file preambles stay:
they keep `node --test test/one.test.js` safe on its own.

Two supporting fixes:

- db.close() is idempotent. pool.end() throws "pool is already closed" on a
  second call, and closing twice is now normal rather than exceptional -- the
  harness closes the pool for every file on top of the suites that close it
  themselves, and a SIGINT followed by a SIGTERM already reached the shutdown
  handler twice.
- test/_helper.js's close() destroys open connections. server.close() only stops
  accepting and waits for existing connections to end, and node's global fetch
  keeps its sockets alive, so the listener outlived the test that created it --
  invisible until now, because the pool was holding the process open anyway.

announceJobs.test.js alone: 120s+ hang -> 0.35s. The whole suite now finishes in
~75s where it previously did not finish at all: 901 tests, 901 pass, verified
three times on CI's exact platform (node:20 on Linux, via Docker).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:27:47 -05:00

103 lines
3.7 KiB
JavaScript

const fs = require('fs')
const path = require('path')
const mariadb = require('mariadb')
require('dotenv').config()
const log = require('./logger')('db')
const { splitStatements } = require('./sqlStatements')
const pool = mariadb.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'runic_gateway',
connectionLimit: 5,
// Return plain JS numbers, never BigInt — keeps JSON responses clean.
insertIdAsNumber: true,
bigIntAsNumber: true,
decimalAsNumber: true,
// The driver defaults to 'local' — silently serializing bound JS Date
// params using the HOST MACHINE's local offset instead of the DB session's
// timezone (discovered via the Discord bot's temp_roles.expires_at coming
// back hours off in dev). 'auto' negotiates the actual session timezone so
// Date round-trips correctly regardless of host TZ — affects any write of
// a JS Date param, e.g. botConfig.model.js's last_connected_at.
timezone: 'auto',
})
/**
* Run a parameterized query and release the connection.
* @param {string} sql
* @param {Array} [params]
*/
async function query(sql, params) {
const conn = await pool.getConnection()
try {
return await conn.query(sql, params)
} finally {
conn.release()
}
}
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'db', 'schema.sql')
/**
* Create tables if they do not exist. Idempotent. Retries while the DB is still
* coming up (important under docker-compose even with a healthcheck).
*
* Once core's schema is in place, every installed module's schema fragment is
* replayed after it (MODULE_API.md §2.6). That step is deliberately OUTSIDE the
* retry loop: a fragment that throws is that module's failure, not a signal the
* database is still coming up, and retrying core's whole schema nine more times
* because one module shipped bad SQL would turn a 503'd module into a two-minute
* boot. It is also why this file knows nothing about modules beyond the one call
* below — the discovery, splitting and per-module failure handling all live in
* modules/schema.js, required lazily so that requiring the pool never drags the
* loader in with it.
*/
async function ensureSchema({ retries = 10, delayMs = 2000 } = {}) {
await ensureCoreSchema({ retries, delayMs })
// eslint-disable-next-line global-require
await require('../modules/schema').replayFragments()
}
/** Core's own schema.sql, with the wait-for-the-database retry. */
async function ensureCoreSchema({ retries, delayMs }) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const conn = await pool.getConnection()
try {
const sql = fs.readFileSync(SCHEMA_PATH, 'utf8')
for (const statement of splitStatements(sql)) {
await conn.query(statement)
}
log.info('schema ensured')
return
} finally {
conn.release()
}
} catch (err) {
if (attempt === retries) throw err
log.warn(`database not ready, retrying (attempt ${attempt}/${retries})`, {
code: err.code || err.message,
})
await new Promise((r) => setTimeout(r, delayMs))
}
}
}
// Idempotent: `pool.end()` throws "pool is already closed" on a second call, and
// closing twice is normal rather than exceptional — a SIGINT followed by a
// SIGTERM reaches the shutdown handler twice, and the test harness closes the
// pool for every file on top of the suites that close it themselves. A teardown
// that fails because it had already succeeded is noise.
let closed = false
async function close() {
if (closed) return
closed = true
await pool.end()
}
module.exports = { pool, query, ensureSchema, close }