Summary
Invocation#key deliberately excludes execution options, so Bus.emit's first-wins dedup can silently override a subscriber's declared async/queue/priority. A synchronous declaration can force an intentionally-async subscription to run inline in the emitter's thread and transaction.
Observed in servus 0.6.0.
Detail
# lib/servus/events/invocation.rb:71-82
# A deterministic deduplication key derived from the service class
# and params. Two invocations with the same key are considered
# duplicates — the Bus keeps the first and skips the rest.
#
# Options are intentionally excluded: identity is *what* to call,
# not *how* to call it.
def key
Digest::SHA256.hexdigest("#{service}:#{params.to_json}")
end
# lib/servus/events/bus.rb:85-86
.uniq(&:key)
.each(&:execute)
The identity rationale is sound: calling the same service with the same params twice is a duplicate regardless of transport. The problem is what happens next — uniq keeps the first invocation, and that invocation carries its own options. So dedup does not merely drop a duplicate call; it silently picks which execution strategy wins.
Failure shape
Two routers (or two declarations) resolve to the same service with the same params:
- Router A (earlier in
Servus.config.routers): invoke MyService — synchronous
- Router B (later): the same service, same params, declared
async: true, queue: :low
Result: the async declaration is dropped. MyService runs synchronously inside the emitting service's call stack and transaction. The author of the async declaration gets no warning, no log line, and no way to discover this short of reading the resolve order.
The reverse is equally surprising: an async declaration resolved first will silently make a subscriber that was declared synchronous run out-of-band, after the transaction commits — which changes its visibility of uncommitted data.
Combined with routers whose resolve order is not deterministic, the winning transport can vary between processes or across a cache refill.
Suggested direction
Options, roughly in increasing strictness:
- Warn on conflict. Keep first-wins, but when duplicates disagree on execution options, log (or
Rails.error.report) that a declaration was overridden. Cheap, non-breaking, and turns a silent behaviour change into a discoverable one.
- Merge deterministically with a documented precedence — e.g. synchronous wins because it is the stronger guarantee, or the most specific router wins — and document it.
- Treat differing options as distinct invocations (include options in
key). Most predictable, but it means a service can now run twice for one event, which is probably not what anyone wants.
(1) seems like the right immediate fix; (2) is the right long-term contract. Happy to open a PR.
Summary
Invocation#keydeliberately excludes execution options, soBus.emit's first-wins dedup can silently override a subscriber's declaredasync/queue/priority. A synchronous declaration can force an intentionally-async subscription to run inline in the emitter's thread and transaction.Observed in
servus 0.6.0.Detail
The identity rationale is sound: calling the same service with the same params twice is a duplicate regardless of transport. The problem is what happens next —
uniqkeeps the first invocation, and that invocation carries its ownoptions. So dedup does not merely drop a duplicate call; it silently picks which execution strategy wins.Failure shape
Two routers (or two declarations) resolve to the same service with the same params:
Servus.config.routers):invoke MyService— synchronousasync: true, queue: :lowResult: the async declaration is dropped.
MyServiceruns synchronously inside the emitting service's call stack and transaction. The author of the async declaration gets no warning, no log line, and no way to discover this short of reading the resolve order.The reverse is equally surprising: an async declaration resolved first will silently make a subscriber that was declared synchronous run out-of-band, after the transaction commits — which changes its visibility of uncommitted data.
Combined with routers whose resolve order is not deterministic, the winning transport can vary between processes or across a cache refill.
Suggested direction
Options, roughly in increasing strictness:
Rails.error.report) that a declaration was overridden. Cheap, non-breaking, and turns a silent behaviour change into a discoverable one.key). Most predictable, but it means a service can now run twice for one event, which is probably not what anyone wants.(1) seems like the right immediate fix; (2) is the right long-term contract. Happy to open a PR.