Skip to content

@studnicky/sample-buffer

Fixed-capacity circular buffer for numeric samples with percentile calculation.

Install

bash
pnpm add @studnicky/sample-buffer

Usage

Create a SampleBuffer with a fixed capacity, push numeric samples into it, and read back percentiles. When full, the oldest sample is evicted to make room for each new one:

ts
import { SampleBuffer } from '../src/index.js';

const buffer = SampleBuffer.create({ 'capacity': 5 });

// Fill to capacity
buffer.push(10);
buffer.push(20);
buffer.push(30);
buffer.push(40);
buffer.push(50);

console.log('length:', buffer.length);   // 5
console.log('isFull:', buffer.isFull);   // true

// Percentile on a full buffer
const median = buffer.percentile(50);
const p95 = buffer.percentile(95);

console.log('p50:', median);
console.log('p95:', p95);

// Push beyond capacity — oldest samples are evicted
buffer.push(60); // evicts 10
buffer.push(70); // evicts 20

console.log('length after overflow:', buffer.length); // still 5

// Clear
buffer.clear();
console.log('length after clear:', buffer.length); // 0

Try it

Builder

SampleBuffer.builder().withCapacity(5).build() constructs the buffer through the fluent builder. Press Execute to fill the capacity-5 buffer, compute p50 and p95, then push two more samples past capacity (oldest two are evicted, length holds at 5) and clear.

Builder — fluent sample buffer construction
/** builder-sample-buffer — construct a SampleBuffer via the fluent builder API. Run: npx tsx packages/sample-buffer/examples/builder-sample-buffer.ts */
import assert from 'node:assert/strict';

import { SampleBuffer } from '../src/index.js';

// #region usage
// Build a SampleBuffer with capacity 5 via the fluent builder.
const buffer = SampleBuffer.builder().withCapacity(5).build();
console.log('builder construction: SampleBuffer.builder().withCapacity(5).build()');

// Push 5 samples to fill the buffer.
buffer.push(10);
buffer.push(20);
buffer.push(30);
buffer.push(40);
buffer.push(50);

console.log('length after 5 pushes:', buffer.length);
console.log('isFull after 5 pushes:', buffer.isFull);
console.log('percentile(50):', buffer.percentile(50));
console.log('percentile(95):', buffer.percentile(95));

// Push 2 more samples past capacity — oldest two (10, 20) are evicted; length stays 5.
buffer.push(60);
buffer.push(70);

console.log('length after 2 overflow pushes:', buffer.length);

// Clear the buffer — length resets to 0.
buffer.clear();
console.log('length after clear():', buffer.length);
// #endregion usage

assert.equal(buffer.length, 0);

// Re-build to test the post-fill assertions cleanly.
const buf2 = SampleBuffer.builder().withCapacity(5).build();
buf2.push(10);
buf2.push(20);
buf2.push(30);
buf2.push(40);
buf2.push(50);

assert.equal(buf2.length, 5);
assert.equal(buf2.isFull, true);

const p50 = buf2.percentile(50);
assert.notEqual(p50, undefined);
assert(p50 !== undefined && p50 >= 10 && p50 <= 50);

const p95 = buf2.percentile(95);
assert.notEqual(p95, undefined);

buf2.push(60);
buf2.push(70);
assert.equal(buf2.length, 5);

buf2.clear();
assert.equal(buf2.length, 0);

console.log('builder-sample-buffer: all assertions passed');
Output
Press Execute to run this example against the real library.

Lifecycle hooks

TracedSampleBuffer subclasses SampleBuffer and overrides seven hooks: onOverflow, onEvict, onPush, onComputeStart, onComputeComplete, onPercentile, and onClear. With capacity=3 and 5 pushes, watch two overflow+eviction pairs fire. The first percentile(50) triggers computeStart and computeComplete; the second call is a cache hit so those hooks do not fire again.

Observed sample buffer — lifecycle hook trace
/** observedSampleBuffer — trace all lifecycle hooks while filling a buffer past capacity and computing a percentile. Run: npx tsx examples/observedSampleBuffer.ts */

import assert from 'node:assert/strict';

// #region usage
import { SampleBuffer } from '../src/index.js';

