Skip to content

Send list broadcasts as {event, items} - #4

Merged
simoncocking merged 3 commits into
mainfrom
feat/list-broadcast-shape
Aug 13, 2026
Merged

simoncocking merged 3 commits into
mainfrom
feat/list-broadcast-shape

Conversation

@simoncocking

@simoncocking simoncocking commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Follows #3. Requires a coordinated change in consuming applications — see Migration below.

The problem

List broadcasts sent a shape that was backwards from every other one in the library, and which changed depending on how many items you passed:

PubSub.broadcast([post], :archived)        # => {:archived, post}
PubSub.broadcast([post, other], :archived) # => [{post, :archived}, {other, :archived}]

A single struct sends {event, subject}. A one-element list was special-cased and delegated to the element, so it sent that too. Two elements sent an inverted, per-item form. Whether a consumer saw one shape or the other depended on how many rows a query happened to return.

The cost was paid in every consumer: two handlers per event, kept in step by hand.

The change

One shape, regardless of cardinality:

PubSub.broadcast(posts, :archived)              # => {:archived, posts_on_that_channel}
PubSub.broadcast(posts, :archived, %{by: id})   # => {:archived, posts_on_that_channel, %{by: id}}

The subject sits where it sits for a single struct, with a list in it.

Batching is unchanged, and deliberately so — it is the reason the List implementation exists. Items are still grouped by channel, still one message per channel, still carrying only that channel's items. A receiver still gets everything for its channel in a single message and can batch the work. Only the nesting differs; consumers stop having to Enum.map(&elem(&1, 0)) before they can use it.

Two things the new shape required or exposed

Schema-level handlers would have stopped firing. The Tuple dispatcher unpacks {event, items}, resolves the List implementation for the subject, and calls handle_info/3 — which List inherited as the injected pass-through. Without new clauses, a batch would arrive at the LiveView but never reach any per-struct handler. Any schema that defines one relies on this happening. List.handle_info/3,4 now reduce over the items, calling each struct's own handler. This is the same silent failure mode as the bug fixed in #3, and would have been just as quiet.

{:halt, socket} was being swallowed. List.handle_info/2 took elem(1) of each result and always reported {:cont, socket}, downgrading a handler that had claimed the message. The existing test asserted the downgraded result, so it was encoding the bug; it now asserts the halt.

Event binding

Binding the event once per group rather than once per item looks like it drops a guarantee. It doesn't, and EventIsolationTest pins why.

message is a scalar argument to the call, closed over by the entry builder and applied per group, so only one event value is ever in scope. The old per-item pairing drew on that same single argument — it repeated one value N times rather than establishing anything about any item. What's gone is redundancy, not a guarantee.

Verified by mutation, not by passing: hard-binding the event in broadcast/2 fails 6 of the 12, in handle_info/3 fails 3, in broadcast/3 fails 1.

Channel isolation

