Skip to content

Invocation dedup excludes execution options, so first-wins silently overrides a subscriber's async/queue declaration #51

Description

@fractaledmind

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 nextuniq 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:

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions