UNPKG

alepha

Version:

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

767 lines (625 loc) 21.5 kB
import { Alepha, z } from "alepha"; import { DateTimeProvider } from "alepha/datetime"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { $batch, AlephaBatch } from "../index.ts"; // Mock handler to track calls and received items const createMockHandler = () => { return vi.fn(async (items: any[]) => { // Default successful handler }); }; describe("$batch primitive", () => { let alepha: Alepha; let time: DateTimeProvider; beforeEach(() => { alepha = Alepha.create().with(AlephaBatch); time = alepha.inject(DateTimeProvider); }); afterEach(async () => { await alepha.stop(); }); test("should batch items and flush when maxSize is reached", async () => { const mockHandler = createMockHandler(); class TestApp { batcher = $batch({ schema: z.text(), maxSize: 3, handler: mockHandler, }); } const app = alepha.inject(TestApp); await alepha.start(); const id1 = await app.batcher.push("A"); const id2 = await app.batcher.push("B"); expect(mockHandler).not.toHaveBeenCalled(); const id3 = await app.batcher.push("C"); // This should trigger the flush await vi.waitFor(() => { expect(mockHandler).toHaveBeenCalledTimes(1); }); expect(mockHandler).toHaveBeenCalledWith(["A", "B", "C"]); // Verify IDs are returned expect(typeof id1).toBe("string"); expect(typeof id2).toBe("string"); expect(typeof id3).toBe("string"); }); test("should flush remaining items when maxDuration is reached", async () => { const mockHandler = createMockHandler(); class TestApp { batcher = $batch({ schema: z.text(), maxSize: 10, maxDuration: [5, "seconds"], handler: mockHandler, }); } const app = alepha.inject(TestApp); await alepha.start(); app.batcher.push("A"); app.batcher.push("B"); expect(mockHandler).not.toHaveBeenCalled(); await time.travel([6, "seconds"]); // Exceed maxDuration await vi.waitFor(() => { expect(mockHandler).toHaveBeenCalledTimes(1); }); expect(mockHandler).toHaveBeenCalledWith(["A", "B"]); }); test("should handle partitioning correctly", async () => { const mockHandler = createMockHandler(); class TestApp { batcher = $batch({ schema: z.object({ id: z.number(), value: z.text() }), maxSize: 2, partitionBy: (item) => `partition-${item.id}`, handler: mockHandler, }); } const app = alepha.inject(TestApp); await alepha.start(); app.batcher.push({ id: 1, value: "A" }); app.batcher.push({ id: 2, value: "B" }); app.batcher.push({ id: 1, value: "C" }); // Flushes partition 1 await vi.waitFor( () => { expect(mockHandler).toHaveBeenCalledTimes(1); }, { timeout: 1000 }, ); expect(mockHandler).toHaveBeenCalledWith([ { id: 1, value: "A" }, { id: 1, value: "C" }, ]); app.batcher.push({ id: 2, value: "D" }); // Flushes partition 2 await vi.waitFor( () => { expect(mockHandler).toHaveBeenCalledTimes(2); }, { timeout: 2000 }, ); expect(mockHandler).toHaveBeenCalledWith([ { id: 2, value: "B" }, { id: 2, value: "D" }, ]); }); test("should flush all pending items on application stop", async () => { const mockHandler = createMockHandler(); class TestApp { batcher = $batch({ schema: z.text(), maxSize: 10, handler: mockHandler, }); } const app = alepha.inject(TestApp); await alepha.start(); app.batcher.push("A"); app.batcher.push("B"); await alepha.stop(); // Graceful shutdown should trigger flush expect(mockHandler).toHaveBeenCalledTimes(1); expect(mockHandler).toHaveBeenCalledWith(["A", "B"]); }); test("should reject wait promise if handler fails after retries", async () => { const failingHandler = vi.fn(async (items: any[]) => { throw new Error("Handler failed"); }); class TestApp { batcher = $batch({ schema: z.text(), maxSize: 1, handler: failingHandler, retry: { max: 2 }, // Try a total of 2 times }); } const app = alepha.inject(TestApp); await alepha.start(); const id = await app.batcher.push("A"); expect(typeof id).toBe("string"); const waitPromise = app.batcher.wait(id); await expect(waitPromise).rejects.toThrow("Handler failed"); await vi.waitFor(() => { expect(failingHandler).toHaveBeenCalledTimes(2); }); // Verify status is failed const status = app.batcher.status(id); expect(status?.status).toBe("failed"); if (status?.status === "failed") { expect(status.error.message).toBe("Handler failed"); } }); test("should resolve wait promise on successful processing", async () => { const mockHandler = vi.fn(async (items: any[]) => { return "success"; }); class TestApp { batcher = $batch({ schema: z.text(), maxSize: 1, handler: mockHandler, }); } const app = alepha.inject(TestApp); await alepha.start(); const id = await app.batcher.push("A"); expect(typeof id).toBe("string"); // Status should be pending or processing let status = app.batcher.status(id); expect(status?.status).toMatch(/pending|processing/); const result = await app.batcher.wait(id); expect(result).toBe("success"); // Status should now be completed status = app.batcher.status(id); expect(status?.status).toBe("completed"); if (status?.status === "completed") { expect(status.result).toBe("success"); } }); test("should respect concurrency option", async () => { let activeHandlers = 0; let maxActiveHandlers = 0; const slowHandler = vi.fn(async (items: any[]) => { activeHandlers++; maxActiveHandlers = Math.max(maxActiveHandlers, activeHandlers); await time.wait(100); // Simulate work activeHandlers--; }); class TestApp { batcher = $batch({ schema: z.text(), maxSize: 1, concurrency: 2, handler: slowHandler, }); } const app = alepha.inject(TestApp); await alepha.start(); // Push 4 items to trigger 4 batches const ids = await Promise.all([ app.batcher.push("A"), app.batcher.push("B"), app.batcher.push("C"), app.batcher.push("D"), ]); // Wait for all to complete await Promise.all(ids.map((id) => app.batcher.wait(id))); expect(slowHandler).toHaveBeenCalledTimes(4); expect(maxActiveHandlers).toBe(2); }); test("should flush manually a specific partition", async () => { const mockHandler = createMockHandler(); class TestApp { batcher = $batch({ schema: z.object({ id: z.number(), value: z.text() }), maxSize: 5, partitionBy: (item) => `p-${item.id}`, handler: mockHandler, }); } const app = alepha.inject(TestApp); await alepha.start(); app.batcher.push({ id: 1, value: "A" }); app.batcher.push({ id: 2, value: "B" }); app.batcher.push({ id: 1, value: "C" }); expect(mockHandler).not.toHaveBeenCalled(); await app.batcher.flush("p-1"); await vi.waitFor(() => { expect(mockHandler).toHaveBeenCalledTimes(1); }); expect(mockHandler).toHaveBeenCalledWith([ { id: 1, value: "A" }, { id: 1, value: "C" }, ]); // The other partition should remain await app.batcher.flush("p-2"); await vi.waitFor(() => { expect(mockHandler).toHaveBeenCalledTimes(2); }); expect(mockHandler).toHaveBeenCalledWith([{ id: 2, value: "B" }]); }); test("should flush all partitions manually", async () => { const mockHandler = createMockHandler(); class TestApp { batcher = $batch({ schema: z.object({ id: z.number(), value: z.text() }), maxSize: 5, partitionBy: (item) => `p-${item.id}`, handler: mockHandler, }); } const app = alepha.inject(TestApp); await alepha.start(); app.batcher.push({ id: 1, value: "A" }); app.batcher.push({ id: 2, value: "B" }); await app.batcher.flush(); await vi.waitFor(() => { expect(mockHandler).toHaveBeenCalledTimes(2); }); expect(mockHandler).toHaveBeenCalledWith([{ id: 1, value: "A" }]); expect(mockHandler).toHaveBeenCalledWith([{ id: 2, value: "B" }]); }); test("should validate items against schema", async () => { const mockHandler = createMockHandler(); class TestApp { batcher = $batch({ schema: z.number(), // Expects numbers maxSize: 1, handler: mockHandler, }); } const app = alepha.inject(TestApp); await alepha.start(); // Vitest doesn't properly catch type errors in async promises thrown by TypeBox, // so we test the rejection with a generic Error. await expect(app.batcher.push("not-a-number" as any)).rejects.toThrow(); expect(mockHandler).not.toHaveBeenCalled(); const id = await app.batcher.push(123); expect(typeof id).toBe("string"); await vi.waitFor(() => { expect(mockHandler).toHaveBeenCalledWith([123]); }); }); test("should handle empty batches gracefully", async () => { const mockHandler = createMockHandler(); class TestApp { batcher = $batch({ schema: z.text(), maxSize: 5, handler: mockHandler, }); } const app = alepha.inject(TestApp); await alepha.start(); await app.batcher.flush(); // Should not throw or call handler expect(mockHandler).not.toHaveBeenCalled(); }); test("should handle empty partitions gracefully", async () => { const mockHandler = createMockHandler(); class TestApp { batcher = $batch({ schema: z.text(), maxSize: 5, maxDuration: [1, "second"], partitionBy: (item) => item, handler: mockHandler, }); } const app = alepha.inject(TestApp); await alepha.start(); await app.batcher.push("D"); await app.batcher.flush("D"); }); test("should allow to get resolved items", async () => { let tick = 0; class TestApp { httpBatch = $batch({ schema: z.text(), maxSize: 10, maxDuration: [100, "milliseconds"], handler: async (urls) => { tick += 1; if (urls.length === 1) { return { [urls[0]]: `Response for ${urls[0]}` }; } const response: Record<string, string> = {}; for (const url of urls) { response[url] = `(batch) Response for ${url}`; } return response; }, }); async fetch(url: string) { const id = await this.httpBatch.push(url); const response = await this.httpBatch.wait(id); return response[url]; } } const app = alepha.inject(TestApp); await alepha.start(); const tasks: Promise<any>[] = []; tasks.push(app.fetch("https://example.com/A")); tasks.push(app.fetch("https://example.com/B")); await time.wait(200); // Wait for batch to accumulate items tasks.push(app.fetch("https://example.com/C")); const result = await Promise.all(tasks); expect(tick).toBe(2); expect(result).toEqual([ "(batch) Response for https://example.com/A", "(batch) Response for https://example.com/B", "Response for https://example.com/C", ]); const response = await app.fetch("https://example.com/D"); expect(response).toBe("Response for https://example.com/D"); }); test("should handle items arriving during batch processing (race condition fix)", async () => { let handlerCallCount = 0; const handlerCalls: string[][] = []; class TestApp { batcher = $batch({ schema: z.text(), maxSize: 2, maxDuration: [10, "seconds"], handler: async (items: string[]) => { handlerCallCount++; handlerCalls.push([...items]); // Simulate slow processing await time.wait(100); return `batch-${handlerCallCount}`; }, }); } const app = alepha.inject(TestApp); await alepha.start(); // Push 2 items to trigger first batch const id1 = await app.batcher.push("A"); const id2 = await app.batcher.push("B"); // Wait a bit for processing to start but not complete await time.wait(50); // Push more items while first batch is processing const id3 = await app.batcher.push("C"); const id4 = await app.batcher.push("D"); // Wait for all items to complete const results = await Promise.all([ app.batcher.wait(id1), app.batcher.wait(id2), app.batcher.wait(id3), app.batcher.wait(id4), ]); // Should have 2 batches expect(handlerCallCount).toBe(2); expect(handlerCalls[0]).toEqual(["A", "B"]); expect(handlerCalls[1]).toEqual(["C", "D"]); expect(results).toEqual(["batch-1", "batch-1", "batch-2", "batch-2"]); }); test("should handle concurrent pushes to same partition during flush", async () => { let processingBatch1 = false; class TestApp { batcher = $batch({ schema: z.object({ id: z.number(), value: z.text() }), maxSize: 2, partitionBy: (item) => `p-${item.id}`, handler: async (items) => { if (!processingBatch1) { processingBatch1 = true; // Simulate slow processing for first batch only await time.wait(100); } return items.map((item) => ({ ...item, processed: true })); }, }); } const app = alepha.inject(TestApp); await alepha.start(); // Push 2 items to partition p-1 to trigger flush const id1 = await app.batcher.push({ id: 1, value: "A" }); const id2 = await app.batcher.push({ id: 1, value: "B" }); // Wait for processing to start await time.wait(50); // Push more items to same partition while processing const id3 = await app.batcher.push({ id: 1, value: "C" }); const id4 = await app.batcher.push({ id: 1, value: "D" }); await Promise.all([ app.batcher.wait(id1), app.batcher.wait(id2), app.batcher.wait(id3), app.batcher.wait(id4), ]); // All items should be processed successfully expect(true).toBe(true); // If we got here without errors, test passes }); test("should use default values for maxSize, concurrency, and maxDuration", async () => { const mockHandler = createMockHandler(); class TestApp { batcher = $batch({ schema: z.text(), // Not providing maxSize, concurrency, or maxDuration handler: mockHandler, }); } const app = alepha.inject(TestApp); await alepha.start(); // Test default maxSize (10) for (let i = 0; i < 9; i++) { app.batcher.push(`item-${i}`); } expect(mockHandler).not.toHaveBeenCalled(); app.batcher.push("item-9"); // 10th item should trigger flush await vi.waitFor(() => { expect(mockHandler).toHaveBeenCalledTimes(1); }); expect(mockHandler).toHaveBeenCalledWith([ "item-0", "item-1", "item-2", "item-3", "item-4", "item-5", "item-6", "item-7", "item-8", "item-9", ]); }); test("should use default maxDuration (1 second)", async () => { const mockHandler = createMockHandler(); class TestApp { batcher = $batch({ schema: z.text(), // Not providing maxDuration, should default to 1 second handler: mockHandler, }); } const app = alepha.inject(TestApp); await alepha.start(); app.batcher.push("A"); expect(mockHandler).not.toHaveBeenCalled(); // Wait for default timeout (1 second) await time.travel([1.1, "seconds"]); await vi.waitFor(() => { expect(mockHandler).toHaveBeenCalledTimes(1); }); expect(mockHandler).toHaveBeenCalledWith(["A"]); }); test("should use default concurrency (1)", async () => { let activeHandlers = 0; let maxActiveHandlers = 0; const slowHandler = vi.fn(async (items: any[]) => { activeHandlers++; maxActiveHandlers = Math.max(maxActiveHandlers, activeHandlers); await time.wait(100); activeHandlers--; }); class TestApp { batcher = $batch({ schema: z.text(), maxSize: 1, // Not providing concurrency, should default to 1 handler: slowHandler, }); } const app = alepha.inject(TestApp); await alepha.start(); // Push 3 items to trigger 3 batches const ids = await Promise.all([ app.batcher.push("A"), app.batcher.push("B"), app.batcher.push("C"), ]); await Promise.all(ids.map((id) => app.batcher.wait(id))); expect(slowHandler).toHaveBeenCalledTimes(3); expect(maxActiveHandlers).toBe(1); // Should never exceed default concurrency of 1 }); test("should track item status through lifecycle", async () => { class TestApp { batcher = $batch({ schema: z.text(), maxSize: 10, maxDuration: [100, "milliseconds"], handler: async (items: string[]) => { await time.wait(50); return items.map((item) => `processed-${item}`); }, }); } const app = alepha.inject(TestApp); await alepha.start(); // Push an item and verify it gets an ID const id = await app.batcher.push("test-item"); expect(typeof id).toBe("string"); // Status should be pending initially let status = app.batcher.status(id); expect(status?.status).toMatch(/pending/); // Wait for processing to complete const result = await app.batcher.wait(id); expect(result).toEqual(["processed-test-item"]); // Status should now be completed status = app.batcher.status(id); expect(status?.status).toBe("completed"); if (status?.status === "completed") { expect(status.result).toEqual(["processed-test-item"]); } // Calling wait again should return the cached result immediately const result2 = await app.batcher.wait(id); expect(result2).toEqual(["processed-test-item"]); // Test non-existent ID const nonExistentStatus = app.batcher.status("non-existent-id"); expect(nonExistentStatus).toBeUndefined(); await expect(app.batcher.wait("non-existent-id")).rejects.toThrow( "Item with id 'non-existent-id' not found", ); }); test("should restart timeout for items that arrive during flush (maxDuration bug)", async () => { const mockHandler = vi.fn(async (items: string[]) => { // Simulate slow processing await time.wait(50); return items.map((item) => `processed-${item}`); }); class TestApp { batcher = $batch({ schema: z.text(), maxSize: 10, maxDuration: [100, "milliseconds"], handler: mockHandler, }); } const app = alepha.inject(TestApp); await alepha.start(); // Push first item, timeout starts const id1 = await app.batcher.push("A"); // Wait for timeout to fire and flush to start await time.travel([100, "milliseconds"]); // Handler is now processing (takes 50ms) // At T=25ms into processing, push another item await time.wait(25); const id2 = await app.batcher.push("B"); // Wait for first flush to complete await time.wait(30); // Total 55ms, first flush completes at 50ms // At this point: // - First batch ["A"] has been processed // - Item "B" is waiting in the partition // - BUG: No timeout exists for "B" because it was pushed during flush expect(mockHandler).toHaveBeenCalledTimes(1); expect(mockHandler).toHaveBeenCalledWith(["A"]); // Item "B" should flush after maxDuration (100ms) await time.travel([110, "milliseconds"]); // More than enough time // Expected: 2 calls (first batch "A", second batch "B") // Without fix: 1 call (only "A" was processed, "B" is stuck) await vi.waitFor( () => { expect(mockHandler).toHaveBeenCalledTimes(2); }, { timeout: 200 }, ); expect(mockHandler).toHaveBeenNthCalledWith(2, ["B"]); // Verify both items completed successfully const result1 = await app.batcher.wait(id1); const result2 = await app.batcher.wait(id2); expect(result1).toEqual(["processed-A"]); expect(result2).toEqual(["processed-B"]); }); test("should return unique IDs for each push", async () => { const mockHandler = createMockHandler(); class TestApp { batcher = $batch({ schema: z.text(), maxSize: 10, handler: mockHandler, }); } const app = alepha.inject(TestApp); await alepha.start(); const id1 = await app.batcher.push("A"); const id2 = await app.batcher.push("B"); const id3 = await app.batcher.push("C"); expect(id1).not.toBe(id2); expect(id2).not.toBe(id3); expect(id1).not.toBe(id3); // All should be valid UUIDs (rough check) expect(id1).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, ); expect(id2).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, ); expect(id3).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, ); }); });