Performance: cloneSession shallow refs + scheduler 1-pass + speed obj alloc
Three deeper optimizations focused on hot allocations: 1. cloneSession(): items/packages references shared instead of per-item shallow clone. The IPC layer runs structuredClone() in the same tick so the renderer always gets an isolated copy; in-process consumers read snapshots synchronously without mutating. Eliminates ~5000 object allocations per emit on a 5000-item queue. 2. findNextQueuedItem(): single-pass priority scan instead of 3 separate passes (high → normal → low). Returns immediately on high-priority match; collects best normal/low candidate while iterating. Saves up to 2x O(n) iterations per scheduler tick. 3. packageSpeedBps: direct loop assembly instead of Object.fromEntries([...Map].map(...)) (3 allocs per entry → 1). Idle case now returns a stable EMPTY_PACKAGE_SPEED_BPS reference so the renderer's useMemo on it doesn't recompute on every snapshot while the queue is paused/stopped. All 565 tests green. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
459d078cb0
commit
5b4ad99923
@ -363,21 +363,25 @@ function generateHistoryId(): string {
|
|||||||
return `hist-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
return `hist-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stable empty object reference reused for snapshots when no package speeds
|
||||||
|
* are active. Avoids allocating a fresh `{}` per snapshot which breaks
|
||||||
|
* React.memo()/useMemo dependency comparisons in the renderer. */
|
||||||
|
const EMPTY_PACKAGE_SPEED_BPS: Readonly<Record<string, number>> = Object.freeze({});
|
||||||
|
|
||||||
function cloneSession(session: SessionState): SessionState {
|
function cloneSession(session: SessionState): SessionState {
|
||||||
const clonedItems: Record<string, DownloadItem> = {};
|
// Shallow clone only — items/packages are emitted to the renderer via IPC,
|
||||||
for (const key of Object.keys(session.items)) {
|
// which runs structuredClone() on the payload in the same event-loop tick
|
||||||
clonedItems[key] = { ...session.items[key] };
|
// (so the renderer always gets an isolated deep copy). All in-process
|
||||||
}
|
// consumers of getSnapshot() (app-controller, debug-server, link export)
|
||||||
const clonedPackages: Record<string, PackageEntry> = {};
|
// read the snapshot synchronously without mutating it. Doing a per-item
|
||||||
for (const key of Object.keys(session.packages)) {
|
// shallow clone here was a redundant ~5000 object allocations per emit
|
||||||
const pkg = session.packages[key];
|
// for a 5000-item queue. Cloning only the outer Records keeps consumers
|
||||||
clonedPackages[key] = { ...pkg, itemIds: [...pkg.itemIds] };
|
// safe from later additions/removals while avoiding per-item allocation.
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...session,
|
...session,
|
||||||
packageOrder: [...session.packageOrder],
|
packageOrder: [...session.packageOrder],
|
||||||
packages: clonedPackages,
|
packages: { ...session.packages },
|
||||||
items: clonedItems
|
items: { ...session.items }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2060,9 +2064,17 @@ export class DownloadManager extends EventEmitter {
|
|||||||
canPause: this.session.running,
|
canPause: this.session.running,
|
||||||
clipboardActive: this.settings.clipboardWatch,
|
clipboardActive: this.settings.clipboardWatch,
|
||||||
reconnectSeconds: Math.ceil(reconnectMs / 1000),
|
reconnectSeconds: Math.ceil(reconnectMs / 1000),
|
||||||
packageSpeedBps: !this.session.running || paused ? {} : Object.fromEntries(
|
packageSpeedBps: !this.session.running || paused
|
||||||
[...this.speedBytesPerPackage].map(([pid, bytes]) => [pid, Math.floor(bytes / SPEED_WINDOW_SECONDS)])
|
? EMPTY_PACKAGE_SPEED_BPS
|
||||||
)
|
: (() => {
|
||||||
|
// Direct loop avoids the [...Map].map().Object.fromEntries() allocation
|
||||||
|
// chain (3 allocations per entry → 1).
|
||||||
|
const out: Record<string, number> = {};
|
||||||
|
for (const [pid, bytes] of this.speedBytesPerPackage) {
|
||||||
|
out[pid] = Math.floor(bytes / SPEED_WINDOW_SECONDS);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
})()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -7579,44 +7591,56 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private findNextQueuedItem(): { packageId: string; itemId: string } | null {
|
private findNextQueuedItem(): { packageId: string; itemId: string } | null {
|
||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
const priorityOrder: Array<PackagePriority> = ["high", "normal", "low"];
|
// Single-pass priority selection: instead of iterating all packages 3 times
|
||||||
for (const prio of priorityOrder) {
|
// (once per priority tier), iterate once and remember the best
|
||||||
|
// normal/low candidate found. "high" priority returns immediately. This
|
||||||
|
// saves up to 2x O(n) passes per scheduler tick on large queues where
|
||||||
|
// most packages have the default "normal" priority.
|
||||||
|
let normalCandidate: { packageId: string; itemId: string } | null = null;
|
||||||
|
let lowCandidate: { packageId: string; itemId: string } | null = null;
|
||||||
|
|
||||||
for (const packageId of this.session.packageOrder) {
|
for (const packageId of this.session.packageOrder) {
|
||||||
const pkg = this.session.packages[packageId];
|
const pkg = this.session.packages[packageId];
|
||||||
if (!pkg || pkg.cancelled || !pkg.enabled) {
|
if (!pkg || pkg.cancelled || !pkg.enabled) continue;
|
||||||
continue;
|
if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) continue;
|
||||||
}
|
const pkgPrio = pkg.priority || "normal";
|
||||||
if ((pkg.priority || "normal") !== prio) {
|
// Once we've found a normal candidate we don't need to scan low-priority
|
||||||
continue;
|
// packages anymore — they would lose anyway.
|
||||||
}
|
if (normalCandidate && pkgPrio === "low") continue;
|
||||||
if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) {
|
// If we already have a normal candidate and this package is also normal,
|
||||||
continue;
|
// keep scanning anyway (we still need to check if it has a startable
|
||||||
}
|
// item — but the first one wins, this is just for correctness).
|
||||||
|
if (normalCandidate && pkgPrio === "normal") continue;
|
||||||
|
|
||||||
for (const itemId of pkg.itemIds) {
|
for (const itemId of pkg.itemIds) {
|
||||||
const item = this.session.items[itemId];
|
const item = this.session.items[itemId];
|
||||||
if (!item) {
|
if (!item) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const retryAfter = this.retryAfterByItem.get(itemId) || 0;
|
const retryAfter = this.retryAfterByItem.get(itemId) || 0;
|
||||||
if (retryAfter > now) {
|
if (retryAfter > now) continue;
|
||||||
continue;
|
if (item.status !== "queued" && item.status !== "reconnect_wait") continue;
|
||||||
|
if (this.delayPacedStartForItem(item, now)) continue;
|
||||||
|
if (this.shouldDelayStartForItem(item)) continue;
|
||||||
|
|
||||||
|
const candidate = { packageId, itemId };
|
||||||
|
if (pkgPrio === "high") {
|
||||||
|
if (retryAfter > 0) this.retryAfterByItem.delete(itemId);
|
||||||
|
return candidate; // highest priority — return immediately
|
||||||
}
|
}
|
||||||
if (retryAfter > 0) {
|
if (pkgPrio === "normal") {
|
||||||
this.retryAfterByItem.delete(itemId);
|
normalCandidate = candidate;
|
||||||
|
} else if (!lowCandidate) {
|
||||||
|
lowCandidate = candidate;
|
||||||
}
|
}
|
||||||
if (item.status === "queued" || item.status === "reconnect_wait") {
|
break; // stop scanning items in this package
|
||||||
if (this.delayPacedStartForItem(item, now)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (this.shouldDelayStartForItem(item)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return { packageId, itemId };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chosen = normalCandidate || lowCandidate;
|
||||||
|
if (chosen) {
|
||||||
|
const retryAfter = this.retryAfterByItem.get(chosen.itemId) || 0;
|
||||||
|
if (retryAfter > 0) this.retryAfterByItem.delete(chosen.itemId);
|
||||||
}
|
}
|
||||||
}
|
return chosen;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single-pass alternative to hasQueuedItems + hasDelayedQueuedItems.
|
/** Single-pass alternative to hasQueuedItems + hasDelayedQueuedItems.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user