class TracedSampleBuffer extends SampleBuffer {
  readonly overflowLog: number[] = [];
  readonly evictLog: number[] = [];
  readonly pushLog: { 'evicted': boolean; 'value': number }[] = [];
  readonly computeStartLog: number[] = [];
  readonly computeCompleteLog: { 'length': number; 'sorted': readonly number[] }[] = [];
  readonly percentileLog: { 'pct': number; 'result': number }[] = [];
  clearCount = 0;

  protected override onOverflow(value: number): void {
    console.log(`[sample-buffer] overflow value=${String(value)} capacity=${String(this.capacity)}`);
    this.overflowLog.push(value);
  }

  protected override onEvict(oldValue: number): void {
    console.log(`[sample-buffer] evict oldValue=${String(oldValue)}`);
    this.evictLog.push(oldValue);
  }

  protected override onPush(value: number, evicted: boolean): void {
    console.log(`[sample-buffer] push value=${String(value)} evicted=${String(evicted)} length=${String(this.length)}`);
    this.pushLog.push({ 'evicted': evicted, 'value': value });
  }

  protected override onComputeStart(length: number): void {
    console.log(`[sample-buffer] computeStart length=${String(length)}`);
    this.computeStartLog.push(length);
  }

  protected override onComputeComplete(length: number, sorted: readonly number[]): void {
    console.log(`[sample-buffer] computeComplete length=${String(length)} sorted=[${sorted.join(',')}]`);
    this.computeCompleteLog.push({ 'length': length, 'sorted': sorted });
  }

  protected override onPercentile(pct: number, result: number): void {
    console.log(`[sample-buffer] percentile pct=${String(pct)} result=${String(result)}`);
    this.percentileLog.push({ 'pct': pct, 'result': result });
  }

  protected override onClear(): void {
    console.log(`[sample-buffer] clear length=${String(this.length)}`);
    this.clearCount++;
  }
}

const buf = TracedSampleBuffer.create({ 'capacity': 3 });

// Fill the buffer (3 pushes, no overflow)
buf.push(10);
buf.push(20);
buf.push(30);

// Push past capacity — triggers overflow + eviction
buf.push(40); // evicts 10
buf.push(50); // evicts 20

// Compute a percentile (triggers computeStart + computeComplete + percentile hook)
const p50 = buf.percentile(50);

// Second call uses cache — no computeStart/computeComplete
const p50Cached = buf.percentile(50);

// Clear
buf.clear();
// #endregion usage

// Assertions
assert.ok(p50 !== undefined, 'p50 should be defined');
assert.ok(p50Cached !== undefined, 'p50Cached should be defined');
assert.equal(p50, p50Cached, 'cached result should match');

// 5 pushes total
assert.equal(buf.pushLog.length, 5, 'push hook fired 5 times');

// 2 overflows (pushes 4 and 5 onto a full buffer)
assert.equal(buf.overflowLog.length, 2, 'overflow hook fired 2 times');
assert.equal(buf.overflowLog[0], 40, 'first overflow value is 40');
assert.equal(buf.overflowLog[1], 50, 'second overflow value is 50');

// 2 evictions match 2 overflows
assert.equal(buf.evictLog.length, 2, 'evict hook fired 2 times');
assert.equal(buf.evictLog[0], 10, 'first eviction was 10');
assert.equal(buf.evictLog[1], 20, 'second eviction was 20');

// computeStart fires once (second percentile() is a cache hit)
assert.equal(buf.computeStartLog.length, 1, 'computeStart fires once per cache miss');

// computeComplete fires once
assert.equal(buf.computeCompleteLog.length, 1, 'computeComplete fires once per cache miss');

// percentile fires twice (both calls)
assert.equal(buf.percentileLog.length, 2, 'percentile hook fires on every non-empty call');

// clear fires once
assert.equal(buf.clearCount, 1, 'clear hook fires');

console.log('observedSampleBuffer: all assertions passed');
Output
Press Execute to run this example against the real library.

Subpath exports

SubpathContents
@studnicky/sample-bufferSampleBuffer
@studnicky/sample-buffer/sample-bufferSampleBuffer (direct subpath)
@studnicky/sample-buffer/interfacesSampleBufferInterface
@studnicky/sample-buffer/constantsDefault capacity constants

