feat(settings): read Node Display settings per-source (#4412 Phase 2) - #4425
Conversation
Phase 2 WP1) There is no runtime global fallback (#2839/#2840), so a source with no stored value falls through to a hardcoded default. Without one home for those defaults, '24' would scatter across a dozen read sites and rot independently. - src/constants/nodeDisplayDefaults.ts: isomorphic, zero imports, so Phase 3 can consume the same runtime values on the frontend. Also carries NODE_DISPLAY_RANGES so validation bounds stop being duplicated between settingsRoutes and server.ts. - src/server/services/nodeDisplaySettings.ts: reader injection, copying the ManagerSettingsDb pattern. It must not import databaseService -- that graph imports into this one. - SettingsRepository.getSettingForSources(): one indexed IN() lookup for a whole selection, so converting the unifiedRoutes fan-out does not become an N+1. Deliberately not built on getSourceSettings, which is a full-table scan (#4419). The allowlist test now imports NODE_DISPLAY_SETTING_KEYS instead of re-declaring the ten keys, so it cross-checks the new module rather than drifting from it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
WP2) sourceDashboardData.ts and unifiedRoutes.ts now resolve maxNodeAgeHours per-source via the WP1 nodeDisplaySettings accessors instead of a single global read. unifiedRoutes' dashboard fan-out uses the batched getMaxNodeAgeHoursForSources() so it stays one query for the whole selection. neighborInfoRoutes.ts read the key 'maxNodeAge', which is not in VALID_SETTINGS_KEYS and always resolved to null, silently pinning the neighbor-info window at the hardcoded 24h fallback regardless of the configured maxNodeAgeHours. It now reads the real per-source setting. User-visible: anyone who tuned maxNodeAgeHours will see their neighbor-info link set change on upgrade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
…WP2)
- neighborInfoRoutes.test.ts: converted from a full vi.mock() fake (which
re-implemented the same dead 'maxNodeAge' key the route read, so it
could not catch the bug) to createRouteTestApp() against the real
router, real DB, and real getMaxNodeAgeHours(). Adds a two-source proof
that different maxNodeAgeHours values filter the same-age link
differently, plus a position-override integration case.
- server.neighbor-info-position.test.ts: deleted. It re-implemented both
neighbor-info route handlers inline (including the same dead key) and
a local copy of the position-override helper. Both are now redundant:
route-level position-override coverage moved into
neighborInfoRoutes.test.ts, and the pure-function override logic
already has dedicated coverage in
src/server/utils/nodeEnhancer.position-override.test.ts.
- sourceDashboardData.perSource.test.ts (new): headline per-source proof
for buildSourceNeighborInfo — two sources with different
maxNodeAgeHours return different neighbor-info sets for an identical
24h-old link; an explicit maxNodeAgeHours argument still overrides the
per-source read (the unified-dashboard batch path).
- unifiedRoutes.perSource.test.ts (new): GET /api/unified/dashboard
proof that two sources bundle different neighborInfo lengths while the
settings read stays a single batched getSettingForSources() call, not
one query per source.
- sourceRoutes.dashboard.test.ts / sourceRoutes.neighbor-info.test.ts /
unifiedRoutes.test.ts: added getSettingForSource(s) to the mocked
settings object so the existing suites keep passing now that the
route/service code resolves maxNodeAgeHours per-source instead of via
the old global getSetting('maxNodeAgeHours').
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
…4412 Phase 2 WP3) Converts every remaining backend read of maxNodeAgeHours and localStatsIntervalMinutes to the per-source accessors added in WP1 (src/server/services/nodeDisplaySettings.ts): - tracerouteRoutes.ts (/recent): maxNodeAgeHours now resolved via getMaxNodeAgeHours(databaseService.settings, recentSourceId ?? null) — a request with ?sourceId= reads that source's window; without it, reads the un-namespaced global row (documented getSettingForSource semantics, not a reintroduced fallback). - pollRoutes.ts (/api/poll): same conversion, scoped to pollSourceId. - meshtasticManager.ts (saveSystemNodeMetrics): scoped to this.sourceId. - virtualNodeServer.ts (sendNodeInfosFromDb): scoped to the manager's sourceId; drops the last databaseService.getSettingAsync use in this file. - src/services/database.ts: getNodeNeedingTracerouteAsync, getNodesNeedingRemoteLocalStatsAsync, getNodeNeedingRemoteAdminCheckAsync all previously read the global this.getSetting('maxNodeAgeHours') despite already having sourceId threaded in from their callers — now scoped. - applyManagerSettings.ts: localStatsIntervalMinutes folds onto the shared getLocalStatsIntervalMinutes() helper instead of duplicating the 0..60 range check inline. Behavior-preserving: both the old null-skip path and the new clamp-to-default path land on 15, matching MeshtasticManager.localStatsIntervalMinutes's class-field default. No read site in this WP's scope stays global — every one already had a sourceId in scope, confirmed by reading each call site before converting. Tests: new database.maxNodeAge.perSource.test.ts proves two sources with different maxNodeAgeHours yield different node sets from getNodeNeedingRemoteAdminCheckAsync/getNodesNeedingRemoteLocalStatsAsync, plus a global-fallback (sourceId omitted) case. Extended tracerouteRoutes.test.ts, pollRoutes.test.ts, virtualNodeServer.zombieFix.test.ts and applyManagerSettings.test.ts with per-source cases and re-pointed their mocks from getSetting/getSettingAsync to getSettingForSource. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
…per-source config (#4412 Phase 2 WP4) inactiveNodeNotificationService now resolves threshold/checkInterval/cooldown per source on every scheduler tick instead of capturing them as instance fields at start() time. A single setInterval drives all sources; a per-source nextRunAt map lets each source honor its own check interval without a timer-per-source (explicitly rejected for lifecycle/cleanup reasons). - start()/stop() no longer take threshold/check/cooldown args; new reschedule(sourceId?) invalidates one source's (or, when omitted, every source's) cached next-run so a settings save takes effect on the next tick without tearing down any timer. - Loop inverted to source-outer/user-inner (per-source due-ness forces this). Due-ness is computed before the users query so the 60s tick doesn't turn an hourly check into a per-minute DB query. nextRunAt is set before doing a source's work so a throw can't hot-loop it, and is pruned when a manager deregisters (bounded map growth, same discipline as #4413). - getStatus().running is now explicit instance state instead of being derived from the interval handle, which doesn't exist during the 60s warm-up (would have regressed to false). - server.ts: deleted the three global getSetting reads + inline 1..720/ 1..1440/1..720 clamping for inactive-node settings; the single tick now resolves and clamps each source's config via getInactiveNodeConfig(). - settingsRoutes.ts: restartInactiveNodeService(threshold, check, cooldown) replaced by rescheduleInactiveNodeService(sourceId?); wired into both the per-source save branch (the side-effect Phase 1 left a TODO for) and the global branch. Range validation for maxNodeAgeHours and the three inactive-node keys now reads bounds from NODE_DISPLAY_RANGES instead of duplicating the literals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
…4412 Phase 2 WP4 review) The zero-users early return in tick() left every due source's nextRunAt unset "so it would be retried on the next tick" — but "no users have inactive-node notifications enabled" is a global condition, not a per-source one, so retrying 60s sooner can't make it true any faster. Since that's the DEFAULT state on a fresh install, this pinned every due source permanently due and turned getUsersWithInactiveNodeNotifications() plus logZeroEligibleDiagnostic()'s own queries into a per-60s poll forever instead of running at each source's configured cadence — the same runaway-work failure class as #4399/#4413. Fix: in that branch, resolve each due source's own checkIntervalMinutes via getInactiveNodeConfig() (wrapped per-source so one failed read can't block scheduling the others) and advance its nextRunAt before returning, exactly as if there had been users but zero eligible nodes. The genuine "don't advance, let it retry sooner" case remains correct: it's the per-source failure path a few lines down, inside the per-source try/catch, where a retry actually can help. Added a regression test asserting the users query is issued once, not once per tick, across several ticks with fake timers when nobody has notifications enabled, plus updated the now-stale "does not advance" assertion to prove the corrected scheduling behavior instead. Full suite: 12636 total, 12506 passed, 0 failed (109 pending — unrelated skips; PG :5433 and MySQL :3307 containers were up for this run). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
|
Claude finished @Yeraze's task —— View job PR Review: feat(settings): read Node Display settings per-source (#4412 Phase 2)
This is a well-engineered, thoroughly documented phase. The PR body is unusually transparent about its own bugs (the scheduler hot-loop regression, the pinned test anti-pattern) and shows genuine ownership of code quality. The observations below are mostly minor; none are blockers given the stated Phase 3/4 scope. Architecture & DesignExcellent decisions:
Minor design observation:
Code Quality
if (INACTIVE_NODE_KEYS.some((key) => key in filteredSettings)) {
callbacks.rescheduleInactiveNodeService?.(null);
logger.debug('✅ Inactive node check rescheduled for all sources');
}The comment correctly explains why
res.json({ success: true, settings: filteredSettings });The per-source branch at line 825 uses
for (const id of this.nextRunAt.keys()) {
if (!activeIds.has(id)) this.nextRunAt.delete(id);
}Iterating a
Bug / Potential Issues
After pruning stale Performance
const resolvedMaxAge = maxNodeAgeHours ?? await getMaxNodeAgeHours(databaseService.settings, source.id);When called from
Test CoverageStrengths:
One gap —
Security
Minor Style
SummaryThe PR achieves its stated goal cleanly: zero global reads of the 10 Node Display keys remain in backend business logic, the scheduler regression is caught and fixed with a pinning test, and two test files that were masking bugs rather than catching them are correctly replaced. The Verdict: Ready to merge with no blocking issues. The observations above are informational for Phase 3/4 planning. |
Review of #4425: pruning nextRunAt and computing `due` both read the same captured managers slice. Not a race (same event loop), but the ordering is load-bearing -- re-reading the registry between them would let the two disagree. Comment-only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
|
Thanks — review triaged. One suggestion applied, one answered with evidence, the rest noted for Phase 3. Applied
Answered —
|
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
|
Claude finished @Yeraze's task —— View job PR Review: feat(settings): read Node Display settings per-source (#4412 Phase 2)
This is a substantial, well-architected phase. The PR body is unusually self-aware about the bugs it found and fixed, and the review history shows the author engaged with all prior feedback constructively. My observations below are mostly confirmatory, with a few new findings. Architecture & DesignStrong decisions:
Code Quality
const maxNodeAgeHours = await getMaxNodeAgeHours(
databaseService.settings,
neighborInfoSourceId ?? null,
);
const tracerouteIntervalMinutes = parseInt(
await databaseService.settings.getSetting('tracerouteIntervalMinutes') || '5'
);
for (const manager of due) {
try {
const cfg = await getInactiveNodeConfig(databaseService.settings, manager.sourceId);
this.nextRunAt.set(manager.sourceId, now + cfg.checkIntervalMinutes * 60_000);
// ... work ...
}
}Inside the zero-users branch, each due source's config is fetched with a separate
res.json({ success: true, settings: filteredSettings });The per-source save at line 825 uses Potential Bugs
maxNodeAgeHours: { min: 1, max: 168, integer: true },But
At Performance
Security
Test CoverageStrengths (directly observed):
One minor gap — zero-users scheduler fix test:
SummaryThe PR is well-executed. The three main goals — zero global reads of the 10 Node Display keys, zero-users scheduler hot-loop fixed, and two bug-masking tests replaced — are all achieved. The The items above are informational for Phase 3/4 planning. No blocking issues found. Verdict: Ready to merge. |
Phase 2 of the per-source Node Display epic (#4412). Converts every backend read of the 10 Node Display keys from global to per-source. Frontend is Phase 3; MeshCore filtering is Phase 4.
Plan:
docs/internal/dev-notes/PER_SOURCE_NODE_DISPLAY_EPIC.mdSpec:
docs/internal/dev-notes/PER_SOURCE_NODE_DISPLAY_PHASE2_SPEC.mdBuilds on Phase 1 (#4417), which seeded
source:{id}:{key}for every existing source via migration 131.GET /api/neighbor-inforead the settings key'maxNodeAge', which has never existed inVALID_SETTINGS_KEYS. It always resolved null and always fell back to 24 hours. The endpoint has been silently pinned at a 24-hour window forever. It now honors the configuredmaxNodeAgeHoursper source, so anyone who tuned that setting will see their neighbor-info link set change on upgrade.What changed
New shared foundation. There is no runtime global fallback (#2839/#2840), so an unset source falls through to a hardcoded default — which meant
24was about to scatter across a dozen read sites and rot independently.src/constants/nodeDisplayDefaults.ts— the values. Isomorphic and import-free so Phase 3 can consume the same runtime values on the frontend (src/server/constants/settings.tswas rejected: the frontend imports onlytypefromsrc/server/**). Also carriesNODE_DISPLAY_RANGES, ending the duplicated validation literals betweensettingsRoutes.tsandserver.ts.src/server/services/nodeDisplaySettings.ts— the accessor, via reader injection copying the existingManagerSettingsDbpattern. It must not importdatabaseService; that graph imports into this one.SettingsRepository.getSettingForSources(ids, key)— one indexedIN()lookup. TheunifiedRoutesdashboard fan-out reads once and broadcasts to every source; converting it naively would have made a hot path N+1. Deliberately not built ongetSourceSettings, which is a full-table scan ([BUG] Per-source settings POST compares against global current-values; getSourceSettings does a full-table scan #4419).~15 read sites converted, including three in
database.ts(getNodeNeedingTracerouteAsync,getNodesNeedingRemoteLocalStatsAsync,getNodeNeedingRemoteAdminCheckAsync) that were reading the global value despite already havingsourceIdthreaded through from every caller. Verified: zero global reads of any of the 10 keys remain outside tests and migrations.Inactive-node service restructured to one timer with per-source config — not timer-per-source, which was rejected for lifecycle coupling. Per-source
nextRunAt, due-ness computed before the users query,nextRunAtset before the work so a throw can't hot-loop, an explicitrunningflag (getStatus()previously derived it from the timer handle and would reportfalseduring the 60s warm-up), and pruning on deregistration.restartInactiveNodeService(t,c,cd)→rescheduleInactiveNodeService(sourceId?).Two tests were pinning bugs rather than catching them
Both are why this phase found what it did:
server.neighbor-info-position.test.tsre-implemented the neighbor-info route body inside the test — including the same dead'maxNodeAge'key. It could not catch the bug because it copied it. Deleted; real-router coverage now lives inneighborInfoRoutes.test.ts(converted tocreateRouteTestApp()) and the existingnodeEnhancer.position-override.test.ts. This is the anti-pattern CLAUDE.md names: a fake that re-implements the logic under test cannot catch regressions in that logic.inactiveNodeNotificationService.test.tsasserted "no eligible users does not advance nextRunAt — the source is retried on the very next tick", pinning the regression below as intended behavior.Regression caught in review, fixed in
707a6d87The restructured scheduler's zero-users branch left every due source permanently due, so
getUsersWithInactiveNodeNotifications()and the zero-eligible diagnostic ran on every 60s tick instead of at each source's configured interval — a 60× increase, and zero-users is the default state on a fresh install. Same failure class as #4399/#4413.The reasoning in the original comment ("nothing was checked, so retry") does not hold: "no users have notifications enabled" is a global condition, not per-source, and cannot become true faster because you re-queried 59 seconds later.
The entire suite passed with this bug present — nothing tested scheduler cadence. The fix ships with a fake-timer test asserting one query across five ticks.
Verification
success: truevia JSON reporter. 109 skips, unchanged from the Phase 1 baseline, so nothing silently stopped running.tsc --noEmitclean.lint:ciratchet clean.*.perSource.test.tsfiles prove two sources with differentmaxNodeAgeHoursget different results, and that two sources with different check intervals each fire on their own schedule.Still not fixed
Per-source permission scoping (#4416) remains open — a source-A-scoped
settings:writegrant still authorizes source-B writes. Do not read this PR as "per-source settings are now secure."Refs #4412
🤖 Generated with Claude Code
https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB