Based on 00/01/poc-results. This document is the 02 implementation spec; new sessions implement directly from it.
The write path is implemented and flipped to be the primary path (worker reads the full history from raw_events and rebuilds, skipping the CH baseline). ClickHouse writes are kept for now for read-path compatibility (04 not yet done).
- ✅ SDK
@greptime/ingesterextended withDecimal128(cost DECIMAL(38,12), lossless precision); openfuse library with 10 tables; connection layergreptime/client.ts;rawEvents(write + full-history read) /converters(dedup + round-trip) /schemaUtils/deletion;GreptimeWriter(projection + EAV fan-out). - ✅ Flip:
processEventBatchraw_events fail-closed;ingestionQueuereads raw_events instead of S3;IngestionServicerebuildFromHistory(skips CH baseline) + deterministic sort (invariant 8); BullMQ payload addsentityType/batchId; Redis seen-cache at batch level. - ✅ Verification: 14 module smoke checks + 3 flip e2e smoke checks (out-of-order input still correct) all pass; converters unit tests + SDK Decimal128 unit test.
- Faithful replication of original Langfuse semantics (intentional, not a gap):
- raw_events is a fail-closed, post-sampling SoT — it is written per entity only after the sampling check accepts that entity, just before enqueue. Because the worker replays the full raw_events history, the SoT must contain exactly the set of events the system intends to process; writing sampled-out events would let a later reprocess/replay surface data the pipeline deliberately dropped. (This differs from the original S3 event store, which is written pre-sampling — but S3 was only ever re-read for enqueued entities, whereas full-history replay reads everything.)
- Deletion: raw_events uses
append_mode+ TTL to control the retention period (event-store standard); no row-level hard-delete. The original S3 event store also relied on lifecycle expiration. Deletion compliance is satisfied jointly bydeletion.tsover projection + EAV (which can DELETE) and raw payload TTL expiration. - No S3-era rolling fallback: this fork removed the worker S3 fallback after switching standard ingestion and eval-generated scores to
raw_events. Drain old S3-era queues before deploying this branch; new jobs are ref-only and must find their payload inraw_events.
- ✅ Delete resurrection (tombstone, implemented): when
deletion.tsdeletes an entity, it appends a tombstone event to raw_events + deletes the projection/EAV.parseRawEventHistorydetects the tombstone (with no live event after it →deleted=true, supports re-create).IngestionService.mergeAndWrite(...,deleted)→ final recordis_deleted=1. Replay only rebuilds the soft-deleted projection (queries filter withWHERE is_deleted=false), it does not resurrect. Project-level deletion is not tombstoned (entity ids cannot be enumerated); it relies on "deleting a project means no further reprocess" — a bulk reprocess-all over an already-deleted project would still resurrect, leaving a project-level deleted-set as a follow-up. ⚠️ Genuine follow-ups:- projection + EAV are non-atomic:
GreptimeWriterdoes a single combinedclient.write([...tables])(projection + EAV share gRPC fate), eliminating the common network split; GreptimeDB has no cross-table transactions, and a residual server partial is backstopped by a drop metric + idempotent rebuild. A reconciliation job can still be added. - The "Implementation Breakdown" below is the original plan intent; as-built deviations are in this status section.
- Object storage is optional: standard ingestion and eval-generated scores no longer write S3 event blobs. OTel and eval observation blobs use the event storage backend (
localby default in Compose,s3optional). - ClickHouse removed: the ClickHouse client and dual-write were removed after the read path (04) and the cutover (P7) landed; GreptimeDB is the sole store.
@greptime/ingesteris a published npm dependency (^0.2.1).
- projection + EAV are non-atomic:
Hard fork, storage ClickHouse → GreptimeDB, option 2 (GreptimeDB carries the SoT + projection, drops the S3 event store). This document = the ingestion write path.
Core decisions (settled):
- App-side merge, reusing existing functions (not changed to pure append): tags = union / metadata = deep-merge, reusing
overwriteObject/mergeRecords. - Full-history rebuild (does not read the projection baseline): the worker reads all events for the entity from raw_events, sorts deterministically, and rebuilds the full snapshot from
{}via anoverwriteObjectchain, writing it to the projection. The write path does not read the projection; the projection degrades to a pure projection, only the read path (04) reads it. Out-of-order naturally disappears. - Worker direct write (gRPC ingester); pipeline not used in 02 (kept for OTel / pure-conversion helpers).
- The projection table keeps metadata (JSON) / tags (JSON) columns (single-table read for the read path) + EAV sub-tables (filtering).
API → raw_events(append, SoT, direct gRPC write) → BullMQ(entity ref)
Worker: read ALL raw_events WHERE (project_id, entity_type, entity_id)
→ dedup by event_id → deterministic sort → enrich(Postgres) → merge from scratch(overwriteObject, baseline={})
→ write full snapshot → projection + EAV(*_metadata last_non_null / *_tags append)
Read path (04) reads the projection
- Out-of-order: full-history rebuild, does not depend on arrival order, no need for field-level version guards.
- time_index uniqueness: the projection merges on
(pk=(project_id,id), time_index). On write, time_index must use the merged immutable logical time (trace.timestamp / observation.start_time / score.timestamp), guaranteeing it is constant for the same entity → one row, making readLIMIT 1reliable. Multiple time_index for the same pk → alert + reprocess. Projectionmerge_mode='last_non_null'. - metadata EAV = merge table:
merge_mode='last_non_null', PK(project_id,entity_id,key), write the full merged metadata → value update overwrites correctly. Must not append (stale hit).*_tags= append (union only grows). - dedup: raw_events append; the worker reads and handles duplicates by
event_id; the Redis seen-cache stays at job/batch level (using batchId), not degraded to event level. - cost =
DECIMAL(38,12): not degraded to Float64. First verify the ingester supports decimal; if not, explicitly note the workaround. - [codex v2] raw_events retention >= projection retention: full rebuild depends on complete history. If a create expires due to TTL → an incomplete rebuild loses immutable/metadata/tags. TTL must not be used as a cost-control mechanism (unless a checkpoint/snapshot is introduced, later).
- [codex v2] created_at =
min(raw_events.ingested_at)(ingestion time), not the logical event timestamp (otherwise historical SDK timestamps pollute it); updated_at = now; event_ts = now. Produce a field-semantics table. - [codex v2] deterministic replay sort:
event_ts ASC+create-before-update(type priority) +ingested_at+event_id. ExtendtoTimeSortedEventList(IngestionService/index.ts:1006). All helpers that depend on the "last event" (e.g. observation prompt lookup, index.ts:767) consume the same sorted list. - [codex v2] duplicate event_id policy: choose one of first-ingested / last-ingested / mismatch-alert (affects idempotency), defined explicitly.
- Connection layer — new
packages/shared/src/server/greptime/client.ts(modeled on clickhouse/client.ts): write = singleton@greptime/ingester(gRPC, conservative retry); read =mysql2pool (MySQL wire, for full-history reads + 04). env (env.ts×2):GREPTIME_GRPC_URL/SQL_HOST/PORT/SQL_READ_ONLY_HOST/DB/USER/PASSWORD/SQL_MAX_OPEN_CONNECTIONS/RAW_EVENTS_TABLE. CH env kept during transition. health check. - raw_events write — new
greptime/rawEvents.tswriteRawEvents()(ingester direct write, SoT verbatim, one event one row); changeprocessEventBatch.tsS3 upload → writeRawEvents, fail-closed, BullMQ payload drops fileKey and carries{type,entityType,eventBodyId,batchId}. - Worker consume + reprocess —
ingestionQueue.ts: delete S3 LIST/download, change toreadRawEventsForEntity()(full history, deterministic sort, event_id dedup); Redis seen at job/batch level; delete blob_storage_file_log write + S3-slowdown;otelIngestionQueue.tslikewise. reprocess = same path, worker-driven. - merge rebuild —
IngestionService/index.ts: deletegetClickhouseRecord; changerecordsToMergefrom[chRecord,...new]to[...allHistory]; reuseoverwriteObject/mergeRecords/immutableEntityKeys/toTimeSortedEventList(extended tie-break); enrich/cost/tokenization unchanged; delete ClickhouseReadSkipCache. - GreptimeWriter — new
worker/src/services/GreptimeWriter/index.ts(port of ClickhouseWriter): singleton + batch queue + flush + requeue + split;TableName= Traces/Observations/Scores (DatasetRunItems moved out of 02);writeToGreptime(gRPC): full projection row (ms, metadata/tags/cost/usage → Json, cost → DECIMAL(38,12), reserved-word quoting, time_index = immutable); EAV fan-out (metadata →*_metadatalast_non_null PK includes key; tags →*_tagsappend).quoteIdent()+GREPTIME_RESERVED=["id","name","value","key","type","level","timestamp"]. - converters / deletion / retention — new
greptime/converters.ts(raw event_body → record);ingestionFileDeletion.ts→ DELETE raw_events/projection/EAV; retention = GreptimeDB TTL (respecting invariant 6). - Files: new
greptime/{client,rawEvents,schemaUtils,converters}.ts+GreptimeWriter; change processEventBatch/ingestionQueue/otelIngestionQueue/IngestionService/definitions/ingestionFileDeletion/env×2/health endpoint; leave untouched utils.ts / merge functions / immutableEntityKeys / enrich / BullMQ / Zod schema.
- R1 (core) raw_events history-scan latency: every event triggers a full-history read + rebuild for that entity. Per-entity event counts are usually limited; need to benchmark long traces / high-frequency updates. Cannot truncate history via TTL to control cost (invariant 6).
- R2 ingester v0.1.0 (verify unary write error classification, Json/DECIMAL round-trip).
- R3 time_index uniqueness violation (invariant 2).
- R6 raw_events JSON size (keep split/truncate).
dual-write (CH + Greptime reconciliation) → switch reads → delete CH. Before switching production ingestion, dataset_run_item support must be in place first (not included in 02).
- seed schema (raw_events + projection [with metadata/tags JSON] + EAV [metadata last_non_null PK includes key, tags append]) via
execute_sql/describe_table. - ingest trace-create + generation-create/update + score → raw_events count matches.
- worker →
SELECT * FROM traces WHERE id=?single merged row (name from create, metadata deep-merge, tags union); traces_metadata one row per key. - Out-of-order: an old event_ts arriving late → correct state. 5. metadata value update: env prod→staging, only staging remains. 6. EAV
explain_queryuses the index. 7. R1: concurrent + long-history p50/95/99. 8. reprocess: delete projection → rebuild consistent. 9. Idempotency: replay produces no duplicates. 10. Tests: adapt IngestionService/processEventBatch tests; merge unit tests pass as-is.
GreptimeDB local localhost:4002 (mysql, no password), MCP mcp__greptimedb__* read-only (use mysql -h127.0.0.1 -P4002 public or the ingester for table creation/writes). TS SDK /Users/dennis/programming/javascript/greptimedb-ingester-ts (@greptime/ingester v0.1.0).