diff --git a/docs/performance-efficiency-plan.md b/docs/performance-efficiency-plan.md index 5595ab34..37e6835b 100644 --- a/docs/performance-efficiency-plan.md +++ b/docs/performance-efficiency-plan.md @@ -1,6 +1,6 @@ # BitVid Performance & Efficiency Plan -Status: **Stage 0 in progress — local-only measurement harness added; no runtime optimization changes implemented** +Status: **Stage 2 in progress — startup hardening validated locally; recommendation indexes now activate on demand** ## Goal @@ -173,6 +173,14 @@ old diagnostic behavior. ## Stage 2 — Lazy recommendation indexes +Implemented first slice: `ExploreDataService` is no longer created during +ordinary bootstrap. Home/Profile sessions therefore create no recommendation +worker or recommendation intervals. Opening For You or Explore creates and +activates the service; events received while inactive mark it dirty and are +included in the activation refresh. Focused lifecycle, feed-coordinator, and +build checks pass; browser validation of both recommendation views remains the +next gate before further Stage 2 coalescing work. + ### Change Do not initialize `ExploreDataService` globally in application bootstrap. diff --git a/js/app/feedCoordinator.js b/js/app/feedCoordinator.js index e46d31ed..73138370 100644 --- a/js/app/feedCoordinator.js +++ b/js/app/feedCoordinator.js @@ -1014,6 +1014,14 @@ export function createFeedCoordinator(deps) { devLogger.log(`Starting loadFeedVideos(${feedType})... (forceFetch =`, forceFetch, ")"); this.setFeedTelemetryContext(feedType); + const recommendationFeed = + feedType === FEED_TYPES.FOR_YOU || feedType === FEED_TYPES.EXPLORE; + if (recommendationFeed && typeof this.ensureExploreDataService === "function") { + await this.ensureExploreDataService(); + } else if (this.exploreDataService?.setActive) { + this.exploreDataService.setActive(false); + } + let includeTags = false; let loadingMessage = "Fetching videos\u2026"; let refreshMethod = null; diff --git a/js/services/exploreDataService.js b/js/services/exploreDataService.js index 56f0933a..f503af42 100644 --- a/js/services/exploreDataService.js +++ b/js/services/exploreDataService.js @@ -144,26 +144,47 @@ export default class ExploreDataService { this.tagIdfInterval = null; this.unsubscribeHandlers = []; this.handleVisibility = this.handleVisibility.bind(this); + this.initialized = false; + this.active = false; + this.historyDirty = true; + this.idfDirty = true; } - initialize() { - this.refreshWatchHistoryTagCounts({ force: true, reason: "init" }); - this.refreshTagIdf({ force: true, reason: "init" }); + initialize({ active = false } = {}) { + if (this.initialized) { + return this.setActive(active); + } + this.initialized = true; this.subscribeToUpdates(); if (typeof document !== "undefined") { document.addEventListener("visibilitychange", this.handleVisibility); } + return this.setActive(active); + } + + async setActive(active) { + this.active = active === true; + if (!this.active || (typeof document !== "undefined" && document.hidden)) { + this.clearIntervals(); + return; + } this.startIntervals(); + const forceHistory = this.historyDirty || !this.watchHistoryTagCountsUpdatedAt; + const forceIdf = this.idfDirty || !this.tagIdfUpdatedAt; + this.historyDirty = false; + this.idfDirty = false; + await Promise.all([ + this.refreshWatchHistoryTagCounts({ force: forceHistory, reason: "active" }), + this.refreshTagIdf({ force: forceIdf, reason: "active" }), + ]); } handleVisibility() { if (typeof document === "undefined") return; if (document.hidden) { this.clearIntervals(); - } else { - this.refreshWatchHistoryTagCounts({ reason: "visibility" }); - this.refreshTagIdf({ reason: "visibility" }); - this.startIntervals(); + } else if (this.active) { + void this.setActive(true); } } @@ -226,6 +247,10 @@ export default class ExploreDataService { } queueWatchHistoryRefresh(reason) { + if (!this.active) { + this.historyDirty = true; + return; + } if (this.watchHistoryRefreshHandle) { return; } @@ -236,6 +261,10 @@ export default class ExploreDataService { } queueTagIdfRefresh(reason) { + if (!this.active) { + this.idfDirty = true; + return; + } if (this.tagIdfRefreshHandle) { return; } diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 639b32ee..a76cca45 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -360,18 +360,22 @@ export default class ApplicationBootstrap { onViewLogged: (detail) => app.handleFeedViewTelemetry(detail), }); - app.exploreDataService = - this.services.exploreDataService || - new ExploreDataService({ - watchHistoryService, - nostrService: app.nostrService, - getActiveActor: () => - typeof app.pubkey === "string" && app.pubkey ? app.pubkey : "", - logger: devLogger, - }); - if (app.exploreDataService && typeof app.exploreDataService.initialize === "function") { - app.exploreDataService.initialize(); - } + app.exploreDataService = this.services.exploreDataService || null; + app.ensureExploreDataService = async () => { + if (!app.exploreDataService) { + app.exploreDataService = new ExploreDataService({ + watchHistoryService, + nostrService: app.nostrService, + getActiveActor: () => + typeof app.pubkey === "string" && app.pubkey ? app.pubkey : "", + logger: devLogger, + }); + } + if (typeof app.exploreDataService.initialize === "function") { + await app.exploreDataService.initialize({ active: true }); + } + return app.exploreDataService; + }; const playbackDependencies = { torrentClient: this.services.torrentClient || torrentClient, diff --git a/tests/services/explore-data-lifecycle.test.mjs b/tests/services/explore-data-lifecycle.test.mjs new file mode 100644 index 00000000..d213dbb3 --- /dev/null +++ b/tests/services/explore-data-lifecycle.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import ExploreDataService from "../../js/services/exploreDataService.js"; + +test("ExploreDataService stays idle until a recommendation feed activates it", async () => { + const service = new ExploreDataService({ + historyRefreshIntervalMs: 0, + idfRefreshIntervalMs: 0, + }); + let historyRefreshes = 0; + let idfRefreshes = 0; + service.refreshWatchHistoryTagCounts = async () => { + historyRefreshes += 1; + return new Map(); + }; + service.refreshTagIdf = async () => { + idfRefreshes += 1; + return new Map(); + }; + + await service.initialize(); + assert.equal(historyRefreshes, 0); + assert.equal(idfRefreshes, 0); + + service.queueWatchHistoryRefresh("note"); + service.queueTagIdfRefresh("note"); + await service.setActive(true); + assert.equal(historyRefreshes, 1, "dirty history is refreshed on activation"); + assert.equal(idfRefreshes, 1, "dirty IDF is refreshed on activation"); + + await service.setActive(false); + service.queueTagIdfRefresh("inactive-note"); + assert.equal(idfRefreshes, 1, "inactive feeds do not rebuild recommendation indexes"); + service.destroy(); +});