use std::future::{poll_fn, Future};
use std::pin::Pin;
use std::sync::Arc;
use std::task::Poll;
use std::time::Duration;
use super::service::DynBusPublisher;
use super::Service;
use crate::bus::{Bus, BusConsumer, RunOptions, TransportError};
pub const DEFAULT_PUBLISH_LEASE: Duration = Duration::from_secs(5);
pub const DEFAULT_MAX_PUBLISH_ATTEMPTS: u32 = 5;
impl Service {
pub fn with_bus<B>(mut self, bus: B) -> Self
where
B: Bus + BusConsumer + 'static,
{
let bus = Arc::new(bus);
self.configure_outbox_publishers(
DynBusPublisher::new(Arc::clone(&bus)),
format!("microsvc-immediate:{}", std::process::id()),
DEFAULT_PUBLISH_LEASE,
DEFAULT_MAX_PUBLISH_ATTEMPTS,
);
self.set_runner(Box::new(
move |service: Arc<Service>, options: RunOptions| {
let bus = Arc::clone(&bus);
Box::pin(async move { run_consumers(&*bus, service, options).await })
},
));
self
}
pub async fn run(mut self, options: RunOptions) -> Result<(), TransportError> {
let Some(runner) = self.take_runner() else {
return Err(TransportError::permanent(
"Service::run requires a bus; call `with_bus` first",
));
};
self.bootstrap_projectors()
.await
.map_err(TransportError::from)?;
runner(Arc::new(self), options).await
}
}
type ConsumerFuture<'b> = Pin<Box<dyn Future<Output = Result<(), TransportError>> + Send + 'b>>;
async fn run_consumers<'b, B>(
bus: &'b B,
service: Arc<Service>,
options: RunOptions,
) -> Result<(), TransportError>
where
B: Bus + BusConsumer,
{
let plan = service.subscription_plan();
let mut consumers: Vec<ConsumerFuture<'b>> = Vec::new();
if !plan.commands.is_empty() {
consumers.push(Box::pin(bus.listen(Arc::clone(&service), options.clone())));
}
if !plan.events.is_empty() {
consumers.push(Box::pin(bus.subscribe(Arc::clone(&service), options)));
}
poll_fn(move |cx| {
let mut index = 0;
while index < consumers.len() {
match consumers[index].as_mut().poll(cx) {
Poll::Ready(Ok(())) => {
let _finished = consumers.remove(index);
}
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
Poll::Pending => index += 1,
}
}
if consumers.is_empty() {
Poll::Ready(Ok(()))
} else {
Poll::Pending
}
})
.await
}
#[cfg(test)]
mod tests {
use serde_json::{json, Value};
use crate::bus::{Bus, InMemoryBus, RunOptions};
use crate::microsvc::{Context, HandlerError, Routes, Service, Session};
use crate::outbox_worker::OutboxStore;
use crate::{
sourced, AggregateBuilder, AggregateRepository, Entity, InMemoryRepository, OutboxMessage,
OutboxMessageStatus, Queueable, QueuedRepository, Snapshot,
};
#[derive(Default)]
struct Dummy {
entity: Entity,
}
#[sourced(entity)]
impl Dummy {
#[event("touched")]
fn touch(&mut self) {
if self.entity.id().is_empty() {
self.entity.set_id("dummy-1");
}
}
}
#[tokio::test]
async fn run_without_a_bus_is_a_permanent_error_not_a_panic() {
let err = Service::new()
.run(RunOptions::idempotent())
.await
.unwrap_err();
assert!(err.is_permanent());
assert!(
err.message().contains("with_bus"),
"error should point at the missing builder step, got: {}",
err.message()
);
}
#[tokio::test]
async fn with_bus_configures_outbox_for_all_eligible_route_bundles() {
let repo_a = InMemoryRepository::new();
let store_a = repo_a.outbox_store();
let repo_b = InMemoryRepository::new();
let store_b = repo_b.outbox_store();
let service = Service::new()
.routes(
Routes::new()
.with_repo(repo_a.queued().aggregate::<Dummy>())
.command("dummy.touch.a")
.handle(touch_and_publish),
)
.routes(
Routes::new()
.with_repo(repo_b.queued().aggregate::<Dummy>())
.command("dummy.touch.b")
.handle(touch_and_publish),
)
.with_bus(InMemoryBus::new());
service
.dispatch("dummy.touch.a", json!({}), Session::new())
.await
.unwrap();
service
.dispatch("dummy.touch.b", json!({}), Session::new())
.await
.unwrap();
let published_a = store_a
.messages_by_status(OutboxMessageStatus::Published, usize::MAX)
.await
.unwrap();
assert_eq!(
published_a.len(),
1,
"first route bundle should publish at commit time"
);
assert_eq!(published_a[0].id(), "evt-1");
assert!(store_a.pending(usize::MAX).await.unwrap().is_empty());
let published_b = store_b
.messages_by_status(OutboxMessageStatus::Published, usize::MAX)
.await
.unwrap();
assert_eq!(
published_b.len(),
1,
"second route bundle should publish at commit time"
);
assert_eq!(published_b[0].id(), "evt-1");
assert!(store_b.pending(usize::MAX).await.unwrap().is_empty());
}
#[tokio::test]
async fn with_bus_keeps_non_outbox_route_bundles_runnable() {
let service = Service::new()
.routes(
Routes::new()
.with_dependencies(String::from("pong"))
.command("ping")
.handle(|ctx: &Context<String>| {
let reply = ctx.dependencies().clone();
async move { Ok(json!({ "reply": reply })) }
}),
)
.with_bus(InMemoryBus::new());
let result = service
.dispatch("ping", json!({}), Session::new())
.await
.unwrap();
assert_eq!(result, json!({ "reply": "pong" }));
}
type TouchRepo = AggregateRepository<QueuedRepository<InMemoryRepository>, Dummy>;
async fn touch_and_publish(ctx: &Context<'_, TouchRepo>) -> Result<Value, HandlerError> {
let mut dummy = Dummy::default();
dummy.touch()?;
let message = OutboxMessage::create("evt-1", "dummy.touched", b"{}".to_vec())?;
ctx.repo().outbox(message).commit(&mut dummy).await?;
Ok(json!({ "ok": true }))
}
#[tokio::test]
async fn dispatch_through_a_handler_publishes_immediately() {
let repo = InMemoryRepository::new();
let store = repo.outbox_store();
let service = Service::new()
.routes(
Routes::new()
.with_repo(repo.queued().aggregate::<Dummy>())
.command("dummy.touch")
.handle(touch_and_publish),
)
.with_bus(InMemoryBus::new());
service
.dispatch("dummy.touch", json!({}), Session::new())
.await
.unwrap();
let published = store
.messages_by_status(OutboxMessageStatus::Published, usize::MAX)
.await
.unwrap();
assert_eq!(published.len(), 1, "row should be published immediately");
assert_eq!(published[0].id(), "evt-1");
assert!(store.pending(usize::MAX).await.unwrap().is_empty());
}
#[tokio::test]
async fn run_consumes_registered_commands_from_the_bus() {
let bus = InMemoryBus::new();
let repo = InMemoryRepository::new();
let store = repo.outbox_store();
let service = Service::new()
.routes(
Routes::new()
.with_repo(repo.queued().aggregate::<Dummy>())
.command("dummy.touch")
.handle(touch_and_publish),
)
.with_bus(bus.clone());
bus.send("dummy.touch", b"{}".to_vec()).await.unwrap();
service.run(RunOptions::idempotent()).await.unwrap();
let published = store
.messages_by_status(OutboxMessageStatus::Published, usize::MAX)
.await
.unwrap();
assert_eq!(
published.len(),
1,
"run() should consume the command and publish its outbox row"
);
}
#[derive(Default, Snapshot)]
struct SnapCounter {
entity: Entity,
value: i64,
}
#[sourced(entity, aggregate_type = "snap_counter")]
impl SnapCounter {
#[event("touched")]
fn touch(&mut self, id: String) {
self.entity.set_id(&id);
self.value += 1;
}
}
type SnapRepo = AggregateRepository<QueuedRepository<InMemoryRepository>, SnapCounter>;
async fn touch_snap(ctx: &Context<'_, SnapRepo>) -> Result<Value, HandlerError> {
let mut counter = SnapCounter::default();
counter.touch("s1".to_string())?;
let message = OutboxMessage::create("evt-s1", "snap.touched", b"{}".to_vec())?;
ctx.repo().outbox(message).commit(&mut counter).await?;
Ok(json!({}))
}
#[tokio::test]
async fn outbox_commit_publishes_with_snapshot_backed_repo() {
let repo = InMemoryRepository::new();
let store = repo.outbox_store();
let service = Service::new()
.routes(
Routes::new()
.with_repo(repo.queued().aggregate::<SnapCounter>().with_snapshots(1))
.command("snap.touch")
.handle(touch_snap),
)
.with_bus(InMemoryBus::new());
service
.dispatch("snap.touch", json!({}), Session::new())
.await
.unwrap();
let published = store
.messages_by_status(OutboxMessageStatus::Published, usize::MAX)
.await
.unwrap();
assert_eq!(
published.len(),
1,
"snapshot-backed outbox commit should publish immediately"
);
assert_eq!(published[0].id(), "evt-s1");
}
}