"use strict";
Object.defineProperty(exports, "__esModule", {
    value: true
});
Object.defineProperty(exports, "FhirQueueManager", {
    enumerable: true,
    get: function() {
        return FhirQueueManager;
    }
});
const _api = require("@opentelemetry/api");
const _config = /*#__PURE__*/ _interop_require_default(require("config"));
const _ms = /*#__PURE__*/ _interop_require_default(require("ms"));
const _os = require("os");
const _constants = require("@tamanu/constants");
const _logging = require("../services/logging");
const _dbNotifier = require("../services/dbNotifier");
const _FhirTopicQueueProcessor = require("./FhirTopicQueueProcessor");
function _interop_require_default(obj) {
    return obj && obj.__esModule ? obj : {
        default: obj
    };
}
let FhirQueueManager = class FhirQueueManager {
    queueProcessors = new Map();
    heartbeat = null;
    heartbeatIntervalMs = null;
    worker = null;
    config = _config.default.integrations.fhir.worker;
    // if false, immediately reprocess the queue after a job is completed
    // to work through the backlog promptly; this makes testing harder, so
    // in "testMode" it's disabled.
    testMode = false;
    lastNotifyAt = null;
    lastJobAt = null;
    pendingCrash = false;
    constructor(context, log){
        this.models = context.models;
        this.sequelize = context.sequelize;
        this.log = log;
    }
    // Called by FhirTopicQueueProcessor whenever it successfully grabs a job.
    // Used together with `lastNotifyAt` to detect a stuck NOTIFY listener:
    // if work is being processed but no notifications are arriving, the
    // backstop in the heartbeat is silently carrying the worker.
    recordJobGrabbed() {
        this.lastJobAt = Date.now();
    }
    async start() {
        const { FhirJobWorker, Setting } = this.models;
        const { enabled } = this.config;
        if (!enabled) {
            this.log.info('FhirQueueManager: disabled');
            return;
        }
        const heartbeatInterval = await Setting.get('fhir.worker.heartbeat');
        this.log.debug('FhirQueueManager: got raw heartbeat interval', {
            heartbeatInterval
        });
        const heartbeat = Math.round((0, _ms.default)(heartbeatInterval) * (1 + Math.random() * 0.2 - 0.1)); // +/- 10%
        this.log.debug('FhirQueueManager: added some jitter to the heartbeat', {
            heartbeat
        });
        this.worker = await FhirJobWorker.register({
            version: 'unknown',
            serverType: 'unknown',
            hostname: (0, _os.hostname)(),
            ...global.serverInfo ?? {}
        });
        this.log.info('FhirQueueManager: registered', {
            workerId: this.worker?.id
        });
        this.log.debug('FhirQueueManager: scheduling heartbeat', {
            intervalMs: heartbeat
        });
        this.heartbeatIntervalMs = heartbeat;
        this.heartbeat = setInterval(async ()=>{
            if (this.pendingCrash) return;
            try {
                await this.worker.reload();
                this.log.info('FhirQueueManager: heartbeat:', {
                    topics: this.worker.metadata.topics,
                    successfulJobs: this.worker.metadata.successfulJobs || 0,
                    failedJobs: this.worker.metadata.failedJobs || 0,
                    totalJobs: this.worker.metadata.totalJobs || 0
                });
                await this.worker.heartbeat();
                // Backstop in case a NOTIFY was missed (e.g. during pg-notify
                // reconnect): poke every registered topic so the next heartbeat
                // tick is the worst-case latency for picking up a queued job.
                for (const topic of this.queueProcessors.keys()){
                    this.processQueueNow(topic);
                }
                this.detectDeadListener();
            } catch (err) {
                this.log.error('FhirQueueManager: heartbeat failed', {
                    err
                });
            }
        }, heartbeat).unref();
        this.log.debug('FhirQueueManager: listen for postgres notifications');
        this.lastNotifyAt = Date.now();
        this.dbNotifier = await (0, _dbNotifier.defineDbNotifier)(this.sequelize.config, [
            _constants.NOTIFY_CHANNELS.JOBS
        ]);
        this.dbNotifier.listeners[_constants.NOTIFY_CHANNELS.JOBS]((payload)=>{
            this.lastNotifyAt = Date.now();
            this.log.debug('FhirQueueManager: got postgres notification', {
                payload
            });
            this.processQueueNow(payload.topic);
        });
    }
    async setHandler(topic, handler) {
        this.log.info('FhirQueueManager: setting topic handler', {
            topic
        });
        await this.worker?.markAsHandling(topic);
        const existingQueueProcessor = this.queueProcessors.get(topic);
        if (existingQueueProcessor) {
            existingQueueProcessor.stop(); // No need to await, let it gracefully stop all current jobs in the background
        }
        this.queueProcessors.set(topic, new _FhirTopicQueueProcessor.FhirTopicQueueProcessor(this, topic, handler));
    }
    async stop() {
        clearInterval(this.heartbeat);
        this.heartbeat = null;
        this.log.info('FhirQueueManager: removing all queue processors');
        await Promise.all(Array.from(this.queueProcessors.values()).map((runner)=>runner.stop()));
        this.queueProcessors.clear();
        await this.worker?.deregister();
        this.worker = null;
        if (this.dbNotifier) {
            this.log.info('FhirQueueManager: removing postgres notification listener');
            await this.dbNotifier.close();
            this.dbNotifier = null;
        }
    }
    /**
   * This is the total number of jobs that can be processed concurrently across all topics.
   *
   * @returns {number} Total capacity of the queue manager.
   */ totalCapacity() {
        return Math.max(0, this.config.concurrency);
    }
    /**
   * How many concurrent jobs can be processed for a topic.
   *
   * This is calculated to evenly distribute the capacity among the topics.
   * Every topic can run at least 1 job (regardless of total capacity), and the remaining capacity
   * is divided evenly among the topics.
   *
   * @returns {number} Amount of jobs to run in parallel for a topic.
   */ parallelisationPerTopic() {
        return Math.max(this.totalCapacity() > 0 ? 1 : 0, Math.floor(this.totalCapacity() / this.queueProcessors.size));
    }
    processQueueNow(topic) {
        if (this.testMode) return;
        // using allSettled to avoid 'uncaught promise rejection' errors
        // and setImmediate to avoid growing the stack
        setImmediate(()=>{
            if (this.pendingCrash) return;
            Promise.allSettled([
                this.processQueue(topic)
            ]);
        }).unref();
    }
    // If we've been processing jobs but haven't received NOTIFYs for them,
    // the LISTEN connection is dead (half-open TCP, server-side disconnect
    // pg-notify didn't notice, etc.). Drain in-flight work and exit; the
    // process supervisor will restart us with a fresh listener.
    detectDeadListener() {
        if (this.pendingCrash || this.testMode) return;
        if (this.lastJobAt === null || this.lastNotifyAt === null) return;
        const now = Date.now();
        const sinceLastNotify = now - this.lastNotifyAt;
        const sinceLastJob = now - this.lastJobAt;
        if (sinceLastNotify > 2 * this.heartbeatIntervalMs && sinceLastJob < this.heartbeatIntervalMs) {
            this.detonate({
                sinceLastNotify,
                sinceLastJob
            });
        }
    }
    async detonate({ sinceLastNotify, sinceLastJob }) {
        if (this.pendingCrash) return;
        this.pendingCrash = true;
        this.log.error('FhirQueueManager: NOTIFY listener appears dead, draining and exiting', {
            sinceLastNotifyMs: sinceLastNotify,
            sinceLastJobMs: sinceLastJob
        });
        try {
            // processor.stop() returns null when no jobs are in flight; Promise.all
            // treats nulls as already-resolved, so this works whether processors are
            // mid-job or idle.
            await Promise.all(Array.from(this.queueProcessors.values()).map((p)=>p.stop()));
            this.log.error('FhirQueueManager: drained, exiting for restart');
        } catch (err) {
            this.log.error('FhirQueueManager: error during drain, exiting anyway', {
                err
            });
        }
        process.exit(1);
    }
    processQueue(topic) {
        // start a new root span here to avoid tying this to any callers
        return (0, _logging.getTracer)().startActiveSpan(`FhirQueueManager.processQueue`, {
            root: true
        }, async (span)=>{
            this.log.debug(`Starting to process the queue from worker ${this.worker.id}.`);
            span.setAttributes({
                'code.function': 'processQueue',
                'job.worker': this.worker.id,
                'job.topic': topic
            });
            try {
                if (this.totalCapacity() === 0) {
                    this.log.debug('FhirQueueManager: no capacity');
                    return;
                }
                const queueProcessor = this.queueProcessors.get(topic);
                if (!queueProcessor) {
                    this.log.debug('FhirQueueManager: no processor for topic', {
                        topic
                    });
                    return;
                }
                await this.queueProcessors.get(topic).processQueue();
            } catch (err) {
                this.log.debug('Trouble retrieving the backlog');
                span.recordException(err);
                span.setStatus({
                    code: _api.SpanStatusCode.ERROR
                });
                throw err;
            } finally{
                span.end();
            }
        });
    }
};

//# sourceMappingURL=FhirQueueManager.js.map