Hue.Bridge.Cache (Hue v0.2.0)

Copy Markdown View Source

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

KeyValue
{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

status()

@type status() :: :connecting | :syncing | :live | {:error, term()}

table()

@type table() :: atom()

Functions

apply_event(table, event)

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

fetch(table, type, rid)

@spec fetch(table(), atom(), String.t()) :: {:ok, map()} | {:error, Hue.Error.t()}

Fetches one resource by rid.

fetch_by_name(table, type, name)

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

list(table, type)

@spec list(table(), atom()) :: {:ok, [map()]} | {:error, Hue.Error.t()}

Every resource of one type.

new(table)

@spec new(table()) :: table()

Creates the table. Called by the owning server, and by nothing else.

put_status(table, status)

@spec put_status(table(), status()) :: :ok

Records what the connection is doing. Purely informational; see the moduledoc.

seed(table, resources)

@spec seed(table(), [map()]) :: :ok

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.

status(table)

@spec status(table()) :: status() | :not_started

Reports the connection's status, or :not_started when no bridge owns this table. Never raises, and never calls a process.