-
Notifications
You must be signed in to change notification settings - Fork 1
Journals
A journal is the Runtime's append-only log of what happened to a machine's resources since the last report. Every change a controller needs to know about, such as a config write, a state change, a command becoming unavailable or an event being emitted, is written to a journal first. On each report export, the journals are drained into the RuntimeReport.
Journals are the reason a controller can rebuild the complete, ordered history of every machine without the Runtime keeping any history itself. That's what the protocol means by "reports are deltas" (see Protocol).
Code: qitech_framework/src/resource/journal.rs. Record types: qitech_framework_core/src/report/.
There is one journal per kind of change, grouped in Journals:
| Journal | Record type | Written when |
|---|---|---|
config_property |
ConfigPropertyEvent |
a config property is registered, written (by the machine or the controller), or its default, write permission or constraints change |
state_property |
StatePropertyEvent |
a state property is registered, or its value changes |
command |
CommandEvent |
a command is registered, executed, or its can_execute result changes |
event |
String (the payload as JSON) |
a machine calls EventEmitter::emit
|
Measurements are not journaled. They change too often to record every value, so the Runtime samples them once per export instead (MeasurementSnapshot). This is the key difference between a measurement and a state property. See Resources.
Every entry is wrapped in an EventRecord<T>:
struct EventRecord<T> {
timestamp: DateTime<Utc>, // when it happened, not when it was exported
machine: MachineInstanceIdentification,
path: String, // resource path
event: T, // what happened
}The timestamp is taken when the record is written, so a controller sees the real time of each change within the ~31 ms export window.
enum ConfigPropertyEvent {
Registered { default, capability, constraints }, // initial snapshot
Written { value, origin, outcome }, // every write attempt
DefaultChanged(value),
CapabilityChanged(OperationCapability), // allow/forbid external writes
ConstraintsChanged(Constraints),
}
enum StatePropertyEvent {
Registered { value },
ValueChanged { value },
}
enum CommandEvent {
Registered,
CapabilityChanged(OperationCapability),
Executed(Result<(), CommandExecuteError>),
}A Written record captures the complete attempt, not just successful changes:
-
originisMachine(the machine calledset) orRequest { request_id }(a controller request), which links the record to the request that caused it. -
outcomeisAccepted { changed }, wherechanged: falsemeans the value was already equal, orRejected(reason), for exampleNotWritableorConstraintViolation.
A rejected write is journaled with the value that was attempted, so the history also shows what someone tried to do.
build every cycle every export (1/32 s)
┌──────────────────────┐ ┌───────────────────────────┐ ┌──────────────────────────────┐
│ Registered records │ │ handles call record() │ │ drain_with(): records move │
│ → temporary journals │─────►│ requests call record() │─────►│ into RuntimeReport, journal │
│ imported on success │ │ (append to Vec) │ │ is empty again │
└──────────────────────┘ └───────────────────────────┘ └──────────────────────────────┘
While a machine builds, its Registered records go into temporary journals (BuildContext::journals_temp), not the real ones. The Runtime then either:
-
imports them into the real journals (
Journal::import) ifbuildsucceeds, or -
drops them if
buildfails, along with the resource allocations, so the controller never sees resources of a machine that doesn't exist.
This mirrors the rollback of the resource storage (PropertyRegistrar).
Resource handles don't talk to Journals directly. Each one holds a JournalHandle<T>, a cheap clone of the journal's shared buffer plus its own ResourceKey (machine and path). So ConfigProperty::set can call self.journal.record(event) without passing the machine or path around:
pub(crate) struct Journal<T> { buffer: Rc<RefCell<Vec<EventRecord<T>>>> }
pub(crate) struct JournalHandle<T> { buffer: Rc<RefCell<Vec<EventRecord<T>>>>, key: ResourceKey }The Runtime itself writes to the journals directly (Journal::record(machine, path, event)) in two cases:
-
controller requests: external config writes and command executions, in
runtime/request.rs; -
capability scans: on each export, the Runtime evaluates every command's
can_executeand recordsCapabilityChangedwhen the result differs from last time.
Because everything runs on the one Runtime thread, Rc<RefCell<…>> is enough and no locking is needed.
In Runtime::export_report_if_due, each journal is drained in full into the matching MachinesReport list (config_property_records, state_property_records, command_records, event_records), and the report is sent. Nothing stays behind, and nothing is ever dropped. That's what makes the report stream complete.
-
Within one journal, records are in the order they were written. Timestamps are non-decreasing in practice. They come from the wall clock (
Utc::now()), so a clock adjustment can break that. -
Across journals, there is no shared order. A report contains four separate lists. To interleave them, for example "the config was written, then the state changed", sort by
timestamp. - Across reports, all records in report n happened before the records in report n + 1.
-
Registeredcomes first. A resource'sRegisteredrecord is in the first report, before any other record for that resource.
- To rebuild a resource's current value: start from
Registered, then apply everyWrittenwithoutcome: Accepted { changed: true }(config) or everyValueChanged(state) in order. - Show
Rejectedwrites to the user. They explain why a setting "didn't stick". - Match
origin: Request { request_id }against your own pending requests to show who changed what. - Never skip a report. A missing report means missing records, and your rebuilt state diverges silently.