UNPKG

@hotmeshio/hotmesh

Version:

Permanent-Memory Workflows & AI Agents

234 lines (233 loc) 8.91 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ScoutManager = void 0; const key_1 = require("../../../../modules/key"); const utils_1 = require("../../../../modules/utils"); const enums_1 = require("../../../../modules/enums"); /** * Scout state manager for coordinating polling across multiple instances. * Only one instance at a time should be the active scout. */ class ScoutManager { constructor(client, appId, getTableName, mintKey, logger) { this.client = client; this.appId = appId; this.getTableName = getTableName; this.mintKey = mintKey; this.logger = logger; this.isScout = false; this.shouldStopScout = false; // Polling metrics this.pollCount = 0; this.totalNotifications = 0; this.scoutStartTime = null; } /** * Start the router scout polling loop. * Winner polls frequently for visible messages, losers retry acquiring role less frequently. */ startRouterScoutPoller() { this.shouldStopScout = false; this.pollForVisibleMessagesLoop().catch((error) => { this.logger.error('postgres-stream-router-scout-start-error', { error }); }); this.logger.info('postgres-stream-router-scout-started', { appId: this.appId, pollInterval: this.getRouterScoutInterval(), scoutInterval: enums_1.HMSH_ROUTER_SCOUT_INTERVAL_SECONDS, }); } /** * Stop the router scout polling loop and release the role. */ async stopRouterScoutPoller() { this.shouldStopScout = true; if (this.isScout) { await this.releaseScoutRole('router'); this.isScout = false; } // Log polling metrics on shutdown this.logPollingMetrics(); } /** * Check if this instance should act as the router scout. */ async shouldScout() { const wasScout = this.isScout; const isScout = wasScout || (this.isScout = await this.reserveRouterScoutRole()); if (isScout) { if (!wasScout) { // First time becoming scout - set timeout to reset after interval and track start time this.scoutStartTime = Date.now(); this.logger.info('postgres-stream-router-scout-role-acquired', { appId: this.appId, }); setTimeout(() => { this.isScout = false; this.scoutStartTime = null; }, enums_1.HMSH_ROUTER_SCOUT_INTERVAL_SECONDS * 1000); } return true; } return false; } /** * Main polling loop for the router scout. */ async pollForVisibleMessagesLoop() { while (!this.shouldStopScout) { try { if (await this.shouldScout()) { // We're the scout - poll for visible messages frequently await this.pollForVisibleMessages(); // Sleep for the short polling interval await (0, utils_1.sleepFor)(this.getRouterScoutInterval()); } else { // Not the scout - sleep longer before trying to acquire role again await (0, utils_1.sleepFor)(enums_1.HMSH_ROUTER_SCOUT_INTERVAL_SECONDS * 1000); } } catch (error) { this.logger.error('postgres-stream-router-scout-loop-error', { error }); await (0, utils_1.sleepFor)(1000); // Brief pause on error } } } /** * Poll for visible messages and trigger notifications for any found. */ async pollForVisibleMessages() { try { const tableName = this.getTableName(); const schemaName = tableName.split('.')[0]; const result = await this.client.query(`SELECT ${schemaName}.notify_visible_messages() as count`); // Track polling metrics this.pollCount++; const notificationCount = result.rows[0]?.count || 0; this.totalNotifications += notificationCount; if (notificationCount > 0) { this.logger.debug('postgres-stream-router-scout-notifications', { count: notificationCount, totalPolls: this.pollCount, totalNotifications: this.totalNotifications, }); } } catch (error) { // Log but don't throw - this is a background task this.logger.debug('postgres-stream-router-scout-poll-error', { error: error.message, }); } } /** * Reserve the router scout role using direct SQL. */ async reserveRouterScoutRole() { try { return await this.reserveScoutRole('router', enums_1.HMSH_ROUTER_SCOUT_INTERVAL_SECONDS); } catch (error) { this.logger.error('postgres-stream-router-scout-reserve-error', { error }); return false; } } /** * Reserve a scout role for the specified type. * Uses SET NX (set if not exists) with expiration to ensure only one instance holds the role. */ async reserveScoutRole(scoutType, delay = enums_1.HMSH_ROUTER_SCOUT_INTERVAL_SECONDS) { try { const key = this.mintKey(key_1.KeyType.WORK_ITEMS, { appId: this.appId, scoutType, }); const value = `${scoutType}:${(0, utils_1.formatISODate)(new Date())}`; const expirySeconds = delay - 1; const tableName = this.getTableName().split('.')[0] + '.roles'; // Use INSERT ... ON CONFLICT to implement SET NX with expiration // Only succeeds if key doesn't exist OR if existing entry has expired const result = await this.client.query(`INSERT INTO ${tableName} (key, value, expiry) VALUES ($1, $2, NOW() + INTERVAL '${expirySeconds} seconds') ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, expiry = EXCLUDED.expiry WHERE ${tableName}.expiry IS NULL OR ${tableName}.expiry <= NOW() RETURNING key`, [key, value]); return result.rows.length > 0; } catch (error) { this.logger.error('postgres-stream-reserve-scout-error', { scoutType, error: error.message }); return false; } } /** * Release a scout role for the specified type. */ async releaseScoutRole(scoutType) { try { const key = this.mintKey(key_1.KeyType.WORK_ITEMS, { appId: this.appId, scoutType, }); const tableName = this.getTableName().split('.')[0] + '.roles'; const result = await this.client.query(`DELETE FROM ${tableName} WHERE key = $1`, [key]); return result.rowCount > 0; } catch (error) { this.logger.error('postgres-stream-release-scout-error', { scoutType, error: error.message }); return false; } } /** * Get the router scout polling interval in milliseconds. */ getRouterScoutInterval() { return enums_1.HMSH_ROUTER_SCOUT_INTERVAL_MS; } /** * Check if this instance is currently the scout. */ isCurrentlyScout() { return this.isScout; } /** * Log polling metrics for this instance's scout tenure. */ logPollingMetrics() { if (this.pollCount === 0) { this.logger.info('postgres-stream-router-scout-metrics', { message: 'No polling occurred during this session', appId: this.appId, }); return; } const durationMs = this.scoutStartTime ? Date.now() - this.scoutStartTime : 0; const durationMinutes = durationMs / 1000 / 60; const qpm = durationMinutes > 0 ? this.pollCount / durationMinutes : 0; const qps = durationMs > 0 ? this.pollCount / (durationMs / 1000) : 0; this.logger.info('postgres-stream-router-scout-metrics', { appId: this.appId, totalPolls: this.pollCount, totalNotifications: this.totalNotifications, durationMs: Math.round(durationMs), durationMinutes: durationMinutes.toFixed(2), queriesPerMinute: qpm.toFixed(2), queriesPerSecond: qps.toFixed(3), avgNotificationsPerPoll: this.pollCount > 0 ? (this.totalNotifications / this.pollCount).toFixed(2) : '0', }); } } exports.ScoutManager = ScoutManager;