From 17370d942f7b7d67596513c7a634e8239c30b1e5 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Thu, 16 Jul 2026 22:12:12 -0700 Subject: [PATCH 1/3] perf: stop SwiftData query invalidations from re-deriving heavy view state on every packet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Meshtastic/Views/Nodes/MeshMapMK.swift | 81 ++++++++++++++++++------ Meshtastic/Views/Nodes/NodeList.swift | 63 ++++++++++-------- Meshtastic/Views/Settings/Settings.swift | 24 ++++++- 3 files changed, 120 insertions(+), 48 deletions(-) diff --git a/Meshtastic/Views/Nodes/MeshMapMK.swift b/Meshtastic/Views/Nodes/MeshMapMK.swift index 643a0804d..fd1a5d5b8 100644 --- a/Meshtastic/Views/Nodes/MeshMapMK.swift +++ b/Meshtastic/Views/Nodes/MeshMapMK.swift @@ -137,8 +137,36 @@ struct MeshMapMK: View { } } - @Query(filter: #Predicate { $0.nodePosition != nil && $0.latest == true && $0.nodePosition?.ignored != true }) - private var allLatestPositions: [PositionEntity] + /// Latest positions, refreshed on a throttled cadence (see the `.task` in `body`) instead of a + /// live `@Query`. Under heavy ingestion (large mesh, TCP replay, reconnect node-DB dump) a live + /// query invalidates `body` on every position write, and each evaluation re-ran the + /// filter + density sort over the whole set — pegging the main thread at ~100% CPU. A ~2s + /// refresh is imperceptible on a map while pan/zoom/filter changes still refresh immediately. + @State private var allLatestPositions: [PositionEntity] = [] + /// Change-detection key of the last applied position state; skips snapshot/overlay rebuilds + /// when a periodic refresh finds nothing visibly different. + @State private var appliedPositionStateKey: Int64 = .min + + private func fetchLatestPositions() -> [PositionEntity] { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.nodePosition != nil && $0.latest == true && $0.nodePosition?.ignored != true } + ) + return (try? context.fetch(descriptor)) ?? [] + } + + /// Re-fetch positions, rebuild the derived state, and apply it when it actually changed. + /// `force` bypasses the change-detection key (used when visibility flips, so snapshots are + /// rebuilt after being dropped even though the underlying positions are unchanged). + @MainActor + private func refreshPositionState(force: Bool = false) { + allLatestPositions = fetchLatestPositions() + let state = visiblePositionState + guard force || state.key != appliedPositionStateKey else { return } + appliedPositionStateKey = state.key + refreshVisiblePositionSnapshots(from: state.positions) + syncFallbackLocation() + decodeOfflineIfVisible() + } /// Enabled saved routes drawn as polylines + start/finish markers (parity with the old map). @Query(filter: #Predicate { $0.enabled == true }, sort: \RouteEntity.name) @@ -486,7 +514,6 @@ struct MeshMapMK: View { } var body: some View { - let positionState = visiblePositionState NavigationStack { ZStack { mapWithSheets @@ -514,10 +541,14 @@ struct MeshMapMK: View { } .toolbarBackground(.hidden, for: .navigationBar) } - .onChange(of: positionState.key) { - refreshVisiblePositionSnapshots(from: positionState.positions) - syncFallbackLocation() - decodeOfflineIfVisible() + .task(id: isMapVisible) { + // Throttled position refresh: re-derive the visible positions on a gentle + // cadence instead of on every SwiftData write (see `allLatestPositions`). + guard isMapVisible else { return } + while !Task.isCancelled { + refreshPositionState() + try? await Task.sleep(for: .seconds(2)) + } } .onChange(of: offlineMapManager.regions) { reloadOfflineSource() @@ -525,8 +556,14 @@ struct MeshMapMK: View { .onChange(of: overlayInputsKey) { rebuildAllMapContent() } - .onChange(of: allLatestPositions) { - syncFallbackLocation() + .onChange(of: filterRefreshKey) { + // Filter/search edits should reflect immediately, not on the next tick. + refreshPositionState(force: true) + } + .onChange(of: regionRefreshKey) { + // Pan/zoom settled: re-filter to the new region now; the state key dedupes + // the rebuild when the visible set is unchanged. + refreshPositionState() } .onChange(of: accessoryManager.activeDeviceNum) { syncFallbackLocation() @@ -534,7 +571,7 @@ struct MeshMapMK: View { .onChange(of: accessoryManager.isInBackground) { // Foreground/background flips isMapVisible; refresh so the overlay-bearing // snapshots are dropped when backgrounded and rebuilt when foregrounded. - refreshVisiblePositionSnapshots(from: positionState.positions) + refreshPositionState(force: true) } .onChange(of: coverageRunner.importedResult) { _, result in guard let result else { return } @@ -571,7 +608,7 @@ struct MeshMapMK: View { case .offline: mapStyle = MapStyle.hybrid(elevation: .realistic, pointsOfInterest: showPointsOfInterest ? .all : .excludingAll, showsTraffic: showTraffic) } - refreshVisiblePositionSnapshots(from: positionState.positions) + refreshPositionState(force: true) } .onDisappear(perform: { UIApplication.shared.isIdleTimerDisabled = false @@ -583,7 +620,7 @@ struct MeshMapMK: View { syncFallbackLocation() refreshMapWindowOpenState() UIApplication.shared.isIdleTimerDisabled = true - refreshVisiblePositionSnapshots() + refreshPositionState(force: true) applyTraceRouteSelection() consumeCoverageEstimateState() } else { @@ -718,6 +755,19 @@ struct MeshMapMK: View { isMapWindowOpen = attachedScenes.count > 1 } + /// Quantized camera key so `.onChange` can react to pan/zoom settles even though + /// `MKCoordinateRegion` isn't `Equatable`. Same quantization the position-state key uses. + private var regionRefreshKey: Int64 { + var key: Int64 = 0 + if let visibleRegion { + combine(&key, Int64((visibleRegion.center.latitude * 10_000).rounded(.towardZero))) + combine(&key, Int64((visibleRegion.center.longitude * 10_000).rounded(.towardZero))) + combine(&key, Int64((visibleRegion.span.latitudeDelta * 10_000).rounded(.towardZero))) + combine(&key, Int64((visibleRegion.span.longitudeDelta * 10_000).rounded(.towardZero))) + } + return key + } + private var filterRefreshKey: Int64 { var key: Int64 = 0 combine(&key, preciseLocationsOnly ? 1 : 0) @@ -739,13 +789,6 @@ struct MeshMapMK: View { return key } - private func refreshVisiblePositionSnapshots() { - visiblePositionSnapshots = makePositionSnapshots(from: visiblePositions) - spreadOverrides = computeSpreadOverrides(visiblePositionSnapshots) - frameInitialRegionIfNeeded() - rebuildOverlays() - } - private func refreshVisiblePositionSnapshots(from positions: [PositionEntity]) { visiblePositionSnapshots = makePositionSnapshots(from: positions) spreadOverrides = computeSpreadOverrides(visiblePositionSnapshots) diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 088728fa3..44e10c5a8 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -213,8 +213,6 @@ private struct NodeListEntry: Identifiable { private struct FilteredNodeList: View { @EnvironmentObject var accessoryManager: AccessoryManager @EnvironmentObject var router: Router - @Query(sort: \NodeInfoEntity.lastHeard, order: .reverse) - private var allNodes: [NodeInfoEntity] @Environment(\.modelContext) private var context /// Throttled snapshot of the filtered/sorted nodes actually shown. Recomputed on a gentle /// cadence (see `.task`) instead of in `body`, so the full-node-set scan in `displayNodes` @@ -248,18 +246,25 @@ private struct FilteredNodeList: View { self._nodeForDisplayNameEdit = nodeForDisplayNameEdit self._nodeListDensity = nodeListDensity self._selectedNodeNum = selectedNodeNum + } - // Push simple filters into the SwiftData predicate to reduce in-memory work - let showIgnored = withFilters.isIgnored - let showFavorite = withFilters.isFavorite - let filterViaLoraOnly = withFilters.viaLora && !withFilters.viaMqtt - let filterViaMqttOnly = !withFilters.viaLora && withFilters.viaMqtt - let filterHopsDirect = withFilters.hopsAway == 0.0 - let filterHopsMax = withFilters.hopsAway > 0.0 - let maxHops = Int32(withFilters.hopsAway) - - _allNodes = Query( - filter: #Predicate { node in + /// Fetch descriptor for the node set, built from the live shared `filters` at fetch time so + /// predicate-level filter edits apply on the next refresh tick. Applied via explicit fetches + /// on a throttled cadence (see `.task`) rather than a live `@Query` — a live query + /// re-evaluated `body` on every SwiftData write, and under heavy ingestion that meant a + /// full-node-set scan per packet, even while the Nodes tab was off-screen (TabView keeps + /// tabs alive). Simple filters are pushed into the predicate to reduce in-memory work. + private func makeNodeFetchDescriptor() -> FetchDescriptor { + let showIgnored = filters.isIgnored + let showFavorite = filters.isFavorite + let filterViaLoraOnly = filters.viaLora && !filters.viaMqtt + let filterViaMqttOnly = !filters.viaLora && filters.viaMqtt + let filterHopsDirect = filters.hopsAway == 0.0 + let filterHopsMax = filters.hopsAway > 0.0 + let maxHops = Int32(filters.hopsAway) + + return FetchDescriptor( + predicate: #Predicate { node in // Ignored filter (always applied) (showIgnored || !node.ignored) && (!showIgnored || node.ignored) && @@ -274,12 +279,11 @@ private struct FilteredNodeList: View { // Hops within range (!filterHopsMax || (node.hopsAway > 0 && node.hopsAway <= maxHops)) }, - sort: \NodeInfoEntity.lastHeard, - order: .reverse + sortBy: [SortDescriptor(\NodeInfoEntity.lastHeard, order: .reverse)] ) } - private func displayNodes(activeNodeNum: Int64?) -> [NodeListEntry] { + private func displayNodes(from allNodes: [NodeInfoEntity], activeNodeNum: Int64?) -> [NodeListEntry] { let searchText = filters.searchText.lowercased() let onlineThreshold = filters.isOnline ? Date().addingTimeInterval(-7_200) : nil let distanceBounds = filters.currentDistanceBounds @@ -366,22 +370,27 @@ private struct FilteredNodeList: View { } } .navigationTitle(String.localizedStringWithFormat("Nodes (%@)".localized, String(displayedNodes.count))) - .task { + .task(id: router.selectedTab) { // Recompute the displayed list on a gentle cadence instead of inside `body`. - // During live ingestion the node @Query invalidates on every packet; running - // displayNodes (a scan over the whole node set) per write pegged the main thread - // on reconnect with a large DB. ~3/sec is imperceptible and keeps CPU sane. + // During live ingestion every packet writes to SwiftData; running displayNodes + // (a scan over the whole node set) per write pegged the main thread on reconnect + // with a large DB. ~3/sec is imperceptible and keeps CPU sane. The scan only runs + // while the Nodes tab is frontmost — TabView keeps this view alive on other tabs, + // where the stale snapshot is invisible anyway; switching back restarts the task + // and refreshes immediately. + refreshDisplayedNodes() + guard router.selectedTab == .nodes else { return } while !Task.isCancelled { - replaceDisplayedNodesIfNeeded(with: displayNodes(activeNodeNum: accessoryManager.activeDeviceNum)) try? await Task.sleep(for: .milliseconds(350)) + refreshDisplayedNodes() } } - .onAppear { - router.updateNodeIndex(from: allNodes) - } - .onChange(of: allNodes.count) { _, _ in - router.updateNodeIndex(from: allNodes) - } + } + + private func refreshDisplayedNodes() { + let allNodes = (try? context.fetch(makeNodeFetchDescriptor())) ?? [] + replaceDisplayedNodesIfNeeded(with: displayNodes(from: allNodes, activeNodeNum: accessoryManager.activeDeviceNum)) + router.updateNodeIndex(from: allNodes) } @ViewBuilder diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 9f8c9c52d..846825ac3 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -15,8 +15,16 @@ struct Settings: View { @Environment(\.modelContext) private var context @Environment(\.colorScheme) private var colorScheme @EnvironmentObject var accessoryManager: AccessoryManager - @Query(sort: \NodeInfoEntity.lastHeard, order: .reverse) - private var nodes: [NodeInfoEntity] + /// Node list for the admin picker / config gating, refreshed on a throttled cadence (see the + /// `.task` in `body`) instead of a live `@Query`. A live query re-evaluated this whole view's + /// `body` on every node write — and TabView keeps Settings alive on other tabs, so under heavy + /// ingestion (large mesh, TCP replay) it burned main-thread CPU while completely off-screen. + @State private var nodes: [NodeInfoEntity] = [] + + private func refreshNodes() { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\NodeInfoEntity.lastHeard, order: .reverse)]) + nodes = (try? context.fetch(descriptor)) ?? [] + } /// Nodes for the admin / configuration picker, ordered favorites-first while /// preserving the `@Query`'s `lastHeard`-descending order within each group. The @@ -755,11 +763,23 @@ struct Settings: View { // If the selection hasn't be initialized yet, try to initalize it. // If we are not fully connected yet, then setSelectedNode will // not select the node and it will remain 0 + refreshNodes() if self.preferredNodeNum <= 0 { self.preferredNodeNum = UserDefaults.preferredPeripheralNum setSelectedNode(to: UserDefaults.preferredPeripheralNum) } } + .task(id: router.selectedTab) { + // Refresh the node snapshot on a gentle cadence, and only while Settings is + // the frontmost tab — the stale snapshot is invisible from other tabs, and + // switching here restarts the task for an immediate refresh. + refreshNodes() + guard router.selectedTab == .settings else { return } + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(2)) + refreshNodes() + } + } .navigationTitle("Settings") .toolbar { ToolbarItem(placement: .topBarLeading) { From 3802d3504efb7e0da5623e6d9f01cb238dc91ddc Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Thu, 16 Jul 2026 22:12:12 -0700 Subject: [PATCH 2/3] fix(discovery): snapshot node values before the timed seeded-reveal loop 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. --- Meshtastic/Services/DiscoveryScanEngine.swift | 88 +++++++++++++------ 1 file changed, 61 insertions(+), 27 deletions(-) diff --git a/Meshtastic/Services/DiscoveryScanEngine.swift b/Meshtastic/Services/DiscoveryScanEngine.swift index 26c95444f..85b25f069 100644 --- a/Meshtastic/Services/DiscoveryScanEngine.swift +++ b/Meshtastic/Services/DiscoveryScanEngine.swift @@ -1176,43 +1176,77 @@ extension DiscoveryScanEngine { let userLat = session.userLatitude let userLon = session.userLongitude + // Snapshot every value the reveal needs in one synchronous pass, BEFORE the timed loop. + // The loop below awaits between batches for most of the dwell; live ingestion keeps + // mutating the store the whole time, and touching a `NodeInfoEntity`/`PositionEntity` + // whose row was pruned mid-reveal traps in SwiftData's persisted-property accessor + // (reproduced under a 100 pkt/s TCP stress replay). Plain values can't be invalidated. + struct SeededNodeSnapshot { + let nodeNum: Int64 + let shortName: String + let longName: String + let hopCount: Int + let snr: Float + let rssi: Int + let isInfrastructure: Bool + let latitude: Double + let longitude: Double + let messageCount: Int + let sensorPacketCount: Int + } + let snapshots: [SeededNodeSnapshot] = candidates.map { node in + SeededNodeSnapshot( + nodeNum: node.num, + shortName: node.user?.shortName ?? "", + longName: node.user?.longName ?? "", + hopCount: Int(node.hopsAway), + snr: node.snr, + rssi: Int(node.rssi), + // Infrastructure roles: Router (2), Router Late (11), Client Base (12) + isInfrastructure: [2, 11, 12].contains(Int(node.user?.role ?? 0)), + latitude: node.positions.last?.latitude ?? 0.0, + longitude: node.positions.last?.longitude ?? 0.0, + messageCount: messageCounts[node.num] ?? 0, + // Sensor packets ≈ environment (1) + air-quality (3) telemetry the node has reported, + // matching the live scan's sensorPacketCount (.environmentMetrics + .airQualityMetrics). + sensorPacketCount: node.telemetries.filter { $0.metricsType == 1 || $0.metricsType == 3 }.count + ) + } + // Spread the reveal across ~85% of the dwell (so it finishes before finalize), in batches // over ~120 ticks for a smooth, accelerated fill regardless of mesh size. let revealWindow = max(2.0, dwellDuration * 0.85) let ticks = 120.0 let tick = max(0.08, revealWindow / ticks) - let batchSize = max(1, Int((Double(candidates.count) / ticks).rounded(.up))) + let batchSize = max(1, Int((Double(snapshots.count) / ticks).rounded(.up))) var index = 0 var seeded = 0 - while index < candidates.count { + while index < snapshots.count { if Task.isCancelled { break } - let end = min(index + batchSize, candidates.count) - for node in candidates[index.. Date: Thu, 16 Jul 2026 22:26:46 -0700 Subject: [PATCH 3/3] review: full-coverage position key, latestPosition accessor, guards before initial snapshot fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- Meshtastic/Services/DiscoveryScanEngine.swift | 4 ++-- Meshtastic/Views/Nodes/MeshMapMK.swift | 10 ++++------ Meshtastic/Views/Nodes/NodeList.swift | 8 ++++---- Meshtastic/Views/Settings/Settings.swift | 7 ++++--- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/Meshtastic/Services/DiscoveryScanEngine.swift b/Meshtastic/Services/DiscoveryScanEngine.swift index 85b25f069..71b7b09b8 100644 --- a/Meshtastic/Services/DiscoveryScanEngine.swift +++ b/Meshtastic/Services/DiscoveryScanEngine.swift @@ -1204,8 +1204,8 @@ extension DiscoveryScanEngine { rssi: Int(node.rssi), // Infrastructure roles: Router (2), Router Late (11), Client Base (12) isInfrastructure: [2, 11, 12].contains(Int(node.user?.role ?? 0)), - latitude: node.positions.last?.latitude ?? 0.0, - longitude: node.positions.last?.longitude ?? 0.0, + latitude: node.latestPosition?.latitude ?? 0.0, + longitude: node.latestPosition?.longitude ?? 0.0, messageCount: messageCounts[node.num] ?? 0, // Sensor packets ≈ environment (1) + air-quality (3) telemetry the node has reported, // matching the live scan's sensorPacketCount (.environmentMetrics + .airQualityMetrics). diff --git a/Meshtastic/Views/Nodes/MeshMapMK.swift b/Meshtastic/Views/Nodes/MeshMapMK.swift index fd1a5d5b8..75431088b 100644 --- a/Meshtastic/Views/Nodes/MeshMapMK.swift +++ b/Meshtastic/Views/Nodes/MeshMapMK.swift @@ -244,17 +244,15 @@ struct MeshMapMK: View { combine(&key, Int64((visibleRegion.span.longitudeDelta * 10_000).rounded(.towardZero))) } combine(&key, filterRefreshKey) - for position in positions.prefix(64) { + // Hash EVERY visible position: since the throttled refresh, this key is the only gate for + // snapshot/overlay rebuilds, and a truncated sample would leave pins beyond it permanently + // stale when a node moves without changing the set's count or ordering. + for position in positions { combine(&key, Int64(truncatingIfNeeded: position.persistentModelID.hashValue)) combine(&key, Int64(position.latitudeI)) combine(&key, Int64(position.longitudeI)) combine(&key, Int64(position.precisionBits)) } - if let last = positions.last { - combine(&key, Int64(truncatingIfNeeded: last.persistentModelID.hashValue)) - combine(&key, Int64(last.latitudeI)) - combine(&key, Int64(last.longitudeI)) - } return MeshMapVisiblePositionState(positions: positions, key: key) } diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 44e10c5a8..0e26a3c53 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -375,11 +375,11 @@ private struct FilteredNodeList: View { // During live ingestion every packet writes to SwiftData; running displayNodes // (a scan over the whole node set) per write pegged the main thread on reconnect // with a large DB. ~3/sec is imperceptible and keeps CPU sane. The scan only runs - // while the Nodes tab is frontmost — TabView keeps this view alive on other tabs, - // where the stale snapshot is invisible anyway; switching back restarts the task - // and refreshes immediately. - refreshDisplayedNodes() + // while the Nodes tab is frontmost — TabView keeps this view alive on other tabs + // (and this task re-fires on every tab switch), so the guard comes first; entering + // the tab re-fires the task and refreshes immediately. guard router.selectedTab == .nodes else { return } + refreshDisplayedNodes() while !Task.isCancelled { try? await Task.sleep(for: .milliseconds(350)) refreshDisplayedNodes() diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 846825ac3..894e32ed5 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -771,10 +771,11 @@ struct Settings: View { } .task(id: router.selectedTab) { // Refresh the node snapshot on a gentle cadence, and only while Settings is - // the frontmost tab — the stale snapshot is invisible from other tabs, and - // switching here restarts the task for an immediate refresh. - refreshNodes() + // the frontmost tab — the stale snapshot is invisible from other tabs, this + // task re-fires on every tab switch (so the guard comes first), and switching + // here restarts it for an immediate refresh. guard router.selectedTab == .settings else { return } + refreshNodes() while !Task.isCancelled { try? await Task.sleep(for: .seconds(2)) refreshNodes()