@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 +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +181 -70
- package/dist/outbox/index.d.ts +1 -0
- package/dist/outbox/outbox-hooks.d.ts +24 -0
- package/dist/outbox/outbox-processor.d.ts +86 -3
- package/dist/outbox/outbox-repository.interface.d.ts +24 -1
- package/package.json +3 -4
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
|
|
2
|
-
import { LibUtils as p, safeRun as
|
|
3
|
-
var
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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),
|
|
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
|
|
115
|
+
class v {
|
|
95
116
|
constructor(e, t = {}) {
|
|
96
|
-
this.handlers = /* @__PURE__ */ new Map(), this.middlewares = [], this.isRunning = !1, this.logger =
|
|
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:
|
|
102
|
-
if (
|
|
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=${
|
|
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:
|
|
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")
|
|
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 [
|
|
183
|
-
() => this.repository.getUnprocessedMessages(
|
|
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 (
|
|
186
|
-
const
|
|
187
|
-
this.logger.error(`Error retrieving messages: ${
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
204
|
-
await this.repository.updateStatus(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
|
|
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,
|
|
220
|
-
() =>
|
|
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}`),
|
|
235
|
-
|
|
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:
|
|
238
|
-
await this.repository.updateStatus(e.id,
|
|
239
|
-
`Message ${e.id} scheduled for retry in ${
|
|
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,
|
|
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()
|
|
254
|
-
|
|
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
|
|
403
|
+
class R {
|
|
293
404
|
constructor(e, t = {}) {
|
|
294
|
-
this.logger =
|
|
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 =
|
|
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:
|
|
429
|
+
({ messageType: r, payload: a, options: o }) => h.createMessage(r, a, {
|
|
319
430
|
priority: this.options.defaultPriority,
|
|
320
|
-
metadata: { ...this.options.defaultMetadata, ...
|
|
321
|
-
...
|
|
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 =
|
|
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
|
-
}),
|
|
445
|
+
}), o = await this.repository.scheduleMessage(a, a.processAfter);
|
|
335
446
|
return this.logger.debug(
|
|
336
|
-
`Scheduled message ${
|
|
337
|
-
),
|
|
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 =
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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(
|
|
422
|
-
totalProcessing: e.getMessageCountByStatus(
|
|
423
|
-
totalProcessed: e.getMessageCountByStatus(
|
|
424
|
-
totalFailed: e.getMessageCountByStatus(
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
557
|
+
n as MessageStatus,
|
|
558
|
+
h as OutboxMessageFactory,
|
|
559
|
+
v as OutboxProcessor,
|
|
560
|
+
R as OutboxService
|
|
450
561
|
};
|
package/dist/outbox/index.d.ts
CHANGED
|
@@ -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<
|
|
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.
|
|
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.
|
|
53
|
+
"@vytches/ddd-events": "0.29.2"
|
|
55
54
|
},
|
|
56
55
|
"scripts": {
|
|
57
56
|
"build": "vite build",
|