From 500320f891bfbd3f2c81cec772e4517ed625daa6 Mon Sep 17 00:00:00 2001 From: Ansis Brammanis Date: Fri, 21 Jun 2019 17:15:42 -0400 Subject: [PATCH] use browser `Cache` interface to cache tiles (#8363) Recent pricing changes introduced a `sku=` query parameter that changes with every map load. This defeats the browser's ability to cache these tiles. We're working around that by implementing our own caching with the new `Cache` api. - skips caching tiles that expire soon - only caches mapbox tiles (no 3rd party tiles, styles, etc because the browser should cache these fine) - does not work in IE, Safari Browser tests can be found in debug/cache_api.html mapboxgl.clearStorage added to provide a way to clear the cache. --- debug/cache_api.html | 338 ++++++++++++++++++++ docs/documentation.yml | 1 + src/index.js | 13 + src/source/raster_tile_source.js | 4 + src/source/vector_tile_source.js | 3 + src/source/worker.js | 5 + src/ui/map.js | 6 + src/util/ajax.js | 78 ++++- src/util/mapbox.js | 6 +- src/util/tile_request_cache.js | 124 +++++++ test/unit/source/vector_tile_source.test.js | 8 +- 11 files changed, 568 insertions(+), 18 deletions(-) create mode 100644 debug/cache_api.html create mode 100644 src/util/tile_request_cache.js diff --git a/debug/cache_api.html b/debug/cache_api.html new file mode 100644 index 00000000000..736e1f01ed3 --- /dev/null +++ b/debug/cache_api.html @@ -0,0 +1,338 @@ + + + + Mapbox GL JS debug page + + + + + + + +
+
Result: running tests...
+
+
+ + + + + + diff --git a/docs/documentation.yml b/docs/documentation.yml index e8d4f7611c2..a18414fbd45 100644 --- a/docs/documentation.yml +++ b/docs/documentation.yml @@ -7,6 +7,7 @@ toc: - supported - version - setRTLTextPlugin + - clearStorage - AnimationOptions - CameraOptions - PaddingOptions diff --git a/src/index.js b/src/index.js index f3250dba471..f6e5eddac9c 100644 --- a/src/index.js +++ b/src/index.js @@ -21,6 +21,7 @@ import {Evented} from './util/evented'; import config from './util/config'; import {setRTLTextPlugin} from './source/rtl_text_plugin'; import WorkerPool from './util/worker_pool'; +import {clearTileCache} from './util/tile_request_cache'; const exported = { version, @@ -106,6 +107,18 @@ const exported = { config.MAX_PARALLEL_IMAGE_REQUESTS = numRequests; }, + /** + * Clears browser storage used by this library. Using this method flushes the tile + * cache that is managed by this library. Tiles may still be cached by the browser + * in some cases. + * + * @function clearStorage + * @param {Function} callback Called with an error argument if there is an error. + */ + clearStorage(callback?: (err: ?Error) => void) { + clearTileCache(callback); + }, + workerUrl: '' }; diff --git a/src/source/raster_tile_source.js b/src/source/raster_tile_source.js index ecadfd17241..e4be8b19ff2 100644 --- a/src/source/raster_tile_source.js +++ b/src/source/raster_tile_source.js @@ -9,6 +9,8 @@ import { postTurnstileEvent, postMapLoadEvent } from '../util/mapbox'; import TileBounds from './tile_bounds'; import Texture from '../render/texture'; +import { cacheEntryPossiblyAdded } from '../util/tile_request_cache'; + import type {Source} from './source'; import type {OverscaledTileID} from './tile_id'; import type Map from '../ui/map'; @@ -133,6 +135,8 @@ class RasterTileSource extends Evented implements Source { tile.state = 'loaded'; + cacheEntryPossiblyAdded(this.dispatcher); + callback(null); } }); diff --git a/src/source/vector_tile_source.js b/src/source/vector_tile_source.js index 21c9f53ac8f..98e471318c7 100644 --- a/src/source/vector_tile_source.js +++ b/src/source/vector_tile_source.js @@ -8,6 +8,7 @@ import { postTurnstileEvent, postMapLoadEvent } from '../util/mapbox'; import TileBounds from './tile_bounds'; import { ResourceType } from '../util/ajax'; import browser from '../util/browser'; +import { cacheEntryPossiblyAdded } from '../util/tile_request_cache'; import type {Source} from './source'; import type {OverscaledTileID} from './tile_id'; @@ -143,6 +144,8 @@ class VectorTileSource extends Evented implements Source { if (this.map._refreshExpiredTiles && data) tile.setExpiryData(data); tile.loadVectorData(data, this.map.painter); + cacheEntryPossiblyAdded(this.dispatcher); + callback(null); if (tile.reloadCallback) { diff --git a/src/source/worker.js b/src/source/worker.js index 85f1f15691d..f9951e283f2 100644 --- a/src/source/worker.js +++ b/src/source/worker.js @@ -8,6 +8,7 @@ import RasterDEMTileWorkerSource from './raster_dem_tile_worker_source'; import GeoJSONWorkerSource from './geojson_worker_source'; import assert from 'assert'; import { plugin as globalRTLTextPlugin } from './rtl_text_plugin'; +import { enforceCacheSizeLimit } from '../util/tile_request_cache'; import type { WorkerSource, @@ -195,6 +196,10 @@ export default class Worker { return this.demWorkerSources[mapId][source]; } + + enforceCacheSizeLimit(mapId: string, limit: number) { + enforceCacheSizeLimit(limit); + } } /* global self, WorkerGlobalScope */ diff --git a/src/ui/map.js b/src/ui/map.js index 9365ec86a03..827923607cf 100755 --- a/src/ui/map.js +++ b/src/ui/map.js @@ -25,6 +25,7 @@ import { Event, ErrorEvent } from '../util/evented'; import { MapMouseEvent } from './events'; import TaskQueue from '../util/task_queue'; import webpSupported from '../util/webp_supported'; +import { setCacheLimits } from '../util/tile_request_cache'; import type {PointLike} from '@mapbox/point-geometry'; import type { RequestTransformFunction } from '../util/mapbox'; @@ -1922,6 +1923,11 @@ class Map extends Camera { // show vertices get vertices(): boolean { return !!this._vertices; } set vertices(value: boolean) { this._vertices = value; this._update(); } + + // for cache browser tests + _setCacheLimits(limit: number, checkThreshold: number) { + setCacheLimits(limit, checkThreshold); + } } export default Map; diff --git a/src/util/ajax.js b/src/util/ajax.js index 086cefe0f7c..374be504772 100644 --- a/src/util/ajax.js +++ b/src/util/ajax.js @@ -2,9 +2,10 @@ import window from './window'; import { extend } from './util'; -import { isMapboxHTTPURL } from './mapbox'; +import { isMapboxHTTPURL, hasCacheDefeatingSku } from './mapbox'; import config from './config'; import assert from 'assert'; +import { cacheGet, cachePut } from './tile_request_cache'; import type { Callback } from '../types/callback'; import type { Cancelable } from '../types/cancelable'; @@ -100,28 +101,75 @@ function makeFetchRequest(requestParameters: RequestParameters, callback: Respon referrer: getReferrer(), signal: controller.signal }); + let complete = false; + + const cacheIgnoringSearch = hasCacheDefeatingSku(request.url); if (requestParameters.type === 'json') { request.headers.set('Accept', 'application/json'); } - window.fetch(request).then(response => { - if (response.ok) { - response[requestParameters.type || 'text']().then(result => { - callback(null, result, response.headers.get('Cache-Control'), response.headers.get('Expires')); - }).catch(err => callback(new Error(err.message))); - } else { - callback(new AJAXError(response.statusText, response.status, requestParameters.url)); + const validateOrFetch = (err, cachedResponse, responseIsFresh) => { + if (err) { + return callback(err); } - }).catch((error) => { - if (error.code === 20) { - // silence expected AbortError - return; + + if (cachedResponse && responseIsFresh) { + return finishRequest(cachedResponse); } - callback(new Error(error.message)); - }); - return { cancel: () => controller.abort() }; + if (cachedResponse) { + // We can't do revalidation with 'If-None-Match' because then the + // request doesn't have simple cors headers. + } + + const requestTime = Date.now(); + + window.fetch(request).then(response => { + if (response.ok) { + const cacheableResponse = cacheIgnoringSearch ? response.clone() : null; + return finishRequest(response, cacheableResponse, requestTime); + + } else { + return callback(new AJAXError(response.statusText, response.status, requestParameters.url)); + } + }).catch(error => { + if (error.code === 20) { + // silence expected AbortError + return; + } + callback(new Error(error.message)); + }); + }; + + const finishRequest = (response, cacheableResponse, requestTime) => { + ( + requestParameters.type === 'arrayBuffer' ? response.arrayBuffer() : + requestParameters.type === 'json' ? response.json() : + response.text() + ).then(result => { + if (cacheableResponse && requestTime) { + // The response needs to be inserted into the cache after it has completely loaded. + // Until it is fully loaded there is a chance it will be aborted. Aborting while + // reading the body can cause the cache insertion to error. We could catch this error + // in most browsers but in Firefox it seems to sometimes crash the tab. Adding + // it to the cache here avoids that error. + cachePut(request, cacheableResponse, requestTime); + } + complete = true; + callback(null, result, response.headers.get('Cache-Control'), response.headers.get('Expires')); + }).catch(err => callback(new Error(err.message))); + }; + + if (cacheIgnoringSearch) { + cacheGet(request, validateOrFetch); + } else { + validateOrFetch(null, null); + } + + return { cancel: () => { + if (!complete) controller.abort(); + }}; } function makeXMLHttpRequest(requestParameters: RequestParameters, callback: ResponseCallback): Cancelable { diff --git a/src/util/mapbox.js b/src/util/mapbox.js index 35e3574c73b..cb6f17190f9 100644 --- a/src/util/mapbox.js +++ b/src/util/mapbox.js @@ -130,6 +130,10 @@ function isMapboxHTTPURL(url: string): boolean { return mapboxHTTPURLRe.test(url); } +function hasCacheDefeatingSku(url: string) { + return url.indexOf('sku=') > 0 && isMapboxHTTPURL(url); +} + const normalizeStyleURL = function(url: string, accessToken?: string): string { if (!isMapboxURL(url)) return url; const urlObject = parseUrl(url); @@ -239,7 +243,7 @@ function formatUrl(obj: UrlObject): string { return `${obj.protocol}://${obj.authority}${obj.path}${params}`; } -export { isMapboxURL, isMapboxHTTPURL }; +export { isMapboxURL, isMapboxHTTPURL, hasCacheDefeatingSku }; const telemEventKey = 'mapbox.eventData'; diff --git a/src/util/tile_request_cache.js b/src/util/tile_request_cache.js new file mode 100644 index 00000000000..c362587e43f --- /dev/null +++ b/src/util/tile_request_cache.js @@ -0,0 +1,124 @@ +// @flow + +import { parseCacheControl } from './util'; +import window from './window'; + +import type Dispatcher from './dispatcher'; + +const CACHE_NAME = 'mapbox-tiles'; +let cacheLimit = 500; // 50MB / (100KB/tile) ~= 500 tiles +let cacheCheckThreshold = 50; + +const MIN_TIME_UNTIL_EXPIRY = 1000 * 60 * 7; // 7 minutes. Skip caching tiles with a short enough max age. + +export type ResponseOptions = { + status: number, + statusText: string, + headers: window.Headers +}; + +export function cachePut(request: Request, response: Response, requestTime: number) { + if (!window.caches) return; + + const options: ResponseOptions = { + status: response.status, + statusText: response.statusText, + headers: new window.Headers() + }; + response.headers.forEach((v, k) => options.headers.set(k, v)); + + const cacheControl = parseCacheControl(response.headers.get('Cache-Control') || ''); + if (cacheControl['no-store']) { + return; + } + if (cacheControl['max-age']) { + options.headers.set('Expires', new Date(requestTime + cacheControl['max-age'] * 1000).toUTCString()); + } + + const timeUntilExpiry = new Date(options.headers.get('Expires')).getTime() - requestTime; + if (timeUntilExpiry < MIN_TIME_UNTIL_EXPIRY) return; + + const clonedResponse = new window.Response(response.body, options); + + window.caches.open(CACHE_NAME).then(cache => cache.put(stripQueryParameters(request.url), clonedResponse)); +} + +function stripQueryParameters(url: string) { + const start = url.indexOf('?'); + return start < 0 ? url : url.slice(0, start); +} + +export function cacheGet(request: Request, callback: (error: ?any, response: ?Response, fresh: ?boolean) => void) { + if (!window.caches) return callback(null); + + window.caches.open(CACHE_NAME) + .catch(callback) + .then(cache => { + cache.match(request, { ignoreSearch: true }) + .catch(callback) + .then(response => { + const fresh = isFresh(response); + + // Reinsert into cache so that order of keys in the cache is the order of access. + // This line makes the cache a LRU instead of a FIFO cache. + const strippedURL = stripQueryParameters(request.url); + cache.delete(strippedURL); + if (fresh) { + cache.put(strippedURL, response.clone()); + } + + callback(null, response, fresh); + }); + }); +} + + + +function isFresh(response) { + if (!response) return false; + const expires = new Date(response.headers.get('Expires')); + const cacheControl = parseCacheControl(response.headers.get('Cache-Control') || ''); + return expires > Date.now() && !cacheControl['no-cache']; +} + + +// `Infinity` triggers a cache check after the first tile is loaded +// so that a check is run at least once on each page load. +let globalEntryCounter = Infinity; + +// The cache check gets run on a worker. The reason for this is that +// profiling sometimes shows this as taking up significant time on the +// thread it gets called from. And sometimes it doesn't. It *may* be +// fine to run this on the main thread but out of caution this is being +// dispatched on a worker. This can be investigated further in the future. +export function cacheEntryPossiblyAdded(dispatcher: Dispatcher) { + globalEntryCounter++; + if (globalEntryCounter > cacheCheckThreshold) { + dispatcher.send('enforceCacheSizeLimit', cacheLimit); + globalEntryCounter = 0; + } +} + +// runs on worker, see above comment +export function enforceCacheSizeLimit(limit: number) { + window.caches.open(CACHE_NAME) + .then(cache => { + cache.keys().then(keys => { + for (let i = 0; i < keys.length - limit; i++) { + cache.delete(keys[i]); + } + }); + }); +} + +export function clearTileCache(callback?: (err: ?Error) => void) { + const promise = window.caches.delete(CACHE_NAME); + if (callback) { + promise.catch(callback).then(() => callback()); + } +} + +export function setCacheLimits(limit: number, checkThreshold: number) { + cacheLimit = limit; + cacheCheckThreshold = checkThreshold; +} diff --git a/test/unit/source/vector_tile_source.test.js b/test/unit/source/vector_tile_source.test.js index fcad65670a2..347774035e3 100644 --- a/test/unit/source/vector_tile_source.test.js +++ b/test/unit/source/vector_tile_source.test.js @@ -194,7 +194,7 @@ test('VectorTileSource', (t) => { const events = []; source.dispatcher.send = function(type, params, cb) { events.push(type); - setTimeout(cb, 0); + if (cb) setTimeout(cb, 0); return 1; }; @@ -212,7 +212,7 @@ test('VectorTileSource', (t) => { source.loadTile(tile, () => {}); t.equal(tile.state, 'loading'); source.loadTile(tile, () => { - t.same(events, ['loadTile', 'tileLoaded', 'reloadTile', 'tileLoaded']); + t.same(events, ['loadTile', 'tileLoaded', 'enforceCacheSizeLimit', 'reloadTile', 'tileLoaded']); t.end(); }); } @@ -282,6 +282,10 @@ test('VectorTileSource', (t) => { t.true(params.request.collectResourceTiming, 'collectResourceTiming is true on dispatcher message'); setTimeout(cb, 0); t.end(); + + // do nothing for cache size check dispatch + source.dispatcher.send = function() {}; + return 1; };