diff --git a/place_building_blocks/place_distance_label/directions_controller.ts b/place_building_blocks/place_distance_label/directions_controller.ts index 1266953..c391824 100644 --- a/place_building_blocks/place_distance_label/directions_controller.ts +++ b/place_building_blocks/place_distance_label/directions_controller.ts @@ -22,12 +22,17 @@ import {LitElement, ReactiveController, ReactiveControllerHost} from 'lit'; import {APILoader} from '../../api_loader/api_loader.js'; import {RequestErrorEvent} from '../../base/events.js'; +import {RequestCache} from '../../utils/request_cache.js'; + +const CACHE_SIZE = 100; /** * Controller that interfaces with the Maps JavaScript API Directions Service. */ export class DirectionsController implements ReactiveController { private static service?: google.maps.DirectionsService; + private static readonly cache = new RequestCache< + google.maps.DirectionsRequest, google.maps.DirectionsResult>(CACHE_SIZE); constructor(private readonly host: ReactiveControllerHost&LitElement) { this.host.addController(this); @@ -42,9 +47,14 @@ export class DirectionsController implements ReactiveController { */ async route(request: google.maps.DirectionsRequest): Promise { - const service = await this.getService(); + let responsePromise = DirectionsController.cache.get(request); + if (responsePromise === null) { + responsePromise = + this.getService().then((service) => service.route(request)); + DirectionsController.cache.set(request, responsePromise); + } try { - return await service.route(request); + return await responsePromise; } catch (error: unknown) { const requestErrorEvent = new RequestErrorEvent(error); this.host.dispatchEvent(requestErrorEvent); diff --git a/utils/request_cache.ts b/utils/request_cache.ts new file mode 100644 index 0000000..1d42d09 --- /dev/null +++ b/utils/request_cache.ts @@ -0,0 +1,75 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {LRUMap} from './lru_map.js'; + +/** + * A limited-capacity cache keyed by serialized request objects. + */ +export class RequestCache { + private readonly requestCacheMap: LRUMap>; + /** + * @param capacity - The maximum number of objects to keep in the cache. + */ + constructor(capacity: number) { + this.requestCacheMap = new LRUMap(capacity); + } + + /** + * Gets the cached result with the given request + */ + get(request: RequestType): Promise|null { + return this.requestCacheMap.get(this.serialize(request)) ?? null; + } + + /** + * Adds the provided request to the cache, replacing the + * existing result if one exists already. + */ + set(key: RequestType, value: Promise) { + this.requestCacheMap.set(this.serialize(key), value); + } + + /** + * Deterministically serializes arbitrary objects to strings. + */ + private serialize(request: RequestType): string { + interface UnknownObject { + [key: string]: unknown; + } + + // Non-numeric keys in modern JS are iterated in order of insertion. + // Make a new object and insert the keys in alphabetical order so that + // the object is serialized alphabetically. + const replacer = (key: string, value: unknown) => { + if (value instanceof Object && !(value instanceof Array)) { + const obj = value as UnknownObject; + const sorted: UnknownObject = {}; + for (const key of Object.keys(obj).sort()) { + sorted[key] = obj[key]; + } + return sorted; + } else { + return value; + } + }; + return JSON.stringify(request, replacer); + } +} diff --git a/utils/request_cache_test.ts b/utils/request_cache_test.ts new file mode 100644 index 0000000..3032ea0 --- /dev/null +++ b/utils/request_cache_test.ts @@ -0,0 +1,101 @@ +/** + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import {RequestCache} from './request_cache.js'; + +interface Request { + id: string; + values: number[]; + location: string; + deliveryEnabled: boolean; + distance: number; + inventory: {[key: string]: number}; +} + +interface Response { + id: string; + cost: number; + delivered: boolean; + status: string; +} + +const FAKE_REQUEST: Request = { + id: 'xHD87228BCE8', + values: [3, 5, 8, 2, 7, 8], + location: '123 Place St', + deliveryEnabled: true, + distance: 105.5, + inventory: {'D': 35, 'A': 10, 'C': 5, 'B': 20}, +}; + +const SORTED_REQUEST: Request = { + deliveryEnabled: true, + distance: 105.5, + id: 'xHD87228BCE8', + inventory: {'A': 10, 'B': 20, 'C': 5, 'D': 35}, + location: '123 Place St', + values: [3, 5, 8, 2, 7, 8], +}; + +const FAKE_RESPONSE: Response = { + id: 'xHD87228BCE8', + cost: 110.75, + delivered: true, + status: 'OK', +}; + +const FAKE_RESPONSE_2: Response = { + id: 'xHD87228BCE8', + cost: 110.75, + delivered: false, + status: 'UNKOWN', +}; + +describe('RequestCache', () => { + it('returns null when no request exists', async () => { + const requestCache = new RequestCache(10); + const result = requestCache.get(FAKE_REQUEST); + expect(result).toBeNull(); + }); + + it('returns the existing result when one exists', async () => { + const requestCache = new RequestCache(10); + requestCache.set(FAKE_REQUEST, Promise.resolve(FAKE_RESPONSE)); + const result = await requestCache.get(FAKE_REQUEST); + expect(result).toEqual(FAKE_RESPONSE); + }); + + it('updates result if request already exists', async () => { + const requestCache = new RequestCache(10); + requestCache.set(FAKE_REQUEST, Promise.resolve(FAKE_RESPONSE)); + requestCache.set(FAKE_REQUEST, Promise.resolve(FAKE_RESPONSE_2)); + const result = await requestCache.get(FAKE_REQUEST); + expect(result).toEqual(FAKE_RESPONSE_2); + }); + + it('treats requests the same regardless of property order', async () => { + const requestCache = new RequestCache(10); + requestCache.set(FAKE_REQUEST, Promise.resolve(FAKE_RESPONSE)); + const result = await requestCache.get(SORTED_REQUEST); + expect(result).toEqual(FAKE_RESPONSE); + }); +});