alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
381 lines (380 loc) • 13.7 kB
JavaScript
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