UNPKG

durabull

Version:

A durable workflow engine built on top of BullMQ and Redis

292 lines (249 loc) 8.23 kB
/** * Storage facade for workflow persistence */ import { Redis } from 'ioredis'; import { WorkflowRecord, History, HistoryEvent } from './history'; import { getSerializer } from '../serializers'; import { Durabull } from '../config/global'; /** * Signal envelope */ export interface SignalEnvelope { name: string; payload: unknown; ts: number; } /** * Storage interface for workflow persistence */ export interface Storage { writeRecord(rec: WorkflowRecord): Promise<void>; readRecord(id: string): Promise<WorkflowRecord | null>; writeHistory(id: string, hist: History): Promise<void>; readHistory(id: string): Promise<History | null>; appendEvent(id: string, ev: HistoryEvent): Promise<void>; listSignals(id: string): Promise<SignalEnvelope[]>; pushSignal(id: string, signal: SignalEnvelope): Promise<void>; popSignal(id: string): Promise<SignalEnvelope | null>; acquireLock(id: string, lockName: string, ttlSeconds: number): Promise<boolean>; releaseLock(id: string, lockName: string): Promise<void>; refreshHeartbeat(workflowId: string, activityId: string, ttlSeconds: number): Promise<void>; checkHeartbeat(workflowId: string, activityId: string): Promise<number | null>; addChild(parentId: string, childId: string): Promise<void>; getChildren(parentId: string): Promise<string[]>; } /** * Redis-based storage implementation */ export class RedisStorage implements Storage { private redis: Redis; private serializer = getSerializer('json'); constructor(redisUrl?: string, serializerType?: 'json' | 'base64') { const url = redisUrl || 'redis://localhost:6379'; this.redis = new Redis(url); if (serializerType) { this.serializer = getSerializer(serializerType); } } /** * Write workflow record */ async writeRecord(rec: WorkflowRecord): Promise<void> { const key = this.getRecordKey(rec.id); const data = this.serializer.serialize(rec); await this.redis.set(key, data); } /** * Read workflow record */ async readRecord(id: string): Promise<WorkflowRecord | null> { const key = this.getRecordKey(id); const data = await this.redis.get(key); if (!data) return null; return this.serializer.deserialize<WorkflowRecord>(data); } /** * Write complete history * Optimized to only update cursor if events are managed via appendEvent */ async writeHistory(id: string, hist: History): Promise<void> { const eventsKey = this.getHistoryEventsKey(id); const cursorKey = this.getHistoryCursorKey(id); // If initializing or clearing if (hist.events.length === 0) { await this.redis.del(eventsKey); await this.redis.set(cursorKey, 0); return; } // Update cursor await this.redis.set(cursorKey, hist.cursor); const data = hist.events.map(e => this.serializer.serialize(e)); // Use Lua script for atomic check-and-set to avoid race conditions const script = ` local key = KEYS[1] local new_len = tonumber(ARGV[1]) local current_len = redis.call('LLEN', key) if current_len == new_len then return 0 end redis.call('DEL', key) if new_len > 0 then redis.call('RPUSH', key, unpack(ARGV, 2)) end return 1 `; await this.redis.eval(script, 1, eventsKey, data.length, ...data); } /** * Read history */ async readHistory(id: string): Promise<History | null> { const eventsKey = this.getHistoryEventsKey(id); const cursorKey = this.getHistoryCursorKey(id); const [eventsData, cursorData] = await Promise.all([ this.redis.lrange(eventsKey, 0, -1), this.redis.get(cursorKey) ]); if (eventsData.length === 0 && !cursorData) { return null; } const events = eventsData.map(item => this.serializer.deserialize<HistoryEvent>(item)); const cursor = cursorData ? parseInt(cursorData, 10) : 0; return { events, cursor }; } /** * Append event to history (optimized) */ async appendEvent(id: string, ev: HistoryEvent): Promise<void> { const key = this.getHistoryEventsKey(id); const data = this.serializer.serialize(ev); await this.redis.rpush(key, data); } /** * List all signals for a workflow */ async listSignals(id: string): Promise<SignalEnvelope[]> { const key = this.getSignalsKey(id); const items = await this.redis.lrange(key, 0, -1); return items.map(item => this.serializer.deserialize<SignalEnvelope>(item)); } /** * Push signal to workflow queue */ async pushSignal(id: string, signal: SignalEnvelope): Promise<void> { const key = this.getSignalsKey(id); const data = this.serializer.serialize(signal); await this.redis.lpush(key, data); } /** * Pop signal from workflow queue (FIFO) */ async popSignal(id: string): Promise<SignalEnvelope | null> { const key = this.getSignalsKey(id); const data = await this.redis.rpop(key); if (!data) return null; return this.serializer.deserialize<SignalEnvelope>(data); } /** * Acquire a lock for workflow or activity */ async acquireLock(id: string, lockName: string, ttlSeconds: number): Promise<boolean> { const key = this.getLockKey(id, lockName); const result = await this.redis.set(key, '1', 'EX', ttlSeconds, 'NX'); return result === 'OK'; } /** * Release a lock */ async releaseLock(id: string, lockName: string): Promise<void> { const key = this.getLockKey(id, lockName); await this.redis.del(key); } /** * Refresh activity heartbeat */ async refreshHeartbeat(workflowId: string, activityId: string, ttlSeconds: number): Promise<void> { const key = this.getHeartbeatKey(workflowId, activityId); await this.redis.set(key, Date.now().toString(), 'EX', ttlSeconds); } /** * Check last heartbeat timestamp */ async checkHeartbeat(workflowId: string, activityId: string): Promise<number | null> { const key = this.getHeartbeatKey(workflowId, activityId); const data = await this.redis.get(key); return data ? parseInt(data, 10) : null; } /** * Add child workflow relationship */ async addChild(parentId: string, childId: string): Promise<void> { const key = this.getChildrenKey(parentId); await this.redis.sadd(key, childId); } /** * Get all children of a workflow */ async getChildren(parentId: string): Promise<string[]> { const key = this.getChildrenKey(parentId); return await this.redis.smembers(key); } /** * Close Redis connection */ async close(): Promise<void> { await this.redis.quit(); } private getHistoryEventsKey(id: string): string { return `durabull:wf:${id}:history:events`; } private getHistoryCursorKey(id: string): string { return `durabull:wf:${id}:history:cursor`; } private getRecordKey(id: string): string { return `durabull:wf:${id}:record`; } private getSignalsKey(id: string): string { return `durabull:wf:${id}:signals`; } private getLockKey(id: string, lockName: string): string { return `durabull:wf:${id}:locks:${lockName}`; } private getHeartbeatKey(workflowId: string, activityId: string): string { return `durabull:wf:${workflowId}:act:${activityId}:hb`; } private getChildrenKey(parentId: string): string { return `durabull:wf:${parentId}:children`; } } /** * Global storage instance */ let storageInstance: Storage | null = null; /** * Get or create storage instance */ export function getStorage(): Storage { if (!storageInstance) { const instance = Durabull.getActive(); const redisUrl = instance?.getConfig().redisUrl || 'redis://localhost:6379'; const serializer = instance?.getConfig().serializer || 'json'; storageInstance = new RedisStorage(redisUrl, serializer); } return storageInstance; } /** * Set custom storage implementation */ export function setStorage(storage: Storage): void { storageInstance = storage; } /** * Close storage connection */ export async function closeStorage(): Promise<void> { if (storageInstance && storageInstance instanceof RedisStorage) { await storageInstance.close(); storageInstance = null; } }