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 }