use std::num::NonZeroUsize;
use std::sync::Arc;
use aion_core::{Event, TimerId, WorkflowFilter, WorkflowId, WorkflowSummary};
use aion_store::{
EventStore, PackageRecord, PackageRouteRecord, PackageStore, ReadableEventStore, RunSummary,
StoreError, TimerEntry, WritableEventStore, WriteToken,
};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use tokio::sync::broadcast;
use super::publisher::BroadcastEventPublisher;
const MAX_BROADCAST_CAPACITY: usize = usize::MAX / 2;
#[derive(thiserror::Error, Clone, Copy, Debug, PartialEq, Eq)]
pub enum PublishError {
#[error(
"event streaming capacity {capacity} exceeds the broadcast channel maximum {MAX_BROADCAST_CAPACITY}"
)]
CapacityTooLarge {
capacity: usize,
},
}
pub struct PublishingEventStore {
inner: Arc<dyn EventStore>,
events: broadcast::Sender<Event>,
}
impl PublishingEventStore {
pub fn new(inner: Arc<dyn EventStore>, capacity: NonZeroUsize) -> Result<Self, PublishError> {
if capacity.get() > MAX_BROADCAST_CAPACITY {
return Err(PublishError::CapacityTooLarge {
capacity: capacity.get(),
});
}
let (events, initial_receiver) = broadcast::channel(capacity.get());
drop(initial_receiver);
Ok(Self { inner, events })
}
#[must_use]
pub fn publisher(&self) -> BroadcastEventPublisher {
BroadcastEventPublisher::new(self.events.clone())
}
fn publish_committed(&self, events: &[Event]) {
for event in events {
if self.events.receiver_count() == 0 {
continue;
}
let delivery = self.events.send(event.clone());
drop(delivery);
}
}
}
#[async_trait]
impl WritableEventStore for PublishingEventStore {
async fn append(
&self,
token: WriteToken,
workflow_id: &WorkflowId,
events: &[Event],
expected_seq: u64,
) -> Result<(), StoreError> {
self.inner
.append(token, workflow_id, events, expected_seq)
.await?;
self.publish_committed(events);
Ok(())
}
async fn append_with_outbox(
&self,
token: WriteToken,
workflow_id: &WorkflowId,
events: &[Event],
expected_seq: u64,
outbox_rows: &[aion_store::OutboxRow],
) -> Result<(), StoreError> {
self.inner
.append_with_outbox(token, workflow_id, events, expected_seq, outbox_rows)
.await?;
self.publish_committed(events);
Ok(())
}
async fn rearm_outbox_pending(&self, rows: &[aion_store::OutboxRow]) -> Result<(), StoreError> {
self.inner.rearm_outbox_pending(rows).await
}
async fn settle_outbox_row_cancelled(&self, dispatch_key: &str) -> Result<(), StoreError> {
self.inner.settle_outbox_row_cancelled(dispatch_key).await
}
async fn settle_workflow_outbox_rows_cancelled(
&self,
workflow_id: &WorkflowId,
) -> Result<Vec<String>, StoreError> {
self.inner
.settle_workflow_outbox_rows_cancelled(workflow_id)
.await
}
}
#[async_trait]
impl ReadableEventStore for PublishingEventStore {
fn set_owned_shards(&self, shards: Option<&[usize]>) {
self.inner.set_owned_shards(shards);
}
fn acquire_owned_shards(&self, shards: &[usize]) -> Result<(), StoreError> {
self.inner.acquire_owned_shards(shards)
}
fn acquire_owned_shard(&self, shard: usize) -> Result<(), StoreError> {
self.inner.acquire_owned_shard(shard)
}
fn extend_owned_shards(&self, shards: &[usize]) {
self.inner.extend_owned_shards(shards);
}
fn is_current_owner(&self, shard: usize) -> bool {
self.inner.is_current_owner(shard)
}
fn publish_shard_owner(&self, shard: usize) -> Result<(), StoreError> {
self.inner.publish_shard_owner(shard)
}
async fn read_history(&self, workflow_id: &WorkflowId) -> Result<Vec<Event>, StoreError> {
self.inner.read_history(workflow_id).await
}
async fn read_history_from(
&self,
workflow_id: &WorkflowId,
from_seq: u64,
) -> Result<Vec<Event>, StoreError> {
self.inner.read_history_from(workflow_id, from_seq).await
}
async fn read_run_chain(
&self,
workflow_id: &WorkflowId,
) -> Result<Vec<RunSummary>, StoreError> {
self.inner.read_run_chain(workflow_id).await
}
async fn list_workflow_ids(&self) -> Result<Vec<WorkflowId>, StoreError> {
self.inner.list_workflow_ids().await
}
async fn list_active(&self) -> Result<Vec<WorkflowId>, StoreError> {
self.inner.list_active().await
}
async fn list_paused(&self) -> Result<Vec<WorkflowId>, StoreError> {
self.inner.list_paused().await
}
async fn query(&self, filter: &WorkflowFilter) -> Result<Vec<WorkflowSummary>, StoreError> {
self.inner.query(filter).await
}
async fn schedule_timer(
&self,
workflow_id: &WorkflowId,
timer_id: &TimerId,
fire_at: DateTime<Utc>,
) -> Result<(), StoreError> {
self.inner
.schedule_timer(workflow_id, timer_id, fire_at)
.await
}
async fn expired_timers(&self, as_of: DateTime<Utc>) -> Result<Vec<TimerEntry>, StoreError> {
self.inner.expired_timers(as_of).await
}
}
#[async_trait]
impl PackageStore for PublishingEventStore {
async fn put_package(&self, record: PackageRecord) -> Result<(), StoreError> {
self.inner.put_package(record).await
}
async fn put_package_with_routes(
&self,
record: PackageRecord,
route_workflow_types: &[String],
) -> Result<(), StoreError> {
self.inner
.put_package_with_routes(record, route_workflow_types)
.await
}
async fn list_packages(&self) -> Result<Vec<PackageRecord>, StoreError> {
self.inner.list_packages().await
}
async fn delete_package(
&self,
workflow_type: &str,
content_hash: &str,
) -> Result<(), StoreError> {
self.inner.delete_package(workflow_type, content_hash).await
}
async fn put_package_route(
&self,
workflow_type: &str,
content_hash: &str,
) -> Result<(), StoreError> {
self.inner
.put_package_route(workflow_type, content_hash)
.await
}
async fn list_package_routes(&self) -> Result<Vec<PackageRouteRecord>, StoreError> {
self.inner.list_package_routes().await
}
}
#[cfg(test)]
mod tests {
use std::num::NonZeroUsize;
use std::sync::Arc;
use std::time::Duration;
use aion_core::{Event, EventEnvelope, Payload, WorkflowId};
use aion_store::{InMemoryStore, StoreError, WriteToken};
use futures::StreamExt;
use serde_json::json;
use crate::engine::delegated::EventFilter;
use crate::engine::delegated::EventPublisher;
use super::*;
fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
}
fn payload(label: &str) -> Result<Payload, aion_core::PayloadError> {
Payload::from_json(&json!({ "label": label }))
}
fn envelope(seq: u64, workflow_id: &WorkflowId) -> EventEnvelope {
EventEnvelope {
seq,
recorded_at: chrono::Utc::now(),
workflow_id: workflow_id.clone(),
}
}
fn started(seq: u64, workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
Ok(Event::WorkflowStarted {
envelope: envelope(seq, workflow_id),
workflow_type: "checkout".to_owned(),
input: payload("input")?,
run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
parent_run_id: None,
package_version: aion_core::PackageVersion::new("a".repeat(64)),
})
}
fn signal(seq: u64, workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
Ok(Event::SignalReceived {
envelope: envelope(seq, workflow_id),
name: "approved".to_owned(),
payload: payload("signal")?,
})
}
fn publishing_store(cap: usize) -> Result<PublishingEventStore, Box<dyn std::error::Error>> {
let inner: Arc<dyn aion_store::EventStore> = Arc::new(InMemoryStore::default());
Ok(PublishingEventStore::new(inner, capacity(cap)?)?)
}
async fn next_item(
stream: &mut futures::stream::BoxStream<
'static,
Result<Event, crate::engine::delegated::EventStreamLagged>,
>,
) -> Result<
Result<Event, crate::engine::delegated::EventStreamLagged>,
Box<dyn std::error::Error>,
> {
tokio::time::timeout(Duration::from_secs(2), stream.next())
.await?
.ok_or_else(|| "subscription stream ended unexpectedly".into())
}
#[tokio::test]
async fn append_publishes_committed_events_in_seq_order()
-> Result<(), Box<dyn std::error::Error>> {
let store = publishing_store(8)?;
let workflow_id = WorkflowId::new_v4();
let mut subscription = store.publisher().subscribe(EventFilter::default());
store
.append(
WriteToken::recorder(),
&workflow_id,
&[started(1, &workflow_id)?, signal(2, &workflow_id)?],
0,
)
.await?;
store
.append(
WriteToken::recorder(),
&workflow_id,
&[signal(3, &workflow_id)?],
2,
)
.await?;
for expected_seq in 1..=3 {
let event = next_item(&mut subscription).await??;
assert_eq!(event.seq(), expected_seq);
}
Ok(())
}
#[tokio::test]
async fn failed_append_publishes_nothing() -> Result<(), Box<dyn std::error::Error>> {
let store = publishing_store(8)?;
let workflow_id = WorkflowId::new_v4();
let mut subscription = store.publisher().subscribe(EventFilter::default());
let conflict = store
.append(
WriteToken::recorder(),
&workflow_id,
&[started(6, &workflow_id)?],
5,
)
.await;
assert!(matches!(conflict, Err(StoreError::SequenceConflict { .. })));
store
.append(
WriteToken::recorder(),
&workflow_id,
&[started(1, &workflow_id)?],
0,
)
.await?;
let event = next_item(&mut subscription).await??;
assert_eq!(event.seq(), 1);
Ok(())
}
#[tokio::test]
async fn reads_delegate_to_inner_store() -> Result<(), Box<dyn std::error::Error>> {
let inner = Arc::new(InMemoryStore::default());
let store = PublishingEventStore::new(
Arc::clone(&inner) as Arc<dyn aion_store::EventStore>,
capacity(8)?,
)?;
let workflow_id = WorkflowId::new_v4();
store
.append(
WriteToken::recorder(),
&workflow_id,
&[started(1, &workflow_id)?],
0,
)
.await?;
let wrapped_history = store.read_history(&workflow_id).await?;
let inner_history = inner.read_history(&workflow_id).await?;
assert_eq!(wrapped_history, inner_history);
assert_eq!(wrapped_history.len(), 1);
assert_eq!(store.list_active().await?, vec![workflow_id]);
Ok(())
}
#[tokio::test]
async fn forwards_per_shard_failover_seam_to_inner() -> Result<(), Box<dyn std::error::Error>> {
use aion_store::testing::ShardSeamSpy;
let spy = Arc::new(ShardSeamSpy::new());
let store = PublishingEventStore::new(
Arc::clone(&spy) as Arc<dyn aion_store::EventStore>,
capacity(8)?,
)?;
assert!(
store.acquire_owned_shard(0).is_err(),
"acquire_owned_shard must forward to the spy's NotOwner sentinel, not the Ok(()) default"
);
assert!(
!store.is_current_owner(1),
"is_current_owner must forward to the spy's false, not the `true` default"
);
assert!(
store.publish_shard_owner(2).is_err(),
"publish_shard_owner must forward to the spy's NotOwner sentinel, not the Ok(()) default"
);
let calls = spy.calls();
assert!(
calls.contains(&"acquire_owned_shard:0".to_owned()),
"spy did not record acquire_owned_shard:0 — call was not forwarded; saw {calls:?}"
);
assert!(
calls.contains(&"is_current_owner:1".to_owned()),
"spy did not record is_current_owner:1 — call was not forwarded; saw {calls:?}"
);
assert!(
calls.contains(&"publish_shard_owner:2".to_owned()),
"spy did not record publish_shard_owner:2 — call was not forwarded; saw {calls:?}"
);
store.set_owned_shards(Some(&[3]));
assert!(
store.acquire_owned_shards(&[4]).is_ok(),
"acquire_owned_shards must forward to the spy's inner Ok(()), not error"
);
store.extend_owned_shards(&[5]);
let calls = spy.calls();
for expected in [
"set_owned_shards:Some([3])",
"acquire_owned_shards:[4]",
"extend_owned_shards:[5]",
] {
assert!(
calls.contains(&expected.to_owned()),
"spy did not record {expected} — call was not forwarded; saw {calls:?}"
);
}
Ok(())
}
#[tokio::test]
async fn forwards_outbox_cancel_settle_to_inner() -> Result<(), Box<dyn std::error::Error>> {
use aion_store::testing::ShardSeamSpy;
let spy = Arc::new(ShardSeamSpy::new());
let store = PublishingEventStore::new(
Arc::clone(&spy) as Arc<dyn aion_store::EventStore>,
capacity(8)?,
)?;
assert!(
store.settle_outbox_row_cancelled("wf-7").await.is_err(),
"settle must forward to the spy's Err sentinel, not the silent Ok(()) no-op default"
);
let calls = spy.calls();
assert!(
calls.contains(&"settle_outbox_row_cancelled:wf-7".to_owned()),
"spy did not record settle_outbox_row_cancelled — the decorator swallowed it; saw {calls:?}"
);
let workflow_id = aion_core::WorkflowId::new_v4();
assert!(
store
.settle_workflow_outbox_rows_cancelled(&workflow_id)
.await
.is_err(),
"workflow settle must forward to the spy's Err sentinel, not the empty-Ok default"
);
let calls = spy.calls();
assert!(
calls.contains(&format!(
"settle_workflow_outbox_rows_cancelled:{workflow_id}"
)),
"spy did not record settle_workflow_outbox_rows_cancelled — the decorator swallowed \
it; saw {calls:?}"
);
Ok(())
}
#[tokio::test]
async fn capacity_above_broadcast_maximum_is_rejected() -> Result<(), Box<dyn std::error::Error>>
{
let inner: Arc<dyn aion_store::EventStore> = Arc::new(InMemoryStore::default());
let error = PublishingEventStore::new(inner, capacity(usize::MAX)?).err();
assert_eq!(
error,
Some(PublishError::CapacityTooLarge {
capacity: usize::MAX
})
);
Ok(())
}
}