conductor

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

log.js (5547B)


      1 // src/lib/log.js - task log spool
      2 //
      3 // Logs are written to local disk while a task runs, then copied to object
      4 // storage when it finishes. The spool is always local even when S3 is
      5 // configured, because a running task produces many small appends and a live
      6 // tail wants cheap random reads; neither suits an object store.
      7 //
      8 // Appends carry the offset the worker believes it is writing at, which makes
      9 // a retry after a dropped connection safe: an overlapping chunk is trimmed
     10 // and a gap is refused so the worker can resend from the right place.
     11 
     12 import fs from 'node:fs/promises';
     13 import path from 'node:path';
     14 
     15 export class LogOffsetError extends Error {
     16   constructor(message, expected) {
     17     super(message);
     18     this.name = 'LogOffsetError';
     19     this.expected = expected;
     20   }
     21 }
     22 
     23 const TRUNCATION_NOTICE = '\n[conductor] log truncated: size limit reached\n';
     24 
     25 export function createLogStore(cfg) {
     26   const root = cfg.log.spool_path;
     27   const maxSize = cfg.log.max_size;
     28 
     29   // One writer per job, so a retried append cannot interleave with the
     30   // original.
     31   const locks = new Map();
     32 
     33   async function withLock(key, fn) {
     34     const previous = locks.get(key) ?? Promise.resolve();
     35     let release;
     36     const current = new Promise((resolve) => { release = resolve; });
     37     locks.set(key, previous.then(() => current));
     38     await previous;
     39     try {
     40       return await fn();
     41     } finally {
     42       release();
     43       if (locks.get(key) === current) locks.delete(key);
     44     }
     45   }
     46 
     47   // A directory per job keeps listings sane, and lets a whole job's spool
     48   // be removed in one go. Both ids are encoded anyway, which costs nothing
     49   // now that neither carries a separator.
     50   function spoolPath(jobId, taskId) {
     51     return path.join(root, encodeURIComponent(jobId), `${encodeURIComponent(taskId)}.log`);
     52   }
     53 
     54   async function currentSize(file) {
     55     try {
     56       return (await fs.stat(file)).size;
     57     } catch (e) {
     58       if (e.code === 'ENOENT') return 0;
     59       throw e;
     60     }
     61   }
     62 
     63   return {
     64     spoolPath,
     65 
     66     async size(jobId, taskId) {
     67       return currentSize(spoolPath(jobId, taskId));
     68     },
     69 
     70     // Returns the new total size. When offset is omitted the chunk is simply
     71     // appended, which is what a worker that never retries will do.
     72     async append(jobId, taskId, chunk, offset) {
     73       const file = spoolPath(jobId, taskId);
     74       const key = `${jobId}/${taskId}`;
     75       const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
     76 
     77       return withLock(key, async () => {
     78         const size = await currentSize(file);
     79 
     80         let payload = data;
     81         if (offset !== undefined && offset !== null) {
     82           if (offset > size) {
     83             throw new LogOffsetError(
     84               `log append starts past the end of the log: got offset ${offset}, have ${size}`,
     85               size
     86             );
     87           }
     88           if (offset < size) {
     89             // The worker is resending something already stored. Keep only
     90             // the part beyond what we have.
     91             const overlap = size - offset;
     92             if (overlap >= payload.length) return { size, written: 0 };
     93             payload = payload.subarray(overlap);
     94           }
     95         }
     96 
     97         if (size >= maxSize) return { size, written: 0, truncated: true };
     98 
     99         let truncated = false;
    100         if (size + payload.length > maxSize) {
    101           payload = Buffer.concat([
    102             payload.subarray(0, Math.max(0, maxSize - size)),
    103             Buffer.from(TRUNCATION_NOTICE, 'utf8'),
    104           ]);
    105           truncated = true;
    106         }
    107 
    108         await fs.mkdir(path.dirname(file), { recursive: true });
    109         await fs.appendFile(file, payload);
    110         return { size: size + payload.length, written: payload.length, truncated };
    111       });
    112     },
    113 
    114     // Reads a window for the live tail. Returns the total size too, so the
    115     // caller knows whether more is already available.
    116     async read(jobId, taskId, { offset = 0, limit = 256 * 1024 } = {}) {
    117       const file = spoolPath(jobId, taskId);
    118       const size = await currentSize(file);
    119       if (size === 0 || offset >= size) {
    120         return { data: Buffer.alloc(0), offset: Math.min(offset, size), size };
    121       }
    122 
    123       const start = Math.max(0, offset);
    124       const length = Math.min(limit, size - start);
    125       const handle = await fs.open(file, 'r');
    126       try {
    127         const buffer = Buffer.alloc(length);
    128         const { bytesRead } = await handle.read(buffer, 0, length, start);
    129         return { data: buffer.subarray(0, bytesRead), offset: start, size };
    130       } finally {
    131         await handle.close();
    132       }
    133     },
    134 
    135     // Copies the finished log into object storage and drops the spool copy.
    136     // Storage is the durable home; the spool only exists to serve a tail.
    137     async finalize(jobId, taskId, storage, storageKey) {
    138       const file = spoolPath(jobId, taskId);
    139       const size = await currentSize(file);
    140       if (size === 0) return { key: null, size: 0 };
    141 
    142       const handle = await fs.open(file, 'r');
    143       try {
    144         await storage.put(storageKey, handle.createReadStream(), {
    145           size,
    146           contentType: 'text/plain; charset=utf-8',
    147         });
    148       } finally {
    149         await handle.close();
    150       }
    151 
    152       await fs.rm(file, { force: true });
    153       return { key: storageKey, size };
    154     },
    155 
    156     async remove(jobId, taskId) {
    157       await fs.rm(spoolPath(jobId, taskId), { force: true });
    158     },
    159 
    160     async removeJob(jobId) {
    161       await fs.rm(path.join(root, encodeURIComponent(jobId)), { recursive: true, force: true });
    162     },
    163   };
    164 }