gecko-dev/services/settings/IDBHelpers.jsm
Gijs Kruitbosch b46afee956 Bug 1636256 - abort importJSONDump tasks in the remote settings worker at shutdown, r=leplatrem,asuth
To use a single transaction for `importJSONDump`, this commit:

- changes IDBHelpers' `executeIDB` method to take either a string or array
  pointing to `objectStore`s that the caller wants to use.
- uses that from RemoteSettingsWorker to start a single transaction using both
  the `records` and the `timestamps` store
- updates `bulkOperationHelper` to take an optional `completion` callback, in
  addition to the rejection callback, to be called when all the bulk
  operations are complete
- uses that optional argument from RemoteSettingsWorker's `importDumpIDB`
  (the actual implementation of IDB access from `importJSONDump`) to first
  bulk-import the actual records, and then update the timestamp stored for
  that remote settings collection.

Then to abort that single transaction, this commit:
- stores pending transactions in a set, similar to what Database.jsm already
  does, and removes items from that set when the `promise` from `executeIDB`
  either resolves or rejects.
- adds a `prepareShutdown` action on the RemoteSettingsWorker's `Agent` class,
  to be called by the jsm side of the worker manager when shutdown happens.
  When called, it iterates over the pending transactions and aborts all of
  them.
  This also sets a `gShutdown` flag.
- ensures that where code `await`s in the middle of an operation, it stops
  (throws) immediately if `gShutdown` has been set.
- adds a test to test_shutdown_handling.js to verify that this mechanism now
  stops pending import tasks in the worker.


Finally, as a driveby, fixes an oversight in test_remote_settings_worker.js
where the second `.get()` call wasn't actually testing whether the
`importJSONDump` call in the worker had succeeded, because if the collection
was empty it would do the import itself, which I realized when I used similar
code in the shutdown test...

Differential Revision: https://phabricator.services.mozilla.com/D74315
2020-05-11 12:53:23 +00:00