Extending

Subclass SampleBuffer and override any lifecycle hook to observe buffer events. All hooks are no-ops by default; only override what you need:

ts
import { SampleBuffer } from '../src/index.js';

class EvictionLog extends SampleBuffer {
  readonly evicted: number[] = [];

  protected override onEvict(oldValue: number): void {
    this.evicted.push(oldValue);
  }
}

const log = EvictionLog.create({ 'capacity': 3 });

// First three fill the buffer — no evictions
log.push(1);
log.push(2);
log.push(3);

// Next two each trigger an eviction
log.push(4); // evicts 1
log.push(5); // evicts 2

console.log('evicted:', log.evicted); // [1, 2]

Observability hooks

Subclass SampleBuffer and override any hook to observe the full push/evict/compute lifecycle. All hooks fire synchronously, after state mutation, with no try/catch.

HookWhen it firesArgs
onOverflow(value)Push onto a full buffer, before evictionvalue: number — the incoming sample
onEvict(oldValue)Full-buffer push path, after overflow, before overwriteoldValue: number — the sample being replaced
onPush(value, evicted)End of push(), after length/head updatedvalue: number, evicted: boolean
onComputeStart(length)Start of buildSortedSamples() — cache miss in percentile()length: number — samples about to be sorted
onComputeComplete(length, sorted)End of buildSortedSamples(), after sortlength: number, sorted: readonly number[]
onPercentile(pct, result)Before returning from percentile(), non-empty buffer onlypct: number, result: number
onClear()Start of clear(), before state resetnone
ts
import { SampleBuffer } from '../src/index.js';

class TracedSampleBuffer extends SampleBuffer {
  readonly overflowLog: number[] = [];
  readonly evictLog: number[] = [];
  readonly pushLog: { 'evicted': boolean; 'value': number }[] = [];
  readonly computeStartLog: number[] = [];
  readonly computeCompleteLog: { 'length': number; 'sorted': readonly number[] }[] = [];
  readonly percentileLog: { 'pct': number; 'result': number }[] = [];
  clearCount = 0;

  protected override onOverflow(value: number): void {
    console.log(`[sample-buffer] overflow value=${String(value)} capacity=${String(this.capacity)}`);
    this.overflowLog.push(value);
  }

  protected override onEvict(oldValue: number): void {
    console.log(`[sample-buffer] evict oldValue=${String(oldValue)}`);
    this.evictLog.push(oldValue);
  }

  protected override onPush(value: number, evicted: boolean): void {
    console.log(`[sample-buffer] push value=${String(value)} evicted=${String(evicted)} length=${String(this.length)}`);
    this.pushLog.push({ 'evicted': evicted, 'value': value });
  }

  protected override onComputeStart(length: number): void {
    console.log(`[sample-buffer] computeStart length=${String(length)}`);
    this.computeStartLog.push(length);
  }

  protected override onComputeComplete(length: number, sorted: readonly number[]): void {
    console.log(`[sample-buffer] computeComplete length=${String(length)} sorted=[${sorted.join(',')}]`);
    this.computeCompleteLog.push({ 'length': length, 'sorted': sorted });
  }

  protected override onPercentile(pct: number, result: number): void {
    console.log(`[sample-buffer] percentile pct=${String(pct)} result=${String(result)}`);
    this.percentileLog.push({ 'pct': pct, 'result': result });
  }

  protected override onClear(): void {
    console.log(`[sample-buffer] clear length=${String(this.length)}`);
    this.clearCount++;
  }
}

const buf = TracedSampleBuffer.create({ 'capacity': 3 });

// Fill the buffer (3 pushes, no overflow)
buf.push(10);
buf.push(20);
buf.push(30);

// Push past capacity — triggers overflow + eviction
buf.push(40); // evicts 10
buf.push(50); // evicts 20

// Compute a percentile (triggers computeStart + computeComplete + percentile hook)
const p50 = buf.percentile(50);

// Second call uses cache — no computeStart/computeComplete
const p50Cached = buf.percentile(50);

// Clear
buf.clear();

The base class never calls any logger or metrics library. All hooks are no-ops by default.

Source on GitHub