Skip to content

perf: throttle SwiftData query-driven re-renders under heavy ingestion + fix discovery reveal crash#2105

Merged
garthvh merged 3 commits into
mainfrom
perf/throttle-query-body-storms
Jul 17, 2026
Merged

perf: throttle SwiftData query-driven re-renders under heavy ingestion + fix discovery reveal crash#2105
garthvh merged 3 commits into
mainfrom
perf/throttle-query-body-storms

Conversation

@garthvh

@garthvh garthvh commented Jul 17, 2026

Copy link
Copy Markdown
Member

What

Fixes the app pegging a full core (and eventually crashing) under sustained packet ingestion — reproduced against a synthetic 1,600-node DEFCON-shaped TCP stress replay at ~100 packets/second, profiled with sample on the simulator.

The problem

Broad live @Query properties re-evaluated whole view bodies on every SwiftData write, and TabView keeps all tabs alive, so the work ran no matter which tab was frontmost:

  • MeshMapMK re-derived its visible-position state in body — a filter plus density sort over every latest position — on every position write. The sort comparator faults managed objects, multiplying the cost.
  • FilteredNodeList (Nodes tab) rescanned the full node set every 350ms even while off-screen, and an onChange(of: allNodes.count) kept its live query hot in body.
  • Settings held a live query over all nodes for the admin picker, so every node write re-rendered Settings from any other tab.

Under load the three together saturated the main thread — the map, message lists, and every animation janked or froze.

The fix

The same pattern in all three places: views render from a throttled snapshot; nothing in body reads a live query over hot entities.

  • MeshMapMK fetches + derives positions on a ~2s tick while the map is visible, with immediate refreshes on pan/zoom settle, filter edits, tab entry, and foreground/background flips. The existing change-detection key still dedupes snapshot/overlay rebuilds.
  • FilteredNodeList's scan loop runs only while the Nodes tab is frontmost (restarts instantly on tab entry) and fetches explicitly; the router's node index refreshes with it. Filter edits apply on the next tick because the descriptor is built from the live shared filter object at fetch time.
  • Settings refreshes its node snapshot every 2s only while the Settings tab is frontmost.

In before/after profiles under the same replay, the per-write body evaluations for these views drop to zero / a handful of samples; what remains is the ingestion pipeline itself (its own actor, off the main thread — follow-up material: per-message saves + WAL checkpoints could batch).

Crash fix (found by the same stress run)

Local Mesh Discovery → Analyze Current Preset SIGTRAPped mid-scan under load: revealSeededNodesFromDatabase spreads its reveal across most of the dwell with awaits between batches while holding fetched NodeInfoEntity/PositionEntity references. Live ingestion prunes positions during that window, and touching a destroyed entity traps in SwiftData's persisted-property accessor. All needed values are now snapshotted into plain structs in one synchronous pass before the loop, and the loop stops early if a device switch resets the container mid-scan. Verified: the same analyze that reliably crashed now completes under the same flood.

Behavior notes

  • Map annotations, node list, and settings picker refresh within ~2s of data changes under load (previously per-write); pan/zoom, filter edits, and tab switches refresh immediately.
  • No rendering or data changes otherwise.

Summary by CodeRabbit

  • Performance Improvements

    • Improved responsiveness by refreshing node lists, map pins, and settings data on controlled intervals instead of continuously tracking live queries.
    • Reduced unnecessary recomputation when switching tabs, changing filters, or moving the map.
  • Bug Fixes

    • Improved reliability of seeded node reveals during scans and prevented writes after scan/session context becomes invalid.
    • Eliminated stale map pins by ensuring map update gating reacts to changes across the full visible region.
    • Ensured node and settings data refresh correctly when returning to the relevant screens.

garthvh added 2 commits July 16, 2026 22:12
…state on every packet

Under sustained ingestion (large mesh, TCP stress replay, reconnect
node-DB dump) three views pegged the main thread at ~100% CPU because
broad live queries re-evaluated their bodies on every SwiftData write:

