batch.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. /**
  2. * Coalesces per-id reads into one bulk request.
  3. *
  4. * A page that shows every printer asks for each printer's status, slot presets,
  5. * plugs and so on, one request per printer. On a print farm that was hundreds
  6. * of requests per page load, all queued behind each other on the server. A
  7. * batched read collects the ids asked for within a few milliseconds and sends
  8. * them as one request to a bulk endpoint, then hands each caller its own
  9. * answer. Callers, their query keys, intervals and invalidations are unchanged.
  10. *
  11. * A window that ends up with a single id uses the single-id request, so one
  12. * printer refreshed after an action, or a page that shows one printer, behaves
  13. * exactly as it did.
  14. */
  15. // Long enough to catch every card's interval refetch firing in the same
  16. // moment, short enough not to be noticed.
  17. const WINDOW_MS = 10;
  18. // Keeps the bulk request's URL short; ids beyond this go in another request.
  19. const MAX_BATCH = 200;
  20. // Windows still collecting ids or waiting for their requests; see batchesSettled
  21. const active = new Set<Promise<unknown>>();
  22. /**
  23. * Resolves once every batch already started has sent its requests and had
  24. * them answered. Tests wait on it between cases, so a request one case
  25. * started doesn't land on the next case's mocks.
  26. */
  27. export function batchesSettled(): Promise<void> {
  28. return Promise.allSettled([...active]).then(() => undefined);
  29. }
  30. interface Waiter<T> {
  31. resolve: (value: T) => void;
  32. reject: (reason: unknown) => void;
  33. }
  34. /**
  35. * @param single the single-id request, used for a window with one id, for an
  36. * id the bulk answer left out, and when the bulk request fails for any
  37. * reason but a refusal (401/403).
  38. * @param many the bulk request; returns an answer for every id it knows.
  39. */
  40. export function batched<T>(
  41. single: (id: number) => Promise<T>,
  42. many: (ids: number[]) => Promise<Map<number, T>>,
  43. ): (id: number) => Promise<T> {
  44. let pending = new Map<number, Waiter<T>[]>();
  45. let timer: ReturnType<typeof setTimeout> | null = null;
  46. const settleOne = (id: number, waiters: Waiter<T>[]) =>
  47. single(id).then(
  48. (value) => waiters.forEach((w) => w.resolve(value)),
  49. (error) => waiters.forEach((w) => w.reject(error)),
  50. );
  51. const flush = (): Promise<unknown> => {
  52. const batch = pending;
  53. pending = new Map();
  54. timer = null;
  55. const ids = [...batch.keys()];
  56. if (ids.length === 1) {
  57. return settleOne(ids[0], batch.get(ids[0])!);
  58. }
  59. const requests: Promise<unknown>[] = [];
  60. for (let i = 0; i < ids.length; i += MAX_BATCH) {
  61. const chunk = ids.slice(i, i + MAX_BATCH);
  62. requests.push(many(chunk).then(
  63. (answers) => {
  64. // An id the bulk answer leaves out (unknown, or outside the
  65. // caller's scope) is asked for on its own, which gives exactly the
  66. // single request's answer: its 404.
  67. const followUps: Promise<unknown>[] = [];
  68. for (const id of chunk) {
  69. const waiters = batch.get(id)!;
  70. if (answers.has(id)) {
  71. const value = answers.get(id) as T;
  72. waiters.forEach((w) => w.resolve(value));
  73. } else {
  74. followUps.push(settleOne(id, waiters));
  75. }
  76. }
  77. return Promise.all(followUps);
  78. },
  79. (error) => {
  80. // Not allowed to read these at all: the single requests would be
  81. // refused the same way, so don't send them.
  82. const status = (error as { status?: number } | null)?.status;
  83. if (status === 401 || status === 403) {
  84. for (const id of chunk) batch.get(id)!.forEach((w) => w.reject(error));
  85. return undefined;
  86. }
  87. // Anything else (a backend without the bulk route, which answers
  88. // 404, 405 or 422; a server error; a dropped connection): ask one by
  89. // one, as before, so one printer the server can't answer for fails
  90. // only its own card rather than every card on the page.
  91. return Promise.all(chunk.map((id) => settleOne(id, batch.get(id)!)));
  92. },
  93. ));
  94. }
  95. return Promise.all(requests);
  96. };
  97. return (id: number) =>
  98. new Promise<T>((resolve, reject) => {
  99. const waiters = pending.get(id);
  100. if (waiters) waiters.push({ resolve, reject });
  101. else pending.set(id, [{ resolve, reject }]);
  102. if (timer === null) {
  103. let done: () => void = () => {};
  104. const settled = new Promise<void>((resolve) => {
  105. done = resolve;
  106. });
  107. active.add(settled);
  108. timer = setTimeout(() => {
  109. void flush().finally(() => {
  110. active.delete(settled);
  111. done();
  112. });
  113. }, WINDOW_MS);
  114. }
  115. });
  116. }
  117. /** A JSON object keyed by id (keys arrive as strings) as a Map. */
  118. export function byId<T>(record: Record<string, T>): Map<number, T> {
  119. return new Map(Object.entries(record).map(([key, value]) => [Number(key), value]));
  120. }
  121. /** A list of rows carrying their own id as a Map. */
  122. export function byField<T>(rows: T[], field: (row: T) => number): Map<number, T> {
  123. return new Map(rows.map((row) => [field(row), row]));
  124. }