← 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 async function(...args) {
    const key = JSON.stringify(args);

    // Check if there's a pending call for this key
    if (cache.has(key)) {
      const entry = cache.get(key);

      // If it's a pending promise, return it (in-flight dedup)
      if (entry.status === 'pending') {
        return entry.promise;
      }

      // If it's a resolved value, check if it's still within TTL
      if (entry.status === 'resolved') {
        const elapsed = now() - entry.timestamp;
        if (elapsed < ttlMs) {
          return entry.value;
        }
      }
    }

    // Call the function and create a new entry
    const promise = fn(...args);

    // Store the pending state
    cache.set(key, {
      status: 'pending',
      promise,
      timestamp: now()
    });

    try {
      const result = await promise;

      // Update cache with resolved value and new timestamp
      cache.set(key, {
        status: 'resolved',
        value: result,
        timestamp: now()
      });

      return result;
    } catch (error) {
      // Remove from cache on rejection (next call will retry)
      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: 2.148295
  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.11795
  type: 'test'
  ...
# Subtest: entry expires after ttl, fn is called again
ok 3 - entry expires after ttl, fn is called again
  ---
  duration_ms: 0.07175
  type: 'test'
  ...
# Subtest: different keys are cached independently
ok 4 - different keys are cached independently
  ---
  duration_ms: 0.075551
  type: 'test'
  ...
# Subtest: multiple arguments form the key
ok 5 - multiple arguments form the key
  ---
  duration_ms: 0.07917
  type: 'test'
  ...
# Subtest: rejections are not cached; next call retries
ok 6 - rejections are not cached; next call retries
  ---
  duration_ms: 0.288501
  type: 'test'
  ...
# Subtest: defaults to Date.now when no clock provided
ok 7 - defaults to Date.now when no clock provided
  ---
  duration_ms: 0.09794
  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.06383
  type: 'test'
  ...
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 33.296793
Reasoning (chain-of-thought)
The user wants me to implement an async memoization function with the following requirements:

1. **Cache key**: `JSON.stringify(args)` - the array of arguments
2. **Cache hit**: Return cached value without calling `fn` again if it resolved within `ttlMs`
3. **In-flight dedup**: If a call with the same key is already pending, return the same promise
4. **Expiry**: Once cached entry is older than `ttlMs`, call `fn` again and refresh
5. **Different keys**: Cached independently
6. **Injectable clock**: Use `now` option (defaults to `Date.now`)
7. **Reject handling**: If pending call rejects, don't cache it (next call retries)

Let me think about the implementation:

1. I need a cache object that stores:
   - For each key: either a cached value with timestamp, or a pending promise

2. When a call comes in:
   - Generate the key from args
   - Check if there's a pending call for this key
   - If pending, return the same promise
   - If there's a cached value and it's not expired, return it
   - Otherwise, call `fn`, store the result with timestamp, return the promise

3. When a promise resolves:
   - Store the result with timestamp
   - If it rejects, don't store anything

Let me write the code:

```js
export function memoizeAsync(fn, { ttlMs, now = Date.now } = {}) {
  const cache = new Map();
  
  return async function(...args) {
    const key = JSON.stringify(args);
    
    // Check if there's a pending call
    if (cache.has(key)) {
      const entry = cache.get(key);
      
      // If it's a pending promise, return it (dedup)
      if (entry.status === 'pending') {
        return entry.promise;
      }
      
      // If it's a resolved value, check expiry
      if (entry.status === 'resolved') {
        const elapsed = now() - entry.timestamp;
        if (elapsed < ttlMs) {
          return entry.value;
        }
      }
    }
    
    // Call the function
    const promise = fn(...args);
    
    // Store the pending state
    cache.set(key, {
      status: 'pending',
      promise,
      timestamp: now()
    });
    
    try {
      const result = await promise;
      
      // Update cache with resolved value
      cache.set(key, {
        status: 'resolved',
        value: result,
        timestamp: now()
      });
      
      return result;
    } catch (error) {
      // Remove from cache on rejection
      cache.delete(key);
      throw error;
    }
  };
}
```

I'm tracking the cache entry's state carefully. When a promise resolves, I update the cache with the new value and timestamp. If it rejects, I remove the entry entirely to allow retrying.

The key insight is managing the cache state across different scenarios - pending calls, resolved values, and expired entries. Each call needs to check the cache before potentially invoking the original function.

The implementation ensures that concurrent calls with the same arguments are deduplicated, and expired cache entries trigger a fresh function call.