@gati-framework/runtime
Version:
Gati runtime execution engine for running handler-based applications
309 lines • 10.8 kB
JavaScript
/**
* @module runtime/queue-fabric
* @description In-memory Queue Fabric implementation with pub/sub and backpressure
*/
import { randomUUID } from 'crypto';
/**
* In-memory Queue Fabric implementation
*
* Provides topic-based pub/sub messaging with:
* - At-least-once and exactly-once delivery semantics
* - Backpressure enforcement
* - Result delivery to originating request contexts
* - Priority-based message ordering
* - TTL-based message expiration
*/
export class InMemoryQueueFabric {
queue = [];
subscriptions = new Map();
requestContexts = new Map();
deliveredMessages = new Set();
processing = false;
shutdownRequested = false;
processingInterval;
config;
stats = {
messagesPublished: 0,
messagesDelivered: 0,
};
constructor(config = {}) {
this.config = {
maxQueueDepth: config.maxQueueDepth ?? 10000,
defaultDeliverySemantics: config.defaultDeliverySemantics ?? 'at-least-once',
maxDeliveryAttempts: config.maxDeliveryAttempts ?? 3,
defaultTtl: config.defaultTtl ?? 60000, // 60 seconds
enableMetrics: config.enableMetrics ?? true,
};
// Start processing loop
this.startProcessing();
}
/**
* Publish a message to a topic
*/
async publish(topic, payload, options = {}) {
if (this.shutdownRequested) {
throw new Error('Queue fabric is shutting down');
}
// Check backpressure BEFORE adding to queue
if (this.queue.length >= this.config.maxQueueDepth) {
const capacityUsed = (this.queue.length / this.config.maxQueueDepth) * 100;
throw new Error(`Backpressure active: queue at ${capacityUsed.toFixed(0)}% capacity`);
}
const messageId = randomUUID();
const timestamp = Date.now();
const ttl = options.ttl ?? this.config.defaultTtl;
const message = {
id: messageId,
topic,
payload,
options: {
deliverySemantics: options.deliverySemantics ?? this.config.defaultDeliverySemantics,
priority: options.priority ?? 5,
ttl,
requestId: options.requestId,
},
timestamp,
deliveryAttempt: 0,
expiresAt: timestamp + ttl,
};
// Insert message in priority order (higher priority first)
const insertIndex = this.queue.findIndex(m => m.options.priority < message.options.priority);
if (insertIndex === -1) {
this.queue.push(message);
}
else {
this.queue.splice(insertIndex, 0, message);
}
this.stats.messagesPublished++;
}
/**
* Subscribe to a topic
*/
subscribe(topic, handler) {
const subscriptionId = randomUUID();
const subscription = {
id: subscriptionId,
topic,
handler,
active: true,
};
if (!this.subscriptions.has(topic)) {
this.subscriptions.set(topic, new Set());
}
this.subscriptions.get(topic).add(subscription);
return {
topic,
unsubscribe: () => {
subscription.active = false;
const topicSubs = this.subscriptions.get(topic);
if (topicSubs) {
topicSubs.delete(subscription);
if (topicSubs.size === 0) {
this.subscriptions.delete(topic);
}
}
},
isActive: () => subscription.active,
};
}
/**
* Enforce backpressure when capacity is reached
*/
enforceBackpressure(threshold) {
if (threshold < 0 || threshold > 1) {
throw new Error('Backpressure threshold must be between 0 and 1');
}
this.config.maxQueueDepth = Math.floor(this.config.maxQueueDepth * threshold);
}
/**
* Deliver result to originating request context
*/
async deliverResult(requestId, result) {
const context = this.requestContexts.get(requestId);
if (!context) {
// No handlers registered for this request
return;
}
// Deliver to all registered handlers
for (const handler of context.resultHandlers) {
try {
handler(result);
}
catch (error) {
console.error(`Error delivering result to request ${requestId}:`, error);
}
}
// Clean up context
this.requestContexts.delete(requestId);
}
/**
* Get current backpressure status
*/
getBackpressureStatus() {
const queueDepth = this.queue.length;
const maxDepth = this.config.maxQueueDepth;
const capacityUsed = maxDepth > 0 ? (queueDepth / maxDepth) * 100 : 0;
return {
active: queueDepth >= maxDepth,
queueDepth,
maxDepth,
capacityUsed,
};
}
/**
* Get queue fabric statistics
*/
getStats() {
const backpressure = this.getBackpressureStatus();
const activeSubscriptions = Array.from(this.subscriptions.values())
.reduce((sum, subs) => sum + subs.size, 0);
return {
messagesPublished: this.stats.messagesPublished,
messagesDelivered: this.stats.messagesDelivered,
queueDepth: this.queue.length,
activeSubscriptions,
backpressure,
};
}
/**
* Shutdown the queue fabric
*/
async shutdown() {
this.shutdownRequested = true;
// Stop processing interval
if (this.processingInterval) {
clearInterval(this.processingInterval);
this.processingInterval = undefined;
}
// Wait for queue to drain (with timeout)
const timeout = 1000;
const start = Date.now();
while (this.queue.length > 0 && Date.now() - start < timeout) {
await this.processQueue();
await new Promise(resolve => setTimeout(resolve, 10));
}
// Clear all subscriptions
this.subscriptions.clear();
this.requestContexts.clear();
this.deliveredMessages.clear();
this.queue = [];
}
/**
* Register a result handler for a request context
*/
registerResultHandler(requestId, handler) {
if (!this.requestContexts.has(requestId)) {
this.requestContexts.set(requestId, {
requestId,
resultHandlers: new Set(),
});
}
this.requestContexts.get(requestId).resultHandlers.add(handler);
}
/**
* Start the message processing loop
*/
startProcessing() {
// Use setInterval for periodic processing
this.processingInterval = setInterval(() => {
if (this.shutdownRequested) {
if (this.processingInterval) {
clearInterval(this.processingInterval);
}
return;
}
this.processQueue();
}, 10); // Process every 10ms
}
/**
* Process messages in the queue
*/
async processQueue() {
if (this.processing || this.queue.length === 0) {
return;
}
this.processing = true;
try {
const now = Date.now();
const message = this.queue[0];
// Check if message has expired
if (message.expiresAt <= now) {
this.queue.shift();
this.processing = false;
return;
}
// Check if already delivered (for exactly-once semantics)
if (message.options.deliverySemantics === 'exactly-once' &&
this.deliveredMessages.has(message.id)) {
this.queue.shift();
this.processing = false;
return;
}
// Get subscribers for this topic
const subscribers = this.subscriptions.get(message.topic);
if (!subscribers || subscribers.size === 0) {
// No subscribers, keep message in queue (don't process it)
this.processing = false;
return;
}
// Deliver to all active subscribers
const metadata = {
messageId: message.id,
topic: message.topic,
timestamp: message.timestamp,
requestId: message.options.requestId,
deliveryAttempt: message.deliveryAttempt + 1,
};
let deliveryFailed = false;
for (const sub of subscribers) {
if (!sub.active)
continue;
try {
await sub.handler(message.payload, metadata);
}
catch (error) {
console.error(`Error delivering message ${message.id} to subscriber:`, error);
deliveryFailed = true;
}
}
// Handle delivery failure
if (deliveryFailed) {
message.deliveryAttempt++;
if (message.deliveryAttempt >= this.config.maxDeliveryAttempts) {
// Max attempts reached, discard message
console.error(`Message ${message.id} discarded after ${message.deliveryAttempt} attempts`);
this.queue.shift();
}
else {
// Retry: move to end of queue
this.queue.shift();
this.queue.push(message);
}
}
else {
// Successful delivery
this.queue.shift();
this.stats.messagesDelivered++;
// Mark as delivered for exactly-once semantics
if (message.options.deliverySemantics === 'exactly-once') {
this.deliveredMessages.add(message.id);
// Clean up old delivered messages (keep last 10000)
if (this.deliveredMessages.size > 10000) {
const toDelete = Array.from(this.deliveredMessages).slice(0, 1000);
toDelete.forEach(id => this.deliveredMessages.delete(id));
}
}
}
}
finally {
this.processing = false;
}
}
}
/**
* Create a new Queue Fabric instance
*/
export function createQueueFabric(config) {
return new InMemoryQueueFabric(config);
}
//# sourceMappingURL=queue-fabric.js.map