Skip to content

o11ylogsdb: in-memory engine design + 27 experiments validating 20× target#180

Merged
strawgate merged 8 commits into
mainfrom
o11ylogsdb-design
Apr 26, 2026
Merged

o11ylogsdb: in-memory engine design + 27 experiments validating 20× target#180
strawgate merged 8 commits into
mainfrom
o11ylogsdb-design

Conversation

@strawgate
Copy link
Copy Markdown
Owner

@strawgate strawgate commented Apr 26, 2026

Summary

Adds packages/o11ylogsdb — a browser-native (also Node, Bun, Edge) logs database for OpenTelemetry, sister to o11ytsdb. Goal: 20× storage efficiency vs raw OTLP/JSON.

  • Engine: chunk format v1 with pluggable ChunkPolicy, per-(resource, scope) stream registry, baseline + columnar + Drain + typed-columnar policies, streaming query executor with header-only chunk pruning.
  • M2 prototype: in-house Drain Rust port at packages/o11ylogsdb/rust-prototype/drain/. 6.7 KB gz, 0.9–3.3 M logs/s native, ARI = 1.0 vs the published Python reference on five public log corpora. TS port at src/drain.ts is bit-identical.
  • M4 first cut: ColumnarDrainPolicy + TypedColumnarDrainPolicy with generic byte-shape detectors (SIGNED_INT, UUID, UUID_NODASH, PREFIXED_INT64, PREFIXED_UUID, TIMESTAMP_DELTA). Engine doesn't assume the log format.
  • Bench harness: 26 reproducible bench modules under bench/, public Loghub-2k corpora + a synthetic structured-JSON corpus checked in.
  • Dev-docs: dev-docs/findings.md, dev-docs/techniques.md, dev-docs/drain-prototype.md capture the validated/refuted assumptions and the codec/index/query stack we ship. No vendor coupling — algorithms are described by their published names; product names of comparator systems are not used.

PR is no longer a draft. Milestones M0/M1/M5/M6/M7 are partially started or pending — see README for the status table. Ship is the design foundation: engine round-trips, query executor works against zone maps, M2 prototype is validation-complete.

Key files

Review-pass fixes

  • LogStore.stats() now uses a cheap chunkWireSize() helper instead of materializing each chunk's full wire buffer (bot-flagged perf bottleneck).
  • package-lock.json regenerated so npm ci picks up the new package.
  • Dangling "Experiment X" labels stripped from src/, bench/, PLAN.md.

Test plan

  • npm run typecheck:packages — passes
  • npm run build:packages — passes
  • npx vitest run --coverage — 32 files, 479 tests passed (sibling packages; o11ylogsdb has no unit tests yet — design-validated via bench)
  • npm run check:release — exit 0
  • npx biome check packages/o11ylogsdb — 0 warnings

🤖 Generated with Claude Code

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 26, 2026

Caution

Review failed

Pull request was closed or merged during review

Walkthrough

