Skip to content

Bucket the runs volume chart on the reader's clock, and click through it - #5191

Merged
stuartc merged 7 commits into
tidy-workflow-healthfrom
frank/con-188
Sep 18, 2026
Merged

stuartc merged 7 commits into
tidy-workflow-healthfrom
frank/con-188

Conversation

@midigofrank

@midigofrank midigofrank commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Description

The grid was laid on the raw epoch, so a "daily" bar was a UTC day — 03:00 to 03:00 in Nairobi, 05:30 in Delhi — and the day a clock changed split across two bars. Buckets are now cut with date_bin on the local wall clock, in the zone the browser sends as an x-timezone header: a boundary lands on local midnight, London's 25-hour October day is one bar, and every axis and tooltip label is read in that same zone rather than in UTC. Bar widths are unchanged — 2-hourly over a day, half-days over a week, daily over a month — but they are now wall-clock hours, so a daily bar is 23 or 25 hours on the day the clocks move.

The response carries bucket_hours and timezone alongside the buckets. The chart used to measure its own bar width by subtracting the first two timestamps, which reads 25 on a clock-change day and mislabels the axis; the server cut the grid, so the server names the width and the clock.

The timezone joins the cache key, because it moves every boundary and Cachex keys an in-flight fetch by key alone — without it the first reader to load the page would pin their grid on everyone else's for the TTL. It is validated against the tz database before it gets there, since :workflow_stats has no size limit and an arbitrary header value would let one authenticated reader mint unbounded entries. An unknown or missing zone falls back to UTC rather than 400ing: a bad window cannot be drawn, but a bad zone still has a drawable answer.

Tzdata and pg_timezone_names are versioned apart — :tzdata autoupdates at runtime, Postgres' zone set is fixed at the server's build — so a zone the controller validated can still be one the binning query has never heard of. America/Coyhaique against PG 15 is exactly that, and it is what Chrome reports for readers in Coyhaique. The query rescues invalid_parameter_value and redraws the window on UTC rather than 500ing, and the result caches under the requested zone, so the failing query runs once per TTL rather than once per poll.

Configures Tzdata as the time zone database, without which every DateTime.shift_zone/2 by name returns :utc_only_time_zone_database.

Click through the chart to the history page

Each band of each bar now links to the work orders behind it: the bar's slot, and the states that band stacked. The red band folds five run states, so it carries all five — a column-wide link would land the reader on the answer they came for mixed back in with the successes.

The history page had no run-level filter to link to. run_date_after, run_date_before and run_status bound a run's inserted_at and its state, matched with an EXISTS over the work order's runs. The existing status filter is the work order's own state, which is its latest run: a failure since retried to success is a success there, and the band that counted the failure still has to reach it. run_status takes final states only, since an unsettled run has no outcome to have been counted.

A bar's slot ends at the next bar's start rather than its own plus a width. The grid is cut on the reader's clock, so a bar spanning a clock change is 11 or 13 real hours wide, and a run landing on a boundary belongs to the same bar date_bin counted it in. The newest bar goes over open-ended — it is still filling. The filter chips read the range in UTC and say so: nothing in Lightning records what clock the bars were cut on, and a bare "14:00" under a bar labelled "16:00" is worse than a suffix.

Closes CON-188

Validation steps

  1. Open the workflow health page
  2. The runs volume chart should be showing in your local timezone
  3. Click a band of a bar — the history page opens filtered to that bar's window and that band's run states, with the range shown in UTC on the chips
  4. Click the newest (right-most) bar — its filter is open-ended at the top, since that bucket is still filling

Please disclose whether you've used AI anywhere in this PR (it's cool, we just
want to know!):

  • I have used Claude Code
  • I have used another model
  • I have not used AI

You can read more details in our
Responsible AI Policy

Pre-submission checklist

  • I have performed an AI review of my code (we recommend using /review
    with Claude Code)
  • I have implemented and tested all related authorization policies.
    (e.g., :owner, :admin, :editor, :viewer)
  • I have updated the changelog.
  • I have ticked a box in "AI usage" in this PR

