Ukiel is a lake. Here, a data lake. In real world it's a lake in Olsztyn, Poland.
Experimental. Not production ready. Testing ideas from my head.
I want an infinitely horizontally scalable, distributed, S3 based, compute-storage separated system.
The experiences at the moment:
- My 1.5 year of running PB+ scale ClickHouse at PostHog
- My 3 years of building Oxla - a distributed, S3 based, compute-storage separated database.
ClickHouse OSS at some scale is causing lot of troubles that are not easily fixable, the company iself is going in a direction that could be hard for us.
Why not Iceberg? It's overcomplicated and there are problems I'm not gonna solve (e.g. S3 Iceberg table does not support planScan). DeltaLake is IMHO more prod ready than Iceberg, but I perceive both as an interop solution. It seems to me that the way those work is: each query engine uses the catalog to exchange the data but builds all the mechanics above it. Both could be added as external sync later.
But all in all, i just want something to experiment quickly without a fuss. I don't really expect this to go production soon, but i'd like to run it in prod to test query performance.
- Using Apache DataFusion as query engine.
- Use Parquet as on disk data format.
- When inserting data, create files using defined table schema but optimize the Parquet files.
- Create a custom catalog to manage files and support efficient paritioning
- what we want to achieve is either multitenancy at file managment level or scalable table-tenancy (meaning having table customized per tenant)
- Catalog must support MV -> events on new data parts
- Replace on catalog files
- Q: how to handle deletes? as the database would be multitenant, we could have a worker rewriting the data, that's all.
- conflicts between: mutations and merges
- Ukiel makes multitenancy a file-management problem. Millions of logical tenant tables can share thousands of physical hypertables.
- Small tenants share Parquet files. Large tenants separate into dedicated files as they grow. This avoids both the tiny-file explosion and the one-layout-fits-nobody trap.
- The Postgres catalog is the transaction layer for the lake. A Kafka offset and its new Parquet parts become visible in one commit.
- Ingest, compaction, deletion, retention, and materialized views use the same atomic ADD/REPLACE/DELETE primitive. Races become ordinary optimistic conflicts. The loser replans from fresh state.
- Every catalog commit is also an ordered event. Background workers get a durable change feed without polling S3 or inventing another control plane.
- Queries never list the object store. Range stats, exact tenant-key bitmaps, and row-group spans decide which bytes can matter before any file is opened.
- Compaction is a bounded-memory streaming merge. A 500 GB tenant can become several time-prunable files instead of one impossible file or one impossible in-memory job.
- Storage stays open: Parquet on S3, DataFusion for compute, Postgres for coordination. Query and worker fleets remain disposable and horizontal.
- The performance is measured. With cached object storage, the full Ukiel stack is within 3% of bare DataFusion on the same files. Tenant pruning can make real product queries 3–4.5x faster than scanning those files directly.
Rust workspace. Design spec: docs/superpowers/specs/2026-07-05-ukiel-design.md,
plans: docs/superpowers/plans/, concept notes: docs/notes/
(e.g. schema management).
Plans follow the house format — the write-plan skill
(.claude/skills/write-plan/) holds the process and template for writing
one from a roadmap row.
Volume-scale benchmarks run against the compose stack via the bench binary —
runbook, tooling, and all recorded results in
bench/README.md. Two families: ukiel's own per-tenant SaaS
scenarios (namespace-scoped, through the HTTP endpoint — the product's real query
shape), and the official upstream suites (ClickBench's 43 queries, JSONBench's
5), vendored with a per-query adaptation log.
Official headline (2026-07-13): on ClickBench at 100M rows, over identical
local files, ukiel's full stack — catalog planning, provider, tenant-isolation
machinery, caches — runs the 41 stack-comparable queries at 0.99× bare
DataFusion 54 (27.4 s vs 27.7 s, median per-query 0.98×, every answer
identical, ukiel ahead on 24 of 41). The plan-32 first measurement said 2.16×
and "beats it on nothing"; the gap decomposed into transport (fixed by the
plan-15 cache tier, 94% recovered), a doubly-evaluated predicate (plan 37),
and a Utf8-vs-Utf8View opt-out (plan 39) — full decomposition in
docs/notes/2026-07-12-official-suite-gap.md. Calibration (plan 34): on the
same parquet, DataFusion outruns ClickHouse (24.7 s vs 31.5 s); ClickHouse's
benchmark lead is MergeTree's storage format (~1.4×) — the named, deliberate
cost of the open-format lakehouse shape. (All reference runners are in the
repo: bench clickbench raw|compare, bench clickhouse … — check us.)
House headline (the product's query shape): catalog pruning makes per-tenant queries cheaper than bare DataFusion on the same files — key-filtered queries run 3–4.5× faster than raw, and the plan-16 key bitmaps make pruning exact: the median 100M-row tenant plans 7 files instead of 108 (−93%), sparse-tenant scan scenarios drop 53–73%, and an in-range tenant with zero rows touches zero objects. The plan-13 footer cache had already cut per-tenant read latency ~30–40% by removing the footer-fan-out floor.
This repo needs Rust. I run it on the newest stable version. Beware, the dependiecies for this project are crazy (DataFusion, Parquet, etc.) and they can easily 80-100GB of disk space in ./target directory (sic).
I develop on Apple M3 36GB ram and AMD Ryzen AI 9 HX 370, 96GB ram.
make play # compose stack (Kafka/Postgres/MinIO) + the ukield serverThen, in another terminal, produce an event and query it back:
echo '{"tenant_id": 1, "ts": 1751800000000, "payload": "hello"}' | \
docker compose exec -T kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server localhost:9092 --topic events
curl -s -X POST http://127.0.0.1:8080/api/query \
-H 'content-type: application/json' \
-d '{"namespace_id": 1, "sql": "SELECT payload FROM events"}'Tables, namespaces, and server roles are declared in ukield.example.toml.
Results come back columnar JSON by default ({"schema": [...], "columns": {col: [...]}, "row_count": N} — cheaper and chart/dataframe-ready); add
"format": "rows" for row-oriented JSON or "format": "arrow" (or Accept: application/vnd.apache.arrow.stream) for a lossless Arrow IPC stream — see
docs/notes/2026-07-06-columnar-results.md. Browse the schema over the same
endpoint: SELECT table_name FROM information_schema.tables.
crates/ukiel-core— shared types (ids, parts, commit ops).crates/ukiel-catalog— Postgres-backed catalog: transactional part commits (ADD / REPLACE / DELETE with optimistic concurrency), packing-key-pruned part listing, ordered change feed. The catalog is tenancy-agnostic: logical tables live in namespaces, parts cover packing-key ranges; multitenancy is a deployment mapping (namespace = tenant).crates/ukiel-ingest— Kafka → sorted ZSTD Parquet L0 files → catalog commits. Exactly-once: Kafka offsets are stored in the catalog in the same transaction as the part commit; workers resume from catalog offsets and never commit Kafka group offsets.crates/ukiel-query— DataFusion query serving: catalog-planned Parquet scans (no object-store listings; range stats + exact packing-key bitmaps + row-group key spans prune before any I/O), namespace isolation injected into the physical plan,Utf8Viewstring scans (engine-default parity), HTTP SQL endpoint (POST /api/query), process-wide Parquet footer cache, and a local-disk cache tier with write-through prewarm and chunked large-object caching in front of the object store.crates/ukiel-compactor— background rewrite workers on the change feed: fanout-driven level-ladder compaction (a run = one commit's key-disjoint output; runs merge a level up at a two-tier trigger — eagerl0_fanoutfor the unpruned L0 files, lazierfanoutfor L1+), placement policiespacked/separated/ size-targeted (target_file_mb: small tenants share files, heavy tenants separate organically), cold-partition finalization into a single key-disjoint run, and atomic key deletion (metadata-only for dedicated files, rewrite for packed ones). All swaps are optimistic REPLACE commits; losers replan from fresh state. Seedocs/notes/2026-07-06-lsm-hierarchy.md.crates/ukiel-expr— deterministic SQL expression engine for column specs:default/materialized(computed at write + recomputed on rewrites = organic backfill) /alias(computed at query time). Seedocs/notes/2026-07-06-materialized-columns.md.crates/ukiel-gc— object garbage collection: reaps tombstoned parts' objects after a grace period (fenced by worker cursors) and sweeps orphaned uploads via a write-ahead intent table (pending_objects) — no object-store listing on the hot path. A rarereconcilepass lists the bucket as defense-in-depth. Purged parts keep catalog rows for feed replay.crates/ukiel-e2e— end-to-end suite against the docker-compose stack (Kafka + Postgres + MinIO); tests are#[ignore]d by default. Philosophy and scenario catalog:docs/superpowers/specs/2026-07-05-ukiel-testing-design.md.crates/ukield— the all-in-one server binary: ingest + query + compactor- GC in one process (per-role deployment via
roles = [...]), idempotent table bootstrap from TOML config, graceful shutdown. ExposesGET /metrics(Prometheus) andGET /readyz(catalog + object-store reachability) on the query listener, plus a standalonemetrics_listenport for processes running without the query role; a periodic collector adds consumer/feed lag, backlog, GC, pool, and cache-dir gauges (monitoring P2, plan 21).
- GC in one process (per-role deployment via
Tests: cargo test (integration tests spin up Postgres/Kafka via
testcontainers — Docker must be running). End-to-end: make e2e.
Known issues: docs/issues/ (0001: query endpoint trusts caller-supplied
namespace — do not expose to untrusted callers until auth lands).