212 lines
6.9 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/. */
var EXPORTED_SYMBOLS = ["IDBHelpers"];
const DB_NAME = "remote-settings";
const DB_VERSION = 3;
// `indexedDB` is accessible in the worker global, but not the JSM global,
// where we have to import it - and the worker global doesn't have Cu.
if (typeof indexedDB == "undefined") {
Cu.importGlobalProperties(["indexedDB"]);
}
/**
* Wrap IndexedDB errors to catch them more easily.
*/
class IndexedDBError extends Error {
constructor(error, method = "", identifier = "") {
if (typeof error == "string") {
error = new Error(error);
}
super(`IndexedDB: ${identifier} ${method} ${error && error.message}`);
this.name = error.name;
this.stack = error.stack;
}
}
class ShutdownError extends IndexedDBError {
constructor(error, method = "", identifier = "") {
super(error, method, identifier);
}
}
// We batch operations in order to reduce round-trip latency to the IndexedDB
// database thread. The trade-offs are that the more records in the batch, the
// more time we spend on this thread in structured serialization, and the
// greater the chance to jank PBackground and this thread when the responses
// come back. The initial choice of 250 was made targeting 2-3ms on a fast
// machine and 10-15ms on a slow machine.
// Every chunk waits for success before starting the next, and
// the final chunk's completion will fire transaction.oncomplete .
function bulkOperationHelper(
store,
{ reject, completion },
operation,
list,
listIndex = 0
) {
try {
const CHUNK_LENGTH = 250;
const max = Math.min(listIndex + CHUNK_LENGTH, list.length);
let request;
for (; listIndex < max; listIndex++) {
request = store[operation](list[listIndex]);
}
if (listIndex < list.length) {
// On error, `transaction.onerror` is called.
request.onsuccess = bulkOperationHelper.bind(
null,
store,
{ reject, completion },
operation,
list,
listIndex
);
} else if (completion) {
completion();
}
// otherwise, we're done, and the transaction will complete on its own.
} catch (e) {
// The executeIDB callsite has a try... catch, but it will not catch
// errors in subsequent bulkOperationHelper calls chained through
// request.onsuccess above. We do want to catch those, so we have to
// feed them through manually. We cannot use an async function with
// promises, because if we wait a microtask after onsuccess fires to
// put more requests on the transaction, the transaction will auto-commit
// before we can add more requests.
reject(e);
}
}
/**
* Helper to wrap some IDBObjectStore operations into a promise.
*
* @param {IDBDatabase} db
* @param {String|String[]} storeNames - either a string or an array of strings.
* @param {String} mode
* @param {function} callback
* @param {String} description of the operation for error handling purposes.
*/
function executeIDB(db, storeNames, mode, callback, desc) {
if (!Array.isArray(storeNames)) {
storeNames = [storeNames];
}
const transaction = db.transaction(storeNames, mode);
let promise = new Promise((resolve, reject) => {
let stores = storeNames.map(name => transaction.objectStore(name));
let result;
let rejectWrapper = e => {
reject(new IndexedDBError(e, desc || "execute()", storeNames.join(", ")));
try {
transaction.abort();
} catch (ex) {
Cu.reportError(ex);
}
};
// Add all the handlers before using the stores.
transaction.onerror = event =>
reject(new IndexedDBError(event.target.error, desc || "execute()"));
transaction.onabort = event =>
reject(
new IndexedDBError(
event.target.error || transaction.error || "IDBTransaction aborted",
desc || "execute()"
)
);
transaction.oncomplete = event => resolve(result);
// Simplify access to a single datastore:
if (stores.length == 1) {
stores = stores[0];
}
try {
// Although this looks sync, once the callback places requests
// on the datastore, it can independently keep the transaction alive and
// keep adding requests. Even once we exit this try.. catch, we may
// therefore experience errors which should abort the transaction.
// This is why we pass the rejection handler - then the consumer can
// continue to ensure that errors are handled appropriately.
// In theory, exceptions thrown from onsuccess handlers should also
// cause IndexedDB to abort the transaction, so this is a belt-and-braces
// approach.
result = callback(stores, rejectWrapper);
} catch (e) {
rejectWrapper(e);
}
});
return { promise, transaction };
}
/**
* Helper to wrap indexedDB.open() into a promise.
*/
async function openIDB(allowUpgrades = true) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = event => {
if (!allowUpgrades) {
reject(
new Error(
`IndexedDB: Error accessing ${DB_NAME} IDB at version ${DB_VERSION}`
)
);
return;
}
// When an upgrade is needed, a transaction is started.
const transaction = event.target.transaction;
transaction.onabort = event => {
const error =
event.target.error ||
transaction.error ||
new DOMException("The operation has been aborted", "AbortError");
reject(new IndexedDBError(error, "open()"));
};
const db = event.target.result;
db.onerror = event => reject(new IndexedDBError(event.target.error));
if (event.oldVersion < 1) {
// Records store
const recordsStore = db.createObjectStore("records", {
keyPath: ["_cid", "id"],
});
// An index to obtain all the records in a collection.
recordsStore.createIndex("cid", "_cid");
// Last modified field
recordsStore.createIndex("last_modified", ["_cid", "last_modified"]);
// Timestamps store
db.createObjectStore("timestamps", {
keyPath: "cid",
});
}
if (event.oldVersion < 2) {
// Collections store
db.createObjectStore("collections", {
keyPath: "cid",
});
}
if (event.oldVersion < 3) {
// Attachment store
db.createObjectStore("attachments", {
keyPath: ["cid", "attachmentId"],
});
}
};
request.onerror = event => reject(new IndexedDBError(event.target.error));
request.onsuccess = event => {
const db = event.target.result;
resolve(db);
};
});
}
var IDBHelpers = {
bulkOperationHelper,
executeIDB,
openIDB,
IndexedDBError,
ShutdownError,
};