Address race conditions when flushing logs

This commit is contained in:
Kegan Dougal 2017-01-20 14:46:19 +00:00
parent 378126e746
commit ea063ab8b0
1 changed files with 68 additions and 26 deletions

View File

@ -99,6 +99,11 @@ class IndexedDBLogStore {
this.id = "instance-" + Math.random() + Date.now(); this.id = "instance-" + Math.random() + Date.now();
this.index = 0; this.index = 0;
this.db = null; this.db = null;
// Promise is not null when a flush is IN PROGRESS
this.flushPromise = null;
// Promise is not null when flush() is called when one is already in
// progress.
this.flushAgainPromise = null;
} }
/** /**
@ -148,35 +153,80 @@ class IndexedDBLogStore {
} }
/** /**
* Flush logs to disk.
*
* There are guards to protect against race conditions in order to ensure
* that all previous flushes have completed before the most recent flush.
* Consider without guards:
* - A calls flush() periodically.
* - B calls flush() and wants to send logs immediately afterwards.
* - If B doesn't wait for A's flush to complete, B will be missing the
* contents of A's flush.
* To protect against this, we set 'flushPromise' when a flush is ongoing.
* Subsequent calls to flush() during this period return a new promise
* 'flushAgainPromise' which is chained off the current 'flushPromise'.
* Subsequent calls to flush() when the first flush hasn't completed will
* return the same 'flushAgainPromise' as we can guarantee that we WILL
* do a brand new flush at some point in the future. Once the first flush
* has completed, 'flushAgainPromise' becomes 'flushPromise' and can be
* chained again.
*
* This guarantees that we will always eventually do a flush when flush() is
* called.
*
* @return {Promise} Resolved when the logs have been flushed. * @return {Promise} Resolved when the logs have been flushed.
*/ */
flush() { flush() {
if (this.flushPromise) { // a flush is ongoing
if (this.flushAgainPromise) { // a flush is queued up, return that.
return this.flushAgainPromise;
}
// queue up a new flush
this.flushAgainPromise = this.flushPromise.then(() => {
// the current flush has completed, so shuffle the promises
// around:
// flushAgainPromise => flushPromise and null flushAgainPromise.
// flushPromise has already nulled itself.
this.flushAgainPromise = null;
return this.flush();
});
return this.flushAgainPromise;
}
this.flushPromise = new Promise((resolve, reject) => {
if (!this.db) { if (!this.db) {
// not connected yet or user rejected access for us to r/w to the db // not connected yet or user rejected access for us to r/w to
return Promise.reject(new Error("No connected database")); // the db.
this.flushPromise = null;
reject(new Error("No connected database"));
return;
} }
const lines = this.logger.flush(); const lines = this.logger.flush();
if (lines.length === 0) { if (lines.length === 0) {
return Promise.resolve(); this.flushPromise = null;
resolve();
return;
} }
return new Promise((resolve, reject) => {
let txn = this.db.transaction(["logs", "logslastmod"], "readwrite"); let txn = this.db.transaction(["logs", "logslastmod"], "readwrite");
let objStore = txn.objectStore("logs"); let objStore = txn.objectStore("logs");
objStore.add(this._generateLogEntry(lines)); objStore.add(this._generateLogEntry(lines));
let lastModStore = txn.objectStore("logslastmod"); let lastModStore = txn.objectStore("logslastmod");
lastModStore.put(this._generateLastModifiedTime()); lastModStore.put(this._generateLastModifiedTime());
txn.oncomplete = (event) => { txn.oncomplete = (event) => {
this.flushPromise = null;
resolve(); resolve();
}; };
txn.onerror = (event) => { txn.onerror = (event) => {
console.error( console.error(
"Failed to flush logs : ", event "Failed to flush logs : ", event
); );
this.flushPromise = null;
reject( reject(
new Error("Failed to write logs: " + event.target.errorCode) new Error("Failed to write logs: " + event.target.errorCode)
); );
} }
}); });
return this.flushPromise;
} }
/** /**
@ -368,17 +418,6 @@ module.exports = {
return initPromise; return initPromise;
}, },
/**
* Force-flush the logs to storage.
* @return {Promise} Resolved when the logs have been flushed.
*/
flush: async function() {
if (!store) {
return;
}
await store.flush();
},
/** /**
* Clean up old logs. * Clean up old logs.
* @return Promise Resolves if cleaned logs. * @return Promise Resolves if cleaned logs.
@ -422,17 +461,20 @@ module.exports = {
// If in incognito mode, store is null, but we still want bug report // If in incognito mode, store is null, but we still want bug report
// sending to work going off the in-memory console logs. // sending to work going off the in-memory console logs.
console.log("Sending bug report.");
let logs = []; let logs = [];
if (store) { if (store) {
// flush most recent logs
await store.flush();
logs = await store.consume(); logs = await store.consume();
} }
// and add the most recent console logs which won't be in the store yet. else {
const consoleLogs = logger.flush(); // remove logs from console logs.push({
const currentId = store ? store.id : "-"; lines: logger.flush(),
logs.unshift({ id: "-",
lines: consoleLogs,
id: currentId,
}); });
}
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
request({ request({
method: "POST", method: "POST",