The process behind Hue.Bridge. Owns the ETS table; nothing else writes to it.
What this process is, and is not, on the hot path for
It is not on the read path at all. Hue.Bridge.fetch/3 is an :ets.lookup
in the calling process, and this server never hears about it. What the server
owns is everything that has to be serialised: seeding the cache, merging
events into it, and — from Task 11 — pacing writes.
Startup is not allowed to depend on the bridge
init/1 creates the table and returns. Everything else runs in
handle_continue/2, after start_link/1 has already returned to the
supervisor. A bridge that is rebooting, unreachable, or answering 503 delays
nothing: the consuming application boots, reads report :not_synced, and
status/1 says why.
The connect sequence, and why it is in this order
open the stream → buffer what it delivers → fetch → seed → replay buffer → liveFetching first and then opening the stream loses every change that happens in between. Opening first and replaying the buffer on top of the seed applies each change after the state it modifies, which is the only ordering that ends correct.
This narrows the window without closing it. Hue.Events.stream/2 connects
lazily on first enumeration, so there is a moment between "the task started"
and "the socket is open" that this process cannot observe. It is smaller than
the fetch it replaces — request headers against 154 KB of response — and
narrowing it costs nothing, but it is a narrowing and not a guarantee.
Reconnect always refetches
No Last-Event-ID resumption. The alternative buys 154 KB on an event that
should be rare, and pays for it with a class of bug in which resumption
appears to have succeeded while events were in fact missed.
A dead stream is this library's characteristic failure
It is silent: every read keeps answering, and every answer is quietly
stale. No keepalive arrives on an idle stream (see Hue.Events's "Silence
is not evidence of anything"), so an idle stream and a dead one are
protocol-indistinguishable — nothing on the wire tells them apart.
[:hue, :stream, :disconnected] is the only thing that reveals a drop, which
is why every path that ends the stream task, cleanly closed or crashed
alike, is routed through disconnected/2.
Why a fetch retry carries a generation number
A fetch failure schedules its own retry (sync_failed/2), independently of
whatever the stream is doing. That is deliberate: a fetch answered 503 while
the stream is fine should retry the fetch alone, not tear down a perfectly
good connection and its buffer along with it.
But the stream can also disconnect while that retry is still pending —
the fetch and the stream fail for unrelated reasons, on their own schedules.
Left unguarded, the stale retry would eventually fire, seed from state that
disconnected/2 already cleared, and declare :live with no stream task
running at all, while the real reconnect cycle disconnected/2 scheduled is
still in flight behind it. generation is incremented once per disconnect
and stamped on every scheduled retry; handle_info/2 drops any retry whose
stamp does not match the current one, which is the only place this needs to
exist — :reconnect always starts a fresh cycle and needs no such guard.
Writes are queued here, but never run here
handle_cast({:write, ...}) only ever touches Hue.Bridge.Writes — a pure
struct — and arms a timer. The PUT itself is sent by send_write/4 from a
task on Bridge.tasks/1, via Task.Supervisor.async_nolink/2. Two things
follow from async_nolink specifically, not from async/2:
- A write that crashed its task does not crash this server. It has no
caller left to report to by the time it fails (
write/4already returned:ok), sohandle_info/2catches the{:DOWN, ...}and routes it toreport_write_failure/3instead of letting the ordinary supervised-task link tear this process down over an HTTP error. - A write's completion arrives as the same
{ref, result}/{:DOWN, ...}shapes the stream task already produces.write_tasksis a second map keyed by ref specifically so the two families of message cannot be confused for each other, matched withis_map_key(tasks, ref). What actually keeps a write's completion from being mistaken for the stream's is not clause order — aTask.Supervisor.async_nolink/2reference is unique per call, so a write's ref can never equalstate.stream_task.refand never becomes a key inwrite_tasksby accident either. Clause order only decides that a write's completion is matched by somehandle_info/2clause at all, rather than falling through to the catch-allhandle_info(_message, state)and being silently ignored — which is why these clauses sit before it.
Writes.due_in/3 can answer :never
Nothing is pending for a type once its last write has been taken — and
Process.send_after/3 raises ArgumentError given :never where it wants
a non-negative integer. arm_flush/2 branches on it explicitly rather than
forwarding whatever due_in/3 returned, because dialyzer does not catch
this: success typing only rejects calls that can never succeed, and
due_in/3's return type is a union that also contains
non_neg_integer(), so a probe that skips the branch and passes the value
straight through compiles clean.
A failed write has no caller left
write/4 already returned :ok by the time a write task's result is
known, so a failure cannot be returned to anyone — it is reported instead,
through [:hue, :write, :failed] telemetry and through an error event to
whoever subscribed. See report_write_failure/3.
Summary
Functions
Returns a specification to start this module under a supervisor.
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.