This pull request introduces o11ylogsdb, a new OpenTelemetry logs database package for browser and Node.js environments. The addition includes a complete TypeScript implementation with codec plugins (gzip, ZSTD), templated compression via Drain, columnar and typed-columnar encoding policies, chunk serialization, streaming queries, and a Rust prototype of the Drain algorithm compiled to WebAssembly. The package includes extensive benchmarks across compression strategies, ingest performance, query latency, memory usage, and specialized indexing techniques. Documentation covers architecture, techniques, findings, and execution milestones. Supporting changes update workspace configuration files to include the new package in build scripts and ignore benchmark output directories.

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Comment on lines +146 to +160
stats(): StoreStats {
let chunks = 0;
let totalLogs = 0;
let totalBytes = 0;
for (const id of this.streams.ids()) {
const chunkList = this.streams.chunksOf(id);
chunks += chunkList.length;
for (const c of chunkList) {
totalLogs += c.header.nLogs;
totalBytes += serializeChunk(c).length;
}
}
return {
streams: this.streams.size(),
chunks,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Performance: stats() re-serializes every chunk on each call

The stats() method calls serializeChunk(c).length inside a loop over all chunks, performing a full wire-format serialization (header encoding + buffer allocation) just to measure byte count. Since ChunkHeader already carries payloadBytes, the total serialized size can be computed from header metadata without re-serializing. This is called from benchmarks (e.g., engine-roundtrip.bench.ts) and will become a bottleneck as chunk count grows.

Suggested fix:

Cache the serialized byte length when a chunk is frozen (e.g., store it alongside the chunk in the stream registry), or compute it from `chunk.header.payloadBytes` plus deterministic wire overhead, avoiding the re-serialization entirely:

// In stats():
for (const c of chunkList) {
  totalLogs += c.header.nLogs;
  totalBytes += c.header.payloadBytes + WIRE_HEADER_OVERHEAD;
}

Was this helpful? React with 👍 / 👎 | Reply gitar fix to apply this suggestion

Comment thread research/experiments/engine-typed/results.md Outdated
strawgate and others added 7 commits April 26, 2026 16:56
Browser-native logs database for OpenTelemetry data. Targets 20×
storage efficiency vs raw OTLP/JSON via a Drain + FSST + ALP +
Roaring-lite + binary fuse codec stack with a streaming query
executor.

Adds:
- Package metadata (package.json, tsconfig.json, .gitignore).
- PLAN.md with priority stack, milestones (M0-M8), per-corpus
  storage targets, benchmark gates, and what we don't build.
- README.md with milestone status table and a reading-order index.
- dev-docs/: findings (validated and refuted assumptions with
  per-corpus B/log numbers and lossless ceilings), techniques (the
  shipped codec/index/query stack), drain-prototype (the M2 Rust
  port).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- types.ts: OTLP-aligned types (LogRecord, AnyValue, Resource,
  InstrumentationScope, KeyValue, BodyKind, StreamId).
- chunk.ts: chunk format v1 (magic OLDB + JSON header + payload),
  ChunkPolicy plug-in surface with preEncode/postDecode and
  encodePayload/decodePayload hooks, severity zone-map computed at
  chunk-close, ChunkBuilder, serializeChunk/deserializeChunk/readRecords.
- stream.ts: per-(resource, scope) stream registry with WeakMap
  reference-identity fast path and ordered chunk lists.
- classify.ts: BodyClassifier and TemplateExtractor interfaces
  plus a default classifier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- codec.ts: pluggable Codec / StringCodec / IntCodec interfaces and
  CodecRegistry.
- codec-baseline.ts: rawCodec, GzipCodec, ZstdCodec,
  lengthPrefixStringCodec, rawInt64Codec, defaultRegistry.
- codec-columnar.ts: ColumnarRawPolicy and ColumnarDrainPolicy — the
  M4 binary columnar payload (delta-zigzag-varint timestamps, u8
  severity/body-kind runs, embedded template dict, length-prefixed
  binary body). Layout dominates compression; Drain refines.
- codec-drain.ts: DrainChunkPolicy — NDJSON-form Drain (kept as a
  reference; structure primitive only — not a compression win on
  NDJSON, refuted as a hot-path codec).
- codec-typed.ts: TypedColumnarDrainPolicy — M4 per-(template, slot)
  value-distribution-aware codec dispatch. Generic detectors
  (SIGNED_INT, UUID, UUID_NODASH, PREFIXED_INT64, PREFIXED_UUID,
  TIMESTAMP_DELTA) recognize byte shapes, never specific literals.
- drain.ts: TS Drain port. Bit-identical to the Rust prototype and
  to the published Python reference (ARI = 1.0 on five public log
  corpora).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- engine.ts: LogStore composes the chunk format, codec registry,
  body classifier, stream registry, and chunk-policy plug-in
  surface. Optional per-stream policyFactory for stateful policies.
- query.ts: streaming query executor (M7 first cut). Chunk-level
  pruning by zone map (time range, severity range) and stream
  pruning by resource attributes — only chunks that survive get
  decoded. QuerySpec covers time range, severity threshold, body
  substring, KVList leaf equality, resource attribute equality,
  and limit. Predicate set kept minimal but useful.
- compact.ts: re-encodes a chunk's payload to a different codec —
  the hot-ingest @ z3 → background @ z19 promotion path.
- index.ts: public API re-exports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bench/ holds the reproducible bench harness (harness.ts,
profile-harness.ts, run.mjs) and 26 modules that drive the merge
gate:

- bytes-per-log: top-line gate; per-corpus B/log under every
  baseline codec.
- engine-roundtrip / engine-drain / engine-columnar / engine-typed:
  M3-M4 thesis tests at successive policy levels.
- pino-roundtrip / pino-query: structured-JSON corpus paths.
- per-stream-chunking / per-stream-drain / multi-stream: stream-
  registry behavior and chunk-list ordering.
- hierarchical-drain / cross-chunk-dict / per-column-zstd /
  zstd-level-asymmetry: refutation/validation runs that informed
  the "what we ship" decisions in dev-docs/findings.md.
- ngram-bloom / token-postings: chunk-scope index economics
  (refuted at chunk scope; revisit at stream scope).
- query-latency / query-at-scale: M7 query-engine measurements.
- sustained-ingest / append-latency / drain-churn: ingest-side
  GC pause and tree-growth profiles.
- compaction / lossy-archive / byte-decomposition / typed-int-column:
  per-column codec-dispatch diagnostics.
- profile-policies / cpuprofile-typed.mjs / cpuprofile-query.mjs:
  CPU profiles for hot-path analysis.

Public Loghub-2k samples and a synthetic structured-JSON corpus
checked in under bench/corpora/. Loghub-2k is the canonical academic
sample with golden ground-truth labels for grouping-accuracy
validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In-house Rust port of the published Drain log template extractor
(He et al., ICWS 2017). Targets wasm32-unknown-unknown:

- No std, no FFI dependencies. The available Rust crate of the
  algorithm links a C regex library that doesn't build for wasm32
  without a sysroot; this 200-LoC port has zero deps.
- Bump-leak allocator on wasm32 backed by a 16 MB static arena.
  Host re-creates the parser per chunk; arena resets by reloading
  the module.
- Minimal extern "C" ABI — drain_create / ingest_line /
  template_count / get_template / destroy. The host owns line
  bytes and template-output buffers.
- 6.7 KB gz, 0.9-3.3 M logs/s native, ARI = 1.0 vs the published
  Python reference on five public log corpora. Cross-validates
  bit-for-bit with the TS port at src/drain.ts.

M2 graduation work — moving the crate into
packages/o11y-codec-rt/drain/, adding a configurable masker, and
adding persistable state — pending; see
dev-docs/drain-prototype.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add packages/o11ylogsdb to root build:packages and typecheck:packages.
- Ignore packages/*/dist-bench/ in gitignore (compiled bench output).
- Biome: ignore **/dist-bench; disable noNonNullAssertion on the
  o11ylogsdb hot-path codec sources and bench harness (intentional
  array[i]! in tight decode loops).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- engine.ts stats(): use new chunkWireSize() helper instead of
  serializeChunk(c).length. Avoids materializing the full wire
  buffer just to count bytes; only the JSON header is encoded.
  Fixes a bot-flagged bottleneck that grew with chunk count.
- chunk.ts: add chunkWireSize() — header-JSON length + payload
  length + 9-byte preamble.
- package-lock.json: regenerate so `npm ci` picks up o11ylogsdb.
  The CI validate jobs were failing on missing lockfile entry.
- Strip dangling "Experiment X" labels from src/, bench/, PLAN.md,
  and the corpus generator. The experiment write-ups they pointed
  to were removed when the research dump became dev-docs/. The
  surrounding text already explains the why.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@strawgate strawgate marked this pull request as ready for review April 26, 2026 22:08
@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines, ignoring generated files. enhancement New feature or request experiment Exploratory experiment performance Performance optimization labels Apr 26, 2026
Comment on lines +146 to +157
stats(): StoreStats {
let chunks = 0;
let totalLogs = 0;
let totalBytes = 0;
for (const id of this.streams.ids()) {
const chunkList = this.streams.chunksOf(id);
chunks += chunkList.length;
for (const c of chunkList) {
totalLogs += c.header.nLogs;
totalBytes += chunkWireSize(c);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Performance: stats() calls JSON.stringify on every chunk header each invocation

The previous review flagged that stats() re-serializes every chunk. The commit message says "stats() perf" was fixed, but the current code still calls chunkWireSize(c) inside a nested loop, which per chunk.ts:211-214 runs JSON.stringify on each chunk's header on every call. For a store with thousands of chunks, this is O(n) stringification work on a read-only summary method.

Consider caching wireSize on the Chunk object at freeze time (it's immutable after that), or maintaining a running total in appendChunk()/flush() so stats() is O(1).

Suggested fix:

// In engine.ts, maintain running totals:
private _totalChunkBytes = 0;
private _totalLogs = 0;
private _chunkCount = 0;

// In appendChunk calls (flush + append):
this._totalChunkBytes += chunkWireSize(chunk);
this._totalLogs += chunk.header.nLogs;
this._chunkCount++;

// stats() becomes O(1):
stats(): StoreStats {
  return { streams: this.streams.size(), chunks: this._chunkCount,
    totalLogs: this._totalLogs, totalChunkBytes: this._totalChunkBytes,
    bytesPerLog: this._totalLogs > 0 ? this._totalChunkBytes / this._totalLogs : 0 };
}

Was this helpful? React with 👍 / 👎 | Reply gitar fix to apply this suggestion

Comment on lines +172 to +179
*iterRecords(): Generator<{ streamId: StreamId; records: LogRecord[] }> {
for (const id of this.streams.ids()) {
const policy = this.policyFor(id);
for (const chunk of this.streams.chunksOf(id)) {
yield { streamId: id, records: readRecords(chunk, this.registry, policy) };
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: iterRecords() silently drops unflushed in-flight records

iterRecords() only yields records from frozen chunks in the stream registry. Any records appended but not yet flushed (i.e. the current in-flight ChunkBuilder) are silently omitted. The bench code in engine-columnar.bench.ts:117 correctly calls store.flush() before iterating, but the engine API doesn't document or enforce this — a caller using iterRecords() without flush() will get incomplete data with no warning.

Consider either auto-flushing at the start of iterRecords(), or adding a JSDoc note that flush() must be called first.

Suggested fix:

// Option A: auto-flush
*iterRecords(): Generator<{ streamId: StreamId; records: LogRecord[] }> {
  this.flush();
  for (const id of this.streams.ids()) {
    // ...
  }
}

Was this helpful? React with 👍 / 👎 | Reply gitar fix to apply this suggestion

@gitar-bot
Copy link
Copy Markdown

gitar-bot Bot commented Apr 26, 2026

Note

Your trial team has used its Gitar budget, so automatic reviews are paused. Upgrade now to unlock full capacity. Comment "Gitar review" to trigger a review manually.
Learn more about usage limits

Code Review ⚠️ Changes requested 1 resolved / 4 findings

Implements an in-memory engine with high-performance experimental validation, but requires addressing excessive serialization overhead in stats() and potential data loss in iterRecords(). Resolved inconsistencies between the TL;DR and results tables in experiment V.

⚠️ Performance: stats() re-serializes every chunk on each call

📄 packages/o11ylogsdb/src/engine.ts:146-160

The stats() method calls serializeChunk(c).length inside a loop over all chunks, performing a full wire-format serialization (header encoding + buffer allocation) just to measure byte count. Since ChunkHeader already carries payloadBytes, the total serialized size can be computed from header metadata without re-serializing. This is called from benchmarks (e.g., engine-roundtrip.bench.ts) and will become a bottleneck as chunk count grows.

Suggested fix
Cache the serialized byte length when a chunk is frozen (e.g., store it alongside the chunk in the stream registry), or compute it from `chunk.header.payloadBytes` plus deterministic wire overhead, avoiding the re-serialization entirely:

// In stats():
for (const c of chunkList) {
  totalLogs += c.header.nLogs;
  totalBytes += c.header.payloadBytes + WIRE_HEADER_OVERHEAD;
}
⚠️ Performance: stats() calls JSON.stringify on every chunk header each invocation

📄 packages/o11ylogsdb/src/engine.ts:146-157

The previous review flagged that stats() re-serializes every chunk. The commit message says "stats() perf" was fixed, but the current code still calls chunkWireSize(c) inside a nested loop, which per chunk.ts:211-214 runs JSON.stringify on each chunk's header on every call. For a store with thousands of chunks, this is O(n) stringification work on a read-only summary method.

Consider caching wireSize on the Chunk object at freeze time (it's immutable after that), or maintaining a running total in appendChunk()/flush() so stats() is O(1).

Suggested fix
// In engine.ts, maintain running totals:
private _totalChunkBytes = 0;
private _totalLogs = 0;
private _chunkCount = 0;

// In appendChunk calls (flush + append):
this._totalChunkBytes += chunkWireSize(chunk);
this._totalLogs += chunk.header.nLogs;
this._chunkCount++;

// stats() becomes O(1):
stats(): StoreStats {
  return { streams: this.streams.size(), chunks: this._chunkCount,
    totalLogs: this._totalLogs, totalChunkBytes: this._totalChunkBytes,
    bytesPerLog: this._totalLogs > 0 ? this._totalChunkBytes / this._totalLogs : 0 };
}
💡 Edge Case: iterRecords() silently drops unflushed in-flight records

📄 packages/o11ylogsdb/src/engine.ts:172-179

iterRecords() only yields records from frozen chunks in the stream registry. Any records appended but not yet flushed (i.e. the current in-flight ChunkBuilder) are silently omitted. The bench code in engine-columnar.bench.ts:117 correctly calls store.flush() before iterating, but the engine API doesn't document or enforce this — a caller using iterRecords() without flush() will get incomplete data with no warning.

Consider either auto-flushing at the start of iterRecords(), or adding a JSDoc note that flush() must be called first.

Suggested fix
// Option A: auto-flush
*iterRecords(): Generator<{ streamId: StreamId; records: LogRecord[] }> {
  this.flush();
  for (const id of this.streams.ids()) {
    // ...
  }
}
✅ 1 resolved
Bug: TL;DR and Results tables contradict each other in Exp V

📄 research/experiments/engine-typed/results.md:22-31 📄 research/experiments/engine-typed/results.md:111-120
The TL;DR table (lines 22-29) reports BGL at 20.81 B/log (-6.7%) and OpenStack at 19.51 B/log (-6.7%), with a mean of -4.1%. However, the actual Results table (lines 111-119) shows BGL at 22.43 B/log (+0.6%) and OpenStack at 21.01 B/log (+0.4%), with a mean of -1.5%. These are significantly different numbers in the same document. It appears the TL;DR describes a later implementation iteration (with UUID and BGL_TS slot types active) while the Results table reflects the measured baseline. This makes the experiment's conclusions unreliable for readers trying to reproduce or build on these results.

🤖 Prompt for agents
Code Review: Implements an in-memory engine with high-performance experimental validation, but requires addressing excessive serialization overhead in stats() and potential data loss in iterRecords(). Resolved inconsistencies between the TL;DR and results tables in experiment V.

1. ⚠️ Performance: stats() re-serializes every chunk on each call
   Files: packages/o11ylogsdb/src/engine.ts:146-160

   The `stats()` method calls `serializeChunk(c).length` inside a loop over all chunks, performing a full wire-format serialization (header encoding + buffer allocation) just to measure byte count. Since `ChunkHeader` already carries `payloadBytes`, the total serialized size can be computed from header metadata without re-serializing. This is called from benchmarks (e.g., `engine-roundtrip.bench.ts`) and will become a bottleneck as chunk count grows.

   Suggested fix:
   Cache the serialized byte length when a chunk is frozen (e.g., store it alongside the chunk in the stream registry), or compute it from `chunk.header.payloadBytes` plus deterministic wire overhead, avoiding the re-serialization entirely:
   
   // In stats():
   for (const c of chunkList) {
     totalLogs += c.header.nLogs;
     totalBytes += c.header.payloadBytes + WIRE_HEADER_OVERHEAD;
   }

2. ⚠️ Performance: stats() calls JSON.stringify on every chunk header each invocation
   Files: packages/o11ylogsdb/src/engine.ts:146-157

   The previous review flagged that `stats()` re-serializes every chunk. The commit message says "stats() perf" was fixed, but the current code still calls `chunkWireSize(c)` inside a nested loop, which per `chunk.ts:211-214` runs `JSON.stringify` on each chunk's header on every call. For a store with thousands of chunks, this is O(n) stringification work on a read-only summary method.
   
   Consider caching `wireSize` on the `Chunk` object at freeze time (it's immutable after that), or maintaining a running total in `appendChunk()`/`flush()` so `stats()` is O(1).

   Suggested fix:
   // In engine.ts, maintain running totals:
   private _totalChunkBytes = 0;
   private _totalLogs = 0;
   private _chunkCount = 0;
   
   // In appendChunk calls (flush + append):
   this._totalChunkBytes += chunkWireSize(chunk);
   this._totalLogs += chunk.header.nLogs;
   this._chunkCount++;
   
   // stats() becomes O(1):
   stats(): StoreStats {
     return { streams: this.streams.size(), chunks: this._chunkCount,
       totalLogs: this._totalLogs, totalChunkBytes: this._totalChunkBytes,
       bytesPerLog: this._totalLogs > 0 ? this._totalChunkBytes / this._totalLogs : 0 };
   }

3. 💡 Edge Case: iterRecords() silently drops unflushed in-flight records
   Files: packages/o11ylogsdb/src/engine.ts:172-179

   `iterRecords()` only yields records from frozen chunks in the stream registry. Any records appended but not yet flushed (i.e. the current in-flight `ChunkBuilder`) are silently omitted. The bench code in `engine-columnar.bench.ts:117` correctly calls `store.flush()` before iterating, but the engine API doesn't document or enforce this — a caller using `iterRecords()` without `flush()` will get incomplete data with no warning.
   
   Consider either auto-flushing at the start of `iterRecords()`, or adding a JSDoc note that `flush()` must be called first.

   Suggested fix:
   // Option A: auto-flush
   *iterRecords(): Generator<{ streamId: StreamId; records: LogRecord[] }> {
     this.flush();
     for (const id of this.streams.ids()) {
       // ...
     }
   }

Options

Display: compact → Showing less information.

Comment with these commands to change:

Compact
gitar display:verbose         

Important

Your trial ends in 6 days — upgrade now to keep code review, CI analysis, auto-apply, custom automations, and more.

Was this helpful? React with 👍 / 👎 | Gitar

@strawgate strawgate merged commit 470fc3e into main Apr 26, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request experiment Exploratory experiment performance Performance optimization size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant