UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

787 lines (598 loc) 24.9 kB
import { Alepha } from "alepha"; import { DateTimeProvider } from "alepha/datetime"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AlephaBatch, BatchProvider } from "../index.ts"; describe("BatchProvider", () => { let alepha: Alepha; let batchProvider: BatchProvider; let time: DateTimeProvider; beforeEach(async () => { alepha = Alepha.create().with(AlephaBatch); batchProvider = alepha.inject(BatchProvider); time = alepha.inject(DateTimeProvider); await alepha.start(); }); afterEach(async () => { await alepha.stop(); }); test("should create a batch context", () => { const handler = vi.fn(); const context = batchProvider.createContext(alepha, { handler }); expect(context).toBeDefined(); expect(context.itemStates).toBeInstanceOf(Map); expect(context.partitions).toBeInstanceOf(Map); expect(context.activeHandlers).toEqual([]); expect(context.isShuttingDown).toBe(false); expect(context.isReady).toBe(false); }); test("should push items and return unique IDs", () => { const handler = vi.fn(); const context = batchProvider.createContext(alepha, { handler }); const id1 = batchProvider.push(context, "item-1"); const id2 = batchProvider.push(context, "item-2"); expect(id1).toBeDefined(); expect(id2).toBeDefined(); expect(id1).not.toBe(id2); // Verify IDs are valid UUIDs const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; expect(id1).toMatch(uuidRegex); expect(id2).toMatch(uuidRegex); }); test("should store items in the correct partition", () => { const handler = vi.fn(); const context = batchProvider.createContext(alepha, { handler, partitionBy: (item: { key: string; value: number }) => item.key, }); batchProvider.push(context, { key: "A", value: 1 }); batchProvider.push(context, { key: "B", value: 2 }); batchProvider.push(context, { key: "A", value: 3 }); expect(context.partitions.has("A")).toBe(true); expect(context.partitions.has("B")).toBe(true); const partitionA = context.partitions.get("A"); const partitionB = context.partitions.get("B"); expect(partitionA?.itemIds).toHaveLength(2); expect(partitionB?.itemIds).toHaveLength(1); }); test("should use default partition when partitionBy is not provided", () => { const handler = vi.fn(); const context = batchProvider.createContext(alepha, { handler }); batchProvider.push(context, "item-1"); batchProvider.push(context, "item-2"); expect(context.partitions.has("default")).toBe(true); expect(context.partitions.get("default")?.itemIds).toHaveLength(2); }); test("should get item status", () => { const handler = vi.fn(); const context = batchProvider.createContext(alepha, { handler }); const id = batchProvider.push(context, "item-1"); const status = batchProvider.status(context, id); expect(status).toEqual({ status: "pending" }); }); test("should return undefined status for non-existent item", () => { const handler = vi.fn(); const context = batchProvider.createContext(alepha, { handler }); const status = batchProvider.status(context, "non-existent-id"); expect(status).toBeUndefined(); }); test("should throw error when waiting for non-existent item", async () => { const handler = vi.fn(); const context = batchProvider.createContext(alepha, { handler }); await expect( batchProvider.wait(context, "non-existent-id"), ).rejects.toThrow("Item with id 'non-existent-id' not found"); }); test("should flush partition and process items", async () => { const handler = vi.fn(async (items: string[]) => { return items.map((item) => `processed-${item}`); }); const context = batchProvider.createContext(alepha, { handler }); const id1 = batchProvider.push(context, "item-1"); const id2 = batchProvider.push(context, "item-2"); await batchProvider.flush(context); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith(["item-1", "item-2"]); // Check status after flush const status1 = batchProvider.status(context, id1); const status2 = batchProvider.status(context, id2); expect(status1?.status).toBe("completed"); expect(status2?.status).toBe("completed"); }); test("should flush specific partition only", async () => { const handler = vi.fn(async (items: any[]) => "processed"); const context = batchProvider.createContext(alepha, { handler, partitionBy: (item: { key: string; value: number }) => item.key, }); batchProvider.push(context, { key: "A", value: 1 }); batchProvider.push(context, { key: "B", value: 2 }); await batchProvider.flush(context, "A"); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith([{ key: "A", value: 1 }]); // Partition B should still have items expect(context.partitions.has("B")).toBe(true); expect(context.partitions.get("B")?.itemIds).toHaveLength(1); }); test("should wait for item result after processing", async () => { const handler = vi.fn(async (items: string[]) => { return `batch-result-${items.length}`; }); const context = batchProvider.createContext(alepha, { handler }); const id = batchProvider.push(context, "item-1"); // Start wait before flush (promise created) const waitPromise = batchProvider.wait(context, id); // Flush to process items await batchProvider.flush(context); const result = await waitPromise; expect(result).toBe("batch-result-1"); }); test("should return cached result on subsequent wait calls", async () => { const handler = vi.fn(async () => "result"); const context = batchProvider.createContext(alepha, { handler }); const id = batchProvider.push(context, "item"); await batchProvider.flush(context); const result1 = await batchProvider.wait(context, id); const result2 = await batchProvider.wait(context, id); expect(result1).toBe("result"); expect(result2).toBe("result"); expect(handler).toHaveBeenCalledTimes(1); }); test("should handle handler errors and mark items as failed", async () => { const handler = vi.fn(async () => { throw new Error("Handler failed"); }); const context = batchProvider.createContext(alepha, { handler }); const id = batchProvider.push(context, "item"); await batchProvider.flush(context); const status = batchProvider.status(context, id); expect(status?.status).toBe("failed"); if (status?.status === "failed") { expect(status.error.message).toBe("Handler failed"); } }); test("should reject wait promise on handler error", async () => { const handler = vi.fn(async () => { throw new Error("Handler failed"); }); const context = batchProvider.createContext(alepha, { handler }); const id = batchProvider.push(context, "item"); const waitPromise = batchProvider.wait(context, id); await batchProvider.flush(context); await expect(waitPromise).rejects.toThrow("Handler failed"); }); test("should respect maxSize when flushing", async () => { const handler = vi.fn(async (items: string[]) => `batch-${items.length}`); const context = batchProvider.createContext(alepha, { handler, maxSize: 2, }); batchProvider.push(context, "item-1"); batchProvider.push(context, "item-2"); batchProvider.push(context, "item-3"); batchProvider.push(context, "item-4"); batchProvider.push(context, "item-5"); // Mark context as ready to enable size-based flushing await batchProvider.markReady(context); // Should have flushed twice (2 items each), leaving 1 item await vi.waitFor(() => { expect(handler).toHaveBeenCalledTimes(2); }); expect(handler).toHaveBeenNthCalledWith(1, ["item-1", "item-2"]); expect(handler).toHaveBeenNthCalledWith(2, ["item-3", "item-4"]); }); test("should respect concurrency limit", async () => { // This test verifies that concurrency limits work by measuring // when handlers start - first 2 should start together, next 2 after delay const handlerStartTimes: number[] = []; const handler = vi.fn(async (items: string[]) => { const startTime = Date.now(); handlerStartTimes.push(startTime); // Use a real delay (not time.wait) to ensure parallel execution await new Promise((resolve) => setTimeout(resolve, 50)); return "done"; }); const context = batchProvider.createContext(alepha, { handler, maxSize: 1, concurrency: 2, partitionBy: (item: string) => item, // Each item in its own partition }); batchProvider.push(context, "a"); batchProvider.push(context, "b"); batchProvider.push(context, "c"); batchProvider.push(context, "d"); // Flush all partitions - this should respect concurrency await batchProvider.flush(context); expect(handler).toHaveBeenCalledTimes(4); // With concurrency=2, first 2 handlers should start at roughly the same time, // and the other 2 should start ~50ms later (after first batch completes) const [t1, t2, t3, t4] = handlerStartTimes; // First two should start within 10ms of each other expect(Math.abs(t1 - t2)).toBeLessThan(20); // Third and fourth should start after first batch (50ms later) // They should also start within 10ms of each other expect(Math.abs(t3 - t4)).toBeLessThan(20); // Gap between first and third should be around 50ms (handler duration) expect(t3 - t1).toBeGreaterThanOrEqual(40); }); test("should markReady and start processing buffered items", async () => { const handler = vi.fn(async (items: string[]) => "processed"); const context = batchProvider.createContext(alepha, { handler, maxSize: 5, }); // Push items before marking ready batchProvider.push(context, "item-1"); batchProvider.push(context, "item-2"); batchProvider.push(context, "item-3"); batchProvider.push(context, "item-4"); batchProvider.push(context, "item-5"); expect(handler).not.toHaveBeenCalled(); expect(context.isReady).toBe(false); await batchProvider.markReady(context); expect(context.isReady).toBe(true); await vi.waitFor(() => { expect(handler).toHaveBeenCalledTimes(1); }); }); test("should handle shutdown and flush all remaining items", async () => { const handler = vi.fn(async (items: string[]) => "processed"); const context = batchProvider.createContext(alepha, { handler, maxSize: 100, // Large size to prevent auto-flush }); batchProvider.push(context, "item-1"); batchProvider.push(context, "item-2"); expect(handler).not.toHaveBeenCalled(); await batchProvider.shutdown(context); expect(context.isShuttingDown).toBe(true); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith(["item-1", "item-2"]); }); test("should handle empty flush gracefully", async () => { const handler = vi.fn(); const context = batchProvider.createContext(alepha, { handler }); await batchProvider.flush(context); expect(handler).not.toHaveBeenCalled(); }); test("should handle flush of non-existent partition gracefully", async () => { const handler = vi.fn(); const context = batchProvider.createContext(alepha, { handler }); await batchProvider.flush(context, "non-existent"); expect(handler).not.toHaveBeenCalled(); }); test("should use default options when not specified", () => { const handler = vi.fn(); const context = batchProvider.createContext(alepha, { handler }); // Access protected methods through any cast for testing const provider = batchProvider as any; expect(provider.getMaxSize(context)).toBe(10); expect(provider.getConcurrency(context)).toBe(1); expect(provider.getMaxDuration(context)).toEqual([1, "second"]); }); test("should use custom options when specified", () => { const handler = vi.fn(); const context = batchProvider.createContext(alepha, { handler, maxSize: 50, concurrency: 5, maxDuration: [30, "seconds"], }); const provider = batchProvider as any; expect(provider.getMaxSize(context)).toBe(50); expect(provider.getConcurrency(context)).toBe(5); expect(provider.getMaxDuration(context)).toEqual([30, "seconds"]); }); test("should retry failed handler with retry options", async () => { let attempts = 0; const handler = vi.fn(async (items: string[]) => { attempts++; if (attempts < 3) { throw new Error("Temporary failure"); } return "success"; }); const context = batchProvider.createContext(alepha, { handler, retry: { max: 3, backoff: 10 }, }); const id = batchProvider.push(context, "item"); await batchProvider.flush(context); const status = batchProvider.status(context, id); expect(status?.status).toBe("completed"); expect(handler).toHaveBeenCalledTimes(3); }); test("should fail after max retries exceeded", async () => { const handler = vi.fn(async () => { throw new Error("Always fails"); }); const context = batchProvider.createContext(alepha, { handler, retry: { max: 2, backoff: 10 }, }); const id = batchProvider.push(context, "item"); await batchProvider.flush(context); const status = batchProvider.status(context, id); expect(status?.status).toBe("failed"); expect(handler).toHaveBeenCalledTimes(2); }); test("should handle timeout-based flushing when ready", async () => { const handler = vi.fn(async (items: string[]) => "processed"); const context = batchProvider.createContext(alepha, { handler, maxSize: 100, maxDuration: [100, "milliseconds"], }); await batchProvider.markReady(context); batchProvider.push(context, "item-1"); batchProvider.push(context, "item-2"); expect(handler).not.toHaveBeenCalled(); // Wait for timeout await time.travel([150, "milliseconds"]); await vi.waitFor(() => { expect(handler).toHaveBeenCalledTimes(1); }); expect(handler).toHaveBeenCalledWith(["item-1", "item-2"]); }); test("should not create timeout for items pushed before ready", async () => { const handler = vi.fn(async () => "processed"); const context = batchProvider.createContext(alepha, { handler, maxSize: 100, maxDuration: [100, "milliseconds"], }); // Push before ready batchProvider.push(context, "item"); // Travel time forward before marking ready await time.travel([200, "milliseconds"]); // Handler should NOT have been called expect(handler).not.toHaveBeenCalled(); // Now mark ready await batchProvider.markReady(context); // Wait for timeout await time.travel([150, "milliseconds"]); await vi.waitFor(() => { expect(handler).toHaveBeenCalledTimes(1); }); }); test("should handle items arriving during flush", async () => { let flushCount = 0; const handler = vi.fn(async (items: string[]) => { flushCount++; await time.wait(50); // Simulate slow processing return `batch-${flushCount}`; }); const context = batchProvider.createContext(alepha, { handler, maxSize: 2, }); await batchProvider.markReady(context); // Push 2 items to trigger first flush batchProvider.push(context, "item-1"); batchProvider.push(context, "item-2"); // Wait for processing to start await time.wait(10); // Push more items during processing batchProvider.push(context, "item-3"); batchProvider.push(context, "item-4"); // Wait for all processing await vi.waitFor(() => { expect(handler).toHaveBeenCalledTimes(2); }); expect(handler).toHaveBeenNthCalledWith(1, ["item-1", "item-2"]); expect(handler).toHaveBeenNthCalledWith(2, ["item-3", "item-4"]); }); test("should skip retries during shutdown", async () => { let attempts = 0; const handler = vi.fn(async () => { attempts++; throw new Error("Always fails"); }); const context = batchProvider.createContext(alepha, { handler, retry: { max: 5, backoff: 100 }, }); batchProvider.push(context, "item"); // Shutdown should call handler directly without retries await batchProvider.shutdown(context); // Handler should only be called once (no retries during shutdown) expect(handler).toHaveBeenCalledTimes(1); }); test("should mark items as processing during flush", async () => { let statusDuringProcessing: any; const handler = vi.fn(async (items: string[]) => { // Capture status while processing statusDuringProcessing = batchProvider.status(context, id); await time.wait(10); return "done"; }); const context = batchProvider.createContext(alepha, { handler }); const id = batchProvider.push(context, "item"); // Status before flush expect(batchProvider.status(context, id)?.status).toBe("pending"); const flushPromise = batchProvider.flush(context); await flushPromise; // Status should have been "processing" during handler execution expect(statusDuringProcessing?.status).toBe("processing"); // Status after flush expect(batchProvider.status(context, id)?.status).toBe("completed"); }); test("should clear partition timeout on flush", async () => { const handler = vi.fn(async () => "processed"); const context = batchProvider.createContext(alepha, { handler, maxSize: 100, maxDuration: [5, "seconds"], }); await batchProvider.markReady(context); batchProvider.push(context, "item"); const partition = context.partitions.get("default"); expect(partition?.timeout).toBeDefined(); await batchProvider.flush(context); // Partition should be deleted after flush expect(context.partitions.has("default")).toBe(false); }); test("should restart timeout after flush if items remain", async () => { const handler = vi.fn(async (items: string[]) => { // Slow handler to allow new items to arrive await time.wait(50); return "processed"; }); const context = batchProvider.createContext(alepha, { handler, maxSize: 2, maxDuration: [100, "milliseconds"], }); await batchProvider.markReady(context); // Push 2 items to trigger flush batchProvider.push(context, "item-1"); batchProvider.push(context, "item-2"); // Wait for processing to start await time.wait(10); // Push another item during processing batchProvider.push(context, "item-3"); // Wait for first flush to complete await time.wait(100); // Partition should still exist with timeout for item-3 const partition = context.partitions.get("default"); if (partition && partition.itemIds.length > 0) { expect(partition.timeout).toBeDefined(); } // Wait for timeout to flush item-3 await time.travel([150, "milliseconds"]); await vi.waitFor(() => { expect(handler).toHaveBeenCalledTimes(2); }); expect(handler).toHaveBeenNthCalledWith(2, ["item-3"]); }); // Tests for new quick win features test("should clear completed items with clearCompleted", async () => { const handler = vi.fn(async () => "result"); const context = batchProvider.createContext(alepha, { handler }); const id1 = batchProvider.push(context, "item-1"); const id2 = batchProvider.push(context, "item-2"); const id3 = batchProvider.push(context, "item-3"); await batchProvider.flush(context); // All items should be completed expect(context.itemStates.size).toBe(3); // Clear completed items const cleared = batchProvider.clearCompleted(context); expect(cleared).toBe(3); expect(context.itemStates.size).toBe(0); }); test("should clear only failed items when status filter is provided", async () => { // Use partitionBy to control which items fail const handler = vi.fn(async (items: Array<{ type: string }>) => { if (items[0].type === "fail") { throw new Error("This batch fails"); } return "success"; }); const context = batchProvider.createContext(alepha, { handler, maxSize: 1, retry: { max: 1 }, // Disable retries - only 1 attempt partitionBy: (item: { type: string }) => item.type, }); batchProvider.push(context, { type: "fail" }); // Will fail batchProvider.push(context, { type: "success" }); // Will succeed await batchProvider.flush(context); expect(context.itemStates.size).toBe(2); // Clear only failed items const cleared = batchProvider.clearCompleted(context, "failed"); expect(cleared).toBe(1); expect(context.itemStates.size).toBe(1); // The remaining item should be completed const remaining = Array.from(context.itemStates.values())[0]; expect(remaining.status).toBe("completed"); }); test("should clear only completed items when status filter is provided", async () => { // Use partitionBy to control which items fail const handler = vi.fn(async (items: Array<{ type: string }>) => { if (items[0].type === "fail") { throw new Error("This batch fails"); } return "success"; }); const context = batchProvider.createContext(alepha, { handler, maxSize: 1, retry: { max: 1 }, // Disable retries - only 1 attempt partitionBy: (item: { type: string }) => item.type, }); batchProvider.push(context, { type: "fail" }); // Will fail batchProvider.push(context, { type: "success" }); // Will succeed await batchProvider.flush(context); // Clear only completed items const cleared = batchProvider.clearCompleted(context, "completed"); expect(cleared).toBe(1); expect(context.itemStates.size).toBe(1); // The remaining item should be failed const remaining = Array.from(context.itemStates.values())[0]; expect(remaining.status).toBe("failed"); }); test("should enforce maxQueueSize limit", () => { const handler = vi.fn(); const context = batchProvider.createContext(alepha, { handler, maxQueueSize: 3, }); // Push up to the limit batchProvider.push(context, "item-1"); batchProvider.push(context, "item-2"); batchProvider.push(context, "item-3"); // Fourth push should throw expect(() => batchProvider.push(context, "item-4")).toThrow( "Batch queue size exceeded for partition 'default' (max: 3)", ); }); test("should enforce maxQueueSize per partition", () => { const handler = vi.fn(); const context = batchProvider.createContext(alepha, { handler, maxQueueSize: 2, partitionBy: (item: { key: string; value: number }) => item.key, }); // Fill partition A batchProvider.push(context, { key: "A", value: 1 }); batchProvider.push(context, { key: "A", value: 2 }); // Partition A is full, but B should still work batchProvider.push(context, { key: "B", value: 1 }); // Partition A should throw expect(() => batchProvider.push(context, { key: "A", value: 3 })).toThrow( "Batch queue size exceeded for partition 'A' (max: 2)", ); // Partition B should still accept batchProvider.push(context, { key: "B", value: 2 }); }); test("should handle partitionBy errors gracefully", () => { const handler = vi.fn(); const context = batchProvider.createContext(alepha, { handler, partitionBy: (item: { key?: string; value?: string }) => { if (!item.key) { throw new Error("Missing key"); } return item.key; }, }); // Item with key should work batchProvider.push(context, { key: "A" }); expect(context.partitions.has("A")).toBe(true); // Item without key should fall back to 'default' partition batchProvider.push(context, { value: "no-key" }); expect(context.partitions.has("default")).toBe(true); expect(context.partitions.get("default")?.itemIds).toHaveLength(1); }); test("should return 0 when clearing with no completed items", () => { const handler = vi.fn(); const context = batchProvider.createContext(alepha, { handler }); // Push items but don't flush batchProvider.push(context, "item-1"); batchProvider.push(context, "item-2"); const cleared = batchProvider.clearCompleted(context); expect(cleared).toBe(0); expect(context.itemStates.size).toBe(2); }); });