perf+fix: long-session lag, tab-switch lag, log-recovery
Bundles four findings from a stability audit plus the missing-log bug the user reported. 1. main.js _flushUploadLog: ENOENT after the log file's directory got deleted mid-session was swallowed; the buffer was cleared before appendFile so entries were silently lost and the cached target kept pointing at the dead path. Now: mkdirSync(recursive) before every flush idempotently recreates a missing dir; on any append error we invalidate the cache, prepend the chunk back to the buffer and schedule a retry. Survives "user dragged the log folder into the trash and didn't notice". 2. renderer/app.js queueJobs auto-prune: with the default removeFromQueueOnDone=false the queue grew forever. Past ~5000 entries every render became O(N) on a perpetually-growing N and the user saw progressive scroll/tab lag. Cap the in-queue terminal jobs (done/skipped/error/aborted) at 500 most-recent on each batch-done; oldest get pruned with their index entries. 3. sortQueueJobs dynamic-key throttle: status/speed/progress/size sorts ran a full O(N log N) sort on every progress tick. Added a 200ms-window cache for the dynamic-key path so the sort is reused within the same UI_UPDATE_INTERVAL — invisibly small reorder lag, massive cost savings at 5000+ jobs. 4. renderHistoryTable delegated listeners: every Verlauf-tab switch was binding one click listener per row (5000 listeners on a long-history user) and rebuilding the entire <tbody> innerHTML. Single delegated tbody listener covers both row-click (copy link) and th-click (sort), bound once per container via dataset flag. 5. sessionFilesData (recent-files panel) cap at 2000 entries with matching _sessionFileKeys cleanup using the existing separator. Stops the lower-panel innerHTML write from inflating to multiple MB on long sessions. 87/87 tests still green.
This commit is contained in:
parent
e49f5493fe
commit
66f8b47b6d
19
main.js
19
main.js
@ -300,6 +300,11 @@ function _flushUploadLog() {
|
|||||||
if (_uploadLogWriting || _uploadLogBuffer.length === 0) return;
|
if (_uploadLogWriting || _uploadLogBuffer.length === 0) return;
|
||||||
const target = _resolveUploadLogTarget();
|
const target = _resolveUploadLogTarget();
|
||||||
if (!target) { _uploadLogBuffer.length = 0; return; }
|
if (!target) { _uploadLogBuffer.length = 0; return; }
|
||||||
|
// Guard against the file's parent directory having been deleted/moved
|
||||||
|
// since the cache was filled. mkdirSync(recursive:true) is a no-op when
|
||||||
|
// the dir already exists; recreates it otherwise. Without this, every
|
||||||
|
// subsequent flush silently fails with ENOENT and entries are lost.
|
||||||
|
try { fs.mkdirSync(path.dirname(target.path), { recursive: true }); } catch {}
|
||||||
const chunk = _uploadLogBuffer.join('');
|
const chunk = _uploadLogBuffer.join('');
|
||||||
_uploadLogBuffer.length = 0;
|
_uploadLogBuffer.length = 0;
|
||||||
_uploadLogWriting = true;
|
_uploadLogWriting = true;
|
||||||
@ -307,6 +312,18 @@ function _flushUploadLog() {
|
|||||||
_uploadLogWriting = false;
|
_uploadLogWriting = false;
|
||||||
if (err) {
|
if (err) {
|
||||||
debugLog(`uploadLog append failed: ${err.message}`);
|
debugLog(`uploadLog append failed: ${err.message}`);
|
||||||
|
// Recovery: drop the cached target so the next flush re-resolves
|
||||||
|
// (could be ENOENT after dir delete, ENOSPC, EBUSY etc.) and
|
||||||
|
// restore the chunk so we don't lose entries on the retry.
|
||||||
|
_invalidateUploadLogTargetCache();
|
||||||
|
_uploadLogBuffer.unshift(chunk);
|
||||||
|
// Retry on the next event-loop tick rather than tight-looping.
|
||||||
|
if (!_uploadLogFlushTimer) {
|
||||||
|
_uploadLogFlushTimer = setTimeout(() => {
|
||||||
|
_uploadLogFlushTimer = null;
|
||||||
|
_flushUploadLog();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
} else if (target.isFallback && !_uploadLogFallbackWarned) {
|
} else if (target.isFallback && !_uploadLogFallbackWarned) {
|
||||||
_uploadLogFallbackWarned = true;
|
_uploadLogFallbackWarned = true;
|
||||||
// Auto-persist the working fallback into the user's config so the
|
// Auto-persist the working fallback into the user's config so the
|
||||||
@ -317,7 +334,7 @@ function _flushUploadLog() {
|
|||||||
mainWindow.webContents.send('upload-log-fallback', { fallbackPath: target.path });
|
mainWindow.webContents.send('upload-log-fallback', { fallbackPath: target.path });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (_uploadLogBuffer.length) setImmediate(_flushUploadLog);
|
if (_uploadLogBuffer.length && !_uploadLogFlushTimer) setImmediate(_flushUploadLog);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1151,6 +1151,14 @@ const _collatorSimple = new Intl.Collator('de');
|
|||||||
let _queueSortCache = { sig: '', result: [], jobsRef: null };
|
let _queueSortCache = { sig: '', result: [], jobsRef: null };
|
||||||
const _STATIC_SORT_KEYS = new Set(['filename', 'host']);
|
const _STATIC_SORT_KEYS = new Set(['filename', 'host']);
|
||||||
|
|
||||||
|
// Dynamic-key sort throttle: status/speed/progress/size change on every
|
||||||
|
// progress tick, so a strict per-call sort is O(N log N) per render. Within
|
||||||
|
// one UI_UPDATE_INTERVAL window (200ms), reuse the previous sort even if it's
|
||||||
|
// slightly out of order — the user can't perceive sub-200ms reorder lag, and
|
||||||
|
// at 5000 queued jobs this is the difference between smooth and stuttering.
|
||||||
|
let _dynamicSortCache = { key: '', direction: '', jobsRef: null, result: null, ts: 0 };
|
||||||
|
const DYNAMIC_SORT_REFRESH_MS = 200;
|
||||||
|
|
||||||
function sortQueueJobs(jobs) {
|
function sortQueueJobs(jobs) {
|
||||||
const { key, direction } = queueSortState;
|
const { key, direction } = queueSortState;
|
||||||
const factor = direction === 'asc' ? 1 : -1;
|
const factor = direction === 'asc' ? 1 : -1;
|
||||||
@ -1159,6 +1167,13 @@ function sortQueueJobs(jobs) {
|
|||||||
if (sig && _queueSortCache.sig === sig && _queueSortCache.jobsRef === jobs) {
|
if (sig && _queueSortCache.sig === sig && _queueSortCache.jobsRef === jobs) {
|
||||||
return _queueSortCache.result;
|
return _queueSortCache.result;
|
||||||
}
|
}
|
||||||
|
// Dynamic-key throttle: same key+direction+array, sorted within the last
|
||||||
|
// 200ms → reuse. Cleared on user-initiated sort changes (different key).
|
||||||
|
if (!canCache && _dynamicSortCache.jobsRef === jobs &&
|
||||||
|
_dynamicSortCache.key === key && _dynamicSortCache.direction === direction &&
|
||||||
|
_dynamicSortCache.result && (Date.now() - _dynamicSortCache.ts) < DYNAMIC_SORT_REFRESH_MS) {
|
||||||
|
return _dynamicSortCache.result;
|
||||||
|
}
|
||||||
|
|
||||||
const sorted = jobs.slice().sort((a, b) => {
|
const sorted = jobs.slice().sort((a, b) => {
|
||||||
let cmp = 0;
|
let cmp = 0;
|
||||||
@ -1171,6 +1186,7 @@ function sortQueueJobs(jobs) {
|
|||||||
return cmp * factor;
|
return cmp * factor;
|
||||||
});
|
});
|
||||||
if (sig) _queueSortCache = { sig, result: sorted, jobsRef: jobs };
|
if (sig) _queueSortCache = { sig, result: sorted, jobsRef: jobs };
|
||||||
|
else _dynamicSortCache = { key, direction, jobsRef: jobs, result: sorted, ts: Date.now() };
|
||||||
return sorted;
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1969,6 +1985,37 @@ function handleBatchDone(summary) {
|
|||||||
}
|
}
|
||||||
queueJobs = nextJobs;
|
queueJobs = nextJobs;
|
||||||
renderQueueTable();
|
renderQueueTable();
|
||||||
|
} else {
|
||||||
|
// Auto-prune for the default (removeOnDone=false) too: cap done/skipped
|
||||||
|
// jobs at the most recent N so the queue can't grow unbounded across
|
||||||
|
// long sessions. Without this, sortQueueJobs / _computeQueueStats /
|
||||||
|
// renderQueueTable all become O(N) on a forever-growing N and the UI
|
||||||
|
// starts visibly lagging once the queue passes a few thousand entries.
|
||||||
|
// 500 most-recent terminal jobs is enough for "see what just happened"
|
||||||
|
// while stopping the runaway growth.
|
||||||
|
const TERMINAL_KEEP_LIMIT = 500;
|
||||||
|
const terminalIdxs = [];
|
||||||
|
for (let i = 0; i < queueJobs.length; i++) {
|
||||||
|
const s = queueJobs[i].status;
|
||||||
|
if (s === 'done' || s === 'skipped' || s === 'error' || s === 'aborted') terminalIdxs.push(i);
|
||||||
|
}
|
||||||
|
if (terminalIdxs.length > TERMINAL_KEEP_LIMIT) {
|
||||||
|
const dropCount = terminalIdxs.length - TERMINAL_KEEP_LIMIT;
|
||||||
|
// terminalIdxs is in insertion order → first `dropCount` are the
|
||||||
|
// oldest. Remove those, keep everything else.
|
||||||
|
const dropSet = new Set(terminalIdxs.slice(0, dropCount));
|
||||||
|
const nextJobs = [];
|
||||||
|
for (let i = 0; i < queueJobs.length; i++) {
|
||||||
|
if (dropSet.has(i)) {
|
||||||
|
removeJobFromIndex(queueJobs[i]);
|
||||||
|
selectedJobIds.delete(queueJobs[i].id);
|
||||||
|
} else {
|
||||||
|
nextJobs.push(queueJobs[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queueJobs = nextJobs;
|
||||||
|
renderQueueTable();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queueJobs.some((job) => !['done', 'skipped'].includes(job.status))) persistQueueStateSoon(true);
|
if (queueJobs.some((job) => !['done', 'skipped'].includes(job.status))) persistQueueStateSoon(true);
|
||||||
@ -2187,6 +2234,10 @@ function syncSelectedFilesFromQueue() {
|
|||||||
selectedFiles = Array.from(fileMap.values());
|
selectedFiles = Array.from(fileMap.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cap recent-files panel growth so a multi-thousand-job session doesn't
|
||||||
|
// turn every renderRecentUploadsPanel call into a multi-MB innerHTML write.
|
||||||
|
const SESSION_FILES_CAP = 2000;
|
||||||
|
|
||||||
function maybeAddSessionFile(job) {
|
function maybeAddSessionFile(job) {
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
|
|
||||||
@ -2207,6 +2258,18 @@ function maybeAddSessionFile(job) {
|
|||||||
order: sessionFilesData.length
|
order: sessionFilesData.length
|
||||||
});
|
});
|
||||||
_sessionDoneCount++;
|
_sessionDoneCount++;
|
||||||
|
// Drop oldest entries past the cap to keep render cost bounded.
|
||||||
|
// Without this, sessionFilesData grows unbounded across the session
|
||||||
|
// and every renderRecentUploadsPanel call becomes a megabyte-sized
|
||||||
|
// innerHTML write — visible as scroll/click lag in the lower panel.
|
||||||
|
if (sessionFilesData.length > SESSION_FILES_CAP) {
|
||||||
|
const drop = sessionFilesData.length - SESSION_FILES_CAP;
|
||||||
|
for (let i = 0; i < drop; i++) {
|
||||||
|
const r = sessionFilesData[i];
|
||||||
|
_sessionFileKeys.delete(`${r.link}${r.filename}${r.host}`);
|
||||||
|
}
|
||||||
|
sessionFilesData = sessionFilesData.slice(drop);
|
||||||
|
}
|
||||||
// Coalesce rapid successive adds into one render per frame.
|
// Coalesce rapid successive adds into one render per frame.
|
||||||
scheduleRecentRender();
|
scheduleRecentRender();
|
||||||
}
|
}
|
||||||
@ -3577,22 +3640,28 @@ function renderHistoryTable(container) {
|
|||||||
html += '</tbody></table>';
|
html += '</tbody></table>';
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
|
||||||
container.querySelectorAll('th.sortable').forEach(th => {
|
// Delegated listeners: bind once per render-target instead of once per
|
||||||
th.addEventListener('click', () => {
|
// row/header. With a 5000-row history the per-row bind path was a
|
||||||
|
// 5000-iteration synchronous loop on every Verlauf-tab switch — the
|
||||||
|
// dominant cause of "tab switching lags" in the user report.
|
||||||
|
if (!container.dataset.historyListenersBound) {
|
||||||
|
container.dataset.historyListenersBound = '1';
|
||||||
|
container.addEventListener('click', (e) => {
|
||||||
|
const th = e.target.closest('th.sortable');
|
||||||
|
if (th && container.contains(th)) {
|
||||||
const key = th.dataset.historySort;
|
const key = th.dataset.historySort;
|
||||||
if (historySortState.key === key) historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc';
|
if (historySortState.key === key) historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc';
|
||||||
else { historySortState.key = key; historySortState.direction = key === 'date' ? 'desc' : 'asc'; }
|
else { historySortState.key = key; historySortState.direction = key === 'date' ? 'desc' : 'asc'; }
|
||||||
renderHistoryTable(container);
|
renderHistoryTable(container);
|
||||||
});
|
return;
|
||||||
});
|
}
|
||||||
|
const row = e.target.closest('.history-row');
|
||||||
container.querySelectorAll('.history-row').forEach(row => {
|
if (row && !row.classList.contains('error')) {
|
||||||
row.addEventListener('click', () => {
|
|
||||||
if (row.classList.contains('error')) return;
|
|
||||||
const link = row.dataset.link;
|
const link = row.dataset.link;
|
||||||
if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); }
|
if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); }
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortHistoryRows(rows) {
|
function sortHistoryRows(rows) {
|
||||||
|
|||||||
@ -1,27 +1,32 @@
|
|||||||
# Perf/Stabilität Audit Log
|
# 3.3.0 — Performance + Log-Recovery
|
||||||
|
|
||||||
## Abgeschlossen in dieser Session
|
## Probleme (User-Bericht)
|
||||||
|
1. Lag beim Tab-Wechsel + Scrollen nach langer Session.
|
||||||
|
2. File-Uploader-Log: bei einem von zwei Hostern gar keine Log-Einträge — möglicherweise nach Datei/Verzeichnis-Löschung kein Recovery.
|
||||||
|
|
||||||
- [x] **3.1.3** — Doppel-Render beim Retry vieler Jobs entfernt.
|
## Root Causes (aus Audit)
|
||||||
- [x] **3.1.4** — Byse disk-space als account-level klassifiziert (vorher fälschlich file-rejected).
|
|
||||||
- [x] **3.1.5** — Pre-job-swap hinter Semaphore-Queue + Late-Resolve bei save-config.
|
|
||||||
- [x] **3.1.6** — `JSON.stringify(files/hosters)` aus start-upload debugLog raus.
|
|
||||||
- [x] **3.1.7** — Status-Change-Events im Renderer via rAF coalesced.
|
|
||||||
- [x] **3.1.8** — Byse-Poller race-condition fix (kein "newFiles.length===1"-Fallback mehr) + transient-network-classifier mit 2 Tests abgesichert + Memory-Snapshot-Logger bei Batch-Boundaries.
|
|
||||||
|
|
||||||
## Getestet / validiert
|
### Performance
|
||||||
|
- **queueJobs wächst unbounded** (Default `removeFromQueueOnDone=false`). Sortierung + Stats + In-place-Render skalieren O(N) und O(N log N) je render. Nach ~5000 Jobs spürbar.
|
||||||
|
- **`sortQueueJobs` cached nur für static keys** (filename, host). Bei status/speed/progress: voller Sort jedes Mal.
|
||||||
|
- **`renderHistoryTable`** bindet per-row click listener + voller `innerHTML`-Rebuild bei jedem Tab-Switch.
|
||||||
|
- **`renderRecentUploadsPanel`** baut komplettes innerHTML bei sort-change neu, `sessionFilesData` Array ungecapt.
|
||||||
|
|
||||||
- 82 Unit-Tests grün
|
### Log-Bug
|
||||||
- Error-Klassifikation (fileRejected / accountError / transient) hat jetzt eindeutige, getestete Trennlinien
|
- `_flushUploadLog` clear buffer **vor** appendFile. Bei `ENOENT` (Datei oder Dir wurde gelöscht) → silent log + buffer verloren.
|
||||||
- Rotation-Pipeline durchspielbar in Tests (session memory, late-add, override-precedence)
|
- `_resolveUploadLogTarget` cached den Path. Bei mid-session Verzeichnis-Löschung wird der cached target wiederverwendet → permanent ENOENT bis Neustart.
|
||||||
|
|
||||||
## Nicht angegangen (Follow-ups)
|
## Plan
|
||||||
|
- [ ] Log: bei error in `appendFile` → cache invalidieren + buffer prepend + retry
|
||||||
|
- [ ] Log: vor jedem flush mkdirSync (idempotent, recreated deleted dir)
|
||||||
|
- [ ] queueJobs: auto-prune nach `handleBatchDone` für älteste 'done' jobs jenseits Cap (z.B. 500)
|
||||||
|
- [ ] sortQueueJobs: für dynamic keys → coalesce auf 1× pro UI_UPDATE_INTERVAL (200ms)
|
||||||
|
- [ ] renderHistoryTable: delegated click-listener auf tbody (nicht per-row), short-circuit wenn `_historyDirty=false`
|
||||||
|
- [ ] renderRecentUploadsPanel: `sessionFilesData` auf letzten 2000 Einträge cappen, `_sessionFileKeys` parallel prunen
|
||||||
|
- [ ] removeFromQueueOnDone: O(N²) → splice via `_jobIndexById` (oder defer auf batch-done)
|
||||||
|
- [ ] Tests: Log-recovery, queue-prune, sort-throttle
|
||||||
|
- [ ] Release als 3.3.0
|
||||||
|
|
||||||
- **Throughput bei 20+ parallelen Uploads** — bräuchte Lasttest-Setup mit Mock-Hoster; speculative ohne User-Beschwerde.
|
## Skip (Low Impact / kosmetisch)
|
||||||
- **Netz-Ausfall-Recovery** — Klassifikator getestet, echter Network-Interrupt-Integrationstest nicht gemacht (aufwendiger Setup, real-world: Transients werden korrekt erkannt).
|
- CSS transition fix
|
||||||
- **Live Memory-Tracking** — Batch-Boundary-Logging liefert jetzt Datenpunkte. Bei wachsendem `rss`/`heapUsed` über Batches hinweg: Leak-Verdacht, dann in DevTools profilen.
|
- Module-level Sets clearing (genug Memory bisher)
|
||||||
|
|
||||||
## Bekannte externe Issues (nicht fixbar bei uns)
|
|
||||||
|
|
||||||
- Byse "Not video file format" bei manchen MKV-Releases ist Byse-seitige Codec/Container-Validierung. Lösung: Datei vorher remuxen (z.B. mit mkvtoolnix).
|
|
||||||
- Real-Debrid-Downloader + Multi-Hoster-Upload konkurrieren um File-Handles → WinError 5 beim Rename. Workaround: Downloader komplett durchlaufen lassen bevor Queue gezogen wird.
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user