Skip to content

Commit 682ec4d

Browse files
committed
docs(dq): add DuckLake-over-ClickHouse improvement analysis
Code-grounded, prioritized analysis of where the DuckLake query layer can exceed the CH-parity implementation (CH limits: pre-materialized MVs, fetch-all-to-Go detector loops, approximate functions). Covers: #0 ignition ongoing-trips (shipped), #1 exact aggregations (shipped+pinned), #2 ASOF trip enrichment (location gap-fill schema-free; distance/avg-speed via the existing Signals channel under no-schema), #3 SQL-native idling/refuel/recharge, #4 spatial (needs LOAD spatial first), #5-7 secondary. Verified by the round-3 E2E reviews (spatial-not-loaded + the schema caveats corrected).
1 parent 8549bbb commit 682ec4d

1 file changed

Lines changed: 235 additions & 0 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
# DuckLake can beat ClickHouse: feature-improvement analysis (dq)
2+
3+
The dq query layer was built to **mirror ClickHouse** (CH-parity) for the CH→DuckLake
4+
cutover. That was right for a safe cutover — but it means dq inherits CH's
5+
*limitations*. ClickHouse forced compromises DuckDB doesn't have. This doc lists
6+
where DuckLake can do **better**, prioritized, code-grounded.
7+
8+
_Analysis 2026-06-23, verified against the code. The ignition ongoing-trip
9+
improvement (#0) is shipped (dq `c83d6b5`). The rest are proposals; SQL sketches are
10+
illustrative — refine before building._
11+
12+
---
13+
14+
## The architectural insight
15+
16+
Segment/trip detection is **shared Go** (`internal/segments/{ignition,idling,refuel,
17+
recharge,frequency,changepoint}.go`) fed by a 3-method SQL `SignalSource`
18+
(`WindowedSignalCounts`, `LevelSamples`, `IgnitionStateChanges`), with a ClickHouse
19+
and a DuckLake impl; trip enrichment (start/end location, speed) is layered on in
20+
`internal/repositories/segments.go`. CH's limits live where the shared Go layer
21+
can't fix them:
22+
23+
1. **Pre-materialized views.** CH needs `signal_state_changes`/`signal_last_state`
24+
(`... FINAL`) and the `signal_latest_*` projection because it can't do stateful
25+
window functions on read — forcing the ignition `-1`-seed + 30-day-lookback
26+
compromises and MV maintenance/lag.
27+
2. **"Fetch all raw samples to Go and loop."** Idling, refuel, recharge pull *every*
28+
RPM/fuel/SoC sample over the wire and detect in memory (`idling.go:61`,
29+
`refuel.go:57`, `recharge.go:52`), because CH SQL couldn't.
30+
3. **Approximate / nondeterministic functions.** `uniq()` (HLL distinct), `median()`
31+
(t-digest), `argMax` ties, `FINAL` eventual dedup.
32+
33+
DuckDB removes all three (window functions on read, ASOF joins, exact aggregates,
34+
deterministic `QUALIFY` dedup, spatial). Several of these are **already silently
35+
diverging in the good direction** on the lake path — see #1 (exact aggregations).
36+
37+
---
38+
39+
## #0 — Ignition seed + ongoing trips (SHIPPED, dq `c83d6b5`) — the template
40+
41+
CH derives ignition state from the `signal_state_changes` MV (seed `-1`, 30-day
42+
lookback) and suppresses in-progress trips. The lake path now computes state **on
43+
read**: seeds `prev_state` from the **true unbounded last value before the window**
44+
(single bucket-pruned `LIMIT 1` lookup) so an entered-ON vehicle isn't fabricated
45+
into a phantom trip, and surfaces an ongoing trip as a synthetic ON. Template for
46+
the rest: on-read freedom → more correct than CH's MV.
47+
48+
---
49+
50+
## PRIORITY 1 — Recognize + document the exact-aggregation divergence that is ALREADY SHIPPING
51+
52+
**This is the most urgent item: it's live, silent, and could be "fixed" back to
53+
approximate by QA at cutover.** The lake path already diverges from CH toward
54+
*correctness*, with no flag and no doc:
55+
56+
- **`median()` — EXACT on lake (`aggregations.go:380`) vs APPROXIMATE t-digest in CH
57+
(`ch/queries.go:239`).** A vehicle's median speed/SoC over a window is now exact;
58+
CH returned a t-digest estimate. Live correctness divergence.
59+
- **`count(DISTINCT name)` — EXACT on lake (`segments_source.go:60,68`) vs
60+
approximate `uniq(name)` HLL in CH (`ch/segments_utils.go:43`).** Near the
61+
distinct-signal threshold, frequency/change-point window eligibility can differ.
62+
- **Tie-free deterministic `arg_max` — lake's `QUALIFY ROW_NUMBER() OVER (PARTITION
63+
BY subject,name,timestamp ORDER BY cloud_event_id)` (`lake_latest.go:19`) makes
64+
every `(subject,name,timestamp)` unique**, so latest/first/last are deterministic;
65+
CH's `FINAL`+`argMax` is tie-nondeterministic and `FINAL` is expensive.
66+
- **`mode()` / `string_agg(DISTINCT)` exact (`aggregations.go:398-400`) vs CH
67+
`topK`/`groupUniqArray` approximate.**
68+
69+
**Action:** decide these are intended improvements, write a divergence note, add
70+
tests pinning the exact behavior, and (optionally) a feature flag if any consumer
71+
depended on CH's approximate median. **Effort S. Value: correctness, effectively
72+
free (already how the code runs). Parity: deliberate, must be documented before QA.**
73+
74+
---
75+
76+
## PRIORITY 2 — ASOF JOIN: turn trips into journeys (highest NEW-build leverage)
77+
78+
**CH limitation:** no ASOF join. Today "location at trip start" is approximated by
79+
`argMin(value_location, timestamp)` **constrained to the trip's own window**
80+
(`ch/queries.go:256-262``LocationAggregationFirst`, wired at `segments.go:125`).
81+
Two costs:
82+
- **Gap blindness:** if no GPS fix landed *inside* a short trip, start/end location is
83+
missing → `noDataLocation()` returns (0,0) (`segments.go:131,290`). A car parked
84+
with its last fix 10 min before ignition shows (0,0).
85+
- **No distance, no avg speed** — only `MAX(speed)` (`segments.go:91`).
86+
87+
The `Segment` model **already carries a start/end location slot** filled by the repo
88+
(`models_gen.go:142-144`, `detector.go:16`) — so the plumbing exists; it's just
89+
weakly filled.
90+
91+
**DuckDB (ASOF JOIN, native):** join each trip boundary *timestamp* to the location
92+
and speed streams "as of" that instant, reaching back to the last known reading:
93+
94+
```sql
95+
-- gap-filled start/end location AS OF the transition (reaches before the window)
96+
SELECT t.seg_idx, t.boundary_ts, l.loc_lat, l.loc_lon
97+
FROM trip_boundaries t -- (seg_idx, boundary_ts) the detector already has
98+
ASOF LEFT JOIN (
99+
SELECT timestamp, loc_lat, loc_lon FROM lake.signals
100+
WHERE subject = ? AND name = 'currentLocationCoordinates' AND (loc_lat != 0 OR loc_lon != 0)
101+
) l ON l.timestamp <= t.boundary_ts;
102+
103+
-- distance: odometer delta (odo already fetched as sigOdoFirst/sigOdoLast, segments.go:96-97),
104+
-- ASOF both ends; or sum of haversine over the route within the window.
105+
```
106+
107+
**Value:** correctness (start/end populated even for sparse GPS) **and** per-trip
108+
distance + avg speed. **Effort:** location gap-fill alone **S** (the `Start`/`End`
109+
location slots already exist — schema-free). **NO-SCHEMA CONSTRAINT:** named
110+
`Segment.distanceKm`/`avgSpeed` top-level fields would need a schema change (ruled
111+
out). Under no-schema, surface them through the **existing `Signals[]` channel**
112+
avg-speed as a `FloatAggregationAvg` aggregate, distance from the odo first/last
113+
already returned there (or a synthetic `Signals[]` entry) — no new fields. **Parity:**
114+
intentional improvement — strictly *more* data; the ASOF start/end loc is a superset
115+
of the windowed value).
116+
117+
---
118+
119+
## PRIORITY 3 — SQL-native run-length / level-jump: delete the fetch-all-to-Go loops
120+
121+
**CH limitation:** can't express run-length or LAG-delta on read, so all three pull
122+
every sample to Go and loop:
123+
- **Idling** = run-length of `0 < RPM <= maxIdle`, gap-broken (`idling.go:61-92`).
124+
- **Refuel** = 5-min-window rise + backward-trough/forward-peak walk (`refuel.go`).
125+
- **Recharge** = 11-sample moving average, trough→peak by `s[i]-s[i-1]`, + odometer
126+
movement filter (a *second* full stream) (`recharge.go:52-61`).
127+
128+
**DuckDB (window functions + gaps-and-islands):** push detection into SQL, return
129+
only the runs/jumps:
130+
131+
```sql
132+
-- idling: islands of consecutive idle-band samples, gap-broken
133+
WITH s AS (SELECT timestamp, value_number rpm FROM lake.signals
134+
WHERE name='powertrainCombustionEngineSpeed' AND subject=? AND <bucket> AND ...),
135+
f AS (SELECT *, lag(rpm>0 AND rpm<=:maxIdle) OVER o lag_idle, lag(timestamp) OVER o lag_ts FROM s
136+
WINDOW o AS (ORDER BY timestamp)),
137+
g AS (SELECT *, (rpm>0 AND rpm<=:maxIdle) idle,
138+
sum(CASE WHEN (rpm>0 AND rpm<=:maxIdle) AND (NOT coalesce(lag_idle,false)
139+
OR timestamp-lag_ts > :maxGap) THEN 1 ELSE 0 END) OVER (ORDER BY timestamp) island
140+
FROM f)
141+
SELECT island, min(timestamp) start_ts, max(timestamp) end_ts FROM g WHERE idle
142+
GROUP BY island HAVING max(timestamp)-min(timestamp) >= :minDur;
143+
144+
-- refuel/recharge: delta = value_number - lag(value_number) OVER (ORDER BY ts); flag jumps the same way.
145+
-- recharge smoothing → avg(value_number) OVER (ROWS BETWEEN 5 PRECEDING AND 5 FOLLOWING).
146+
```
147+
148+
**Implementation:** add richer SQL-returning methods to the **lake** `SignalSource`
149+
(e.g. `IdleRuns`, `LevelJumps`) so the CH path keeps its Go loop — clean backend
150+
separation; `parity_test.go` guards equivalence on shared fixtures. **Value:** much
151+
less data over the wire, exact boundaries, single round trip; retires the Go state
152+
machines. **Effort:** idling **S/M**, refuel/recharge **M** (port the
153+
`refuelPeakStabilizationDrop` / smoothing thresholds exactly). **Parity:**
154+
improvement, low risk if thresholds reproduced.
155+
156+
---
157+
158+
## PRIORITY 4 — Spatial extension for geo filters + trip routes (baked into the image, NOT yet loaded)
159+
160+
**CH limitation:** `pointInPolygon`/`geoDistance` (`ch/queries.go:711-723`); the lake
161+
port reimplements both as **pure-SQL math** — haversine (`aggregations.go:210-217`)
162+
and an even-odd ray-cast unrolled per vertex (`aggregations.go:226-239`), with an
163+
explicit antimeridian-breaks caveat (mirrors CH's own TODO at `ch/queries.go:699`),
164+
no spatial index.
165+
166+
**DuckDB:** the `spatial` extension is **baked into the image** (`installext`) but the
167+
runtime bootstrap (`duck.go` LOADs only httpfs/aws/ducklake/postgres) **never `LOAD
168+
spatial`** — so `ST_*` is unavailable at runtime today (`grep ST_` → only comments).
169+
The first step is adding `LOAD spatial` to the per-connection bootstrap (offline-safe,
170+
binary pre-baked — but it's the hot per-connection path, so verify cost). Then:
171+
172+
```sql
173+
WHERE ST_DWithin(ST_Point(loc_lon, loc_lat), ST_Point(:clon,:clat), :radius_m) -- inCircle
174+
WHERE ST_Contains(ST_GeomFromText(:wkt_polygon), ST_Point(loc_lon, loc_lat)) -- inPolygon
175+
-- trip route length: ST_Length(ST_MakeLine(list(ST_Point(loc_lon,loc_lat) ORDER BY timestamp)))
176+
```
177+
178+
**Value:** correct geodesic/polygon (incl. antimeridian, complex rings), ~50 fewer
179+
lines of trig-string generation, and **trip route geometry + route distance** (a real
180+
feature, complements #2; `ST_MakeLine`+`list()` showcases DuckDB list aggregates CH
181+
lacks). **Effort:** M — add `LOAD spatial` to the bootstrap (NOT already done), then
182+
swap the two predicates + parity tests. Trip-route geometry as a *field* hits the
183+
no-schema constraint (#2); a `Signals[]`-style surface avoids it. **Keep the pure-SQL
184+
path as a no-extension fallback.** **Parity:** improvement; pin tests —
185+
distances differ at the mm/edge level.
186+
187+
---
188+
189+
## Secondary (worthwhile, lower leverage)
190+
191+
- **#5 Recharge odometer filter via ASOF/window.** `recharge.go` fetches a *second*
192+
full odometer stream just to reject sessions where the car moved. With #2/#3 this
193+
becomes an ASOF/`SUM(delta)` join in the same query — one round trip. **Effort S
194+
once #3 lands; parity-neutral.**
195+
- **#6 Lean on the on-read latest path; the rollup is an optimization, not a
196+
correctness requirement.** CH *must* maintain `signal_latest_*` with byte-exact
197+
aggregate text (`ch/queries.go:46-48` — a whitespace change silently disables it).
198+
Lake has both an on-read path (`lake_latest.go`, exact `arg_max` over the deduped
199+
scan, O(distinct names), bucket-pruned) **and** the optional `signals_latest`
200+
rollup (`lake_rollup.go`). The rollup is pure speed and droppable; no projection-
201+
text footgun, no MV-seed compromises. **Effort: none new — architectural note.**
202+
- **#7 Fold the ignition debounce + segment assembly into SQL.** The seed already
203+
moved to SQL (`c83d6b5`), but `filterNoise` + assembly still run in Go
204+
(`ignition.go:46-139`) — expressible with `LAG`/`LEAD` + gaps-and-islands,
205+
retiring the last app-side loop. **Effort M, parity-sensitive (subtle, well-tested
206+
— port carefully, keep CH on Go). Perf/cleanliness, not new capability.**
207+
208+
---
209+
210+
## Does NOT improve
211+
212+
**Change-point (CUSUM)** is an inherently sequential cumulative-sum — it belongs in
213+
Go. Keep it; feed it the (now exact, #1) windowed counts.
214+
215+
---
216+
217+
## Parity framing
218+
219+
Everything here **diverges from CH** — the point of the exercise. Two flavors:
220+
- **Strictly-better** (exact vs approximate median/distinct, deterministic vs
221+
tie-nondeterministic latest, true vs fabricated ignition state, faster geofencing):
222+
no product debate — just verify no consumer depended on the CH quirk, and
223+
**document** (#1 is already live and undocumented).
224+
- **New features** (trip distance/route/speed, spatial routes): product calls — quick
225+
sign-off before building.
226+
227+
## Recommended order
228+
229+
1. **#1 (exact aggregations)** — recognize + document the *already-shipping*
230+
divergence before cutover so it isn't reverted. Cheap, urgent.
231+
2. **#2 (ASOF trip enrichment)** — highest new-user-value; location gap-fill is S,
232+
distance/avg-speed is M. Prototype the enriched-trip SQL first.
233+
3. **#3 (SQL-native idling/refuel/recharge)** — perf + the ASOF speed/odo correlation.
234+
4. **#4 (spatial)** — pairs with #2 for route/distance.
235+
5. **#5#7** as cleanup once the above land.

0 commit comments

Comments
 (0)