Notifications & Reactivity
How field changes propagate through the system in real time.
Notifications are how ControlBird turns a passive database into a reactive system. Instead of polling for changes, services declare interest in specific fields and the Store pushes an event whenever those fields are written. Each event carries the new value, the previous value, and any related data the subscriber asked for, all captured atomically at the moment the change occurred.
This is the foundation of ControlBird's store-centric architecture: services never call each other directly. They write to fields and react to notifications, which keeps components decoupled, makes every interaction observable, and lets services restart and catch up without special coordination. See the Architecture Overview for how this fits into the wider system, and the Data Model for how fields, entities, and indirection work.
Push, never poll
Notifications are pure push. A subscriber drains its incoming events as a non-blocking operation (or lets a pipeline drain them automatically) and never runs an active polling loop against the Store.
How a Change Propagates
When a service writes a field, the Store evaluates every registered notification and delivers an event to each interested subscriber. The flow is:
- A service writes a field.
- The Store applies the write to the in-memory tree.
- The Store checks all registered notifications for ones that match the written field.
- For each match, the Store resolves the configured context paths and builds an event containing current state, previous state, and context values.
- The event is delivered to every subscriber registered for that notification.
Registering Interest
A subscriber registers by describing the change it wants to watch and providing a receiver that it integrates into its own event loop. The registration describes what to watch; the receiver is where matching events arrive. Delivery is event-stream based rather than callback based, so subscribers stay in control of when they process work.
Notification Settings
| Setting | Description |
|---|---|
| Target | Which entity to watch. You can target a single entity instance, or target an entity type so the notification fires for every instance of that type. |
| Field | The field whose writes trigger the notification. |
| Trigger on change | When enabled, only writes that change the value notify. When disabled, every write notifies (including identical re-writes). |
| Context | A list of field paths read atomically with the change. Each path may use indirection (for example, the parent's name). |
Registrations are deduplicated
Subscribers that register identical settings share a single server-side registration: only the first one contacts the server, and the server registration is removed only when the last subscriber unregisters. The first registration returns the current values; subsequent ones return an empty result.
Example: watch a status field with context
A subscriber watches the status field of an entity and asks for two context paths alongside it: the entity's own name, and its parent's name (reached through entity-reference indirection). Whenever status changes, the subscriber receives a notification that already includes both names, with no follow-up reads required.
Trigger Modes
The trigger-on-change setting is the single most important choice when registering. It distinguishes monitoring stable configuration and state from implementing request/response exchanges.
| Mode | Behaviour | Use for |
|---|---|---|
| Trigger on change | Notifies only when the written value differs from the current one. | Configuration fields, status, and steady-state values where redundant writes should be suppressed to reduce noise. |
| Trigger on every write | Notifies on every write, even no-op rewrites of the same value. | Request/response patterns where each write is a discrete event and must be observed regardless of value. |
Example: request/response
For a request/response exchange, a subscriber watches the response field with trigger-on-every-write enabled and no context paths, so that every write to that field is observed as a distinct event regardless of its value.
Both states are always included
The trigger setting only decides whether a notification is sent. Every notification carries both current and previous state, so a subscriber can always compare before and after, even when triggering on every write and the values are identical.
The Notification Event
Each delivered notification is a self-contained, atomic snapshot. Its parts are:
| Part | Description |
|---|---|
| Current | The new state: the entity, the resolved field path, value, timestamp, and who wrote it. |
| Previous | The prior state of the field. |
| Context | The values for each registered context path, captured atomically. A context path that points at a missing entity or field still appears here, with an empty value. |
What each snapshot contains
Every value in a notification, whether current, previous, or a context entry, is an atomic snapshot of one field at one point in time. Each snapshot includes:
| Part | Description |
|---|---|
| Entity | The entity the value belongs to. |
| Field path | The resolved field path (supports indirection). |
| Value | The field's value, or empty when the read failed or the field was unset. |
| Timestamp | When the value was captured. |
| Writer | Who wrote the value, for traceability. |
Context Fields and Atomicity
Context fields are the mechanism for receiving related data alongside a change without issuing separate reads. Each context path is relative to the monitored entity and may chain through entity references. A path like "parent, name" means "the name of this entity's parent", resolved through entity-reference indirection at notification time. A single context path of "parent, name" makes one atomic notification carry both the changed field's value and the parent's name together.
Because context paths are resolved at the same instant the change is captured, the subscriber sees a consistent view. Always prefer context fields over follow-up reads; separate reads can race with later writes and break atomicity.
Receiving Notifications
Subscribers receive events by draining their incoming stream. In a standalone loop, you pull any pending events from the server (a non-blocking drain), then process them. When batching work through a pipeline, draining is handled for you.
Pipelines
When you batch operations through a pipeline, notifications are handled transparently. If a notification arrives before or between command responses while the batch runs, the pipeline intercepts it, dispatches it to local subscribers, and continues without any caller intervention.
Both synchronous and asynchronous clients support notification subscription, so the same model works in tight service loops and in async runtimes alike.
Unregistering
When a subscriber is done, it unregisters its interest. Because identical registrations are shared, only the last remaining subscriber for a given registration triggers an actual unregister on the server; earlier unregisters simply remove the local receiver.
Limitations and Guidance
- Failed indirection is not an error: if a context path references a missing entity or field, the context entry still appears with an empty value rather than dropping the notification.
- Entity-type notifications fan out: they fire for every instance of the type. With many instances this produces high volume; prefer targeting a single entity for focused monitoring.
- Drain promptly: incoming events are not rate-limited, so very high notification rates can cause memory pressure if a subscriber does not drain fast enough.
- Server memory scales with distinct registrations: each unique registration is stored server-side. Many registrations with different context paths cost more than fewer registrations that share contexts.
Choosing a registration strategy
Target a single entity with trigger-on-change and rich context for monitoring configuration and state. Use trigger-on-every-write with an empty context for request/response fields. Reserve entity-type targeting for cases where you genuinely need to react to every instance of a type.