The ETS table behind Hue.Bridge, and every path that reads it.
Why the table is named, and why that matters
The table is a named :set, :protected, with read_concurrency: true.
Named, so a reader in any process finds it from the bridge's name alone —
no GenServer.call, no lookup table, no message passing. :protected, so
only the owning server can write to it. This is the whole reason Hue.Bridge
is not the bottleneck that a cache-behind-a-GenServer becomes: nineteen
LiveViews reading nineteen lights do not queue behind each other, and they do
not queue behind an eventstream frame being merged.
Two lifecycle facts, deliberately separate
Both live in the table rather than in the server's state, so that reads can consult them without a call.
- Seeded — has a full fetch ever completed? Until it has, reads return
:not_synced, because an empty table and a bridge with no lights are indistinguishable from the outside and answering{:ok, []}would be a lie. - Status — what the connection is doing right now:
:connecting,:syncing,:live, or{:error, reason}.
They are not the same question. A bridge that synced an hour ago and lost its
eventstream five seconds ago is {:error, :closed} and still readable:
the cached state is stale by five seconds, which is far better than refusing
to answer. Hue.Bridge.status/1 is how a consumer learns the difference, and
[:hue, :stream, :disconnected] is how it learns without asking.
Never remove before inserting
seed/2 and reindex/1 both replace a whole set of keys — every resource on
a reseed, both name indexes on a reindex. An earlier version did that by
deleting first and inserting after (:ets.delete_all_objects/1, or
:ets.match_delete/2 ahead of the rebuild). Since reads never go through
this process, that opens a window any concurrent reader can land in: the
table briefly looks unseeded, or a name that still exists briefly cannot be
found. On a reconnect that window reproduces the exact lie this module
promises not to tell — a bridge that synced an hour ago reporting
:not_synced, indistinguishable from a bridge that has never synced.
The fix is the same shape in both functions: insert the new state first, then delete whatever key the new state does not contain. A key that survives the reseed is overwritten in place and is never briefly absent. A key that does not survive is removed only after its replacement — the union of everything else — is already in the table, so the deletion is the only observable change and it is exactly the one the new state calls for.
@seeded_key and @status_key are never candidates for either prune step,
for the structural reason "Key shapes" below already establishes: the match
specs that find keys to delete cannot match a bare atom. No function in this
module ever deletes @seeded_key or @status_key once set; put_status/2
overwrites @status_key in place.
Key shapes
| Key | Value |
|---|---|
{type, rid} | the resource map |
{:name, type, name} | rid |
{:rid_name, type, rid} | name |
:__seeded__ | true |
:__status__ | the status term |
Resource keys are two-tuples and index keys are three-tuples, so a match on
{{type, :"$1"}, :"$2"} selects resources and nothing else.
Summary
Functions
Applies one decoded eventstream event.
Fetches one resource by rid.
Fetches one resource by the name a user sees in the Hue app.
Every resource of one type.
Creates the table. Called by the owning server, and by nothing else.
Records what the connection is doing. Purely informational; see the moduledoc.
Replaces the cache's entire resource set with resources and rebuilds both
name indexes, insert-then-prune throughout — see "Never remove before
inserting" above. @status_key is never written here: it was never removed,
so there is nothing to restore. @seeded_key is set last, so the first
seed still gates reads as :not_synced right up until the table is actually
populated; a reseed leaves it true throughout, because nothing before this
final insert ever unset it.
Reports the connection's status, or :not_started when no bridge owns this
table. Never raises, and never calls a process.
Types
Functions
@spec apply_event(table(), Hue.Event.t()) :: :ok
Applies one decoded eventstream event.
update deep-merges (see Hue.Bridge.Merge), add inserts, delete
removes, and error changes nothing — an error envelope describes a failure
on the bridge, not a state transition, and the server surfaces it through
telemetry and to subscribers instead.
Why a rename rebuilds the whole index
An event that changes metadata.name or services invalidates index entries
that no longer have any resource pointing at them, and there is no cheap way
to find them from the delta alone — a device rename orphans one entry per
service it owns. Rebuilding both indexes from the table costs a walk of ~178
resources, which is microseconds, and it is unconditionally correct. Renames
are rare; a subtly stale name index would not be.
@spec fetch(table(), atom(), String.t()) :: {:ok, map()} | {:error, Hue.Error.t()}
Fetches one resource by rid.
@spec fetch_by_name(table(), atom(), String.t()) :: {:ok, map()} | {:error, Hue.Error.t()}
Fetches one resource by the name a user sees in the Hue app.
@spec list(table(), atom()) :: {:ok, [map()]} | {:error, Hue.Error.t()}
Every resource of one type.
Creates the table. Called by the owning server, and by nothing else.
Records what the connection is doing. Purely informational; see the moduledoc.
Replaces the cache's entire resource set with resources and rebuilds both
name indexes, insert-then-prune throughout — see "Never remove before
inserting" above. @status_key is never written here: it was never removed,
so there is nothing to restore. @seeded_key is set last, so the first
seed still gates reads as :not_synced right up until the table is actually
populated; a reseed leaves it true throughout, because nothing before this
final insert ever unset it.
Reports the connection's status, or :not_started when no bridge owns this
table. Never raises, and never calls a process.