This venue's subscription lifecycle — internal. The facade's subscribe/2,
unsubscribe/2, update_symbols/2 and coverage/1 are served from here.
What a consumer can and cannot learn
A consumer learns what is arriving, through coverage/1. It cannot learn how: this
module owns the sockets, the sharding and the pacing, and none of that reaches the
facade.
Coverage is observed, never intended
A symbol enters the coverage map when a payload for it arrives, never when it is subscribed. That distinction is the strongest guarantee in the contract and it exists because a venue once reported 325 symbols subscribed and confirmed while 174 were delivering. Reporting the subscription would have said 325.
A symbol that has been subscribed and has delivered nothing is simply absent, which the
facade documents as :not_covered.
Sharded — this used to run on one connection, and that stopped being true
This venue's whole scope used to run on a single socket, and that was measured: once its self-killing heartbeat was fixed it subscribed 401 of 401 pairs on one connection, and sharding it anyway opened fourteen connections for no gain.
That stopped being true on 2026-08-26. Coinbase started answering a level2
subscribe over its per-session limit with "too many L2 streams requested in a single session", closing the socket — a total data gap, not degraded coverage: measured
2026-08-27 against a real ~400-symbol universe, 355 of 405 pairs went stale and 1,480
refusals were logged in one window. The measurement about one socket being enough was
honest when it was taken; it stopped being true the moment the venue's own limit did.
@pairs_per_socket is 100, carried over from the reference fix this replaces
rather than re-derived — the number came from a real production incident, not this
package's own probing, and is recorded as such rather than presented as freshly
measured.
level2 before ticker, on the same socket
Each shard's socket carries both channels for its own slice of symbols, level2
subscribed first: it is what takes a shard from partial to full coverage, and
subscribing it before the lighter channel means the book is already flowing by the
time ticker adds its own load. The two are spaced apart on the wire — a level2
subscribe triggers a full per-symbol book snapshot, and firing ticker's subscribe
into a socket still decoding that arrives as a send_timeout and can take the
connection down with it.
A timed-out subscribe used to be thrown away — now it is retried
FrameSender's own moduledoc says the whole point of turning a send_frame exit into
{:error, :send_timeout} is that "a slow socket becomes a failed batch, which a caller
can report and retry, rather than a dead connection", and that subscribes are idempotent
on every venue in this family, so a duplicate is harmless. This module used to log that
error and drop it — the retry half of the design was never wired, so a channel_subscribe
that lost the race against a level2 snapshot burst simply stayed unsubscribed until the
next unconditional resubscribe tick, which reproduces the identical busy-socket condition
and fails identically.
This is not hypothetical. A consumer running against a real ~400-symbol universe measured the exact inversion this predicts, across five boots over roughly 5.5 hours:
| state | quotes (ticker) | order_book (level2) |
|---|---|---|
| broken (4 boots) | ~5 / 406 | ~406 / 406, 11,000+ frames |
| healthy (1 boot) | 400 / 406 | 6 / 406 |
When level2 gets through broadly, its opening snapshot burst is what starves ticker;
when the venue refused most level2 subscriptions outright (its own per-session stream
limit — see above), ticker had the socket to itself and got everything. A lone
:send_timeout on a ticker subscribe was also observed directly in an earlier run.
Both are DpCryptoManagement's issue #22.
Classify before retrying — not every failure can be fixed by waiting
{:error, :send_timeout} and {:error, {:send_exit, reason}} are transient: the
socket was busy decoding a burst, or briefly gone, and the identical request can
reasonably succeed once it catches up. {:error, {:credentials_required, channel}} (see
Socket's subscription_message/3) is permanent — no amount of waiting supplies a
credential that was never given, and retrying it would only loop, so it fails loudly on
the first attempt and is never rescheduled.
The backoff borrows a number this module already trusts, rather than inventing one
A retry needs to wait out the same busy-socket condition @channel_spacing_ms already
exists to wait out between level2 and ticker on the same socket — so
@subscribe_retry_delay_ms is @channel_spacing_ms, not a second, independently
guessed number for the same underlying wait. @max_subscribe_retries is 2: one initial
attempt plus two retries is enough to survive one snapshot burst without turning a stuck
socket into an unbounded loop. See the constants' own comments for the arithmetic that
keeps the whole retry chain well inside a resubscribe cycle, so it can never stack frames
against the unconditional re-issue documented above.
Exhaustion is loud
A channel that never subscribed is exactly the invisible half-dead feed this whole issue
is about, and it used to surface as a Logger.warning a consumer had no facade-level way
to see. Giving up — whether because the failure was permanent or because retries ran out
— now also emits a Core.Notice of kind :coverage_change: those symbols will not
deliver this kind of data, which is exactly the fact coverage/1 and coverage_by_kind/1
need a consumer to go re-check rather than discover from a quiet chart.
A reconnect that does not resubscribe is a coverage collapse with no error
WebSockex reconnects a dropped socket on its own, and a bare reconnect leaves it connected and subscribed to nothing — silently, because a socket that is up and receiving nothing is not itself an error. That is a real, measured incident on this venue's own reference implementation: coverage decayed from full to the REST-poll floor over roughly forty minutes with the feed still reporting healthy, because nothing re-asked the venue for anything after the reconnect.
This coordinator re-issues every shard's subscriptions on a timer, unconditionally. Re-subscribing a channel the socket already carries costs one frame the venue ignores; not re-subscribing one it silently dropped costs the shard's whole coverage until someone notices a quiet chart.
Every shard beyond the first must open on its own tick, not the same one
@shard_spacing_ms staggers shard opens relative to each other, not relative to a
fixed instant. A scope wide enough to need three or more shards — DpCryptoManagement's
issue #20, 406 symbols / 5 shards, filed against real production traffic — used to
schedule every shard past the first (the synchronous one) with the same fixed delay,
so all of them opened in the same instant: exactly the connect burst this module's own
design note above warns the venue answers with resets. Only the shard whose burst-mate
connections lost that race ever delivered a tick; coverage sat at whatever fraction of
one shard survived, indistinguishable from the outside from a quiet market. The
60-second unconditional resubscribe re-issued the same burst every minute. Both paths
now schedule each shard's turn position * @shard_spacing_ms after the one before it.
The venue rewrites an aliased product id on delivery, and that has to be undone HERE
Measured live, 2026-09-05, against wss://advanced-trade-ws.coinbase.com:
subscribing ticker to ["XLM-USDC", "AVAX-USDC"] — sent exactly as asked, both real,
listed products — delivers every frame tagged XLM-USD and AVAX-USD. The venue's own
subscription acknowledgement even echoes the rewritten names back
("ticker" => ["XLM-USD", "AVAX-USD"]), not the ones actually sent. This is the venue's
own declared behaviour, not a guess: Rest.get_alias_map/1 reads the same public
/market/products catalogue this module already reaches through Rest.get_symbols/1
and Rest.list_instruments/1, and on this date 112 of the first 114 USDC products
carried a non-empty alias naming their -USD counterpart. A caller subscribed under
the alias form received nothing under the name it asked for while a name it never asked
for arrived instead — measured against a real 406-symbol consumer scope
(DpCryptoManagement's issue #22): 174 of 406 requested pairs delivered nothing, while
401 pairs never requested were decoded and stored.
Attribution lives here, not in Socket
Socket stays venue-mechanics-only: it decodes a frame and delivers a struct tagged
with whatever product_id the venue actually sent, exactly as it did before this fix.
Every consumer-facing rewrite happens in this module's handle_info({:dp_exchange, :coinbase, payload}, state), immediately before a delivered payload is recorded as
coverage and fanned out — because this is the one place that already holds wanted
(what the caller actually asked for) beside the delivered payload. Duplicating wanted
into Socket just to make the same decision twice would be a second place for the two
to disagree; Socket.books for level2 stays keyed by whatever id the venue delivers
under, which is correct and unobservable — one maintained book per real market, whether
one or two caller-facing names point at it, matching whichever channel delivered it
(ticker via deliver_ticker/3 or level2 via apply_book_event/3/deliver_book/3
in Socket) since both arrive here as the same {:dp_exchange, :coinbase, payload}
shape and both structs carry :symbol.
Built once, from the venue's own catalogue, never from string-munging
Rest.get_alias_map/1 is the only source for this map — reusing the same
/market/products fetch get_symbols/1 and list_instruments/1 already make, per the
standing rule against a second way to ask. Munging -USDC into -USD would be exactly
the "nearby substitute" this family forbids, and would be wrong for any pair the venue
does not alias — nothing here assumes the suffix relationship holds in general.
It is fetched once, asynchronously, the first time subscribe/3 or
update_symbols/2 is called (maybe_schedule_alias_map_fetch/1, gated on
alias_map_status: :unfetched so a second call never re-schedules it) — not from
init/1, so a Feed that is merely supervised and never asked to stream anything never
makes a network call, and not synchronously inside the triggering handle_call/3, so it
never competes with @call_timeout's socket-connect budget. Until it resolves, and
forever if it fails, state.alias_map is simply %{} — indistinguishable, by design,
from "the venue aliases nothing here", which resolves to the same safe fallback below.
A failed fetch is never retried: this mirrors the file's other "decide once, unconditionally"
choices (the resubscribe timer, the shard stagger) rather than adding a second kind of
recovery logic, and a Feed that needs to try again is restarted, same as any other
stuck state a supervisor exists to clear.
A failed fetch also reports itself exactly once, as a :data_quality notice to
notice_subscribers — "attribution is degraded and here is why" — never a silently
guessed mapping. attribution_targets/2 is the single fallback for every case where no
wanted name resolves — unfetched, failed, legitimately alias-free, or a frame arriving
for a symbol outside wanted altogether (in-flight just after an unsubscribe, or a raw
test send/2): deliver under whatever the venue actually sent, exactly the pre-fix
behaviour, rather than inventing a name.
Both caller-facing names, when both are wanted
The venue treats XLM-USDC and XLM-USD as one market. If a caller subscribes to
both, both are entitled to every update — attribution_targets/2 resolves a delivered
id to every name in wanted that names the same market (its own id and, where the
catalogue says so, its alias), and the delivery loop sends one copy per resolved name.
This holds regardless of whether the venue itself echoes one frame or two per update for
a dual subscription — resolution runs per delivered frame against the full candidate
set, so two frames naming the same pair of wanted symbols do not double-deliver into a
name twice per market update; they each resolve to the same one-or-both names again.
coverage/1 needed no code change to become honest
Coverage was already whatever key delivering holds — see the moduledoc up top. Once
delivery is recorded under the caller's own requested name instead of the venue's
rewritten one, coverage/1 reports exactly what was asked for, by construction, with
nothing endpoint-specific added at the coverage/1 call site itself.
coverage_by_kind/1 — the same fact, split by what actually arrived
coverage/1 answers "is anything arriving for this symbol", and it answers that
question truthfully — but it asks nothing about which kind of payload showed up.
Types.Quote and Types.OrderBook both carry :symbol, so a level2 book update and
a ticker quote count identically toward delivering, and a symbol with one of the two
dark looks exactly like a symbol with both healthy.
That is not a hypothetical: level2 on this venue delivered upward of 11,000 frames
across 406 subscribed symbols while ticker stayed dark on all but a handful of them,
and coverage/1 still answered :stream for all 406 — correctly, by its own definition,
and useless for telling anyone that quotes had gone silent. Two separate
DpCryptoManagement issues (#20 and #22) sat unpinned for days because nothing in this
package's own observability could distinguish "everything is fine" from "the book is
fine and the ticker is dead". coverage_by_kind/1 exists to make that distinction
answerable without adding a second, differently-shaped API: it reports the same
observed-arrival fact coverage/1 reports, just partitioned by
DpExchange.Core.Capabilities.data_kind/0 instead of collapsed across it.
The kind is read off the payload's own struct — %Types.Quote{} is :quotes,
%Types.OrderBook{} is :order_book — never off a channel name. level2 and ticker
are this venue's words for its own wire protocol and stop existing the moment a frame
becomes a Core.Types.* struct; coverage_by_kind/1 never sees them and could not leak
them if it wanted to.
state.delivering therefore keys each symbol to a small map of kind => timestamp
rather than a single timestamp, so a symbol that has delivered both a quote and a book
update carries both kinds at once, and one going dark does not erase the other.
coverage_by_kind/1 folds that structure the other way — kind first, then symbol — to
match the shape DpExchange.Core.Venue.coverage_by_kind/1 promises.
Summary
Functions
Returns a specification to start this module under a supervisor.
coverage/1, split by which Core.Types.* kind actually arrived — see the moduledoc's
"coverage_by_kind/1" section for why coverage/1 alone could not tell "ticker dark,
book healthy" apart from "everything healthy".
The scope split into one list per socket.
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec coverage(GenServer.server()) :: %{ required(String.t()) => :stream | :internal_poll | :not_covered }
@spec coverage_by_kind(GenServer.server()) :: %{ required(DpExchange.Core.Capabilities.data_kind()) => %{ required(String.t()) => :stream | :internal_poll | :not_covered } }
coverage/1, split by which Core.Types.* kind actually arrived — see the moduledoc's
"coverage_by_kind/1" section for why coverage/1 alone could not tell "ticker dark,
book healthy" apart from "everything healthy".
@spec pairs_per_socket() :: pos_integer()
The scope split into one list per socket.
@spec start_link(keyword()) :: GenServer.on_start()
@spec subscribe(GenServer.server(), [String.t()], keyword()) :: :ok | {:error, term()}
@spec subscribe_notices( GenServer.server(), keyword() ) :: :ok
@spec unsubscribe(GenServer.server(), [String.t()]) :: :ok | {:error, term()}
@spec update_symbols(GenServer.server(), [String.t()]) :: :ok | {:error, term()}