conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

dialects.test.js (5447B)


      1 // test/dialects.test.js - the same behaviour on postgres and mysql
      2 //
      3 // Everything else jobs on sqlite, so the other two dialects were only
      4 // exercised by reading them. This applies every migration against a real
      5 // server and then jobs the retention sweep, which is the query that most
      6 // depends on the dialect: it joins, filters on nulls, orders, and binds a
      7 // LIMIT, and the drivers do not all accept those the same way.
      8 //
      9 // Skipped when docker is unavailable, or when the optional drivers are
     10 // not installed.
     11 
     12 import test from 'node:test';
     13 import assert from 'node:assert/strict';
     14 import fs from 'node:fs/promises';
     15 import os from 'node:os';
     16 import path from 'node:path';
     17 
     18 import { startDatabase, dialectAvailable } from './helpers/database.js';
     19 import { openDatabase, runMigrations, dialectFromUrl } from '../src/lib/db/index.js';
     20 import { createStorage } from '../src/lib/storage/index.js';
     21 import { createLogStore } from '../src/lib/log.js';
     22 import { createRetention } from '../src/conductor/retention.js';
     23 
     24 const DAY = 24 * 60 * 60 * 1000;
     25 const NOW = 1_800_000_000_000;
     26 
     27 // The same shape the sqlite tests use, so the expectations below can be
     28 // compared against what that dialect already proves.
     29 async function seed(db, storage) {
     30   await db.run(
     31     `INSERT INTO projects (id, name, repo_url, enabled, job_counter, created_at, updated_at)
     32      VALUES ('demo', 'Demo', '/tmp/repo', 1, 0, {now}, {now})`,
     33     { now: NOW }
     34   );
     35 
     36   for (let i = 1; i <= 5; i += 1) {
     37     // Job 5 is recent; the rest are long past.
     38     const at = NOW - (i === 5 ? 1 : 100) * DAY;
     39     await db.run(
     40       `INSERT INTO jobs (id, project_id, number, head_sha, trigger_type, state,
     41                          visibility, created_at, started_at, finished_at)
     42        VALUES ({id}, 'demo', {number}, {sha}, 'push', 'failed', 'public', {at}, {at}, {at})`,
     43       { id: `r${i}`, number: i, sha: 'a'.repeat(40), at }
     44     );
     45     await db.run(
     46       `INSERT INTO tasks (id, job_id, name, base_name, image, requires, spec, state,
     47                          allow_failure, attempt, max_attempts, timeout,
     48                          log_key, log_size, created_at, finished_at)
     49        VALUES ({id}, {job}, 'build', 'build', 'alpine:3', '[]', '{}', 'failed',
     50                0, 1, 1, 3600, {logKey}, 10, {at}, {at})`,
     51       { id: `r${i}:build`, job: `r${i}`, logKey: `logs/r${i}`, at }
     52     );
     53     await db.run(
     54       `INSERT INTO artifacts (id, task_id, job_id, path, storage_key, size, sha256,
     55                               created_at, expires_at)
     56        VALUES ({id}, {task}, {job}, 'out.txt', {key}, 5, {sha}, {at}, {expires})`,
     57       {
     58         id: `a${i}`,
     59         task: `r${i}:build`,
     60         job: `r${i}`,
     61         key: `art/r${i}`,
     62         sha: 'b'.repeat(64),
     63         at,
     64         // Job 1 carries an explicit deadline, which nothing protects.
     65         expires: i === 1 ? NOW - 1 : null,
     66       }
     67     );
     68     await storage.put(`art/r${i}`, Buffer.from('artifact'));
     69     await storage.put(`logs/r${i}`, Buffer.from('log'));
     70   }
     71 }
     72 
     73 for (const dialect of ['postgres', 'mysql']) {
     74   test(`migrations apply and retention sweeps on ${dialect}`, { skip: !dialectAvailable(dialect) }, async () => {
     75     const server = await startDatabase(dialect);
     76     const root = await fs.mkdtemp(path.join(os.tmpdir(), `conductor-${dialect}-`));
     77 
     78     const cfg = {
     79       database: { url: server.url, dialect: dialectFromUrl(server.url) },
     80       storage: { path: path.join(root, 'storage') },
     81       log: { spool_path: path.join(root, 'logs'), max_size: 1 << 20 },
     82       retention: {
     83         artifact_keep_jobs: 2,
     84         artifact_keep_days: 7,
     85         log_keep_days: 7,
     86         sweep_interval: 3600,
     87         batch: 2,
     88       },
     89     };
     90 
     91     let db;
     92     try {
     93       db = await openDatabase(cfg);
     94       await runMigrations(db, { logger: () => {} });
     95 
     96       const storage = await createStorage(cfg);
     97       const logs = createLogStore(cfg);
     98       await seed(db, storage);
     99 
    100       const retention = createRetention({ cfg, db, storage, logs, logger: {} });
    101 
    102       const first = await retention.sweep({ now: NOW });
    103 
    104       // Job 1 by its own deadline, jobs 2 and 3 by policy. Jobs 4 and 5
    105       // are the last two, so they are kept whatever their age.
    106       assert.equal(first.artifacts, 3, 'three artifacts should go on the first pass');
    107 
    108       const left = (await db.all('SELECT id FROM artifacts ORDER BY id', {})).map((r) => r.id);
    109       assert.deepEqual(left, ['a4', 'a5']);
    110 
    111       // Logs are batched, so the four old ones take two passes.
    112       assert.equal(first.logs, 2, 'the batch limit should hold on this dialect too');
    113       const second = await retention.sweep({ now: NOW });
    114       assert.equal(second.artifacts, 0, 'nothing further to delete');
    115       assert.equal(second.logs, 2);
    116 
    117       const third = await retention.sweep({ now: NOW });
    118       assert.equal(third.logs, 0, 'a swept log must not be found again');
    119 
    120       const withLogs = await db.all('SELECT id FROM tasks WHERE log_key IS NOT NULL ORDER BY id', {});
    121       assert.deepEqual(withLogs.map((r) => r.id), ['r5:build'], 'only the recent job keeps its log');
    122 
    123       // The tasks themselves are history and stay.
    124       const tasks = await db.get('SELECT COUNT(*) AS c FROM tasks', {});
    125       assert.equal(Number(tasks.c), 5, 'sweeping a log must not remove the task');
    126     } finally {
    127       await db?.close().catch(() => {});
    128       await fs.rm(root, { recursive: true, force: true });
    129       await server.stop();
    130     }
    131   });
    132 }