Replace ClickHouse with embedded DuckDB - #1
Closed
SrihariLegend wants to merge 5 commits into
Closed
SrihariLegend wants to merge 5 commits into
SrihariLegend wants to merge 5 commits into
Conversation
The analytics store (session telemetry, audit/security logs, webhooks, layer snapshots, session rollups) moves from ClickHouse to embedded DuckDB. No container, no HTTP hop, no Yandex lineage. - services/duckdb/: connection+query client (httpx-shaped QueryResult, legacy-syntax compat layer), Arrow batch inserts with INSERT OR REPLACE write-time dedup (replaces ReplacingMergeTree + FINAL), versioned SQL migrations, runtime init + resource pragmas - services/clickhouse/: deprecated shim re-exporting the DuckDB implementation; all existing callers work unchanged - duckdb/migrations/001_baseline.sql: consolidated final schema - retention cron now owns the former DDL TTLs (730d audit/security, 30d raw_line scrub); maintain job becomes CHECKPOINT - deploy: API_WORKERS=1, arq worker embedded in the API process (EMBEDDED_WORKER=true), ClickHouse service removed from compose - migration jobs: ClickHouse scopes rejected with a clear error (DuckDB archive export/import is follow-up) - tests: new live-engine suites (test_duckdb_analytics, test_duckdb_resource_tuning); 8232 root tests pass, server suite failure set identical to main (all pre-existing) - scripts/bench_duckdb_vs_clickhouse.py: reproducible benchmark Benchmark (1M session events + 500k audit rows, this machine): ingest: DuckDB 30.2k rows/s vs ClickHouse 19.9k rows/s (1.5x) queries: DuckDB p50 0.9-3.5ms vs CH p50 4.8-10.8ms (1.4x-10x) storage: CH 110MB vs DuckDB 347MB (CH 3.2x smaller, FSST vs ZSTD) Follow-ups: observal_cli installer, server-package compose, Grafana dashboards, DuckDB archive export/import for migration jobs.
…icy regressions
Three product bugs found in the ClickHouse→DuckDB port, none caught by the
test suite (it mocked _query or used the same broken translation):
- Array(String) placeholders returned empty results: legacy callers bind
stringified literals ("['s1','s2']") which DuckDB treated as one scalar
VARCHAR, so IN ($ids) silently matched nothing. The compat layer now
parses such literals back into real Python LIST params and rewrites
IN ($name) to = ANY($name) (NOT IN -> <> ALL). Fixes silently-empty
user profiles (tools/languages), version-impact "why" analysis, and
insight transcript fetches. Real-list params keep working too.
- alert_evaluator._query_token_usage: DuckDB sum() over an empty window is
NULL (ClickHouse returned 0), so float(None) aborted the query and
below-threshold token_usage alerts never fired on zero usage. The sum is
now wrapped in coalesce(..., 0).
- QueryResult.json() now emits the ClickHouse `rows` key alongside `data`,
so api/routes/admin/policy.py security-events `total` is no longer
always 0.
Also declare pytz: duckdb's Python client requires it to convert
TIMESTAMP WITH TIME ZONE values (bare now()/current_timestamp) back to
Python datetimes; without it every such query raised "Required module
'pytz' failed to import".
Verified end-to-end against a live DuckDB file (migrations → inserts →
refresh → user_profile/version_impact/session_meta_extractor queries →
token_usage alerts → json() rows → bare now()), plus 4 new regression
tests in tests/test_duckdb_analytics.py. Root suite: 8540 passed.
…pecs - Remove ClickHouse plugin and dependency from docker-compose.observability.yml - Fix syntax error in review diff screenshots E2E test payload - Fix doctor CLI command invocation in Kiro E2E test - Add .dirac-cache to .gitignore
…KDB_PATH - version_impact: guard avg_* float() conversions against NULL (DuckDB aggregates are NULL over NULL-only groups, e.g. all-rendered=0 sessions; ClickHouse returned 0) - compose: observal-init runs the DuckDB migrations and must share the API's DUCKDB_PATH explicitly - it has SKIP_DDL_ON_STARTUP=true, so a missing/mismatched path would boot the API against an unmigrated file
|
Owner
Author
|
Superseded by upstream PR: Observal#1706 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose / Description
Replace ClickHouse with embedded DuckDB as the analytics store (session telemetry, audit/security logs, webhook deliveries, layer snapshots, session rollups). Motivation: remove the ClickHouse dependency (Yandex lineage), shrink the deployment profile (one less container, no 2GB RAM budget, no extra image), and improve query latency for the dashboard/trace workloads.
DuckDB is embedded in the API process: no server, no HTTP hop, no credentials. Backup becomes "copy one file".
Fixes
Approach
observal-server/services/duckdb/— the real implementation:client.py: process-wide connection (RLock +asyncio.to_thread),_query()returning an httpx-shapedQueryResult, and a compatibility layer that auto-translates legacy ClickHouse syntax ({name:Type}placeholders,Array(...)params,FINAL,FORMAT JSON, trailingSETTINGS,count(),now64(3),toUInt64,INTERVAL {n:Type} UNIT). Unused named params are dropped (DuckDB rejects them; legacy callers pass them).insert.py: batch inserts via Arrow relations — DuckDB'sexecutemanyand row-at-a-timeINSERT OR REPLACEagainst ART primary keys are pathologically slow (~200–2k rows/s measured); the Arrow scan + set merge path sustains ~30k rows/s.ReplacingMergeTreesemantics preserved via PRIMARY KEY +INSERT OR REPLACE(write-time dedup — noFINALanywhere).migrations.py,schema.py: versioned SQL runner (duckdb/migrations/001_baseline.sqlconsolidates legacy CH migrations 001–004), runtime init, resource pragmas (memory_limit,threads).services/clickhouse/— deprecated shim re-exporting the DuckDB implementation under legacy names; all ~31 callers work unchanged.raw_linescrub).API_WORKERS=1and the arq worker runs embedded in the API process (EMBEDDED_WORKER=true, default); the separate worker service and the ClickHouse service are removed from compose.docs/duckdb-analytics.md): observal_cli installer, server-package compose, Grafana dashboards.How Has This Been Tested?
tests/test_duckdb_analytics.py(19 tests: migrations idempotency, write-time dedup, checkpoint rewind, rollup correctness, retention TTLs, concurrency, compat layer) andtests/test_duckdb_resource_tuning.py(11 tests, live pragma verification). Root suite: 8,235 passed, 0 failed; server-suite failure set byte-identical tomain(12 pre-existing failures).skipped: 5, still 1 session) → live audit trail → embedded arq cron firing. One real integration bug found and fixed (arq.create_workerimport + signal handling)./telemetry/hooksendpoint, missing fixture users, nonexistent DELETE routes), zero failures attributable to the port.scripts/bench_duckdb_vs_clickhouse.py, reproducible): 1M session events + 500k audit rows, vs a real ClickHouse 26.6 container:To reproduce:
docker run -p 18123:8123 -e CLICKHOUSE_PASSWORD=clickhouse clickhouse/clickhouse-server:26.6, then fromobserval-server/:DUCKDB_PATH=/tmp/bench.duckdb python ../scripts/bench_duckdb_vs_clickhouse.py --sessions 2000 --events 500 --audit-rows 500000.Screenshots (live stack on this branch, real ingested session — a Pi coding session with 1.2M input tokens / 361 tool calls):
Executive dashboard off DuckDB rollups:

Traces list with populated token/tool columns:

Trace detail — session replay with dedup ("953 events, deduped from 954"):

Audit log writing to and reading from DuckDB:

Learning (optional, can help others)
executemanyis a per-row prepared-statement loop (~2k rows/s) and row-at-a-timeINSERT OR REPLACEon ART-indexed tables is ~200 rows/s; staging viapyarrowrelations + one set-merge is the fast path (30k+ rows/s).FINAL(merge-on-read forReplacingMergeTree) is replaced by PRIMARY KEY + write-time dedup; readers get exact ART point-lookups instead of bloom-filter-assisted granule scans.Arrayparams differently than ClickHouse HTTP; the compat layer handles both.EMBEDDED_WORKER).Checklist
Licenses
AI Assistance
Was generative AI tooling used to co-author this PR?
Discord username (optional)
Discord username: