UNPKG

alepha

Version:

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

381 lines (380 loc) 13.7 kB
import { $hook, $inject, $module, AlephaError, KIND, Primitive, createPrimitive } from "alepha"; import { CryptoProvider } from "alepha/crypto"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { RetryProvider } from "alepha/retry"; //#region ../../src/batch/providers/BatchProvider.ts /** * Service for batch processing operations. * Provides methods to manage batches of items with automatic flushing based on size or time. */ var BatchProvider = class { log = $logger(); dateTime = $inject(DateTimeProvider); retryProvider = $inject(RetryProvider); crypto = $inject(CryptoProvider); /** * All active batch contexts managed by this provider. */ contexts = /* @__PURE__ */ new Set(); /** * Creates a new batch context with the given options. */ createContext(alepha, options) { const context = { options, itemStates: /* @__PURE__ */ new Map(), partitions: /* @__PURE__ */ new Map(), activeHandlers: [], isShuttingDown: false, isReady: false, alepha }; this.contexts.add(context); return context; } /** * Shutdown hook - flushes all batch contexts on application stop. */ 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 = []; 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. */ getMaxSize(context) { return context.options.maxSize ?? 10; } /** * Get the effective concurrency for a context. */ getConcurrency(context) { return context.options.concurrency ?? 1; } /** * Get the effective maxDuration for a context. */ getMaxDuration(context) { 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(context, item) { const id = this.crypto.randomUUID(); let partitionKey; 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"; } const itemState = { id, item, partitionKey, status: "pending" }; context.itemStates.set(id, itemState); if (!context.partitions.has(partitionKey)) context.partitions.set(partitionKey, { itemIds: [], flushing: false }); const partition = context.partitions.get(partitionKey); if (context.options.maxQueueSize !== void 0 && partition.itemIds.length >= context.options.maxQueueSize) throw new AlephaError(`Batch queue size exceeded for partition '${partitionKey}' (max: ${context.options.maxQueueSize})`); partition.itemIds.push(id); const maxSize = this.getMaxSize(context); const maxDuration = this.getMaxDuration(context); if (context.isReady) { 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) 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 this.log.trace(`Buffering item in partition '${partitionKey}' (app not ready yet, ${partition.itemIds.length} items buffered)`); 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(context, id) { const itemState = context.itemStates.get(id); if (!itemState) throw new AlephaError(`Item with id '${id}' not found`); if (itemState.status === "completed") return itemState.result; if (itemState.status === "failed") throw itemState.error; if (!itemState.promise) itemState.promise = new Promise((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(context, id) { const itemState = context.itemStates.get(id); if (!itemState) return; 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(context, status) { 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(context, partitionKey) { const promises = []; 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. */ async flushPartition(context, partitionKey, limit) { const partition = context.partitions.get(partitionKey); if (!partition || partition.itemIds.length === 0) { context.partitions.delete(partitionKey); return; } partition.timeout?.clear(); partition.timeout = void 0; const itemsToTake = limit !== void 0 ? Math.min(limit, partition.itemIds.length) : partition.itemIds.length; const itemIdsToProcess = partition.itemIds.splice(0, itemsToTake); partition.flushing = true; const itemsToProcess = []; 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); while (context.activeHandlers.length >= concurrency) { this.log.trace(`Batch handler is at concurrency limit, waiting for a slot...`); await Promise.race(context.activeHandlers.map((it) => it.promise)); } const promise = Promise.withResolvers(); context.activeHandlers.push(promise); let result; try { result = await context.alepha.context.run(() => context.isShuttingDown ? context.options.handler(itemsToProcess) : this.retryProvider.retry({ ...context.options.retry, handler: context.options.handler }, itemsToProcess)); for (const id of itemIdsToProcess) { const itemState = context.itemStates.get(id); if (itemState) { itemState.status = "completed"; itemState.result = result; itemState.resolve?.(result); } } } catch (error) { this.log.error(`Batch handler failed`, error); for (const id of itemIdsToProcess) { const itemState = context.itemStates.get(id); if (itemState) { itemState.status = "failed"; itemState.error = error; itemState.reject?.(error); } } } finally { promise.resolve(); context.activeHandlers = context.activeHandlers.filter((it) => it !== promise); const currentPartition = context.partitions.get(partitionKey); if (currentPartition?.flushing && currentPartition.itemIds.length === 0) context.partitions.delete(partitionKey); else if (currentPartition) { currentPartition.flushing = false; 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(context) { 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(context) { 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. */ async startProcessing(context) { 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`); 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); } 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); } } } }; //#endregion //#region ../../src/batch/primitives/$batch.ts /** * Creates a batch processing primitive for efficient grouping and processing of multiple operations. */ const $batch = (options) => createPrimitive(BatchPrimitive, options); var BatchPrimitive = class extends Primitive { batchProvider = $inject(BatchProvider); context; constructor(...args) { super(...args); this.context = this.batchProvider.createContext(this.alepha, { handler: this.options.handler, maxSize: this.options.maxSize, maxQueueSize: this.options.maxQueueSize, maxDuration: this.options.maxDuration, partitionBy: this.options.partitionBy, concurrency: this.options.concurrency, retry: this.options.retry }); } /** * 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. */ async push(item) { const validatedItem = this.alepha.codec.validate(this.options.schema, item); return this.batchProvider.push(this.context, validatedItem); } /** * 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(id) { return this.batchProvider.wait(this.context, id); } /** * 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(id) { return this.batchProvider.status(this.context, id); } /** * Flush all partitions or a specific partition. */ async flush(partitionKey) { return this.batchProvider.flush(this.context, partitionKey); } /** * Clears completed and failed items from memory. * Call this periodically in long-running applications to prevent memory leaks. * * @param status Optional: only clear items with this specific status ('completed' or 'failed') * @returns The number of items cleared */ clearCompleted(status) { return this.batchProvider.clearCompleted(this.context, status); } onReady = $hook({ on: "ready", handler: async () => { await this.batchProvider.markReady(this.context); } }); }; $batch[KIND] = BatchPrimitive; //#endregion //#region ../../src/batch/index.ts /** * Batch accumulation and processing. * * **Features:** * - Batch accumulator with handler * - Configurable batch size * - Time-based triggers * - Status tracking * * @module alepha.batch */ const AlephaBatch = $module({ name: "alepha.batch", primitives: [$batch], services: [BatchProvider] }); //#endregion export { $batch, AlephaBatch, BatchPrimitive, BatchProvider }; //# sourceMappingURL=index.js.map