Skip to content

Fix wrong sort order at UTC month boundaries and pre-release versions#4687

Merged
midigofrank merged 12 commits into
mainfrom
fix-dashboard-stats-datetime-sort
May 4, 2026
Merged

Fix wrong sort order at UTC month boundaries and pre-release versions#4687
midigofrank merged 12 commits into
mainfrom
fix-dashboard-stats-datetime-sort

Conversation

@elias-ba
Copy link
Copy Markdown
Contributor

@elias-ba elias-ba commented May 1, 2026

Description

Several places in the codebase used Enum.sort_by(.., :asc | :desc) (or the equivalent Enum.sort/2 form, or <=/>= as a comparator) on a key whose value was a DateTime or a Version struct. That form does Erlang structural term comparison, which on these structs walks fields alphabetically and disagrees with chronological / semver order. Same root cause across four surfaces.

  • Workflow list, "Latest Work Order" column (project dashboard). Sorted incorrectly when the most recent work orders for two workflows fell on either side of a UTC month boundary.
  • Projects list, "Last Updated" column, support-user view. Same shape, same trigger. Non-support users were unaffected; they go through a SQL order_by.
  • Admin tables that sort on date columns ("Created at" and "Scheduled deletion" on the admin projects table, "Scheduled deletion" on the admin users table). All go through a shared TableHelpers.sort_items/4 helper, which used <=/>= (structural compare) and produced the same inversion at month boundaries.
  • Adaptor version dropdown. Listed pre-release versions above their corresponding stable release because the sort relied on structural compare of parsed version structs.

Each fix maps the sort key through a domain-aware comparator: DateTime becomes integer microseconds (or ISO 8601 strings inside the shared table helper), version strings go through Version.compare/2. The dashboard, projects-overview, and admin-table fixes have real user impact at month boundaries. The adaptor version fix is a real-but-currently-zero-impact correction (OpenFn adaptors essentially always ship plain X.Y.Z semver, so today no user is hitting it; included here because it is the same root cause).

Validation steps

  1. Run the affected test files together:

    mix test \
      test/lightning/dashboard_stats_test.exs \
      test/lightning/projects_test.exs \
      test/lightning_web/live/helpers/table_helpers_test.exs \
      test/lightning_web/live/job_live/adaptor_picker_test.exs
    

    All tests pass on this branch, including four new regression tests that pin chronological / semver ordering across boundaries.

  2. Quick spot-check in iex -S mix:

    # DateTime sort, dashboard / projects family
    stats = [
      %Lightning.DashboardStats.WorkflowStats{workflow: %{name: "older"}, last_workorder: %{updated_at: ~U[2026-04-30 23:10:00Z]}},
      %Lightning.DashboardStats.WorkflowStats{workflow: %{name: "newer"}, last_workorder: %{updated_at: ~U[2026-05-01 01:10:00Z]}}
    ]
    Lightning.DashboardStats.sort_workflow_stats(stats, :last_workorder_updated_at, :desc) |> Enum.map(& &1.workflow.name)
    # On this branch: ["newer", "older"]. On main: ["older", "newer"].
    
    # Admin tables sort helper
    items = [
      %{name: "April", inserted_at: ~U[2026-04-30 23:10:00Z]},
      %{name: "May", inserted_at: ~U[2026-05-01 01:10:00Z]}
    ]
    LightningWeb.Live.Helpers.TableHelpers.sort_items(items, "inserted_at", "desc", %{"inserted_at" => :inserted_at}) |> Enum.map(& &1.name)
    # On this branch: ["May", "April"]. On main: ["April", "May"].
    
    # Version sort, adaptor family
    LightningWeb.JobLive.AdaptorPicker.sort_versions_desc(["1.0.0", "1.0.0-beta", "1.0.0-alpha"])
    # On this branch: ["1.0.0", "1.0.0-beta", "1.0.0-alpha"]. On main: ["1.0.0-beta", "1.0.0-alpha", "1.0.0"].

Additional notes for the reviewer

AI Usage

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

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

`Enum.sort_by/3` with `:asc`/`:desc` does structural comparison on
`DateTime` structs, ordering by struct-key sequence (day before month
before year) rather than chronologically. The "Latest Work Order"
column relied on that comparison and produced inverted orderings
whenever the most recent work orders for two workflows straddled a UTC
date boundary, so ordering by that column was wrong roughly the first
few hours after UTC midnight every day.

