Skip to content

Commit 15c3283

Browse files
authored
Merge pull request #14 from mapbox/async
async cacher
2 parents 2970c6a + 76a47ab commit 15c3283

14 files changed

Lines changed: 9504 additions & 388 deletions

.eslintrc

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
11
{
2-
"extends": "eslint-config-unstyled"
2+
"extends": "@mapbox/eslint-config-mapbox",
3+
"plugins": ["node", "jest"],
4+
"parserOptions": {
5+
"ecmaVersion": "latest"
6+
},
7+
"env": {
8+
"es6": true,
9+
"jest/globals": true
10+
},
11+
"rules": {
12+
"no-async-promise-executor": "off"
13+
}
314
}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules

.travis.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
language: node_js
2+
dist: jammy
23
node_js:
3-
- 10
4-
- 12
5-
- 14
4+
- 16
5+
- 18

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
# Changelog
22

3+
## v4.0.0
4+
5+
**BREAKING**
6+
7+
- library now exports two items, `Locking` and `LockingAsync`
8+
- `Locking` function, which contains the same interface as previous versions
9+
- `LockingAsync` class, for managing async/promise functions of arbitrary parameters
10+
- update lru-cache 7.x, this includes updated options:
11+
- `stale` is now `allowStale`
12+
- `maxAge` is now `ttl`
13+
- use jest as a test runner
14+
- test on node 16 & 18, drop node 10 & 12
15+
316
## v3.3.0
17+
418
- Make sure not to fetch data with a key that doesn't exist
519

620
## v3.2.0

README.md

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,53 @@
1-
locking
2-
-------
1+
# @mapbox/locking :lock: [![Build Status](https://app.travis-ci.com/mapbox/locking.svg?branch=main)](https://app.travis-ci.com/mapbox/locking)
32

