Skip to content

perf: remove per-poll List allocation in notifications polling - #9804

Draft
alejandro-jimenez-dcl wants to merge 1 commit into
mainfrom
bugsweep/notifications-poll-list-alloc
Draft

perf: remove per-poll List allocation in notifications polling#9804
alejandro-jimenez-dcl wants to merge 1 commit into
mainfrom
bugsweep/notifications-poll-list-alloc

Conversation

@alejandro-jimenez-dcl

Copy link
Copy Markdown
Contributor

Problem

The notifications poller allocates a fresh List<INotification> plus a full Newtonsoft
LINQ-to-JSON object graph every 5 seconds for the entire app lifetime, even when zero
notifications arrive - steady-state GC churn on the hottest always-on polling loop in the
client. Tracked by the in-code TODO and tech-debt issue #9263.

Root cause

Newtonsoft's JsonSerializer.Populate never consults root-level JsonConverters, and the
notifications payload is an object wrapping an interface-typed list only
NotificationJsonDtoConverter can parse - so the existing reuse path
(OverwriteFromNewtonsoftJsonAsync) was unusable for this endpoint and the poll loop was
stuck on the allocating create-op. The converter's own existingValue support was dead
code.

Fix (~30 LOC)

  • GenericDownloadHandlerUtils: new PopulateInto<T>(JsonReader, T target, JsonSerializer)
    • routes the first matching registered converter with the target as existingValue,
      falling back to serializer.Populate (existing behavior). The Overwrite op's Newtonsoft
      branch now uses it, making converter-aware reuse available to every poller.
  • NotificationsRequestController: a reusable pollNotificationsBuffer cleared per
    iteration + OverwriteFromNewtonsoftJsonAsync; the per-poll URL interpolation is hoisted
    to a ctor-built field. GetMostRecentNotificationsAsync is deliberately not pooled (its
    list escapes to the panel controller).

Contract blast radius: behavior changes only when a registered converter matches the ROOT
target type of an Overwrite call - a combination that today either throws or silently
ignores the converter; all existing callers audited unaffected.

Test

  • NotificationsRequestControllerShould.ReuseSingleListInstanceAcrossPollIterations -
    drives two poll iterations against a stubbed web controller; asserts the overwrite op is
    used, the same Target instance is passed both iterations, and the buffer is empty at
    delivery (the re-dispatch hazard).
  • GenericDownloadHandlerPopulateIntoShould (3 tests) - converter-routed populate in
    place, no duplication after Clear(), and the no-matching-converter fallback.

Validation

Windows Unity 6000.4 EditMode lane at the pin: RED FAIL 1/1 as intended (overwrite op
received 0 calls at pin - the loop allocates a fresh list per poll) / GREEN PASS 4/4.
Side-find from validation (separate issue): a real wire "events_ended" notification would
throw JsonSerializationException in production ReadJson - NotificationType has no
matching enum member.

Fixes #9263

Includes inspection-warning cleanup in all touched files.

Fixes #9263

## Problem

The notifications poller allocates a fresh `List<INotification>` plus a full Newtonsoft
LINQ-to-JSON object graph every 5 seconds for the entire app lifetime, even when zero
notifications arrive — steady-state GC churn on the hottest always-on polling loop in the
client. Tracked by the in-code TODO and tech-debt issue #9263.

## Root cause

Newtonsoft's `JsonSerializer.Populate` never consults root-level `JsonConverter`s, and the
notifications payload is an object wrapping an interface-typed list only
`NotificationJsonDtoConverter` can parse — so the existing reuse path
(`OverwriteFromNewtonsoftJsonAsync`) was unusable for this endpoint and the poll loop was
stuck on the allocating create-op. The converter's own `existingValue` support was dead
code.

## Fix (~30 LOC)

- `GenericDownloadHandlerUtils`: new `PopulateInto<T>(JsonReader, T target, JsonSerializer)`
  — routes the first matching registered converter with the target as `existingValue`,
  falling back to `serializer.Populate` (existing behavior). The Overwrite op's Newtonsoft
  branch now uses it, making converter-aware reuse available to every poller.
- `NotificationsRequestController`: a reusable `pollNotificationsBuffer` cleared per
  iteration + `OverwriteFromNewtonsoftJsonAsync`; the per-poll URL interpolation is hoisted
  to a ctor-built field. `GetMostRecentNotificationsAsync` is deliberately not pooled (its
  list escapes to the panel controller).

Contract blast radius: behavior changes only when a registered converter matches the ROOT
target type of an Overwrite call — a combination that today either throws or silently
ignores the converter; all existing callers audited unaffected.

## Test

- `NotificationsRequestControllerShould.ReuseSingleListInstanceAcrossPollIterations` —
  drives two poll iterations against a stubbed web controller; asserts the overwrite op is
  used, the same `Target` instance is passed both iterations, and the buffer is empty at
  delivery (the re-dispatch hazard).
