@net3/queuer
Version:
738 lines (645 loc) • 23.9 kB
text/typescript
import {
QueueConfiguration,
QueueState,
QueueUsage,
QueueItem,
QueueStatus,
} from './types';
import { StoreScope } from '@activepieces/pieces-framework';
/**
* Central utility handling all queue storage and scheduling logic.
* All data is persisted via the piece `context.store` (PROJECT scope).
*/
export class QueueManager {
/* ---------------------------------------------------------------------- */
/* Helpers – configuration, state & usage */
/* ---------------------------------------------------------------------- */
static async getQueueConfiguration(
context: any,
queueId: string,
): Promise<QueueConfiguration | null> {
return await context.store.get(`queue_config_${queueId}`, StoreScope.PROJECT);
}
/**
* NOTE: Activepieces key-value store cannot list keys, so a full “list queues”
* is not possible without maintaining an external index. Stub returns [].
*/
static async listAllQueues(/* context: any */): Promise<QueueConfiguration[]> {
return [];
}
static async getQueueState(
context: any,
queueId: string,
): Promise<QueueState> {
const key = `queue_state_${queueId}`;
return (
(await context.store.get(key, StoreScope.PROJECT)) || {
queueId,
lastReleaseTime: 0,
itemCount: 0,
currentExecutingItem: null,
lastExecutedTime: 0,
version: 0,
}
);
}
static async getQueueUsage(
context: any,
queueId: string,
): Promise<QueueUsage> {
const now = new Date();
const dateKey = now.toISOString().split('T')[0];
const hourKey = now.toISOString().slice(0, 13);
const key = `queue_usage_${queueId}`;
const usage: QueueUsage =
(await context.store.get(key, StoreScope.PROJECT)) || {
date: dateKey,
hour: hourKey,
dailyCount: 0,
hourlyCount: 0,
};
// daily rollover
if (usage.date !== dateKey) {
usage.date = dateKey;
usage.dailyCount = 0;
}
// hourly rollover
if (usage.hour !== hourKey) {
usage.hour = hourKey;
usage.hourlyCount = 0;
}
return usage;
}
/* ---------------------------------------------------------------------- */
/* Status & rate-limit helpers */
/* ---------------------------------------------------------------------- */
static async getQueueStatus(
context: any,
queueId: string,
): Promise<QueueStatus | null> {
const queue = await this.getQueueConfiguration(context, queueId);
if (!queue) return null;
const state = await this.getQueueState(context, queueId);
const usage = await this.getQueueUsage(context, queueId);
const isWithinActiveHours = queue.activeHours
? this.isWithinActiveHours(queue.activeHours)
: true;
const nextActiveWindow =
queue.activeHours && !isWithinActiveHours
? this.getNextActiveWindow(queue.activeHours)
: undefined;
return {
pendingItems: state.itemCount,
currentlyExecuting: state.currentExecutingItem,
lastExecutedTime: state.lastExecutedTime
? new Date(state.lastExecutedTime).toISOString()
: null,
dailyUsed: usage.dailyCount,
dailyLimit: queue.dailyLimit,
dailyRemaining:
queue.dailyLimit > 0 ? queue.dailyLimit - usage.dailyCount : null,
hourlyUsed: usage.hourlyCount,
hourlyLimit: queue.hourlyLimit,
hourlyRemaining:
queue.hourlyLimit > 0 ? queue.hourlyLimit - usage.hourlyCount : null,
isWithinActiveHours,
nextActiveWindow,
};
}
/** Check daily/hourly rate limits and return availability. */
static async checkRateLimits(
context: any,
queueId: string,
): Promise<{
canSend: boolean;
nextAvailableTime?: number;
dailyRemaining?: number;
hourlyRemaining?: number;
}> {
const queue = await this.getQueueConfiguration(context, queueId);
if (!queue) return { canSend: true };
if (queue.dailyLimit === 0 && queue.hourlyLimit === 0)
return { canSend: true };
const usage = await this.getQueueUsage(context, queueId);
const now = new Date();
// daily
if (queue.dailyLimit > 0 && usage.dailyCount >= queue.dailyLimit) {
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
return {
canSend: false,
nextAvailableTime: tomorrow.getTime(),
dailyRemaining: 0,
hourlyRemaining:
queue.hourlyLimit > 0 ? queue.hourlyLimit - usage.hourlyCount : 0,
};
}
// hourly
if (queue.hourlyLimit > 0 && usage.hourlyCount >= queue.hourlyLimit) {
const nextHour = new Date(now);
nextHour.setHours(nextHour.getHours() + 1, 0, 0, 0);
return {
canSend: false,
nextAvailableTime: nextHour.getTime(),
dailyRemaining:
queue.dailyLimit > 0 ? queue.dailyLimit - usage.dailyCount : undefined,
hourlyRemaining: 0,
};
}
return {
canSend: true,
dailyRemaining:
queue.dailyLimit > 0 ? queue.dailyLimit - usage.dailyCount : undefined,
hourlyRemaining:
queue.hourlyLimit > 0 ? queue.hourlyLimit - usage.hourlyCount : undefined,
};
}
/* ---------------------------------------------------------------------- */
/* Delay calculations */
/* ---------------------------------------------------------------------- */
static calculateDelayMs(
delayType: 'fixed' | 'random',
delayUnit: 'seconds' | 'minutes' | 'hours' | 'days',
delayValue?: number,
delayMin?: number,
delayMax?: number,
): number {
const mult: Record<typeof delayUnit, number> = {
seconds: 1000,
minutes: 60 * 1000,
hours: 60 * 60 * 1000,
days: 24 * 60 * 60 * 1000,
};
if (delayType === 'fixed') {
return Math.floor((delayValue || 0) * mult[delayUnit]);
}
const min = delayMin || 0;
const max = delayMax || 0;
const rnd = Math.random() * (max - min) + min;
return Math.floor(rnd * mult[delayUnit]);
}
static async calculateItemDelay(
context: any,
queue: QueueConfiguration,
limits: any,
): Promise<{
delayMs: number;
limitDelay: number;
activeHoursDelay: number;
}> {
let baseDelay = this.calculateDelayMs(
queue.delayType,
queue.delayUnit,
queue.delayValue,
queue.delayMin,
queue.delayMax,
);
let limitDelay = 0;
let activeHoursDelay = 0;
if (!limits.canSend && limits.nextAvailableTime) {
const now = Date.now();
limitDelay = Math.max(0, limits.nextAvailableTime - now);
baseDelay = Math.max(baseDelay, limitDelay);
}
if (queue.activeHours) {
// First check if we're currently within active hours
const isCurrentlyActive = this.isWithinActiveHours(queue.activeHours);
if (!isCurrentlyActive) {
// Only apply active hours delay if we're outside active hours
const nextActive = this.getNextActiveTime(queue.activeHours);
const now = Date.now();
if (nextActive > now) {
activeHoursDelay = nextActive - now;
baseDelay = Math.max(baseDelay, activeHoursDelay);
}
}
// If we're within active hours, no additional delay needed
}
return { delayMs: baseDelay, limitDelay, activeHoursDelay };
}
/* ---------------------------------------------------------------------- */
/* Active-hours helpers */
/* ---------------------------------------------------------------------- */
static isWithinActiveHours(activeHours: any): boolean {
if (!activeHours?.schedule) return true;
const now = new Date();
const tz = activeHours.timezone || 'UTC';
const local = new Date(now.toLocaleString('en-US', { timeZone: tz }));
const day = local
.toLocaleDateString('en-US', { weekday: 'long' })
.toLowerCase();
const time = local.toTimeString().slice(0, 5); // HH:MM
const sched = activeHours.schedule[day];
if (!sched || !sched.enabled) return false;
if (sched.start && sched.end) {
return time >= sched.start && time <= sched.end;
}
return true;
}
static getNextActiveTime(activeHours: any): number {
if (!activeHours?.schedule) return Date.now();
const now = new Date();
const tz = activeHours.timezone || 'UTC';
for (let i = 0; i < 7; i++) {
const cand = new Date(now);
cand.setDate(cand.getDate() + i);
const local = new Date(cand.toLocaleString('en-US', { timeZone: tz }));
const day = local
.toLocaleDateString('en-US', { weekday: 'long' })
.toLowerCase();
const sched = activeHours.schedule[day];
if (sched && sched.enabled && sched.start) {
const [h, m] = sched.start.split(':').map(Number);
cand.setHours(h, m, 0, 0);
if (cand.getTime() > now.getTime()) return cand.getTime();
}
}
return now.getTime() + 24 * 60 * 60 * 1000;
}
static getNextActiveWindow(activeHours: any): string {
return new Date(this.getNextActiveTime(activeHours)).toISOString();
}
/* ---------------------------------------------------------------------- */
/* Core scheduling – add, validate, update, cleanup */
/* ---------------------------------------------------------------------- */
static async addItemToQueue(
context: any,
queueId: string,
targetAction: string,
actionConfig: any,
delayMs: number,
): Promise<{ queueItemId: string; releaseTime: number }> {
const MAX_RETRIES = 5;
let attempt = 0;
const queue = await this.getQueueConfiguration(context, queueId);
if (!queue) throw new Error(`Queue not found: ${queueId}`);
while (attempt < MAX_RETRIES) {
try {
const ts = Date.now();
const rand = Math.random().toString(36).substr(2, 9);
const queueItemId = `${queueId}_${ts}_${rand}`;
const stateKey = `queue_state_${queueId}`;
const currentState: QueueState =
(await context.store.get(stateKey, StoreScope.PROJECT)) || {
queueId,
lastReleaseTime: 0,
itemCount: 0,
currentExecutingItem: null,
lastExecutedTime: 0,
version: 0,
};
// Get actual latest release time from queued items (FIFO)
const itemsListKey = `queue_${queueId}_items`;
const itemIds: string[] = (await context.store.get(itemsListKey, StoreScope.PROJECT)) || [];
let latestReleaseTime = 0;
for (const itemId of itemIds) {
const item = await this.getQueueItem(context, itemId);
if (item && item.status === 'scheduled' && item.releaseTime > latestReleaseTime) {
latestReleaseTime = item.releaseTime;
}
}
const earliest = ts + delayMs;
const nextRelease = Math.max(
earliest,
latestReleaseTime + delayMs,
);
const uniqueOffset = Math.floor(Math.random() * 1000);
let finalReleaseTime = nextRelease + uniqueOffset;
// Apply active hours constraints AFTER calculating queue position
if (queue.activeHours) {
const isCurrentlyActive = this.isWithinActiveHours(queue.activeHours);
if (!isCurrentlyActive) {
// Only push to next active window if we're outside active hours
const nextActive = this.getNextActiveTime(queue.activeHours);
if (nextActive > finalReleaseTime) {
finalReleaseTime = nextActive;
}
}
// If we're within active hours, keep the calculated time
}
// slot reservation
const slotKey = `queue_time_${queueId}_${finalReleaseTime}`;
if (await context.store.get(slotKey, StoreScope.PROJECT)) {
attempt++;
await new Promise((r) => setTimeout(r, 50 * attempt));
continue;
}
await context.store.put(slotKey, queueItemId, StoreScope.PROJECT);
const queueItem: QueueItem = {
id: queueItemId,
queueId,
releaseTime: finalReleaseTime,
targetAction,
actionConfig,
status: 'scheduled',
createdAt: ts,
};
await context.store.put(
`queue_item_${queueItemId}`,
queueItem,
StoreScope.PROJECT,
);
const newState: QueueState = {
...currentState,
lastReleaseTime: finalReleaseTime,
itemCount: currentState.itemCount + 1,
version: currentState.version + 1,
};
const verify = await context.store.get(stateKey, StoreScope.PROJECT);
if (verify && verify.version !== currentState.version) {
// optimistic lock failed – cleanup
await context.store.delete(`queue_item_${queueItemId}`, StoreScope.PROJECT);
await context.store.delete(slotKey, StoreScope.PROJECT);
attempt++;
await new Promise((r) => setTimeout(r, 100 * attempt));
continue;
}
await context.store.put(stateKey, newState, StoreScope.PROJECT);
// track item list
const list: string[] =
(await context.store.get(itemsListKey, StoreScope.PROJECT)) || [];
list.push(queueItemId);
await context.store.put(itemsListKey, list, StoreScope.PROJECT);
return { queueItemId, releaseTime: finalReleaseTime };
} catch (err: any) {
attempt++;
if (attempt >= MAX_RETRIES)
throw new Error(
`Failed to add item after ${MAX_RETRIES} attempts: ${err.message}`,
);
await new Promise((r) => setTimeout(r, 100 * attempt));
}
}
throw new Error('Failed to add item to queue');
}
static async getQueueItem(
context: any,
queueItemId: string,
): Promise<QueueItem | null> {
return await context.store.get(`queue_item_${queueItemId}`, StoreScope.PROJECT);
}
static async updateItemStatus(
context: any,
queueItemId: string,
status: QueueItem['status'],
error?: string,
) {
const item = await this.getQueueItem(context, queueItemId);
if (item) {
item.status = status;
if (error) item.lastError = error;
await context.store.put(`queue_item_${queueItemId}`, item, StoreScope.PROJECT);
}
}
static async validateQueuePosition(
context: any,
queueItemId: string,
): Promise<{
isValid: boolean;
shouldRepause: boolean;
newReleaseTime?: number;
reason?: string;
}> {
const item = await this.getQueueItem(context, queueItemId);
if (!item)
return { isValid: false, shouldRepause: false, reason: 'Item not found' };
const now = Date.now();
const state = await this.getQueueState(context, item.queueId);
if (
state.currentExecutingItem &&
state.currentExecutingItem !== queueItemId
) {
return {
isValid: false,
shouldRepause: true,
newReleaseTime: now + 5000,
reason: 'Another item executing',
};
}
const nextOk = await this.checkIfNextInQueue(context, item);
if (!nextOk) {
const queue = await this.getQueueConfiguration(context, item.queueId);
if (!queue)
return {
isValid: false,
shouldRepause: false,
reason: 'Queue not found',
};
const delayMs = this.calculateDelayMs(
queue.delayType,
queue.delayUnit,
queue.delayValue,
queue.delayMin,
queue.delayMax,
);
const newRelease = state.lastReleaseTime + delayMs;
item.releaseTime = newRelease;
await context.store.put(`queue_item_${item.id}`, item, StoreScope.PROJECT);
return {
isValid: false,
shouldRepause: true,
newReleaseTime: newRelease,
reason: 'Not next in queue',
};
}
if (now >= item.releaseTime) {
state.currentExecutingItem = queueItemId;
await context.store.put(`queue_state_${item.queueId}`, state, StoreScope.PROJECT);
return { isValid: true, shouldRepause: false };
}
return {
isValid: false,
shouldRepause: true,
newReleaseTime: item.releaseTime,
reason: 'Not time yet',
};
}
static async checkIfNextInQueue(
context: any,
currentItem: QueueItem,
): Promise<boolean> {
const list: string[] =
(await context.store.get(
`queue_${currentItem.queueId}_items`,
StoreScope.PROJECT,
)) || [];
for (const id of list) {
if (id === currentItem.id) continue;
const it = await this.getQueueItem(context, id);
if (it && it.status === 'scheduled' && it.releaseTime < currentItem.releaseTime)
return false;
}
return true;
}
static async markExecutionComplete(
context: any,
queueId: string,
queueItemId: string,
) {
const state = await this.getQueueState(context, queueId);
state.currentExecutingItem = null;
state.lastExecutedTime = Date.now();
state.itemCount = Math.max(0, state.itemCount - 1);
await context.store.put(`queue_state_${queueId}`, state, StoreScope.PROJECT);
const listKey = `queue_${queueId}_items`;
const list: string[] =
(await context.store.get(listKey, StoreScope.PROJECT)) || [];
await context.store.put(
listKey,
list.filter((id) => id !== queueItemId),
StoreScope.PROJECT,
);
}
static async cleanupQueueItem(
context: any,
queueId: string,
queueItemId: string,
releaseTime: number,
) {
await context.store.delete(
`queue_time_${queueId}_${releaseTime}`,
StoreScope.PROJECT,
);
await context.store.delete(`queue_item_${queueItemId}`, StoreScope.PROJECT);
}
static async incrementUsageCounters(context: any, queueId: string) {
const usage = await this.getQueueUsage(context, queueId);
usage.dailyCount++;
usage.hourlyCount++;
await context.store.put(`queue_usage_${queueId}`, usage, StoreScope.PROJECT);
const queue = await this.getQueueConfiguration(context, queueId);
if (queue) {
queue.totalProcessed++;
queue.lastUsed = Date.now();
await context.store.put(`queue_config_${queueId}`, queue, StoreScope.PROJECT);
}
}
/* ---------------------------------------------------------------------- */
/* Queue Management Operations */
/* ---------------------------------------------------------------------- */
/**
* Get all items in a queue with their details
*/
static async listQueueItems(context: any, queueId: string): Promise<QueueItem[]> {
const listKey = `queue_${queueId}_items`;
const itemIds: string[] = (await context.store.get(listKey, StoreScope.PROJECT)) || [];
const items: QueueItem[] = [];
for (const itemId of itemIds) {
const item = await this.getQueueItem(context, itemId);
if (item) {
items.push(item);
}
}
// Sort by release time
return items.sort((a, b) => a.releaseTime - b.releaseTime);
}
/**
* Clear all items from a queue
*/
static async clearQueue(context: any, queueId: string): Promise<{
clearedCount: number;
queueId: string;
}> {
if (!queueId) {
throw new Error('Queue ID is required for clearing queue');
}
if (!context) {
throw new Error('Context is required for clearing queue');
}
try {
const listKey = `queue_${queueId}_items`;
let itemIds: string[];
try {
itemIds = (await context.store.get(listKey, StoreScope.PROJECT)) || [];
} catch (error: any) {
throw new Error(`Failed to get queue items list: ${error.message}`);
}
let clearedCount = 0;
const errors: string[] = [];
// Delete all queue items and their time slots
for (const itemId of itemIds) {
try {
const item = await this.getQueueItem(context, itemId);
if (item) {
// Delete the item
try {
await context.store.delete(`queue_item_${itemId}`, StoreScope.PROJECT);
} catch (error: any) {
errors.push(`Failed to delete item ${itemId}: ${error.message}`);
continue;
}
// Delete the time slot reservation
try {
const slotKey = `queue_time_${queueId}_${item.releaseTime}`;
await context.store.delete(slotKey, StoreScope.PROJECT);
} catch (error: any) {
errors.push(`Failed to delete time slot for item ${itemId}: ${error.message}`);
}
clearedCount++;
}
} catch (error: any) {
errors.push(`Failed to process item ${itemId}: ${error.message}`);
}
}
// Clear the items list
try {
await context.store.delete(listKey, StoreScope.PROJECT);
} catch (error: any) {
throw new Error(`Failed to clear items list: ${error.message}`);
}
// Reset queue state
const stateKey = `queue_state_${queueId}`;
try {
const currentState: QueueState = (await context.store.get(stateKey, StoreScope.PROJECT)) || {
queueId,
lastReleaseTime: 0,
itemCount: 0,
currentExecutingItem: null,
lastExecutedTime: 0,
version: 0,
};
const resetState: QueueState = {
...currentState,
itemCount: 0,
currentExecutingItem: null,
lastReleaseTime: 0, // Reset this so new items start fresh
version: currentState.version + 1,
};
await context.store.put(stateKey, resetState, StoreScope.PROJECT);
} catch (error: any) {
throw new Error(`Failed to reset queue state: ${error.message}`);
}
// If there were partial errors, include them in the response
if (errors.length > 0) {
throw new Error(`Queue cleared with ${errors.length} errors: ${errors.join('; ')}`);
}
return {
clearedCount,
queueId,
};
} catch (error: any) {
throw new Error(`Clear queue operation failed: ${error.message}`);
}
}
/**
* Get complete queue information including configuration and status
*/
static async getQueueDetails(context: any, queueId: string): Promise<{
configuration: QueueConfiguration | null;
status: QueueStatus | null;
state: QueueState;
usage: QueueUsage;
}> {
const configuration = await this.getQueueConfiguration(context, queueId);
const status = await this.getQueueStatus(context, queueId);
const state = await this.getQueueState(context, queueId);
const usage = await this.getQueueUsage(context, queueId);
return {
configuration,
status,
state,
usage,
};
}
}