database.js (5563B)
1 // test/helpers/database.js - real postgres and mysql for the tests 2 // 3 // The suite otherwise jobs entirely on sqlite, which means the postgres 4 // and mysql migrations and the dialect specific query compilation were 5 // only ever read, never executed. A migration that does not apply, or a 6 // query that binds its parameters in a way one driver dislikes, would 7 // reach whoever jobs that database rather than the test suite. 8 // 9 // Set CONDUCTOR_TEST_POSTGRES_URL or CONDUCTOR_TEST_MYSQL_URL to use a 10 // server you already have instead of starting a container. 11 12 import { execFile, execFileSync } from 'node:child_process'; 13 import { promisify } from 'node:util'; 14 import crypto from 'node:crypto'; 15 import net from 'node:net'; 16 17 import { openDatabase, dialectFromUrl } from '../../src/lib/db/index.js'; 18 import { reapStale } from './containers.js'; 19 20 const execFileAsync = promisify(execFile); 21 22 // Pinned, so an upstream release cannot change what is being tested 23 // against without the change being visible here. 24 export const IMAGES = { 25 postgres: 'postgres:16-alpine', 26 mysql: 'mysql:8.4', 27 }; 28 29 const READY_TIMEOUT = 180 * 1000; 30 const NAME_PREFIX = 'conductor-db-test-'; 31 const PASSWORD = 'conductortestpassword'; 32 33 // Checked synchronously: the runner needs to know whether to skip while 34 // it is collecting tests, and an unsettled top-level await at that point 35 // aborts the whole file. 36 export function dockerAvailableSync() { 37 if (process.env.CONDUCTOR_TEST_NO_DOCKER) return false; 38 try { 39 execFileSync('docker', ['version', '--format', '{{.Server.Version}}'], { 40 timeout: 15000, 41 stdio: 'ignore', 42 }); 43 return true; 44 } catch { 45 return false; 46 } 47 } 48 49 // Whether a given dialect can be exercised: a url was supplied, or docker 50 // is here to start one. The drivers are optional dependencies, so a 51 // checkout that skipped them cannot job these either. 52 export function dialectAvailable(dialect) { 53 if (envUrl(dialect)) return true; 54 if (!dockerAvailableSync()) return false; 55 try { 56 // require rather than import, to stay synchronous for the same reason. 57 import.meta.resolve(dialect === 'postgres' ? 'pg' : 'mysql2'); 58 return true; 59 } catch { 60 return false; 61 } 62 } 63 64 function envUrl(dialect) { 65 return dialect === 'postgres' 66 ? process.env.CONDUCTOR_TEST_POSTGRES_URL 67 : process.env.CONDUCTOR_TEST_MYSQL_URL; 68 } 69 70 async function freePort() { 71 return new Promise((resolve, reject) => { 72 const server = net.createServer(); 73 server.unref(); 74 server.on('error', reject); 75 server.listen(0, '127.0.0.1', () => { 76 const { port } = server.address(); 77 server.close(() => resolve(port)); 78 }); 79 }); 80 } 81 82 // Whether the server is actually usable yet. 83 // 84 // Not pg_isready or mysqladmin ping over docker exec, which is the 85 // obvious choice and the wrong one: both images job a temporary server 86 // during initialisation to create the database, and those clients answer 87 // for it over the local socket. The probe then passes, the temporary 88 // server shuts down, and the first real query dies with ECONNRESET. 89 // 90 // Both images keep that temporary server off the network, so connecting 91 // over the published port and running a statement is the thing that 92 // cannot be satisfied early. 93 async function probe(url) { 94 let db; 95 try { 96 db = await openDatabase({ database: { url, dialect: dialectFromUrl(url) } }); 97 await db.get('SELECT 1 AS ok', {}); 98 return true; 99 } catch { 100 return false; 101 } finally { 102 await db?.close().catch(() => {}); 103 } 104 } 105 106 // Starts one, returning its url and a stop function. Each caller gets its 107 // own database so tests cannot see each other's rows. 108 export async function startDatabase(dialect) { 109 const supplied = envUrl(dialect); 110 if (supplied) { 111 return { url: supplied, async stop() {} }; 112 } 113 114 await reapStale(NAME_PREFIX); 115 116 const name = `${NAME_PREFIX}${dialect}-${crypto.randomBytes(4).toString('hex')}`; 117 const port = await freePort(); 118 const inner = dialect === 'postgres' ? 5432 : 3306; 119 120 const env = dialect === 'postgres' 121 ? [ 122 '--env', 'POSTGRES_USER=conductor', 123 '--env', `POSTGRES_PASSWORD=${PASSWORD}`, 124 '--env', 'POSTGRES_DB=conductor', 125 ] 126 : [ 127 '--env', `MYSQL_ROOT_PASSWORD=${PASSWORD}`, 128 '--env', 'MYSQL_DATABASE=conductor', 129 ]; 130 131 await execFileAsync('docker', [ 132 'run', '--detach', '--rm', 133 '--name', name, 134 '--publish', `127.0.0.1:${port}:${inner}`, 135 // Nothing here outlives the test, so durability is only a cost. 136 '--tmpfs', dialect === 'postgres' ? '/var/lib/postgresql/data' : '/var/lib/mysql', 137 ...env, 138 IMAGES[dialect], 139 ], { timeout: 300000 }); 140 141 const stop = async () => { 142 await execFileAsync('docker', ['rm', '-f', name], { timeout: 60000 }).catch(() => {}); 143 }; 144 145 const url = dialect === 'postgres' 146 ? `postgres://conductor:${PASSWORD}@127.0.0.1:${port}/conductor` 147 : `mysql://root:${PASSWORD}@127.0.0.1:${port}/conductor`; 148 149 const deadline = Date.now() + READY_TIMEOUT; 150 let ready = false; 151 while (Date.now() < deadline) { 152 if (await probe(url)) { 153 ready = true; 154 break; 155 } 156 await new Promise((r) => { setTimeout(r, 500); }); 157 } 158 159 if (!ready) { 160 const diagnosis = await execFileAsync('docker', ['logs', '--tail', '20', name], { timeout: 10000 }) 161 .then(({ stdout, stderr }) => `${stdout}${stderr}`.trim()) 162 .catch((e) => `could not read container logs: ${e.message}`); 163 await stop(); 164 throw new Error(`${dialect} did not become ready within ${READY_TIMEOUT / 1000}s\n${diagnosis}`); 165 } 166 167 return { url, stop }; 168 }