Skip to content

fix: non-deterministic event ordering in Genesis Plaza map panel - #9599

Open
eordano wants to merge 4 commits into
devfrom
bugsweep/map-events-tab-out-of-order
Open

fix: non-deterministic event ordering in Genesis Plaza map panel#9599
eordano wants to merge 4 commits into
devfrom
bugsweep/map-events-tab-out-of-order

Conversation

@eordano

@eordano eordano commented Aug 5, 2026

Copy link
Copy Markdown
Member

Symptom
Opening the map, clicking Genesis Plaza, and switching to the Events tab shows the place's events in non-chronological order, and the order is different every time the tab is reopened (100% repro).

Root cause
Two independent defects combine. First, PlaceInfoPanelController takes rows from a pooled EventElementView object pool without ever calling SetAsLastSibling(); the pool is LIFO, so a reused row keeps whatever sibling slot it occupied on a previous fill, and the loading-skeleton Get/release cycle on every reopen produces a fresh permutation of the data-to-row mapping each time. Second, the panel never sorts the fetched events itself and displays start_at (the recurring event's original series start) while the backend orders by next_start_at, so even a stable response looks chronologically scrambled.

Fix
HttpEventsApiService.GetEventsByParcelAsync(parcels) and FetchEventListAsync now return the freshly deserialized EventDTO[] directly, and PlaceInfoPanelController orders it in place with Array.Sort on the array it already owns — no copy of the response list. The order is total, defined by EventDisplayOrderComparer (live events first, then soonest NextStartAtProcessed, then id): the id tiebreaker means the unstable Array.Sort cannot reshuffle equal keys between reopens. element.transform.SetAsLastSibling() is called immediately after every eventElementPool.Get() — both in the population loop and in the loading-skeleton path — mirroring the identical idiom already used by SearchResultPanelController.

Tests
EventDisplayOrderComparerShould pins the order contract (live-first, chronological, id tie-break, and same output for every input permutation — exactly the unstable-sort case), alongside the original pooled-row regression test, which fails at the base pin without the fix and passes with it (validated on v16, Windows Unity 6000.4 EditMode).

Also in touched files (warning ratchet): EventDTO.cs now carries the documented wire-format DTO naming suppression + schema link, and a stray ;; in HttpEventsApiService is gone (−37 lint warnings).

Fixes #9529

🤖 Generated with Claude Code

PlaceInfoPanelController's events list bound pooled EventElementView
rows without ever calling SetAsLastSibling(), so the LIFO object pool
left rows in whatever sibling slot they last occupied — the on-screen
order permuted on every reopen instead of following the fetched data.
The fetch was also never sorted, so events appeared in raw server
order (next_start_at) while the panel displays start_at.

Sort events (live first, then by NextStartAtProcessed) before binding,
and call transform.SetAsLastSibling() after every pool Get() in both
the population loop and the loading-skeleton path, mirroring the
existing idiom in SearchResultPanelController.

Fixes #9529

Includes a regression test that fails without this fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@eordano
eordano requested review from a team as code owners August 5, 2026 07:06
@github-actions
github-actions Bot requested a review from DafGreco August 5, 2026 07:06
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

badge

Build skipped — no changes detected under Explorer/.

@decentraland-bot decentraland-bot added the ext-contribution Identifies a contribution which was not initiated by a Unity Developer label Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Slack notification sent to #explorer-ext-contributions for external review.
To re-send, delete this comment and re-add the ext-contribution label.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

badge

Lint did not finish (failure) — the warning ratchet could not be evaluated. See logs.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

badge

⚠️ EditMode produced no results — the run likely crashed or timed out before finishing. Check the Unity Test / Test (editmode) job.

TESTS SUITE Result Passed Failed Skipped
EditMode ⚠️ No results
PlayMode ✅ Passed 236 0 5

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PR Review: fix: non-deterministic event ordering in Genesis Plaza map panel

STEP 2 — Root-cause check: PASS ✅

The PR correctly identifies two independent defects:

  1. Pool reuse orderingObjectPool<EventElementView> is LIFO; released rows keep their previous sibling index, so a reopen permutes on-screen order relative to the data.
  2. Missing client-side sort — the panel displayed events in whatever order the API returned them, showing start_at (series start) while the backend orders by next_start_at.

The fix addresses both root causes directly: it sorts before binding and repositions after every pool retrieval. This is not a symptom-level patch.

STEP 3 — Design & integration: PASS ✅

No new long-lived units introduced. All changes are localized within the existing FetchAndShowEventsOfThePlace() method of PlaceInfoPanelController, which already owns the event display lifecycle. No new subscriptions, connections, or lifecycle management added.

The SetAsLastSibling() idiom mirrors existing usage in SearchResultPanelController.cs (confirmed — placeElementView.transform.SetAsLastSibling() after pool retrieval). Consistent with codebase patterns.

Teardown trace: No new subscriptions or connections added. The existing ClearEventElements() teardown path (which releases pool elements and removes listeners) is unchanged and already covers the new code path — SetAsLastSibling() is a fire-and-forget Transform operation with no corresponding unsubscribe.

STEP 4 — Member audit: PASS ✅

No public properties or accessors added or changed. All modifications are to private method bodies.

STEP 5 — Line-level review

One P2 finding — see inline comment on the sort comparator.

Allocation note: new List<EventDTO>(events) allocates a copy of the events list. This is acceptable here — it runs in a user-triggered async path (clicking the Events tab), not in a per-frame system Update(). The sort delegate captures no external variables, so the compiler caches it as a static delegate (no allocation). EventDTO is a struct, so the list copy is by-value — fine for the typical single-digit event count per place.

Test observations (non-blocking):

  • The test uses FormatterServices.GetUninitializedObject to bypass the heavy constructor — precedented in the repo (NearbyAudioPerformanceManualTest.cs).
  • The test covers the loading-skeleton SetAsLastSibling fix. It does not additionally drive the sort + SetAsLastSibling in the main fetch-result loop — the PR explicitly acknowledges this gap and explains the rationale. Acceptable for a regression test scoped to the pool-reuse bug.
  • Reflection into private members (FetchAndShowEventsOfThePlace, eventElements) is fragile but pragmatic for this use case.

Security review: No security issues found. This is a client-side UI sorting fix with no input handling, authentication, authorization, or data exposure changes.

STEP 6 — Complexity

SIMPLE — Touches 1 production file (~10 lines of meaningful change) + 1 test file. No ECS systems, async pattern changes, or cross-assembly modifications.

STEP 7 — QA assessment

YES — Changes affect what the user sees (event ordering in the map panel Events tab). Modifies runtime code under Explorer/.

STEP 8 — Non-blocking warnings

None. Main.unity is not in the changed files.

CI Status

Several checks are still pending (Test (editmode), Test (playmode), Prebuild). The Lint and watchdog failures appear to be infrastructure-related (Docker disk space cancellation), not caused by this PR's changes. enforce-approvals is waiting on QA/DEV approval (expected). semantic / title-matches-convention passes — the PR title follows fix: convention.


REVIEW_RESULT: PASS ✅
COMPLEXITY: SIMPLE
COMPLEXITY_REASON: Localized UI fix in a single controller method — sorts events and repositions pooled views after retrieval, no ECS/async/system changes.
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by unknown via Slack

foreach (EventDTO @event in events)
// Deterministic order: live events first, then by next occurrence (the field the backend sorts by).
List<EventDTO> sortedEvents = new (events);
sortedEvents.Sort((a, b) => a.live != b.live ? (a.live ? -1 : 1) : a.NextStartAtProcessed.CompareTo(b.NextStartAtProcessed));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Sort stability: List<T>.Sort is not stable in .NET — events with identical live status and NextStartAtProcessed values can still appear in non-deterministic order across reopens, which doesn't fully deliver on the "deterministic ordering" promise. Adding a tiebreaker by id (a unique string) makes the sort fully deterministic.

Suggested change
sortedEvents.Sort((a, b) => a.live != b.live ? (a.live ? -1 : 1) : a.NextStartAtProcessed.CompareTo(b.NextStartAtProcessed));
sortedEvents.Sort((a, b) =>
{
if (a.live != b.live) return a.live ? -1 : 1;
int cmp = a.NextStartAtProcessed.CompareTo(b.NextStartAtProcessed);
return cmp != 0 ? cmp : string.Compare(a.id, b.id, StringComparison.Ordinal);
});

@mikhail-dcl mikhail-dcl left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It would be better to move sorting to backend so it's unified across all consumers


foreach (EventDTO @event in events)
// Deterministic order: live events first, then by next occurrence (the field the backend sorts by).
List<EventDTO> sortedEvents = new (events);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Avoid allocation of a new collection

foreach (EventDTO @event in events)
// Deterministic order: live events first, then by next occurrence (the field the backend sorts by).
List<EventDTO> sortedEvents = new (events);
sortedEvents.Sort((a, b) => a.live != b.live ? (a.live ? -1 : 1) : a.NextStartAtProcessed.CompareTo(b.NextStartAtProcessed));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Sort is unstable as well

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Windows and Mac build successful in Unity Cloud! You can find a link to the downloadable artifact below.

Name Link
Commit d7f287e
Logs https://github.com/decentraland/unity-explorer/actions/runs/32031196398
Download Windows https://github.com/decentraland/unity-explorer/suites/86834486589/artifacts/9290113768
Download Windows S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/bugsweep/map-events-tab-out-of-order/pr-25202-d7f287e/Decentraland_windows64.zip
Download Mac https://github.com/decentraland/unity-explorer/suites/86834486589/artifacts/9290273411
Download Mac S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/bugsweep/map-events-tab-out-of-order/pr-25202-d7f287e/Decentraland_macos.zip
Built on 2026-08-17T13:25:32Z

Lint

Warnings count reduced: 13156 => 13137

Warnings/errors in files changed by this PR (15)
Assets/DCL/Tests/Editor/PlaceInfoPanelControllerEventOrderingShould.cs:133  AssignNullToNotNullAttribute  Possible 'null' assignment to non-nullable entity
Assets/DCL/Tests/Editor/PlaceInfoPanelControllerEventOrderingShould.cs:36  CSharpWarnings::CS8618  Non-nullable field 'controller' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Tests/Editor/PlaceInfoPanelControllerEventOrderingShould.cs:35  CSharpWarnings::CS8618  Non-nullable field 'pool' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Tests/Editor/PlaceInfoPanelControllerEventOrderingShould.cs:33  CSharpWarnings::CS8618  Non-nullable field 'poolParent' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Tests/Editor/PlaceInfoPanelControllerEventOrderingShould.cs:32  CSharpWarnings::CS8618  Non-nullable field 'poolParentGO' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Tests/Editor/PlaceInfoPanelControllerEventOrderingShould.cs:34  CSharpWarnings::CS8618  Non-nullable field 'templateGO' is uninitialized. Consider adding the 'required' modifier or declaring the field as nullable.
Assets/DCL/Tests/Editor/PlaceInfoPanelControllerEventOrderingShould.cs:29  InconsistentNaming  Name 'EventElementsField' does not match rule 'static_readonly_should_be_capital_snake_case'. Suggested name is 'EVENT_ELEMENTS_FIELD'.
Assets/DCL/Tests/Editor/PlaceInfoPanelControllerEventOrderingShould.cs:26  InconsistentNaming  Name 'FetchAndShowEventsMethod' does not match rule 'static_readonly_should_be_capital_snake_case'. Suggested name is 'FETCH_AND_SHOW_EVENTS_METHOD'.
Assets/DCL/EventsApi/HttpEventsApiService.cs:226  InconsistentNaming  Name 'ok' does not match rule 'members_should_be_pascal_case'. Suggested name is 'Ok'.
Assets/DCL/EventsApi/HttpEventsApiService.cs:99  NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract  '??' left operand is never null according to nullable reference types' annotations
Assets/DCL/EventsApi/HttpEventsApiService.cs:118  NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract  '??' left operand is never null according to nullable reference types' annotations
Assets/DCL/EventsApi/HttpEventsApiService.cs:220  NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract  '??' left operand is never null according to nullable reference types' annotations
Assets/DCL/Tests/Editor/PlaceInfoPanelControllerEventOrderingShould.cs:66  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/EventsApi/HttpEventsApiService.cs:21  UnusedMember.Local  Constant 'PLACE_ID_PARAMETER' is never used
Assets/DCL/EventsApi/HttpEventsApiService.cs:19  UnusedMember.Local  Constant 'POSITION_PARAMETER' is never used

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped
EditMode ✅ Passed 25003 0 13
PlayMode ✅ Passed 236 0 37

eordano and others added 2 commits August 12, 2026 17:16
Address review feedback on the ordering fix:

- GetEventsByParcelAsync(parcels) and FetchEventListAsync now return the freshly deserialized EventDTO[] directly, so the Events tab orders it with Array.Sort on the array it already owns - no copy of the response list.
- The order lives in EventDisplayOrderComparer (live first, then soonest next occurrence, then id). The id tiebreaker makes the order total, so the unstable Array.Sort cannot reshuffle equal keys between reopens. EventDisplayOrderComparerShould pins the contract, including identical-key ties under every input rotation.
- Lint cleanup in touched files: EventDTO.cs gets the documented wire-format DTO naming suppression plus a schema link, and a stray double semicolon in HttpEventsApiService is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t comments

nickkhalow's review blocked on the diff-ratchet lint BLOCK for
`place!.Positions` at PlaceInfoPanelController.cs:439: the diff retyped the
fetch's return value but re-touched a pre-existing null-forgiving
dereference instead of guarding it. Root cause is that
FetchEventsAndShowThemAsync never establishes that `place` is non-null
before using it, unlike sibling methods in this file (SetOriginParcel,
StartNavigation) which already guard with `if (place == null) return;`.
Add the same guard and capture the narrowed value into a local before the
SetAsLoadingState() call runs (a field's narrowed null-state does not
survive a method call), so the fetch call site no longer needs `!`. The
file's other six pre-existing `place!` occurrences are left untouched as
out of scope for this PR.

Also trims PlaceInfoPanelControllerEventOrderingShould.cs's three comment
blocks (class doc + two SetUp asides) down to this repo's established
1-3 line regression-comment convention (JumpButtonShould.cs,
LiveKitChatMessagesBusShould.cs, DebugWebRequestInfoShould.cs); the
narrative content they trimmed was independently verified accurate but
belongs in the PR description, not the diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018c638dR1vPysCMbYt2qQg5
@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9599, run #32035092962

Builds: Windows change, Windows baseline, macOS change, macOS baseline

How to read this table
  • Each build is measured 3 times. The values are the median, and (min–max) is the lowest and highest of those runs — a wide range means the metric is noisy and small differences are not trustworthy.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a real difference: larger than both 3% and the run-to-run range.
  • ⚪ within noise — the difference is smaller than how much the build varies between its own runs, so it cannot be told apart from random variation. Treat it as no change.
  • Exceptions per run — the average number of exceptions in a run's log; more than the baseline is flagged 🔴 even when frame times look fine. The Exception breakdown under each table groups them by the explorer's report category and exception type (as totals across the runs).
  • A run that logged unusually many exceptions (at least 10 and 5× the median of its build's runs — e.g. a service was down during it) is excluded from all numbers and called out under the table.

⚠️ Apple M1 failed to produce results — see the run for details.

⚠️ Intel Core i5 failed to produce results — see the run for details.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ext-contribution Identifies a contribution which was not initiated by a Unity Developer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix non-deterministic event ordering in Genesis Plaza map panel

3 participants