DpExchange.Coinbase.Feed (DpExchangeCoinbase v0.1.27)

Copy Markdown View Source

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:

statequotes (ticker)order_book (level2)
broken (4 boots)~5 / 406~406 / 406, 11,000+ frames
healthy (1 boot)400 / 4066 / 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

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

coverage(feed \\ __MODULE__)

@spec coverage(GenServer.server()) :: %{
  required(String.t()) => :stream | :internal_poll | :not_covered
}

coverage_by_kind(feed \\ __MODULE__)

@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".

pairs_per_socket()

@spec pairs_per_socket() :: pos_integer()

shards(symbols)

@spec shards([String.t()]) :: [[String.t()]]

The scope split into one list per socket.

start_link(opts)

@spec start_link(keyword()) :: GenServer.on_start()

subscribe(feed \\ __MODULE__, symbols, opts \\ [])

@spec subscribe(GenServer.server(), [String.t()], keyword()) :: :ok | {:error, term()}

subscribe_notices(feed \\ __MODULE__, opts \\ [])

@spec subscribe_notices(
  GenServer.server(),
  keyword()
) :: :ok

unsubscribe(feed \\ __MODULE__, symbols)

@spec unsubscribe(GenServer.server(), [String.t()]) :: :ok | {:error, term()}

update_symbols(feed \\ __MODULE__, symbols)

@spec update_symbols(GenServer.server(), [String.t()]) :: :ok | {:error, term()}