@github-project-automation github-project-automation Bot moved this to New Issues in Core Sep 17, 2026
@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.75000% with 3 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (tidy-workflow-health@4cf589a). Learn more about missing BASE report.

Files with missing lines Patch % Lines
lib/lightning_web/live/run_live/index.ex 60.0% 2 Missing ⚠️
..._web/controllers/api/workflow_health_controller.ex 91.7% 1 Missing ⚠️
Additional details and impacted files
@@                  Coverage Diff                   @@
##             tidy-workflow-health   #5191   +/-   ##
======================================================
  Coverage                        ?   91.1%           
======================================================
  Files                           ?     452           
  Lines                           ?   22817           
  Branches                        ?       0           
======================================================
  Hits                            ?   20797           
  Misses                          ?    2020           
  Partials                        ?       0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@midigofrank
midigofrank changed the base branch from release-2.19.0 to tidy-workflow-health September 17, 2026 08:06
@midigofrank
midigofrank marked this pull request as ready for review September 17, 2026 08:41
@midigofrank
midigofrank requested a review from stuartc September 17, 2026 08:41
@github-actions

Copy link
Copy Markdown

Security Review ✅

  • S0 (project scoping): runs/3 in lib/lightning/workflows/stats.ex:243-264 still joins through WorkOrder.workflow_id, and the controller derives the workflow via Workflows.get_workflow_for_project + Permissions.can(:project_users, :access_project, ...) in workflow_health_controller.ex:92-115 — the new timezone parameter does not alter scoping.
  • S1 (authorization): No new web-layer action; plug :validate_timezone runs after plug :authorize_workflow (workflow_health_controller.ex:19-23), and only reads a validated header used for presentation.
  • S2 (audit trail): N/A, read-only aggregation endpoint with no config-resource writes.

@midigofrank midigofrank changed the title Bucket the runs volume chart on the reader's clock Bucket the runs volume chart on the reader's clock, and click through it Sep 17, 2026
The grid was laid on the raw epoch, so a "daily" bar was a UTC day —
03:00 to 03:00 in Nairobi, 05:30 in Delhi — and the day a clock changed
split across two bars. Buckets are now cut with date_bin on the local
wall clock, in the zone the browser sends as an x-timezone header: a
boundary lands on local midnight, London's 25-hour October day is one
bar, and every axis and tooltip label is read in that same zone rather
than in UTC. Bar widths are unchanged — 2-hourly over a day, half-days
over a week, daily over a month — but they are now wall-clock hours, so
a daily bar is 23 or 25 hours on the day the clocks move.

The response carries bucket_hours and timezone alongside the buckets.
The chart used to measure its own bar width by subtracting the first two
timestamps, which reads 25 on a clock-change day and mislabels the axis;
the server cut the grid, so the server names the width and the clock.

The timezone joins the cache key, because it moves every boundary and
Cachex keys an in-flight fetch by key alone — without it the first
reader to load the page would pin their grid on everyone else's for the
TTL. It is validated against the tz database before it gets there, since
:workflow_stats has no size limit and an arbitrary header value would let
one authenticated reader mint unbounded entries. An unknown or missing
zone falls back to UTC rather than 400ing: a bad window cannot be drawn,
but a bad zone still has a drawable answer.

Configures Tzdata as the time zone database, without which every
DateTime.shift_zone/2 by name returns :utc_only_time_zone_database.
Tzdata and pg_timezone_names are versioned apart: :tzdata autoupdates at
runtime, while Postgres' zone set is fixed at the server's build. So a zone
the controller validated can still be one the binning query has never heard
of — America/Coyhaique against PG 15 is exactly that, and it is what Chrome
reports for readers in Coyhaique, Chile.

Rescue invalid_parameter_value and redraw the window on UTC rather than
500 the endpoint. This matches the policy the controller already sets for
unusable timezones: a bad window cannot be drawn, a bad clock still can.
The result caches under the requested zone, so the failing query runs once
per TTL rather than once per poll.
Each band of each bar links to the work orders behind it: the bar's slot,
and the states that band stacked. The red band folds five run states, so it
carries all five — a column-wide link would land the reader on the answer
they came for mixed back in with the successes.

