263 lines
10 KiB
Plaintext
263 lines
10 KiB
Plaintext
/**
|
|
* In-memory implementations of TaskStore and TaskMessageQueue.
|
|
* WARNING: These APIs are experimental and may change without notice.
|
|
*
|
|
* @experimental
|
|
*/
|
|
import { isTerminal } from '../interfaces.js';
|
|
import { randomBytes } from 'node:crypto';
|
|
/**
|
|
* A simple in-memory implementation of TaskStore for demonstration purposes.
|
|
*
|
|
* This implementation stores all tasks in memory and provides automatic cleanup
|
|
* based on the ttl duration specified in the task creation parameters.
|
|
*
|
|
* A task created with a sessionId belongs to that session: calls that pass a different
|
|
* sessionId treat the task as not found, and listTasks with a sessionId returns that
|
|
* session's tasks and the tasks created without a sessionId. Calls that pass no sessionId
|
|
* are not restricted.
|
|
*
|
|
* Note: This is not suitable for production use as all data is lost on restart.
|
|
* For production, consider implementing TaskStore with a database or distributed cache.
|
|
*
|
|
* @experimental
|
|
*/
|
|
export class InMemoryTaskStore {
|
|
constructor() {
|
|
this.tasks = new Map();
|
|
this.cleanupTimers = new Map();
|
|
}
|
|
/**
|
|
* Generates a unique task ID.
|
|
* Uses 16 bytes of random data encoded as hex (32 characters).
|
|
*/
|
|
generateTaskId() {
|
|
return randomBytes(16).toString('hex');
|
|
}
|
|
/**
|
|
* Returns the stored task unless it belongs to a different session than the caller's.
|
|
*/
|
|
lookup(taskId, sessionId) {
|
|
const stored = this.tasks.get(taskId);
|
|
if (stored && sessionId !== undefined && stored.sessionId !== undefined && stored.sessionId !== sessionId) {
|
|
return undefined;
|
|
}
|
|
return stored;
|
|
}
|
|
async createTask(taskParams, requestId, request, sessionId) {
|
|
// Generate a unique task ID
|
|
const taskId = this.generateTaskId();
|
|
// Ensure uniqueness
|
|
if (this.tasks.has(taskId)) {
|
|
throw new Error(`Task with ID ${taskId} already exists`);
|
|
}
|
|
const actualTtl = taskParams.ttl ?? null;
|
|
// Create task with generated ID and timestamps
|
|
const createdAt = new Date().toISOString();
|
|
const task = {
|
|
taskId,
|
|
status: 'working',
|
|
ttl: actualTtl,
|
|
createdAt,
|
|
lastUpdatedAt: createdAt,
|
|
pollInterval: taskParams.pollInterval ?? 1000
|
|
};
|
|
this.tasks.set(taskId, {
|
|
task,
|
|
request,
|
|
requestId,
|
|
sessionId
|
|
});
|
|
// Schedule cleanup if ttl is specified
|
|
// Cleanup occurs regardless of task status
|
|
if (actualTtl) {
|
|
const timer = setTimeout(() => {
|
|
this.tasks.delete(taskId);
|
|
this.cleanupTimers.delete(taskId);
|
|
}, actualTtl);
|
|
this.cleanupTimers.set(taskId, timer);
|
|
}
|
|
return task;
|
|
}
|
|
async getTask(taskId, sessionId) {
|
|
const stored = this.lookup(taskId, sessionId);
|
|
return stored ? { ...stored.task } : null;
|
|
}
|
|
async storeTaskResult(taskId, status, result, sessionId) {
|
|
const stored = this.lookup(taskId, sessionId);
|
|
if (!stored) {
|
|
throw new Error(`Task with ID ${taskId} not found`);
|
|
}
|
|
// Don't allow storing results for tasks already in terminal state
|
|
if (isTerminal(stored.task.status)) {
|
|
throw new Error(`Cannot store result for task ${taskId} in terminal status '${stored.task.status}'. Task results can only be stored once.`);
|
|
}
|
|
stored.result = result;
|
|
stored.task.status = status;
|
|
stored.task.lastUpdatedAt = new Date().toISOString();
|
|
// Reset cleanup timer to start from now (if ttl is set)
|
|
if (stored.task.ttl) {
|
|
const existingTimer = this.cleanupTimers.get(taskId);
|
|
if (existingTimer) {
|
|
clearTimeout(existingTimer);
|
|
}
|
|
const timer = setTimeout(() => {
|
|
this.tasks.delete(taskId);
|
|
this.cleanupTimers.delete(taskId);
|
|
}, stored.task.ttl);
|
|
this.cleanupTimers.set(taskId, timer);
|
|
}
|
|
}
|
|
async getTaskResult(taskId, sessionId) {
|
|
const stored = this.lookup(taskId, sessionId);
|
|
if (!stored) {
|
|
throw new Error(`Task with ID ${taskId} not found`);
|
|
}
|
|
if (!stored.result) {
|
|
throw new Error(`Task ${taskId} has no result stored`);
|
|
}
|
|
return stored.result;
|
|
}
|
|
async updateTaskStatus(taskId, status, statusMessage, sessionId) {
|
|
const stored = this.lookup(taskId, sessionId);
|
|
if (!stored) {
|
|
throw new Error(`Task with ID ${taskId} not found`);
|
|
}
|
|
// Don't allow transitions from terminal states
|
|
if (isTerminal(stored.task.status)) {
|
|
throw new Error(`Cannot update task ${taskId} from terminal status '${stored.task.status}' to '${status}'. Terminal states (completed, failed, cancelled) cannot transition to other states.`);
|
|
}
|
|
stored.task.status = status;
|
|
if (statusMessage) {
|
|
stored.task.statusMessage = statusMessage;
|
|
}
|
|
stored.task.lastUpdatedAt = new Date().toISOString();
|
|
// If task is in a terminal state and has ttl, start cleanup timer
|
|
if (isTerminal(status) && stored.task.ttl) {
|
|
const existingTimer = this.cleanupTimers.get(taskId);
|
|
if (existingTimer) {
|
|
clearTimeout(existingTimer);
|
|
}
|
|
const timer = setTimeout(() => {
|
|
this.tasks.delete(taskId);
|
|
this.cleanupTimers.delete(taskId);
|
|
}, stored.task.ttl);
|
|
this.cleanupTimers.set(taskId, timer);
|
|
}
|
|
}
|
|
async listTasks(cursor, sessionId) {
|
|
const PAGE_SIZE = 10;
|
|
// Page over the tasks the caller can look up, so listTasks follows the same rule as getTask.
|
|
const allTaskIds = Array.from(this.tasks.keys()).filter(taskId => this.lookup(taskId, sessionId));
|
|
let startIndex = 0;
|
|
if (cursor) {
|
|
const cursorIndex = allTaskIds.indexOf(cursor);
|
|
if (cursorIndex >= 0) {
|
|
startIndex = cursorIndex + 1;
|
|
}
|
|
else {
|
|
// Invalid cursor - throw error
|
|
throw new Error(`Invalid cursor: ${cursor}`);
|
|
}
|
|
}
|
|
const pageTaskIds = allTaskIds.slice(startIndex, startIndex + PAGE_SIZE);
|
|
const tasks = pageTaskIds.map(taskId => {
|
|
const stored = this.tasks.get(taskId);
|
|
return { ...stored.task };
|
|
});
|
|
const nextCursor = startIndex + PAGE_SIZE < allTaskIds.length ? pageTaskIds[pageTaskIds.length - 1] : undefined;
|
|
return { tasks, nextCursor };
|
|
}
|
|
/**
|
|
* Cleanup all timers (useful for testing or graceful shutdown)
|
|
*/
|
|
cleanup() {
|
|
for (const timer of this.cleanupTimers.values()) {
|
|
clearTimeout(timer);
|
|
}
|
|
this.cleanupTimers.clear();
|
|
this.tasks.clear();
|
|
}
|
|
/**
|
|
* Get all tasks of all sessions (useful for debugging)
|
|
*/
|
|
getAllTasks() {
|
|
return Array.from(this.tasks.values()).map(stored => ({ ...stored.task }));
|
|
}
|
|
}
|
|
/**
|
|
* A simple in-memory implementation of TaskMessageQueue for demonstration purposes.
|
|
*
|
|
* This implementation stores messages in memory, organized by task ID and optional session ID.
|
|
* Messages are stored in FIFO queues per task.
|
|
*
|
|
* Note: This is not suitable for production use in distributed systems.
|
|
* For production, consider implementing TaskMessageQueue with Redis or other distributed queues.
|
|
*
|
|
* @experimental
|
|
*/
|
|
export class InMemoryTaskMessageQueue {
|
|
constructor() {
|
|
this.queues = new Map();
|
|
}
|
|
/**
|
|
* Generates a queue key from taskId.
|
|
* SessionId is intentionally ignored because taskIds are globally unique
|
|
* and the task store decides which session a task is found for.
|
|
*/
|
|
getQueueKey(taskId, _sessionId) {
|
|
return taskId;
|
|
}
|
|
/**
|
|
* Gets or creates a queue for the given task and session.
|
|
*/
|
|
getQueue(taskId, sessionId) {
|
|
const key = this.getQueueKey(taskId, sessionId);
|
|
let queue = this.queues.get(key);
|
|
if (!queue) {
|
|
queue = [];
|
|
this.queues.set(key, queue);
|
|
}
|
|
return queue;
|
|
}
|
|
/**
|
|
* Adds a message to the end of the queue for a specific task.
|
|
* Atomically checks queue size and throws if maxSize would be exceeded.
|
|
* @param taskId The task identifier
|
|
* @param message The message to enqueue
|
|
* @param sessionId Optional session ID for binding the operation to a specific session
|
|
* @param maxSize Optional maximum queue size - if specified and queue is full, throws an error
|
|
* @throws Error if maxSize is specified and would be exceeded
|
|
*/
|
|
async enqueue(taskId, message, sessionId, maxSize) {
|
|
const queue = this.getQueue(taskId, sessionId);
|
|
// Atomically check size and enqueue
|
|
if (maxSize !== undefined && queue.length >= maxSize) {
|
|
throw new Error(`Task message queue overflow: queue size (${queue.length}) exceeds maximum (${maxSize})`);
|
|
}
|
|
queue.push(message);
|
|
}
|
|
/**
|
|
* Removes and returns the first message from the queue for a specific task.
|
|
* @param taskId The task identifier
|
|
* @param sessionId Optional session ID for binding the query to a specific session
|
|
* @returns The first message, or undefined if the queue is empty
|
|
*/
|
|
async dequeue(taskId, sessionId) {
|
|
const queue = this.getQueue(taskId, sessionId);
|
|
return queue.shift();
|
|
}
|
|
/**
|
|
* Removes and returns all messages from the queue for a specific task.
|
|
* @param taskId The task identifier
|
|
* @param sessionId Optional session ID for binding the query to a specific session
|
|
* @returns Array of all messages that were in the queue
|
|
*/
|
|
async dequeueAll(taskId, sessionId) {
|
|
const key = this.getQueueKey(taskId, sessionId);
|
|
const queue = this.queues.get(key) ?? [];
|
|
this.queues.delete(key);
|
|
return queue;
|
|
}
|
|
}
|
|
//# sourceMappingURL=in-memory.js.map |