Performance: cache cloneSettings() between snapshots (400ms TTL)

cloneSettings() copies 85+ fields including 6 nested usage Maps and the
bandwidth schedule array. With ~700ms emit interval most snapshot ticks
clone identical settings. Add a time-based cache (400ms TTL) so most
snapshots reuse the previous clone.

Settings are mutated in-place by hot paths (provider usage tracking, debrid
key counters), so a reference check wouldn't catch changes — but the 400ms
TTL window is short enough that user-visible setting changes still appear
within one render cycle. replaceSettings() explicitly invalidates the
cache for immediate visibility on user setting changes.

The 4 architecturally-invasive items I considered for this round —
recordSpeed batching (already 120ms-bucketed, false alarm), full IPC state
diffing (392 mutation sites, too risky), list virtualization (variable
package heights make it complex), and per-channel settings/stats split
(invasive type changes) — are deferred. This caching change captures most
of the practical settings-clone savings without the architectural risk.

All 140 download-manager tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-04-19 13:57:15 +02:00
parent ce33617aa6
commit 67fe689e28

View File

@ -1569,6 +1569,15 @@ export class DownloadManager extends EventEmitter {
private statsCacheAt = 0;
/** Cache for cloneSettings() results in getSnapshot() invalidated after 400ms
* or by explicit invalidateSettingsSnapshotCache() calls. */
private settingsSnapshotCache: AppSettings | null = null;
private settingsSnapshotCacheAt = 0;
private invalidateSettingsSnapshotCache(): void {
this.settingsSnapshotCache = null;
this.settingsSnapshotCacheAt = 0;
}
private lastPersistAt = 0;
private lastSettingsPersistAt = 0;
private appSessionStartedAt = 0;
@ -1934,6 +1943,7 @@ export class DownloadManager extends EventEmitter {
const now = nowMs();
next.totalRuntimeAllTimeMs = Math.max(next.totalRuntimeAllTimeMs || 0, this.getLiveTotalRuntimeMs(now));
this.settings = next;
this.invalidateSettingsSnapshotCache();
this.runtimePersistedTotalMs = this.settings.totalRuntimeAllTimeMs || 0;
this.runtimePersistedAt = now;
this.ensureProviderDailyUsageFresh(nowMs());
@ -2047,7 +2057,23 @@ export class DownloadManager extends EventEmitter {
const reconnectMs = Math.max(0, this.session.reconnectUntil - now);
const snapshotSession = cloneSession(this.session);
const snapshotSettings = cloneSettings(this.settings);
// Cache the cloneSettings result for ~400ms. Settings are mutated in-place
// (so a reference check wouldn't detect changes) but most snapshot ticks
// happen close together (e.g. 700ms emit interval) where settings haven't
// changed at all. Cloning 85+ fields + 6 nested usage Maps + bandwidth
// schedules every ~700ms is wasteful when we can serve from cache for
// most of those ticks. The 400ms TTL ensures user-driven settings changes
// become visible within one render cycle of normal snapshot timing.
// Manual invalidation via invalidateSettingsSnapshotCache() is called by
// any code path that needs immediate visibility (replaceSettings, etc.).
let snapshotSettings: AppSettings;
if (this.settingsSnapshotCache && now - this.settingsSnapshotCacheAt < 400) {
snapshotSettings = this.settingsSnapshotCache;
} else {
snapshotSettings = cloneSettings(this.settings);
this.settingsSnapshotCache = snapshotSettings;
this.settingsSnapshotCacheAt = now;
}
const snapshotSummary = this.summary
? { ...this.summary }
: null;