@vytches/ddd-messaging 0.28.0 → 0.29.2

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 o=(i=>(i.PENDING="PENDING",i.PROCESSING="PROCESSING",i.PROCESSED="PROCESSED",i.FAILED="FAILED",i))(o||{}),g=(i=>(i.LOW="low",i.NORMAL="normal",i.HIGH="high",i.CRITICAL="critical",i))(g||{});class p{scheduleRetry(e,t){return Promise.resolve()}}class c{static createMessage(e,t,s){const r={id:d.LibUtils.getUUID(),messageType:e,payload:t,metadata:s?.metadata||{},status:o.PENDING,attempts:0,createdAt:new Date,priority:s?.priority||g.NORMAL};return s?.processAfter&&(r.processAfter=s.processAfter),r}static createDelayedMessage(e,t,s,r){const a=new Date(Date.now()+s);return c.createMessage(e,t,{...r,processAfter:a})}static createHighPriorityMessage(e,t,s){return c.createMessage(e,t,{...s,priority:g.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),c.createMessage(`integration_event:${e.eventName}`,e.payload,s)}}class y{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:r,multiplier:a,maxDelay:n}=t.retryBackoff;if(r<=0||a<=0||n<=0)throw new RangeError(`OutboxProcessor: retryBackoff fields must be positive numbers (initial=${r}, multiplier=${a}, maxDelay=${n})`)}this.repository=e,this.options={batchSize:s,maxRetries:t.maxRetries??3,processingInterval:t.processingInterval??5e3,priorityOrder:t.priorityOrder??[g.CRITICAL,g.HIGH,g.NORMAL,g.LOW],messageTimeout:t.messageTimeout??3e4,enableLogging:t.enableLogging??!1,retryBackoff:t.retryBackoff}}registerHandler(e,t){this.handlers.set(e,t),this.logger.debug(`Registered handler for message type: ${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"),this.scheduleProcessing()}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.logger.info("Stopped outbox processor")}async processBatch(){if(!this.isRunning)return;const[e,t]=await d.safeRun(()=>this.repository.getUnprocessedMessages(this.options.batchSize,this.options.priorityOrder));if(e){const a=e instanceof Error?e.message:String(e);this.logger.error(`Error retrieving messages: ${a}`);return}const s=t;if(!s||!Array.isArray(s)||s.length===0){this.logger.debug("No pending messages to process");return}this.logger.info(`Processing ${s.length} messages`);const r=s.map(a=>this.processMessage(a));await Promise.allSettled(r),this.logger.info(`Completed processing batch of ${s.length} messages`)}async processMessage(e){const[t]=await d.safeRun(async()=>{await this.repository.updateStatus(e.id,o.PROCESSING),await this.buildPipeline(e)(e),await this.repository.updateStatus(e.id,o.PROCESSED),this.logger.debug(`Successfully processed message ${e.id}`)});t&&await this.handleMessageError(e,t)}buildPipeline(e){const t=this.handlers.get(e.messageType);if(!t)throw new Error(`No handler registered for message type: ${e.messageType}`);const s=async r=>{await Promise.race([t.handle(r),new Promise((a,n)=>setTimeout(()=>n(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}`),s>=this.options.maxRetries)await this.repository.updateStatus(e.id,o.FAILED,t),this.logger.error(`Message ${e.id} marked as failed after ${s} attempts`);else if(this.options.retryBackoff){const{initial:r,multiplier:a,maxDelay:n}=this.options.retryBackoff,l=Math.min(r*Math.pow(a,s-1),n),h=new Date(Date.now()+l);await this.repository.updateStatus(e.id,o.PENDING),await this.repository.scheduleRetry(e.id,h),this.logger.info(`Message ${e.id} scheduled for retry in ${l}ms (attempt ${s})`)}else await this.repository.updateStatus(e.id,o.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(){this.isRunning&&(this.processingTimer=setTimeout(async()=>{await this.processBatch(),this.scheduleProcessing()},this.options.processingInterval))}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}}}class f{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??g.NORMAL,defaultMetadata:t.defaultMetadata??{},autoCleanup:t.autoCleanup??!1,cleanupAfterDays:t.cleanupAfterDays??7,enableLogging:t.enableLogging??!1}}async saveMessage(e,t,s){const r=c.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:n})=>c.createMessage(r,a,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...n?.metadata},...n})),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=c.createDelayedMessage(e,t,s,{priority:this.options.defaultPriority,metadata:{...this.options.defaultMetadata,...r?.metadata},...r}),n=await this.repository.scheduleMessage(a,a.processAfter);return this.logger.debug(`Scheduled message ${n} of type ${e} for processing in ${s}ms`),n}async saveHighPriorityMessage(e,t,s){return this.saveMessage(e,t,{...s,priority:g.HIGH})}async saveCriticalMessage(e,t,s){return this.saveMessage(e,t,{...s,priority:g.CRITICAL})}async saveDomainEvent(e,t){const s=c.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,o.PENDING),this.logger.info(`Reset message ${e} to pending for retry`)}async retryMessages(e){await this.repository.updateStatusBatch(e,o.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,o.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(o.PENDING),totalProcessing:e.getMessageCountByStatus(o.PROCESSING),totalProcessed:e.getMessageCountByStatus(o.PROCESSED),totalFailed:e.getMessageCountByStatus(o.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=f;exports.IOutboxRepository=p;exports.MessagePriority=g;exports.MessageStatus=o;exports.OutboxMessageFactory=c;exports.OutboxProcessor=y;exports.OutboxService=m;
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||{}),g=(i=>(i.LOW="low",i.NORMAL="normal",i.HIGH="high",i.CRITICAL="critical",i))(g||{});class p{scheduleRetry(e,t){return Promise.resolve()}resetStaleProcessing(e){return Promise.resolve(0)}}class l{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||g.NORMAL};return s?.processAfter&&(r.processAfter=s.processAfter),r}static createDelayedMessage(e,t,s,r){const a=new Date(Date.now()+s);return l.createMessage(e,t,{...r,processAfter:a})}static createHighPriorityMessage(e,t,s){return l.createMessage(e,t,{...s,priority:g.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),l.createMessage(`integration_event:${e.eventName}`,e.payload,s)}}class y{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??[g.CRITICAL,g.HIGH,g.NORMAL,g.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}`)}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);if(!t)throw new Error(`No handler registered 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}}}class f{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??g.NORMAL,defaultMetadata:t.defaultMetadata??{},autoCleanup:t.autoCleanup??!1,cleanupAfterDays:t.cleanupAfterDays??7,enableLogging:t.enableLogging??!1}}async saveMessage(e,t,s){const r=l.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})=>l.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=l.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:g.HIGH})}async saveCriticalMessage(e,t,s){return this.saveMessage(e,t,{...s,priority:g.CRITICAL})}async saveDomainEvent(e,t){const s=l.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=f;exports.IOutboxRepository=p;exports.MessagePriority=g;exports.MessageStatus=n;exports.OutboxMessageFactory=l;exports.OutboxProcessor=y;exports.OutboxService=m;
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export type { IOutboxMessage, OutboxMessageOptions, IOutboxMessageHandler, OutboxMiddleware, OutboxProcessorOptions, RetryBackoffConfig, OutboxServiceOptions, } from './outbox';
1
+ export type { IOutboxMessage, OutboxMessageOptions, IOutboxMessageHandler, OutboxMiddleware, OutboxProcessorOptions, OutboxProcessorHooks, RetryBackoffConfig, OutboxServiceOptions, } from './outbox';
2
2
  export { MessageStatus, MessagePriority } from './outbox';
3
3
  export { IOutboxRepository, OutboxMessageFactory, OutboxProcessor, EventBusOutboxHandler, OutboxService, } from './outbox';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { Logger as h } from "@vytches/ddd-logging";
2
- import { LibUtils as p, safeRun as l } from "@vytches/ddd-utils";
3
- var o = /* @__PURE__ */ ((i) => (i.PENDING = "PENDING", i.PROCESSING = "PROCESSING", i.PROCESSED = "PROCESSED", i.FAILED = "FAILED", i))(o || {}), g = /* @__PURE__ */ ((i) => (i.LOW = "low", i.NORMAL = "normal", i.HIGH = "high", i.CRITICAL = "critical", i))(g || {});
1
+ import { Logger as u } from "@vytches/ddd-logging";
2
+ import { LibUtils as p, safeRun as d } from "@vytches/ddd-utils";
3
+ var n = /* @__PURE__ */ ((i) => (i.PENDING = "PENDING", i.PROCESSING = "PROCESSING", i.PROCESSED = "PROCESSED", i.FAILED = "FAILED", i))(n || {}), g = /* @__PURE__ */ ((i) => (i.LOW = "low", i.NORMAL = "normal", i.HIGH = "high", i.CRITICAL = "critical", i))(g || {});
4
4
  class m {
5
5
  /**
6
6
  * Schedules a retry for a failed message at a specific future time.
@@ -21,8 +21,29 @@ class m {
21
21
  scheduleRetry(e, t) {
22
22
  return Promise.resolve();
23
23
  }
24
+ /**
25
+ * Resets messages stuck in `PROCESSING` back to `PENDING` so they can be
26
+ * re-dispatched. Used for crash recovery: if a worker dies mid-batch, its
27
+ * in-flight messages remain `PROCESSING` forever without this.
28
+ *
29
+ * Default implementation is a no-op returning `0` — repositories that do not
30
+ * track a per-message "processing since" timestamp simply opt out. Override
31
+ * in concrete repositories, e.g.:
32
+ * `UPDATE outbox_messages SET status='PENDING' WHERE status='PROCESSING' AND updated_at < olderThan`.
33
+ *
34
+ * **Safety:** implementations MUST only reset messages whose processing
35
+ * started strictly before `olderThan`. `OutboxProcessor` always passes a past
36
+ * timestamp (`now - crashRecoveryThresholdMs`, with the threshold validated to
37
+ * be `>= messageTimeout`), so legitimately in-flight messages are never reset.
38
+ *
39
+ * @param olderThan Reset only `PROCESSING` messages last touched before this instant
40
+ * @returns Number of messages reset to `PENDING`
41
+ */
42
+ resetStaleProcessing(e) {
43
+ return Promise.resolve(0);
44
+ }
24
45
  }
25
- class c {
46
+ class h {
26
47
  /**
27
48
  * Creates a new outbox message
28
49
  * @param messageType Type of the message
@@ -36,7 +57,7 @@ class c {
36
57
  messageType: e,
37
58
  payload: t,
38
59
  metadata: s?.metadata || {},
39
- status: o.PENDING,
60
+ status: n.PENDING,
40
61
  attempts: 0,
41
62
  createdAt: /* @__PURE__ */ new Date(),
42
63
  priority: s?.priority || g.NORMAL
@@ -53,7 +74,7 @@ class c {
53
74
  */
54
75
  static createDelayedMessage(e, t, s, r) {
55
76
  const a = new Date(Date.now() + s);
56
- return c.createMessage(e, t, {
77
+ return h.createMessage(e, t, {
57
78
  ...r,
58
79
  processAfter: a
59
80
  });
@@ -66,7 +87,7 @@ class c {
66
87
  * @returns High priority outbox message
67
88
  */
68
89
  static createHighPriorityMessage(e, t, s) {
69
- return c.createMessage(e, t, {
90
+ return h.createMessage(e, t, {
70
91
  ...s,
71
92
  priority: g.HIGH
72
93
  });
@@ -84,26 +105,31 @@ class c {
84
105
  ...t?.metadata || {}
85
106
  }
86
107
  };
87
- return t?.priority && (s.priority = t.priority), t?.processAfter && (s.processAfter = t.processAfter), c.createMessage(
108
+ return t?.priority && (s.priority = t.priority), t?.processAfter && (s.processAfter = t.processAfter), h.createMessage(
88
109
  `integration_event:${e.eventName}`,
89
110
  e.payload,
90
111
  s
91
112
  );
92
113
  }
93
114
  }
94
- class w {
115
+ class v {
95
116
  constructor(e, t = {}) {
96
- this.handlers = /* @__PURE__ */ new Map(), this.middlewares = [], this.isRunning = !1, this.logger = h.create("OutboxProcessor");
117
+ this.handlers = /* @__PURE__ */ new Map(), this.middlewares = [], this.isRunning = !1, this.logger = u.create("OutboxProcessor");
97
118
  const s = t.batchSize ?? 10;
98
119
  if (s > 1e4)
99
120
  throw new RangeError(`OutboxProcessor: batchSize must be ≤ 10,000, got ${s}`);
100
121
  if (t.retryBackoff) {
101
- const { initial: r, multiplier: a, maxDelay: n } = t.retryBackoff;
102
- if (r <= 0 || a <= 0 || n <= 0)
122
+ const { initial: o, multiplier: l, maxDelay: c } = t.retryBackoff;
123
+ if (o <= 0 || l <= 0 || c <= 0)
103
124
  throw new RangeError(
104
- `OutboxProcessor: retryBackoff fields must be positive numbers (initial=${r}, multiplier=${a}, maxDelay=${n})`
125
+ `OutboxProcessor: retryBackoff fields must be positive numbers (initial=${o}, multiplier=${l}, maxDelay=${c})`
105
126
  );
106
127
  }
128
+ const r = t.messageTimeout ?? 3e4, a = t.crashRecoveryThresholdMs ?? Math.max(3e5, r);
129
+ if (a < r)
130
+ throw new RangeError(
131
+ `OutboxProcessor: crashRecoveryThresholdMs (${a}) must be ≥ messageTimeout (${r}) to avoid resetting in-flight messages`
132
+ );
107
133
  this.repository = e, this.options = {
108
134
  batchSize: s,
109
135
  maxRetries: t.maxRetries ?? 3,
@@ -114,11 +140,32 @@ class w {
114
140
  g.NORMAL,
115
141
  g.LOW
116
142
  ],
117
- messageTimeout: t.messageTimeout ?? 3e4,
143
+ messageTimeout: r,
118
144
  enableLogging: t.enableLogging ?? !1,
119
- retryBackoff: t.retryBackoff
145
+ retryBackoff: t.retryBackoff,
146
+ messageTypes: t.messageTypes,
147
+ crashRecoveryIntervalMs: t.crashRecoveryIntervalMs,
148
+ crashRecoveryThresholdMs: a,
149
+ adaptiveRepoll: t.adaptiveRepoll ?? !1,
150
+ adaptiveRepollMaxConsecutive: t.adaptiveRepollMaxConsecutive ?? 5,
151
+ adaptiveRepollPauseMs: t.adaptiveRepollPauseMs ?? 50,
152
+ startupJitterMs: t.startupJitterMs ?? 0,
153
+ hooks: t.hooks
120
154
  };
121
155
  }
156
+ /**
157
+ * Invokes an observability hook in isolation. A throwing hook is logged and
158
+ * swallowed so it can never break the processing loop (mitigation T3).
159
+ */
160
+ safelyInvokeHook(e, t) {
161
+ if (this.options.hooks)
162
+ try {
163
+ t();
164
+ } catch (s) {
165
+ const r = s instanceof Error ? s.message : String(s);
166
+ this.logger.error(`Outbox hook ${e} threw and was ignored: ${r}`);
167
+ }
168
+ }
122
169
  /**
123
170
  * Registers a message handler for a specific message type
124
171
  */
@@ -139,7 +186,9 @@ class w {
139
186
  this.logger.warn("Processor is already running");
140
187
  return;
141
188
  }
142
- this.isRunning = !0, this.logger.info("Starting outbox processor"), this.scheduleProcessing();
189
+ this.isRunning = !0, this.logger.info("Starting outbox processor");
190
+ const e = this.options.startupJitterMs > 0 ? Math.floor(Math.random() * this.options.startupJitterMs) : 0;
191
+ this.scheduleProcessing(0, this.options.processingInterval + e), this.scheduleCrashRecovery();
143
192
  }
144
193
  /**
145
194
  * Stops the processor
@@ -149,7 +198,7 @@ class w {
149
198
  this.logger.warn("Processor is not running");
150
199
  return;
151
200
  }
152
- this.isRunning = !1, this.processingTimer && (clearTimeout(this.processingTimer), this.processingTimer = void 0), this.logger.info("Stopped outbox processor");
201
+ 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");
153
202
  }
154
203
  /**
155
204
  * Processes a single batch of pending messages.
@@ -172,38 +221,57 @@ class w {
172
221
  * **Quiescence:** if there are no pending messages, returns immediately
173
222
  * without invoking handlers.
174
223
  *
224
+ * @returns `{ processed, batchSize }` — `processed` is how many messages were
225
+ * dispatched this batch, `batchSize` the configured limit. A full batch
226
+ * (`processed >= batchSize`) signals a likely backlog. Callers that ignore
227
+ * the return value are unaffected (the value was previously `void`).
175
228
  * @public
176
229
  * @stable
177
230
  * @since 0.1.0
178
231
  */
179
232
  async processBatch() {
233
+ const e = this.options.batchSize;
180
234
  if (!this.isRunning)
181
- return;
182
- const [e, t] = await l(
183
- () => this.repository.getUnprocessedMessages(this.options.batchSize, this.options.priorityOrder)
235
+ return { processed: 0, batchSize: e };
236
+ const [t, s] = await d(
237
+ () => this.repository.getUnprocessedMessages(
238
+ e,
239
+ this.options.priorityOrder,
240
+ this.options.messageTypes
241
+ )
184
242
  );
185
- if (e) {
186
- const a = e instanceof Error ? e.message : String(e);
187
- this.logger.error(`Error retrieving messages: ${a}`);
188
- return;
189
- }
190
- const s = t;
191
- if (!s || !Array.isArray(s) || s.length === 0) {
192
- this.logger.debug("No pending messages to process");
193
- return;
243
+ if (t) {
244
+ const c = t instanceof Error ? t.message : String(t);
245
+ return this.logger.error(`Error retrieving messages: ${c}`), { processed: 0, batchSize: e };
194
246
  }
195
- this.logger.info(`Processing ${s.length} messages`);
196
- const r = s.map((a) => this.processMessage(a));
197
- await Promise.allSettled(r), this.logger.info(`Completed processing batch of ${s.length} messages`);
247
+ const r = s;
248
+ if (!r || !Array.isArray(r) || r.length === 0)
249
+ return this.logger.debug("No pending messages to process"), { processed: 0, batchSize: e };
250
+ this.logger.info(`Processing ${r.length} messages`);
251
+ const a = Date.now(), l = (await Promise.allSettled(
252
+ r.map((c) => this.processMessage(c))
253
+ )).filter(
254
+ (c) => c.status === "rejected" || c.status === "fulfilled" && c.value === !1
255
+ ).length;
256
+ return this.logger.info(`Completed processing batch of ${r.length} messages`), this.safelyInvokeHook(
257
+ "onBatchComplete",
258
+ () => this.options.hooks?.onBatchComplete?.({
259
+ processed: r.length,
260
+ failed: l,
261
+ batchSize: e,
262
+ durationMs: Date.now() - a
263
+ })
264
+ ), { processed: r.length, batchSize: e };
198
265
  }
199
266
  /**
200
- * Processes a single message through the middleware pipeline
267
+ * Processes a single message through the middleware pipeline.
268
+ * @returns `true` if the message was processed successfully, `false` if it failed.
201
269
  */
202
270
  async processMessage(e) {
203
- const [t] = await l(async () => {
204
- await this.repository.updateStatus(e.id, o.PROCESSING), await this.buildPipeline(e)(e), await this.repository.updateStatus(e.id, o.PROCESSED), this.logger.debug(`Successfully processed message ${e.id}`);
271
+ const [t] = await d(async () => {
272
+ 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}`);
205
273
  });
206
- t && await this.handleMessageError(e, t);
274
+ return t ? (await this.handleMessageError(e, t), !1) : !0;
207
275
  }
208
276
  /**
209
277
  * Builds the middleware pipeline for message processing
@@ -216,8 +284,8 @@ class w {
216
284
  await Promise.race([
217
285
  t.handle(r),
218
286
  new Promise(
219
- (a, n) => setTimeout(
220
- () => n(new Error("Message processing timeout")),
287
+ (a, o) => setTimeout(
288
+ () => o(new Error("Message processing timeout")),
221
289
  this.options.messageTimeout
222
290
  )
223
291
  )
@@ -231,27 +299,70 @@ class w {
231
299
  async handleMessageError(e, t) {
232
300
  try {
233
301
  const s = await this.repository.incrementAttempt(e.id);
234
- if (this.logger.warn(`Message ${e.id} failed, attempt ${s}: ${t?.message}`), s >= this.options.maxRetries)
235
- await this.repository.updateStatus(e.id, o.FAILED, t), this.logger.error(`Message ${e.id} marked as failed after ${s} attempts`);
302
+ if (this.logger.warn(`Message ${e.id} failed, attempt ${s}: ${t?.message}`), this.safelyInvokeHook(
303
+ "onMessageFailed",
304
+ () => this.options.hooks?.onMessageFailed?.(e, t, s)
305
+ ), s >= this.options.maxRetries)
306
+ await this.repository.updateStatus(e.id, n.FAILED, t), this.logger.error(`Message ${e.id} marked as failed after ${s} attempts`), this.safelyInvokeHook(
307
+ "onPermanentFailure",
308
+ () => this.options.hooks?.onPermanentFailure?.(e, t)
309
+ );
236
310
  else if (this.options.retryBackoff) {
237
- const { initial: r, multiplier: a, maxDelay: n } = this.options.retryBackoff, d = Math.min(r * Math.pow(a, s - 1), n), u = new Date(Date.now() + d);
238
- await this.repository.updateStatus(e.id, o.PENDING), await this.repository.scheduleRetry(e.id, u), this.logger.info(
239
- `Message ${e.id} scheduled for retry in ${d}ms (attempt ${s})`
311
+ const { initial: r, multiplier: a, maxDelay: o } = this.options.retryBackoff, l = Math.min(r * Math.pow(a, s - 1), o), c = new Date(Date.now() + l);
312
+ await this.repository.updateStatus(e.id, n.PENDING), await this.repository.scheduleRetry(e.id, c), this.logger.info(
313
+ `Message ${e.id} scheduled for retry in ${l}ms (attempt ${s})`
240
314
  );
241
315
  } else
242
- await this.repository.updateStatus(e.id, o.PENDING), this.logger.info(`Message ${e.id} scheduled for retry (attempt ${s + 1})`);
316
+ await this.repository.updateStatus(e.id, n.PENDING), this.logger.info(`Message ${e.id} scheduled for retry (attempt ${s + 1})`);
243
317
  } catch (s) {
244
318
  const r = s instanceof Error ? s.message : String(s);
245
319
  this.logger.error(`Error updating message status: ${r}`);
246
320
  }
247
321
  }
248
322
  /**
249
- * Schedules the next processing cycle
323
+ * Schedules the next processing cycle.
324
+ *
325
+ * With `adaptiveRepoll` enabled, a full batch re-polls immediately (`delay 0`)
326
+ * to drain a backlog — until `adaptiveRepollMaxConsecutive` consecutive full
327
+ * batches trip the livelock guard, after which an `adaptiveRepollPauseMs`
328
+ * pause is inserted. Any non-full batch resets the cadence to
329
+ * `processingInterval`.
330
+ *
331
+ * @param consecutiveFullBatches running count of back-to-back full batches
332
+ * @param delay milliseconds to wait before the next batch
250
333
  */
251
- scheduleProcessing() {
334
+ scheduleProcessing(e = 0, t = this.options.processingInterval) {
252
335
  this.isRunning && (this.processingTimer = setTimeout(async () => {
253
- await this.processBatch(), this.scheduleProcessing();
254
- }, this.options.processingInterval));
336
+ const s = await this.processBatch();
337
+ if (this.options.adaptiveRepoll && s.processed > 0 && s.processed >= s.batchSize) {
338
+ const a = e + 1, o = a > this.options.adaptiveRepollMaxConsecutive ? this.options.adaptiveRepollPauseMs : 0;
339
+ this.scheduleProcessing(a, o);
340
+ } else
341
+ this.scheduleProcessing(0, this.options.processingInterval);
342
+ }, t));
343
+ }
344
+ /**
345
+ * Schedules the next crash-recovery cycle. No-op unless
346
+ * `crashRecoveryIntervalMs` was configured.
347
+ */
348
+ scheduleCrashRecovery() {
349
+ !this.isRunning || this.options.crashRecoveryIntervalMs === void 0 || (this.crashRecoveryTimer = setTimeout(async () => {
350
+ await this.runCrashRecovery(), this.scheduleCrashRecovery();
351
+ }, this.options.crashRecoveryIntervalMs));
352
+ }
353
+ /**
354
+ * Resets messages orphaned in `PROCESSING` (older than
355
+ * `crashRecoveryThresholdMs`) back to `PENDING` via the repository. Errors are
356
+ * logged, never thrown — crash recovery must not crash the processor.
357
+ */
358
+ async runCrashRecovery() {
359
+ const e = new Date(Date.now() - this.options.crashRecoveryThresholdMs), [t, s] = await d(() => this.repository.resetStaleProcessing(e));
360
+ if (t) {
361
+ const r = t instanceof Error ? t.message : String(t);
362
+ this.logger.error(`Crash recovery failed: ${r}`);
363
+ return;
364
+ }
365
+ s && s > 0 && this.logger.warn(`Crash recovery reset ${s} stale PROCESSING message(s) to PENDING`);
255
366
  }
256
367
  /**
257
368
  * Logs messages if logging is enabled
@@ -289,9 +400,9 @@ class M {
289
400
  await this.eventBus.publish(s);
290
401
  }
291
402
  }
292
- class S {
403
+ class R {
293
404
  constructor(e, t = {}) {
294
- this.logger = h.create("OutboxService"), this.repository = e, this.options = {
405
+ this.logger = u.create("OutboxService"), this.repository = e, this.options = {
295
406
  defaultPriority: t.defaultPriority ?? g.NORMAL,
296
407
  defaultMetadata: t.defaultMetadata ?? {},
297
408
  autoCleanup: t.autoCleanup ?? !1,
@@ -303,7 +414,7 @@ class S {
303
414
  * Saves a message to the outbox
304
415
  */
305
416
  async saveMessage(e, t, s) {
306
- const r = c.createMessage(e, t, {
417
+ const r = h.createMessage(e, t, {
307
418
  priority: this.options.defaultPriority,
308
419
  metadata: { ...this.options.defaultMetadata, ...s?.metadata },
309
420
  ...s
@@ -315,10 +426,10 @@ class S {
315
426
  */
316
427
  async saveMessages(e) {
317
428
  const t = e.map(
318
- ({ messageType: r, payload: a, options: n }) => c.createMessage(r, a, {
429
+ ({ messageType: r, payload: a, options: o }) => h.createMessage(r, a, {
319
430
  priority: this.options.defaultPriority,
320
- metadata: { ...this.options.defaultMetadata, ...n?.metadata },
321
- ...n
431
+ metadata: { ...this.options.defaultMetadata, ...o?.metadata },
432
+ ...o
322
433
  })
323
434
  ), s = await this.repository.saveBatch(t);
324
435
  return this.logger.debug(`Saved batch of ${e.length} messages`), s;
@@ -327,14 +438,14 @@ class S {
327
438
  * Schedules a message for delayed processing
328
439
  */
329
440
  async scheduleMessage(e, t, s, r) {
330
- const a = c.createDelayedMessage(e, t, s, {
441
+ const a = h.createDelayedMessage(e, t, s, {
331
442
  priority: this.options.defaultPriority,
332
443
  metadata: { ...this.options.defaultMetadata, ...r?.metadata },
333
444
  ...r
334
- }), n = await this.repository.scheduleMessage(a, a.processAfter);
445
+ }), o = await this.repository.scheduleMessage(a, a.processAfter);
335
446
  return this.logger.debug(
336
- `Scheduled message ${n} of type ${e} for processing in ${s}ms`
337
- ), n;
447
+ `Scheduled message ${o} of type ${e} for processing in ${s}ms`
448
+ ), o;
338
449
  }
339
450
  /**
340
451
  * Saves a high priority message to the outbox
@@ -358,7 +469,7 @@ class S {
358
469
  * Converts a domain event to an outbox message and saves it
359
470
  */
360
471
  async saveDomainEvent(e, t) {
361
- const s = c.createFromIntegrationEvent(
472
+ const s = h.createFromIntegrationEvent(
362
473
  {
363
474
  eventName: e.eventName,
364
475
  payload: e,
@@ -392,13 +503,13 @@ class S {
392
503
  * Retries a failed message by resetting it to pending status
393
504
  */
394
505
  async retryMessage(e) {
395
- await this.repository.updateStatus(e, o.PENDING), this.logger.info(`Reset message ${e} to pending for retry`);
506
+ await this.repository.updateStatus(e, n.PENDING), this.logger.info(`Reset message ${e} to pending for retry`);
396
507
  }
397
508
  /**
398
509
  * Retries multiple failed messages
399
510
  */
400
511
  async retryMessages(e) {
401
- await this.repository.updateStatusBatch(e, o.PENDING), this.logger.info(`Reset ${e.length} messages to pending for retry`);
512
+ await this.repository.updateStatusBatch(e, n.PENDING), this.logger.info(`Reset ${e.length} messages to pending for retry`);
402
513
  }
403
514
  /**
404
515
  * Cleans up old processed messages
@@ -408,7 +519,7 @@ class S {
408
519
  s.setDate(s.getDate() - t);
409
520
  const r = await this.repository.deleteByStatusAndAge(
410
521
  s,
411
- o.PROCESSED
522
+ n.PROCESSED
412
523
  );
413
524
  return this.logger.info(`Cleaned up ${r} processed messages older than ${t} days`), r;
414
525
  }
@@ -418,10 +529,10 @@ class S {
418
529
  async getStats() {
419
530
  const e = this.repository;
420
531
  return e.getMessageCountByStatus ? {
421
- totalPending: e.getMessageCountByStatus(o.PENDING),
422
- totalProcessing: e.getMessageCountByStatus(o.PROCESSING),
423
- totalProcessed: e.getMessageCountByStatus(o.PROCESSED),
424
- totalFailed: e.getMessageCountByStatus(o.FAILED)
532
+ totalPending: e.getMessageCountByStatus(n.PENDING),
533
+ totalProcessing: e.getMessageCountByStatus(n.PROCESSING),
534
+ totalProcessed: e.getMessageCountByStatus(n.PROCESSED),
535
+ totalFailed: e.getMessageCountByStatus(n.FAILED)
425
536
  } : {
426
537
  totalPending: (await this.repository.getUnprocessedMessages(1e3)).length,
427
538
  totalProcessing: 0,
@@ -443,8 +554,8 @@ export {
443
554
  M as EventBusOutboxHandler,
444
555
  m as IOutboxRepository,
445
556
  g as MessagePriority,
446
- o as MessageStatus,
447
- c as OutboxMessageFactory,
448
- w as OutboxProcessor,
449
- S as OutboxService
557
+ n as MessageStatus,
558
+ h as OutboxMessageFactory,
559
+ v as OutboxProcessor,
560
+ R as OutboxService
450
561
  };
@@ -2,4 +2,5 @@ export * from './outbox-interfaces';
2
2
  export * from './outbox-repository.interface';
3
3
  export * from './outbox-message-factory';
4
4
  export * from './outbox-processor';
5
+ export * from './outbox-hooks';
5
6
  export * from './outbox-service';
@@ -0,0 +1,24 @@
1
+ import { IOutboxMessage } from './outbox-interfaces';
2
+ /**
3
+ * Observability callbacks for {@link OutboxProcessor}. Use them to wire metrics
4
+ * (Prometheus counters, DataDog), alerting on permanent failures, or dead-letter
5
+ * routing — without coupling the processor to any specific telemetry stack.
6
+ *
7
+ * **Hooks must be synchronous and fast.** Each call is wrapped in a try/catch by
8
+ * the processor, so a throwing hook is logged and ignored (it never breaks the
9
+ * processing loop), but a slow hook still blocks the batch. Offload heavy work
10
+ * (network, disk) to a queue inside the hook.
11
+ */
12
+ export interface OutboxProcessorHooks {
13
+ /** Fired once per processed batch with aggregate counts and wall-clock duration. */
14
+ onBatchComplete?(result: {
15
+ processed: number;
16
+ failed: number;
17
+ batchSize: number;
18
+ durationMs: number;
19
+ }): void;
20
+ /** Fired on every message failure, including ones that will be retried. */
21
+ onMessageFailed?(message: IOutboxMessage, error: Error, attempt: number): void;
22
+ /** Fired when a message is marked `FAILED` after exhausting `maxRetries`. */
23
+ onPermanentFailure?(message: IOutboxMessage, error: Error): void;
24
+ }
@@ -1,6 +1,7 @@
1
1
  import { IEventBus } from '../../../contracts/src/index.ts';
2
2
  import { IOutboxMessage, IOutboxMessageHandler, OutboxMiddleware, MessagePriority } from './outbox-interfaces';
3
3
  import { IOutboxRepository } from './outbox-repository.interface';
4
+ import { OutboxProcessorHooks } from './outbox-hooks';
4
5
  export interface RetryBackoffConfig {
5
6
  /** Initial delay in milliseconds before first retry (default: 1000) */
6
7
  initial: number;
@@ -31,6 +32,54 @@ export interface OutboxProcessorOptions {
31
32
  * If omitted, immediate retry is used for all messages.
32
33
  */
33
34
  retryBackoff?: RetryBackoffConfig;
35
+ /**
36
+ * Optional allow-list of message types to process. When set, only messages
37
+ * of these types are fetched from the repository (passed through to
38
+ * `getUnprocessedMessages`). Lets multiple processors partition the outbox
39
+ * by type. Omit to process all types.
40
+ */
41
+ messageTypes?: string[];
42
+ /**
43
+ * When set, the processor periodically calls
44
+ * `IOutboxRepository.resetStaleProcessing()` every N ms to recover messages
45
+ * orphaned in `PROCESSING` by a crashed worker. Omit to disable crash recovery.
46
+ */
47
+ crashRecoveryIntervalMs?: number;
48
+ /**
49
+ * How old (in ms) a `PROCESSING` message must be before crash recovery resets
50
+ * it to `PENDING`. Must be `>= messageTimeout` so legitimately in-flight
51
+ * messages are never reset. Defaults to `max(300_000, messageTimeout)`.
52
+ * Setting this below `messageTimeout` throws a `RangeError` from the constructor.
53
+ */
54
+ crashRecoveryThresholdMs?: number;
55
+ /**
56
+ * When `true`, after a **full** batch (every fetched slot used) the processor
57
+ * re-polls immediately instead of waiting `processingInterval`, draining a
58
+ * backlog faster. Defaults to `false` (opt-in — no behavior change).
59
+ */
60
+ adaptiveRepoll?: boolean;
61
+ /**
62
+ * Livelock guard for `adaptiveRepoll`: after this many consecutive full
63
+ * batches the processor inserts an `adaptiveRepollPauseMs` pause instead of
64
+ * re-polling immediately, yielding the event loop. Defaults to `5`.
65
+ */
66
+ adaptiveRepollMaxConsecutive?: number;
67
+ /**
68
+ * Pause (ms) inserted once the `adaptiveRepollMaxConsecutive` guard trips.
69
+ * Defaults to `50`.
70
+ */
71
+ adaptiveRepollPauseMs?: number;
72
+ /**
73
+ * Random delay (0..N ms) added before the first poll on `start()`, to
74
+ * de-synchronize many processors that boot simultaneously. Defaults to `0`.
75
+ */
76
+ startupJitterMs?: number;
77
+ /**
78
+ * Observability callbacks (batch completion, message failure, permanent
79
+ * failure). Each invocation is isolated in a try/catch — a throwing hook is
80
+ * logged and never breaks the processing loop. Omit to disable.
81
+ */
82
+ hooks?: OutboxProcessorHooks;
34
83
  }
35
84
  export declare class OutboxProcessor {
36
85
  private readonly repository;
@@ -39,8 +88,14 @@ export declare class OutboxProcessor {
39
88
  private readonly middlewares;
40
89
  private isRunning;
41
90
  private processingTimer?;
91
+ private crashRecoveryTimer?;
42
92
  private readonly logger;
43
93
  constructor(repository: IOutboxRepository, options?: OutboxProcessorOptions);
94
+ /**
95
+ * Invokes an observability hook in isolation. A throwing hook is logged and
96
+ * swallowed so it can never break the processing loop (mitigation T3).
97
+ */
98
+ private safelyInvokeHook;
44
99
  /**
45
100
  * Registers a message handler for a specific message type
46
101
  */
@@ -78,13 +133,21 @@ export declare class OutboxProcessor {
78
133
  * **Quiescence:** if there are no pending messages, returns immediately
79
134
  * without invoking handlers.
80
135
  *
136
+ * @returns `{ processed, batchSize }` — `processed` is how many messages were
137
+ * dispatched this batch, `batchSize` the configured limit. A full batch
138
+ * (`processed >= batchSize`) signals a likely backlog. Callers that ignore
139
+ * the return value are unaffected (the value was previously `void`).
81
140
  * @public
82
141
  * @stable
83
142
  * @since 0.1.0
84
143
  */
85
- processBatch(): Promise<void>;
144
+ processBatch(): Promise<{
145
+ processed: number;
146
+ batchSize: number;
147
+ }>;
86
148
  /**
87
- * Processes a single message through the middleware pipeline
149
+ * Processes a single message through the middleware pipeline.
150
+ * @returns `true` if the message was processed successfully, `false` if it failed.
88
151
  */
89
152
  private processMessage;
90
153
  /**
@@ -96,9 +159,29 @@ export declare class OutboxProcessor {
96
159
  */
97
160
  private handleMessageError;
98
161
  /**
99
- * Schedules the next processing cycle
162
+ * Schedules the next processing cycle.
163
+ *
164
+ * With `adaptiveRepoll` enabled, a full batch re-polls immediately (`delay 0`)
165
+ * to drain a backlog — until `adaptiveRepollMaxConsecutive` consecutive full
166
+ * batches trip the livelock guard, after which an `adaptiveRepollPauseMs`
167
+ * pause is inserted. Any non-full batch resets the cadence to
168
+ * `processingInterval`.
169
+ *
170
+ * @param consecutiveFullBatches running count of back-to-back full batches
171
+ * @param delay milliseconds to wait before the next batch
100
172
  */
101
173
  private scheduleProcessing;
174
+ /**
175
+ * Schedules the next crash-recovery cycle. No-op unless
176
+ * `crashRecoveryIntervalMs` was configured.
177
+ */
178
+ private scheduleCrashRecovery;
179
+ /**
180
+ * Resets messages orphaned in `PROCESSING` (older than
181
+ * `crashRecoveryThresholdMs`) back to `PENDING` via the repository. Errors are
182
+ * logged, never thrown — crash recovery must not crash the processor.
183
+ */
184
+ private runCrashRecovery;
102
185
  /**
103
186
  * Logs messages if logging is enabled
104
187
  */
@@ -16,9 +16,13 @@ export declare abstract class IOutboxRepository {
16
16
  * Gets unprocessed messages from the outbox
17
17
  * @param limit Maximum number of messages to retrieve
18
18
  * @param priorityOrder Order of priority for processing (default: [CRITICAL, HIGH, NORMAL, LOW])
19
+ * @param messageTypes Optional allow-list of message types to fetch. When
20
+ * provided, only messages whose `messageType` is in this list are returned
21
+ * (e.g. `WHERE message_type IN (...)`). Omitting it fetches all types —
22
+ * this parameter is optional and backward-compatible.
19
23
  * @returns Unprocessed messages
20
24
  */
21
- abstract getUnprocessedMessages(limit?: number, priorityOrder?: MessagePriority[]): Promise<IOutboxMessage[]>;
25
+ abstract getUnprocessedMessages(limit?: number, priorityOrder?: MessagePriority[], messageTypes?: string[]): Promise<IOutboxMessage[]>;
22
26
  /**
23
27
  * Gets specific message by ID
24
28
  * @param id Message ID
@@ -75,4 +79,23 @@ export declare abstract class IOutboxRepository {
75
79
  * @param processAfter When to retry the message
76
80
  */
77
81
  scheduleRetry(_id: string, _processAfter: Date): Promise<void>;
82
+ /**
83
+ * Resets messages stuck in `PROCESSING` back to `PENDING` so they can be
84
+ * re-dispatched. Used for crash recovery: if a worker dies mid-batch, its
85
+ * in-flight messages remain `PROCESSING` forever without this.
86
+ *
87
+ * Default implementation is a no-op returning `0` — repositories that do not
88
+ * track a per-message "processing since" timestamp simply opt out. Override
89
+ * in concrete repositories, e.g.:
90
+ * `UPDATE outbox_messages SET status='PENDING' WHERE status='PROCESSING' AND updated_at < olderThan`.
91
+ *
92
+ * **Safety:** implementations MUST only reset messages whose processing
93
+ * started strictly before `olderThan`. `OutboxProcessor` always passes a past
94
+ * timestamp (`now - crashRecoveryThresholdMs`, with the threshold validated to
95
+ * be `>= messageTimeout`), so legitimately in-flight messages are never reset.
96
+ *
97
+ * @param olderThan Reset only `PROCESSING` messages last touched before this instant
98
+ * @returns Number of messages reset to `PENDING`
99
+ */
100
+ resetStaleProcessing(_olderThan: Date): Promise<number>;
78
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vytches/ddd-messaging",
3
- "version": "0.28.0",
3
+ "version": "0.29.2",
4
4
  "description": "Outbox pattern and reliable message delivery",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -36,8 +36,7 @@
36
36
  "typescript": "^5.0.0",
37
37
  "vite": "^7.3.2",
38
38
  "vite-plugin-dts": "^4.0.0",
39
- "vitest": "^4.0.0",
40
- "@vytches/ddd-testing": "0.28.0"
39
+ "vitest": "^4.0.0"
41
40
  },
42
41
  "nx": {
43
42
  "tags": [
@@ -51,7 +50,7 @@
51
50
  "access": "public"
52
51
  },
53
52
  "dependencies": {
54
- "@vytches/ddd-events": "0.28.0"
53
+ "@vytches/ddd-events": "0.29.2"
55
54
  },
56
55
  "scripts": {
57
56
  "build": "vite build",