Map the timestamp to integer microseconds inside the per-field sorter
so the call site stays uniform and any future datetime-valued sort key
gets the same treatment. Pin the chronological behaviour with a
regression test using fixed `DateTime` values that cross midnight, so
the test is independent of wall-clock time, and tighten the existing
ascending/descending tests so they assert against a chronological
oracle instead of structural sort.
@github-project-automation github-project-automation Bot moved this to New Issues in Core May 1, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 1, 2026

The change is purely an in-memory sort comparator fix in dashboard_stats.ex — no new queries, web entrypoints, or config-resource writes. All three checks are N/A.

Security Review ✅

  • S0 (project scoping): N/A, the change only swaps a sort-key fallback (~U[1970-01-01]0 unix-µs) inside sort_workflow_stats/3; no queries or data access altered.
  • S1 (authorization): N/A, no new web-layer actions, controllers, or LiveView events are introduced.
  • S2 (audit trail): N/A, no config-resource writes or Repo.insert/update/delete calls in the diff.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 1, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.89%. Comparing base (bef2bc2) to head (73e8d26).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4687   +/-   ##
=======================================
  Coverage   89.88%   89.89%           
=======================================
  Files         444      444           
  Lines       21941    21951   +10     
=======================================
+ Hits        19722    19733   +11     
+ Misses       2219     2218    -1     

☔ View full report in Codecov by Sentry.
📢 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.

elias-ba added 2 commits May 1, 2026 04:25
The previous wording said "UTC date boundary", which overstates the
trigger. Mid-month UTC midnight rollovers do not fire the bug. Only
month boundaries do, because that is when the day-of-month resets to a
smaller number and the structural comparison inverts.
The test name said "across UTC midnight", which overstates the trigger.
Mid-month UTC midnight rollovers do not fire the bug. Only the day-of-
month resetting at a month boundary does. Match the language used in
the changelog and PR description.
elias-ba added 2 commits May 1, 2026 05:11
`Enum.sort_by(versions, &Version.parse/1, :desc)` does structural
comparison on the resulting `Version` structs, which orders by struct
keys alphabetically (build, major, minor, patch, pre) rather than
following semver. Pre-release versions sort after their corresponding
release, so a `1.0.0-beta` would appear above `1.0.0` in the dropdown.

Switch to `Enum.sort(versions, {:desc, Version})` so the comparison
goes through `Version.compare/2`. Add a regression test using
pre-release versions, and tighten the existing ordering assertion to
use the same semver oracle.
The support-user variant of `get_projects_overview/2` builds project
rows in memory and sorts them with `Enum.sort_by(rows, key_fn,
sort_direction)`. When the caller passes `order_by:
{:last_updated_at, :desc | :asc}` the key is a `DateTime`, so the
sort uses Erlang structural term order, which walks struct keys
alphabetically (day before month before year) and inverts at month
boundaries.

Map the value through a sort-key helper that converts `DateTime` to
unix microseconds before sorting, leaving non-datetime keys
(`:name`, `:workflows_count`, etc.) untouched. Pin the chronological
behaviour with a regression test that uses a support user and
timestamps straddling a UTC month boundary.
@elias-ba elias-ba changed the title Fix Latest Work Order sort across UTC date boundary Fix workflow, projects, and adaptor sorts to use domain order May 1, 2026
`TableHelpers.sort_items/4` uses `<=`/`>=` as the comparator (via its
`sort_compare_fn`), so when a sort_map points to a `DateTime`,
`NaiveDateTime`, `Date`, or `Time` value the comparison runs through
Erlang structural term order. That walks struct keys alphabetically
(day before month before year) and disagrees with chronology at month
boundaries, the same root cause we hit on the dashboard and projects-
overview sorts.

The admin Projects table exposes "Created at" and "Scheduled deletion"
sort columns. The admin Users table exposes "Scheduled deletion".
Both go through this helper.

Centralize the fix in `get_sort_value/2`'s normalizer: convert
`DateTime`/`NaiveDateTime`/`Date`/`Time` values to ISO 8601 strings,
which sort lexicographically the same as chronologically. Cover the
behaviour with a focused test on `sort_items/4` using fixed timestamps
that straddle a UTC month boundary.
@elias-ba elias-ba changed the title Fix workflow, projects, and adaptor sorts to use domain order Fix sort calls that compared DateTime / Version structurally May 1, 2026
@elias-ba elias-ba changed the title Fix sort calls that compared DateTime / Version structurally Fix workflow, projects, admin, and adaptor sort order May 1, 2026
@elias-ba elias-ba changed the title Fix workflow, projects, admin, and adaptor sort order Fix sorting on workflow, projects, admin, and adaptor lists May 1, 2026
@elias-ba elias-ba changed the title Fix sorting on workflow, projects, admin, and adaptor lists Fix wrong sort order at UTC month boundaries and pre-release versions May 1, 2026
elias-ba and others added 2 commits May 1, 2026 05:41
Add tests for the case clauses introduced alongside the sort fixes
that the regression tests did not exercise:

