Skip to main content

eventcore_postgres/projections/
runner.rs

1use eventcore_types::{
2    AttemptNumber, DeliveryPosition, DeliveryUpperBound, PersistedEventEnvelope, ProjectionSource,
3};
4use std::future::Future;
5use std::num::NonZeroU32;
6use std::time::Duration;
7
8use super::{
9    AfterCommit, PostgresProjectionConfig, PostgresProjectionMode, PostgresProjectionStore,
10    PostgresProjector, ProjectionFailureContext, ProjectionFailureDecision, ProjectionLeader,
11    TransactionalProjectionError,
12};
13
14enum EnvelopeResult {
15    Processed,
16    Skipped,
17    Suppressed,
18    Stopped,
19}
20
21enum DrainCycleOutcome {
22    CaughtUp(Option<DeliveryPosition>),
23    Stopped(DeliveryPosition),
24}
25
26/// Observable result of a transactional projection run.
27#[derive(Debug, Clone, PartialEq, Eq)]
28#[non_exhaustive]
29pub enum ProjectionRunOutcome {
30    /// Batch mode reached its captured source frontier.
31    CaughtUp {
32        /// Number of committed event effects.
33        processed: u64,
34        /// Number of explicitly skipped events.
35        skipped: u64,
36        /// Captured high-water mark, absent when the source was empty.
37        through: Option<DeliveryPosition>,
38    },
39    /// The runner ended without committing the pending position.
40    Stopped {
41        /// Position left pending.
42        position: DeliveryPosition,
43        /// Number of committed event effects before stopping.
44        processed: u64,
45        /// Number of explicitly skipped events before stopping.
46        skipped: u64,
47    },
48    /// Continuous execution observed cancellation.
49    Cancelled {
50        /// Number of committed event effects before cancellation.
51        processed: u64,
52        /// Number of explicitly skipped events before cancellation.
53        skipped: u64,
54    },
55}
56
57/// Starts a PostgreSQL transactional projection.
58#[expect(
59    clippy::manual_async_fn,
60    reason = "public future must be Send for backend-neutral fixture traits"
61)]
62pub fn run_transactional_projection<P, S>(
63    projector: P,
64    source: &S,
65    store: &PostgresProjectionStore,
66    config: PostgresProjectionConfig,
67) -> impl Future<Output = Result<ProjectionRunOutcome, TransactionalProjectionError>> + Send
68where
69    P: PostgresProjector,
70    S: ProjectionSource,
71{
72    async move {
73        let projector_name = projector.name().clone();
74        let leader = store.acquire_leader(&projector_name).await?;
75        run_transactional_projection_with_leader(projector, source, config, leader).await
76    }
77}
78
79pub(crate) async fn run_transactional_projection_with_leader<P, S>(
80    mut projector: P,
81    source: &S,
82    config: PostgresProjectionConfig,
83    mut leader: ProjectionLeader,
84) -> Result<ProjectionRunOutcome, TransactionalProjectionError>
85where
86    P: PostgresProjector,
87    S: ProjectionSource,
88{
89    let projector_name = projector.name().clone();
90    let mut after = load_validated_progress(
91        &mut leader,
92        &projector_name,
93        source.source_id(),
94        config.selection().id(),
95    )
96    .await?;
97    let mut processed = 0;
98    let mut skipped = 0;
99
100    loop {
101        let cycle = drain_cycle(
102            &mut projector,
103            &mut leader,
104            &projector_name,
105            source,
106            &config,
107            &mut after,
108            &mut processed,
109            &mut skipped,
110        )
111        .await?;
112        let through = match cycle {
113            DrainCycleOutcome::CaughtUp(through) => through,
114            DrainCycleOutcome::Stopped(position) => {
115                leader.release().await?;
116                return Ok(ProjectionRunOutcome::Stopped {
117                    position,
118                    processed,
119                    skipped,
120                });
121            }
122        };
123
124        match config.mode() {
125            PostgresProjectionMode::Batch => {
126                leader.release().await?;
127                return Ok(ProjectionRunOutcome::CaughtUp {
128                    processed,
129                    skipped,
130                    through,
131                });
132            }
133            PostgresProjectionMode::Continuous(cancellation) => {
134                tokio::select! {
135                    () = config
136                        .poll_sleeper()
137                        .sleep(config.continuous_poll_interval()) => {}
138                    () = cancellation.cancelled() => {
139                        leader.release().await?;
140                        return Ok(ProjectionRunOutcome::Cancelled { processed, skipped });
141                    }
142                }
143            }
144        }
145    }
146}
147
148#[expect(
149    clippy::too_many_arguments,
150    reason = "the drain cycle makes runner ownership and cumulative state explicit"
151)]
152async fn drain_cycle<P, S>(
153    projector: &mut P,
154    leader: &mut ProjectionLeader,
155    projector_name: &eventcore_types::ProjectorName,
156    source: &S,
157    config: &PostgresProjectionConfig,
158    after: &mut Option<DeliveryPosition>,
159    processed: &mut u64,
160    skipped: &mut u64,
161) -> Result<DrainCycleOutcome, TransactionalProjectionError>
162where
163    P: PostgresProjector,
164    S: ProjectionSource,
165{
166    let through = source.high_watermark().await.map_err(source_error)?;
167    let Some(through) = through else {
168        return Ok(DrainCycleOutcome::CaughtUp(None));
169    };
170
171    loop {
172        let envelopes = source
173            .read_envelopes(
174                config.selection(),
175                *after,
176                DeliveryUpperBound::Inclusive(through),
177                config.batch_size(),
178            )
179            .await
180            .map_err(source_error)?;
181        if envelopes.is_empty() {
182            return Ok(DrainCycleOutcome::CaughtUp(Some(through)));
183        }
184
185        for envelope in envelopes {
186            let position = envelope.position();
187            *after = Some(position);
188            match process_envelope(projector, leader, projector_name, source, config, envelope)
189                .await?
190            {
191                EnvelopeResult::Processed => *processed += 1,
192                EnvelopeResult::Skipped => *skipped += 1,
193                EnvelopeResult::Suppressed => {}
194                EnvelopeResult::Stopped => return Ok(DrainCycleOutcome::Stopped(position)),
195            }
196        }
197    }
198}
199
200async fn load_validated_progress(
201    leader: &mut ProjectionLeader,
202    projector_name: &eventcore_types::ProjectorName,
203    source_id: &eventcore_types::DeliverySourceId,
204    selection_id: &eventcore_types::ProjectionSelectionId,
205) -> Result<Option<DeliveryPosition>, TransactionalProjectionError> {
206    let mut transaction = leader.begin().await?;
207    let progress = match ProjectionLeader::load_progress(&mut transaction, projector_name).await {
208        Ok(progress) => progress,
209        Err(error) => return Err(rollback_preserving_error(transaction, error).await),
210    };
211    let validation = progress.as_ref().map_or(Ok(()), |progress| {
212        if progress.source_id() != source_id {
213            return Err(TransactionalProjectionError::SourceIdentityMismatch {
214                projector: projector_name.clone(),
215                persisted: progress.source_id().clone(),
216                configured: source_id.clone(),
217            });
218        }
219        if progress.selection_id() != selection_id {
220            return Err(TransactionalProjectionError::SelectionIdentityMismatch {
221                projector: projector_name.clone(),
222                persisted: progress.selection_id().clone(),
223                configured: selection_id.clone(),
224            });
225        }
226        Ok(())
227    });
228    if let Err(error) = validation {
229        return Err(rollback_preserving_error(transaction, error).await);
230    }
231    transaction.rollback().await.map_err(leadership_lost)?;
232    Ok(progress.map(|progress| progress.position()))
233}
234
235async fn process_envelope<P, S>(
236    projector: &mut P,
237    leader: &mut ProjectionLeader,
238    projector_name: &eventcore_types::ProjectorName,
239    source: &S,
240    config: &PostgresProjectionConfig,
241    envelope: PersistedEventEnvelope,
242) -> Result<EnvelopeResult, TransactionalProjectionError>
243where
244    P: PostgresProjector,
245    S: ProjectionSource,
246{
247    let position = envelope.position();
248    let mut transaction = leader
249        .begin()
250        .await
251        .map_err(|error| progress_at_position(position, error))?;
252    let pending =
253        match position_is_pending(&mut transaction, projector_name, source, config, position).await
254        {
255            Ok(pending) => pending,
256            Err(error) => return Err(rollback_preserving_error(transaction, error).await),
257        };
258    if !pending {
259        transaction.rollback().await.map_err(leadership_lost)?;
260        return Ok(EnvelopeResult::Suppressed);
261    }
262    let event = match projector.decode(&envelope) {
263        Ok(event) => event,
264        Err(source) => {
265            let error = TransactionalProjectionError::Decode { position, source };
266            return Err(rollback_preserving_error(transaction, error).await);
267        }
268    };
269    let mut attempt = 1_u32;
270
271    loop {
272        match projector.apply(&event, position, &mut transaction).await {
273            Ok(after_commit) => {
274                if let Err(error) = ProjectionLeader::advance_progress(
275                    &mut transaction,
276                    projector_name,
277                    source.source_id(),
278                    config.selection().id(),
279                    position,
280                )
281                .await
282                .map_err(|error| progress_at_position(position, error))
283                {
284                    return Err(rollback_preserving_error(transaction, error).await);
285                }
286                transaction.commit().await.map_err(|source| {
287                    TransactionalProjectionError::CommitIndeterminate {
288                        position,
289                        source: Box::new(source),
290                    }
291                })?;
292                after_commit.execute().await.map_err(|source| {
293                    TransactionalProjectionError::AfterCommitFailed {
294                        committed_position: position,
295                        source: Box::new(source),
296                    }
297                })?;
298                return Ok(EnvelopeResult::Processed);
299            }
300            Err(error) => {
301                let decision = projector.on_error(ProjectionFailureContext::new(
302                    position,
303                    &error,
304                    AttemptNumber::new(
305                        NonZeroU32::new(attempt).expect("application attempt starts at one"),
306                    ),
307                ));
308                transaction.rollback().await.map_err(leadership_lost)?;
309
310                match decision {
311                    ProjectionFailureDecision::Retry => {
312                        if attempt > config.retry_policy().max_retries() {
313                            return Err(TransactionalProjectionError::RetryExhausted {
314                                position,
315                                attempts: attempt,
316                                source: Box::new(error),
317                            });
318                        }
319                        let delay = retry_delay(config, attempt);
320                        config.retry_sleeper().sleep(delay).await;
321                        attempt = attempt.checked_add(1).ok_or_else(|| {
322                            TransactionalProjectionError::Configuration {
323                                source: super::ProjectionConfigurationError::TooManyRetries,
324                            }
325                        })?;
326                        transaction = leader
327                            .begin()
328                            .await
329                            .map_err(|error| progress_at_position(position, error))?;
330                        let pending = match position_is_pending(
331                            &mut transaction,
332                            projector_name,
333                            source,
334                            config,
335                            position,
336                        )
337                        .await
338                        {
339                            Ok(pending) => pending,
340                            Err(error) => {
341                                return Err(rollback_preserving_error(transaction, error).await);
342                            }
343                        };
344                        if !pending {
345                            transaction.rollback().await.map_err(leadership_lost)?;
346                            return Ok(EnvelopeResult::Suppressed);
347                        }
348                    }
349                    ProjectionFailureDecision::Skip => {
350                        return skip_position(leader, projector_name, source, config, position)
351                            .await;
352                    }
353                    ProjectionFailureDecision::Stop => return Ok(EnvelopeResult::Stopped),
354                    ProjectionFailureDecision::Fatal => {
355                        return Err(TransactionalProjectionError::Application {
356                            position,
357                            source: Box::new(error),
358                        });
359                    }
360                }
361            }
362        }
363    }
364}
365
366async fn skip_position<S>(
367    leader: &mut ProjectionLeader,
368    projector_name: &eventcore_types::ProjectorName,
369    source: &S,
370    config: &PostgresProjectionConfig,
371    position: DeliveryPosition,
372) -> Result<EnvelopeResult, TransactionalProjectionError>
373where
374    S: ProjectionSource,
375{
376    let mut transaction = leader
377        .begin()
378        .await
379        .map_err(|error| progress_at_position(position, error))?;
380    let pending =
381        match position_is_pending(&mut transaction, projector_name, source, config, position).await
382        {
383            Ok(pending) => pending,
384            Err(error) => return Err(rollback_preserving_error(transaction, error).await),
385        };
386    if !pending {
387        transaction.rollback().await.map_err(leadership_lost)?;
388        return Ok(EnvelopeResult::Suppressed);
389    }
390    if let Err(error) = ProjectionLeader::advance_progress(
391        &mut transaction,
392        projector_name,
393        source.source_id(),
394        config.selection().id(),
395        position,
396    )
397    .await
398    .map_err(|error| progress_at_position(position, error))
399    {
400        return Err(rollback_preserving_error(transaction, error).await);
401    }
402    transaction.commit().await.map_err(|source| {
403        TransactionalProjectionError::CommitIndeterminate {
404            position,
405            source: Box::new(source),
406        }
407    })?;
408    Ok(EnvelopeResult::Skipped)
409}
410
411async fn rollback_preserving_error(
412    transaction: sqlx::Transaction<'_, sqlx::Postgres>,
413    error: TransactionalProjectionError,
414) -> TransactionalProjectionError {
415    match transaction.rollback().await {
416        Ok(()) => error,
417        Err(source) => leadership_lost(source),
418    }
419}
420
421fn leadership_lost(source: sqlx::Error) -> TransactionalProjectionError {
422    TransactionalProjectionError::LeadershipLost {
423        source: Box::new(source),
424    }
425}
426
427async fn position_is_pending<S>(
428    transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
429    projector_name: &eventcore_types::ProjectorName,
430    source: &S,
431    config: &PostgresProjectionConfig,
432    position: DeliveryPosition,
433) -> Result<bool, TransactionalProjectionError>
434where
435    S: ProjectionSource,
436{
437    let progress = ProjectionLeader::load_progress(transaction, projector_name)
438        .await
439        .map_err(|error| progress_at_position(position, error))?;
440    let Some(progress) = progress else {
441        return Ok(true);
442    };
443    if progress.source_id() != source.source_id() {
444        return Err(TransactionalProjectionError::SourceIdentityMismatch {
445            projector: projector_name.clone(),
446            persisted: progress.source_id().clone(),
447            configured: source.source_id().clone(),
448        });
449    }
450    if progress.selection_id() != config.selection().id() {
451        return Err(TransactionalProjectionError::SelectionIdentityMismatch {
452            projector: projector_name.clone(),
453            persisted: progress.selection_id().clone(),
454            configured: config.selection().id().clone(),
455        });
456    }
457    Ok(position > progress.position())
458}
459
460fn retry_delay(config: &PostgresProjectionConfig, retry_number: u32) -> Duration {
461    let policy = config.retry_policy();
462    let initial = policy.initial_delay();
463    let maximum = policy.maximum_delay();
464    if initial.is_zero() {
465        return initial;
466    }
467
468    let factor = policy
469        .multiplier()
470        .powf(f64::from(retry_number.saturating_sub(1)));
471    let scaled_seconds = initial.as_secs_f64() * factor;
472    if !scaled_seconds.is_finite() || scaled_seconds >= maximum.as_secs_f64() {
473        maximum
474    } else {
475        Duration::from_secs_f64(scaled_seconds)
476    }
477}
478
479fn progress_at_position(
480    position: DeliveryPosition,
481    error: TransactionalProjectionError,
482) -> TransactionalProjectionError {
483    match error {
484        TransactionalProjectionError::ProgressStore { source } => {
485            TransactionalProjectionError::Progress { position, source }
486        }
487        error => error,
488    }
489}
490
491fn source_error(
492    error: impl std::error::Error + Send + Sync + 'static,
493) -> TransactionalProjectionError {
494    TransactionalProjectionError::Source {
495        source: Box::new(error),
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use std::time::Duration;
502
503    use eventcore_types::{
504        EventTypeName, ProjectionSelection, ProjectionSelectionId, ProjectionStreamFilter,
505    };
506
507    use super::{PostgresProjectionConfig, retry_delay};
508    use crate::ProjectionRetryPolicy;
509
510    // Break caught: removing the zero-delay guard lets an overflowing exponential factor turn
511    // `0 * infinity` into NaN and incorrectly sleep for the configured maximum.
512    #[test]
513    fn zero_initial_retry_delay_remains_zero_when_the_multiplier_overflows() {
514        let selection = ProjectionSelection::try_new(
515            ProjectionSelectionId::try_new("retry-delay-test")
516                .expect("test selection ID should be valid"),
517            ProjectionStreamFilter::All,
518            vec![EventTypeName::try_new("retry-event").expect("test event type should be valid")],
519        )
520        .expect("test selection should be valid");
521        let policy = ProjectionRetryPolicy::new(
522            u32::MAX - 1,
523            Duration::ZERO,
524            f64::MAX,
525            Duration::from_secs(1),
526        )
527        .expect("test retry policy should be valid");
528        let config = PostgresProjectionConfig::new(selection).with_retry_policy(policy);
529
530        assert_eq!(retry_delay(&config, u32::MAX), Duration::ZERO);
531    }
532}