diff options
Diffstat (limited to 'browser/components/urlbar/private/QuickSuggestRemoteSettings.sys.mjs')
-rw-r--r-- | browser/components/urlbar/private/QuickSuggestRemoteSettings.sys.mjs | 395 |
1 files changed, 395 insertions, 0 deletions
diff --git a/browser/components/urlbar/private/QuickSuggestRemoteSettings.sys.mjs b/browser/components/urlbar/private/QuickSuggestRemoteSettings.sys.mjs new file mode 100644 index 0000000000..326b649780 --- /dev/null +++ b/browser/components/urlbar/private/QuickSuggestRemoteSettings.sys.mjs @@ -0,0 +1,395 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +const RS_COLLECTION = "quicksuggest"; + +// Default score for remote settings suggestions. +const DEFAULT_SUGGESTION_SCORE = 0.2; + +// Entries are added to `SuggestionsMap` map in chunks, and each chunk will add +// at most this many entries. +const SUGGESTIONS_MAP_CHUNK_SIZE = 1000; + +const TELEMETRY_LATENCY = "FX_URLBAR_QUICK_SUGGEST_REMOTE_SETTINGS_LATENCY_MS"; + +/** + * Manages quick suggest remote settings data. + */ +class _QuickSuggestRemoteSettings { + /** + * @returns {number} + * The default score for remote settings suggestions, a value in the range + * [0, 1]. All suggestions require a score that can be used for comparison, + * so if a remote settings suggestion does not have one, it's assigned this + * value. + */ + get DEFAULT_SUGGESTION_SCORE() { + return DEFAULT_SUGGESTION_SCORE; + } + + constructor() { + this.#emitter = new lazy.EventEmitter(); + } + + /** + * @returns {RemoteSettings} + * The underlying `RemoteSettings` client object. + */ + get rs() { + return this.#rs; + } + + /** + * @returns {EventEmitter} + * The client will emit events on this object. + */ + get emitter() { + return this.#emitter; + } + + /** + * @returns {object} + * Global quick suggest configuration stored in remote settings. When the + * config changes the `emitter` property will emit a "config-set" event. The + * config is an object that looks like this: + * + * { + * best_match: { + * min_search_string_length, + * blocked_suggestion_ids, + * }, + * impression_caps: { + * nonsponsored: { + * lifetime, + * custom: [ + * { interval_s, max_count }, + * ], + * }, + * sponsored: { + * lifetime, + * custom: [ + * { interval_s, max_count }, + * ], + * }, + * }, + * show_less_frequently_cap, + * } + */ + get config() { + return this.#config; + } + + /** + * @returns {Array} + * Array of `BasicFeature` instances. + */ + get features() { + return [...this.#features]; + } + + get logger() { + if (!this.#logger) { + this.#logger = lazy.UrlbarUtils.getLogger({ + prefix: "QuickSuggestRemoteSettings", + }); + } + return this.#logger; + } + + /** + * Registers a quick suggest feature that uses remote settings. + * + * @param {BaseFeature} feature + * An instance of a `BaseFeature` subclass. See `BaseFeature` for methods + * that the subclass must implement. + */ + register(feature) { + this.logger.debug("Registering feature: " + feature.name); + this.#features.add(feature); + if (this.#features.size == 1) { + this.#enableSettings(true); + } + this.#syncFeature(feature); + } + + /** + * Unregisters a quick suggest feature that uses remote settings. + * + * @param {BaseFeature} feature + * An instance of a `BaseFeature` subclass. + */ + unregister(feature) { + this.logger.debug("Unregistering feature: " + feature.name); + this.#features.delete(feature); + if (!this.#features.size) { + this.#enableSettings(false); + } + } + + /** + * Queries remote settings suggestions from all registered features. + * + * @param {string} searchString + * The search string. + * @returns {Array} + * The remote settings suggestions. If there are no matches, an empty array + * is returned. + */ + async query(searchString) { + let suggestions; + let stopwatchInstance = {}; + TelemetryStopwatch.start(TELEMETRY_LATENCY, stopwatchInstance); + try { + suggestions = await this.#queryHelper(searchString); + TelemetryStopwatch.finish(TELEMETRY_LATENCY, stopwatchInstance); + } catch (error) { + TelemetryStopwatch.cancel(TELEMETRY_LATENCY, stopwatchInstance); + this.logger.error("Query error: " + error); + } + + return suggestions || []; + } + + async #queryHelper(searchString) { + this.logger.info("Handling query: " + JSON.stringify(searchString)); + + let results = await Promise.all( + [...this.#features].map(async feature => { + let suggestions = await feature.queryRemoteSettings(searchString); + return [feature, suggestions ?? []]; + }) + ); + + let allSuggestions = []; + for (let [feature, suggestions] of results) { + for (let suggestion of suggestions) { + suggestion.source = "remote-settings"; + suggestion.provider = feature.name; + if (typeof suggestion.score != "number") { + suggestion.score = DEFAULT_SUGGESTION_SCORE; + } + allSuggestions.push(suggestion); + } + } + + return allSuggestions; + } + + async #enableSettings(enabled) { + if (enabled && !this.#rs) { + this.logger.debug("Creating RemoteSettings client"); + this.#onSettingsSync = event => this.#syncAll({ event }); + this.#rs = lazy.RemoteSettings(RS_COLLECTION); + this.#rs.on("sync", this.#onSettingsSync); + await this.#syncConfig(); + } else if (!enabled && this.#rs) { + this.logger.debug("Destroying RemoteSettings client"); + this.#rs.off("sync", this.#onSettingsSync); + this.#rs = null; + this.#onSettingsSync = null; + } + } + + async #syncConfig() { + this.logger.debug("Syncing config"); + let rs = this.#rs; + + let configArray = await rs.get({ filters: { type: "configuration" } }); + if (rs != this.#rs) { + return; + } + + this.logger.debug("Got config array: " + JSON.stringify(configArray)); + this.#setConfig(configArray?.[0]?.configuration || {}); + } + + async #syncFeature(feature) { + this.logger.debug("Syncing feature: " + feature.name); + await feature.onRemoteSettingsSync(this.#rs); + } + + async #syncAll({ event = null } = {}) { + this.logger.debug("Syncing all"); + let rs = this.#rs; + + // Remove local files of deleted records + if (event?.data?.deleted) { + await Promise.all( + event.data.deleted + .filter(d => d.attachment) + .map(entry => + Promise.all([ + this.#rs.attachments.deleteDownloaded(entry), // type: data + this.#rs.attachments.deleteFromDisk(entry), // type: icon + ]) + ) + ); + if (rs != this.#rs) { + return; + } + } + + let promises = [this.#syncConfig()]; + for (let feature of this.#features) { + promises.push(this.#syncFeature(feature)); + } + await Promise.all(promises); + } + + /** + * Sets the quick suggest config and emits a "config-set" event. + * + * @param {object} config + * The config object. + */ + #setConfig(config) { + config ??= {}; + this.logger.debug("Setting config: " + JSON.stringify(config)); + this.#config = config; + this.#emitter.emit("config-set"); + } + + // The `RemoteSettings` client. + #rs = null; + + // Registered `BaseFeature` instances. + #features = new Set(); + + // Configuration data synced from remote settings. See the `config` getter. + #config = {}; + + #emitter = null; + #logger = null; + #onSettingsSync = null; +} + +export var QuickSuggestRemoteSettings = new _QuickSuggestRemoteSettings(); + +/** + * A wrapper around `Map` that handles quick suggest suggestions from remote + * settings. It maps keywords to suggestions. It has two benefits over `Map`: + * + * - The main benefit is that map entries are added in batches on idle to avoid + * blocking the main thread for too long, since there can be many suggestions + * and keywords. + * - A secondary benefit is that the interface is tailored to quick suggest + * suggestions, which have a `keywords` property. + */ +export class SuggestionsMap { + /** + * Returns the list of suggestions for a keyword. + * + * @param {string} keyword + * The keyword. + * @returns {Array} + * The array of suggestions for the keyword. If the keyword isn't in the + * map, the array will be empty. + */ + get(keyword) { + let object = this.#suggestionsByKeyword.get(keyword.toLocaleLowerCase()); + if (!object) { + return []; + } + return Array.isArray(object) ? object : [object]; + } + + /** + * Adds a list of suggestion objects to the results map. Each suggestion must + * have a `keywords` property whose value is an array of keyword strings. The + * suggestion's keywords will be taken from this array either exactly as they + * are specified or by generating new keywords from them; see `mapKeyword`. + * + * @param {Array} suggestions + * Array of suggestion objects. + * @param {Function} mapKeyword + * If null, each suggestion's keywords will be taken from its `keywords` + * array exactly as they are specified. Otherwise, as each suggestion is + * processed, this function will be called for each string in its `keywords` + * array. The function should return an array of strings. The suggestion's + * final list of keywords will be all the keywords returned by this function + * as it is called for each string in `keywords`. + */ + async add(suggestions, mapKeyword = null) { + // There can be many suggestions, and each suggestion can have many + // keywords. To avoid blocking the main thread for too long, update the map + // in chunks, and to avoid blocking the UI and other higher priority work, + // do each chunk only when the main thread is idle. During each chunk, we'll + // add at most `chunkSize` entries to the map. + let suggestionIndex = 0; + let keywordIndex = 0; + + // Keep adding chunks until all suggestions have been fully added. + while (suggestionIndex < suggestions.length) { + await new Promise(resolve => { + Services.tm.idleDispatchToMainThread(() => { + // Keep updating the map until the current chunk is done. + let indexInChunk = 0; + while ( + indexInChunk < SuggestionsMap.chunkSize && + suggestionIndex < suggestions.length + ) { + let suggestion = suggestions[suggestionIndex]; + if (keywordIndex == suggestion.keywords.length) { + // We've added entries for all keywords of the current suggestion. + // Move on to the next suggestion. + suggestionIndex++; + keywordIndex = 0; + continue; + } + + let originalKeyword = suggestion.keywords[keywordIndex]; + let keywords = mapKeyword?.(originalKeyword) ?? [originalKeyword]; + for (let keyword of keywords) { + // If the keyword's only suggestion is `suggestion`, store it + // directly as the value. Otherwise store an array of unique + // suggestions. See the `#suggestionsByKeyword` comment. + let object = this.#suggestionsByKeyword.get(keyword); + if (!object) { + this.#suggestionsByKeyword.set(keyword, suggestion); + } else { + let isArray = Array.isArray(object); + if (!isArray && object != suggestion) { + this.#suggestionsByKeyword.set(keyword, [object, suggestion]); + } else if (isArray && !object.includes(suggestion)) { + object.push(suggestion); + } + } + } + + keywordIndex++; + indexInChunk++; + } + + // The current chunk is done. + resolve(); + }); + }); + } + } + + clear() { + this.#suggestionsByKeyword.clear(); + } + + // Maps each keyword in the dataset to one or more suggestions for the + // keyword. If only one suggestion uses a keyword, the keyword's value in the + // map will be the suggestion object. If more than one suggestion uses the + // keyword, the value will be an array of the suggestions. The reason for not + // always using an array is that we expect the vast majority of keywords to be + // used by only one suggestion, and since there are potentially very many + // keywords and suggestions and we keep them in memory all the time, we want + // to save as much memory as possible. + #suggestionsByKeyword = new Map(); + + // This is only defined as a property so that tests can override it. + static chunkSize = SUGGESTIONS_MAP_CHUNK_SIZE; +} |