UNPKG

nats-jobs

Version:
251 lines (250 loc) 9.43 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.jobProcessor = void 0; const ms_1 = __importDefault(require("ms")); const nats_1 = require("nats"); const util_1 = require("./util"); const debug_1 = __importDefault(require("debug")); const fp_1 = __importDefault(require("lodash/fp")); const eventemitter3_1 = __importDefault(require("eventemitter3")); const debug = (0, debug_1.default)('nats-jobs'); const streamDefaults = (def) => fp_1.default.defaults({ name: def.stream, retention: nats_1.RetentionPolicy.Workqueue, storage: nats_1.StorageType.File, max_age: (0, util_1.nanos)('1w'), num_replicas: 1, subjects: [def.stream], discard: nats_1.DiscardPolicy.Old, deny_delete: false, deny_purge: false, }, def.streamConfig); /** * Create the stream. By default we create a work queue with * file storage. The default subject is the name of the stream. */ const createStream = async (conn, def) => { const jsm = await conn.jetstreamManager(); // Stream config const config = streamDefaults(def); debug('stream config %O', config); // Add stream return jsm.streams.add(config); }; const defaultAckWait = (0, util_1.nanos)('10s'); const consumerDefaults = (def) => fp_1.default.defaults({ durable_name: `${def.stream}Consumer`, max_deliver: def.numAttempts ?? 5, ack_policy: nats_1.AckPolicy.Explicit, ack_wait: defaultAckWait, deliver_policy: nats_1.DeliverPolicy.All, replay_policy: nats_1.ReplayPolicy.Instant, }, def.consumerConfig); /** * Create a pull consumer on the stream. Require manual acks. * By default we don't filter subjects. */ const createConsumer = (conn, def) => { // Consumer config const config = consumerDefaults(def); debug('consumer config %O', config); const js = conn.jetstream(); // Create a pull consumer return js.pullSubscribe(def.filterSubject || '', { stream: def.stream, mack: true, config, }); }; const extendAckTimeoutThresholdFactor = 0.8; /** * Call `start` to begin processing jobs based on def. To * gracefully shutdown call `stop` method. */ const jobProcessor = async (opts) => { // Connect to NATS const conn = await (0, nats_1.connect)(opts); const js = conn.jetstream(); const stopFns = []; const emitter = new eventemitter3_1.default(); const emit = (event, data) => { debug(`${event} %O`, data); emitter.emit(event, { type: event, ...data }); }; const start = (def) => { emit('start', def); const abortController = new AbortController(); let deferred; // Flag that indicates the stop function was called let stopping = false; // Pull messages repeatedly let puller; // How often to pull down messages from the consumer const pullInterval = def.pullInterval ?? (0, ms_1.default)('30s'); // How long to wait for the batch of messages const expires = pullInterval - 500; // Retry a failed message after a second by default const backoff = def.backoff ?? (0, ms_1.default)('1s'); // Pull down 1 message by default const batch = def.batch ?? 1; // Consumer config const consumerConfig = consumerDefaults(def); /** * Automatically extend the ack timeout by periodically telling NATS * we're working. */ const extendAckTimeout = (msg) => { if (def.autoExtendAckTimeout) { const ackWait = consumerConfig.ack_wait; const intervalMs = (0, util_1.nanosToMs)(ackWait) * extendAckTimeoutThresholdFactor; return setInterval(() => { emit('working', { ...getMetadata(msg), intervalMs }); msg.working(); }, intervalMs); } }; const handleTimeout = (msg) => { const timeoutMs = def.timeoutMs; if (timeoutMs) { return setTimeout(() => { emit('timeout', { ...getMetadata(msg), timeoutMs }); // Abort abortController.abort('timeout'); }, timeoutMs); } }; const getMetadata = (msg) => ({ msgInfo: msg.info, consumerConfig }); const areAttemptsExhausted = (msg) => msg.info.redeliveryCount === consumerConfig.max_deliver; const getExceededMs = (durationMs) => def.expectedMs ? durationMs - def.expectedMs : 0; const run = async () => { // Create stream // TODO: Maybe handle errors better // eslint-disable-next-line await createStream(conn, def).catch(() => { }); // Create pull consumer const ps = await createConsumer(conn, def); // Pull messages from the consumer puller = (0, util_1.repeater)(() => { emit('pull', { consumerConfig, batch, expires }); ps.pull({ batch, expires }); }, pullInterval); // Pull the next message(s) puller.start(); // Stopwatch const watch = (0, util_1.stopwatch)(); // Consume messages for await (const msg of ps) { // Don't pull while processing message(s) puller.stop(); const metadata = getMetadata(msg); emit('receive', metadata); deferred = (0, util_1.defer)(); // Auto-extend ack timeout const extendAckTimer = extendAckTimeout(msg); // Handle timeout const timeoutTimeout = handleTimeout(msg); // Start job stopwatch watch.start(); try { // Process the message await def.perform(msg, { signal: abortController.signal, def, js }); const durationMs = watch.stop(); emit('complete', { ...metadata, durationMs, exceededMs: getExceededMs(durationMs), }); // Ack message and wait for NATS to ack the ack const succeeded = await msg.ackAck(); // Ack failed if (!succeeded) { emit('noAck', metadata); } } catch (e) { const backoffMs = (0, util_1.getNextBackoff)(backoff, msg); const durationMs = watch.stop(); emit('error', { ...metadata, attemptsExhausted: areAttemptsExhausted(msg), durationMs, backoffMs, exceededMs: getExceededMs(durationMs), error: e, }); // Negative ack message with backoff msg.nak(backoffMs); } finally { // Clear timeout timeout clearTimeout(timeoutTimeout); // Clear ack_wait delay timer clearInterval(extendAckTimer); deferred.done(); } // Don't process any more messages if (stopping) { return; } // Pull the next message(s) puller.start(); } }; const stop = async () => { emit('stop', { consumerConfig }); // Set this to true so we don't process any more messages stopping = true; // Don't pull new messages puller?.stop(); // Send abort signal to perform abortController.abort('stop'); // Wait for current message to finish processing await deferred?.promise; }; // Track all stop fns so we can shutdown with one call stopFns.push(stop); // Start processing messages run(); return { /** * To be used in conjunction with SIGTERM and SIGINT. * * ```ts * const processor = await jobProcessor() * const stop = processor.start({}) * const shutDown = async () => { * await stop() * process.exit(0) * } * * process.on('SIGTERM', shutDown) * process.on('SIGINT', shutDown) * ``` */ stop, }; }; const stop = async () => { // Call stop on all jobs await Promise.all(stopFns.map((stop) => stop())); // Remove all event listeners emitter.removeAllListeners(); // Close NATS connection await conn.close(); }; return { /** * Call perform for each message received on the stream. */ start, /** * Call stop on all jobs and close the NATS connection. */ stop, emitter, }; }; exports.jobProcessor = jobProcessor;