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    CapturedReferencedValue, CommittedNotificationPayload, EventEnrollmentProjectionSnapshot,
15    project_event_enrollment_payload,
16};
17use crate::server::event_timestamp::{
18    SampledEventClock, confirm_event_timestamp, stage_event_timestamp,
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                // The server has no text of its own for an enrollment, so
443                // the slot is empty unless Event_Message_Texts_Config
444                // supplies one (#1507).
445                message_text: Some(String::new()),
446            };
447            let commit = EventEnrollmentReliabilityCommit {
448                reliability: reliability.desired,
449                transition: Some(transition),
450            };
451            let commit_result = db
452                .get_mut(&oid)
453                .map_or(Err(EventTransitionCommitError::Unsupported), |object| {
454                    object.commit_event_enrollment_reliability_internal(commit)
455                });
456
457            if commit_result.is_err() {
458                let outcome = if reliability_or_state_landed(db, &oid, &reliability) {
459                    EventEnrollmentEvaluationOutcome::LandedAfterError
460                } else {
461                    EventEnrollmentEvaluationOutcome::Rejected
462                };
463                clear_source_ownership(db, oid, database_eval_sources);
464                db.set_enrollment_eval_state_invalidated(oid, true);
465                report.diagnostics.push(diagnostic(
466                    oid,
467                    EventEnrollmentEvaluationStage::Reliability,
468                    outcome,
469                ));
470                continue;
471            }
472
473            let recipient_clock = confirm_event_timestamp(db, staged_timestamp).clock;
474            let result = EventEnrollmentReliabilityResult {
475                enrollment_oid: oid,
476                monitored_oid: reliability.monitored_oid,
477                previous_reliability: reliability.previous,
478                new_reliability: reliability.desired,
479                state_change: Some(change),
480                distribute: reliability.distribute,
481                cause: reliability.cause,
482            };
483            report.reliability_results.push(result.clone());
484            let event_type = EventType::CHANGE_OF_RELIABILITY;
485            if let Some(event_values) = project_event_enrollment_payload(
486                db,
487                oid,
488                reliability.monitored_oid,
489                result
490                    .state_change
491                    .as_ref()
492                    .expect("committed Reliability delivery has a state change"),
493                event_type,
494                None,
495                Some(&reliability.referenced_value),
496            ) {
497                deliveries.push(CommittedEventEnrollmentDelivery {
498                    result: CommittedEventEnrollmentResult::Reliability(result),
499                    ack_required: reliability.ack_required,
500                    recipient_clock,
501                    event_type,
502                    event_values,
503                });
504            } else {
505                tracing::debug!(
506                    enrollment = %oid,
507                    "Committed Event Enrollment reliability payload rejected; suppressing distribution"
508                );
509            }
510            continue;
511        }
512
513        let Some(fired) = update.fired else {
514            report.diagnostics.push(diagnostic(
515                oid,
516                EventEnrollmentEvaluationStage::EvaluationState,
517                if update.canceled && state_committed {
518                    EventEnrollmentEvaluationOutcome::CancellationCommitted
519                } else {
520                    EventEnrollmentEvaluationOutcome::NoTransition
521                },
522            ));
523            continue;
524        };
525
526        // A same-state normal-event transition depends on private source
527        // ownership to distinguish the new indication from the previous one.
528        // Reliability re-entry is independently distinguished by its changed
529        // Reliability value and therefore is not suppressed by source failure.
530        if source_failed && fired.from == fired.to {
531            continue;
532        }
533
534        let coordinate = EventTransition::for_target_state(fired.to);
535        let staged_timestamp = stage_event_timestamp(db);
536        let change = EventStateChange {
537            from: fired.from,
538            to: fired.to,
539        };
540        let commit = EventTransitionCommit {
541            change: change.clone(),
542            coordinate,
543            ack_required: fired.ack_required,
544            timestamp: staged_timestamp.sample.timestamp.clone(),
545            // As for the Reliability commit above.
546            message_text: Some(String::new()),
547        };
548        let commit_result = db
549            .get_mut(&oid)
550            .map_or(Err(EventTransitionCommitError::Unsupported), |object| {
551                object.commit_event_transition_internal(commit)
552            });
553
554        if commit_result.is_err() {
555            let outcome = if fired.from != fired.to && event_state_landed(db, &oid, fired.to) {
556                EventEnrollmentEvaluationOutcome::LandedAfterError
557            } else {
558                EventEnrollmentEvaluationOutcome::Rejected
559            };
560            clear_source_ownership(db, oid, database_eval_sources);
561            db.set_enrollment_eval_state_invalidated(oid, true);
562            report.diagnostics.push(diagnostic(
563                oid,
564                EventEnrollmentEvaluationStage::EventTransition,
565                outcome,
566            ));
567            continue;
568        }
569
570        let recipient_clock = confirm_event_timestamp(db, staged_timestamp).clock;
571        let event_type = change.event_type(fired.event_type);
572        let result = EventEnrollmentTransition {
573            enrollment_oid: oid,
574            monitored_oid: fired.monitored_oid,
575            change,
576            event_type,
577            distribute: fired.distribute,
578        };
579        report.transitions.push(result.clone());
580        if let Some(event_values) = project_event_enrollment_payload(
581            db,
582            oid,
583            Some(fired.monitored_oid),
584            &result.change,
585            event_type,
586            Some(&fired.projection),
587            None,
588        ) {
589            deliveries.push(CommittedEventEnrollmentDelivery {
590                result: CommittedEventEnrollmentResult::Normal(result),
591                ack_required: fired.ack_required,
592                recipient_clock,
593                event_type,
594                event_values,
595            });
596        } else {
597            tracing::debug!(
598                enrollment = %oid,
599                ?event_type,
600                "Committed Event Enrollment payload rejected; suppressing distribution"
601            );
602        }
603    }
604
605    EventEnrollmentEvaluationBatch { report, deliveries }
606}