- MeshMapMK computed visiblePositionState in body — a filter plus
  density sort over every latest position — per invalidation. The
  positions are now fetched and derived on a ~2s cadence in a task,
  with immediate refreshes on pan/zoom settle, filter edits, tab
  entry, and foreground; a change-detection key still dedupes
  snapshot/overlay rebuilds.
- FilteredNodeList rescanned the full node set every 350ms even while
  the Nodes tab was off-screen (TabView keeps tabs alive), and its
  onChange(of: allNodes.count) kept the live query hot. The scan loop
  now runs only while the Nodes tab is frontmost and fetches
  explicitly; the router node index refreshes with it.
- Settings held a live query over all nodes for the admin picker, so
  every node write re-rendered Settings from any tab. It now reads a
  snapshot refreshed every 2s while the Settings tab is frontmost.

Rendering is unchanged — each view still shows the same data — only
the recompute cadence moved from per-write to a throttled tick.
revealSeededNodesFromDatabase spread its reveal across most of the
dwell with awaits between batches while holding fetched
NodeInfoEntity/PositionEntity references. Live ingestion keeps
mutating the store during that window; touching a position whose row
was pruned mid-reveal trapped in SwiftData's persisted-property
accessor (SIGTRAP, reproduced under a TCP stress replay while running
Analyze Current Preset).

All values the reveal needs are now snapshotted into plain structs in
one synchronous pass before the loop, so no managed object crosses an
await. The loop also stops early if a device switch resets the
container mid-scan instead of trapping on the dead session/result.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f5e6f15f-f7ec-4b8f-8df1-cc5b25de2a67

📥 Commits

Reviewing files that changed from the base of the PR and between 3802d35 and e739291.

📒 Files selected for processing (4)
  • Meshtastic/Services/DiscoveryScanEngine.swift
  • Meshtastic/Views/Nodes/MeshMapMK.swift
  • Meshtastic/Views/Nodes/NodeList.swift
  • Meshtastic/Views/Settings/Settings.swift
🚧 Files skipped from review as they are similar to previous changes (4)
  • Meshtastic/Services/DiscoveryScanEngine.swift
  • Meshtastic/Views/Settings/Settings.swift
  • Meshtastic/Views/Nodes/MeshMapMK.swift
  • Meshtastic/Views/Nodes/NodeList.swift

📝 Walkthrough

Walkthrough

Changes

Snapshot-based data refreshes

Layer / File(s) Summary
Seeded reveal snapshotting
Meshtastic/Services/DiscoveryScanEngine.swift
Seeded node data is captured before timed reveals, with context invalidation guards and snapshot-based bounds checking.
Map position refresh pipeline
Meshtastic/Views/Nodes/MeshMapMK.swift
Map positions now use explicit fetches, derived state keys, throttled refreshes, region/filter triggers, and lifecycle refreshes.
Node and settings snapshots
Meshtastic/Views/Nodes/NodeList.swift, Meshtastic/Views/Settings/Settings.swift
Node and settings data use explicit SwiftData fetches into local snapshots refreshed while their tabs are active.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Poem

