Repository navigation
Delayed / deferred message delivery #258
Copy link
Copy link
Closed
Labels
enhancementNew feature or requestNew feature or requestroadmapPart of the project roadmapPart of the project roadmap
Milestone
Description
Context
There's no way to send or publish a message for delivery later. Transports only delay retries:
- SQS: visibility timeouts
- RabbitMQ: TTL bucket queues
<queue>-retry-<n>ms - in-memory:
setTimeout
The original proposal doesn't hold up on every transport:
- SQS: bus-sqs sends and publishes through SNS, which has no per-message delay, so
DelaySecondscan't be used. Holding a message on the receiving side is capped at 12 h per receive, and each receive counts towardsmaxReceiveCount. - RabbitMQ: TTL buckets only expire at the head of the queue, so a long delay can arrive up to 2x late. The delayed-message plugin isn't in the CI image and doesn't replicate.
- Phase 4: Kafka and Redis Streams (Additional transports: Kafka, Azure Service Bus, Redis Streams #267) have no native delay at all.
This is a prerequisite for workflow timeouts (#259) and the durable outbox (#260), which reuses its store and dispatcher.
Decisions
Answered by the maintainer:
- One mechanism for every transport. Delayed messages are stored in the persistence, and each bus runs a dispatcher that sends them when they're due. There are no native transport paths.
- Warn when the store isn't durable. A bus whose persistence doesn't survive a restart (such as the default
InMemoryPersistence) logs a warning that scheduled messages are lost on restart, and says to configure a durable persistence withwithPersistence()(e.g. bus-postgres). The warning is logged once per bus, on the first delayed send.
Proposed:
- API.
send(cmd, { deliverAfter: ms })orsend(cmd, { deliverAt: Date }), and the same forpublish, on the bus and onctx. The two are a mutually exclusive union on the options parameter, which extendsPartial<MessageAttributes>. - Store. Shared with Durable transactional outbox #260: rows of
{ id, kind: 'send' | 'publish', message (plain JSON), attributes, headers, dueAt, leaseUntil }.idis themessageIdfrom Core message id, sent time and endpoint name #322. - When middleware runs. Outgoing middleware (Incoming and outgoing pipeline behaviours (middleware) #263) runs once, when the message is scheduled. The dispatcher hands the stored message to the transport.
- Durability. Persistences declare it with an optional
readonly durable?: boolean.InMemoryPersistencesetsfalse.
Non-goals
- Native transport delays (SQS
DelaySeconds, the RabbitMQ delayed-message plugin, TTL buckets). - Cancelling a scheduled message.
- Recurring or cron schedules.
- Precision finer than the dispatcher's poll interval.
Approach
- Outgoing pipeline: when
dueAtis in the future, the last outgoing step writes a row instead of calling the transport. Inside a handler this happens when the outbox flushes. - New optional
Persistencemethods:storeOutgoingMessages(messages)claimDueOutgoingMessages(now, limit, leaseMs)deleteOutgoingMessages(ids)
- Implementations:
InMemoryPersistence- bus-postgres: an
outgoing_messagestable in the configured schema, claimed withFOR UPDATE SKIP LOCKED - bus-mongodb: an
outgoingmessagescollection with an index ondueAt, claimed with afindOneAndUpdatelease
- Missing support: a delayed send on a persistence without these methods throws
DelayedDeliveryNotSupported, which names the persistence and the fix. - Dispatcher:
- Runs on every started, receiving bus. It polls every 1 s (a constant), plus an in-process timer for the earliest
dueAtthis process scheduled. - Claims rows with a lease, so several instances on one store send each row once. It stops on
stop(). - Send-only buses and
Receiverbuses (Lambda) only write rows; another running bus on the same store dispatches them. The docs say so.
- Runs on every started, receiving bus. It polls every 1 s (a constant), plus an in-process timer for the earliest
- Durability warning: logged at
warnon the first delayed send whenpersistence.durable === false.
Acceptance criteria
-
deliverAfter/deliverAtonbus.send/publishandctx.send/publish, type-checked as mutually exclusive - A delayed message keeps its
correlationId, sticky attributes and headers, and isn't delivered beforedueAt - Several bus instances on one store send each due message once (lease test)
-
InMemoryPersistence, bus-postgres and bus-mongodb implement the store; a sharedscheduledMessageTests(persistence)suite in bus-test covers them - A persistence without the store methods gets
DelayedDeliveryNotSupportedwith the fix in itshelp - A bus with a non-durable persistence (the default
InMemoryPersistence) logs one warning on its first delayed send, saying scheduled messages are lost on restart and how to configure a durable persistence; a test covers it - Docs page
/guide/delayed-delivery, including the send-only, Lambda and durability limits; changeset
Metadata
Metadata
Assignees
Labels
enhancementNew feature or requestNew feature or requestroadmapPart of the project roadmapPart of the project roadmap
Activity