Batshit Batch Manager

repository·master·Indexed 42 days ago

https://github.com/yornaath/batshit

A batch manager that deduplicates and groups multiple requests for the same data type into a single execution within a specific time window or custom schedule. It is designed to optimize network traffic and prevent request waterfalls, particularly in React applications using libraries like React Query. Features include custom resolvers, flexible schedulers (windowed, finite, and max batch size), and dedicated devtools for inspecting the batching process.

Tokens
6.6K
Snippets
20
Records
25
Agent score
53%

What's inside Batshit

  1. Quickstart: Create a basic Batcher

    master

    To use @yornaath/batshit, initialize a Batcher by providing a fetcher function, a resolver, and an optional scheduler. The Batcher deduplicates and batches requests made within a specific time window into a single call.

    • fetcher: An async function that receives an array of keys and returns the batched data.
    • resolver: Uses keyResolver to identify how to extract unique keys from the data or how to map requests to results.
    • scheduler: Uses windowScheduler(ms) to define the time window for batching. If omitted, it defaults to a window of 10ms.

    This is ideal for reducing network overhead when multiple components or functions request data simultaneously.

    import { Batcher, keyResolver, windowScheduler } from "@yornaath/batshit";
    
    type User = { id: number; name: string };
    
    const users = Batcher<User, number>({
      fetcher: async (ids) => {
        // ids is an array of the keys requested
        return client.users.where({
          userId_in: ids,
        });
      },
      resolver: keyResolver("id"),
      scheduler: windowScheduler(10), // Default and can be omitted.
    });
    
    // Requests made within the 10ms window will be batched into one call
    const bob = users.fetch(1);
    const alice = users.fetch(2);
    
    const [bobData, aliceData] = await Promise.all([bob, alice]);
  2. Integrate with React Query

    master

    You can use a batcher inside react-query (or @tanstack/react-query) to deduplicate requests from multiple components.

    Important: A batcher for a group of items should be created once (e.g., at the module level). Do not create batchers inside hooks, as this will prevent batching across different component instances.

    import { useQuery } from "react-query";
    import { create, keyResolver, windowScheduler } from "@yornaath/batshit";
    
    const users = create({
      fetcher: async (ids: number[]) => {
        return client.users.where({
          userId_in: ids,
        });
      },
      resolver: keyResolver("id"),
      scheduler: windowScheduler(10),
    });
    
    const useUser = (id: number) => {
      return useQuery(["users", id], async () => {
        return users.fetch(id);
      });
    };
    
    const UserDetails = (props: { userId: number }) => {
      const { isFetching, data } = useUser(props.userId);
      return (
        <>
          {isFetching ? (
            <div>Loading user {props.userId}</div>
          ) : (
            <div>User: {data.name}</div>
          )}
        </>
      );
    };
    
    const UserList = () => {
      const userIds = [1, 2, 3, 4];
      return (
        <>
          {userIds.map((id) => (
            <UserDetails key={id} userId={id} />
          ))}
        </>
      );
        </>
      );
    };
  3. Debug batching with Batshit Devtools

    master

    To inspect the batching process in a React application, install @yornaath/batshit-devtools and @yornaath/batshit-devtools-react. When creating a batcher, provide a name property to identify it in the devtools UI.

    yarn add @yornaath/batshit-devtools @yornaath/batshit-devtools-react
    import { create, keyResolver, windowScheduler } from "@yornaath/batshit";
    import BatshitDevtools from "@yornaath/batshit-devtools-react";
    
    const batcher = create({
      fetcher: async (queries: number[]) => {...},
      scheduler: windowScheduler(10),
      resolver: keyResolver("id"),
      name: "batcher:data" // identifier for devtools
    });
    
    const App = () => (
      <>
        <BatshitDevtools />
      </>
    );
  4. Use @yornaath/batshit-devtools

    master

    The @yornaath/batshit-devtools package provides tools for inspecting and debugging batching operations in batshit. It exports functionality related to devtools core logic, event monitoring, and state management.

    To use the devtools, you will typically interact with the exported modules for devtools, events, and state to observe how batches are being resolved and how the internal state of the batcher evolves.

  5. How Batcher scheduling works

    master

    A Batcher manages a queue of queries. When fetch(query) is called, the scheduler is invoked to decide when the batch should run.

    Schedule Return Values

    • number (ms): The batch will execute after the specified delay.
    • 'immediate': The batch will execute as soon as possible (the current batch is processed immediately).
    • 'never': The batch will not execute automatically; it requires a manual call to batcher.next().

    Mental Model

    1. Queueing: fetch(q) adds q to a Set and updates the latest timestamp.
    2. Scheduling: The scheduler looks at the start time (first request in batch), latest time, and current batchSize to return a Schedule.
    3. Execution: If a delay is returned, a setTimeout is set. If 'immediate', fetchBatch() is called. If 'never', the request stays in the queue until next() is called.
  6. Integrate Batcher with React Query

    master

    You can use @yornaath/batshit inside react-query to prevent a 'waterfall' of network requests when multiple components render at once. By wrapping the users.fetch(id) call inside a useQuery hook, all components requesting user data within the same 10ms window will trigger only one batch request instead of individual network calls.

    import { useQuery } from "react-query";
    import { Batcher, keyResolver, windowScheduler } from "@yornaath/batshit";
    
    const users = Batcher<User, number>({
      fetcher: async (ids) => {
        return client.users.where({
          userId_in: ids,
        });
      },
      resolver: keyResolver("id"),
      scheduler: windowScheduler(10),
    });
    
    const useUser = (id: number) => {
      return useQuery(["users", id], async () => {
        return users.fetch(id);
      });
    };
    
    const UserDetails = (props: { userId: number }) => {
      const { isFetching, data } = useUser(props.userId);
      return (
        <>
          {isFetching ? (
            <div>Loading user {props.userId}</div>
          ) : (
            <div>User: {data.name}</div>
          )}
        </>
      );
    };
    
    const UserList = () => {
      const userIds = [1, 2, 3, 4];
      return (
        <>
          {userIds.map((id) => (
            <UserDetails key={id} userId={id} />
          ))}
        </>
      );
    };
  7. Resolve responses that are objects of items

    master

    If your API returns a record/object where keys are the IDs (e.g., { "1": { ... } }) instead of an array, use indexedResolver() to map the results correctly.

    import * as batshit from "@yornaath/batshit";
    
    const batcher = batshit.create({
      fetcher: async (ids: string[]) => {
        const users: Record<string, User> = await fetchUserRecords(ids)
        return users
      },
      resolver: batshit.indexedResolver(),
    });
  8. Use a Custom Batch Resolver

    master

    If the relationship between the query and the result is complex (e.g., fetching posts for multiple users), you can provide a custom resolver function. The resolver receives the full list of results from the fetcher and the specific query that triggered the request.

    const userposts = create({
      fetcher: async (queries: { authorId: number }) => {
        return api.posts.where({
          authorId_in: queries.map((q) => q.authorId),
        });
      },
      scheduler: windowScheduler(10),
      resolver: (posts, query) =>
        posts.filter((post) => post.authorId === query.authorId),
    });
    
    const [alicesPosts, bobsPost] = await Promise.all([
      userposts.fetch({authorId: 1}),
      userposts.fetch({authorId: 2})
    ]);
  9. Use a Custom Batch Resolver for complex queries

    master

    When your fetcher returns a flat list of items that doesn't map 1:1 to the requested keys (e.g., fetching posts for multiple authors), you can provide a custom resolver function.

    The resolver signature is: (results, query) => T.

    • results: The array of data returned by the fetcher.
    • query: The specific query object passed to .fetch().

    This allows you to filter the bulk results to return only the data relevant to the specific caller.

    // Example: Fetching posts for multiple authors in one request
    const userposts = Batcher<{ authorId: number, content: string }, { authorId: number }, any[]>({
      fetcher: async (queries) => {
        // queries is an array of the objects passed to .fetch()
        return api.posts.where({
          authorId_in: queries.map((q) => q.authorId),
        });
      },
      scheduler: windowScheduler(10),
      // The resolver filters the bulk 'posts' list for the specific 'query' requested
      resolver: (posts, query) =>
        posts.filter((post) => post.authorId === query.authorId),
    });
    
    // These two calls will be batched into one API request
    const [alicesPosts, bobsPosts] = await Promise.all([
      userposts.fetch({ authorId: 1 }),
      userposts.fetch({ authorId: 2 }),
    ]);
  10. Execute a batch early with batcher.next()

    master

    You can force the current batch to execute immediately, even if the scheduler's time window hasn't expired, by calling batcher.next().

    const batcher = create({
      fetcher: async (ids: number[]) => {
        return mock.usersByIds(ids);
      },
      resolver: keyResolver("id"),
      scheduler: windowScheduler(33),
    });
    
    let all = Promise.all([batcher.fetch(1), batcher.fetch(2), batcher.fetch(3), batcher.fetch(4)]);
    
    batcher.next();
    
    const users = await all; // The batch is fetched immediately due to .next()
  11. Abort a batch with batcher.abort()

    master

    You can cancel the current pending batch using batcher.abort(). If your fetcher accepts an AbortSignal as its second argument, the signal will be passed down, allowing you to cancel the underlying network request (e.g., via fetch).

    const batcher = create({
      fetcher: async (ids: number[], signal: AbortSignal) => {
        return fetch(`/users?ids=${ids.join(",")}`, { signal })
      },
      resolver: keyResolver("id"),
    });
    
    let all = Promise.all([batcher.fetch(1), batcher.fetch(2), batcher.fetch(3), batcher.fetch(4)]);
    
    setTimeout(() => {
      batcher.abort();
    }, 5);
    
    const error = await all.catch((error) => error);
    // error will be a DOMException with message "Aborted"