- From: Jake Archibald <notifications@github.com>
- Date: Sat, 23 Jan 2016 16:02:38 -0800
- To: slightlyoff/ServiceWorker <ServiceWorker@noreply.github.com>
- Message-ID: <slightlyoff/ServiceWorker/issues/823@github.com>
## What we need to solve `cache.put`, `cache.add` and `cache.allAll` have transaction-like properties that aren't well explained by the spec. Eg, what happens in this case? ```js const pa = cache.put(request, response); const pb = cache.put(request, anotherResponse); ``` Should the second cache abort the first? If so what happens to the `pa` promise? Should `pb` wait until `pa` settles? This is not currently defined. `cache.add` and `cache.addAll` are atomic, but you cannot ensure responses are ok, making it a bit of a footgun. Developers are surprised to hear this, despite it being consistent with `fetch()`. We should change `add` and `addAll` to reject if any of the responses are `!response.ok`, or if ok-ness cannot be determined (opaque response) - maybe include an option to revert to current behaviour. This change is safe, as there the number of opaque responses being added via `add` & `addAll` rounds to 0.00%. ## Use-cases Adding multiple resources to the cache atomically. Similar to: ```js caches.open('foo').then(cache => { return Promise.all( ['/hello', '/world'].map(url => { return fetch(url).then(response => { if (!response.ok) throw Error("Response not ok"); return cache.put(`${url}-cached`, response); }); }) ); }) ``` The above isn't atomic, "/hello" can succeed & be written to the cache, but "/world" can fail. Say I wanted to look into the cache and decide whether to delete entries based on their response: ```js cache.open('foo').then(cache => { cache.keys().then(requests => { requests.forEach(request => { cache.match(request).then(response => { if (isTooOld(response)) { cache.delete(request); } }); }) }); }); ``` The above is racy. The response we remove from the cache may not be `response`, as a `cache.put(request, differentResponse)` may have happened in a different thread. ## Proposal: cache transactions Aims: * Allow the above usecases * Avoid excessive locking that would hurt performance * Explain existing cache-writing methods in terms of transactions ```idl TODO do IDL properly interface CacheStorage { # … [NewObject] Promise<CacheTransaction> transaction(DOMString cacheName, optional CacheTransactionOptions options); } dictionary CacheQueryOptions { sequence<RequestInfo> limitTo; }; interface CacheTransaction { void waitUntil(Promise<any> f); # Same method signatures as Cache, except add & addAll [NewObject] Promise<Response> match(RequestInfo request, optional CacheQueryOptions options); [NewObject] Promise<sequence<Response>> matchAll(optional RequestInfo request, optional CacheQueryOptions options); [NewObject] Promise<void> put(RequestInfo request, Response response); [NewObject] Promise<boolean> delete(RequestInfo request, optional CacheQueryOptions options); [NewObject] Promise<sequence<Request>> keys(optional RequestInfo request, optional CacheQueryOptions options); } ``` ### Examples Multiple `.put`s atomically (from the usecases): ```js const urls = ['/hello', '/world']; Promise.all( urls.map(u => fetch(u)) ).then(responses => { if (responses.some(r => !r.ok)) throw Error("Response not ok"); const cacheURLs = urls.map(u => `${u}-cached`; return caches.transaction('foo', { // keep a minimal lock limitTo: cacheURLs }).then(tx => { cacheURLs.forEach((url, i) => { tx.put(url, responses[i]); }); return tx.complete; }); }); ``` Look into the cache and decide whether to delete entries based on their response (from usecases): ```js caches.transaction('foo').then(tx => { tx.waitUntil( tx.keys().then(requests => { return Promise.all( requests.map(request => { return tx.match(request).then(response => { if (isTooOld(response)) { return tx.delete(request); } }); }) ); }); ); return tx.complete; }); ``` `cache.put` and `cache.delete` become shortcuts to creating transactions, eg: ```js Cache.prototype.put = function(request, response) { return caches.transaction(this._cacheName, { limitTo: [request] }).then(tx => { tx.put(request, response); return tx.complete; }); }; Cache.prototype.delete = function(request) { return caches.transaction(this._cacheName, { limitTo: [request] }).then(tx => { tx.delete(request); return tx.complete; }); }; ``` And our modified `addAll` can be defined (roughly): ```js Cache.prototype.addAll = function(requests) { return Promise.all( requests.map(r => fetch(r)) ).then(responses => { if (responses.some(r => !r.ok)) throw TypeError('Nooope'); return caches.transaction(this._cacheName, { limitTo: requests }).then(tx => { requests.forEach((request, i) => { tx.put(request, responses[i]); }); return tx.complete; }); }); } ``` ### Detail Some of this is a little rough. Apologies for lack of strictness, hopefully it gets the idea across. * A **cache transaction** has an associated **locked requests**, a list of `Request`s, initially null * A **cache transaction** has an associated **closed** flag * A **cache transaction** has an associated **settled** flag * A **cache transaction** has an associated **aborted** flag * A **cache transaction** has an associated **operations** array of `CacheBatchOperation` dictionary objects * A **cache transaction** has associated **extend lifetime promises** * A **request to response map** as an associated **pending transactions**, a list of **cache transaction**s `caches.transaction(cacheName, options)`: 1. If the result of running *is settings object a secure context* with the *incumbent settings object* is "Not Secure", return a new promise rejected with a `SecurityError` exception. 1. Let `p` be a new promise. 1. TODO: type check, turn `options.limitTo` into requests 1. TODO: Same as `caches.open`, aside from creating a `Cache`. Then once we have a **request to response map**… 1. Let `pendingTransaction` be the result of running the **Request transaction** algorithm, passing in the **request to response map** and `options.limitTo` 1. Let `cacheTransaction` be a 1. Resolve `p` with a new `CacheTransaction` associated with the **cache transaction** 1. Queue a microtask: 1. If the **cache transaction**'s **extend lifetime promises** is not null 1. Let `waitFor` be waiting for all of **cache transaction**'s **extend lifetime promises** 1. Else 1. Set `pendingTransaction`'s **closed** flag 1. Let `waitFor` be a promise resolved with no value 1. Wait for `waitFor` to settle 1. Set `pendingTransaction`'s **closed** flag 1. If rejected: 1. Set `pendingTransaction`'s **aborted** flag 1. If `pendingTransaction`'s **aborted** flag set 1. Remove `pendingTransaction` from **request to response map**'s *pending transactions* 1. Set `pendingTransaction`'s **settled** flag 1. Reject `p` with `TypeError` 1. Resolve `p` with the result of running **Batch Cache Operations** passing the `pendingTransaction`'s **operations** as the argument 1. Return `p`. `cacheTransaction.match` & `cacheTransaction.matchAll` behave as they currently do, except: 1. If **closed** throw `TypeError` 1. If **locked requests** is not null, and request url is not equal to one of those in locked requests, throw `TypeError` (TODO: or do we allow it, but developer should be aware it's racy?) 1. TODO: …consider pending writes in **operations**? `cacheTransaction.keys` behaves as it currently does, except: 1. If **closed** throw `TypeError` 1. If **locked requests** is not null, throw `TypeError` (TODO: or… same question as above) 1. TODO: …consider pending writes in **operations**? `cacheTransaction.put(request, response)` 1. If **closed** throw `TypeError` 1. If **locked requests** is not null, and request url is not equal to one of those in locked requests, throw `TypeError` 1. As current `.put` steps 2-11 (TODO: check numbers), but also **abort transaction** before rejecting 1. Append `o` to **operations** 1. Return promise resolved with undefined `cacheTransaction.delete(request)` 1. If **closed** throw `TypeError` 1. If **locked requests** is not null, and request url is not equal to one of those in locked requests, throw `TypeError` 1. As current `.delete` steps 1-9 (TODO: check numbers) 1. Append `o` to **operations** 1. Return `this.match(request).then(r => !!r)` **Request transaction**: Input * `requestResponseMap`, a **request to response map** * `lockedRequests`, a sequence of **request**s Outputs `pendingTransaction`, a **pending transaction** 1. Let `currentPendingTransactions` be a copy of the **pending transactions** associated with `requestResponseMap` 1. Let `pendingTransaction` be a **cache transaction** 1. Set `pendingTransaction`s **locked request** to `lockedRequests` 1. Add a new `pendingTransaction` to `requestResponseMap`'s **pending transactions** 1. For each `pendingTransaction` in `currentPendingTransactions`: 1. If `lockedRequests` is undefined, or if any of `lockedRequests` have the same url as one or more of the requests in `pendingTransaction`'s **locked requests** 1. Wait for `pendingTransaction`'s **settled** flag to become set 1. Return `pendingTransaction` **Abort transaction**: * `cacheTransaction`, a **cache transaction** Outputs none 1. Set `cacheTransaction`'s **aborted** flag 1. Reject `cacheTransaction`'s **extend lifetime promises** **Batch Cache Operations** Similar to as it is now, except: * Performs squashing to remove redundant operations * Resolves once data fully written into cache, and reverts if writing fails (similar to what the current `.put` is doing in step 14 TODO check numbers) * Deletes should be reverted if puts fail - I think we should be able to perform the deletes after all the puts once correctly squashed ## Questions Creating a transaction: ```js caches.transaction(cacheName, options).then(tx => { tx.waitUntil(promise); return tx.complete; }).then(() => console.log("Transaction complete!")); // or… caches.transaction(cacheName, tx => { return promise; }, options).then(() => console.log("Transaction complete!")); // No need for waitUntil or tx.complete // Easier to reason about transaction lifetime? (less like IDB) // Can detect throws in callback execution // Using a callback feels like an antipattern // or even… cache.open(cacheName).then(cache => { cache.transaction(tx => { return promise; }, options).then(() => console.log("Transaction complete!")); }); // One method of opening a cache // Really easy to deadlock by returning `cache.put(…)` in the transaction callback ``` `cacheTransaction.put` could return synchronously, is it better to return a promise for consistency? Should read operations like `cacheTransaction.match` consider pending tasks (**operations**) in their return values? I guess `cacheTransaction.abort` makes sense. Apologies for both the length and hand-waving in this proposal, but I'd rather not spend any more time on it if we think it's a no-go. --- Reply to this email directly or view it on GitHub: https://github.com/slightlyoff/ServiceWorker/issues/823
Received on Sunday, 24 January 2016 00:03:19 UTC