Fix date_bucket origin on Nullable ClickHouse columns
What does this MR do and why?
Fixes a bug where GraphQL analytics aggregated queries returned HTTP 500 when a date_bucket dimension used a fixed-day granularity (for example 30d) with the origin argument, on a Nullable ClickHouse date column.
Root cause: toStartOfInterval(value, INTERVAL N DAY, origin) uses the default Nullable implementation, which evaluates over the whole column block. NULL positions hold the type default 1970-01-01 00:00:00. The anchor was phase-normalised into [1970-01-01, 1970-01-01 + N days), which lands after that default, so ClickHouse raised Code: 36. DB::Exception: The origin must be before the end date / date with time (BAD_ARGUMENTS).
An earlier version of this fix shifted the anchor one full period earlier, making it pre-epoch. That was dropped after testing on a real 25.3.14.14 server showed 25.3 rejects any pre-epoch origin outright, independent of nullability — it turns a conditional failure (500 only with NULLs present) into an unconditional one (500 on every origin query). Support for pre-epoch origins only landed in 25.4; GitLab 19.0+ supports ClickHouse up to 26.6, and CI only runs 25.12 and 26.2, so this would not have been caught before release. The current fix keeps the original post-epoch anchor and handles NULLs explicitly: if(isNull(col), NULL, toStartOfInterval(ifNull(col, anchor), INTERVAL 30 DAY, anchor)). The ifNull fills NULL positions with the anchor itself so the origin check always passes; the outer if restores the NULL. A bare if(col IS NULL, NULL, toStartOfInterval(col, ...)) does not work, since ClickHouse still evaluates both branches over the full column.
The failure happens when a NULL reaches the outer projection where toStartOfInterval runs. Filters are applied in an inner subquery, one nesting level below that projection. A range filter on the nullable column itself (for example startedAtFrom) drops the NULL rows there through ordinary SQL three-valued logic, so no NULL reaches the projection and the query succeeds. A filter on a different column (for example status) guarantees nothing, and the failure still occurs.
References
Issue: #629599 (closed)
The origin argument shipped in GitLab 19.5 via !254135 (merged) and !254973 (merged)
Screenshots or screen recordings
Not applicable. This is a backend-only GraphQL fix with no UI change.
How to set up and validate locally
Reproducing this locally needs the scanned data to actually contain a NULL in the relevant column. A Nullable column with no NULLs in it works fine, which is why this is easy to miss. In a stock GDK the affected tables are commonly not reproducible as-is:
siphon_p_ci_pipelinesrows can carry a placeholdertraversal_paththat the engine's scope filter does not match.- The ClickHouse
merge_requeststable is often empty. - Seeded sessions often have no NULL
created_event_at. - The engine reads through a dedup subquery with
HAVING argMax(_siphon_deleted, _siphon_replicated_at) = 0, so a row whose newest version is a tombstone is invisible.
Because of that, the spec is the primary validation since it is self-contained:
-
Run the spec (note the
--tag click_houseflag):bundle exec rspec spec/lib/gitlab/database/aggregation/click_house/date_bucket_dimension_spec.rb --tag click_house -
Confirm all examples pass.
Optionally, validate against the real schema too, using a project that has a pipeline row with a NULL started_at and a matching traversal path:
query {
project(fullPath: "gitlab-org/gitlab-test") {
analytics {
pipelines {
aggregated(first: 3) {
nodes {
dimensions {
startedAt(granularity: "30d", origin: "2026-07-18T00:00:00Z")
}
totalCount
}
}
}
}
}
}MR acceptance checklist
Evaluate this MR against the MR acceptance checklist.