- `GenericDownloadHandlerPopulateIntoShould` (3 tests) — converter-routed populate in
  place, no duplication after `Clear()`, and the no-matching-converter fallback.

## Validation

Windows Unity 6000.4 EditMode lane at the pin: RED FAIL 1/1 as intended (overwrite op
received 0 calls at pin — the loop allocates a fresh list per poll) / GREEN PASS 4/4.
Side-find from validation (separate issue): a real wire "events_ended" notification would
throw `JsonSerializationException` in production `ReadJson` — `NotificationType` has no
matching enum member.

Fixes #9263

Includes inspection-warning cleanup in all touched files.
@alejandro-jimenez-dcl
alejandro-jimenez-dcl requested review from a team as code owners August 19, 2026 12:20
@alejandro-jimenez-dcl alejandro-jimenez-dcl self-assigned this Aug 19, 2026
@github-actions
github-actions Bot requested a review from DafGreco August 19, 2026 12:31
@decentraland-bot
decentraland-bot self-requested a review August 19, 2026 12:38
@alejandro-jimenez-dcl
alejandro-jimenez-dcl marked this pull request as draft August 19, 2026 12:49
@github-actions
github-actions Bot requested review from dalkia and pravusjif August 19, 2026 12:59

@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 — #9804 perf: remove per-poll List allocation in notifications polling

STEP 2 — Root-cause check: PASS

The problem is that JsonSerializer.Populate never consults root-level JsonConverters, so NotificationJsonDtoConverter was unreachable from the existing OverwriteFromNewtonsoftJsonAsync path. The fix introduces PopulateInto<T> to route matching root-level converters manually, making the reuse-op generically correct — not just a workaround for notifications. This addresses the cause (converter-unaware Populate), not a symptom.

STEP 3 — Design & integration: PASS

No new long-lived units introduced. The pollNotificationsBuffer is a method-local variable scoped to StartGettingNewNotificationsOverTimeAsync — it doesn't escape the loop (items are dispatched individually to NotificationsBusController). The notificationsUrl field is a simple readonly cache of a previously per-call allocation.

PopulateInto<T> lives in GenericDownloadHandlerUtils, which already owns all JSON deserialization plumbing for the web-request framework. This is the correct home — it fixes a general gap in OverwriteFromJsonAsyncOp, not a notifications-specific one.

Teardown trace: No subscriptions, event hookups, or disposables are added by this diff. The buffer is a plain List<> cleared each iteration; no teardown needed.

Existing caller impact verified: Searched all OverwriteFromNewtonsoftJsonAsync / OverwriteFromJsonAsync callers:

  • ProfilesRequest.cs — uses SERIALIZER_SETTINGS with ProfileConverter/ProfileCompactInfoConverter, but the root target type is IList<Profile>, which neither converter matches → falls through to serializer.Populate() (identical to prior behavior).
  • PlacesAPIClient.cs / RealmController.cs — use WRJsonParser.Unity path (JsonUtility.FromJsonOverwrite), PopulateInto is never called.
  • LoadElementsByPointersSystem.cs — uses WRJsonParser.Newtonsoft with default settings (no custom converters) → falls through to serializer.Populate().

All existing callers are unaffected.

STEP 4 — Member audit: PASS

PopulateInto<T> (public static) — 1 direct consumer (OverwriteFromJsonAsyncOp.ExecuteAsync). Public because it completes the Overwrite op's contract for any future caller with a root-level converter. Not single-use indirection — it fills a real gap that would otherwise require each caller to duplicate the converter-routing logic.

notificationsUrl (private readonly) — 2 consumers within the class (GetMostRecentNotificationsAsync, StartGettingNewNotificationsOverTimeAsync). Replaces per-call URLDomain.FromString(string-interpolation) with a ctor-cached value.

STEP 5 — Line-level review

See inline comments below.

STEP 6 — Complexity: COMPLEX

Touches the shared JSON deserialization infrastructure (GenericDownloadHandlerUtils) used by all web-request pollers, and modifies an async polling loop with converter-aware reuse semantics.

STEP 7 — QA: YES

Modifies runtime code under Explorer/ that ships in the build (notifications polling path).

STEP 8 — Non-blocking warnings

None. Main scene is not modified.

Security review

No security issues found. No new external input handling, no secrets, no auth changes. Error handling patterns preserved.

Test coverage

Excellent. 6 tests covering:

  • Buffer reuse across poll iterations (controller integration test)
  • Converter-routed populate (happy path)
  • Clear + reuse (no duplication)
  • Fresh-instance safety check (throws)
  • Null body via converter null guard
  • Fallback to serializer.Populate when no converter matches

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies shared JSON deserialization infrastructure (GenericDownloadHandlerUtils) and converter-aware reuse path for the web-request framework
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub

