alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
787 lines (598 loc) • 24.9 kB
text/typescript
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);
});
});