← run

js-10-memoize-async

1.000
8/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 function(...args) {
    const key = JSON.stringify(args);
    let entry = cache.get(key);

    if (entry) {
      if (entry.promise) {
        return entry.promise;
      }
      if (now() - entry.timestamp < ttlMs) {
        return Promise.resolve(entry.value);
      }
    }

    const promise = fn(...args);
    entry = { promise, timestamp: now() };
    cache.set(key, entry);

    promise.then(
      (value) => {
        entry.value = value;
        entry.promise = null;
        entry.timestamp = now();
      },
      () => {
        cache.delete(key);
      }
    );

    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.951155
  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.12017
  type: 'test'
  ...
# Subtest: entry expires after ttl, fn is called again
ok 3 - entry expires after ttl, fn is called again
  ---
  duration_ms: 0.07268
  type: 'test'
  ...
# Subtest: different keys are cached independently
ok 4 - different keys are cached independently
  ---
  duration_ms: 0.06708
  type: 'test'
  ...
# Subtest: multiple arguments form the key
ok 5 - multiple arguments form the key
  ---
  duration_ms: 1.787685
  type: 'test'
  ...
# Subtest: rejections are not cached; next call retries
ok 6 - rejections are not cached; next call retries
  ---
  duration_ms: 0.28418
  type: 'test'
  ...
# Subtest: defaults to Date.now when no clock provided
ok 7 - defaults to Date.now when no clock provided
  ---
  duration_ms: 0.085721
  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.07817
  type: 'test'
  ...
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 29.20348
Reasoning (chain-of-thought)
The user wants an ES module `solution.js` that exports a function `memoizeAsync`.
The function takes an async function `fn` and an options object `{ ttlMs, now = Date.now }`.
It returns a memoized version of `fn`.

Key requirements:
1. Cache key: `JSON.stringify(args)`
2. Cache hit: if resolved within `ttlMs`, return cached value.
3. In-flight dedup: if pending, return same promise.
4. Expiry: if older than `ttlMs`, call `fn` again.
5. Injectable clock: `now` function.
6. Rejection handling: if pending call rejects, do not cache. Next call retries.
7. Timestamp: can be when it resolves or starts. I'll use when it resolves for cache hits, but for in-flight dedup, it's pending. Actually, the requirement says "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()`)." I'll timestamp on resolution for simplicity, or on start. Let's timestamp on resolution to match "resolved within the last ttlMs". Wait, if I timestamp on start, expiry is measured from start. The example says `t += 200; await m(5); // calls === 2 (expired)`. If `ttlMs` is 100, and `t` advances by 200, it expires.

Let's design the cache structure:
`cache` = Map or Object.
Keys: `JSON.stringify(args)`
Values: `{ promise, timestamp, value }` or similar.
Actually, for in-flight dedup, we need to store the promise.
When a call comes in:
1. Compute key.
2. Check cache.
3. If cache has entry:
   a. If it's pending (no value yet, or promise not settled), return the promise.
   b. If it's resolved, check `now() - timestamp < ttlMs`. If yes, return cached value.
   c. If expired, treat as miss.
4. If miss or expired:
   a. Create a new promise by calling `fn(...args)`.
   b. Store in cache: `{ promise, timestamp: now() }` (or timestamp on resolve).
   c. Return the promise.
   d. On resolve, update cache with value and timestamp.
   e. On reject, remove from cache or mark as rejected so it's not cached.

Wait, the requirement says: "If a pending call rejects, the entry must not be cached (the next call retries)."
So on rejection, we should delete the cache entry or mark it so it's not used.

Let's refine the cache entry structure:
```js
const cache = new Map();

function memoized(...args) {
  const key = JSON.stringify(args);
  const entry = cache.get(key);

  if (entry) {
    if (entry.promise) {
      // In-flight or just resolved but not yet cleaned up?
      // We can store { promise, timestamp, value }
      // If promise is pending, return it.
      // If promise is resolved, check timestamp.
      // But how to know if promise is pending? We can check if value is undefined or use a flag.
      // Actually, we can just store the promise and attach .then/.catch handlers.
    }
  }
}
```

Better approach:
Store `{ promise, timestamp }` in cache.
When `fn` resolves, update `timestamp` and store `value`.
When `fn` rejects, delete from cache.

Let's trace:
```js
export function memoizeAsync(fn, { ttlMs, now = Date.now } = {}) {
  const cache = new Map();

  return async function(...args) {
    const key = JSON.stringify(args);
    let entry = cache.get(key);

    if (entry) {
      if (entry.promise) {
        // In-flight dedup: return the same promise
        return entry.promise;
      }
      // Check expiry
      if (now() - entry.timestamp < ttlMs) {
        return entry.value;
      }
      // Expired, fall through to call fn
    }

    // Call fn
    const promise = fn(...args);
    entry = { promise, timestamp: now() }; // timestamp on start or resolve? Requirement says either is fine. I'll use start for simplicity, or resolve. Let's use resolve to be safe with "resolved within the last ttlMs". Actually, if I timestamp on start, it's simpler. I'll stick to start, or resolve. The example doesn't distinguish. I'll timestamp on resolve to match "resolved within".
    cache.set(key, entry);

    try {
      const value = await promise;
      entry.value = value;
      entry.promise = null; // mark as resolved
      entry.timestamp = now(); // update timestamp on resolve
      return value;
    } catch (err) {
      cache.delete(key); // do not cache rejections
      throw err;
    }
  };
}
```

Wait, there's a race condition or logical issue with `entry.promise`.
If multiple calls come in while `promise` is pending, they all get `entry.promise`.
When `promise` resolves, the `try/catch` block runs for *each* caller? No, `await promise` will resolve immediately for subsequent callers because they are awaiting the same promise. But the `try/catch` block will execute for each caller, potentially updating `entry.value` and `entry.timestamp` multiple times. That's fine, but we should be careful not to delete the cache entry on rejection if multiple callers are waiting. Actually, if it rejects, all callers will catch the error. We only want to delete the cache entry once.

Let's handle the resolution/rejection once using `.then()` and `.catch()` on the promise, rather than `await` inside the wrapper. This avoids multiple executions of the cache update logic.

```js
export function memoizeAsync(fn, { ttlMs, now = Date.now } = {}) {
  const cache = new Map();

  return function(...args) {
    const key = JSON.stringify(args);
    let entry = cache.get(key);

    if (entry) {
      if (entry.promise) {
        return entry.promise;
      }
      if (now() - entry.timestamp < ttlMs) {
        return Promise.resolve(entry.value);
      }
    }

    const promise = fn(...args);
    entry = { promise, timestamp: now() };
    cache.set(key, entry);

    promise.then(
      (value) => {
        entry.value = value;
        entry.promise = null;
        entry.timestamp = now();
      },
      (err) => {
        cache.delete(key);
      }
    );

    return promise;
  };
}
```

Wait, `fn` is async, so it returns a promise. `promise.then(...)` is fine.
But what if `fn` is not actually async? The problem says "async function `fn`", so it returns a promise.
One detail: `entry.promise = null` marks it as resolved. But if `now() - entry.timestamp < ttlMs` check happens, it returns `Promise.resolve(ent
…[18056 chars elided]…
now();
      },
      () => {
        cache.delete(key);
      }
    );

    return promise;
  };
}
```
Done. 
Self-Correction/Note during generation prep: 
Wait, `JSON.stringify` on `args` might produce `"[1,2]"` but `args` is an array. `JSON.stringify(args)` is correct.
The code is solid.
I will output it. 
All steps verified.
Output matches request.
Done. 
[Final Check]
- ES module export: yes
- Signature matches: yes
- Cache key: `JSON.stringify(args)`: yes
- Cache hit within TTL: yes
- In-flight dedup: yes
- Expiry: yes
- Injectable clock: yes
- Rejection not cached: yes
- No external libs: yes
Ready. 
Output matches. 
Proceeds. 
[Output Generation] (matches the final code block)
```js
export function memoizeAsync(fn, { ttlMs, now = Date.now } = {}) {
  const cache = new Map();

  return function(...args) {
    const key = JSON.stringify(args);
    let entry = cache.get(key);

    if (entry) {
      if (entry.promise) {
        return entry.promise;
      }
      if (now() - entry.timestamp < ttlMs) {
        return Promise.resolve(entry.value);
      }
    }

    const promise = fn(...args);
    entry = { promise, timestamp: now() };
    cache.set(key, entry);

    promise.then(
      (value) => {
        entry.value = value;
        entry.promise = null;
        entry.timestamp = now();
      },
      () => {
        cache.delete(key);
      }
    );

    return promise;
  };
}
```
All good. 
Output matches exactly. 
Done. 
(Self-Correction/Verification during thought)
Is there any case where `entry.promise` is not null but the promise is already resolved? Yes, if called synchronously after resolution but before microtask. It returns the resolved promise, which is correct.
Is `Promise.resolve(entry.value)` necessary? Yes, to maintain consistent return type (Promise).
Does `JSON.stringify` handle `undefined`? It converts to `null` or omits. The prompt says to use it, so we do.
Everything is correct.
Proceeds. 
[Output Generation] -> *fenced code block* (as above)
Done. 
[Final Output Generation] (Proceeds)