Hue.Bridge.Server (Hue v0.2.0)

Copy Markdown View Source

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  live

Fetching 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/4 already returned :ok), so handle_info/2 catches the {:DOWN, ...} and routes it to report_write_failure/3 instead 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_tasks is a second map keyed by ref specifically so the two families of message cannot be confused for each other, matched with is_map_key(tasks, ref). What actually keeps a write's completion from being mistaken for the stream's is not clause order — a Task.Supervisor.async_nolink/2 reference is unique per call, so a write's ref can never equal state.stream_task.ref and never becomes a key in write_tasks by accident either. Clause order only decides that a write's completion is matched by some handle_info/2 clause at all, rather than falling through to the catch-all handle_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

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.