diff --git a/lib/db.js b/lib/db.js index 7a103482..dd7bd571 100644 --- a/lib/db.js +++ b/lib/db.js @@ -20,9 +20,26 @@ const pool = mysql.createPool({ charset: "utf8mb4", }); +// MySQL closes a connection after wait_timeout seconds without a query (60 on +// prod), and the pool only finds out when the next query on it fails with +// errno 4031 ("disconnected by the server because of inactivity"). The +// server closed the connection before that query arrived, so the query never +// ran and one retry on a fresh connection is safe, writes included. Any other +// error, a connection lost mid-query among them, is passed on unchanged. +const SERVER_CLOSED_IDLE = 4031; + +export async function retryIfIdleClosed(run) { + try { + return await run(); + } catch (err) { + if (err?.errno !== SERVER_CLOSED_IDLE) throw err; + return run(); + } +} + export default { query: function (query, params) { - return pool.query(query, params) + return retryIfIdleClosed(() => pool.query(query, params)) .then((result) => result[0]); // [rows, fields] }, // The refresh-* bins call this to close the pool so the process can exit diff --git a/test/db-retry.test.js b/test/db-retry.test.js new file mode 100644 index 00000000..32a482d6 --- /dev/null +++ b/test/db-retry.test.js @@ -0,0 +1,41 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { retryIfIdleClosed } from '../lib/db.js'; + +const idleClosed = Object.assign(new Error('disconnected by the server'), { errno: 4031 }); + +test('a query on a connection MySQL closed for idling runs once more', async () => { + let calls = 0; + const rows = await retryIfIdleClosed(async () => { + calls++; + if (calls === 1) throw idleClosed; + return 'rows'; + }); + assert.equal(rows, 'rows'); + assert.equal(calls, 2); +}); + +test('any other error is not retried', async () => { + let calls = 0; + const lost = Object.assign(new Error('lost'), { code: 'PROTOCOL_CONNECTION_LOST' }); + await assert.rejects( + retryIfIdleClosed(async () => { + calls++; + throw lost; + }), + lost + ); + assert.equal(calls, 1); +}); + +test('it retries only once', async () => { + let calls = 0; + await assert.rejects( + retryIfIdleClosed(async () => { + calls++; + throw idleClosed; + }), + idleClosed + ); + assert.equal(calls, 2); +});