Repository navigation
Send list broadcasts as {event, items} - #4
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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:
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
Listimplementation 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 toEnum.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
Tupledispatcher unpacks{event, items}, resolves theListimplementation for the subject, and callshandle_info/3— whichListinherited 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,4now 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/2tookelem(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
EventIsolationTestpins why.messageis a scalar argument to the call, closed over by theentrybuilder 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/2fails 6 of the 12, inhandle_info/3fails 3, inbroadcast/3fails 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: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 ause Amplified.PubSub do ... endblock replaces the default of that name and arity rather than adding a clause ahead of it, so defining onehandle_info/3removes the catch-all and an unmatched event raisesFunctionClauseErrorin 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.
OverrideTestholds it to account, including that the arities are independent: overridinghandle_info/3leaves thehandle_info/4default intact.Migration
The two handlers collapse into one, and the unwrapping goes away:
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/1passes the tuple through as an item andTuple.channel/2raises"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), aRecorderfixture that logs every{id, event}it's dispatched so receive-side assertions are positive rather than inferred, and aPartialfixture holding the override pitfall.Recorderis a plaindefstructrather than anEcto.Schemalike the older fixtures — it needs nothing Ecto provides, and it's now the one fixture provinguse Amplified.PubSubworks on a bare struct. Channel derivation is asserted against it.