|
| 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