Skip to content

Commit

Permalink
Race condition fix for on the fly requests, improve cache implementat…
Browse files Browse the repository at this point in the history
…ion and tests

Co-authored-by: Goffert van Gool <ruphin@ruphin.net>
Co-authored-by: Martin Pool <martin.pool@ing.com>
  • Loading branch information
3 people committed Sep 21, 2021
1 parent dec9c75 commit 8795985
Show file tree
Hide file tree
Showing 22 changed files with 1,768 additions and 959 deletions.
5 changes: 5 additions & 0 deletions .changeset/silly-lamps-flash.md
@@ -0,0 +1,5 @@
---
'@lion/ajax': patch
---

Fix cache session race condition for in-flight requests
8 changes: 8 additions & 0 deletions .changeset/tall-adults-act.md
@@ -0,0 +1,8 @@
---
'@lion/ajax': minor
---

**BREAKING** public API changes:

- Changed `timeToLive` to `maxAge`
- Renamed `requestIdentificationFn` to `requestIdFunction`
51 changes: 26 additions & 25 deletions docs/docs/tools/ajax/features.md
Expand Up @@ -15,9 +15,11 @@ const getCacheIdentifier = () => {
return userId;
};

const TEN_MINUTES = 1000 * 60 * 10; // in milliseconds

const cacheOptions = {
useCache: true,
timeToLive: 1000 * 60 * 10, // 10 minutes
maxAge: TEN_MINUTES,
};

const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors(
Expand Down Expand Up @@ -72,9 +74,13 @@ const newUser = await response.json();

### JSON requests

We usually deal with JSON requests and responses. With `fetchJson` you don't need to specifically stringify the request body or parse the response body.
We usually deal with JSON requests and responses. `ajax.fetchJson` supports JSON by:

- Serializing request body as JSON
- Deserializing response payload as JSON
- Adding the correct Content-Type and Accept headers

The result will have the Response object on `.response` property, and the decoded json will be available on `.body`.
> Note that, the result will have the Response object on `.response` property, and the parsed JSON will be available on `.body`.
## GET JSON request

Expand Down Expand Up @@ -133,7 +139,7 @@ export const errorHandling = () => {
}
} else {
// an error happened before receiving a response,
// ex. an incorrect request or network error
// Example: an incorrect request or network error
actionLogger.log(error);
}
}
Expand All @@ -157,32 +163,28 @@ For IE11 you will need a polyfill for fetch. You should add this on your top lev

