adasilva 8c2a57878b fix(service-worker): fix LruList bugs (#22769)
'remove' method not removing url from state.map
'accessed' method not removing 'previous' reference from existing  node when it becomes the head

Fixes #22218
Fixes #22768

PR Close #22769
2018-03-21 13:11:26 -07:00

557 lines
18 KiB
TypeScript

/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Adapter, Context} from './adapter';
import {Database, Table} from './database';
import {DataGroupConfig} from './manifest';
/**
* A metadata record of how old a particular cached resource is.
*/
interface AgeRecord {
age: number;
}
/**
* A node in the LRU chain for a given `DataGroup`.
*
* Serializable as previous/next are identified by their URL and are not references.
*/
interface LruNode {
/**
* The URL tracked by this node.
*/
url: string;
/**
* The previous (more recent) node in the chain, or null if this is the head.
*/
previous: string|null;
/**
* The next (less recent) node in the chain, or null if this is the tail.
*/
next: string|null;
}
/**
* Serializable state of an entire LRU chain.
*
* Essentially a doubly linked list of URLs.
*/
interface LruState {
/**
* URL of the head node, or null if the chain is empty.
*/
head: string|null;
/**
* URL of the tail node, or null if the chain is empty.
*/
tail: string|null;
/**
* Map of URLs to data for each URL (including next/prev pointers).
*/
map: {[url: string]: LruNode | undefined};
/**
* Count of the number of nodes in the chain.
*/
count: number;
}
/**
* Manages an instance of `LruState` and moves URLs to the head of the
* chain when requested.
*/
class LruList {
state: LruState;
constructor(state?: LruState) {
if (state === undefined) {
state = {
head: null,
tail: null,
map: {},
count: 0,
};
}
this.state = state;
}
/**
* The current count of URLs in the list.
*/
get size(): number { return this.state.count; }
/**
* Remove the tail.
*/
pop(): string|null {
// If there is no tail, return null.
if (this.state.tail === null) {
return null;
}
const url = this.state.tail;
this.remove(url);
// This URL has been successfully evicted.
return url;
}
remove(url: string): boolean {
const node = this.state.map[url];
if (node === undefined) {
return false;
}
// Special case if removing the current head.
if (this.state.head === url) {
// The node is the current head. Special case the removal.
if (node.next === null) {
// This is the only node. Reset the cache to be empty.
this.state.head = null;
this.state.tail = null;
this.state.map = {};
this.state.count = 0;
return true;
}
// There is at least one other node. Make the next node the new head.
const next = this.state.map[node.next !] !;
next.previous = null;
this.state.head = next.url;
node.next = null;
delete this.state.map[url];
this.state.count--;
return true;
}
// The node is not the head, so it has a previous. It may or may not be the tail.
// If it is not, then it has a next. First, grab the previous node.
const previous = this.state.map[node.previous !] !;
// Fix the forward pointer to skip over node and go directly to node.next.
previous.next = node.next;
// node.next may or may not be set. If it is, fix the back pointer to skip over node.
// If it's not set, then this node happened to be the tail, and the tail needs to be
// updated to point to the previous node (removing the tail).
if (node.next !== null) {
// There is a next node, fix its back pointer to skip this node.
this.state.map[node.next] !.previous = node.previous !;
} else {
// There is no next node - the accessed node must be the tail. Move the tail pointer.
this.state.tail = node.previous !;
}
node.next = null;
node.previous = null;
delete this.state.map[url];
// Count the removal.
this.state.count--;
return true;
}
accessed(url: string): void {
// When a URL is accessed, its node needs to be moved to the head of the chain.
// This is accomplished in two steps:
//
// 1) remove the node from its position within the chain.
// 2) insert the node as the new head.
//
// Sometimes, a URL is accessed which has not been seen before. In this case, step 1 can
// be skipped completely (which will grow the chain by one). Of course, if the node is
// already the head, this whole operation can be skipped.
if (this.state.head === url) {
// The URL is already in the head position, accessing it is a no-op.
return;
}
// Look up the node in the map, and construct a new entry if it's
const node = this.state.map[url] || {url, next: null, previous: null};
// Step 1: remove the node from its position within the chain, if it is in the chain.
if (this.state.map[url] !== undefined) {
this.remove(url);
}
// Step 2: insert the node at the head of the chain.
// First, check if there's an existing head node. If there is, it has previous: null.
// Its previous pointer should be set to the node we're inserting.
if (this.state.head !== null) {
this.state.map[this.state.head] !.previous = url;
}
// The next pointer of the node being inserted gets set to the old head, before the head
// pointer is updated to this node.
node.next = this.state.head;
// The new head is the new node.
this.state.head = url;
// If there is no tail, then this is the first node, and is both the head and the tail.
if (this.state.tail === null) {
this.state.tail = url;
}
// Set the node in the map of nodes (if the URL has been seen before, this is a no-op)
// and count the insertion.
this.state.map[url] = node;
this.state.count++;
}
}
/**
* A group of cached resources determined by a set of URL patterns which follow a LRU policy
* for caching.
*/
export class DataGroup {
/**
* Compiled regular expression set used to determine which resources fall under the purview
* of this group.
*/
private readonly patterns: RegExp[];
/**
* The `Cache` instance in which resources belonging to this group are cached.
*/
private readonly cache: Promise<Cache>;
/**
* Tracks the LRU state of resources in this cache.
*/
private _lru: LruList|null = null;
/**
* Database table used to store the state of the LRU cache.
*/
private readonly lruTable: Promise<Table>;
/**
* Database table used to store metadata for resources in the cache.
*/
private readonly ageTable: Promise<Table>;
constructor(
private scope: ServiceWorkerGlobalScope, private adapter: Adapter,
private config: DataGroupConfig, private db: Database, private prefix: string) {
this.patterns = this.config.patterns.map(pattern => new RegExp(pattern));
this.cache = this.scope.caches.open(`${this.prefix}:dynamic:${this.config.name}:cache`);
this.lruTable = this.db.open(`${this.prefix}:dynamic:${this.config.name}:lru`);
this.ageTable = this.db.open(`${this.prefix}:dynamic:${this.config.name}:age`);
}
/**
* Lazily initialize/load the LRU chain.
*/
private async lru(): Promise<LruList> {
if (this._lru === null) {
const table = await this.lruTable;
try {
this._lru = new LruList(await table.read<LruState>('lru'));
} catch (e) {
this._lru = new LruList();
}
}
return this._lru;
}
/**
* Sync the LRU chain to non-volatile storage.
*/
async syncLru(): Promise<void> {
if (this._lru === null) {
return;
}
const table = await this.lruTable;
return table.write('lru', this._lru !.state);
}
/**
* Process a fetch event and return a `Response` if the resource is covered by this group,
* or `null` otherwise.
*/
async handleFetch(req: Request, ctx: Context): Promise<Response|null> {
// Do nothing
if (!this.patterns.some(pattern => pattern.test(req.url))) {
return null;
}
// Lazily initialize the LRU cache.
const lru = await this.lru();
// The URL matches this cache. First, check whether this is a mutating request or not.
switch (req.method) {
case 'OPTIONS':
// Don't try to cache this - it's non-mutating, but is part of a mutating request.
// Most likely SWs don't even see this, but this guard is here just in case.
return null;
case 'GET':
case 'HEAD':
// Handle the request with whatever strategy was selected.
switch (this.config.strategy) {
case 'freshness':
return this.handleFetchWithFreshness(req, ctx, lru);
case 'performance':
return this.handleFetchWithPerformance(req, ctx, lru);
default:
throw new Error(`Unknown strategy: ${this.config.strategy}`);
}
default:
// This was a mutating request. Assume the cache for this URL is no longer valid.
const wasCached = lru.remove(req.url);
// If there was a cached entry, remove it.
if (wasCached) {
await this.clearCacheForUrl(req.url);
}
// Sync the LRU chain to non-volatile storage.
await this.syncLru();
// Finally, fall back on the network.
return this.safeFetch(req);
}
}
private async handleFetchWithPerformance(req: Request, ctx: Context, lru: LruList):
Promise<Response|null> {
let res: Response|null|undefined = null;
// Check the cache first. If the resource exists there (and is not expired), the cached
// version can be used.
const fromCache = await this.loadFromCache(req, lru);
if (fromCache !== null) {
res = fromCache.res;
// Check the age of the resource.
if (this.config.refreshAheadMs !== undefined && fromCache.age >= this.config.refreshAheadMs) {
ctx.waitUntil(this.safeCacheResponse(req, this.safeFetch(req)));
}
}
if (res !== null) {
return res;
}
// No match from the cache. Go to the network. Note that this is not an 'await'
// call, networkFetch is the actual Promise. This is due to timeout handling.
const [timeoutFetch, networkFetch] = this.networkFetchWithTimeout(req);
res = await timeoutFetch;
// Since fetch() will always return a response, undefined indicates a timeout.
if (res === undefined) {
// The request timed out. Return a Gateway Timeout error.
res = this.adapter.newResponse(null, {status: 504, statusText: 'Gateway Timeout'});
// Cache the network response eventually.
ctx.waitUntil(this.safeCacheResponse(req, networkFetch));
}
// The request completed in time, so cache it inline with the response flow.
await this.cacheResponse(req, res, lru);
return res;
}
private async handleFetchWithFreshness(req: Request, ctx: Context, lru: LruList):
Promise<Response|null> {
// Start with a network fetch.
const [timeoutFetch, networkFetch] = this.networkFetchWithTimeout(req);
let res: Response|null|undefined;
// If that fetch errors, treat it as a timed out request.
try {
res = await timeoutFetch;
} catch (e) {
res = undefined;
}
// If the network fetch times out or errors, fall back on the cache.
if (res === undefined) {
ctx.waitUntil(this.safeCacheResponse(req, networkFetch));
// Ignore the age, the network response will be cached anyway due to the
// behavior of freshness.
const fromCache = await this.loadFromCache(req, lru);
res = (fromCache !== null) ? fromCache.res : null;
} else {
await this.cacheResponse(req, res, lru, true);
}
// Either the network fetch didn't time out, or the cache yielded a usable response.
// In either case, use it.
if (res !== null) {
return res;
}
// No response in the cache. No choice but to fall back on the full network fetch.
res = await networkFetch;
await this.cacheResponse(req, res, lru, true);
return res;
}
private networkFetchWithTimeout(req: Request): [Promise<Response|undefined>, Promise<Response>] {
// If there is a timeout configured, race a timeout Promise with the network fetch.
// Otherwise, just fetch from the network directly.
if (this.config.timeoutMs !== undefined) {
const networkFetch = this.scope.fetch(req);
const safeNetworkFetch = (async() => {
try {
return await networkFetch;
} catch (err) {
return this.adapter.newResponse(null, {
status: 504,
statusText: 'Gateway Timeout',
});
}
})();
const networkFetchUndefinedError = (async() => {
try {
return await networkFetch;
} catch (err) {
return undefined;
}
})();
// Construct a Promise<undefined> for the timeout.
const timeout = this.adapter.timeout(this.config.timeoutMs) as Promise<undefined>;
// Race that with the network fetch. This will either be a Response, or `undefined`
// in the event that the request errored or timed out.
return [Promise.race([networkFetchUndefinedError, timeout]), safeNetworkFetch];
} else {
const networkFetch = this.safeFetch(req);
// Do a plain fetch.
return [networkFetch, networkFetch];
}
}
private async safeCacheResponse(req: Request, res: Promise<Response>): Promise<void> {
try {
await this.cacheResponse(req, await res, await this.lru());
} catch (e) {
// TODO: handle this error somehow?
}
}
private async loadFromCache(req: Request, lru: LruList):
Promise<{res: Response, age: number}|null> {
// Look for a response in the cache. If one exists, return it.
const cache = await this.cache;
let res = await cache.match(req);
if (res !== undefined) {
// A response was found in the cache, but its age is not yet known. Look it up.
try {
const ageTable = await this.ageTable;
const age = this.adapter.time - (await ageTable.read<AgeRecord>(req.url)).age;
// If the response is young enough, use it.
if (age <= this.config.maxAge) {
// Successful match from the cache. Use the response, after marking it as having
// been accessed.
lru.accessed(req.url);
return {res, age};
}
// Otherwise, or if there was an error, assume the response is expired, and evict it.
} catch (e) {
// Some error getting the age for the response. Assume it's expired.
}
lru.remove(req.url);
await this.clearCacheForUrl(req.url);
// TODO: avoid duplicate in event of network timeout, maybe.
await this.syncLru();
}
return null;
}
/**
* Operation for caching the response from the server. This has to happen all
* at once, so that the cache and LRU tracking remain in sync. If the network request
* completes before the timeout, this logic will be run inline with the response flow.
* If the request times out on the server, an error will be returned but the real network
* request will still be running in the background, to be cached when it completes.
*/
private async cacheResponse(
req: Request, res: Response, lru: LruList, okToCacheOpaque: boolean = false): Promise<void> {
// Only cache successful responses.
if (!res.ok || (okToCacheOpaque && res.type === 'opaque')) {
return;
}
// If caching this response would make the cache exceed its maximum size, evict something
// first.
if (lru.size >= this.config.maxSize) {
// The cache is too big, evict something.
const evictedUrl = lru.pop();
if (evictedUrl !== null) {
await this.clearCacheForUrl(evictedUrl);
}
}
// TODO: evaluate for possible race conditions during flaky network periods.
// Mark this resource as having been accessed recently. This ensures it won't be evicted
// until enough other resources are requested that it falls off the end of the LRU chain.
lru.accessed(req.url);
// Store the response in the cache (cloning because the browser will consume
// the body during the caching operation).
await(await this.cache).put(req, res.clone());
// Store the age of the cache.
const ageTable = await this.ageTable;
await ageTable.write(req.url, {age: this.adapter.time});
// Sync the LRU chain to non-volatile storage.
await this.syncLru();
}
/**
* Delete all of the saved state which this group uses to track resources.
*/
async cleanup(): Promise<void> {
// Remove both the cache and the database entries which track LRU stats.
await Promise.all([
this.scope.caches.delete(`${this.prefix}:dynamic:${this.config.name}:cache`),
this.db.delete(`${this.prefix}:dynamic:${this.config.name}:age`),
this.db.delete(`${this.prefix}:dynamic:${this.config.name}:lru`),
]);
}
/**
* Clear the state of the cache for a particular resource.
*
* This doesn't remove the resource from the LRU table, that is assumed to have
* been done already. This clears the GET and HEAD versions of the request from
* the cache itself, as well as the metadata stored in the age table.
*/
private async clearCacheForUrl(url: string): Promise<void> {
const [cache, ageTable] = await Promise.all([this.cache, this.ageTable]);
await Promise.all([
cache.delete(this.adapter.newRequest(url, {method: 'GET'})),
cache.delete(this.adapter.newRequest(url, {method: 'HEAD'})),
ageTable.delete(url),
]);
}
private async safeFetch(req: Request): Promise<Response> {
try {
return this.scope.fetch(req);
} catch (err) {
return this.adapter.newResponse(null, {
status: 504,
statusText: 'Gateway Timeout',
});
}
}
}