ChannelIsolationTest (added in #3) had to have its assertions rewritten for the new nesting, which is exactly when such a guard gets quietly weakened. Re-verified by mutation afterwards, twice:

  • broadcasting the whole list to every channel — 5 of 8 fail
  • a rotation giving each channel one message of the right size belonging to a different channel — 5 of 8 fail

Only the nesting in the expected values changed, never which items appear under which channel.

Documentation fix

The moduledoc claimed "the defaults always return {:cont, socket}, so unmatched messages fall through safely." That holds only until you override something. A function defined in a use Amplified.PubSub do ... end block replaces the default of that name and arity rather than adding a clause ahead of it, so defining one handle_info/3 removes the catch-all and an unmatched event raises FunctionClauseError in the receiving LiveView.

Overriding does mean owning the whole function, but the failure lands in a process far from the schema that caused it, so it's now a warning admonition with the remedy. Pre-existing behaviour, unchanged — only the docs were wrong. OverrideTest holds it to account, including that the arities are independent: overriding handle_info/3 leaves the handle_info/4 default intact.

Migration

The two handlers collapse into one, and the unwrapping goes away:

# before
def handle_info({:archived, %Post{} = post}, socket), do: archive([post], socket)

def handle_info([{%Post{}, :archived} | _] = list, socket),
  do: list |> Enum.map(&elem(&1, 0)) |> archive(socket)

# after
def handle_info({:archived, [%Post{} | _] = posts}, socket), do: archive(posts, socket)

A handler for a bare struct broadcast (broadcast(post, :archived)) is unaffected and still matches {:archived, %Post{}}. Only broadcasts whose subject is a list or stream change.

Safety net: a caller still constructing [{item, event}, ...] doesn't have those pairs quietly treated as items and rebound to the call's event — extract_tuple/1 passes the tuple through as an item and Tuple.channel/2 raises "No channel for {...}", which is loud and names the call site. Pinned with a test.

Tests

109 passing. Clean under --warnings-as-errors.

Adds EventIsolationTest (12), OverrideTest (4), a Recorder fixture that logs every {id, event} it's dispatched so receive-side assertions are positive rather than inferred, and a Partial fixture holding the override pitfall.

Recorder is a plain defstruct rather than an Ecto.Schema like the older fixtures — it needs nothing Ecto provides, and it's now the one fixture proving use Amplified.PubSub works on a bare struct. Channel derivation is asserted against it.

BREAKING. A list broadcast now sends `{event, items}` per channel —
`{event, items, attrs}` from `broadcast/3` — instead of
`[{item, event}, ...]`. The subject sits where it sits for a single struct,
with a list in it, so a receiver reads a batch the same way it reads one
item.

Batching is unchanged, and it is the reason the List implementation exists:
items are still grouped by channel, still one message per channel, still
carrying only that channel's items. Only the nesting differs.

The shape also no longer varies with the number of items. A one-element list
was special-cased and delegated to the element, so `broadcast([post], :archived)`
sent `{:archived, post}` while `broadcast([a, b], :archived)` sent
`[{a, :archived}, {b, :archived}]` — one shape or the other depending on how
many rows a query happened to return. Consumers had to write two handlers for
one event and keep them in step, which is a standing invitation for the two to
drift. Both now send the list form.

A handler for a bare struct broadcast is unaffected and still matches
`{:archived, %Post{}}`. Only broadcasts whose subject is a list or stream
change.

Two fixes the new shape requires or exposes:

- `List.handle_info/3,4` reduce over the items, calling each struct's own
  handler. The Tuple dispatcher unpacks `{event, items}` and resolves the List
  implementation, which inherited the injected pass-through for those arities —
  so without these, moving to this shape would have silently stopped
  schema-level `handle_info/3,4` from firing for batched broadcasts. That is
  the same failure mode as the bug fixed in 0.2.2, and it would have been just
  as quiet.

- A `{:halt, socket}` from an item's handler now halts the batch.
  `List.handle_info/2` took `elem(1)` of each result and always reported
  `{:cont, socket}`, downgrading a handler that had claimed the message. The
  existing test asserted the downgraded result, so it was encoding the bug; it
  now asserts the halt.

ChannelIsolationTest's assertions were rewritten for the new nesting, which is
the moment such a guard is most likely to be quietly weakened. Re-verified by
mutation afterwards: broadcasting the whole list to every channel fails 5 of
the 8, and so does a rotation that gives each channel one message of the right
size belonging to a different channel. Only the nesting in the expected values
changed, never which items appear under which channel.
The move to `{event, items}` binds the event once for a group where the old
shape bound it once per item. That looks like a loss of a guarantee, so pin
what actually holds it.

It holds structurally: `message` is a scalar argument to the call, closed over
by the `entry` builder in `broadcast/2,3` and applied per group, so there is
only ever one event value in scope. The old per-item pairing drew on that same
single argument, repeating one value N times rather than establishing anything
about any item — so no guarantee is lost, only redundancy.

Adds EventIsolationTest covering both directions: that each call's items arrive
under that call's event and never another's, that one call spanning several
channels applies its event to all of them, that the same items rebroadcast
under a second event don't retain the first, and that the event dispatched to
each item on receipt is the batch's own. Adds a Recorder fixture that records
every `{id, event}` pair it is dispatched, so the receive-side assertions are
positive rather than inferred from a handler that matches a single event.

Verified by mutation rather than by passing: hard-binding the event in
`broadcast/2` fails 6 of the 12, in `handle_info/3` fails 3, and in
`broadcast/3` fails 1.

Also pins the migration safety net. A caller still constructing
`[{item, event}, ...]` does not have those pairs quietly treated as items and
rebound to whatever event the call passed — `extract_tuple/1` passes the tuple
through as an item and `Tuple.channel/2` raises, which is loud and names the
call site.

Two findings recorded here rather than fixed, both pre-existing and neither
caused by this change:

- Defining `handle_info/3` inside a `use Amplified.PubSub do ... end` block
  replaces the injected default catch-all rather than adding to it, so an
  unmatched event raises FunctionClauseError. The moduledoc's claim that
  "the defaults always return {:cont, socket}, so unmatched messages fall
  through safely" does not survive an override. Confirmed identical for a
  single struct and for the old-shape list, so this predates the new shape.
- `test/support/fixtures/pubsub_fixtures.ex` disagrees with the formatter on
  `field :name, :string`, because .formatter.exs has no `import_deps: [:ecto]`.
  The Recorder fixture follows the file's existing style.
Overriding does mean accepting responsibility for the whole function, but the
moduledoc actively said otherwise — "the defaults always return
{:cont, socket}, so unmatched messages fall through safely" holds only until
you override something. A function defined in a `use Amplified.PubSub do ... end`
block replaces the default of that name and arity rather than adding a clause
ahead of it, so defining one `handle_info/3` removes the catch-all and an
unmatched event raises FunctionClauseError. The failure surfaces in a LiveView
process well away from the schema that caused it, which is what makes it worth
a warning admonition and a worked remedy rather than a footnote.

Adds OverrideTest and a Partial fixture to hold the documented behaviour to
account, including the claim that the arities are independent — overriding
handle_info/3 leaves the handle_info/4 default intact. That claim was checked
first with a throwaway module defined in the test file, which proved nothing:
the protocol was already consolidated, so the impl never registered and both
paths returned {:cont, socket} from `impl_for/1` being nil. It needed a real
fixture in test/support.

Recorder becomes a plain defstruct. Ecto is a test-only dependency that
predates this work and three fixtures already use Ecto.Schema, so nothing was
added for it — but a single-field struct with no changeset and no queries gains
nothing from a schema. Dropping it also buys coverage: every other fixture is
an Ecto schema, so nothing proved `use Amplified.PubSub` works on a bare
struct. It does, and channel derivation is now asserted against one.
@simoncocking
simoncocking merged commit c19554e into main Aug 13, 2026
1 check passed
@simoncocking
simoncocking deleted the feat/list-broadcast-shape branch August 13, 2026 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant