@hotmeshio/hotmesh
Version:
Permanent-Memory Workflows & AI Agents
234 lines (233 loc) • 8.91 kB
JavaScript
"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;