[This is the polyfill we recommend](https://github.com/github/fetch). It also has a [section for polyfilling AbortController](https://github.com/github/fetch#aborting-requests)

## Ajax Cache
## Ajax Caching Support

A caching library that uses `@lion/ajax` and adds cache interceptors to provide caching for use in
frontend `services`.
Ajax package provides in-memory cache support through interceptors. And cache interceptors can be added manually or by configuring the Ajax instance.

The **request interceptor**'s main goal is to determine whether or not to
**return the cached object**. This is done based on the options that are being
passed.
The cache request interceptor and cache response interceptor are designed to work together to support caching of network requests/responses.

The **response interceptor**'s goal is to determine **when to cache** the
requested response, based on the options that are being passed.
> The **request interceptor** checks whether the response for this particular request is cached, and if so returns the cached response.
> And the **response interceptor** caches the response for this particular request.
### Getting started

Consume the global `ajax` instance and add interceptors to it, using a cache configuration which is applied on application level. If a developer wants to add specifics to cache behaviour they have to provide a cache config per action (`get`, `post`, etc.) via `cacheOptions` field of local ajax config,
see examples below.

> **Note**: make sure to add the **interceptors** only **once**. This is usually
> done on app-level
> **Note**: make sure to add the **interceptors** only **once**. This is usually done on app-level
```js
import { ajax, createCacheInterceptors } from '@lion-web/ajax';

const globalCacheOptions = {
useCache: true,
timeToLive: 1000 * 60 * 5, // 5 minutes
maxAge: 1000 * 60 * 5, // 5 minutes
};

// Cache is removed each time an identifier changes,
Expand All @@ -208,7 +210,7 @@ import { Ajax } from '@lion/ajax';
export const ajax = new Ajax({
cacheOptions: {
useCache: true,
timeToLive: 1000 * 60 * 5, // 5 minutes
maxAge: 1000 * 60 * 5, // 5 minutes
getCacheIdentifier: () => getActiveProfile().profileId,
},
});
Expand All @@ -218,8 +220,7 @@ export const ajax = new Ajax({

> Let's assume that we have a user session, for this demo purposes we already created an identifier function for this and set the cache interceptors.
We can see if a response is served from the cache by checking the `response.fromCache` property,
which is either undefined for normal requests, or set to true for responses that were served from cache.
We can see if a response is served from the cache by checking the `response.fromCache` property, which is either undefined for normal requests, or set to true for responses that were served from cache.

```js preview-story
export const cache = () => {
Expand Down Expand Up @@ -284,28 +285,28 @@ export const cacheActionOptions = () => {

Invalidating the cache, or cache busting, can be done in multiple ways:

- Going past the `timeToLive` of the cache object
- Going past the `maxAge` of the cache object
- Changing cache identifier (e.g. user session or active profile changes)
- Doing a non GET request to the cached endpoint
- Invalidates the cache of that endpoint
- Invalidates the cache of all other endpoints matching `invalidatesUrls` and `invalidateUrlsRegex`

## Time to live
## maxAge

In this demo we pass a timeToLive of three seconds.
Try clicking the fetch button and watch fromCache change whenever TTL expires.
In this demo we pass a maxAge of three seconds.
Try clicking the fetch button and watch fromCache change whenever maxAge expires.

After TTL expires, the next request will set the cache again, and for the next 3 seconds you will get cached responses for subsequent requests.
After maxAge expires, the next request will set the cache again, and for the next 3 seconds you will get cached responses for subsequent requests.

```js preview-story
export const cacheTimeToLive = () => {
export const cacheMaxAge = () => {
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);

const fetchHandler = () => {
ajax
.fetchJson(`../assets/pabu.json`, {
cacheOptions: {
timeToLive: 1000 * 3, // 3 seconds
maxAge: 1000 * 3, // 3 seconds
},
})
.then(result => {
Expand Down
11 changes: 5 additions & 6 deletions docs/docs/tools/ajax/overview.md
@@ -1,10 +1,7 @@
# Tools >> Ajax >> Overview ||10

```js script
import { html } from '@mdjs/mdjs-preview';
import { renderLitAsNode } from '@lion/helpers';
import { ajax, createCacheInterceptors } from '@lion/ajax';
import '@lion/helpers/define';

const getCacheIdentifier = () => {
let userId = localStorage.getItem('lion-ajax-cache-demo-user-id');
Expand All @@ -15,9 +12,11 @@ const getCacheIdentifier = () => {
return userId;
};

const TEN_MINUTES = 1000 * 60 * 10; // in milliseconds

const cacheOptions = {
useCache: true,
timeToLive: 1000 * 60 * 10, // 10 minutes
maxAge: TEN_MINUTES,
};

const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors(
Expand All @@ -33,8 +32,8 @@ ajax.addResponseInterceptor(cacheResponseInterceptor);

- Allows globally registering request and response interceptors
- Throws on 4xx and 5xx status codes
- Prevents network request if a request interceptor returns a response
- Supports a JSON request which automatically encodes/decodes body request and response payload as JSON
- Supports caching, so a request can be prevented from reaching to network, by returning the cached response.
- Supports JSON with `ajax.fetchJSON` by automatically serializing request body and deserializing response payload as JSON, and adding the correct Content-Type and Accept headers.
- Adds accept-language header to requests based on application language
- Adds XSRF header to request if the cookie is present

Expand Down
33 changes: 20 additions & 13 deletions packages/ajax/src/Ajax.js
Expand Up @@ -8,9 +8,14 @@ import { AjaxFetchError } from './AjaxFetchError.js';
import './typedef.js';

/**
* HTTP Client which acts as a small wrapper around `fetch`. Allows registering hooks which
* intercept request and responses, for example to add authorization headers or logging. A
* request can also be prevented from reaching the network at all by returning the Response directly.
* A small wrapper around `fetch`.
- Allows globally registering request and response interceptors
- Throws on 4xx and 5xx status codes
- Supports caching, so a request can be prevented from reaching to network, by returning the cached response.
- Supports JSON with `ajax.fetchJSON` by automatically serializing request body and
deserializing response payload as JSON, and adding the correct Content-Type and Accept headers.
- Adds accept-language header to requests based on application language
- Adds XSRF header to request if the cookie is present
*/
export class Ajax {
/**
Expand Down Expand Up @@ -49,18 +54,18 @@ export class Ajax {

const { cacheOptions } = this.__config;
if (cacheOptions?.useCache) {
const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors(
const { cacheRequestInterceptor, cacheResponseInterceptor } = createCacheInterceptors(
cacheOptions.getCacheIdentifier,
cacheOptions,
);
this.addRequestInterceptor(/** @type {RequestInterceptor} */ (cacheRequestInterceptor));
this.addResponseInterceptor(/** @type {ResponseInterceptor} */ (cacheResponseInterceptor));
this.addRequestInterceptor(cacheRequestInterceptor);
this.addResponseInterceptor(cacheResponseInterceptor);
}
}

/**
* Sets the config for the instance
* @param {Partial<AjaxConfig>} config configuration for the AjaxClass instance
* Configures the Ajax instance
* @param {Partial<AjaxConfig>} config configuration for the Ajax instance
*/
set options(config) {
this.__config = config;
Expand Down Expand Up @@ -95,8 +100,7 @@ export class Ajax {
}

/**
* Makes a fetch request, calling the registered fetch request and response
* interceptors.
* Fetch by calling the registered request and response interceptors.
*
* @param {RequestInfo} info
* @param {RequestInit & Partial<CacheRequestExtension>} [init]
Expand Down Expand Up @@ -126,8 +130,11 @@ export class Ajax {
}

/**
* Makes a fetch request, calling the registered fetch request and response
* interceptors. Encodes/decodes the request and response body as JSON.
* Fetch by calling the registered request and response
* interceptors. And supports JSON by:
* - Serializing request body as JSON
* - Deserializing response payload as JSON
* - Adding the correct Content-Type and Accept headers
*
* @param {RequestInfo} info
* @param {LionRequestInit} [init]
Expand All @@ -149,7 +156,7 @@ export class Ajax {
lionInit.body = JSON.stringify(lionInit.body);
}

// Now that we stringified lionInit.body, we can safely typecast LionRequestInit back to RequestInit
// typecast LionRequestInit back to RequestInit
const jsonInit = /** @type {RequestInit} */ (lionInit);
const response = await this.fetch(info, jsonInit);
let responseText = await response.text();
Expand Down
65 changes: 65 additions & 0 deletions packages/ajax/src/Cache.js
@@ -0,0 +1,65 @@
import './typedef.js';

export default class Cache {
constructor() {
/**
* @type {{ [requestId: string]: { createdAt: number, response: CacheResponse } }}
* @private
*/
this._cachedRequests = {};
}

/**
* Store an item in the cache
* @param {string} requestId key by which the request is stored
* @param {Response} response the cached response
*/
set(requestId, response) {
this._cachedRequests[requestId] = {
createdAt: Date.now(),
response,
};
}

/**
* Retrieve an item from the cache
* @param {string} requestId key by which the cache is stored
* @param {number} maxAge maximum age of a cached request to serve from cache, in milliseconds
* @returns {CacheResponse | undefined}
*/
get(requestId, maxAge = 0) {
const cachedRequest = this._cachedRequests[requestId];
if (!cachedRequest) {
return;
}
const cachedRequestAge = Date.now() - cachedRequest.createdAt;
if (Number.isFinite(maxAge) && cachedRequestAge < maxAge) {
// eslint-disable-next-line consistent-return
return cachedRequest.response;
}
}

/**
* Delete the item with the given requestId from the cache
* @param {string } requestId the request id to delete from the cache
*/
delete(requestId) {
delete this._cachedRequests[requestId];
}

/**
* Delete all items from the cache that match given regex
* @param {RegExp} regex a regular expression to match cache entries
*/
deleteMatching(regex) {
Object.keys(this._cachedRequests).forEach(requestId => {
if (new RegExp(regex).test(requestId)) {
this.delete(requestId);
}
});
}

reset() {
this._cachedRequests = {};
}
}
62 changes: 62 additions & 0 deletions packages/ajax/src/PendingRequestStore.js
@@ -0,0 +1,62 @@
import './typedef.js';

export default class PendingRequestStore {
constructor() {
/**
* @type {{ [requestId: string]: { promise: Promise<void>, resolve: (value?: any) => void } }}
* @private
*/
this._pendingRequests = {};
}

/**
* Creates a promise for a pending request with given key
* @param {string} requestId
*/
set(requestId) {
if (this._pendingRequests[requestId]) {
return;
}
/** @type {(value?: any) => void } */
let resolve;
const promise = new Promise(_resolve => {
resolve = _resolve;
});
// @ts-ignore
this._pendingRequests[requestId] = { promise, resolve };
}

/**
* Gets the promise for a pending request with given key
* @param {string} requestId
* @returns {Promise<void> | undefined}
*/
get(requestId) {
return this._pendingRequests[requestId]?.promise;
}

/**
* Resolves the promise of a pending request that matches the given string
* @param { string } requestId the requestId to resolve
*/
resolve(requestId) {
this._pendingRequests[requestId]?.resolve();
delete this._pendingRequests[requestId];
}

/**
* Resolves the promise of pending requests that match the given regex
* @param { RegExp } regex an regular expression to match store entries
*/
resolveMatching(regex) {
Object.keys(this._pendingRequests).forEach(pendingRequestId => {
if (regex.test(pendingRequestId)) {
this.resolve(pendingRequestId);
}
});
}

reset() {
this._pendingRequests = {};
}
}

0 comments on commit 8795985

Please sign in to comment.