conductor

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

logstream.js (5228B)


      1 // src/worker/logstream.js - batching log shipper with masking
      2 //
      3 // Task output arrives as many small writes; shipping each one would be a
      4 // request per line. Output is accumulated and flushed on a timer, or as soon
      5 // as it grows past a threshold.
      6 //
      7 // The offset the conductor has accepted is tracked locally, so a retry after
      8 // a dropped connection resends from the right place rather than duplicating
      9 // or losing output. A 409 carries the conductor's view of the offset and
     10 // wins, since it is the side holding the file.
     11 //
     12 // Masking happens here, before anything leaves the host. Two details make it
     13 // actually work:
     14 //
     15 //   Masking is applied when a batch is assembled, not as each write
     16 //   arrives, and the last few characters are held back until more output
     17 //   appears. A secret written across two chunks is therefore still matched.
     18 //   Masking each write on its own would defeat the hold back entirely.
     19 //
     20 //   While masking is active the stream is decoded as UTF-8 with a
     21 //   StringDecoder, so a multi byte character split across writes is not
     22 //   mangled. With no secrets configured the bytes are passed through
     23 //   untouched and the stream stays binary safe.
     24 
     25 import { StringDecoder } from 'node:string_decoder';
     26 
     27 const FLUSH_BYTES = 16 * 1024;
     28 const FLUSH_MS = 1000;
     29 const REDACTION = '[masked]';
     30 
     31 // Below this length a value is too common to redact usefully, and would
     32 // turn ordinary output into noise.
     33 const MIN_SECRET_LENGTH = 4;
     34 
     35 export function createLogStream(client, url, { masked = [], flushMs = FLUSH_MS, logger = console } = {}) {
     36   // Longest first, so an overlapping shorter secret cannot partially
     37   // reveal a longer one.
     38   const secrets = [...new Set(masked.filter((s) => typeof s === 'string' && s.length >= MIN_SECRET_LENGTH))]
     39     .sort((a, b) => b.length - a.length);
     40   const masking = secrets.length > 0;
     41   const holdBack = masking ? Math.max(...secrets.map((s) => s.length)) - 1 : 0;
     42 
     43   const decoder = masking ? new StringDecoder('utf8') : null;
     44   let text = '';
     45   let bytes = Buffer.alloc(0);
     46   let outbox = Buffer.alloc(0);
     47 
     48   let offset = 0;
     49   let timer = null;
     50   let flushing = null;
     51   let ended = false;
     52   let failed = null;
     53 
     54   function mask(value) {
     55     let out = value;
     56     for (const secret of secrets) out = out.split(secret).join(REDACTION);
     57     return out;
     58   }
     59 
     60   function buffered() {
     61     return outbox.length + (masking ? text.length : bytes.length);
     62   }
     63 
     64   // Moves everything that is safe to send into the outbox, masking on the
     65   // way. Anything within holdBack of the end waits for more input, unless
     66   // this is the final flush.
     67   function assemble(final) {
     68     if (!masking) {
     69       if (bytes.length === 0) return;
     70       outbox = Buffer.concat([outbox, bytes]);
     71       bytes = Buffer.alloc(0);
     72       return;
     73     }
     74 
     75     if (final) text += decoder.end();
     76     const keep = final ? 0 : Math.min(holdBack, text.length);
     77     const take = text.slice(0, text.length - keep);
     78     text = text.slice(text.length - keep);
     79     if (take.length === 0) return;
     80     outbox = Buffer.concat([outbox, Buffer.from(mask(take), 'utf8')]);
     81   }
     82 
     83   function schedule() {
     84     if (timer !== null || ended) return;
     85     timer = setTimeout(() => { timer = null; void flush(); }, flushMs);
     86     timer.unref?.();
     87   }
     88 
     89   async function flush(final = false) {
     90     if (timer !== null) {
     91       clearTimeout(timer);
     92       timer = null;
     93     }
     94     // Serialize, so two flushes cannot race on the offset.
     95     while (flushing) await flushing;
     96 
     97     assemble(final);
     98     if (outbox.length === 0) return;
     99 
    100     flushing = (async () => {
    101       while (outbox.length > 0) {
    102         const chunk = outbox;
    103         outbox = Buffer.alloc(0);
    104         try {
    105           const result = await client.appendLog(url, chunk, offset);
    106           if (result.conflict) {
    107             // The conductor knows what it already has; drop that much and
    108             // resend the remainder.
    109             const skip = result.expected - offset;
    110             offset = result.expected;
    111             const remainder = skip > 0 ? chunk.subarray(Math.min(skip, chunk.length)) : chunk;
    112             outbox = Buffer.concat([remainder, outbox]);
    113             continue;
    114           }
    115           offset = result.size;
    116         } catch (e) {
    117           // Losing log output must not fail an otherwise good task.
    118           failed = e;
    119           logger.warn?.(`log shipping failed, dropping ${chunk.length} bytes: ${e.message}`);
    120         }
    121       }
    122     })();
    123 
    124     try {
    125       await flushing;
    126     } finally {
    127       flushing = null;
    128     }
    129   }
    130 
    131   return {
    132     write(chunk) {
    133       if (ended) return;
    134       const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
    135       if (masking) text += decoder.write(buffer);
    136       else bytes = Buffer.concat([bytes, buffer]);
    137 
    138       if (buffered() >= FLUSH_BYTES) void flush();
    139       else schedule();
    140     },
    141 
    142     // A line from the worker rather than the task, so the log can explain a
    143     // timeout, a cancellation or a setup failure.
    144     note(line) {
    145       this.write(`${line}\n`);
    146     },
    147 
    148     async close() {
    149       if (ended) return;
    150       await flush(true);
    151       ended = true;
    152     },
    153 
    154     get offset() {
    155       return offset;
    156     },
    157 
    158     get error() {
    159       return failed;
    160     },
    161   };
    162 }