4-
[![Build Status](https://travis-ci.org/mapbox/locking.svg?branch=master)](https://travis-ci.org/mapbox/locking)
3+
```
4+
npm install --save @mapbox/locking
5+
```
56

6-
Wraps any loader function of the form `loader(id, callback)` to use
7-
an LRU cache and lock I/O operations across concurrent calls.
7+
Wrap any callback or async function to use an LRU cache and prevent (lock) asynchronous operations across concurrent calls.
8+
9+
# Usage
810

911
```js
10-
var Locking = require('@mapbox/locking');
11-
var fs = require('fs');
12-
var readFile = Locking(fs.readFile);
12+
const { Locking, LockingAsync } = require('@mapbox/locking');
13+
```
14+
15+
The `options` objects are passed directly to [lru-cache](https://github.com/isaacs/node-lru-cache/tree/9bb53afaf4654dff759e7ece6b092f6c33d036dc#options) allowing you to set the max age, max items, and other behaviors of the LRU cache. When `options.allowStale` is set to `true` the locking cache implements additional behavior to continue serving a stale item until the item has been refreshed in the background.
16+
17+
### `Locking(function, options)`
18+
19+
For locking callback functions in the form `function(id, callback)`. Returns a callable function
20+
21+
```js
22+
const { Locking } = require('@mapbox/locking');
23+
const fs = require('fs');
24+
const readFile = Locking(fs.readFile, options);
1325

1426
// Reads file once, calls callback 10x.
15-
for (var i = 0; i < 10; i++) {
16-
readFile('./sample.txt', function(err, data) {
17-
console.log(data);
18-
});
27+
for (let i = 0; i < 10; i++) {
28+
readFile('./sample.txt', function(err, data) {
29+
console.log(data);
30+
});
1931
}
2032
```
2133

22-
`options` is passed directly to [lru-cache](https://github.com/isaacs/node-lru-cache/blob/f25bdae0b4bb0166a75fa01d664a3e3cece1ce98/README.md#options) allowing you to set the max age, max items, and other behaviors of the LRU cache.
34+
### `LockingAsync(function, options)`
35+
36+
Constructor for locking promise or async functions in the form of `function(...arguments)`. This can manage functions of arbitrary arguments and types. Returns a class with two functions, which includes a callable method `get()` for calling the original function.
2337

24-
When `options.stale` is set to `true` the locking cache implements additional behavior to continue serving a stale item until the item has been refreshed in the background.
38+
```js
39+
const { LockingAsync } = require('@mapbox/locking');
40+
const got = require('got');
41+
42+
const jsonApi = new Locking((url) => {
43+
return got(url).json();
44+
}, options);
2545

26-
See also:
46+
// call function
47+
await jsonApi.get('https://api.mapbox.com');
48+
await jsonApi.get('https://api.mapbox.com');
49+
await jsonApi.get('https://api.mapbox.com');
2750

28-
- https://github.com/isaacs/async-cache
51+
// get stats
52+
console.log(jsonApi.stats); // { total: 3, hit: 2, miss: 1, locks: 2, refreshHit: 0, currentLocks: 0, size: 1 }
53+
```

index.js

Lines changed: 5 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,117 +1,6 @@
1-
var loaders = [];
2-
var cachers = [];
3-
var LRU = require('lru-cache');
1+
'use strict';
42

5-
module.exports = locking;
6-
7-
/**
8-
* Wraps any loader function of the form `loader(id, callback)` to use
9-
* an LRU cache and lock I/O operations across concurrent calls.
10-
*
11-
* @param {Function} loader any asynchronous function that takes
12-
* a callback as its last argument.
13-
* @param {Object} options passed to the lru-cache instance
14-
* @returns {Function} a version of that function that locks on successive
15-
* simultaneous calls.
16-
*/
17-
function locking(loader, options) {
18-
var existing = loaders.indexOf(loader);
19-
if (existing !== -1) return cachers[existing];
20-
21-
options = options || { max: 100, maxAge: 30e3 };
22-
23-
var cache = new LRU(options);
24-
var locks = {};
25-
var cacheStats = { hit:0, miss:0, mchit:0, mcmiss:0 };
26-
var cacher = options.stale ?
27-
createStaleCacher(cache, locks, cacheStats, loader) :
28-
createCacher(cache, locks, cacheStats, loader);
29-
cacher.cacheStats = cacheStats;
30-
cacher.cache = cache;
31-
loaders.push(loader);
32-
cachers.push(cacher);
33-
return cacher;
34-
}
35-
36-
function createStaleCacher(cache, locks, cacheStats, loader) {
37-
return function(id, callback) {
38-
// Stringify objects.
39-
var key = JSON.stringify(id);
40-
41-
// Instance is in LRU cache.
42-
var cached = cache.get(key);
43-
if (cached) {
44-
cacheStats.hit++;
45-
// The retrieved version of the object is not stale.
46-
if (cache.has(key)) {
47-
return callback(null, cached);
48-
// The retrieved version of the object was stale: return it and
49-
// cache a fresh version of the object in the background.
50-
//
51-
// 1. Return it
52-
// 2. Set it again in the cache so calls to the loader continue
53-
// receiving the stale version until the fresh one is loaded
54-
// 3. Continue onto loading logic
55-
} else {
56-
callback(null, cached);
57-
cache.set(key, cached);
58-
}
59-
} else {
60-
// Previous instance creation is in progress (locking).
61-
if (locks[key]) return locks[key].push(callback);
62-
63-
// Create a new lock.
64-
locks[key] = [callback];
65-
}
66-
67-
loader(id, function(err, instance) {
68-
if (!err && instance) {
69-
cacheStats.miss++;
70-
cache.set(key, instance);
71-
}
72-
73-
if (locks[key]) {
74-
var q = locks[key];
75-
delete locks[key];
76-
for (var i = 0; i < q.length; i++) {
77-
q[i](err, instance);
78-
}
79-
}
80-
});
81-
};
82-
}
83-
84-
function createCacher(cache, locks, cacheStats, loader) {
85-
return function(id, callback) {
86-
// Stringify objects.
87-
var key = JSON.stringify(id);
88-
89-
// Instance is in LRU cache.
90-
var cached = cache.get(key);
91-
if (cached) {
92-
cacheStats.hit++;
93-
return callback(null, cached);
94-
}
95-
96-
// Previous instance creation is in progress (locking).
97-
if (locks[key]) return locks[key].push(callback);
98-
99-
// Create a new lock.
100-
locks[key] = [callback];
101-
102-
loader(id, function(err, instance) {
103-
if (!err && instance) {
104-
cacheStats.miss++;
105-
cache.set(key, instance);
106-
}
107-
108-
if (locks[key]) {
109-
var q = locks[key];
110-
delete locks[key];
111-
for (var i = 0; i < q.length; i++) {
112-
q[i](err, instance);
113-
}
114-
}
115-
});
116-
};
117-
}
3+
module.exports = {
4+
Locking: require('./lib/callback.js'),
5+
LockingAsync: require('./lib/async.js')
6+
};

lib/async.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
'use strict';
2+
3+
const LRU = require('lru-cache');
4+
const hash = require('object-hash');
5+
6+
class LockingAsync {
7+
/*
8+
temporary locker for holding resolve/rejects of
9+
concurrent cache misses for the same key
10+
11+
The locker key includes an array of promise resolve/reject
12+
functions to be invoked once the original caller
13+
fetches the result of the operation. Example:
14+
15+
{
16+
"1119717257625be99": [[resolve, reject], [resolve, reject], [resolve, reject]]
17+
}
18+
*/
19+
#locks;
20+
#stale;
21+
#stats;
22+
23+
constructor(func, params) {
24+
if (!func) throw new Error('Locking: locking class requires two arguments: function, params');
25+
this.func = func;
26+
this.cache = new LRU({ max: 1000, ...params });
27+
this.#locks = {};
28+
this.#stale = this.cache.allowStale;
29+
this.#stats = {
30+
locks: 0,
31+
miss: 0,
32+
hit: 0,
33+
refreshHit: 0,
34+
calls: 0
35+
};
36+
}
37+
38+
#createKey(args) {
39+
return hash(args);
40+
}
41+
42+
#resolveLocks(key, result) {
43+
const locks = this.#locks[key];
44+
if (!locks) return;
45+
delete this.#locks[key];
46+
locks.forEach((lock) => {
47+
lock[0](result);
48+
});
49+
}
50+
51+
#rejectLocks(key, err) {
52+
const locks = this.#locks[key];
53+
if (!locks) return;
54+
delete this.#locks[key];
55+
locks.forEach((lock) => {
56+
lock[1](err);
57+
});
58+
}
59+
60+
get stats() {
61+
const activeLocks = Object.keys(this.#locks).reduce((total, key) => {
62+
return total += this.#locks[key].length;
63+
}, 0, this) || 0;
64+
65+
return {
66+
activeLocks,
67+
size: this.cache.size,
68+
...this.#stats
69+
};
70+
}
71+
72+
async get() {
73+
const args = [...arguments];
74+
this.#stats.calls++;
75+
return new Promise(async (resolve, reject) => {
76+
let staleRefresh = false;
77+
const key = this.#createKey(args);
78+
const cached = this.cache.get(key);
79+
// if item is in the cache, return to caller
80+
if (cached) {
81+
this.#stats.hit++;
82+
if (!this.#stale) return resolve(cached);
83+
84+
// item is not stale
85+
if (this.cache.has(key)) return resolve(cached);
86+
87+
// item is in cache but stale
88+
// resolve with stale item but keep refreshing
89+
// in the background
90+
this.#stats.refreshHit++;
91+
resolve(cached);
92+
staleRefresh = true;
93+
this.cache.set(key, cached);
94+
}
95+
96+
this.#stats.miss++;
97+
98+
// Use the lock if it exists to preserve the resolve/reject
99+
// functions of the promise to be called in the future.
100+
if (this.#locks[key] && !staleRefresh) {
101+
this.#stats.locks++;
102+
this.#locks[key].push([resolve, reject]);
103+
return;
104+
}
105+
106+
// Set lock for 1 or more concurrent operations
107+
// for the same key. Do not set a lock for a stale
108+
// hit so we can just refresh the cache value
109+
// in the background
110+
if (!staleRefresh) {
111+
this.#locks[key] = [[resolve, reject]];
112+
}
113+
114+
// get fresh item and resolve/reject 1 or more locks
115+
try {
116+
const result = await this.func(...args);
117+
this.cache.set(key, result);
118+
this.#resolveLocks(key, result);
119+
} catch (err) {
120+
this.#rejectLocks(key, err);
121+
}
122+
});
123+
}
124+
}
125+
126+
module.exports = LockingAsync;

0 commit comments

Comments
 (0)