UNPKG

alepha

Version:

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

617 lines (554 loc) 19.1 kB
import { $hook, $inject, type Alepha, AlephaError } from "alepha"; import { CryptoProvider } from "alepha/crypto"; import { DateTimeProvider, type DurationLike } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { type RetryBackoffOptions, RetryProvider } from "alepha/retry"; // --------------------------------------------------------------------------------------------------------------------- export interface BatchOptions<TItem, TResponse = any> { /** * The batch processing handler function that processes arrays of validated items. */ handler: (items: TItem[]) => TResponse; /** * Maximum number of items to collect before automatically flushing the batch. * * @default 10 */ maxSize?: number; /** * Maximum number of items that can be queued in a single partition. * If exceeded, push() will throw an error. */ maxQueueSize?: number; /** * Maximum time to wait before flushing a batch, even if it hasn't reached maxSize. * * @default [1, "second"] */ maxDuration?: DurationLike; /** * Function to determine partition keys for grouping items into separate batches. */ partitionBy?: (item: TItem) => string; /** * Maximum number of batch handlers that can execute simultaneously. * * @default 1 */ concurrency?: number; /** * Retry configuration for failed batch processing operations. */ retry?: { /** * The maximum number of attempts. * * @default 3 */ max?: number; /** * The backoff strategy for delays between retries. * Can be a fixed number (in ms) or a configuration object for exponential backoff. * * @default { initial: 200, factor: 2, jitter: true } */ backoff?: number | RetryBackoffOptions; /** * An overall time limit for all retry attempts combined. * * e.g., `[5, 'seconds']` */ maxDuration?: DurationLike; /** * A function that determines if a retry should be attempted based on the error. * * @default (error) => true (retries on any error) */ when?: (error: Error) => boolean; /** * A custom callback for when a retry attempt fails. * This is called before the delay. */ onError?: (error: Error, attempt: number) => void; }; } // --------------------------------------------------------------------------------------------------------------------- export type BatchItemStatus = "pending" | "processing" | "completed" | "failed"; export interface BatchItemState<TItem, TResponse> { id: string; item: TItem; partitionKey: string; status: BatchItemStatus; result?: TResponse; error?: Error; promise?: Promise<TResponse>; resolve?: (value: TResponse) => void; reject?: (error: Error) => void; } export interface PartitionState { itemIds: string[]; timeout?: { clear: () => void }; flushing: boolean; } /** * Context object that holds all state for a batch processor instance. */ export interface BatchContext<TItem, TResponse> { options: BatchOptions<TItem, TResponse>; itemStates: Map<string, BatchItemState<TItem, TResponse>>; partitions: Map<string, PartitionState>; activeHandlers: PromiseWithResolvers<void>[]; isShuttingDown: boolean; isReady: boolean; alepha: Alepha; } // --------------------------------------------------------------------------------------------------------------------- /** * Service for batch processing operations. * Provides methods to manage batches of items with automatic flushing based on size or time. */ export class BatchProvider { protected readonly log = $logger(); protected readonly dateTime = $inject(DateTimeProvider); protected readonly retryProvider = $inject(RetryProvider); protected readonly crypto = $inject(CryptoProvider); /** * All active batch contexts managed by this provider. */ protected readonly contexts = new Set<BatchContext<any, any>>(); /** * Creates a new batch context with the given options. */ createContext<TItem, TResponse>( alepha: Alepha, options: BatchOptions<TItem, TResponse>, ): BatchContext<TItem, TResponse> { const context: BatchContext<TItem, TResponse> = { options, itemStates: new Map(), partitions: new Map(), activeHandlers: [], isShuttingDown: false, isReady: false, alepha, }; this.contexts.add(context); return context; } /** * Shutdown hook - flushes all batch contexts on application stop. */ protected readonly onStop = $hook({ on: "stop", priority: "first", handler: async () => { if (this.contexts.size === 0) { return; } this.log.debug(`Shutting down ${this.contexts.size} batch context(s)...`); const promises: Promise<void>[] = []; for (const context of this.contexts) { promises.push(this.shutdown(context)); } await Promise.all(promises); this.log.debug("All batch contexts shut down"); }, }); /** * Get the effective maxSize for a context. */ protected getMaxSize<TItem, TResponse>( context: BatchContext<TItem, TResponse>, ): number { return context.options.maxSize ?? 10; } /** * Get the effective concurrency for a context. */ protected getConcurrency<TItem, TResponse>( context: BatchContext<TItem, TResponse>, ): number { return context.options.concurrency ?? 1; } /** * Get the effective maxDuration for a context. */ protected getMaxDuration<TItem, TResponse>( context: BatchContext<TItem, TResponse>, ): DurationLike { return context.options.maxDuration ?? [1, "second"]; } /** * Pushes an item into the batch and returns immediately with a unique ID. * The item will be processed asynchronously with other items when the batch is flushed. * Use wait(id) to get the processing result. * * @throws Error if maxQueueSize is exceeded */ push<TItem, TResponse>( context: BatchContext<TItem, TResponse>, item: TItem, ): string { // 1. Generate unique ID const id = this.crypto.randomUUID(); // 2. Determine the partition key (with error handling) let partitionKey: string; try { partitionKey = context.options.partitionBy ? context.options.partitionBy(item) : "default"; } catch (error) { this.log.warn( "partitionBy function threw an error, using 'default' partition", { error }, ); partitionKey = "default"; } // 3. Create item state const itemState: BatchItemState<TItem, TResponse> = { id, item, partitionKey, status: "pending", }; // CAUTION: Do not log.debug/info here as it may cause infinite loops if logging is batched context.itemStates.set(id, itemState); // 4. Get or create the partition state if (!context.partitions.has(partitionKey)) { context.partitions.set(partitionKey, { itemIds: [], flushing: false, }); } const partition = context.partitions.get(partitionKey)!; // 5. Check maxQueueSize before adding if ( context.options.maxQueueSize !== undefined && partition.itemIds.length >= context.options.maxQueueSize ) { throw new AlephaError( `Batch queue size exceeded for partition '${partitionKey}' (max: ${context.options.maxQueueSize})`, ); } // 6. Add item ID to partition partition.itemIds.push(id); const maxSize = this.getMaxSize(context); const maxDuration = this.getMaxDuration(context); // 7. Only start processing if the app is ready (after "ready" hook) // During startup, items are just buffered in memory if (context.isReady) { // Check if the batch is full if (partition.itemIds.length >= maxSize) { this.log.trace( `Batch partition '${partitionKey}' is full, flushing...`, ); this.flushPartition(context, partitionKey).catch((error) => this.log.error( `Failed to flush batch partition '${partitionKey}' on max size`, error, ), ); } else if (!partition.timeout && !partition.flushing) { // 8. Start the timeout if it's not already running for this partition and not currently flushing partition.timeout = this.dateTime.createTimeout(() => { this.log.trace( `Batch partition '${partitionKey}' timed out, flushing...`, ); this.flushPartition(context, partitionKey).catch((error) => this.log.error( `Failed to flush batch partition '${partitionKey}' on timeout`, error, ), ); }, maxDuration); } } else { // Not ready yet - just buffer items, no size checks or timeouts this.log.trace( `Buffering item in partition '${partitionKey}' (app not ready yet, ${partition.itemIds.length} items buffered)`, ); } // 9. Return ID immediately return id; } /** * Wait for a specific item to be processed and get its result. * @param id The item ID returned from push() * @returns The processing result * @throws If the item doesn't exist or processing failed */ async wait<TItem, TResponse>( context: BatchContext<TItem, TResponse>, id: string, ): Promise<TResponse> { const itemState = context.itemStates.get(id); if (!itemState) { throw new AlephaError(`Item with id '${id}' not found`); } // If already completed or failed, return immediately if (itemState.status === "completed") { return itemState.result!; } if (itemState.status === "failed") { throw itemState.error!; } // Create promise on-demand if not already created if (!itemState.promise) { itemState.promise = new Promise<TResponse>((resolve, reject) => { itemState.resolve = resolve; itemState.reject = reject; }); } return itemState.promise; } /** * Get the current status of an item. * @param id The item ID returned from push() * @returns Status information or undefined if item doesn't exist */ status<TItem, TResponse>( context: BatchContext<TItem, TResponse>, id: string, ): | { status: "pending" | "processing" } | { status: "completed"; result: TResponse } | { status: "failed"; error: Error } | undefined { const itemState = context.itemStates.get(id); if (!itemState) { return undefined; } if (itemState.status === "completed") { return { status: "completed", result: itemState.result! }; } if (itemState.status === "failed") { return { status: "failed", error: itemState.error! }; } return { status: itemState.status }; } /** * Clears completed and failed items from the context to free memory. * Returns the number of items cleared. * * @param context The batch context * @param status Optional: only clear items with this specific status ('completed' or 'failed') * @returns The number of items cleared */ clearCompleted<TItem, TResponse>( context: BatchContext<TItem, TResponse>, status?: "completed" | "failed", ): number { let count = 0; for (const [id, state] of context.itemStates) { if (status) { if (state.status === status) { context.itemStates.delete(id); count++; } } else if (state.status === "completed" || state.status === "failed") { context.itemStates.delete(id); count++; } } return count; } /** * Flush all partitions or a specific partition. */ async flush<TItem, TResponse>( context: BatchContext<TItem, TResponse>, partitionKey?: string, ): Promise<void> { const promises: Promise<void>[] = []; if (partitionKey) { if (context.partitions.has(partitionKey)) { promises.push(this.flushPartition(context, partitionKey)); } } else { for (const key of context.partitions.keys()) { promises.push(this.flushPartition(context, key)); } } await Promise.all(promises); } /** * Flush a specific partition. */ protected async flushPartition<TItem, TResponse>( context: BatchContext<TItem, TResponse>, partitionKey: string, limit?: number, ): Promise<void> { const partition = context.partitions.get(partitionKey); if (!partition || partition.itemIds.length === 0) { context.partitions.delete(partitionKey); return; } // Clear the timeout and grab the item IDs (up to limit if specified) partition.timeout?.clear(); partition.timeout = undefined; const itemsToTake = limit !== undefined ? Math.min(limit, partition.itemIds.length) : partition.itemIds.length; const itemIdsToProcess = partition.itemIds.splice(0, itemsToTake); // Mark partition as flushing to prevent race conditions partition.flushing = true; // Get the items and mark them as processing const itemsToProcess: TItem[] = []; for (const id of itemIdsToProcess) { const itemState = context.itemStates.get(id); if (itemState) { itemState.status = "processing"; itemsToProcess.push(itemState.item); } } const concurrency = this.getConcurrency(context); const maxDuration = this.getMaxDuration(context); // Wait until there's a free slot (if at concurrency limit) while (context.activeHandlers.length >= concurrency) { this.log.trace( `Batch handler is at concurrency limit, waiting for a slot...`, ); // Wait for any single handler to complete, not all of them await Promise.race(context.activeHandlers.map((it) => it.promise)); } const promise = Promise.withResolvers<void>(); context.activeHandlers.push(promise); let result: any; try { result = await context.alepha.context.run(() => // during shutdown, call handler directly to avoid retry cancellation context.isShuttingDown ? context.options.handler(itemsToProcess) : this.retryProvider.retry( { ...context.options.retry, handler: context.options.handler, }, itemsToProcess, ), ); // Mark all items as completed and resolve their promises for (const id of itemIdsToProcess) { const itemState = context.itemStates.get(id); if (itemState) { itemState.status = "completed"; itemState.result = result; // Only resolve if someone is waiting itemState.resolve?.(result); } } } catch (error) { this.log.error(`Batch handler failed`, error); // Mark all items as failed and reject their promises for (const id of itemIdsToProcess) { const itemState = context.itemStates.get(id); if (itemState) { itemState.status = "failed"; itemState.error = error as Error; // Only reject if someone is waiting (promise was created) itemState.reject?.(error as Error); } } } finally { promise.resolve(); context.activeHandlers = context.activeHandlers.filter( (it) => it !== promise, ); // Only delete partition if no new items arrived during processing const currentPartition = context.partitions.get(partitionKey); if (currentPartition?.flushing && currentPartition.itemIds.length === 0) { context.partitions.delete(partitionKey); } else if (currentPartition) { // Reset flushing flag if partition still exists with items currentPartition.flushing = false; // Restart timeout for items that arrived during flush if (currentPartition.itemIds.length > 0 && !currentPartition.timeout) { currentPartition.timeout = this.dateTime.createTimeout(() => { this.log.trace( `Batch partition '${partitionKey}' timed out, flushing...`, ); this.flushPartition(context, partitionKey).catch((error) => this.log.error( `Failed to flush batch partition '${partitionKey}' on timeout`, error, ), ); }, maxDuration); } } } } /** * Mark the context as ready and start processing buffered items. * Called after the "ready" hook. */ async markReady<TItem, TResponse>( context: BatchContext<TItem, TResponse>, ): Promise<void> { this.log.debug( "Batch processor is now ready, starting to process buffered items...", ); context.isReady = true; await this.startProcessing(context); } /** * Mark the context as shutting down and flush all remaining items. */ async shutdown<TItem, TResponse>( context: BatchContext<TItem, TResponse>, ): Promise<void> { this.log.debug("Flushing all remaining batch partitions on shutdown..."); context.isShuttingDown = true; await this.flush(context); this.log.debug("All batch partitions flushed"); } /** * Called after the "ready" hook to start processing buffered items that were * pushed during startup. This checks all partitions and starts timeouts/flushes * for items that were accumulated before the app was ready. */ protected async startProcessing<TItem, TResponse>( context: BatchContext<TItem, TResponse>, ): Promise<void> { const maxSize = this.getMaxSize(context); const maxDuration = this.getMaxDuration(context); for (const [partitionKey, partition] of context.partitions.entries()) { if (partition.itemIds.length === 0) { continue; } this.log.trace( `Starting processing for partition '${partitionKey}' with ${partition.itemIds.length} buffered items`, ); // Flush batches of maxSize while we have items >= maxSize while (partition.itemIds.length >= maxSize) { this.log.trace( `Partition '${partitionKey}' has ${partition.itemIds.length} items, flushing batch of ${maxSize}...`, ); await this.flushPartition(context, partitionKey, maxSize); } // After flushing full batches, start timeout for any remaining items if ( partition.itemIds.length > 0 && !partition.timeout && !partition.flushing ) { this.log.trace( `Starting timeout for partition '${partitionKey}' with ${partition.itemIds.length} remaining items`, ); partition.timeout = this.dateTime.createTimeout(() => { this.log.trace( `Batch partition '${partitionKey}' timed out, flushing...`, ); this.flushPartition(context, partitionKey).catch((error) => this.log.error( `Failed to flush partition '${partitionKey}' on timeout after startup`, error, ), ); }, maxDuration); } } } }