← run

js-10-memoize-async

0.625
5/8 tests· concurrency
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 cached = cache.get(key);

    if (cached) {
      // Check if the cached entry is still valid
      if (cached.promise && !cached.resolved) {
        // In-flight request, return the same promise
        return cached.promise;
      } else if (cached.resolved && cached.timestamp + ttlMs >= now()) {
        // Cache hit, return the resolved value
        return cached.value;
      }
    }

    // No valid cache entry, create a new promise
    const promise = fn(...args).then(
      (value) => {
        // Mark as resolved and store the value
        if (cached) {
          cached.resolved = true;
          cached.value = value;
        }
        return value;
      },
      (error) => {
        // On rejection, remove the cache entry if it exists
        if (cached) {
          cache.delete(key);
        }
        throw error;
      }
    );

    // Store the pending promise in the cache
    cache.set(key, { promise, resolved: false, timestamp: now() });

    return promise;
  };
}
```

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.994171
  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.11881
  type: 'test'
  ...
# Subtest: entry expires after ttl, fn is called again
not ok 3 - entry expires after ttl, fn is called again
  ---
  duration_ms: 0.4033
  type: 'test'
  location: '/tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.test.js:36:1'
  failureType: 'testCodeFailure'
  error: |-
    Expected values to be strictly equal:
    
    1 !== 2
    
  code: 'ERR_ASSERTION'
  name: 'AssertionError'
  expected: 2
  actual: 1
  operator: 'strictEqual'
  stack: |-
    TestContext.<anonymous> (file:///tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.test.js:48:10)
    async Test.run (node:internal/test_runner/test:1313:7)
    async Test.processPendingSubtests (node:internal/test_runner/test:897:7)
  ...
# Subtest: different keys are cached independently
ok 4 - different keys are cached independently
  ---
  duration_ms: 0.10376
  type: 'test'
  ...
# Subtest: multiple arguments form the key
ok 5 - multiple arguments form the key
  ---
  duration_ms: 0.08741
  type: 'test'
  ...
# Subtest: rejections are not cached; next call retries
not ok 6 - rejections are not cached; next call retries
  ---
  duration_ms: 0.35813
  type: 'test'
  location: '/tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.test.js:77:1'
  failureType: 'testCodeFailure'
  error: |-
    The input did not match the regular expression /boom 2/. Input:
    
    'Error: boom 1'
    
  code: 'ERR_ASSERTION'
  name: 'AssertionError'
  expected:
  actual:
  error: 'boom 1'
  stack: |-
    fn (file:///tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.test.js:81:11)
    file:///tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.js:20:21
    file:///tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.test.js:84:30
    waitForActual (node:assert:632:21)
    strict.rejects (node:assert:769:31)
    TestContext.<anonymous> (file:///tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.test.js:84:16)
    Test.runInAsyncScope (node:async_hooks:227:14)
    Test.run (node:internal/test_runner/test:1306:25)
    Test.processPendingSubtests (node:internal/test_runner/test:897:18)
    Test.postRun (node:internal/test_runner/test:1447:19)
  operator: 'rejects'
  stack: |-
    async TestContext.<anonymous> (file:///tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.test.js:85:3)
    async Test.run (node:internal/test_runner/test:1313:7)
    async Test.processPendingSubtests (node:internal/test_runner/test:897:7)
  ...
# Subtest: defaults to Date.now when no clock provided
ok 7 - defaults to Date.now when no clock provided
  ---
  duration_ms: 0.09014
  type: 'test'
  ...
# Subtest: expiry boundary: exactly ttl old is treated as expired
not ok 8 - expiry boundary: exactly ttl old is treated as expired
  ---
  duration_ms: 0.12077
  type: 'test'
  location: '/tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.test.js:101:1'
  failureType: 'testCodeFailure'
  error: |-
    Expected values to be strictly equal:
    
    1 !== 2
    
  code: 'ERR_ASSERTION'
  name: 'AssertionError'
  expected: 2
  actual: 1
  operator: 'strictEqual'
  stack: |-
    TestContext.<anonymous> (file:///tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.test.js:112:10)
    async Test.run (node:internal/test_runner/test:1313:7)
    async Test.processPendingSubtests (node:internal/test_runner/test:897:7)
  ...
1..8
# tests 8
# suites 0
# pass 5
# fail 3
# cancelled 0
# skipped 0
# todo 0
# duration_ms 32.007308
Self-repair attempts
[
  {
    "total": 8,
    "answer": "```js\nexport function memoizeAsync(fn, { ttlMs, now = Date.now } = {}) {\n  const cache = new Map();\n\n  return async function (...args) {\n    const key = JSON.stringify(args);\n    const cached = cache.get(key);\n\n    if (cached) {\n      // Check if the cached entry is still valid\n      if (cached.promise && !cached.resolved) {\n        // In-flight request, return the same promise\n        return cached.promise;\n      } else if (cached.resolved && cached.timestamp + ttlMs >= now()) {\n        // Cache hit, return the resolved value\n        return cached.value;\n      }\n    }\n\n    // No valid cache entry, create a new promise\n    const promise = fn(...args).then(\n      (value) => {\n        // Mark as resolved and store the value\n        if (cached) {\n          cached.resolved = true;\n          cached.value = value;\n        }\n        return value;\n      },\n      (error) => {\n        // On rejection, remove the cache entry if it exists\n        if (cached) {\n          cache.delete(key);\n        }\n        throw error;\n      }\n    );\n\n    // Store the pending promise in the cache\n    cache.set(key, { promise, resolved: false, timestamp: now() });\n\n    return promise;\n  };\n}\n```",
    "passed": 5,
    "test_error": ": multiple arguments form the key\nok 5 - multiple arguments form the key\n  ---\n  duration_ms: 0.08741\n  type: 'test'\n  ...\n# Subtest: rejections are not cached; next call retries\nnot ok 6 - rejections are not cached; next call retries\n  ---\n  duration_ms: 0.35813\n  type: 'test'\n  location: '/tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.test.js:77:1'\n  failureType: 'testCodeFailure'\n  error: |-\n    The input did not match the regular expression /boom 2/. Input:\n    \n    'Error: boom 1'\n    \n  code: 'ERR_ASSERTION'\n  name: 'AssertionError'\n  expected:\n  actual:\n  error: 'boom 1'\n  stack: |-\n    fn (file:///tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.test.js:81:11)\n    file:///tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.js:20:21\n    file:///tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.test.js:84:30\n    waitForActual (node:assert:632:21)\n    strict.rejects (node:assert:769:31)\n    TestContext.<anonymous> (file:///tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.test.js:84:16)\n    Test.runInAsyncScope (node:async_hooks:227:14)\n    Test.run (node:internal/test_runner/test:1306:25)\n    Test.processPendingSubtests (node:internal/test_runner/test:897:18)\n    Test.postRun (node:internal/test_runner/test:1447:19)\n  operator: 'rejects'\n  stack: |-\n    async TestContext.<anonymous> (file:///tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.test.js:85:3)\n    async Test.run (node:internal/test_runner/test:1313:7)\n    async Test.processPendingSubtests (node:internal/test_runner/test:897:7)\n  ...\n# Subtest: defaults to Date.now when no clock provided\nok 7 - defaults to Date.now when no clock provided\n  ---\n  duration_ms: 0.09014\n  type: 'test'\n  ...\n# Subtest: expiry boundary: exactly ttl old is treated as expired\nnot ok 8 - expiry boundary: exactly ttl old is treated as expired\n  ---\n  duration_ms: 0.12077\n  type: 'test'\n  location: '/tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.test.js:101:1'\n  failureType: 'testCodeFailure'\n  error: |-\n    Expected values to be strictly equal:\n    \n    1 !== 2\n    \n  code: 'ERR_ASSERTION'\n  name: 'AssertionError'\n  expected: 2\n  actual: 1\n  operator: 'strictEqual'\n  stack: |-\n    TestContext.<anonymous> (file:///tmp/llmlab-js-10-memoize-async-0lb3gxzu/solution.test.js:112:10)\n    async Test.run (node:internal/test_runner/test:1313:7)\n    async Test.processPendingSubtests (node:internal/test_runner/test:897:7)\n  ...\n1..8\n# tests 8\n# suites 0\n# pass 5\n# fail 3\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 32.007308"
  },
  {
    "total": 8,
    "answer": "```js\nexport function memoizeAsync(fn, { ttlMs, now = Date.now } = {}) {\n  const cache = new Map();\n\n  return async function (...args) {\n    const key = JSON.stringify(args);\n    const cached = cache.get(key);\n\n    if (cached) {\n      // Check if the cached entry is still valid\n      if (cached.promise && !cached.resolved) {\n        // In-flight request, return the same promise\n        return cached.promise;\n      } else if (cached.resolved && cached.timestamp + ttlMs > now()) {\n        // Cache hit, return the resolved value\n        return cached.value;\n      }\n    }\n\n    // No valid cache entry, create a new promise\n    const promise = fn(...args).then(\n      (value) => {\n        // Mark as resolved and store the value\n        if (cached) {\n          cached.resolved = true;\n          cached.value = value;\n        }\n        return value;\n      },\n      (error) => {\n        // On rejection, remove the cache entry if it exists\n        if (cached) {\n          cache.delete(key);\n        }\n        throw error;\n      }\n    );\n\n    // Store the pending promise in the cache\n    cache.set(key, { promise, resolved: false, timestamp: now() });\n\n    return promise;\n  };\n}\n```",
    "passed": 5,
    "test_error": " multiple arguments form the key\nok 5 - multiple arguments form the key\n  ---\n  duration_ms: 3.223341\n  type: 'test'\n  ...\n# Subtest: rejections are not cached; next call retries\nnot ok 6 - rejections are not cached; next call retries\n  ---\n  duration_ms: 0.35753\n  type: 'test'\n  location: '/tmp/llmlab-js-10-memoize-async-mpghs2uh/solution.test.js:77:1'\n  failureType: 'testCodeFailure'\n  error: |-\n    The input did not match the regular expression /boom 2/. Input:\n    \n    'Error: boom 1'\n    \n  code: 'ERR_ASSERTION'\n  name: 'AssertionError'\n  expected:\n  actual:\n  error: 'boom 1'\n  stack: |-\n    fn (file:///tmp/llmlab-js-10-memoize-async-mpghs2uh/solution.test.js:81:11)\n    file:///tmp/llmlab-js-10-memoize-async-mpghs2uh/solution.js:20:21\n    file:///tmp/llmlab-js-10-memoize-async-mpghs2uh/solution.test.js:84:30\n    waitForActual (node:assert:632:21)\n    strict.rejects (node:assert:769:31)\n    TestContext.<anonymous> (file:///tmp/llmlab-js-10-memoize-async-mpghs2uh/solution.test.js:84:16)\n    Test.runInAsyncScope (node:async_hooks:227:14)\n    Test.run (node:internal/test_runner/test:1306:25)\n    Test.processPendingSubtests (node:internal/test_runner/test:897:18)\n    Test.postRun (node:internal/test_runner/test:1447:19)\n  operator: 'rejects'\n  stack: |-\n    async TestContext.<anonymous> (file:///tmp/llmlab-js-10-memoize-async-mpghs2uh/solution.test.js:85:3)\n    async Test.run (node:internal/test_runner/test:1313:7)\n    async Test.processPendingSubtests (node:internal/test_runner/test:897:7)\n  ...\n# Subtest: defaults to Date.now when no clock provided\nok 7 - defaults to Date.now when no clock provided\n  ---\n  duration_ms: 0.08588\n  type: 'test'\n  ...\n# Subtest: expiry boundary: exactly ttl old is treated as expired\nnot ok 8 - expiry boundary: exactly ttl old is treated as expired\n  ---\n  duration_ms: 0.11898\n  type: 'test'\n  location: '/tmp/llmlab-js-10-memoize-async-mpghs2uh/solution.test.js:101:1'\n  failureType: 'testCodeFailure'\n  error: |-\n    Expected values to be strictly equal:\n    \n    1 !== 2\n    \n  code: 'ERR_ASSERTION'\n  name: 'AssertionError'\n  expected: 2\n  actual: 1\n  operator: 'strictEqual'\n  stack: |-\n    TestContext.<anonymous> (file:///tmp/llmlab-js-10-memoize-async-mpghs2uh/solution.test.js:112:10)\n    async Test.run (node:internal/test_runner/test:1313:7)\n    async Test.processPendingSubtests (node:internal/test_runner/test:897:7)\n  ...\n1..8\n# tests 8\n# suites 0\n# pass 5\n# fail 3\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 32.283689"
  },
  {
    "total": 8,
    "answer": "```js\nexport function memoizeAsync(fn, { ttlMs, now = Date.now } = {}) {\n  const cache = new Map();\n\n  return async function (...args) {\n    const key = JSON.stringify(args);\n    const cached = cache.get(key);\n\n    if (cached) {\n      // Check if the cached entry is still valid\n      if (cached.promise && !cached.resolved) {\n        // In-flight request, return the same promise\n        return cached.promise;\n      } else if (cached.resolved && cached.timestamp + ttlMs >= now()) {\n        // Cache hit, return the resolved value\n        return cached.value;\n      }\n    }\n\n    // No valid cache entry, create a new promise\n    const promise = fn(...args).then(\n      (value) => {\n        // Mark as resolved and store the value\n        if (cached) {\n          cached.resolved = true;\n          cached.value = value;\n        }\n        return value;\n      },\n      (error) => {\n        // On rejection, remove the cache entry if it exists\n        if (cached) {\n          cache.delete(key);\n        }\n        throw error;\n      }\n    );\n\n    // Store the pending promise in the cache\n    cache.set(key, { promise, resolved: false, timestamp: now() });\n\n    return promise;\n  };\n}\n```",
    "passed": 5,
    "test_error": ""
  }
]