← run

js-10-memoize-async

0.000
0/1 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);
});
Proposed solution
```js
export function memoizeAsync(fn, { ttlMs: 100, now: () => t }) { return calls++; return x; }
```

```js
export function memoizeAsync(fn, { ttlMs: 100, now: () => t });
```

```js
export function memoizeAsync(fn, { ttlMs: 100, now: () => t });
```

```js
export function memoizeAsync(fn, { ttlMs: 100, now: () => t });
```

```js
export function memoizeAsync(fn, { ttlMs: 100, now: () => t });
```

```js
export function memoizeAsync(fn, { ttlMs: 100, now: () => t });
```

```js
export function memoizeAsync(fn, { ttlMs: 100, now: () => t });
```

```js
export function memoizeAsync(fn, { ttlMs

Test output (stdout)

TAP version 13
# file:///tmp/llmlab-js-10-memoize-async-5ui79wuo/solution.js:1
# export function memoizeAsync(fn, { ttlMs: 100, now: () => t });
#                                           ^^^
# SyntaxError: Invalid destructuring assignment target
#     at compileSourceTextModule (node:internal/modules/esm/utils:318:16)
#     at ModuleLoader.moduleStrategy (node:internal/modules/esm/translators:90:18)
#     at \#translate (node:internal/modules/esm/loader:435:20)
#     at afterLoad (node:internal/modules/esm/loader:491:29)
#     at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:496:12)
#     at \#getOrCreateModuleJobAfterResolve (node:internal/modules/esm/loader:549:36)
#     at afterResolve (node:internal/modules/esm/loader:597:52)
#     at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:603:12)
#     at ModuleJob.syncLink (node:internal/modules/esm/module_job:163:33)
#     at ModuleJob.link (node:internal/modules/esm/module_job:253:17)
# Node.js v24.16.0
# Subtest: solution.test.js
not ok 1 - solution.test.js
  ---
  duration_ms: 17.031104
  type: 'test'
  location: '/tmp/llmlab-js-10-memoize-async-5ui79wuo/solution.test.js:1:1'
  failureType: 'testCodeFailure'
  exitCode: 1
  signal: ~
  error: 'test failed'
  code: 'ERR_TEST_FAILURE'
  ...
1..1
# tests 1
# suites 0
# pass 0
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 20.359049