3.5 KiB
batching
(no submodules)
Purpose
Buffers outbound log records and flushes them in batches over a single
MessagePort frame. Reducing one-frame-per-record to one-frame-per-batch
materially lowers the wire cost for workers under load, where a sustained
plugin can emit several hundred records per second. The batcher is
sink-agnostic — it invokes an onFlush callback supplied at construction
with a frozen snapshot of buffered records, so it composes equally with
the worker-side client's postMessage call and with test sinks.
A second concern handled here is backpressure. Logging is best-effort: a
slow main-side sink must never break the application by surfacing as an
exception in log.info(...). The batcher caps the number of
outstanding-but-not-yet-released batches at maxOutstandingBatches; once
the cap is reached new records are dropped silently and an onDrop
callback fires so the consumer can increment the
LOGGER_PROXY_BACKPRESSURE_DROP counter.
Features
- Flush triggers: max batch size (default 100), idle interval (default
50 ms),
error/fatallevel (immediate, but records buffered before the error still ship in the same batch — order is preserved). - Bounded outstanding-batch budget (default 8). Dropped records increment
a counter; the batcher never throws on
enqueue. - Injectable
TimeoutSchedulerfor deterministic timer tests. dispose()clears the timer and drops anything still buffered.
Usage: Basic
import { LogBatcher } from "@suaveplan/api-logger-worker-proxy/batching";
const batcher = new LogBatcher((records) => {
port.postMessage({
v: "1.0.0",
kind: "log-batch",
plugin: { name: "demo", version: "0.1.0" },
records,
});
batcher.releaseBatch();
});
batcher.enqueue({
level: "info",
msg: "hello",
ts: new Date().toISOString(),
});
Usage: Advanced
Wiring the drop counter to an OTel meter and using a deterministic scheduler in tests:
import {
LogBatcher,
type TimeoutScheduler,
} from "@suaveplan/api-logger-worker-proxy/batching";
let dropped = 0;
const fakeScheduler: TimeoutScheduler = {
set: (h) => {
queueMicrotask(h);
return null;
},
clear: () => {},
};
const batcher = new LogBatcher((records) => sink.write(records), {
maxBatchSize: 10,
flushIntervalMs: 5,
maxOutstandingBatches: 4,
onDrop: () => {
dropped++;
},
scheduler: fakeScheduler,
});
API Reference
new LogBatcher(onFlush, options?)— constructor.enqueue(record): boolean—trueaccepted,falsedropped.flush()— flush whatever is buffered; idempotent.releaseBatch()— return a batch slot to the budget.dispose()— stop accepting; drop everything in the buffer.droppedCount/flushedBatchCount/bufferedCount/outstandingBatches— read-only counters for tests + telemetry.DEFAULT_BATCHER_OPTIONS— the defaults exposed for re-use.
Implementation Notes
error and fatal flush immediately AFTER being appended to the buffer
so any records queued before them ship in the same batch and the
ordering is preserved. The outstanding counter is decremented by
releaseBatch(); the worker-side client invokes that synchronously
after postMessage returns since MessagePort has no ack frame for
log batches and there is no transport-level confirmation to wait for.
Best-effort posture: every code path that could otherwise throw on a
log call (full buffer, disposed batcher) routes through recordDrop
instead.