I snapshot nodes beneath the moon,
And refresh maps with a humming tune.
The SwiftData stream now waits its turn,
While sleepy tabs wake, fetch, and learn.
No stale hops escape my sight—
Hop, hop, hooray, the state is right!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: throttling SwiftData-driven re-renders and fixing the discovery reveal crash.
Description check ✅ Passed The description explains what changed and why in detail, though it omits explicit testing and checklist sections from the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Meshtastic/Services/DiscoveryScanEngine.swift`:
- Around line 1207-1208: Update the coordinate construction around latitude and
longitude to use node.latestPosition instead of node.positions.last, preserving
the existing 0.0 fallbacks when no latest position is available.
- Line 1209: Update the messageCount value in the discovery result to use
node.user?.sentMessages.count instead of the globally grouped messageCounts
lookup. Apply the same text-message filtering used by the .textMessageApp and
.textMessageCompressedApp handling so the persisted count matches the live
discovery tally, and remove the now-unnecessary global MessageEntity
fetch/grouping if it is only used for this value.

In `@Meshtastic/Views/Nodes/MeshMapMK.swift`:
- Around line 164-166: Update the change-detection key used by the guard in the
position refresh flow around appliedPositionStateKey so it incorporates every
visible position, not only a truncated subset; preserve stable ordering and
count while ensuring changes anywhere in state.positions produce a new key and
trigger refreshVisiblePositionSnapshots.

In `@Meshtastic/Views/Nodes/NodeList.swift`:
- Around line 373-385: Move the router.selectedTab == .nodes guard before the
initial refreshDisplayedNodes() call in NodeList’s .task, so no snapshot fetch
occurs off-screen. In Meshtastic/Views/Settings/Settings.swift lines 766-780,
guard both the onAppear refresh and the task’s initial refresh with
router.selectedTab == .settings; preserve the existing refresh cadence for the
visible tab.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: aca76655-6314-4e83-8ddd-9bccbd0ba296

📥 Commits

Reviewing files that changed from the base of the PR and between 608a2ca and 3802d35.

📒 Files selected for processing (4)
  • Meshtastic/Services/DiscoveryScanEngine.swift
  • Meshtastic/Views/Nodes/MeshMapMK.swift
  • Meshtastic/Views/Nodes/NodeList.swift
  • Meshtastic/Views/Settings/Settings.swift

Comment thread Meshtastic/Services/DiscoveryScanEngine.swift Outdated
Comment thread Meshtastic/Services/DiscoveryScanEngine.swift
Comment thread Meshtastic/Views/Nodes/MeshMapMK.swift
Comment thread Meshtastic/Views/Nodes/NodeList.swift
@github-actions

Copy link
Copy Markdown

📄 Docs staleness warning

This PR modifies user-facing Swift source files but does not update any page under docs/user/ or docs/developer/.

Changed source files:

Meshtastic/Views/Nodes/MeshMapMK.swift
Meshtastic/Views/Nodes/NodeList.swift
Meshtastic/Views/Settings/Settings.swift

What to check:

Changed area Likely doc page
Views/Messages/ docs/user/messages.md
Views/Nodes/ docs/user/nodes.md
Views/Map/ docs/user/map.md
Views/Settings/Bluetooth/ docs/user/bluetooth.md
Views/Settings/Discovery/ docs/user/discovery.md
Views/Settings/MQTT/ docs/user/mqtt.md
Views/Settings/TAK/ docs/user/tak.md
Views/Settings/Firmware/ docs/user/firmware.md
Views/Settings/ (telemetry/sensor) docs/user/telemetry.md
Views/Settings/ (general) docs/user/settings.md
Meshtastic Watch App/ docs/user/watch.md
Model/ docs/developer/swiftdata.md or docs/developer/architecture.md
Accessory/Transports/ docs/developer/transport.md

If this PR does not require a doc update (e.g., internal refactor, bug fix, test change), add the skip-docs-check label to dismiss this warning.

After updating docs/, re-run bash scripts/build-docs.sh --output Meshtastic/Resources/docs locally and commit the regenerated HTML bundle.

…efore initial snapshot fetches

- MeshMapMK: hash every visible position into the change-detection key;
  with the throttled refresh it's the only rebuild gate, and the old
  64-position sample could leave pins beyond it permanently stale.
- DiscoveryScanEngine: snapshot coordinates via latestPosition (O(1)
  cache, deterministic) instead of unsorted positions.last.
- NodeList/Settings: the snapshot tasks re-fire on every tab switch, so
  the frontmost-tab guard now precedes the initial refresh — no more
  full node scan on unrelated tab changes.
@garthvh
garthvh merged commit e8bbbc7 into main Jul 17, 2026
4 of 5 checks passed
@garthvh
garthvh deleted the perf/throttle-query-body-storms branch July 17, 2026 05:54
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