Skip to content

perf(rum): spread bursts of RUM calls over later turns - #86

Merged
Fiona2016 merged 1 commit into
mainfrom
perf/rum-dispatch-queue
Nov 10, 2026
Merged

Fiona2016 merged 1 commit into
mainfrom
perf/rum-dispatch-queue

Conversation

@Fiona2016

Copy link
Copy Markdown
Collaborator

Problem

Every RUM event is assembled, serialized and written to disk synchronously on the caller's thread, usually the UI thread. A host that reports thousands of events in a row, for example one custom action per synced message, holds that thread for as long as the burst takes. On a current phone that is about 1 s for 3,700 actions with ~350-character attributes. A burst like that also writes a full view document per action. A few thousand actions in a row exceed the 4 MB disk quota before the uploader catches up, and the oldest batches are evicted.

Change

DefaultRumMonitor.dispatch becomes adaptive:

  • Inline path: within one turn of the event loop, events are processed on the caller's turn until the SDK has spent 4 ms of that turn. Occasional calls behave exactly as before.
  • Queued path: past the budget, calls are queued and processed in 4 ms slices on later turns (setTimeout(0)), so the thread yields between slices.
    • The queue is bounded at 5,000 events. At the bound, a slice runs before the event is queued.
    • Events are built at call time and keep the timestamp, attributes and order of their call.
  • Synchronous drains: the queue is processed synchronously before anything that reads or changes the scope tree outside it:
    • markCrash, reportError and openLaunchView, so a persisted crash incident sees every earlier call;
    • keep-alive, which covers background, flush and stop;
    • session queries, withheld-session changes and drawDetachedSession;
    • a new core→RUM drain_pending message. FlashcatCore sends it before setUserInfo, clearUserInfo and a tracking-consent change, so queued events keep the user and consent of their call.
  • After stop: calls are processed on the caller's turn, as before.
  • View coalescing (RumViewScope): while queued events are processed, the view document an action updates is written once per slice instead of once per action. A failing deferred write is logged; it does not stop later slices or throw into the timer.

Measurements

HarmonyOS phone (API 26), debug build, 350-character action attributes. Before is main, after is this branch. One run per scenario.

Scenario Before After
1,000 actions in one loop, longest main-thread stall ~300 ms 42 ms
3,700 actions in one loop, longest main-thread stall 1,038 ms 61 ms
300 actions/s for 12 s, frames over 16 ms 122 2
SDK CPU for the 3,700-action burst ~1,650 ms ~1,100 ms
3,700 actions within one second, actions that reached the intake ~2,000 (quota eviction) 3,700

Per action on main: ~60 µs to serialize the three documents and ~100 µs for three open/append/close calls.

Note for integrators: wrapping each call in its own setTimeout(0) on the host side does not help. Thousands of timers that are due together run back to back in one turn: 3,700 timers produced a 1,028 ms stall.

Tests

  • node scripts/unit-node/run.mjs: 622 passed.
  • New flashcat-rum/src/test/DispatchQueue.test.ets and a core test in AppForeground.test.ets cover:
    • inline processing within the budget;
    • spreading over turns in call order, with call-time timestamps;
    • one view write per slice;
    • drains on keep-alive, markCrash, openLaunchView, session queries, stop and drain_pending;
    • recovery after a failing deferred view write;
    • inline processing after stop;
    • the core announcing drain_pending before user and consent changes.
  • Negative controls: each new behaviour's test fails with that part of the change reverted. Without the try/catch around deferred view writes, the exception escapes the SDK's timer and terminates the process.
  • hvigorw assembleHar passes for all modules, and a HAP that compiles the SDK sources passes too.

Known limits

  • An event mapper that itself changes the user or the consent while queued events are being processed does not trigger a nested drain. The remaining queued events then carry the new value.
  • Events still queued when a native crash kills the process are lost. JS crashes, background, flush and stop all drain first.
  • addError, the session queries and the other synchronous drains process the whole queue on the caller's turn. The 4 ms per-turn bound applies to the queued path.

Every RUM event was assembled, serialized and written to disk synchronously
on the caller's thread, usually the UI thread. A host reporting thousands
of events in a row (for example one action per synced message) held that
thread for as long as the burst took: about 1 s for 3,700 actions on a
current phone.

Within one turn of the event loop the monitor now processes events on the
caller's turn for at most 4 ms. Calls past that budget are queued and
processed in slices of the same size on later turns (setTimeout 0), so the
thread yields between slices. The queue is bounded at 5,000 events; at the
bound a slice runs before the event is queued.

- Events are built at call time and keep the timestamp and attributes of
  their call, and their order.
- The queue is processed synchronously before a crash is counted
  (markCrash, reportError) or a launch view is opened for a JS fault
  (openLaunchView), before keep-alive (background, flush and stop),
  before session queries and withheld-session changes, and before the user
  or the tracking consent changes: the core sends RUM a drain_pending
  message before it applies either.
- After stop, calls are processed on the caller's turn as before.
- While queued events are processed, the view document an action updates
  is written once per slice instead of once per action. A burst writes
  about half as much, so a few thousand actions in a row no longer exceed
  the 4 MB disk quota and evict batches before they are uploaded.
- A failing deferred view write is logged and does not stop later slices
  or throw into the timer.

Measured on a HarmonyOS phone (API 26, debug build) with 350-character action
attributes:
- longest main-thread stall for 3,700 actions in one loop: 1,038 ms ->
  61 ms;
- frames over 16 ms at 300 actions/s for 12 s: 122 -> 2;
- SDK CPU for the 3,700-action burst: about one third lower.
@Fiona2016
Fiona2016 merged commit 3ba2b87 into main Nov 10, 2026
6 checks passed
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.

1 participant