- `overview_sort_key/2` in `Lightning.Projects`: nil
  `last_updated_at` (project with no workflows) and non-DateTime sort
  keys (`:name`).
- `TableHelpers.normalize_sort_value/1`: `NaiveDateTime`, `Date`,
  `Time`, and nil values (both explicit nil and missing field).
- `TableHelpers.get_sort_value/2`'s catch-all clause: sort key absent
  from the sort_map (input order preserved).
- `TableHelpers.sort_items/4` on a plain string field, to pin the
  default lexicographic compare.
Copy link
Copy Markdown
Member

@taylordowns2000 taylordowns2000 left a comment

Choose a reason for hiding this comment

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

Approved, but please update the changelog (link to the issue or the PR itself) and consider these before merging:

  1. What was using the DateTime that you're now converting to Unix? Do all those downstream sites (maybe UI displays of the dates?) now need updating too?
  2. Does the nil -> 0 branch map missing timestamps to the Unix epoch? Mixing "absent" with "1970-01-01" would not be great.

elias-ba added 3 commits May 1, 2026 15:30
The sort-key extractors previously mapped nil to a bare `0` (or `""`),
which collides with the unix-epoch value of a hypothetical
`~U[1970-01-01 00:00:00Z]` row at the comparator level. Switch to a
2-tuple `{0, _}` for nil and `{1, value}` for present, so nil rows can
never tie with a real timestamp regardless of what value falls into
the second slot. Same user-visible ordering (nils still at the low
end), but the theoretical tie is impossible by construction.

Cover the behaviour with a regression test on each affected helper
that mixes nil and a real `~U[1970-01-01 00:00:00Z]` row in both
directions.
Per review feedback, each CHANGELOG entry should reference the PR (or
an issue) so the change can be traced from release notes back to the
diff. The four sort fixes ship in #4687.
The previous version had four bullets, each pointing at the same PR.
Group the chronological-sort fixes (workflow list, projects overview,
admin tables) into one bullet that names the affected columns, and
keep the adaptor version sort as a separate bullet because the
failure mode (semver vs chronology) is qualitatively different.
Drops the internal-type names from the user-facing prose.
Copy link
Copy Markdown
Collaborator

@midigofrank midigofrank left a comment

Choose a reason for hiding this comment

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

Nicely done @elias-ba , I'm just not sure why we need the 0 and 1,
why can't we do DateTime.to_unix("1970-01-01") when it's nil? The tuple makes it feel complicated

@github-project-automation github-project-automation Bot moved this from New Issues to In review in Core May 4, 2026
@elias-ba
Copy link
Copy Markdown
Contributor Author

elias-ba commented May 4, 2026

@midigofrank thanks for the review man! I totally hear you on the tuple feeling complicated, and I went back and forth on it myself. Let me explain where the 0 / 1 shape came from.

There's a business requirement we need to honor: a nil row and a hypothetical real ~U[1970-01-01] row must not tie at the comparator level. The reason your suggestion of mapping nil to DateTime.to_unix(~U[1970-01-01 00:00:00Z], :microsecond) doesn't quite get us there is that the result of that conversion is exactly 0, which is the same value a real ~U[1970-01-01] row would produce. So the two cases would collapse to the same sort key.

The tuple is the smallest change I could find that keeps both worlds:

  • Second slot is unix microseconds, so the chronological compare works exactly the same as your suggestion.
  • First slot tags nil vs present, so the two cases stay structurally distinct regardless of what value lands in the second slot.

We also shipped this same shape in the billing app for the same root-cause class, so I'd like to keep it consistent here too.

@elias-ba elias-ba requested a review from midigofrank May 4, 2026 09:16
@midigofrank
Copy link
Copy Markdown
Collaborator

Aaah, okay. Makes sense

@midigofrank midigofrank merged commit 71a29cb into main May 4, 2026
7 checks passed
@midigofrank midigofrank deleted the fix-dashboard-stats-datetime-sort branch May 4, 2026 14:21
@github-project-automation github-project-automation Bot moved this from In review to Done in Core May 4, 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.

3 participants