use serde::{Deserialize, Serialize};
use std::marker::PhantomData;
use job::CurrentJob;
use crate::out::lane::{InsertOrder, Lane};
use crate::out::subscription::StreamPosition;
use crate::sequence::{CommitSequence, EventSequence};
pub(crate) type HandlerError = Box<dyn std::error::Error + Send + Sync>;
#[derive(Default, Clone, Serialize, Deserialize)]
pub(crate) struct OutboxEventJobState {
pub(crate) sequence: EventSequence,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) commit_sequence: Option<CommitSequence>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) paused: Option<PausedState>,
}
#[derive(Clone, Serialize, Deserialize)]
pub(crate) struct PausedState {
pub(crate) sequence: EventSequence,
pub(crate) until: chrono::DateTime<chrono::Utc>,
}
pub(crate) struct BatchTracker {
pub(crate) collected: usize,
pub(crate) persisted: StreamPosition,
pub(crate) last_persist: tokio::time::Instant,
}
pub(crate) trait CheckpointMirror: Send + Sync {
fn mirror<'a>(
&'a self,
op: &'a mut es_entity::DbOp<'static>,
checkpoint: EventSequence,
) -> futures::future::BoxFuture<'a, Result<(), sqlx::Error>>;
}
pub(crate) struct CtxParts<'inv> {
pub(crate) op_slot: &'inv mut Option<es_entity::DbOp<'static>>,
pub(crate) current_job: &'inv mut CurrentJob,
pub(crate) state: &'inv mut OutboxEventJobState,
pub(crate) tracker: &'inv mut BatchTracker,
pub(crate) mirror: Option<&'inv dyn CheckpointMirror>,
}
#[must_use = "return the Handled token from handle_persistent"]
pub struct Handled<'inv> {
pub(crate) outcome: Outcome,
pub(crate) _invocation: PhantomData<&'inv ()>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Outcome {
Skip,
Collect,
Commit,
Pause(chrono::DateTime<chrono::Utc>),
CommitAndPause(chrono::DateTime<chrono::Utc>),
}
#[must_use = "resolve the EventCtx via skip / collect / consume"]
pub struct EventCtx<'inv, B = ()> {
pub(crate) parts: CtxParts<'inv>,
pub(crate) batch: &'inv mut B,
pub(crate) flusher: &'inv dyn ItemFlush<B>,
}
impl<'inv, B> EventCtx<'inv, B> {
pub fn skip(self) -> Handled<'inv> {
Handled {
outcome: Outcome::Skip,
_invocation: PhantomData,
}
}
pub fn collect_with(self, f: impl FnOnce(&mut B)) -> Handled<'inv> {
f(self.batch);
self.parts.tracker.collected += 1;
Handled {
outcome: Outcome::Collect,
_invocation: PhantomData,
}
}
pub async fn consume(self) -> Result<IsolatedOp<'inv>, HandlerError>
where
B: Default,
{
let EventCtx {
mut parts,
batch,
flusher,
} = self;
flush_batch(&mut parts, batch, flusher, "consume_entry").await?;
*parts.op_slot = Some(
es_entity::DbOp::init_with_clock(parts.current_job.pool(), parts.current_job.clock())
.await?,
);
let op = parts.op_slot.as_mut().expect("just materialized above");
Ok(IsolatedOp { op })
}
}
impl<'inv, T> EventCtx<'inv, Vec<T>> {
pub fn collect(self, item: T) -> Handled<'inv> {
self.collect_with(|batch| batch.push(item))
}
}
impl<'inv, K, V, S> EventCtx<'inv, std::collections::HashMap<K, V, S>>
where
K: std::hash::Hash + Eq,
S: std::hash::BuildHasher,
{
pub fn collect(self, key: K, value: V) -> Handled<'inv> {
self.collect_with(|batch| {
batch.insert(key, value);
})
}
}
#[must_use = "exit with .commit() to produce the Handled token"]
pub struct IsolatedOp<'inv> {
op: &'inv mut es_entity::DbOp<'static>,
}
impl<'inv> IsolatedOp<'inv> {
pub fn commit(self) -> Handled<'inv> {
Handled {
outcome: Outcome::Commit,
_invocation: PhantomData,
}
}
}
impl std::ops::Deref for IsolatedOp<'_> {
type Target = es_entity::DbOp<'static>;
fn deref(&self) -> &Self::Target {
self.op
}
}
es_entity::delegate_atomic_operation!(IsolatedOp<'_>, { s => s.op });
pub(crate) type BoxFuture<'a, T> =
std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
pub(crate) trait ItemFlush<B>: Send + Sync {
fn flush_items<'a>(
&'a self,
op: &'a mut es_entity::DbOp<'static>,
items: B,
state: &'a OutboxEventJobState,
) -> BoxFuture<'a, Result<(), HandlerError>>;
fn position_of(&self, state: &OutboxEventJobState) -> StreamPosition;
}
pub struct FlushOp<'a, L = InsertOrder>
where
L: Lane,
{
op: &'a mut es_entity::DbOp<'static>,
position: L::Position,
}
impl<'a, L> FlushOp<'a, L>
where
L: Lane,
{
pub(crate) fn new(op: &'a mut es_entity::DbOp<'static>, position: L::Position) -> Self {
Self { op, position }
}
pub fn position(&self) -> L::Position {
self.position
}
}
es_entity::delegate_atomic_operation!([<L: Lane>] FlushOp<'_, L>, { s => s.op });
#[derive(Debug)]
pub struct FlushError {
pub reason: &'static str,
pub after: StreamPosition,
pub through: StreamPosition,
pub source: HandlerError,
}
impl std::fmt::Display for FlushError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"flush of batch ({}, {}] failed (reason={}): {}",
self.after, self.through, self.reason, self.source
)
}
}
impl std::error::Error for FlushError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(self.source.as_ref())
}
}
#[tracing::instrument(
name = "outbox.flush_batch",
skip_all,
fields(
reason = reason,
collected = parts.tracker.collected,
checkpoint_seq = u64::from(parts.state.sequence),
),
err
)]
pub(crate) async fn flush_batch<B: Default>(
parts: &mut CtxParts<'_>,
batch: &mut B,
flusher: &dyn ItemFlush<B>,
reason: &'static str,
) -> Result<(), HandlerError> {
if parts.op_slot.is_none() && parts.tracker.collected == 0 {
return Ok(());
}
if parts.tracker.collected > 0 {
if parts.op_slot.is_none() {
*parts.op_slot = Some(
es_entity::DbOp::init_with_clock(
parts.current_job.pool(),
parts.current_job.clock(),
)
.await?,
);
}
let items = std::mem::take(batch);
parts.tracker.collected = 0;
let state = &*parts.state;
let op = parts.op_slot.as_mut().expect("op was materialized above");
let through = flusher.position_of(state);
if let Err(source) = flusher.flush_items(op, items, state).await {
return Err(Box::new(FlushError {
reason,
after: parts.tracker.persisted,
through,
source,
}));
}
}
let mut op = parts
.op_slot
.take()
.expect("a pending batch always has an op by now");
parts
.current_job
.update_execution_state_in_op(&mut op, parts.state)
.await?;
if let Some(mirror) = parts.mirror {
mirror.mirror(&mut op, parts.state.sequence).await?;
}
op.commit().await?;
parts.tracker.persisted = flusher.position_of(parts.state);
parts.tracker.last_persist = tokio::time::Instant::now();
Ok(())
}
#[tracing::instrument(
name = "outbox.persist_checkpoint",
skip_all,
fields(checkpoint_seq = u64::from(state.sequence)),
err
)]
pub(crate) async fn persist_checkpoint(
current_job: &mut CurrentJob,
state: &OutboxEventJobState,
mirror: Option<&dyn CheckpointMirror>,
) -> Result<(), HandlerError> {
let mut op = es_entity::DbOp::init_with_clock(current_job.pool(), current_job.clock()).await?;
current_job
.update_execution_state_in_op(&mut op, state)
.await?;
if let Some(mirror) = mirror {
mirror.mirror(&mut op, state.sequence).await?;
}
op.commit().await?;
Ok(())
}
#[must_use = "resolve the KeyedEventCtx via skip / collect / consume / pause_until"]
pub struct KeyedEventCtx<'inv, B = ()> {
pub(crate) parts: CtxParts<'inv>,
pub(crate) batch: &'inv mut B,
pub(crate) flusher: &'inv dyn ItemFlush<B>,
pub(crate) event_seq: EventSequence,
}
impl<'inv, B> KeyedEventCtx<'inv, B> {
pub fn skip(self) -> Handled<'inv> {
Handled {
outcome: Outcome::Skip,
_invocation: PhantomData,
}
}
pub fn collect_with(self, f: impl FnOnce(&mut B)) -> Handled<'inv> {
f(self.batch);
self.parts.tracker.collected += 1;
Handled {
outcome: Outcome::Collect,
_invocation: PhantomData,
}
}
pub async fn consume(self) -> Result<StagedOp<'inv>, HandlerError>
where
B: Default,
{
let KeyedEventCtx {
mut parts,
batch,
flusher,
event_seq,
} = self;
flush_batch(&mut parts, batch, flusher, "consume_entry").await?;
let op =
es_entity::DbOp::init_with_clock(parts.current_job.pool(), parts.current_job.clock())
.await?;
Ok(StagedOp {
op,
parts,
event_seq,
})
}
pub fn pause_until(self, at: chrono::DateTime<chrono::Utc>) -> Handled<'inv> {
let KeyedEventCtx {
parts, event_seq, ..
} = self;
parts.state.paused = Some(PausedState {
sequence: event_seq,
until: at,
});
Handled {
outcome: Outcome::Pause(at),
_invocation: PhantomData,
}
}
}
impl<'inv, T> KeyedEventCtx<'inv, Vec<T>> {
pub fn collect(self, item: T) -> Handled<'inv> {
self.collect_with(|batch| batch.push(item))
}
}
impl<'inv, K, V, S> KeyedEventCtx<'inv, std::collections::HashMap<K, V, S>>
where
K: std::hash::Hash + Eq,
S: std::hash::BuildHasher,
{
pub fn collect(self, key: K, value: V) -> Handled<'inv> {
self.collect_with(|batch| {
batch.insert(key, value);
})
}
}
#[must_use = "exit with .commit() / .suspend() / .pause_until()"]
pub struct StagedOp<'inv> {
op: es_entity::DbOp<'static>,
parts: CtxParts<'inv>,
event_seq: EventSequence,
}
impl<'inv> StagedOp<'inv> {
pub async fn suspend(self) -> Result<Suspended<'inv>, HandlerError> {
let StagedOp {
op,
parts,
event_seq,
} = self;
op.commit().await?;
Ok(Suspended { parts, event_seq })
}
pub fn pause_until(self, at: chrono::DateTime<chrono::Utc>) -> Handled<'inv> {
let StagedOp {
op,
parts,
event_seq,
} = self;
parts.state.paused = Some(PausedState {
sequence: event_seq,
until: at,
});
*parts.op_slot = Some(op);
Handled {
outcome: Outcome::CommitAndPause(at),
_invocation: PhantomData,
}
}
pub fn commit(self) -> Handled<'inv> {
let StagedOp { op, parts, .. } = self;
*parts.op_slot = Some(op);
Handled {
outcome: Outcome::Commit,
_invocation: PhantomData,
}
}
}
impl std::ops::Deref for StagedOp<'_> {
type Target = es_entity::DbOp<'static>;
fn deref(&self) -> &Self::Target {
&self.op
}
}
es_entity::delegate_atomic_operation!(StagedOp<'_>, { s => s.op });
#[must_use = "continue with .resume() or pause with .pause_until()"]
pub struct Suspended<'inv> {
parts: CtxParts<'inv>,
event_seq: EventSequence,
}
impl<'inv> Suspended<'inv> {
pub async fn resume(self) -> Result<StagedOp<'inv>, HandlerError> {
let Suspended { parts, event_seq } = self;
let op =
es_entity::DbOp::init_with_clock(parts.current_job.pool(), parts.current_job.clock())
.await?;
Ok(StagedOp {
op,
parts,
event_seq,
})
}
pub fn pause_until(self, at: chrono::DateTime<chrono::Utc>) -> Handled<'inv> {
let Suspended { parts, event_seq } = self;
parts.state.paused = Some(PausedState {
sequence: event_seq,
until: at,
});
Handled {
outcome: Outcome::Pause(at),
_invocation: PhantomData,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pre_pause_execution_state_still_decodes() {
let state: OutboxEventJobState =
serde_json::from_str(r#"{"sequence":42}"#).expect("legacy state must decode");
assert_eq!(u64::from(state.sequence), 42);
assert!(state.paused.is_none());
}
#[test]
fn state_without_a_pause_serializes_unchanged() {
let state = OutboxEventJobState {
sequence: EventSequence::from(7u64),
commit_sequence: None,
paused: None,
};
assert_eq!(
serde_json::to_string(&state).expect("serializes"),
r#"{"sequence":7}"#
);
}
}