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#[derive(Debug, Clone, PartialEq, Eq)]
28#[non_exhaustive]
29pub enum ProjectionRunOutcome {
30 CaughtUp {
32 processed: u64,
34 skipped: u64,
36 through: Option<DeliveryPosition>,
38 },
39 Stopped {
41 position: DeliveryPosition,
43 processed: u64,
45 skipped: u64,
47 },
48 Cancelled {
50 processed: u64,
52 skipped: u64,
54 },
55}
56
57#[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 #[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}