firefox-desktop/browser/components/places/SnapshotMonitor.sys.mjs
2022-10-26 20:50:36 +02:00

377 lines
11 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
DomainGroupBuilder: "resource:///modules/DomainGroupBuilder.sys.mjs",
PinnedGroupBuilder: "resource:///modules/PinnedGroupBuilder.sys.mjs",
Snapshots: "resource:///modules/Snapshots.sys.mjs",
clearTimeout: "resource://gre/modules/Timer.sys.mjs",
setTimeout: "resource://gre/modules/Timer.sys.mjs",
});
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"SNAPSHOT_ADDED_TIMER_DELAY",
"browser.places.snapshots.monitorDelayAdded",
5000
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"SNAPSHOT_REMOVED_TIMER_DELAY",
"browser.places.snapshots.monitorDelayRemoved",
1000
);
// Expiration days for automatic and user managed snapshots.
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"SNAPSHOT_EXPIRE_DAYS",
"browser.places.snapshots.expiration.days",
210
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"SNAPSHOT_USERMANAGED_EXPIRE_DAYS",
"browser.places.snapshots.expiration.userManaged.days",
420
);
// We expire on the next idle after a snapshot was added or removed, and
// idle-daily, but we don't want to expire too often or rarely.
// Thus we define both a mininum and maximum time in the session among which
// we'll expire chunks of snapshots.
const EXPIRE_EVERY_MIN_MS = 60 * 60000; // 1 Hour.
const EXPIRE_EVERY_MAX_MS = 120 * 60000; // 2 Hours.
// The number of snapshots to expire at once.
const EXPIRE_CHUNK_SIZE = 10;
/**
* Monitors changes in snapshots (additions, deletions, etc) and triggers
* the snapshot group builders to run as necessary.
*/
export const SnapshotMonitor = new (class SnapshotMonitor {
/**
* @type {number}
*/
#addedTimerDelay = lazy.SNAPSHOT_ADDED_TIMER_DELAY;
/**
* @type {number}
*/
#removedTimerDelay = lazy.SNAPSHOT_REMOVED_TIMER_DELAY;
/**
* The set of urls that have been added since the last builder update.
*
* @type {Set<string>}
*/
#addedItems = new Set();
/**
* The set of urls that have been removed since the last builder update.
*
* @type {Set<string>}
*/
#removedUrls = new Set();
/**
* The current timer, if any, which triggers the next builder update.
*
* @type {number}
*/
#timer = null;
/**
* The time the current timer is targetted to. Used to work out if the timer
* needs respositioning.
*
* @type {Set<string>}
*/
#currentTargetTime = null;
/**
* Test-only. Used to specify one or more builders to use instead of the
* built-in group builders.
*
* @type {object[]}
*/
testGroupBuilders = null;
/**
* The time of the last snapshots expiration.
*/
#lastExpirationTime = 0;
/**
* How many snapshots to expire per chunk.
*/
#expirationChunkSize = EXPIRE_CHUNK_SIZE;
/**
* Internal getter to get the builders used.
*
* @returns {object[]}
*/
get #groupBuilders() {
if (this.testGroupBuilders) {
return this.testGroupBuilders;
}
return [lazy.DomainGroupBuilder, lazy.PinnedGroupBuilder];
}
/**
* Returns a list of builder names which have a single group which is always
* displayed regardless of the minimum snapshot count for snapshot groups.
*
* @returns {string[]}
*/
get skipMinimumSizeBuilders() {
let names = [];
for (let builder of this.#groupBuilders) {
let name = builder.skipMinimumSize;
if (name) {
names.push(builder.name);
}
}
return names;
}
/**
* Performs initialization to add observers.
*/
init() {
// Only enable if interactions are enabled.
if (
!Services.prefs.getBoolPref("browser.places.interactions.enabled", false)
) {
return;
}
Services.obs.addObserver(this, "places-snapshots-added");
Services.obs.addObserver(this, "places-snapshots-deleted");
Services.obs.addObserver(this, "idle-daily");
}
/**
* Test-only function used to override the delay values to provide shorter
* delays for tests.
*
* @param {object} [options]
* The object containing the delay values.
* @param {number} [options.added]
* The delay for added snapshots.
* @param {number} [options.removed]
* The delay for removed snapshots.
*/
setTimerDelaysForTests({
added = lazy.SNAPSHOT_ADDED_TIMER_DELAY,
removed = lazy.SNAPSHOT_REMOVED_TIMER_DELAY,
} = {}) {
this.#addedTimerDelay = added;
this.#removedTimerDelay = removed;
}
/**
* Triggers the individual group builders to either rebuild or update
* their groups.
*
* @param {boolean} rebuild
* Set to true to force a full rebuild of the groups.
*/
async #triggerBuilders(rebuild = false) {
if (this.#timer) {
lazy.clearTimeout(this.#timer);
}
this.#timer = null;
this.#currentTargetTime = null;
if (rebuild) {
let snapshots = await lazy.Snapshots.query({ limit: -1 });
for (let builder of this.#groupBuilders) {
await builder.rebuild(snapshots);
}
} else {
for (let builder of this.#groupBuilders) {
await builder.update({
addedItems: this.#addedItems,
removedUrls: this.#removedUrls,
});
}
}
this.#addedItems.clear();
this.#removedUrls.clear();
}
/**
* Triggers expiration of a chunk of snapshots.
* We differentiate snapshots depending on whether they are user managed:
* 1. manually created by the user
* 2. part of a group
* TODO: evaluate whether we want to consider user managed only snapshots
* that are part of a user curated group, rather than any group.
* User managed snapshots will expire if their last interaction is older than
* browser.snapshots.expiration.userManaged.days, while others will expire
* after browser.snapshots.expiration.days.
* Snapshots that have a tombstone (removed_at is set) should not be expired.
*
* @param {boolean} onIdle
* Whether this is running on idle. When it's false expiration is
* rescheduled for the next idle.
*/
async #expireSnapshotsChunk(onIdle = false) {
let now = Date.now();
if (now - this.#lastExpirationTime < EXPIRE_EVERY_MIN_MS) {
return;
}
let instance = (this._expireInstance = {});
let skip = false;
if (!onIdle) {
// Wait for the next idle.
skip = await new Promise(resolve =>
ChromeUtils.idleDispatch(deadLine => {
// Skip if we couldn't find an idle, unless we're over max waiting time.
resolve(
deadLine.didTimeout &&
now - this.#lastExpirationTime < EXPIRE_EVERY_MAX_MS
);
})
);
}
if (skip || instance != this._expireInstance) {
return;
}
this.#lastExpirationTime = now;
let urls = (
await lazy.Snapshots.query({
includeUserPersisted: false,
includeTombstones: false,
includeSnapshotsInUserManagedGroups: false,
lastInteractionBefore: now - lazy.SNAPSHOT_EXPIRE_DAYS * 86400000,
limit: this.#expirationChunkSize,
})
).map(s => s.url);
if (instance != this._expireInstance) {
return;
}
if (urls.length < this.#expirationChunkSize) {
// If we couldn't find enough automatic snapshots, check if there's any
// user managed ones we can expire.
urls.push(
...(
await lazy.Snapshots.query({
includeUserPersisted: true,
includeTombstones: false,
lastInteractionBefore:
now - lazy.SNAPSHOT_USERMANAGED_EXPIRE_DAYS * 86400000,
limit: this.#expirationChunkSize - urls.length,
})
).map(s => s.url)
);
}
if (instance != this._expireInstance) {
return;
}
await lazy.Snapshots.delete(
[...new Set(urls)],
lazy.Snapshots.REMOVED_REASON.EXPIRED
);
}
/**
* Sets a timer ensuring that if the new timeout would occur sooner than the
* current target time, the timer is changed to the sooner time.
*
* @param {number} timeout
* The timeout in milliseconds to use.
*/
#setTimer(timeout) {
let targetTime = Date.now() + timeout;
if (this.#currentTargetTime && targetTime >= this.#currentTargetTime) {
// If the new time is in the future, there's no need to reset the timer.
return;
}
if (this.#timer) {
lazy.clearTimeout(this.#timer);
}
this.#currentTargetTime = targetTime;
this.#timer = lazy.setTimeout(() => {
this.#expireSnapshotsChunk().catch(console.error);
this.#triggerBuilders().catch(console.error);
}, timeout);
}
/**
* observe function for nsIObserver. This is async so that we can call it in
* tests and know that the triggerBuilders for idle-daily has finished.
*
* @param {object} subject
* Notification specific interface pointer
* @param {string} topic
* The topic of the notification
* @param {string} data
* The data attached to the notification
*/
async observe(subject, topic, data) {
switch (topic) {
case "places-snapshots-added":
this.#onSnapshotAdded(JSON.parse(data));
break;
case "places-snapshots-deleted":
this.#onSnapshotRemoved(JSON.parse(data));
break;
case "idle-daily":
await this.#expireSnapshotsChunk(true);
await this.#triggerBuilders(true);
break;
case "test-trigger-builders":
await this.#triggerBuilders(true);
break;
case "test-expiration":
this.#lastExpirationTime =
subject.lastExpirationTime || this.#lastExpirationTime;
this.#expirationChunkSize =
subject.expirationChunkSize || this.#expirationChunkSize;
await this.#expireSnapshotsChunk(subject.onIdle);
break;
}
}
/**
* Handles snapshots being added - adds to the internal list and sets the
* timer.
*
* @param {object[]} items
* An array of items that have been added.
* @param {string} items.url
* The url of the item.
* @param {number} items.userPersisted
* The userPersisted state of the item.
*/
#onSnapshotAdded(items) {
for (let item of items) {
this.#addedItems.add(item);
}
this.#setTimer(this.#addedTimerDelay);
}
/**
* Handles snapshots being removed - adds to the internal list and sets the
* timer.
*
* @param {string[]} urls
* An array of snapshot urls that have been removed.
*/
#onSnapshotRemoved(urls) {
for (let url of urls) {
this.#removedUrls.add(url);
}
this.#setTimer(this.#removedTimerDelay);
}
})();