Skip to main content

bacnet_server/event_enrollment/
commit.rs

1use std::collections::{HashMap, HashSet};
2
3use bacnet_objects::database::ObjectDatabase;
4use bacnet_objects::event::{
5    EventStateChange, EventTransition, EventTransitionCommit, EventTransitionCommitError,
6};
7use bacnet_objects::event_enrollment::EventEnrollmentReliabilityCommit;
8use bacnet_objects::event_enrollment::{EventEnrollmentEvalState, EventEnrollmentMonitoredSource};
9use bacnet_types::enums::{EventState, EventType, PropertyIdentifier, Reliability};
10use bacnet_types::primitives::{ObjectIdentifier, PropertyValue};
11
12use super::EventEnrollmentTransition;
13use crate::server::event_notification_payload::{
14    project_event_enrollment_payload, CapturedReferencedValue, CommittedNotificationPayload,
15    EventEnrollmentProjectionSnapshot,
16};
17use crate::server::event_timestamp::{
18    confirm_event_timestamp, stage_event_timestamp, SampledEventClock,
19};
20
21/// Stage at which an Event Enrollment observation or commit was evaluated.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum EventEnrollmentEvaluationStage {
24    /// The enrollment produced no mutation or transition proposal.
25    Evaluation,
26    /// Monitored-source ownership was being updated.
27    EvaluationSource,
28    /// Private countdown or baseline state was being updated.
29    EvaluationState,
30    /// Reliability observation or the combined Reliability transition hook ran.
31    Reliability,
32    /// The atomic Event_State/Acked_Transitions/Event_Time_Stamps hook ran.
33    EventTransition,
34}
35
36/// Observable result of one Event Enrollment evaluation stage.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum EventEnrollmentEvaluationOutcome {
39    /// Evaluation completed without a transition.
40    NoTransition,
41    /// A pending transition was canceled and its private state was stored.
42    CancellationCommitted,
43    /// A required local or remote observation was temporarily unavailable.
44    ///
45    /// No public Reliability, Event_State, acknowledgement, event-history,
46    /// timestamp, or sequence transition is committed. The repository-local
47    /// D4 policy may clear private continuity state and monitored-source
48    /// ownership.
49    ObservationUnavailable,
50    /// A required internal mutation was rejected.
51    Rejected,
52    /// A custom hook returned an error after Reliability or Event_State landed.
53    ///
54    /// This violates the atomic hook contract. The evaluator suppresses the
55    /// result token, does not consume the staged clockless sequence number,
56    /// and invalidates private evaluation state for a later reset.
57    LandedAfterError,
58}
59
60impl EventEnrollmentEvaluationOutcome {
61    /// Whether this outcome represents a commit failure.
62    pub fn is_failure(self) -> bool {
63        matches!(self, Self::Rejected | Self::LandedAfterError)
64    }
65}
66
67/// One structured Event Enrollment evaluation diagnostic.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct EventEnrollmentEvaluationDiagnostic {
70    /// Enrollment whose evaluation produced this diagnostic.
71    pub enrollment_oid: ObjectIdentifier,
72    /// Evaluation or commit stage that produced the outcome.
73    pub stage: EventEnrollmentEvaluationStage,
74    /// Observable stage outcome.
75    pub outcome: EventEnrollmentEvaluationOutcome,
76}
77
78/// Precedence source that selected a committed Event Enrollment Reliability.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum EventEnrollmentReliabilityCause {
81    /// A definitive local configuration defect selected CONFIGURATION_ERROR.
82    Configuration,
83    /// The monitored object's nonzero Reliability selected MONITORED_OBJECT_FAULT.
84    MonitoredObject,
85    /// A supported configured fault algorithm selected the value or recovery.
86    FaultAlgorithm,
87}
88
89/// One successfully committed Event Enrollment Reliability result.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct EventEnrollmentReliabilityResult {
92    /// Event Enrollment object whose atomic commit succeeded.
93    pub enrollment_oid: ObjectIdentifier,
94    /// Configured monitored object, absent for a malformed/missing reference.
95    pub monitored_oid: Option<ObjectIdentifier>,
96    /// Reliability value read before the commit.
97    pub previous_reliability: Reliability,
98    /// Reliability value stored by the commit.
99    pub new_reliability: Reliability,
100    /// FAULT entry, re-entry, or recovery committed with Reliability.
101    pub state_change: Option<EventStateChange>,
102    /// Whether Event_Enable permits distribution for the transition coordinate.
103    pub distribute: bool,
104    /// Precedence source that selected `new_reliability`.
105    pub cause: EventEnrollmentReliabilityCause,
106}
107
108impl EventEnrollmentReliabilityResult {
109    /// Select the event type for this committed Reliability result.
110    ///
111    /// `configured_algorithm` is the Event Enrollment object's configured
112    /// Event_Type. `None` means that the result has no committed state change.
113    pub fn event_type(&self, configured_algorithm: EventType) -> Option<EventType> {
114        self.state_change
115            .as_ref()
116            .map(|change| change.event_type(configured_algorithm))
117    }
118}
119
120/// Complete result of one Event Enrollment evaluation pass.
121#[derive(Debug, Clone, Default, PartialEq)]
122pub struct EventEnrollmentEvaluationReport {
123    /// Transitions whose complete atomic object commit succeeded.
124    pub transitions: Vec<EventEnrollmentTransition>,
125    /// Reliability results whose complete combined object commit succeeded.
126    pub reliability_results: Vec<EventEnrollmentReliabilityResult>,
127    /// Evaluation, observation, and commit diagnostics in enrollment evaluation order.
128    pub diagnostics: Vec<EventEnrollmentEvaluationDiagnostic>,
129}
130
131/// Public result family carried by one private, already-committed delivery.
132pub(crate) enum CommittedEventEnrollmentResult {
133    Normal(EventEnrollmentTransition),
134    Reliability(EventEnrollmentReliabilityResult),
135}
136
137/// One Event Enrollment commit plus policy captured under its database guard.
138pub(crate) struct CommittedEventEnrollmentDelivery {
139    pub(crate) result: CommittedEventEnrollmentResult,
140    pub(crate) ack_required: bool,
141    pub(crate) recipient_clock: SampledEventClock,
142    pub(crate) event_type: EventType,
143    pub(crate) event_values: CommittedNotificationPayload,
144}
145
146/// Public report plus the commit-order stream consumed only by the server.
147pub(crate) struct EventEnrollmentEvaluationBatch {
148    pub(crate) report: EventEnrollmentEvaluationReport,
149    pub(crate) deliveries: Vec<CommittedEventEnrollmentDelivery>,
150}
151
152pub(crate) fn log_evaluation_report(report: &EventEnrollmentEvaluationReport) {
153    for result in &report.reliability_results {
154        tracing::debug!(
155            enrollment = %result.enrollment_oid,
156            monitored = ?result.monitored_oid,
157            previous = ?result.previous_reliability,
158            new = ?result.new_reliability,
159            state_change = ?result.state_change,
160            distribute = result.distribute,
161            cause = ?result.cause,
162            "Event enrollment: reliability committed"
163        );
164    }
165    for diagnostic in report
166        .diagnostics
167        .iter()
168        .filter(|diagnostic| diagnostic.outcome.is_failure())
169    {
170        tracing::warn!(
171            enrollment = %diagnostic.enrollment_oid,
172            stage = ?diagnostic.stage,
173            outcome = ?diagnostic.outcome,
174            "Event enrollment: internal commit failed"
175        );
176    }
177}
178
179/// Coalesced desired mutation for one enrollment and one evaluation pass.
180#[derive(Default)]
181pub(super) struct EnrollmentUpdate {
182    eval_state: Option<EventEnrollmentEvalState>,
183    eval_source: Option<Option<EventEnrollmentMonitoredSource>>,
184    clears_invalidation: bool,
185    fired: Option<FiredTransition>,
186    reliability: Option<ReliabilityUpdate>,
187    observation_unavailable: bool,
188    canceled: bool,
189}
190
191pub(super) struct FiredTransition {
192    pub(super) monitored_oid: ObjectIdentifier,
193    pub(super) event_type: EventType,
194    pub(super) from: EventState,
195    pub(super) to: EventState,
196    pub(super) distribute: bool,
197    pub(super) ack_required: bool,
198    pub(super) projection: EventEnrollmentProjectionSnapshot,
199}
200
201pub(super) struct ReliabilityUpdate {
202    pub(super) monitored_oid: Option<ObjectIdentifier>,
203    pub(super) previous: Reliability,
204    pub(super) desired: Reliability,
205    pub(super) from: EventState,
206    pub(super) to: EventState,
207    pub(super) distribute: bool,
208    pub(super) ack_required: bool,
209    pub(super) cause: EventEnrollmentReliabilityCause,
210    pub(super) referenced_value: CapturedReferencedValue,
211}
212
213impl EnrollmentUpdate {
214    /// Replace the desired private state with the state reached later in the
215    /// same deterministic phase-1 flow. This is semantic coalescing: reset,
216    /// cancellation, and reseed operations all update one final snapshot.
217    pub(super) fn set_eval_state(&mut self, state: EventEnrollmentEvalState) {
218        self.eval_state = Some(state);
219    }
220
221    pub(super) fn reset_eval_state(&mut self) {
222        self.eval_state = Some(EventEnrollmentEvalState::default());
223        self.clears_invalidation = true;
224    }
225
226    pub(super) fn cancel_pending(&mut self, state: EventEnrollmentEvalState) {
227        self.eval_state = Some(state);
228        self.canceled = true;
229    }
230
231    pub(super) fn set_eval_source(&mut self, source: Option<EventEnrollmentMonitoredSource>) {
232        self.eval_source = Some(source);
233    }
234
235    /// Replace every source mutation staged earlier in this pass with the
236    /// minimal owner-clear required by the pre-pass source coordinate.
237    pub(super) fn reset_eval_source_for_observation_gap(
238        &mut self,
239        previous: Option<Option<EventEnrollmentMonitoredSource>>,
240    ) {
241        self.eval_source = match previous {
242            Some(Some(_)) => Some(None),
243            Some(None) | None => None,
244        };
245    }
246
247    pub(super) fn fire(&mut self, fired: FiredTransition) {
248        debug_assert!(
249            self.fired.is_none(),
250            "one enrollment fires at most once per pass"
251        );
252        self.fired = Some(fired);
253    }
254
255    pub(super) fn set_reliability(&mut self, reliability: ReliabilityUpdate) {
256        debug_assert!(
257            self.reliability.is_none() && self.fired.is_none(),
258            "one enrollment commits one transition family per pass"
259        );
260        self.reliability = Some(reliability);
261    }
262
263    pub(super) fn observation_unavailable(&mut self) {
264        // A terminal gap cannot leak a transition staged earlier in the same
265        // deterministic phase-1 flow.
266        self.fired = None;
267        self.reliability = None;
268        self.canceled = false;
269        self.observation_unavailable = true;
270    }
271}
272
273fn diagnostic(
274    enrollment_oid: ObjectIdentifier,
275    stage: EventEnrollmentEvaluationStage,
276    outcome: EventEnrollmentEvaluationOutcome,
277) -> EventEnrollmentEvaluationDiagnostic {
278    EventEnrollmentEvaluationDiagnostic {
279        enrollment_oid,
280        stage,
281        outcome,
282    }
283}
284
285fn event_state_landed(db: &ObjectDatabase, oid: &ObjectIdentifier, state: EventState) -> bool {
286    matches!(
287        db.get(oid)
288            .and_then(|object| object.read_property(PropertyIdentifier::EVENT_STATE, None).ok()),
289        Some(PropertyValue::Enumerated(raw)) if raw == state.to_raw()
290    )
291}
292
293fn reliability_or_state_landed(
294    db: &ObjectDatabase,
295    oid: &ObjectIdentifier,
296    update: &ReliabilityUpdate,
297) -> bool {
298    let Some(object) = db.get(oid) else {
299        return false;
300    };
301    let reliability_landed = update.previous != update.desired
302        && matches!(
303            object.read_property(PropertyIdentifier::RELIABILITY, None),
304            Ok(PropertyValue::Enumerated(raw)) if raw == update.desired.to_raw()
305        );
306    let state_landed = update.from != update.to
307        && matches!(
308            object.read_property(PropertyIdentifier::EVENT_STATE, None),
309            Ok(PropertyValue::Enumerated(raw)) if raw == update.to.to_raw()
310        );
311    reliability_landed || state_landed
312}
313
314fn clear_source_ownership(
315    db: &mut ObjectDatabase,
316    oid: ObjectIdentifier,
317    database_eval_sources: &HashSet<ObjectIdentifier>,
318) {
319    if database_eval_sources.contains(&oid) {
320        db.set_enrollment_eval_source(oid, None);
321    } else if let Some(object) = db.get_mut(&oid) {
322        let _ = object.set_enrollment_eval_source_internal(None);
323    }
324}
325
326#[cfg(test)]
327pub(super) fn apply_updates(
328    db: &mut ObjectDatabase,
329    oids: &[ObjectIdentifier],
330    updates: HashMap<ObjectIdentifier, EnrollmentUpdate>,
331    database_eval_sources: &HashSet<ObjectIdentifier>,
332) -> EventEnrollmentEvaluationReport {
333    apply_updates_for_delivery(db, oids, updates, database_eval_sources).report
334}
335
336pub(super) fn apply_updates_for_delivery(
337    db: &mut ObjectDatabase,
338    oids: &[ObjectIdentifier],
339    mut updates: HashMap<ObjectIdentifier, EnrollmentUpdate>,
340    database_eval_sources: &HashSet<ObjectIdentifier>,
341) -> EventEnrollmentEvaluationBatch {
342    let mut report = EventEnrollmentEvaluationReport::default();
343    let mut deliveries = Vec::new();
344
345    for &oid in oids {
346        let Some(update) = updates.remove(&oid) else {
347            report.diagnostics.push(diagnostic(
348                oid,
349                EventEnrollmentEvaluationStage::Evaluation,
350                EventEnrollmentEvaluationOutcome::NoTransition,
351            ));
352            continue;
353        };
354
355        let mut source_failed = false;
356        let mut state_failed = false;
357        if let Some(source) = update.eval_source {
358            if database_eval_sources.contains(&oid) {
359                db.set_enrollment_eval_source(oid, source);
360            } else {
361                let failed = db.get_mut(&oid).is_none_or(|object| {
362                    object.set_enrollment_eval_source_internal(source).is_err()
363                });
364                if failed {
365                    source_failed = true;
366                    report.diagnostics.push(diagnostic(
367                        oid,
368                        EventEnrollmentEvaluationStage::EvaluationSource,
369                        EventEnrollmentEvaluationOutcome::Rejected,
370                    ));
371                }
372            }
373        }
374
375        let mut state_committed = false;
376        if source_failed {
377            // Dependent state cannot retain an owner that failed to land.
378            // This is the single allowed private-state setter call this pass.
379            state_failed = db.get_mut(&oid).is_none_or(|object| {
380                object
381                    .set_enrollment_eval_state_internal(EventEnrollmentEvalState::default())
382                    .is_err()
383            });
384            if state_failed {
385                db.set_enrollment_eval_state_invalidated(oid, true);
386                report.diagnostics.push(diagnostic(
387                    oid,
388                    EventEnrollmentEvaluationStage::EvaluationState,
389                    EventEnrollmentEvaluationOutcome::Rejected,
390                ));
391            } else {
392                state_committed = true;
393            }
394        } else if let Some(state) = update.eval_state {
395            state_failed = db
396                .get_mut(&oid)
397                .is_none_or(|object| object.set_enrollment_eval_state_internal(state).is_err());
398            if state_failed {
399                clear_source_ownership(db, oid, database_eval_sources);
400                db.set_enrollment_eval_state_invalidated(oid, true);
401                report.diagnostics.push(diagnostic(
402                    oid,
403                    EventEnrollmentEvaluationStage::EvaluationState,
404                    EventEnrollmentEvaluationOutcome::Rejected,
405                ));
406            } else {
407                state_committed = true;
408                if update.clears_invalidation {
409                    db.set_enrollment_eval_state_invalidated(oid, false);
410                }
411            }
412        }
413
414        if update.observation_unavailable {
415            if source_failed || state_failed {
416                db.set_enrollment_eval_state_invalidated(oid, true);
417            }
418            report.diagnostics.push(diagnostic(
419                oid,
420                EventEnrollmentEvaluationStage::Reliability,
421                EventEnrollmentEvaluationOutcome::ObservationUnavailable,
422            ));
423            continue;
424        }
425
426        if state_failed {
427            continue;
428        }
429
430        if let Some(reliability) = update.reliability {
431            let coordinate = EventTransition::for_target_state(reliability.to);
432            let staged_timestamp = stage_event_timestamp(db);
433            let change = EventStateChange {
434                from: reliability.from,
435                to: reliability.to,
436            };
437            let transition = EventTransitionCommit {
438                change: change.clone(),
439                coordinate,
440                ack_required: reliability.ack_required,
441                timestamp: staged_timestamp.sample.timestamp.clone(),
442                message_text: None,
443            };
444            let commit = EventEnrollmentReliabilityCommit {
445                reliability: reliability.desired,
446                transition: Some(transition),
447            };
448            let commit_result = db
449                .get_mut(&oid)
450                .map_or(Err(EventTransitionCommitError::Unsupported), |object| {
451                    object.commit_event_enrollment_reliability_internal(commit)
452                });
453
454            if commit_result.is_err() {
455                let outcome = if reliability_or_state_landed(db, &oid, &reliability) {
456                    EventEnrollmentEvaluationOutcome::LandedAfterError
457                } else {
458                    EventEnrollmentEvaluationOutcome::Rejected
459                };
460                clear_source_ownership(db, oid, database_eval_sources);
461                db.set_enrollment_eval_state_invalidated(oid, true);
462                report.diagnostics.push(diagnostic(
463                    oid,
464                    EventEnrollmentEvaluationStage::Reliability,
465                    outcome,
466                ));
467                continue;
468            }
469
470            let recipient_clock = confirm_event_timestamp(db, staged_timestamp).clock;
471            let result = EventEnrollmentReliabilityResult {
472                enrollment_oid: oid,
473                monitored_oid: reliability.monitored_oid,
474                previous_reliability: reliability.previous,
475                new_reliability: reliability.desired,
476                state_change: Some(change),
477                distribute: reliability.distribute,
478                cause: reliability.cause,
479            };
480            report.reliability_results.push(result.clone());
481            let event_type = EventType::CHANGE_OF_RELIABILITY;
482            if let Some(event_values) = project_event_enrollment_payload(
483                db,
484                oid,
485                reliability.monitored_oid,
486                result
487                    .state_change
488                    .as_ref()
489                    .expect("committed Reliability delivery has a state change"),
490                event_type,
491                None,
492                Some(&reliability.referenced_value),
493            ) {
494                deliveries.push(CommittedEventEnrollmentDelivery {
495                    result: CommittedEventEnrollmentResult::Reliability(result),
496                    ack_required: reliability.ack_required,
497                    recipient_clock,
498                    event_type,
499                    event_values,
500                });
501            } else {
502                tracing::debug!(
503                    enrollment = %oid,
504                    "Committed Event Enrollment reliability payload rejected; suppressing distribution"
505                );
506            }
507            continue;
508        }
509
510        let Some(fired) = update.fired else {
511            report.diagnostics.push(diagnostic(
512                oid,
513                EventEnrollmentEvaluationStage::EvaluationState,
514                if update.canceled && state_committed {
515                    EventEnrollmentEvaluationOutcome::CancellationCommitted
516                } else {
517                    EventEnrollmentEvaluationOutcome::NoTransition
518                },
519            ));
520            continue;
521        };
522
523        // A same-state normal-event transition depends on private source
524        // ownership to distinguish the new indication from the previous one.
525        // Reliability re-entry is independently distinguished by its changed
526        // Reliability value and therefore is not suppressed by source failure.
527        if source_failed && fired.from == fired.to {
528            continue;
529        }
530
531        let coordinate = EventTransition::for_target_state(fired.to);
532        let staged_timestamp = stage_event_timestamp(db);
533        let change = EventStateChange {
534            from: fired.from,
535            to: fired.to,
536        };
537        let commit = EventTransitionCommit {
538            change: change.clone(),
539            coordinate,
540            ack_required: fired.ack_required,
541            timestamp: staged_timestamp.sample.timestamp.clone(),
542            message_text: None,
543        };
544        let commit_result = db
545            .get_mut(&oid)
546            .map_or(Err(EventTransitionCommitError::Unsupported), |object| {
547                object.commit_event_transition_internal(commit)
548            });
549
550        if commit_result.is_err() {
551            let outcome = if fired.from != fired.to && event_state_landed(db, &oid, fired.to) {
552                EventEnrollmentEvaluationOutcome::LandedAfterError
553            } else {
554                EventEnrollmentEvaluationOutcome::Rejected
555            };
556            clear_source_ownership(db, oid, database_eval_sources);
557            db.set_enrollment_eval_state_invalidated(oid, true);
558            report.diagnostics.push(diagnostic(
559                oid,
560                EventEnrollmentEvaluationStage::EventTransition,
561                outcome,
562            ));
563            continue;
564        }
565
566        let recipient_clock = confirm_event_timestamp(db, staged_timestamp).clock;
567        let event_type = change.event_type(fired.event_type);
568        let result = EventEnrollmentTransition {
569            enrollment_oid: oid,
570            monitored_oid: fired.monitored_oid,
571            change,
572            event_type,
573            distribute: fired.distribute,
574        };
575        report.transitions.push(result.clone());
576        if let Some(event_values) = project_event_enrollment_payload(
577            db,
578            oid,
579            Some(fired.monitored_oid),
580            &result.change,
581            event_type,
582            Some(&fired.projection),
583            None,
584        ) {
585            deliveries.push(CommittedEventEnrollmentDelivery {
586                result: CommittedEventEnrollmentResult::Normal(result),
587                ack_required: fired.ack_required,
588                recipient_clock,
589                event_type,
590                event_values,
591            });
592        } else {
593            tracing::debug!(
594                enrollment = %oid,
595                ?event_type,
596                "Committed Event Enrollment payload rejected; suppressing distribution"
597            );
598        }
599    }
600
601    EventEnrollmentEvaluationBatch { report, deliveries }
602}