Comment on lines +287 to +288
if (reader.TokenType == JsonToken.None)
reader.Read();

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] Defensive: reader.Read() return value is discarded. If a truncated or empty stream reaches this point, Read() returns false and the converter receives a reader still at JsonToken.None, producing a confusing downstream error from JObject.Load instead of a clear failure. In practice this is unlikely (empty HTTP bodies are caught earlier), but a defensive check here would surface the issue at its origin.

Suggested change
if (reader.TokenType == JsonToken.None)
reader.Read();
if (reader.TokenType == JsonToken.None && !reader.Read())
throw new JsonSerializationException("Unexpected end of JSON input; cannot populate target.");

{
JsonConverter converter = converters[i];

if (converter.CanRead && converter.CanConvert(typeof(T)))

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] Style: evaluation order. Newtonsoft internally evaluates CanConvert(type) first (the selective type gate), then CanRead (a broad flag that defaults to true). Swapping to match the conventional order improves readability for anyone familiar with Newtonsoft internals, and short-circuits on the cheaper type check when converters opt out of specific types.

Suggested change
if (converter.CanRead && converter.CanConvert(typeof(T)))
if (converter.CanConvert(typeof(T)) && converter.CanRead)

@github-actions

github-actions Bot commented Aug 19, 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 15f2bf9
Logs https://github.com/decentraland/unity-explorer/actions/runs/32252162317
Download Windows https://github.com/decentraland/unity-explorer/suites/87428398377/artifacts/
Download Windows S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/bugsweep/notifications-poll-list-alloc/pr-25318-15f2bf9/Decentraland_windows64.zip
Download Mac https://github.com/decentraland/unity-explorer/suites/87428398377/artifacts/9371282188
Download Mac S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/bugsweep/notifications-poll-list-alloc/pr-25318-15f2bf9/Decentraland_macos.zip
Built on 2026-08-19T15:15:13Z

Lint

Warnings not reduced: 12724 => 13131 — remove at least 408 warnings to merge.

Warnings/errors in files changed by this PR (2)
Assets/DCL/WebRequests/GenericDownloadHandlerUtils.cs:298  CSharpWarnings::CS8604  Possible null reference argument for parameter 'target' in 'Newtonsoft.Json.JsonSerializer.Populate'
Assets/DCL/WebRequests/Tests/GenericDownloadHandlerPopulateIntoShould.cs:106  ReturnTypeCanBeNotNullable  Return type of 'ReadJson' can be made non-nullable

Tests

All Unity tests passed ✅

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

@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9804, run #32268989339

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.

Intel Core i5

Metric Baseline Change Δ Result
Samples 2313 (×3) 1794 (×3)
CPU average 38.6 ms (33.4–38.8) 50.5 ms (48.7–55.2) 11.9 ms 🔴 31% slower
CPU 1% worst 322.6 ms (57.0–343.5) 1016.4 ms (978.3–1054.6) 693.8 ms 🔴 215% slower
CPU 0.1% worst 344.1 ms (341.1–360.3) 1022.9 ms (994.2–1087.9) 678.7 ms 🔴 197% slower
GPU average 9.5 ms (9.2–9.6) 10.5 ms (10.0–10.6) 0.9 ms 🔴 10% slower
GPU 1% worst 35.6 ms (23.5–37.7) 94.9 ms (93.1–97.3) 59.3 ms 🔴 167% slower
GPU 0.1% worst 44.4 ms (39.8–45.0) 99.4 ms (97.8–100.0) 55.1 ms 🔴 124% slower
Exceptions per run 66 66 0 ⚪ none new
Exception breakdown
Exception Baseline (3 runs) Change (3 runs)
[UI] DllNotFoundException 192 192
[ENGINE] NullReferenceException 3 3
[ENGINE] ObjectDisposedException 3 3

Apple M1

Metric Baseline Change Δ Result
Samples 4105 (×3) 4089 (×3)
CPU average 21.8 ms (21.8–22.9) 21.9 ms (21.1–22.4) 0.1 ms ⚪ within noise
CPU 1% worst 215.9 ms (215.7–217.7) 205.9 ms (146.5–216.4) -10.0 ms ⚪ within noise
CPU 0.1% worst 226.3 ms (222.9–228.8) 236.8 ms (235.3–240.2) 10.5 ms 🔴 5% slower
GPU average 2.5 ms (2.0–3.2) 8.8 ms (7.9–10.0) 6.3 ms 🔴 254% slower
GPU 1% worst 34.3 ms (34.2–36.2) 35.1 ms (34.0–36.3) 0.8 ms ⚪ within noise
GPU 0.1% worst 36.3 ms (34.8–37.5) 36.8 ms (35.2–38.1) 0.5 ms ⚪ within noise
Exceptions per run 0 0 0 ⚪ none new

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants