use batpak::event::sourcing::{
EventSourced, ProjectionEvent, ProjectionStateContract, RawMsgpackInput, StateExtent,
};
use batpak::event::{EventKind, EventPayload};
use serde::{Deserialize, Serialize};
pub const SYNCBAT_OPERATION_STATUS_EVENT_KIND: EventKind = EventKind::custom(0xC, 0x5B9);
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OperationStatusLifecycle {
#[default]
Started,
Completed,
Failed,
Denied,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct OperationStatusFactV1 {
pub schema_version: u32,
pub operation: String,
pub lifecycle: OperationStatusLifecycle,
#[serde(skip_serializing_if = "Option::is_none")]
pub receipt_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_hash: Option<[u8; 32]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_hash: Option<[u8; 32]>,
}
impl OperationStatusFactV1 {
pub const SCHEMA_VERSION: u32 = 1;
#[must_use]
pub fn started(operation: impl Into<String>, receipt_kind: impl Into<String>) -> Self {
Self {
schema_version: Self::SCHEMA_VERSION,
operation: operation.into(),
lifecycle: OperationStatusLifecycle::Started,
receipt_kind: Some(receipt_kind.into()),
code: None,
message: None,
input_hash: None,
output_hash: None,
}
}
#[must_use]
pub fn terminal(
operation: impl Into<String>,
lifecycle: OperationStatusLifecycle,
receipt_kind: impl Into<String>,
code: Option<String>,
message: Option<String>,
input_hash: Option<[u8; 32]>,
output_hash: Option<[u8; 32]>,
) -> Self {
Self {
schema_version: Self::SCHEMA_VERSION,
operation: operation.into(),
lifecycle,
receipt_kind: Some(receipt_kind.into()),
code,
message,
input_hash,
output_hash,
}
}
}
impl EventPayload for OperationStatusFactV1 {
const KIND: EventKind = SYNCBAT_OPERATION_STATUS_EVENT_KIND;
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct OperationStatusView {
pub schema_version: u32,
pub operation: String,
pub lifecycle: OperationStatusLifecycle,
pub attempts_seen: u64,
pub started_count: u64,
pub completed_count: u64,
pub failed_count: u64,
pub denied_count: u64,
pub last_phase: Option<String>,
pub last_receipt_kind: Option<String>,
pub last_code: Option<String>,
pub last_message: Option<String>,
pub last_input_hash: Option<[u8; 32]>,
pub last_output_hash: Option<[u8; 32]>,
}
impl OperationStatusView {
pub const SCHEMA_VERSION: u32 = 1;
}
impl EventSourced for OperationStatusView {
type Input = RawMsgpackInput;
const STATE_CONTRACT: ProjectionStateContract =
ProjectionStateContract::single_entity("syncbat-operation-status-view");
fn from_events(events: &[ProjectionEvent<Self>]) -> Option<Self> {
let mut view = None;
for event in events {
let fact = decode_fact(event)?;
match &mut view {
None => view = Some(Self::apply_fact(None, &fact)),
Some(current) => Self::apply_fact_to(current, &fact),
}
}
view
}
fn apply_event(&mut self, event: &ProjectionEvent<Self>) {
if let Some(fact) = decode_fact(event) {
Self::apply_fact_to(self, &fact);
}
}
fn relevant_event_kinds() -> &'static [EventKind] {
&[SYNCBAT_OPERATION_STATUS_EVENT_KIND]
}
fn schema_version() -> u64 {
u64::from(Self::SCHEMA_VERSION)
}
fn supports_incremental_apply() -> bool {
true
}
fn state_extent(&self) -> StateExtent {
StateExtent::single_entity()
}
}
impl OperationStatusView {
fn apply_fact(existing: Option<Self>, fact: &OperationStatusFactV1) -> Self {
let mut view = existing.unwrap_or_else(|| Self {
schema_version: Self::SCHEMA_VERSION,
operation: fact.operation.clone(),
lifecycle: OperationStatusLifecycle::Started,
attempts_seen: 0,
started_count: 0,
completed_count: 0,
failed_count: 0,
denied_count: 0,
last_phase: None,
last_receipt_kind: None,
last_code: None,
last_message: None,
last_input_hash: None,
last_output_hash: None,
});
Self::apply_fact_to(&mut view, fact);
view
}
fn apply_fact_to(view: &mut Self, fact: &OperationStatusFactV1) {
view.operation = fact.operation.clone();
view.lifecycle = fact.lifecycle.clone();
match fact.lifecycle {
OperationStatusLifecycle::Started => {
view.attempts_seen = view.attempts_seen.saturating_add(1);
view.started_count = view.started_count.saturating_add(1);
view.last_phase = Some("started".to_owned());
}
OperationStatusLifecycle::Completed => {
view.completed_count = view.completed_count.saturating_add(1);
view.last_phase = Some("completed".to_owned());
update_terminal_fields(view, fact);
}
OperationStatusLifecycle::Failed => {
view.failed_count = view.failed_count.saturating_add(1);
view.last_phase = Some("failed".to_owned());
update_terminal_fields(view, fact);
}
OperationStatusLifecycle::Denied => {
if !has_open_attempt(view) {
view.attempts_seen = view.attempts_seen.saturating_add(1);
}
view.denied_count = view.denied_count.saturating_add(1);
view.last_phase = Some("denied".to_owned());
update_terminal_fields(view, fact);
}
}
}
}
fn has_open_attempt(view: &OperationStatusView) -> bool {
let terminal_count = view
.completed_count
.saturating_add(view.failed_count)
.saturating_add(view.denied_count);
view.started_count > terminal_count
}
fn update_terminal_fields(view: &mut OperationStatusView, fact: &OperationStatusFactV1) {
view.last_receipt_kind = fact.receipt_kind.clone();
view.last_code = fact.code.clone();
view.last_message = fact.message.clone();
view.last_input_hash = fact.input_hash;
view.last_output_hash = fact.output_hash;
}
fn decode_fact(event: &ProjectionEvent<OperationStatusView>) -> Option<OperationStatusFactV1> {
if event.header.event_kind != SYNCBAT_OPERATION_STATUS_EVENT_KIND {
return None;
}
batpak::canonical::from_bytes(event.payload.as_slice()).ok()
}
#[cfg(test)]
mod event_sourced_mutation_tests {
use batpak::coordinate::DagPosition;
use batpak::event::sourcing::EventSourced;
use batpak::event::{Event, EventHeader};
use super::{
has_open_attempt, OperationStatusFactV1, OperationStatusView,
SYNCBAT_OPERATION_STATUS_EVENT_KIND,
};
fn fact_event(fact: &OperationStatusFactV1) -> Event<Vec<u8>> {
let payload = batpak::canonical::to_bytes(fact).expect("encode status fact");
let size = u32::try_from(payload.len()).expect("payload size fits in u32");
let header = EventHeader::new(
1,
0,
None,
0,
DagPosition::root(),
size,
SYNCBAT_OPERATION_STATUS_EVENT_KIND,
);
Event::new(header, payload)
}
#[test]
fn apply_event_folds_a_started_fact() {
let mut view = OperationStatusView::default();
let started = OperationStatusFactV1::started("op", "receipt.kind.v1");
view.apply_event(&fact_event(&started));
assert_eq!(view.started_count, 1, "apply_event must fold the fact in");
assert_eq!(view.attempts_seen, 1);
assert_eq!(view.operation, "op");
assert_eq!(view.last_phase.as_deref(), Some("started"));
}
#[test]
fn relevant_event_kinds_lists_the_status_kind() {
let kinds = OperationStatusView::relevant_event_kinds();
assert_eq!(kinds, &[SYNCBAT_OPERATION_STATUS_EVENT_KIND][..]);
}
#[test]
fn schema_version_reports_one() {
assert_eq!(OperationStatusView::schema_version(), 1);
}
#[test]
fn supports_incremental_apply_is_true() {
assert!(OperationStatusView::supports_incremental_apply());
}
#[test]
fn has_open_attempt_compares_started_against_terminal_counts() {
let mut view = OperationStatusView::default();
assert!(
!has_open_attempt(&view),
"no started checkout means no open attempt"
);
view.started_count = 1;
assert!(
has_open_attempt(&view),
"a started checkout with no terminal fact is open"
);
view.completed_count = 1;
assert!(
!has_open_attempt(&view),
"equal started and terminal counts mean no open attempt"
);
view.started_count = 2;
view.failed_count = 1;
assert!(
!has_open_attempt(&view),
"a failed terminal fact must close the open attempt"
);
view.started_count = 3;
view.denied_count = 1;
assert!(
!has_open_attempt(&view),
"a denied terminal fact must close the open attempt"
);
}
}