js-10-memoize-async
1.000
Challenge · difficulty 5/5
# Async memoize with TTL and in-flight dedup
Implement an ES module **`solution.js`** (no external libraries):
```js
export function memoizeAsync(fn, { ttlMs, now = Date.now } = {}) { /* ... */ }
```
Return a memoized version of the async function `fn`. The cache key is
`JSON.stringify(args)` (the array of arguments the wrapper was called with).
Behavior:
- **Cache hit:** if a previous call with the same key resolved within the last `ttlMs`
milliseconds, return the cached value **without calling `fn` again**.
- **In-flight dedup:** if a call with the same key is already pending (its promise has not
settled yet), a new call with that key must return the **same in-flight promise** — `fn`
is invoked only once for concurrent identical calls.
- **Expiry:** once a cached entry is older than `ttlMs`, the next call with that key calls
`fn` again and refreshes the entry.
- Different keys are cached independently.
**Injectable clock:** time is read via the `now` option (a function returning the current
time in ms), which defaults to `Date.now`. Tests pass a controllable `now` so expiry is
deterministic. Timestamp a cache entry using `now()` when it resolves (or when the call
starts — either is acceptable as long as expiry is measured against `now()`).
If a pending call rejects, the entry must not be cached (the next call retries).
Example:
```js
let calls = 0;
let t = 1000;
const slow = async (x) => { calls++; return x * 2; };
const m = memoizeAsync(slow, { ttlMs: 100, now: () => t });
await Promise.all([m(5), m(5)]); // calls === 1 (deduped)
await m(5); // calls === 1 (cache hit)
t += 200; // advance past ttl
await m(5); // calls === 2 (expired)
```
tests/solution.test.js
import { test } from "node:test";
import { strict as assert } from "node:assert";
import { memoizeAsync } from "./solution.js";
const tick = () => new Promise((res) => setTimeout(res, 1));
test("concurrent identical calls share one in-flight promise (dedup)", async () => {
let calls = 0;
const fn = async (x) => {
calls++;
await tick();
return x * 2;
};
const m = memoizeAsync(fn, { ttlMs: 1000, now: () => 0 });
const [a, b, c] = await Promise.all([m(5), m(5), m(5)]);
assert.equal(a, 10);
assert.equal(b, 10);
assert.equal(c, 10);
assert.equal(calls, 1);
});
test("cache hit within ttl does not call fn again", async () => {
let calls = 0;
let t = 1000;
const fn = async (x) => {
calls++;
return x + 1;
};
const m = memoizeAsync(fn, { ttlMs: 100, now: () => t });
assert.equal(await m(7), 8);
t = 1050; // still within ttl
assert.equal(await m(7), 8);
assert.equal(calls, 1);
});
test("entry expires after ttl, fn is called again", async () => {
let calls = 0;
let t = 1000;
const fn = async (x) => {
calls++;
return x;
};
const m = memoizeAsync(fn, { ttlMs: 100, now: () => t });
await m("k");
assert.equal(calls, 1);
t = 1200; // past ttl
await m("k");
assert.equal(calls, 2);
});
test("different keys are cached independently", async () => {
let calls = 0;
const fn = async (x) => {
calls++;
return x * 10;
};
const m = memoizeAsync(fn, { ttlMs: 1000, now: () => 0 });
assert.equal(await m(1), 10);
assert.equal(await m(2), 20);
assert.equal(await m(1), 10); // cached
assert.equal(calls, 2);
});
test("multiple arguments form the key", async () => {
let calls = 0;
const fn = async (a, b) => {
calls++;
return a + b;
};
const m = memoizeAsync(fn, { ttlMs: 1000, now: () => 0 });
assert.equal(await m(1, 2), 3);
assert.equal(await m(1, 2), 3); // hit
assert.equal(await m(2, 1), 3); // different key
assert.equal(calls, 2);
});
test("rejections are not cached; next call retries", async () => {
let calls = 0;
const fn = async () => {
calls++;
throw new Error(`boom ${calls}`);
};
const m = memoizeAsync(fn, { ttlMs: 1000, now: () => 0 });
await assert.rejects(() => m("x"), /boom 1/);
await assert.rejects(() => m("x"), /boom 2/);
assert.equal(calls, 2);
});
test("defaults to Date.now when no clock provided", async () => {
let calls = 0;
const fn = async (x) => {
calls++;
return x;
};
const m = memoizeAsync(fn, { ttlMs: 10000 });
await m(42);
await m(42);
assert.equal(calls, 1);
});
test("expiry boundary: exactly ttl old is treated as expired", async () => {
let calls = 0;
let t = 0;
const fn = async (x) => {
calls++;
return x;
};
const m = memoizeAsync(fn, { ttlMs: 100, now: () => t });
await m("b"); // stored at time 0
t = 100; // age === ttl -> not < ttl -> expired
await m("b");
assert.equal(calls, 2);
});
System prompt
You are an expert programmer. Solve the task exactly as specified. Output your solution as fenced code blocks using the required file name(s) and the exact function/type signatures requested. Prefer correctness; do not include prose outside code unless asked.
Proposed solution
```js
export function memoizeAsync(fn, { ttlMs, now = Date.now } = {}) {
const cache = new Map();
return async function(...args) {
const key = JSON.stringify(args);
const currentTime = now();
// Check if we have a valid cached entry
if (cache.has(key)) {
const entry = cache.get(key);
// If the entry is still valid (not expired)
if (currentTime - entry.timestamp < ttlMs) {
return entry.value;
}
// If expired, remove the old entry
cache.delete(key);
}
// Check if there's an in-flight request for this key
if (cache.has(key)) {
const entry = cache.get(key);
// This should be a pending promise since we just deleted expired entries
return entry.value;
}
// Create a new promise for this call
const promise = fn(...args);
// Store the pending promise in the cache
cache.set(key, {
value: promise,
timestamp: currentTime
});
try {
const result = await promise;
// Update the entry with resolved value and timestamp when it resolves
cache.set(key, {
value: result,
timestamp: now()
});
return result;
} catch (error) {
// If the promise rejects, remove the entry from cache
cache.delete(key);
throw error;
}
};
}
```Test output (stdout)
TAP version 13 # Subtest: concurrent identical calls share one in-flight promise (dedup) ok 1 - concurrent identical calls share one in-flight promise (dedup) --- duration_ms: 1.930216 type: 'test' ... # Subtest: cache hit within ttl does not call fn again ok 2 - cache hit within ttl does not call fn again --- duration_ms: 0.11908 type: 'test' ... # Subtest: entry expires after ttl, fn is called again ok 3 - entry expires after ttl, fn is called again --- duration_ms: 0.072441 type: 'test' ... # Subtest: different keys are cached independently ok 4 - different keys are cached independently --- duration_ms: 0.0735 type: 'test' ... # Subtest: multiple arguments form the key ok 5 - multiple arguments form the key --- duration_ms: 0.08088 type: 'test' ... # Subtest: rejections are not cached; next call retries ok 6 - rejections are not cached; next call retries --- duration_ms: 0.303331 type: 'test' ... # Subtest: defaults to Date.now when no clock provided ok 7 - defaults to Date.now when no clock provided --- duration_ms: 0.083081 type: 'test' ... # Subtest: expiry boundary: exactly ttl old is treated as expired ok 8 - expiry boundary: exactly ttl old is treated as expired --- duration_ms: 0.06757 type: 'test' ... 1..8 # tests 8 # suites 0 # pass 8 # fail 0 # cancelled 0 # skipped 0 # todo 0 # duration_ms 29.461396