@vytches/ddd-messaging 0.30.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const u=require("@vytches/ddd-logging"),d=require("@vytches/ddd-utils");var n=(i=>(i.PENDING="PENDING",i.PROCESSING="PROCESSING",i.PROCESSED="PROCESSED",i.FAILED="FAILED",i))(n||{}),l=(i=>(i.LOW="low",i.NORMAL="normal",i.HIGH="high",i.CRITICAL="critical",i))(l||{});const p=["critical","high","normal","low"];function y(i,e,t=p){const s=t.includes(i)?t.indexOf(i):t.length,r=t.includes(e)?t.indexOf(e):t.length;return s-r}class f{scheduleRetry(e,t){return Promise.resolve()}resetStaleProcessing(e){return Promise.resolve(0)}}class g{static createMessage(e,t,s){const r={id:d.LibUtils.getUUID(),messageType:e,payload:t,metadata:s?.metadata||{},status:n.PENDING,attempts:0,createdAt:new Date,priority:s?.priority||l.NORMAL};return s?.processAfter&&(r.processAfter=s.processAfter),r}static createDelayedMessage(e,t,s,r){const a=new Date(Date.now()+s);return g.createMessage(e,t,{...r,processAfter:a})}static createHighPriorityMessage(e,t,s){return g.createMessage(e,t,{...s,priority:l.HIGH})}static createFromIntegrationEvent(e,t){const s={metadata:{...e.metadata||{},...t?.metadata||{}}};return t?.priority&&(s.priority=t.priority),t?.processAfter&&(s.processAfter=t.processAfter),g.createMessage(`integration_event:${e.eventName}`,e.payload,s)}}class m{constructor(e,t={}){this.handlers=new Map,this.middlewares=[],this.isRunning=!1,this.logger=u.Logger.create("OutboxProcessor");const s=t.batchSize??10;if(s>1e4)throw new RangeError(`OutboxProcessor: batchSize must be ≤ 10,000, got ${s}`);if(t.retryBackoff){const{initial:o,multiplier:h,maxDelay:c}=t.retryBackoff;if(o<=0||h<=0||c<=0)throw new RangeError(`OutboxProcessor: retryBackoff fields must be positive numbers (initial=${o}, multiplier=${h}, maxDelay=${c})`)}const r=t.messageTimeout??3e4,a=t.crashRecoveryThresholdMs??Math.max(3e5,r);if(a<r)throw new RangeError(`OutboxProcessor: crashRecoveryThresholdMs (${a}) must be ≥ messageTimeout (${r}) to avoid resetting in-flight messages`);this.repository=e,this.options={batchSize:s,maxRetries:t.maxRetries??3,processingInterval:t.processingInterval??5e3,priorityOrder:t.priorityOrder??[l.CRITICAL,l.HIGH,l.NORMAL,l.LOW],messageTimeout:r,enableLogging:t.enableLogging??!1,retryBackoff:t.retryBackoff,messageTypes:t.messageTypes,crashRecoveryIntervalMs:t.crashRecoveryIntervalMs,crashRecoveryThresholdMs:a,adaptiveRepoll:t.adaptiveRepoll??!1,adaptiveRepollMaxConsecutive:t.adaptiveRepollMaxConsecutive??5,adaptiveRepollPauseMs:t.adaptiveRepollPauseMs??50,startupJitterMs:t.startupJitterMs??0,hooks:t.hooks}}safelyInvokeHook(e,t){if(this.options.hooks)try{t()}catch(s){const r=s instanceof Error?s.message:String(s);this.logger.error(`Outbox hook ${e} threw and was ignored: ${r}`)}}registerHandler(e,t){this.handlers.set(e,t),this.logger.debug(`Registered handler for message type: ${e}`)}registerDefaultHandler(e){this.defaultHandler=e}use(e){this.middlewares.push(e),this.logger.debug("Registered middleware")}start(){if(this.isRunning){this.logger.warn("Processor is already running");return}this.isRunning=!0,this.logger.info("Starting outbox processor");const e=this.options.startupJitterMs>0?Math.floor(Math.random()*this.options.startupJitterMs):0;this.scheduleProcessing(0,this.options.processingInterval+e),this.scheduleCrashRecovery()}stop(){if(!this.isRunning){this.logger.warn("Processor is not running");return}this.isRunning=!1,this.processingTimer&&(clearTimeout(this.processingTimer),this.processingTimer=void 0),this.crashRecoveryTimer&&(clearTimeout(this.crashRecoveryTimer),this.crashRecoveryTimer=void 0),this.logger.info("Stopped outbox processor")}async processBatch(){const e=this.options.batchSize;if(!this.isRunning)return{processed:0,batchSize:e};const[t,s]=await d.safeRun(()=>this.repository.getUnprocessedMessages(e,this.options.priorityOrder,this.options.messageTypes));if(t){const c=t instanceof Error?t.message:String(t);return this.logger.error(`Error retrieving messages: ${c}`),{processed:0,batchSize:e}}const r=s;if(!r||!Array.isArray(r)||r.length===0)return this.logger.debug("No pending messages to process"),{processed:0,batchSize:e};this.logger.info(`Processing ${r.length} messages`);const a=Date.now(),h=(await Promise.allSettled(r.map(c=>this.processMessage(c)))).filter(c=>c.status==="rejected"||c.status==="fulfilled"&&c.value===!1).length;return this.logger.info(`Completed processing batch of ${r.length} messages`),this.safelyInvokeHook("onBatchComplete",()=>this.options.hooks?.onBatchComplete?.({processed:r.length,failed:h,batchSize:e,durationMs:Date.now()-a})),{processed:r.length,batchSize:e}}async processMessage(e){const[t]=await d.safeRun(async()=>{await this.repository.updateStatus(e.id,n.PROCESSING),await this.buildPipeline(e)(e),await this.repository.updateStatus(e.id,n.PROCESSED),this.logger.debug(`Successfully processed message ${e.id}`)});return t?(await this.handleMessageError(e,t),!1):!0}buildPipeline(e){const t=this.handlers.get(e.messageType)??this.defaultHandler;if(!t)throw new Error(`No handler registered for message type: ${e.messageType}`);this.handlers.has(e.messageType)||this.logger.debug(`Default handler used for message type: ${e.messageType}`);const s=async r=>{await Promise.race([t.handle(r),new Promise((a,o)=>setTimeout(()=>o(new Error("Message processing timeout")),this.options.messageTimeout))])};return this.middlewares.reduceRight((r,a)=>a(r),s)}async handleMessageError(e,t){try{const s=await this.repository.incrementAttempt(e.id);if(this.logger.warn(`Message ${e.id} failed, attempt ${s}: ${t?.message}`),this.safelyInvokeHook("onMessageFailed",()=>this.options.hooks?.onMessageFailed?.(e,t,s)),s>=this.options.maxRetries)await this.repository.updateStatus(e.id,n.FAILED,t),this.logger.error(`Message ${e.id} marked as failed after ${s} attempts`),this.safelyInvokeHook("onPermanentFailure",()=>this.options.hooks?.onPermanentFailure?.(e,t));else if(this.options.retryBackoff){const{initial:r,multiplier:a,maxDelay:o}=this.options.retryBackoff,h=Math.min(r*Math.pow(a,s-1),o),c=new Date(Date.now()+h);await this.repository.updateStatus(e.id,n.PENDING),await this.repository.scheduleRetry(e.id,c),this.logger.info(`Message ${e.id} scheduled for retry in ${h}ms (attempt ${s})`)}else await this.repository.updateStatus(e.id,n.PENDING),this.logger.info(`Message ${e.id} scheduled for retry (attempt ${s+1})`)}catch(s){const r=s instanceof Error?s.message:String(s);this.logger.error(`Error updating message status: ${r}`)}}scheduleProcessing(e=0,t=this.options.processingInterval){this.isRunning&&(this.processingTimer=setTimeout(async()=>{const s=await this.processBatch();if(this.options.adaptiveRepoll&&s.processed>0&&s.processed>=s.batchSize){const a=e+1,o=a>this.options.adaptiveRepollMaxConsecutive?this.options.adaptiveRepollPauseMs:0;this.scheduleProcessing(a,o)}else this.scheduleProcessing(0,this.options.processingInterval)},t))}scheduleCrashRecovery(){!this.isRunning||this.options.crashRecoveryIntervalMs===void 0||(this.crashRecoveryTimer=setTimeout(async()=>{await this.runCrashRecovery(),this.scheduleCrashRecovery()},this.options.crashRecoveryIntervalMs))}async runCrashRecovery(){const e=new Date(Date.now()-this.options.crashRecoveryThresholdMs),[t,s]=await d.safeRun(()=>this.repository.resetStaleProcessing(e));if(t){const r=t instanceof Error?t.message:String(t);this.logger.error(`Crash recovery failed: ${r}`);return}s&&s>0&&this.logger.warn(`Crash recovery reset ${s} stale PROCESSING message(s) to PENDING`)}log(e){this.options.enableLogging&&this.logger.info(e)}async getStats(){return{isRunning:this.isRunning,batchSize:this.options.batchSize,maxRetries:this.options.maxRetries,processingInterval:this.options.processingInterval,registeredHandlers:Array.from(this.handlers.keys()),middlewareCount:this.middlewares.length,hasDefaultHandler:this.defaultHandler!==void 0}}}class v{constructor(e){this.eventBus=e}async handle(e){const s={eventName:e.messageType.startsWith("integration_event:")?e.messageType.replace("integration_event:",""):e.messageType,eventId:e.id,occurredOn:e.createdAt,version:1,...e.payload,...e.metadata};await this.eventBus.publish(s)}}class M{constructor(e,t={}){this.logger=u.Logger.create("OutboxService"),this.repository=e,this.options={defaultPriority:t.defaultPriority??l.NORMAL,defaultMetadata:t.defaultMetadata??{},autoCleanup:t.autoCleanup??!1,cleanupAfterDays:t.cleanupAfterDays??7,enableLogging:t.enableLogging??!1}}async saveMessage(e,t,s){const r=g.createMessage(e,t,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...s?.metadata},...s}),a=await this.repository.saveMessage(r);return this.logger.debug(`Saved message ${a} of type ${e}`),a}async saveMessages(e){const t=e.map(({messageType:r,payload:a,options:o})=>g.createMessage(r,a,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...o?.metadata},...o})),s=await this.repository.saveBatch(t);return this.logger.debug(`Saved batch of ${e.length} messages`),s}async scheduleMessage(e,t,s,r){const a=g.createDelayedMessage(e,t,s,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...r?.metadata},...r}),o=await this.repository.scheduleMessage(a,a.processAfter);return this.logger.debug(`Scheduled message ${o} of type ${e} for processing in ${s}ms`),o}async saveHighPriorityMessage(e,t,s){return this.saveMessage(e,t,{...s,priority:l.HIGH})}async saveCriticalMessage(e,t,s){return this.saveMessage(e,t,{...s,priority:l.CRITICAL})}async saveDomainEvent(e,t){const s=g.createFromIntegrationEvent({eventName:e.eventName,payload:e,metadata:{eventId:e?.metadata?.eventId,occurredOn:e?.metadata?.occurredOn,version:e?.metadata?.version}},{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...t?.metadata},...t}),r=await this.repository.saveMessage(s);return this.logger.debug(`Saved domain event ${e.eventName} as message ${r}`),r}async getMessage(e){return this.repository.getById(e)}async getPendingMessages(e=100){return this.repository.getUnprocessedMessages(e)}async retryMessage(e){await this.repository.updateStatus(e,n.PENDING),this.logger.info(`Reset message ${e} to pending for retry`)}async retryMessages(e){await this.repository.updateStatusBatch(e,n.PENDING),this.logger.info(`Reset ${e.length} messages to pending for retry`)}async cleanup(e){const t=e??this.options.cleanupAfterDays,s=new Date;s.setDate(s.getDate()-t);const r=await this.repository.deleteByStatusAndAge(s,n.PROCESSED);return this.logger.info(`Cleaned up ${r} processed messages older than ${t} days`),r}async getStats(){const e=this.repository;return e.getMessageCountByStatus?{totalPending:e.getMessageCountByStatus(n.PENDING),totalProcessing:e.getMessageCountByStatus(n.PROCESSING),totalProcessed:e.getMessageCountByStatus(n.PROCESSED),totalFailed:e.getMessageCountByStatus(n.FAILED)}:{totalPending:(await this.repository.getUnprocessedMessages(1e3)).length,totalProcessing:0,totalProcessed:0,totalFailed:0}}log(e){this.options.enableLogging&&this.logger.info(e)}}exports.EventBusOutboxHandler=v;exports.IOutboxRepository=f;exports.MessagePriority=l;exports.MessageStatus=n;exports.OutboxMessageFactory=g;exports.OutboxProcessor=m;exports.OutboxService=M;exports.comparePriority=y;
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const p=require("@vytches/ddd-utils"),c=require("@vytches/ddd-contracts/internal");var n=(i=>(i.PENDING="PENDING",i.PROCESSING="PROCESSING",i.PROCESSED="PROCESSED",i.FAILED="FAILED",i))(n||{}),l=(i=>(i.LOW="low",i.NORMAL="normal",i.HIGH="high",i.CRITICAL="critical",i))(l||{});const m=["critical","high","normal","low"];function f(i,e,t=m){const s=t.includes(i)?t.indexOf(i):t.length,a=t.includes(e)?t.indexOf(e):t.length;return s-a}class v{async claimBatch(e,t,s){const a=await this.getUnprocessedMessages(e,t,s);return a.length===0?a:(await this.updateStatusBatch(a.map(r=>r.id),n.PROCESSING),a.map(r=>({...r,status:n.PROCESSING})))}scheduleRetry(e,t){return Promise.resolve()}resetStaleProcessing(e){return Promise.resolve(0)}}class h{static createMessage(e,t,s){const a={id:p.LibUtils.getUUID(),messageType:e,payload:t,metadata:s?.metadata||{},status:n.PENDING,attempts:0,createdAt:new Date,priority:s?.priority||l.NORMAL};return s?.processAfter&&(a.processAfter=s.processAfter),a}static createDelayedMessage(e,t,s,a){const r=new Date(Date.now()+s);return h.createMessage(e,t,{...a,processAfter:r})}static createHighPriorityMessage(e,t,s){return h.createMessage(e,t,{...s,priority:l.HIGH})}static createFromIntegrationEvent(e,t){const s={metadata:{...e.metadata||{},...t?.metadata||{}}};return t?.priority&&(s.priority=t.priority),t?.processAfter&&(s.processAfter=t.processAfter),h.createMessage(`integration_event:${e.eventName}`,e.payload,s)}}class M{constructor(e,t={}){this.handlers=new Map,this.middlewares=[],this.isRunning=!1,this.warnedMissingClaimBatch=!1;const s=t.batchSize??10;if(s>1e4)throw new RangeError(`OutboxProcessor: batchSize must be ≤ 10,000, got ${s}`);if(t.retryBackoff){const{initial:o,multiplier:u,maxDelay:d}=t.retryBackoff;if(o<=0||u<=0||d<=0)throw new RangeError(`OutboxProcessor: retryBackoff fields must be positive numbers (initial=${o}, multiplier=${u}, maxDelay=${d})`)}const a=t.messageTimeout??3e4,r=t.crashRecoveryThresholdMs??Math.max(3e5,a);if(r<a)throw new RangeError(`OutboxProcessor: crashRecoveryThresholdMs (${r}) must be ≥ messageTimeout (${a}) to avoid resetting in-flight messages`);this.repository=e,this.options={batchSize:s,maxRetries:t.maxRetries??3,processingInterval:t.processingInterval??5e3,priorityOrder:t.priorityOrder??[l.CRITICAL,l.HIGH,l.NORMAL,l.LOW],messageTimeout:a,enableLogging:t.enableLogging??!1,retryBackoff:t.retryBackoff,messageTypes:t.messageTypes,crashRecoveryIntervalMs:t.crashRecoveryIntervalMs,crashRecoveryThresholdMs:r,adaptiveRepoll:t.adaptiveRepoll??!1,adaptiveRepollMaxConsecutive:t.adaptiveRepollMaxConsecutive??5,adaptiveRepollPauseMs:t.adaptiveRepollPauseMs??50,startupJitterMs:t.startupJitterMs??0,hooks:t.hooks,useClaimBatch:t.useClaimBatch??!1}}safelyInvokeHook(e,t){if(this.options.hooks)try{t()}catch(s){c.internalLogger.error(`OutboxProcessor: hook ${e} threw and was ignored`,s instanceof Error?s:void 0,{hookName:e})}}registerHandler(e,t){this.handlers.set(e,t)}registerDefaultHandler(e){this.defaultHandler&&c.internalLogger.warn("registerDefaultHandler: replacing existing default handler — previous handler discarded"),this.defaultHandler=e}use(e){this.middlewares.push(e)}start(){if(this.isRunning){c.internalLogger.warn("OutboxProcessor: processor is already running");return}this.isRunning=!0;const e=this.options.startupJitterMs>0?Math.floor(Math.random()*this.options.startupJitterMs):0;this.scheduleProcessing(0,this.options.processingInterval+e),this.scheduleCrashRecovery()}stop(){if(!this.isRunning){c.internalLogger.warn("OutboxProcessor: processor is not running");return}this.isRunning=!1,this.processingTimer&&(clearTimeout(this.processingTimer),this.processingTimer=void 0),this.crashRecoveryTimer&&(clearTimeout(this.crashRecoveryTimer),this.crashRecoveryTimer=void 0)}async processBatch(){const e=this.options.batchSize;if(!this.isRunning)return{processed:0,batchSize:e};const t=this.repository.claimBatch,s=this.options.useClaimBatch===!0&&typeof t=="function";this.options.useClaimBatch&&!s&&!this.warnedMissingClaimBatch&&(this.warnedMissingClaimBatch=!0,c.internalLogger.warn("OutboxProcessor: useClaimBatch is enabled but the repository does not implement claimBatch() — falling back to getUnprocessedMessages (no atomic-claim protection)"));const[a,r]=await p.safeRun(()=>s&&t?t.call(this.repository,e,this.options.priorityOrder,this.options.messageTypes):this.repository.getUnprocessedMessages(e,this.options.priorityOrder,this.options.messageTypes));if(a)return c.internalLogger.error("OutboxProcessor: error retrieving messages",a instanceof Error?a:void 0),{processed:0,batchSize:e};const o=r;if(!o||!Array.isArray(o)||o.length===0)return{processed:0,batchSize:e};const u=Date.now(),y=(await Promise.allSettled(o.map(g=>this.processMessage(g,s)))).filter(g=>g.status==="rejected"||g.status==="fulfilled"&&g.value===!1).length;return this.safelyInvokeHook("onBatchComplete",()=>this.options.hooks?.onBatchComplete?.({processed:o.length,failed:y,batchSize:e,durationMs:Date.now()-u})),{processed:o.length,batchSize:e}}async processMessage(e,t=!1){const[s]=await p.safeRun(async()=>{t||await this.repository.updateStatus(e.id,n.PROCESSING),await this.buildPipeline(e)(e),await this.repository.updateStatus(e.id,n.PROCESSED)});return s?(await this.handleMessageError(e,s),!1):!0}buildPipeline(e){const t=this.handlers.get(e.messageType)??this.defaultHandler;if(!t)throw new Error(`No handler registered for message type: ${e.messageType}`);const s=async a=>{let r;try{await Promise.race([t.handle(a),new Promise((o,u)=>{r=setTimeout(()=>u(new Error("Message processing timeout")),this.options.messageTimeout)})])}finally{r!==void 0&&clearTimeout(r)}};return this.middlewares.reduceRight((a,r)=>r(a),s)}async handleMessageError(e,t){try{const s=await this.repository.incrementAttempt(e.id);if(c.internalLogger.warn(`OutboxProcessor: message ${e.id} failed, attempt ${s}: ${p.LibUtils.sanitizeLogMessage(t?.message??String(t))}`),this.safelyInvokeHook("onMessageFailed",()=>this.options.hooks?.onMessageFailed?.(e,t,s)),s>=this.options.maxRetries)await this.repository.updateStatus(e.id,n.FAILED,t),c.internalLogger.error("OutboxProcessor: message permanently failed",t,{messageId:e.id,attempts:s}),this.safelyInvokeHook("onPermanentFailure",()=>this.options.hooks?.onPermanentFailure?.(e,t));else if(this.options.retryBackoff){const{initial:a,multiplier:r,maxDelay:o}=this.options.retryBackoff,u=Math.min(a*Math.pow(r,s-1),o),d=new Date(Date.now()+u);await this.repository.updateStatus(e.id,n.PENDING),await this.repository.scheduleRetry(e.id,d)}else await this.repository.updateStatus(e.id,n.PENDING)}catch(s){c.internalLogger.error("OutboxProcessor: error updating message status",s instanceof Error?s:void 0,{messageId:e.id})}}scheduleProcessing(e=0,t=this.options.processingInterval){this.isRunning&&(this.processingTimer=setTimeout(async()=>{const s=await this.processBatch();if(this.options.adaptiveRepoll&&s.processed>0&&s.processed>=s.batchSize){const r=e+1,o=r>this.options.adaptiveRepollMaxConsecutive?this.options.adaptiveRepollPauseMs:0;this.scheduleProcessing(r,o)}else this.scheduleProcessing(0,this.options.processingInterval)},t))}scheduleCrashRecovery(){!this.isRunning||this.options.crashRecoveryIntervalMs===void 0||(this.crashRecoveryTimer=setTimeout(async()=>{await this.runCrashRecovery(),this.scheduleCrashRecovery()},this.options.crashRecoveryIntervalMs))}async runCrashRecovery(){const e=new Date(Date.now()-this.options.crashRecoveryThresholdMs),[t,s]=await p.safeRun(()=>this.repository.resetStaleProcessing(e));if(t){c.internalLogger.error("OutboxProcessor: crash recovery failed",t instanceof Error?t:void 0);return}s&&s>0&&c.internalLogger.warn(`OutboxProcessor: crash recovery reset ${s} stale PROCESSING message(s) to PENDING`)}async getStats(){return{isRunning:this.isRunning,batchSize:this.options.batchSize,maxRetries:this.options.maxRetries,processingInterval:this.options.processingInterval,registeredHandlers:Array.from(this.handlers.keys()),middlewareCount:this.middlewares.length,hasDefaultHandler:this.defaultHandler!==void 0}}}class R{constructor(e){this.eventBus=e}async handle(e){const s={eventName:e.messageType.startsWith("integration_event:")?e.messageType.replace("integration_event:",""):e.messageType,eventId:e.id,occurredOn:e.createdAt,version:1,...e.payload,...e.metadata};await this.eventBus.publish(s)}}class P{constructor(e,t={}){this.repository=e,this.options={defaultPriority:t.defaultPriority??l.NORMAL,defaultMetadata:t.defaultMetadata??{},autoCleanup:t.autoCleanup??!1,cleanupAfterDays:t.cleanupAfterDays??7,enableLogging:t.enableLogging??!1}}async saveMessage(e,t,s){const a=h.createMessage(e,t,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...s?.metadata},...s});return await this.repository.saveMessage(a)}async saveMessages(e){const t=e.map(({messageType:a,payload:r,options:o})=>h.createMessage(a,r,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...o?.metadata},...o}));return await this.repository.saveBatch(t)}async scheduleMessage(e,t,s,a){const r=h.createDelayedMessage(e,t,s,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...a?.metadata},...a});return await this.repository.scheduleMessage(r,r.processAfter)}async saveHighPriorityMessage(e,t,s){return this.saveMessage(e,t,{...s,priority:l.HIGH})}async saveCriticalMessage(e,t,s){return this.saveMessage(e,t,{...s,priority:l.CRITICAL})}async saveDomainEvent(e,t){const s=h.createFromIntegrationEvent({eventName:e.eventName,payload:e,metadata:{eventId:e?.metadata?.eventId,occurredOn:e?.metadata?.occurredOn,version:e?.metadata?.version}},{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...t?.metadata},...t});return await this.repository.saveMessage(s)}async getMessage(e){return this.repository.getById(e)}async getPendingMessages(e=100){return this.repository.getUnprocessedMessages(e)}async retryMessage(e){await this.repository.updateStatus(e,n.PENDING)}async retryMessages(e){await this.repository.updateStatusBatch(e,n.PENDING)}async cleanup(e){const t=e??this.options.cleanupAfterDays,s=new Date;return s.setDate(s.getDate()-t),await this.repository.deleteByStatusAndAge(s,n.PROCESSED)}async getStats(){const e=this.repository;return e.getMessageCountByStatus?{totalPending:e.getMessageCountByStatus(n.PENDING),totalProcessing:e.getMessageCountByStatus(n.PROCESSING),totalProcessed:e.getMessageCountByStatus(n.PROCESSED),totalFailed:e.getMessageCountByStatus(n.FAILED)}:{totalPending:(await this.repository.getUnprocessedMessages(1e3)).length,totalProcessing:0,totalProcessed:0,totalFailed:0}}}exports.EventBusOutboxHandler=R;exports.IOutboxRepository=v;exports.MessagePriority=l;exports.MessageStatus=n;exports.OutboxMessageFactory=h;exports.OutboxProcessor=M;exports.OutboxService=P;exports.comparePriority=f;
|
package/dist/index.js
CHANGED
|
@@ -1,18 +1,59 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { LibUtils as y, safeRun as g } from "@vytches/ddd-utils";
|
|
2
|
+
import { internalLogger as c } from "@vytches/ddd-contracts/internal";
|
|
3
3
|
var n = /* @__PURE__ */ ((i) => (i.PENDING = "PENDING", i.PROCESSING = "PROCESSING", i.PROCESSED = "PROCESSED", i.FAILED = "FAILED", i))(n || {}), l = /* @__PURE__ */ ((i) => (i.LOW = "low", i.NORMAL = "normal", i.HIGH = "high", i.CRITICAL = "critical", i))(l || {});
|
|
4
|
-
const
|
|
4
|
+
const f = [
|
|
5
5
|
"critical",
|
|
6
6
|
"high",
|
|
7
7
|
"normal",
|
|
8
8
|
"low"
|
|
9
9
|
/* LOW */
|
|
10
10
|
];
|
|
11
|
-
function
|
|
12
|
-
const s = t.includes(i) ? t.indexOf(i) : t.length,
|
|
13
|
-
return s -
|
|
11
|
+
function R(i, e, t = f) {
|
|
12
|
+
const s = t.includes(i) ? t.indexOf(i) : t.length, a = t.includes(e) ? t.indexOf(e) : t.length;
|
|
13
|
+
return s - a;
|
|
14
14
|
}
|
|
15
|
-
class
|
|
15
|
+
class w {
|
|
16
|
+
/**
|
|
17
|
+
* Atomically claims a batch of unprocessed messages: reads PENDING
|
|
18
|
+
* messages *and* marks them `PROCESSING` in the same operation, so two
|
|
19
|
+
* concurrent callers (two processor instances, or two racing calls on one
|
|
20
|
+
* instance) never both receive the same message.
|
|
21
|
+
*
|
|
22
|
+
* **Optional, and non-atomic by default.** This method is declared with
|
|
23
|
+
* `?` — it is NOT part of the required abstract contract, so existing
|
|
24
|
+
* `IOutboxRepository` subclasses keep compiling unmodified (AC#5). The
|
|
25
|
+
* default implementation provided here is a convenience shim only: it
|
|
26
|
+
* calls {@link getUnprocessedMessages} and then `updateStatusBatch(...,
|
|
27
|
+
* PROCESSING)` as two separate steps. **That is NOT atomic** — it has the
|
|
28
|
+
* exact same read-then-mark race window as calling
|
|
29
|
+
* `getUnprocessedMessages` + `updateStatus` manually, and is safe only for
|
|
30
|
+
* a single-worker deployment. Override `claimBatch` in a concrete
|
|
31
|
+
* repository (e.g. backed by `SELECT ... FOR UPDATE SKIP LOCKED`, or a
|
|
32
|
+
* single atomic `UPDATE ... WHERE status='PENDING' LIMIT n RETURNING *`)
|
|
33
|
+
* to get real multi-worker/multi-processor safety.
|
|
34
|
+
*
|
|
35
|
+
* `OutboxProcessor` only calls this method when
|
|
36
|
+
* `OutboxProcessorOptions.useClaimBatch` is explicitly set to `true`
|
|
37
|
+
* (opt-in, default `false`) — see AC#1/AC#2. When enabled, the processor
|
|
38
|
+
* skips its own redundant `updateStatus(id, PROCESSING)` call for messages
|
|
39
|
+
* returned by `claimBatch`, since claiming already performed that mark.
|
|
40
|
+
*
|
|
41
|
+
* Handlers should be idempotent to side effects regardless — transient
|
|
42
|
+
* failures or duplicate-claim races (e.g. with the non-atomic default
|
|
43
|
+
* implementation) may still cause a handler to run more than once.
|
|
44
|
+
*
|
|
45
|
+
* @param limit - Maximum number of messages to claim.
|
|
46
|
+
* @param priorityOrder - See {@link getUnprocessedMessages}.
|
|
47
|
+
* @param messageTypes - See {@link getUnprocessedMessages}.
|
|
48
|
+
* @returns Messages that were claimed (already marked `PROCESSING`).
|
|
49
|
+
*/
|
|
50
|
+
async claimBatch(e, t, s) {
|
|
51
|
+
const a = await this.getUnprocessedMessages(e, t, s);
|
|
52
|
+
return a.length === 0 ? a : (await this.updateStatusBatch(
|
|
53
|
+
a.map((r) => r.id),
|
|
54
|
+
n.PROCESSING
|
|
55
|
+
), a.map((r) => ({ ...r, status: n.PROCESSING })));
|
|
56
|
+
}
|
|
16
57
|
/**
|
|
17
58
|
* Schedules a retry for a failed message at a specific future time.
|
|
18
59
|
*
|
|
@@ -54,7 +95,7 @@ class M {
|
|
|
54
95
|
return Promise.resolve(0);
|
|
55
96
|
}
|
|
56
97
|
}
|
|
57
|
-
class
|
|
98
|
+
class d {
|
|
58
99
|
/**
|
|
59
100
|
* Creates a new outbox message
|
|
60
101
|
* @param messageType Type of the message
|
|
@@ -63,8 +104,8 @@ class h {
|
|
|
63
104
|
* @returns Outbox message
|
|
64
105
|
*/
|
|
65
106
|
static createMessage(e, t, s) {
|
|
66
|
-
const
|
|
67
|
-
id:
|
|
107
|
+
const a = {
|
|
108
|
+
id: y.getUUID(),
|
|
68
109
|
messageType: e,
|
|
69
110
|
payload: t,
|
|
70
111
|
metadata: s?.metadata || {},
|
|
@@ -73,7 +114,7 @@ class h {
|
|
|
73
114
|
createdAt: /* @__PURE__ */ new Date(),
|
|
74
115
|
priority: s?.priority || l.NORMAL
|
|
75
116
|
};
|
|
76
|
-
return s?.processAfter && (
|
|
117
|
+
return s?.processAfter && (a.processAfter = s.processAfter), a;
|
|
77
118
|
}
|
|
78
119
|
/**
|
|
79
120
|
* Creates a delayed outbox message
|
|
@@ -83,11 +124,11 @@ class h {
|
|
|
83
124
|
* @param options Additional options
|
|
84
125
|
* @returns Outbox message scheduled for later processing
|
|
85
126
|
*/
|
|
86
|
-
static createDelayedMessage(e, t, s,
|
|
87
|
-
const
|
|
88
|
-
return
|
|
89
|
-
...
|
|
90
|
-
processAfter:
|
|
127
|
+
static createDelayedMessage(e, t, s, a) {
|
|
128
|
+
const r = new Date(Date.now() + s);
|
|
129
|
+
return d.createMessage(e, t, {
|
|
130
|
+
...a,
|
|
131
|
+
processAfter: r
|
|
91
132
|
});
|
|
92
133
|
}
|
|
93
134
|
/**
|
|
@@ -98,7 +139,7 @@ class h {
|
|
|
98
139
|
* @returns High priority outbox message
|
|
99
140
|
*/
|
|
100
141
|
static createHighPriorityMessage(e, t, s) {
|
|
101
|
-
return
|
|
142
|
+
return d.createMessage(e, t, {
|
|
102
143
|
...s,
|
|
103
144
|
priority: l.HIGH
|
|
104
145
|
});
|
|
@@ -116,30 +157,30 @@ class h {
|
|
|
116
157
|
...t?.metadata || {}
|
|
117
158
|
}
|
|
118
159
|
};
|
|
119
|
-
return t?.priority && (s.priority = t.priority), t?.processAfter && (s.processAfter = t.processAfter),
|
|
160
|
+
return t?.priority && (s.priority = t.priority), t?.processAfter && (s.processAfter = t.processAfter), d.createMessage(
|
|
120
161
|
`integration_event:${e.eventName}`,
|
|
121
162
|
e.payload,
|
|
122
163
|
s
|
|
123
164
|
);
|
|
124
165
|
}
|
|
125
166
|
}
|
|
126
|
-
class
|
|
167
|
+
class P {
|
|
127
168
|
constructor(e, t = {}) {
|
|
128
|
-
this.handlers = /* @__PURE__ */ new Map(), this.middlewares = [], this.isRunning = !1, this.
|
|
169
|
+
this.handlers = /* @__PURE__ */ new Map(), this.middlewares = [], this.isRunning = !1, this.warnedMissingClaimBatch = !1;
|
|
129
170
|
const s = t.batchSize ?? 10;
|
|
130
171
|
if (s > 1e4)
|
|
131
172
|
throw new RangeError(`OutboxProcessor: batchSize must be ≤ 10,000, got ${s}`);
|
|
132
173
|
if (t.retryBackoff) {
|
|
133
|
-
const { initial: o, multiplier:
|
|
134
|
-
if (o <= 0 ||
|
|
174
|
+
const { initial: o, multiplier: h, maxDelay: u } = t.retryBackoff;
|
|
175
|
+
if (o <= 0 || h <= 0 || u <= 0)
|
|
135
176
|
throw new RangeError(
|
|
136
|
-
`OutboxProcessor: retryBackoff fields must be positive numbers (initial=${o}, multiplier=${
|
|
177
|
+
`OutboxProcessor: retryBackoff fields must be positive numbers (initial=${o}, multiplier=${h}, maxDelay=${u})`
|
|
137
178
|
);
|
|
138
179
|
}
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
180
|
+
const a = t.messageTimeout ?? 3e4, r = t.crashRecoveryThresholdMs ?? Math.max(3e5, a);
|
|
181
|
+
if (r < a)
|
|
141
182
|
throw new RangeError(
|
|
142
|
-
`OutboxProcessor: crashRecoveryThresholdMs (${
|
|
183
|
+
`OutboxProcessor: crashRecoveryThresholdMs (${r}) must be ≥ messageTimeout (${a}) to avoid resetting in-flight messages`
|
|
143
184
|
);
|
|
144
185
|
this.repository = e, this.options = {
|
|
145
186
|
batchSize: s,
|
|
@@ -151,17 +192,18 @@ class R {
|
|
|
151
192
|
l.NORMAL,
|
|
152
193
|
l.LOW
|
|
153
194
|
],
|
|
154
|
-
messageTimeout:
|
|
195
|
+
messageTimeout: a,
|
|
155
196
|
enableLogging: t.enableLogging ?? !1,
|
|
156
197
|
retryBackoff: t.retryBackoff,
|
|
157
198
|
messageTypes: t.messageTypes,
|
|
158
199
|
crashRecoveryIntervalMs: t.crashRecoveryIntervalMs,
|
|
159
|
-
crashRecoveryThresholdMs:
|
|
200
|
+
crashRecoveryThresholdMs: r,
|
|
160
201
|
adaptiveRepoll: t.adaptiveRepoll ?? !1,
|
|
161
202
|
adaptiveRepollMaxConsecutive: t.adaptiveRepollMaxConsecutive ?? 5,
|
|
162
203
|
adaptiveRepollPauseMs: t.adaptiveRepollPauseMs ?? 50,
|
|
163
204
|
startupJitterMs: t.startupJitterMs ?? 0,
|
|
164
|
-
hooks: t.hooks
|
|
205
|
+
hooks: t.hooks,
|
|
206
|
+
useClaimBatch: t.useClaimBatch ?? !1
|
|
165
207
|
};
|
|
166
208
|
}
|
|
167
209
|
/**
|
|
@@ -173,15 +215,18 @@ class R {
|
|
|
173
215
|
try {
|
|
174
216
|
t();
|
|
175
217
|
} catch (s) {
|
|
176
|
-
|
|
177
|
-
|
|
218
|
+
c.error(
|
|
219
|
+
`OutboxProcessor: hook ${e} threw and was ignored`,
|
|
220
|
+
s instanceof Error ? s : void 0,
|
|
221
|
+
{ hookName: e }
|
|
222
|
+
);
|
|
178
223
|
}
|
|
179
224
|
}
|
|
180
225
|
/**
|
|
181
226
|
* Registers a message handler for a specific message type
|
|
182
227
|
*/
|
|
183
228
|
registerHandler(e, t) {
|
|
184
|
-
this.handlers.set(e, t)
|
|
229
|
+
this.handlers.set(e, t);
|
|
185
230
|
}
|
|
186
231
|
/**
|
|
187
232
|
* Registers a catch-all handler invoked when no type-specific handler matches.
|
|
@@ -196,27 +241,28 @@ class R {
|
|
|
196
241
|
* only types that pass through that filter. To handle all types, leave
|
|
197
242
|
* `messageTypes` unset.
|
|
198
243
|
*
|
|
199
|
-
*
|
|
200
|
-
* handler (idempotent).
|
|
244
|
+
* Second/subsequent call emits a warning and replaces the previous handler.
|
|
201
245
|
*/
|
|
202
246
|
registerDefaultHandler(e) {
|
|
203
|
-
this.defaultHandler
|
|
247
|
+
this.defaultHandler && c.warn(
|
|
248
|
+
"registerDefaultHandler: replacing existing default handler — previous handler discarded"
|
|
249
|
+
), this.defaultHandler = e;
|
|
204
250
|
}
|
|
205
251
|
/**
|
|
206
252
|
* Registers middleware for message processing pipeline
|
|
207
253
|
*/
|
|
208
254
|
use(e) {
|
|
209
|
-
this.middlewares.push(e)
|
|
255
|
+
this.middlewares.push(e);
|
|
210
256
|
}
|
|
211
257
|
/**
|
|
212
258
|
* Starts the processor with automatic interval processing
|
|
213
259
|
*/
|
|
214
260
|
start() {
|
|
215
261
|
if (this.isRunning) {
|
|
216
|
-
|
|
262
|
+
c.warn("OutboxProcessor: processor is already running");
|
|
217
263
|
return;
|
|
218
264
|
}
|
|
219
|
-
this.isRunning = !0
|
|
265
|
+
this.isRunning = !0;
|
|
220
266
|
const e = this.options.startupJitterMs > 0 ? Math.floor(Math.random() * this.options.startupJitterMs) : 0;
|
|
221
267
|
this.scheduleProcessing(0, this.options.processingInterval + e), this.scheduleCrashRecovery();
|
|
222
268
|
}
|
|
@@ -225,10 +271,10 @@ class R {
|
|
|
225
271
|
*/
|
|
226
272
|
stop() {
|
|
227
273
|
if (!this.isRunning) {
|
|
228
|
-
|
|
274
|
+
c.warn("OutboxProcessor: processor is not running");
|
|
229
275
|
return;
|
|
230
276
|
}
|
|
231
|
-
this.isRunning = !1, this.processingTimer && (clearTimeout(this.processingTimer), this.processingTimer = void 0), this.crashRecoveryTimer && (clearTimeout(this.crashRecoveryTimer), this.crashRecoveryTimer = void 0)
|
|
277
|
+
this.isRunning = !1, this.processingTimer && (clearTimeout(this.processingTimer), this.processingTimer = void 0), this.crashRecoveryTimer && (clearTimeout(this.crashRecoveryTimer), this.crashRecoveryTimer = void 0);
|
|
232
278
|
}
|
|
233
279
|
/**
|
|
234
280
|
* Processes a single batch of pending messages.
|
|
@@ -263,45 +309,57 @@ class R {
|
|
|
263
309
|
const e = this.options.batchSize;
|
|
264
310
|
if (!this.isRunning)
|
|
265
311
|
return { processed: 0, batchSize: e };
|
|
266
|
-
const
|
|
267
|
-
|
|
312
|
+
const t = this.repository.claimBatch, s = this.options.useClaimBatch === !0 && typeof t == "function";
|
|
313
|
+
this.options.useClaimBatch && !s && !this.warnedMissingClaimBatch && (this.warnedMissingClaimBatch = !0, c.warn(
|
|
314
|
+
"OutboxProcessor: useClaimBatch is enabled but the repository does not implement claimBatch() — falling back to getUnprocessedMessages (no atomic-claim protection)"
|
|
315
|
+
));
|
|
316
|
+
const [a, r] = await g(
|
|
317
|
+
() => s && t ? t.call(
|
|
318
|
+
this.repository,
|
|
319
|
+
e,
|
|
320
|
+
this.options.priorityOrder,
|
|
321
|
+
this.options.messageTypes
|
|
322
|
+
) : this.repository.getUnprocessedMessages(
|
|
268
323
|
e,
|
|
269
324
|
this.options.priorityOrder,
|
|
270
325
|
this.options.messageTypes
|
|
271
326
|
)
|
|
272
327
|
);
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
328
|
+
if (a)
|
|
329
|
+
return c.error(
|
|
330
|
+
"OutboxProcessor: error retrieving messages",
|
|
331
|
+
a instanceof Error ? a : void 0
|
|
332
|
+
), { processed: 0, batchSize: e };
|
|
333
|
+
const o = r;
|
|
334
|
+
if (!o || !Array.isArray(o) || o.length === 0)
|
|
335
|
+
return { processed: 0, batchSize: e };
|
|
336
|
+
const h = Date.now(), m = (await Promise.allSettled(
|
|
337
|
+
o.map((p) => this.processMessage(p, s))
|
|
283
338
|
)).filter(
|
|
284
|
-
(
|
|
339
|
+
(p) => p.status === "rejected" || p.status === "fulfilled" && p.value === !1
|
|
285
340
|
).length;
|
|
286
|
-
return this.
|
|
341
|
+
return this.safelyInvokeHook(
|
|
287
342
|
"onBatchComplete",
|
|
288
343
|
() => this.options.hooks?.onBatchComplete?.({
|
|
289
|
-
processed:
|
|
290
|
-
failed:
|
|
344
|
+
processed: o.length,
|
|
345
|
+
failed: m,
|
|
291
346
|
batchSize: e,
|
|
292
|
-
durationMs: Date.now() -
|
|
347
|
+
durationMs: Date.now() - h
|
|
293
348
|
})
|
|
294
|
-
), { processed:
|
|
349
|
+
), { processed: o.length, batchSize: e };
|
|
295
350
|
}
|
|
296
351
|
/**
|
|
297
352
|
* Processes a single message through the middleware pipeline.
|
|
353
|
+
* @param alreadyClaimed When `true`, the message was already marked
|
|
354
|
+
* `PROCESSING` by `IOutboxRepository.claimBatch()` — the redundant
|
|
355
|
+
* `updateStatus(id, PROCESSING)` call is skipped.
|
|
298
356
|
* @returns `true` if the message was processed successfully, `false` if it failed.
|
|
299
357
|
*/
|
|
300
|
-
async processMessage(e) {
|
|
301
|
-
const [
|
|
302
|
-
await this.repository.updateStatus(e.id, n.PROCESSING), await this.buildPipeline(e)(e), await this.repository.updateStatus(e.id, n.PROCESSED)
|
|
358
|
+
async processMessage(e, t = !1) {
|
|
359
|
+
const [s] = await g(async () => {
|
|
360
|
+
t || await this.repository.updateStatus(e.id, n.PROCESSING), await this.buildPipeline(e)(e), await this.repository.updateStatus(e.id, n.PROCESSED);
|
|
303
361
|
});
|
|
304
|
-
return
|
|
362
|
+
return s ? (await this.handleMessageError(e, s), !1) : !0;
|
|
305
363
|
}
|
|
306
364
|
/**
|
|
307
365
|
* Builds the middleware pipeline for message processing
|
|
@@ -310,19 +368,23 @@ class R {
|
|
|
310
368
|
const t = this.handlers.get(e.messageType) ?? this.defaultHandler;
|
|
311
369
|
if (!t)
|
|
312
370
|
throw new Error(`No handler registered for message type: ${e.messageType}`);
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
(
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
371
|
+
const s = async (a) => {
|
|
372
|
+
let r;
|
|
373
|
+
try {
|
|
374
|
+
await Promise.race([
|
|
375
|
+
t.handle(a),
|
|
376
|
+
new Promise((o, h) => {
|
|
377
|
+
r = setTimeout(
|
|
378
|
+
() => h(new Error("Message processing timeout")),
|
|
379
|
+
this.options.messageTimeout
|
|
380
|
+
);
|
|
381
|
+
})
|
|
382
|
+
]);
|
|
383
|
+
} finally {
|
|
384
|
+
r !== void 0 && clearTimeout(r);
|
|
385
|
+
}
|
|
324
386
|
};
|
|
325
|
-
return this.middlewares.reduceRight((
|
|
387
|
+
return this.middlewares.reduceRight((a, r) => r(a), s);
|
|
326
388
|
}
|
|
327
389
|
/**
|
|
328
390
|
* Handles errors during message processing
|
|
@@ -330,24 +392,30 @@ class R {
|
|
|
330
392
|
async handleMessageError(e, t) {
|
|
331
393
|
try {
|
|
332
394
|
const s = await this.repository.incrementAttempt(e.id);
|
|
333
|
-
if (
|
|
395
|
+
if (c.warn(
|
|
396
|
+
`OutboxProcessor: message ${e.id} failed, attempt ${s}: ${y.sanitizeLogMessage(t?.message ?? String(t))}`
|
|
397
|
+
), this.safelyInvokeHook(
|
|
334
398
|
"onMessageFailed",
|
|
335
399
|
() => this.options.hooks?.onMessageFailed?.(e, t, s)
|
|
336
400
|
), s >= this.options.maxRetries)
|
|
337
|
-
await this.repository.updateStatus(e.id, n.FAILED, t),
|
|
401
|
+
await this.repository.updateStatus(e.id, n.FAILED, t), c.error("OutboxProcessor: message permanently failed", t, {
|
|
402
|
+
messageId: e.id,
|
|
403
|
+
attempts: s
|
|
404
|
+
}), this.safelyInvokeHook(
|
|
338
405
|
"onPermanentFailure",
|
|
339
406
|
() => this.options.hooks?.onPermanentFailure?.(e, t)
|
|
340
407
|
);
|
|
341
408
|
else if (this.options.retryBackoff) {
|
|
342
|
-
const { initial:
|
|
343
|
-
await this.repository.updateStatus(e.id, n.PENDING), await this.repository.scheduleRetry(e.id,
|
|
344
|
-
`Message ${e.id} scheduled for retry in ${g}ms (attempt ${s})`
|
|
345
|
-
);
|
|
409
|
+
const { initial: a, multiplier: r, maxDelay: o } = this.options.retryBackoff, h = Math.min(a * Math.pow(r, s - 1), o), u = new Date(Date.now() + h);
|
|
410
|
+
await this.repository.updateStatus(e.id, n.PENDING), await this.repository.scheduleRetry(e.id, u);
|
|
346
411
|
} else
|
|
347
|
-
await this.repository.updateStatus(e.id, n.PENDING)
|
|
412
|
+
await this.repository.updateStatus(e.id, n.PENDING);
|
|
348
413
|
} catch (s) {
|
|
349
|
-
|
|
350
|
-
|
|
414
|
+
c.error(
|
|
415
|
+
"OutboxProcessor: error updating message status",
|
|
416
|
+
s instanceof Error ? s : void 0,
|
|
417
|
+
{ messageId: e.id }
|
|
418
|
+
);
|
|
351
419
|
}
|
|
352
420
|
}
|
|
353
421
|
/**
|
|
@@ -366,8 +434,8 @@ class R {
|
|
|
366
434
|
this.isRunning && (this.processingTimer = setTimeout(async () => {
|
|
367
435
|
const s = await this.processBatch();
|
|
368
436
|
if (this.options.adaptiveRepoll && s.processed > 0 && s.processed >= s.batchSize) {
|
|
369
|
-
const
|
|
370
|
-
this.scheduleProcessing(
|
|
437
|
+
const r = e + 1, o = r > this.options.adaptiveRepollMaxConsecutive ? this.options.adaptiveRepollPauseMs : 0;
|
|
438
|
+
this.scheduleProcessing(r, o);
|
|
371
439
|
} else
|
|
372
440
|
this.scheduleProcessing(0, this.options.processingInterval);
|
|
373
441
|
}, t));
|
|
@@ -387,19 +455,17 @@ class R {
|
|
|
387
455
|
* logged, never thrown — crash recovery must not crash the processor.
|
|
388
456
|
*/
|
|
389
457
|
async runCrashRecovery() {
|
|
390
|
-
const e = new Date(Date.now() - this.options.crashRecoveryThresholdMs), [t, s] = await
|
|
458
|
+
const e = new Date(Date.now() - this.options.crashRecoveryThresholdMs), [t, s] = await g(() => this.repository.resetStaleProcessing(e));
|
|
391
459
|
if (t) {
|
|
392
|
-
|
|
393
|
-
|
|
460
|
+
c.error(
|
|
461
|
+
"OutboxProcessor: crash recovery failed",
|
|
462
|
+
t instanceof Error ? t : void 0
|
|
463
|
+
);
|
|
394
464
|
return;
|
|
395
465
|
}
|
|
396
|
-
s && s > 0 &&
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
* Logs messages if logging is enabled
|
|
400
|
-
*/
|
|
401
|
-
log(e) {
|
|
402
|
-
this.options.enableLogging && this.logger.info(e);
|
|
466
|
+
s && s > 0 && c.warn(
|
|
467
|
+
`OutboxProcessor: crash recovery reset ${s} stale PROCESSING message(s) to PENDING`
|
|
468
|
+
);
|
|
403
469
|
}
|
|
404
470
|
/**
|
|
405
471
|
* Gets processor statistics
|
|
@@ -416,7 +482,7 @@ class R {
|
|
|
416
482
|
};
|
|
417
483
|
}
|
|
418
484
|
}
|
|
419
|
-
class
|
|
485
|
+
class I {
|
|
420
486
|
constructor(e) {
|
|
421
487
|
this.eventBus = e;
|
|
422
488
|
}
|
|
@@ -432,9 +498,9 @@ class w {
|
|
|
432
498
|
await this.eventBus.publish(s);
|
|
433
499
|
}
|
|
434
500
|
}
|
|
435
|
-
class
|
|
501
|
+
class S {
|
|
436
502
|
constructor(e, t = {}) {
|
|
437
|
-
this.
|
|
503
|
+
this.repository = e, this.options = {
|
|
438
504
|
defaultPriority: t.defaultPriority ?? l.NORMAL,
|
|
439
505
|
defaultMetadata: t.defaultMetadata ?? {},
|
|
440
506
|
autoCleanup: t.autoCleanup ?? !1,
|
|
@@ -446,38 +512,36 @@ class P {
|
|
|
446
512
|
* Saves a message to the outbox
|
|
447
513
|
*/
|
|
448
514
|
async saveMessage(e, t, s) {
|
|
449
|
-
const
|
|
515
|
+
const a = d.createMessage(e, t, {
|
|
450
516
|
priority: this.options.defaultPriority,
|
|
451
517
|
metadata: { ...this.options.defaultMetadata, ...s?.metadata },
|
|
452
518
|
...s
|
|
453
|
-
})
|
|
454
|
-
return this.
|
|
519
|
+
});
|
|
520
|
+
return await this.repository.saveMessage(a);
|
|
455
521
|
}
|
|
456
522
|
/**
|
|
457
523
|
* Saves multiple messages to the outbox in a batch
|
|
458
524
|
*/
|
|
459
525
|
async saveMessages(e) {
|
|
460
526
|
const t = e.map(
|
|
461
|
-
({ messageType:
|
|
527
|
+
({ messageType: a, payload: r, options: o }) => d.createMessage(a, r, {
|
|
462
528
|
priority: this.options.defaultPriority,
|
|
463
529
|
metadata: { ...this.options.defaultMetadata, ...o?.metadata },
|
|
464
530
|
...o
|
|
465
531
|
})
|
|
466
|
-
)
|
|
467
|
-
return this.
|
|
532
|
+
);
|
|
533
|
+
return await this.repository.saveBatch(t);
|
|
468
534
|
}
|
|
469
535
|
/**
|
|
470
536
|
* Schedules a message for delayed processing
|
|
471
537
|
*/
|
|
472
|
-
async scheduleMessage(e, t, s,
|
|
473
|
-
const
|
|
538
|
+
async scheduleMessage(e, t, s, a) {
|
|
539
|
+
const r = d.createDelayedMessage(e, t, s, {
|
|
474
540
|
priority: this.options.defaultPriority,
|
|
475
|
-
metadata: { ...this.options.defaultMetadata, ...
|
|
476
|
-
...
|
|
477
|
-
})
|
|
478
|
-
return this.
|
|
479
|
-
`Scheduled message ${o} of type ${e} for processing in ${s}ms`
|
|
480
|
-
), o;
|
|
541
|
+
metadata: { ...this.options.defaultMetadata, ...a?.metadata },
|
|
542
|
+
...a
|
|
543
|
+
});
|
|
544
|
+
return await this.repository.scheduleMessage(r, r.processAfter);
|
|
481
545
|
}
|
|
482
546
|
/**
|
|
483
547
|
* Saves a high priority message to the outbox
|
|
@@ -501,7 +565,7 @@ class P {
|
|
|
501
565
|
* Converts a domain event to an outbox message and saves it
|
|
502
566
|
*/
|
|
503
567
|
async saveDomainEvent(e, t) {
|
|
504
|
-
const s =
|
|
568
|
+
const s = d.createFromIntegrationEvent(
|
|
505
569
|
{
|
|
506
570
|
eventName: e.eventName,
|
|
507
571
|
payload: e,
|
|
@@ -516,8 +580,8 @@ class P {
|
|
|
516
580
|
metadata: { ...this.options.defaultMetadata, ...t?.metadata },
|
|
517
581
|
...t
|
|
518
582
|
}
|
|
519
|
-
)
|
|
520
|
-
return this.
|
|
583
|
+
);
|
|
584
|
+
return await this.repository.saveMessage(s);
|
|
521
585
|
}
|
|
522
586
|
/**
|
|
523
587
|
* Gets a message by ID
|
|
@@ -535,25 +599,23 @@ class P {
|
|
|
535
599
|
* Retries a failed message by resetting it to pending status
|
|
536
600
|
*/
|
|
537
601
|
async retryMessage(e) {
|
|
538
|
-
await this.repository.updateStatus(e, n.PENDING)
|
|
602
|
+
await this.repository.updateStatus(e, n.PENDING);
|
|
539
603
|
}
|
|
540
604
|
/**
|
|
541
605
|
* Retries multiple failed messages
|
|
542
606
|
*/
|
|
543
607
|
async retryMessages(e) {
|
|
544
|
-
await this.repository.updateStatusBatch(e, n.PENDING)
|
|
608
|
+
await this.repository.updateStatusBatch(e, n.PENDING);
|
|
545
609
|
}
|
|
546
610
|
/**
|
|
547
611
|
* Cleans up old processed messages
|
|
548
612
|
*/
|
|
549
613
|
async cleanup(e) {
|
|
550
614
|
const t = e ?? this.options.cleanupAfterDays, s = /* @__PURE__ */ new Date();
|
|
551
|
-
s.setDate(s.getDate() - t)
|
|
552
|
-
const r = await this.repository.deleteByStatusAndAge(
|
|
615
|
+
return s.setDate(s.getDate() - t), await this.repository.deleteByStatusAndAge(
|
|
553
616
|
s,
|
|
554
617
|
n.PROCESSED
|
|
555
618
|
);
|
|
556
|
-
return this.logger.info(`Cleaned up ${r} processed messages older than ${t} days`), r;
|
|
557
619
|
}
|
|
558
620
|
/**
|
|
559
621
|
* Gets outbox statistics
|
|
@@ -575,20 +637,14 @@ class P {
|
|
|
575
637
|
// Would need separate query
|
|
576
638
|
};
|
|
577
639
|
}
|
|
578
|
-
/**
|
|
579
|
-
* Logs messages if logging is enabled
|
|
580
|
-
*/
|
|
581
|
-
log(e) {
|
|
582
|
-
this.options.enableLogging && this.logger.info(e);
|
|
583
|
-
}
|
|
584
640
|
}
|
|
585
641
|
export {
|
|
586
|
-
|
|
587
|
-
|
|
642
|
+
I as EventBusOutboxHandler,
|
|
643
|
+
w as IOutboxRepository,
|
|
588
644
|
l as MessagePriority,
|
|
589
645
|
n as MessageStatus,
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
646
|
+
d as OutboxMessageFactory,
|
|
647
|
+
P as OutboxProcessor,
|
|
648
|
+
S as OutboxService,
|
|
649
|
+
R as comparePriority
|
|
594
650
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IEventBus } from '
|
|
1
|
+
import { IEventBus } from '@vytches/ddd-contracts';
|
|
2
2
|
import { IOutboxMessage, IOutboxMessageHandler, OutboxMiddleware, MessagePriority } from './outbox-interfaces';
|
|
3
3
|
import { IOutboxRepository } from './outbox-repository.interface';
|
|
4
4
|
import { OutboxProcessorHooks } from './outbox-hooks';
|
|
@@ -80,6 +80,30 @@ export interface OutboxProcessorOptions {
|
|
|
80
80
|
* logged and never breaks the processing loop. Omit to disable.
|
|
81
81
|
*/
|
|
82
82
|
hooks?: OutboxProcessorHooks;
|
|
83
|
+
/**
|
|
84
|
+
* Opt-in: when `true`, `processBatch` calls `IOutboxRepository.claimBatch()`
|
|
85
|
+
* instead of `getUnprocessedMessages()`, and `processMessage` skips its own
|
|
86
|
+
* (now redundant) `updateStatus(id, PROCESSING)` call for messages obtained
|
|
87
|
+
* this way — the repository's `claimBatch` is responsible for the
|
|
88
|
+
* PROCESSING mark.
|
|
89
|
+
*
|
|
90
|
+
* **Correctness depends on the repository.** `claimBatch` is an optional
|
|
91
|
+
* method on `IOutboxRepository` with a non-atomic default implementation
|
|
92
|
+
* (read then mark, two steps). Setting `useClaimBatch: true` against a
|
|
93
|
+
* repository that has NOT overridden `claimBatch` with a real atomic
|
|
94
|
+
* claim (e.g. `SELECT ... FOR UPDATE SKIP LOCKED`) provides **no**
|
|
95
|
+
* additional safety over the default `getUnprocessedMessages` path — it
|
|
96
|
+
* only prevents duplicate dispatch when the repository's `claimBatch` is
|
|
97
|
+
* genuinely atomic.
|
|
98
|
+
*
|
|
99
|
+
* If the repository does not implement `claimBatch` at all (the method is
|
|
100
|
+
* `undefined`, e.g. a hand-rolled repository predating this option), the
|
|
101
|
+
* processor logs a warning once and falls back to
|
|
102
|
+
* `getUnprocessedMessages` for that batch.
|
|
103
|
+
*
|
|
104
|
+
* Defaults to `false` — no behavior change for existing consumers (AC#5).
|
|
105
|
+
*/
|
|
106
|
+
useClaimBatch?: boolean;
|
|
83
107
|
}
|
|
84
108
|
export declare class OutboxProcessor {
|
|
85
109
|
private readonly repository;
|
|
@@ -90,7 +114,8 @@ export declare class OutboxProcessor {
|
|
|
90
114
|
private isRunning;
|
|
91
115
|
private processingTimer?;
|
|
92
116
|
private crashRecoveryTimer?;
|
|
93
|
-
|
|
117
|
+
/** Guards the one-time fallback warning when `useClaimBatch` is set but the repository has no `claimBatch`. */
|
|
118
|
+
private warnedMissingClaimBatch;
|
|
94
119
|
constructor(repository: IOutboxRepository, options?: OutboxProcessorOptions);
|
|
95
120
|
/**
|
|
96
121
|
* Invokes an observability hook in isolation. A throwing hook is logged and
|
|
@@ -114,8 +139,7 @@ export declare class OutboxProcessor {
|
|
|
114
139
|
* only types that pass through that filter. To handle all types, leave
|
|
115
140
|
* `messageTypes` unset.
|
|
116
141
|
*
|
|
117
|
-
*
|
|
118
|
-
* handler (idempotent).
|
|
142
|
+
* Second/subsequent call emits a warning and replaces the previous handler.
|
|
119
143
|
*/
|
|
120
144
|
registerDefaultHandler(handler: IOutboxMessageHandler): void;
|
|
121
145
|
/**
|
|
@@ -165,6 +189,9 @@ export declare class OutboxProcessor {
|
|
|
165
189
|
}>;
|
|
166
190
|
/**
|
|
167
191
|
* Processes a single message through the middleware pipeline.
|
|
192
|
+
* @param alreadyClaimed When `true`, the message was already marked
|
|
193
|
+
* `PROCESSING` by `IOutboxRepository.claimBatch()` — the redundant
|
|
194
|
+
* `updateStatus(id, PROCESSING)` call is skipped.
|
|
168
195
|
* @returns `true` if the message was processed successfully, `false` if it failed.
|
|
169
196
|
*/
|
|
170
197
|
private processMessage;
|
|
@@ -200,10 +227,6 @@ export declare class OutboxProcessor {
|
|
|
200
227
|
* logged, never thrown — crash recovery must not crash the processor.
|
|
201
228
|
*/
|
|
202
229
|
private runCrashRecovery;
|
|
203
|
-
/**
|
|
204
|
-
* Logs messages if logging is enabled
|
|
205
|
-
*/
|
|
206
|
-
private log;
|
|
207
230
|
/**
|
|
208
231
|
* Gets processor statistics
|
|
209
232
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IOutboxMessage, MessagePriority
|
|
1
|
+
import { MessageStatus, IOutboxMessage, MessagePriority } from './outbox-interfaces';
|
|
2
2
|
export declare abstract class IOutboxRepository {
|
|
3
3
|
/**
|
|
4
4
|
* Saves a message to the outbox
|
|
@@ -15,6 +15,23 @@ export declare abstract class IOutboxRepository {
|
|
|
15
15
|
/**
|
|
16
16
|
* Returns unprocessed messages up to the given limit.
|
|
17
17
|
*
|
|
18
|
+
* **Not a claim.** This is a pure read — it does not mark returned messages
|
|
19
|
+
* as `PROCESSING` or otherwise reserve them. `OutboxProcessor` calls
|
|
20
|
+
* `updateStatus(id, PROCESSING)` for each message *after* this read
|
|
21
|
+
* returns, which means there is a window between "read" and "claimed"
|
|
22
|
+
* during which two concurrent processors (or two calls racing on the same
|
|
23
|
+
* processor) can both read the same PENDING message and both dispatch it.
|
|
24
|
+
* Multi-worker deployments that need "exactly one processor claims each
|
|
25
|
+
* message" should implement {@link claimBatch} with an atomic
|
|
26
|
+
* read+mark (e.g. `SELECT ... FOR UPDATE SKIP LOCKED` or a single
|
|
27
|
+
* `UPDATE ... WHERE status='PENDING' RETURNING *` statement / compare-
|
|
28
|
+
* and-swap on `status`) rather than relying on this method plus a
|
|
29
|
+
* follow-up `updateStatus`.
|
|
30
|
+
*
|
|
31
|
+
* Regardless of claim strategy, handlers should be idempotent to side
|
|
32
|
+
* effects — transient failures or duplicate-claim races may cause a
|
|
33
|
+
* handler to run more than once.
|
|
34
|
+
*
|
|
18
35
|
* @param limit - Maximum number of messages to return.
|
|
19
36
|
* @param priorityOrder - Ordered array of MessagePriority values defining
|
|
20
37
|
* processing order (first element = highest priority, processed first).
|
|
@@ -28,6 +45,41 @@ export declare abstract class IOutboxRepository {
|
|
|
28
45
|
* returned. When absent, all message types are returned.
|
|
29
46
|
*/
|
|
30
47
|
abstract getUnprocessedMessages(limit?: number, priorityOrder?: MessagePriority[], messageTypes?: string[]): Promise<IOutboxMessage[]>;
|
|
48
|
+
/**
|
|
49
|
+
* Atomically claims a batch of unprocessed messages: reads PENDING
|
|
50
|
+
* messages *and* marks them `PROCESSING` in the same operation, so two
|
|
51
|
+
* concurrent callers (two processor instances, or two racing calls on one
|
|
52
|
+
* instance) never both receive the same message.
|
|
53
|
+
*
|
|
54
|
+
* **Optional, and non-atomic by default.** This method is declared with
|
|
55
|
+
* `?` — it is NOT part of the required abstract contract, so existing
|
|
56
|
+
* `IOutboxRepository` subclasses keep compiling unmodified (AC#5). The
|
|
57
|
+
* default implementation provided here is a convenience shim only: it
|
|
58
|
+
* calls {@link getUnprocessedMessages} and then `updateStatusBatch(...,
|
|
59
|
+
* PROCESSING)` as two separate steps. **That is NOT atomic** — it has the
|
|
60
|
+
* exact same read-then-mark race window as calling
|
|
61
|
+
* `getUnprocessedMessages` + `updateStatus` manually, and is safe only for
|
|
62
|
+
* a single-worker deployment. Override `claimBatch` in a concrete
|
|
63
|
+
* repository (e.g. backed by `SELECT ... FOR UPDATE SKIP LOCKED`, or a
|
|
64
|
+
* single atomic `UPDATE ... WHERE status='PENDING' LIMIT n RETURNING *`)
|
|
65
|
+
* to get real multi-worker/multi-processor safety.
|
|
66
|
+
*
|
|
67
|
+
* `OutboxProcessor` only calls this method when
|
|
68
|
+
* `OutboxProcessorOptions.useClaimBatch` is explicitly set to `true`
|
|
69
|
+
* (opt-in, default `false`) — see AC#1/AC#2. When enabled, the processor
|
|
70
|
+
* skips its own redundant `updateStatus(id, PROCESSING)` call for messages
|
|
71
|
+
* returned by `claimBatch`, since claiming already performed that mark.
|
|
72
|
+
*
|
|
73
|
+
* Handlers should be idempotent to side effects regardless — transient
|
|
74
|
+
* failures or duplicate-claim races (e.g. with the non-atomic default
|
|
75
|
+
* implementation) may still cause a handler to run more than once.
|
|
76
|
+
*
|
|
77
|
+
* @param limit - Maximum number of messages to claim.
|
|
78
|
+
* @param priorityOrder - See {@link getUnprocessedMessages}.
|
|
79
|
+
* @param messageTypes - See {@link getUnprocessedMessages}.
|
|
80
|
+
* @returns Messages that were claimed (already marked `PROCESSING`).
|
|
81
|
+
*/
|
|
82
|
+
claimBatch?(limit?: number, priorityOrder?: MessagePriority[], messageTypes?: string[]): Promise<IOutboxMessage[]>;
|
|
31
83
|
/**
|
|
32
84
|
* Gets specific message by ID
|
|
33
85
|
* @param id Message ID
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IDomainEvent } from '
|
|
1
|
+
import { IDomainEvent } from '@vytches/ddd-contracts';
|
|
2
2
|
import { IOutboxMessage, OutboxMessageOptions, MessagePriority } from './outbox-interfaces';
|
|
3
3
|
import { IOutboxRepository } from './outbox-repository.interface';
|
|
4
4
|
export interface OutboxServiceOptions {
|
|
@@ -16,7 +16,6 @@ export interface OutboxServiceOptions {
|
|
|
16
16
|
export declare class OutboxService {
|
|
17
17
|
private readonly repository;
|
|
18
18
|
private readonly options;
|
|
19
|
-
private readonly logger;
|
|
20
19
|
constructor(repository: IOutboxRepository, options?: OutboxServiceOptions);
|
|
21
20
|
/**
|
|
22
21
|
* Saves a message to the outbox
|
|
@@ -75,8 +74,4 @@ export declare class OutboxService {
|
|
|
75
74
|
totalProcessed: number;
|
|
76
75
|
totalFailed: number;
|
|
77
76
|
}>;
|
|
78
|
-
/**
|
|
79
|
-
* Logs messages if logging is enabled
|
|
80
|
-
*/
|
|
81
|
-
private log;
|
|
82
77
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vytches/ddd-messaging",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Outbox pattern and reliable message delivery",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"LLMGUIDE.md",
|
|
19
19
|
"README.md",
|
|
20
|
-
"dist"
|
|
20
|
+
"dist",
|
|
21
|
+
"LICENSE"
|
|
21
22
|
],
|
|
22
23
|
"keywords": [
|
|
23
24
|
"ddd",
|
|
@@ -36,7 +37,7 @@
|
|
|
36
37
|
"typescript": "^5.0.0",
|
|
37
38
|
"vite": "^7.3.2",
|
|
38
39
|
"vite-plugin-dts": "^4.0.0",
|
|
39
|
-
"vitest": "^4.
|
|
40
|
+
"vitest": "^4.1.0"
|
|
40
41
|
},
|
|
41
42
|
"nx": {
|
|
42
43
|
"tags": [
|
|
@@ -50,7 +51,9 @@
|
|
|
50
51
|
"access": "public"
|
|
51
52
|
},
|
|
52
53
|
"dependencies": {
|
|
53
|
-
"@vytches/ddd-events": "0.
|
|
54
|
+
"@vytches/ddd-events": "0.31.0",
|
|
55
|
+
"@vytches/ddd-contracts": "0.31.0",
|
|
56
|
+
"@vytches/ddd-utils": "0.31.0"
|
|
54
57
|
},
|
|
55
58
|
"scripts": {
|
|
56
59
|
"build": "vite build",
|