diff --git a/keyserver/src/creators/day-creator.js b/keyserver/src/creators/day-creator.js index 2b70f97bd..a50aa9e32 100644 --- a/keyserver/src/creators/day-creator.js +++ b/keyserver/src/creators/day-creator.js @@ -1,55 +1,57 @@ // @flow import { ServerError } from 'lib/utils/errors'; import { dbQuery, SQL } from '../database/database'; import createIDs from './id-creator'; +const MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE = 1062; + async function fetchOrCreateDayID( threadID: string, date: string, ): Promise { if (!threadID || !date) { throw new ServerError('invalid_parameters'); } const existingQuery = SQL` SELECT id FROM days WHERE date = ${date} AND thread = ${threadID} `; const [existingResult] = await dbQuery(existingQuery); if (existingResult.length > 0) { const existingRow = existingResult[0]; return existingRow.id.toString(); } const [id] = await createIDs('days', 1); const insertQuery = SQL` INSERT INTO days(id, date, thread) VALUES ${[[id, date, threadID]]} `; try { await dbQuery(insertQuery); return id; } catch (e) { - if (e.errno !== 1062) { + if (e.errno !== MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE) { throw new ServerError('unknown_error'); } // There's a race condition that can happen if two people start editing // the same date at the same time, and two IDs are created for the same // row. If this happens, the UNIQUE constraint `date_thread` should be // triggered on the second racer, and for that execution path our last // query will have failed. We will recover by re-querying for the ID here, // and deleting the extra ID we created from the `ids` table. const deleteIDQuery = SQL`DELETE FROM ids WHERE id = ${id}`; const [[raceResult]] = await Promise.all([ dbQuery(existingQuery), dbQuery(deleteIDQuery), ]); if (raceResult.length === 0) { throw new ServerError('unknown_error'); } const raceRow = raceResult[0]; return raceRow.id.toString(); } } export default fetchOrCreateDayID; diff --git a/keyserver/src/database/database.js b/keyserver/src/database/database.js index e227634ba..76f0a950c 100644 --- a/keyserver/src/database/database.js +++ b/keyserver/src/database/database.js @@ -1,160 +1,162 @@ // @flow import type { QueryResults } from 'mysql'; import mysql from 'mysql2'; import mysqlPromise from 'mysql2/promise'; import SQL from 'sql-template-strings'; import { getScriptContext } from '../scripts/script-context'; import { connectionLimit, queryWarnTime } from './consts'; import { getDBConfig } from './db-config'; import DatabaseMonitor from './monitor'; import type { Pool, SQLOrString, SQLStatementType } from './types'; const SQLStatement: SQLStatementType = SQL.SQLStatement; let pool, databaseMonitor; async function loadPool(): Promise { if (pool) { return pool; } const scriptContext = getScriptContext(); const dbConfig = await getDBConfig(); pool = mysqlPromise.createPool({ ...dbConfig, connectionLimit, multipleStatements: !!( scriptContext && scriptContext.allowMultiStatementSQLQueries ), }); databaseMonitor = new DatabaseMonitor(pool); return pool; } function endPool() { pool?.end(); } function appendSQLArray( sql: SQLStatementType, sqlArray: $ReadOnlyArray, delimeter: SQLOrString, ): SQLStatementType { if (sqlArray.length === 0) { return sql; } const [first, ...rest] = sqlArray; sql.append(first); if (rest.length === 0) { return sql; } return rest.reduce( (prev: SQLStatementType, curr: SQLStatementType) => prev.append(delimeter).append(curr), sql, ); } function mergeConditions( conditions: $ReadOnlyArray, delimiter: SQLStatementType, ): SQLStatementType { const sql = SQL` (`; appendSQLArray(sql, conditions, delimiter); sql.append(SQL`) `); return sql; } function mergeAndConditions( andConditions: $ReadOnlyArray, ): SQLStatementType { return mergeConditions(andConditions, SQL` AND `); } function mergeOrConditions( andConditions: $ReadOnlyArray, ): SQLStatementType { return mergeConditions(andConditions, SQL` OR `); } // We use this fake result for dry runs function FakeSQLResult() { this.insertId = -1; } FakeSQLResult.prototype = Array.prototype; const fakeResult: QueryResults = (new FakeSQLResult(): any); +const MYSQL_DEADLOCK_ERROR_CODE = 1213; + type QueryOptions = { +triesLeft?: number, +multipleStatements?: boolean, }; async function dbQuery( statement: SQLStatementType, options?: QueryOptions, ): Promise { const triesLeft = options?.triesLeft ?? 2; const multipleStatements = options?.multipleStatements ?? false; let connection; if (multipleStatements) { connection = await getMultipleStatementsConnection(); } if (!connection) { connection = await loadPool(); } const timeoutID = setTimeout( () => databaseMonitor.reportLaggingQuery(statement.sql), queryWarnTime, ); const scriptContext = getScriptContext(); try { const sql = statement.sql.trim(); if ( scriptContext && scriptContext.dryRun && (sql.startsWith('INSERT') || sql.startsWith('DELETE') || sql.startsWith('UPDATE')) ) { console.log(rawSQL(statement)); return ([fakeResult]: any); } return await connection.query(statement); } catch (e) { - if (e.errno === 1213 && triesLeft > 0) { + if (e.errno === MYSQL_DEADLOCK_ERROR_CODE && triesLeft > 0) { console.log('deadlock occurred, trying again', e); return await dbQuery(statement, { ...options, triesLeft: triesLeft - 1 }); } e.query = statement.sql; throw e; } finally { clearTimeout(timeoutID); if (multipleStatements) { connection.end(); } } } function rawSQL(statement: SQLStatementType): string { return mysql.format(statement.sql, statement.values); } async function getMultipleStatementsConnection() { const dbConfig = await getDBConfig(); return await mysqlPromise.createConnection({ ...dbConfig, multipleStatements: true, }); } export { endPool, SQL, SQLStatement, appendSQLArray, mergeAndConditions, mergeOrConditions, dbQuery, rawSQL, }; diff --git a/keyserver/src/database/migrations.js b/keyserver/src/database/migrations.js index a83c5edd5..b307da042 100644 --- a/keyserver/src/database/migrations.js +++ b/keyserver/src/database/migrations.js @@ -1,78 +1,80 @@ // @flow import type { QueryResults } from 'mysql'; import { getMessageForException } from 'lib/utils/errors'; import { dbQuery, SQL } from './database'; import { fetchDBVersion, updateDBVersion } from './db-version'; import { migrations } from './migration-config'; import { setupDB } from './setup-db'; async function migrate(): Promise { let dbVersion = null; try { dbVersion = await setUpDBAndReturnVersion(); console.log(`(node:${process.pid}) DB version: ${dbVersion}`); } catch (e) { const dbVersionExceptionMessage = String(getMessageForException(e)); console.error(`(node:${process.pid}) ${dbVersionExceptionMessage}`); return false; } for (const [idx, migration] of migrations.entries()) { if (idx <= dbVersion) { continue; } try { await startTransaction(); await migration(); await updateDBVersion(idx); await commitTransaction(); console.log(`(node:${process.pid}) migration ${idx} succeeded.`); } catch (e) { const transactionExceptionMessage = String(getMessageForException(e)); console.error(`(node:${process.pid}) migration ${idx} failed.`); console.error(transactionExceptionMessage); await rollbackTransaction(); return false; } } return true; } +const MYSQL_TABLE_DOESNT_EXIST_ERROR_CODE = 1146; + async function setUpDBAndReturnVersion(): Promise { try { return await fetchDBVersion(); } catch (e) { - if (e.errno !== 1146) { + if (e.errno !== MYSQL_TABLE_DOESNT_EXIST_ERROR_CODE) { throw e; } await setupDB(); return await fetchDBVersion(); } } async function startTransaction(): Promise { const beginTxnQuery = SQL` START TRANSACTION; `; return dbQuery(beginTxnQuery); } async function commitTransaction(): Promise { const endTxnQuery = SQL` COMMIT; `; return dbQuery(endTxnQuery); } async function rollbackTransaction(): Promise { const rollbackTxnQuery = SQL` ROLLBACK; `; return dbQuery(rollbackTxnQuery); } export { migrate };