UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

213 lines • 9.5 kB
/** * outbox — the one owner of buffered, timed, serialized delivery for the * batching sinks in this folder: `file`, `cloudwatch` (and `agentcore`, which * is built on it) and `xray`. * * A sink accepts items on its hot path (sync, no I/O) and hands them here. The * outbox decides WHEN a batch leaves and makes sure only one leaves at a time: * * - **One queue, every trigger.** The size trigger, the timer and `drain()` * (the sink's `flush()`, and so every shutdown) all queue a delivery on * the same promise chain. Nothing calls `deliver` any other way. * - **One delivery in flight.** A delivery starts only after the previous * one settled. A large `fs.promises.appendFile` is several `write()`s; two * of them interleaved tear NDJSON lines (reported in #223, where the timer * bypassed the chain). Whatever a sink does around its write — the file * sink's rotation — runs inside its delivery, so it can never be skipped * or reordered either. This keeps ONE sink's deliveries from interleaving; * it does not make a delivery atomic (a disk that fails partway through an * append can still leave part of it behind). * - **Oldest first, within the receiver's limits.** A delivery takes the * oldest waiting items, so items leave in the order they were accepted. * When a receiving service limits one request (`maxItemsPerDelivery`, * `maxBytesPerDelivery` — CloudWatch's PutLogEvents), a delivery takes * only the oldest items that fit, and the rest go in the next delivery, * right after. That matters BECAUSE of the queue: a slow delivery lets a * backlog build behind it, which one request may not be allowed to carry. * Each part settles on its own — shipped, or dropped and reported. * - **`drain()` is bounded.** It resolves once every item accepted BEFORE * the call has settled (delivered, or dropped and reported), one the timer * already had in flight included — then makes one sweep of what arrived * while it waited. It never chases a producer that keeps emitting: under * steady traffic it ends after about two deliveries. * - **A failed batch is dropped and reported once**, then the next batch * goes. It is never requeued, so a full disk or an outage cannot grow the * outbox. The queue never rejects: a failure report that itself throws is * said on the console instead of becoming an unhandled rejection. * * What it does NOT do: bound a delivery that never settles. A hung disk holds * the queue, and `drain()` with it; nothing can cancel an in-flight write. * * Role: leaf helper for `adapters/observability/*`. Composed as a field, * never inherited. */ import { rateLimitedConsoleSink } from './deliveryErrors.js'; /** Build the outbox one sink owns. A per-delivery cap that could take * nothing is refused: such a delivery could never finish a drain. */ export function createOutbox(opts) { if (opts.maxItemsPerDelivery !== undefined) { requireDeliveryCap(opts.name, opts.maxItemsPerDelivery); } const waiting = []; let waitingBytes = 0; // Running counts. Deliveries take the oldest items, one delivery at a time, // so once `settled` reaches n the first n accepted items are done. let accepted = 0; // handed to `add` let taken = 0; // taken by a delivery (in flight or settled) let settled = 0; // delivered, or dropped and reported // The ONE queue. Every delivery is chained here; `deliverNext` never // rejects, so the chain never does either. let queue = Promise.resolve(); // A delivery is queued and has not started yet. It will take everything // waiting when it starts, so another trigger meanwhile has nothing to add — // and the chain cannot grow while a delivery hangs. let deliveryQueued = false; let timer; let stopped = false; const consoleFallback = rateLimitedConsoleSink(opts.name); function add(item, bytes = 0) { waiting.push({ item, bytes }); waitingBytes += bytes; accepted++; if (atSizeTrigger()) queueDelivery(); else armTimer(); } function atSizeTrigger() { if (waiting.length >= opts.maxItems) return true; return opts.maxBytes !== undefined && waitingBytes >= opts.maxBytes; } function armTimer() { if (timer !== undefined || stopped || opts.flushIntervalMs <= 0) return; timer = setTimeout(onTimer, opts.flushIntervalMs); // `unref` exists on Node's Timeout and not on a browser's number, so it // is feature-detected. if (!opts.timerHoldsProcess) timer.unref?.(); } function onTimer() { timer = undefined; queueDelivery(); } function queueDelivery() { if (deliveryQueued) return; deliveryQueued = true; queue = queue.then(deliverNext); } async function deliverNext() { deliveryQueued = false; const { batch, leftBehind } = takeOldest(); if (batch.length === 0) return; try { await opts.deliver(batch); } catch (cause) { reportDropped(batch, cause); } // Settled exactly once, whatever happened to the deliveries around it. settled += batch.length; // Items a per-delivery limit left behind were already due when this // delivery started — deliver them now rather than on the next timer. if (leftBehind) queueDelivery(); } /** Take the oldest waiting items one delivery may carry. `leftBehind` says * a per-delivery limit stopped the take with items still waiting. */ function takeOldest() { const entries = waiting.splice(0, countForOneDelivery()); for (const entry of entries) waitingBytes -= entry.bytes; if (waiting.length === 0) waitingBytes = 0; taken += entries.length; return { batch: entries.map((entry) => entry.item), leftBehind: waiting.length > 0 }; } /** How many of the oldest waiting items fit one delivery: at least one, * then as many as the item and byte limits allow, in order. */ function countForOneDelivery() { const most = Math.min(waiting.length, opts.maxItemsPerDelivery ?? waiting.length); const byteLimit = opts.maxBytesPerDelivery; if (byteLimit === undefined) return most; const overhead = opts.overheadBytesPerItem ?? 0; let count = 0; let bytes = 0; while (count < most) { const entry = waiting[count]; if (entry === undefined) break; const cost = entry.bytes + overhead; if (count > 0 && bytes + cost > byteLimit) break; bytes += cost; count++; } return count; } function reportDropped(batch, cause) { let error; try { error = opts.dropped(batch, cause); } catch { error = new Error(`${batch.length} item(s) dropped: ${messageOf(cause)}`); } try { opts.report(error); } catch (hookError) { // The consumer's own `onError` threw. Say both things on the console // rather than letting the queue reject (an unhandled rejection, which // ends a Node process by default). consoleFallback(new Error(`${error.message} — and the onError hook threw: ${messageOf(hookError)}`)); } } async function drain() { // Everything accepted before this call, in flight or waiting… await settleThrough(accepted); // …then ONE sweep of what arrived while it waited, so a `stop()` right // after `flush()` strands nothing that was already here. The target is // fixed when the sweep starts, so steady traffic cannot keep it going. await settleThrough(accepted); } /** Wait until the first `target` accepted items have settled. */ async function settleThrough(target) { while (settled < target) { const before = settled; // Some of them still wait: make sure a delivery will take them. if (taken < target) queueDelivery(); await queue; // A pass that settled nothing would repeat forever. With the cap // refused above it cannot happen; if it ever does, end — never spin. if (settled === before) return; } } function stop() { stopped = true; if (timer !== undefined) { clearTimeout(timer); timer = undefined; } } return { add, drain, stop }; } /** The sink-to-outbox contract: a cap is a whole number above zero. Sinks * map and refuse their own options first (X-Ray: `segmentsPerPut`), so this * guards the contract rather than a caller's input. */ function requireDeliveryCap(name, cap) { if (Number.isInteger(cap) && cap > 0) return; throw new TypeError(`[${name}Observability] the outbox was given a per-delivery cap of ${String(cap)}; ` + `a delivery that may take no items could never finish a drain.`); } function messageOf(err) { return err instanceof Error ? err.message : String(err); } //# sourceMappingURL=outbox.js.map