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_pipelines rows can carry a placeholder traversal_path that the engine's scope filter does not match.
  • The ClickHouse merge_requests table 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:

  1. Run the spec (note the --tag click_house flag):

    bundle exec rspec spec/lib/gitlab/database/aggregation/click_house/date_bucket_dimension_spec.rb --tag click_house
  2. 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.

🤖 Generated with Claude Code

Edited by Serhii Yarynovskyi

Merge request reports

Loading
Loading