The history page had no run-level filter to link to. run_date_after,
run_date_before and run_status bound a run's inserted_at and its state,
matched with an EXISTS over the work order's runs. The existing status
filter is the work order's own state, which is its latest run: a failure
since retried to success is a success there, and the band that counted the
failure still has to reach it. run_status takes final states only, since an
unsettled run has no outcome to have been counted.

A bar's slot ends at the next bar's start rather than its own plus a width.
The grid is cut on the reader's clock, so a bar spanning a clock change is
11 or 13 real hours wide, and a run landing on a boundary belongs to the
same bar date_bin counted it in. The newest bar goes over open-ended — it
is still filling.

historyUrl now repeats key[] for a list value, which is what Plug decodes
back into something an {:array, _} field can cast.

The chips read the range in UTC and say so. Nothing in Lightning records
what clock the bars were cut on, and a bare "14:00" under a bar labelled
"16:00" is worse than a suffix.
date_bin cut the buckets with Postgres' zone rules while bucket_starts and
resolve cut the same grid with Tzdata's. The two are versioned apart, so a
transition they disagreed about put a tally under a local timestamp absent
from the bar list, and Map.get dropped those runs. The bar read zero rather
than wrong, which is the kind of wrong nobody reports.

width_bucket takes the resolved boundaries instead and returns the slot, so
Postgres never sees a zone name and Tzdata alone decides where a bar opens.
The Coyhaique case goes with it: nothing asks Postgres about a zone it may
not know, so there is nothing to rescue.

Those boundaries have to be strictly ascending for width_bucket to mean
anything, and Postgres answers an unsorted array with a plausible number
rather than an error. Antarctica/Troll shifts two hours, so a transition can
be as wide as the narrowest bucket — the property holds because of where the
transitions land, not because of any margin, and the boundary test is what
holds it.

boundaries/3 is public so the grid can be checked without a database. That
is the only way to put a fixed clock change inside the window, which is
otherwise always relative to now, and it reaches the gap and ambiguous
branches of resolve that nothing exercised before. Every run test now
asserts the bars sum to the runs in the window.
Three places chose UTC: the controller's default, the rescue that redrew the
window when Postgres rejected a zone, and a default argument on Stats.runs/3.
The rescue went with the binning change and the default argument goes here,
leaving the plug.

No header is UTC, because a cached bundle from before the header existed has
to keep working through a deploy. Etc/Unknown is UTC too — it is CLDR's
sentinel for a host clock it could not map, so a browser sending it is saying
it does not know rather than naming a zone we failed to recognise. Anything
else the tz database does not know is a 400: the browser picked it, and
drawing someone else's clock would hide that rather than fix it.

vary, because this is the one action whose body turns on a request header and
nothing between the browser and here would guess it.

The stats cache stays unbounded. The timezone is in its key, so the key space
is wider than it was, but an entry is ~16 KiB against the ~200 ms aggregate
it takes to fill one — the database is the scarce resource here, and a size
limit would bound the wrong one.
The click-through and the history filters land separately for a reader, so
they read as separate entries rather than one. The timezone entry now says
what a browser gets when it sends no zone or one the tz database does not
know.

@stuartc stuartc left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work dude. I made a few changes:

  1. The bucketing now works out the edges with Tzdata, and then passes that to postgres. So we don't have to deal with the mismatch between tzdata and postgres. Since tzdata is updated all the time I picked that as the source of truth.
  2. Moved the default timezone into one place (in the controller plug). No header means UTC, browsers can say "Etc/Unknown" and we make that UTC. Anything else is a 400; I might regret that hardening but yeah thought I'd rather see an error than quietly have users get UTC when they shouldn't.
  3. Added Vary: x-timezone so that any caching layer we might end up having would know that it's part of the cache keys and it will consider it a different request.

@stuartc
stuartc merged commit 726d73b into tidy-workflow-health Sep 18, 2026
7 checks passed
@stuartc
stuartc deleted the frank/con-188 branch September 18, 2026 14:35
@github-project-automation github-project-automation Bot moved this from New Issues to Done in Core Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants