From 710bf7ed5385ba61539f559a2872be401921a451 Mon Sep 17 00:00:00 2001 From: David Fahlander Date: Thu, 24 Nov 2016 23:47:57 +0100 Subject: [PATCH] New tool: Dexie.waitFor() keeps transaction alive. Inspired by @wwoods solution described in #374 and the new [indexeddb-promises proposal](https://github.com/inexorabletash/indexeddb-promises/issues/11), I've added the method Dexie.waitFor() that may wait on a non-indexedDB Promise or Promise-returning function to complete and keep transaction alive during the whole lifetime of that task. Implementation was also inspired by the elegant code in [polyfill.js](https://github.com/inexorabletash/indexeddb-promises/blob/master/polyfill.js) by @inexorabletash. --- src/Dexie.js | 56 +++++++++++++++++++++++-- src/Promise.js | 11 ++++- test/tests-transaction.js | 88 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 150 insertions(+), 5 deletions(-) diff --git a/src/Dexie.js b/src/Dexie.js index 920b4605c..e17d241f9 100644 --- a/src/Dexie.js +++ b/src/Dexie.js @@ -1387,13 +1387,13 @@ export default function Dexie(dbName, options) { this.on = Events(this, "complete", "error", "abort"); this.parent = parent || null; this.active = true; - this._tables = null; this._reculock = 0; this._blockedFuncs = []; - this._psd = null; - this._dbschema = dbschema; this._resolve = null; this._reject = null; + this._waitingFor = null; + this._waitingQueue = null; + this._spinCount = 0; // Just for debugging waitFor() this._completion = new Promise ((resolve, reject) => { this._resolve = resolve; this._reject = reject; @@ -1519,6 +1519,45 @@ export default function Dexie(dbName, options) { } }, + _root: function () { + return this.parent ? this.parent._root() : this; + }, + + waitFor (promise) { + // Always operate on the root transaction (in case this is a sub stransaction) + var root = this._root(); + // For stability reasons, convert parameter to promise no matter what type is passed to waitFor(). + // (We must be able to call .then() on it.) + promise = Promise.resolve(promise); + if (root._waitingFor) { + // Already called waitFor(). Wait for both to complete. + root._waitingFor = root._waitingFor.then(()=>promise); + } else { + // We're not in waiting state. Start waiting state. + root._waitingFor = promise; + root._waitingQueue = []; + // Start interacting with indexedDB until promise completes: + var store = root.idbtrans.objectStore(root.storeNames[0]); + (function spin(){ + ++root._spinCount; // For debugging only + while (root._waitingQueue.length) (root._waitingQueue.shift())(); + if (root._waitingFor) store.get(-Infinity).onsuccess = spin; + }()); + } + var currentWaitPromise = root._waitingFor; + return new Promise ((resolve, reject) => { + promise.then ( + res => root._waitingQueue.push(wrap(resolve.bind(null, res))), + err => root._waitingQueue.push(wrap(reject.bind(null, err))) + ).finally(() => { + if (root._waitingFor === currentWaitPromise) { + // No one added a wait after us. Safe to stop the spinning. + root._waitingFor = null; + } + }); + }); + }, + // // Transaction Public Properties and Methods // @@ -3053,6 +3092,17 @@ props(Dexie, { currentTransaction: { get: () => PSD.trans || null }, + + waitFor: function (promiseOrFunction, optionalTimeout) { + // If a function is provided, invoke it and pass the returning value to Transaction.waitFor() + var promise = Promise.resolve( + typeof promiseOrFunction === 'function' ? Dexie.ignoreTransaction(promiseOrFunction) : promiseOrFunction) + .timeout(optionalTimeout || 60000); // Default the timeout to one minute. Caller may specify Infinity if required. + + // Run given promise on current transaction. If no current transaction, just return a Dexie promise based + // on given value. + return PSD.trans ? PSD.trans.waitFor(promise) : promise; + }, // Export our Promise implementation since it can be handy as a standalone Promise implementation Promise: Promise, diff --git a/src/Promise.js b/src/Promise.js index 1bd5eba53..594093d03 100644 --- a/src/Promise.js +++ b/src/Promise.js @@ -2,6 +2,7 @@ import {doFakeAutoComplete, tryCatch, props, setProp, _global, getPropertyDescriptor, getArrayOf, extend} from './utils'; import {nop, callBoth, mirror} from './chaining-functions'; import {debug, prettyStack, getErrorWithStack} from './debug'; +import {exceptions} from './errors'; // // Promise and Zone (PSD) for Dexie library @@ -251,6 +252,14 @@ props(Promise.prototype, { stack_being_generated = false; } } + }, + + timeout: function (ms, msg) { + return ms < Infinity ? + new Promise((resolve, reject) => { + var handle = setTimeout(() => reject(new exceptions.Timeout(msg)), ms); + this.then(resolve, reject).finally(clearTimeout.bind(null, handle)); + }) : this; } }); @@ -299,7 +308,7 @@ props (Promise, { values.map(value => Promise.resolve(value).then(resolve, reject)); }); }, - + PSD: { get: ()=>PSD, set: value => PSD = value diff --git a/test/tests-transaction.js b/test/tests-transaction.js index 115b3bb76..6682c779a 100644 --- a/test/tests-transaction.js +++ b/test/tests-transaction.js @@ -1,6 +1,6 @@ import Dexie from 'dexie'; import {module, stop, start, asyncTest, equal, ok} from 'QUnit'; -import {resetDatabase, spawnedTest} from './dexie-unittest-utils'; +import {resetDatabase, spawnedTest, promisedTest} from './dexie-unittest-utils'; "use strict"; @@ -789,3 +789,89 @@ asyncTest("Dexie.currentTransaction in CRUD hooks", 53, function () { start(); }); }); + +function sleep (ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +promisedTest("waitFor()", async ()=>{ + await db.transaction('rw', db.users, async trans =>{ + // Wait for a promise: + await trans.waitFor(sleep(100)); + // Do an operation on transaction + await trans.users.put({username: "testingtesting"}); + await trans.waitFor(sleep(100)); + let result = await trans.users.get("testingtesting"); + ok(result && result.username === "testingtesting", "Should be able to continue transaction after waiting for non-indexedDB promise"); + ok(true, `Waiting spin count:${trans._spinCount}`); + + // With timeout + await Dexie.waitFor(sleep(2000), 10) // Timeout of 10 ms. + .then (()=>ok(false, "Should have timed out!")) + .catch('TimeoutError', ex => ok(true, "Timed out as expected")); + + // Wait for function + await Dexie.waitFor(async ()=>{ + ok(Dexie.currentTransaction === null, + "We should not be in the transaction zone here because transaction can be in a temporary inactive state here"); + await sleep(10); + ok (true, "Slept 10 ms") + // Let's test if we can access the transaction from here. + // The transaction should be alive indeed but not in an active state. + await trans.users.count().then(()=>{ + // This happens on IE11 + ok(true, "Could access transaction within the wait callback. Nice for you, but you were just lucky!"); + }).catch(ex => { + // This happens on Firefox and Chrome + ok(true, "Could NOT access transaction within the wait callback. As expected. Error: " + ex); + }); + ok(Dexie.currentTransaction === null, + "We should not be in the transaction zone here because transaction can be in inactive state here"); + }); + + result = await trans.users.get("testingtesting"); + ok(result && result.username === "testingtesting", "Should still be able to operate on the transaction"); + ok(true, `Waiting spin count:${trans._spinCount}`); + ok(Dexie.currentTransaction === trans, "Zone info should still be correct"); + + // Subtransaction + await db.transaction('r', db.users, function* (subTrans) { + ok(subTrans !== trans, "Should be in a sub transaction"); + ok(Dexie.currentTransaction === subTrans, "Should be in a sub transaction"); + let count = yield trans.users.count(); + ok(true, "Should be able to operate on sub transaction. User count = " + count); + yield subTrans.waitFor(sleep(10)); + ok(true, "Should be able to call waitFor() on sub transaction"); + count = yield trans.users.count(); + ok(true, "Should be able to operate on sub transaction. User count = " + count); + }); + + // Calling waitFor multiple times in parallell + await Promise.all([ + trans.waitFor(sleep(10)), + trans.waitFor(sleep(10)), + trans.waitFor(sleep(10))]); + ok (true, "Could wait for several tasks in parallell"); + + result = await trans.users.get("testingtesting"); + ok(result && result.username === "testingtesting", "Should still be able to operate on the transaction"); + //await sleep(100); + //ok(true, `Waiting spin count:${trans._spinCount}`); + }).then(()=>ok(true, "Transaction committed")); +}); + +promisedTest("Dexie.waitFor() outside transaction", async ()=> { + // Test that waitFor can be called when not in a transaction as well. + // The meaning of this is that sometimes a function does db operations without + // a transaction, but should be able to call also within the caller's transaction. + // A function should therefore be able to call Dexie.waitFor() no matter if is executing + // within a transaction or not. + let result = await Dexie.waitFor(sleep(10).then(()=>true)); + ok(result, "Could call waitFor outside a transaction as well"); + let codeExecuted = false; + await Dexie.waitFor(async ()=>{ + await sleep(10); + codeExecuted = true; + }); + ok(codeExecuted, "Could call waitFor(function) outside a transation as well"); +});