← All tasksNode.js · Senior

Batch loader (like DataLoader)

In GraphQL resolvers each post.author queries the database separately: 100 posts mean 100 queries (the N+1 problem). Write createLoader(batchFn) with load(key): all load calls made within one turn of the event loop are combined into a single batchFn(keys) call that returns an array of values in the same order.

Repeated keys aren't duplicated in a batch, and an already loaded key comes from the cache without a new request. If an Error is returned in a key's position, only that load rejects. If batchFn throws or returns an array of a different length, every load in the batch rejects, and the keys aren't cached, so the next load tries again.

Tests (6)

  1. const batchFn = fn(async (ids) => ids.map((id) => ({ id, name: 'user' + id })));
    const loader = createLoader(batchFn);
    const [a, b, c] = await Promise.all([loader.load(1), loader.load(2), loader.load(3)]);
    expect([a.name, b.name, c.name]).toEqual(['user1', 'user2', 'user3']);
    expect(batchFn).toHaveBeenCalledTimes(1);
    expect(batchFn).toHaveBeenCalledWith([1, 2, 3]);
  2. const batchFn = fn(async (ids) => ids.map((id) => id * 10));
    const loader = createLoader(batchFn);
    const [x, y] = await Promise.all([loader.load(5), loader.load(5)]);
    expect([x, y]).toEqual([50, 50]);
    expect(batchFn).toHaveBeenLastCalledWith([5]);
  3. const batchFn = fn(async (ids) => ids.map((id) => id));
    const loader = createLoader(batchFn);
    await loader.load(1);
    await loader.load(2);
    await loader.load(1);
    expect(batchFn).toHaveBeenCalledTimes(2);
  4. const loader = createLoader(async (ids) => ids.map((id) => (id === 2 ? new Error('User 2 not found') : id)));
    const results = await Promise.allSettled([loader.load(1), loader.load(2), loader.load(3)]);
    expect(results.map((r) => r.status)).toEqual(['fulfilled', 'rejected', 'fulfilled']);
    expect(results[1].reason.message).toBe('User 2 not found');
  5. let fail = true;
    const batchFn = fn(async (ids) => { if (fail) throw new Error('db down'); return ids; });
    const loader = createLoader(batchFn);
    await expect(loader.load(1)).rejects.toThrow('db down');
    fail = false;
    expect(await loader.load(1)).toBe(1);
  6. const loader = createLoader(async (ids) => ids.slice(1));
    await expect(loader.load('a')).rejects.toThrow('same length');