Skip to main content

eventcore_postgres/projections/
config.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Arc;
4use std::time::Duration;
5
6use eventcore_types::{BatchSize, ProjectionSelection};
7use thiserror::Error;
8use tokio_util::sync::CancellationToken;
9
10/// Asynchronous delay used between transactional projection retry attempts.
11///
12/// Applications normally use the default Tokio-backed implementation. The abstraction permits
13/// deterministic observation of requested retry delays without pausing the Tokio runtime that
14/// also drives live database I/O.
15pub trait ProjectionRetrySleeper: std::fmt::Debug + Send + Sync {
16    /// Returns a future that completes after the requested retry delay.
17    fn sleep(&self, duration: Duration) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
18}
19
20/// Asynchronous delay used while a continuous projection is caught up.
21pub trait ProjectionPollSleeper: std::fmt::Debug + Send + Sync {
22    /// Returns a future that completes after the requested idle polling delay.
23    fn sleep(&self, duration: Duration) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
24}
25
26/// Default retry sleeper backed by [`tokio::time::sleep`].
27#[derive(Debug, Clone, Copy, Default)]
28pub struct TokioProjectionRetrySleeper;
29
30impl ProjectionRetrySleeper for TokioProjectionRetrySleeper {
31    fn sleep(&self, duration: Duration) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
32        Box::pin(tokio::time::sleep(duration))
33    }
34}
35
36/// Default continuous polling sleeper backed by [`tokio::time::sleep`].
37#[derive(Debug, Clone, Copy, Default)]
38pub struct TokioProjectionPollSleeper;
39
40impl ProjectionPollSleeper for TokioProjectionPollSleeper {
41    fn sleep(&self, duration: Duration) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
42        Box::pin(tokio::time::sleep(duration))
43    }
44}
45
46/// Controls retry behavior after an application requests a retry.
47#[derive(Debug, Clone, PartialEq)]
48pub struct ProjectionRetryPolicy {
49    max_retries: u32,
50    initial_delay: Duration,
51    multiplier: f64,
52    maximum_delay: Duration,
53}
54
55impl ProjectionRetryPolicy {
56    /// Creates a capped exponential retry policy.
57    ///
58    /// `max_retries` counts retries after the initial application attempt. [`u32::MAX`] is
59    /// rejected with [`ProjectionConfigurationError::TooManyRetries`] because the runner must
60    /// represent that initial attempt as well. `multiplier` must be finite and at least `1.0`;
61    /// non-finite values and values below `1.0` produce
62    /// [`ProjectionConfigurationError::InvalidRetryMultiplier`].
63    ///
64    /// The delay before retry number *n* is `initial_delay * multiplier^(n - 1)`, capped at
65    /// `maximum_delay`. A calculation that exceeds the representable finite duration also uses
66    /// the cap. Zero is accepted for either delay, including a policy with no delay at all.
67    pub fn new(
68        max_retries: u32,
69        initial_delay: Duration,
70        multiplier: f64,
71        maximum_delay: Duration,
72    ) -> Result<Self, ProjectionConfigurationError> {
73        if max_retries == u32::MAX {
74            return Err(ProjectionConfigurationError::TooManyRetries);
75        }
76        if !multiplier.is_finite() || multiplier < 1.0 {
77            return Err(ProjectionConfigurationError::InvalidRetryMultiplier);
78        }
79
80        Ok(Self {
81            max_retries,
82            initial_delay,
83            multiplier,
84            maximum_delay,
85        })
86    }
87
88    /// Returns the number of retries after the initial attempt.
89    pub fn max_retries(&self) -> u32 {
90        self.max_retries
91    }
92
93    /// Returns the delay before the first retry.
94    pub fn initial_delay(&self) -> Duration {
95        self.initial_delay
96    }
97
98    /// Returns the multiplier used to increase retry delays.
99    pub fn multiplier(&self) -> f64 {
100        self.multiplier
101    }
102
103    /// Returns the maximum retry delay.
104    pub fn maximum_delay(&self) -> Duration {
105        self.maximum_delay
106    }
107}
108
109/// Selects whether a runner stops at its initial catch-up frontier or keeps polling.
110#[derive(Debug, Clone)]
111#[non_exhaustive]
112pub enum PostgresProjectionMode {
113    /// Drain the committed source frontier captured at run start, then return.
114    Batch,
115    /// Keep polling after each catch-up frontier until cancellation.
116    ///
117    /// Cancellation is observed only between completed catch-up cycles. It does not interrupt
118    /// application, a retry delay, commit, or after-commit work already in progress.
119    Continuous(CancellationToken),
120}
121
122/// Configuration rejected before a runner is started.
123#[derive(Debug, Clone, PartialEq, Eq, Error)]
124#[non_exhaustive]
125pub enum ProjectionConfigurationError {
126    /// A transactional projection must read at least one envelope per source page.
127    #[error("transactional projection batch size must be positive")]
128    ZeroBatchSize,
129    /// A continuous projection must wait for a positive duration between empty polls.
130    #[error("continuous poll interval must be positive")]
131    ZeroContinuousPollInterval,
132    /// Retry multipliers must be finite and at least one.
133    #[error("retry multiplier must be finite and at least one")]
134    InvalidRetryMultiplier,
135    /// The retry count must leave room for the one-based initial attempt.
136    #[error("retry count is too large to represent the initial attempt plus retries")]
137    TooManyRetries,
138}
139
140/// Settings for a transactional PostgreSQL projection run.
141#[derive(Debug, Clone)]
142pub struct PostgresProjectionConfig {
143    selection: ProjectionSelection,
144    batch_size: BatchSize,
145    mode: PostgresProjectionMode,
146    retry_policy: ProjectionRetryPolicy,
147    retry_sleeper: Arc<dyn ProjectionRetrySleeper>,
148    poll_sleeper: Arc<dyn ProjectionPollSleeper>,
149    continuous_poll_interval: Duration,
150}
151
152impl PostgresProjectionConfig {
153    /// Creates configuration with the default batch execution policy.
154    ///
155    /// The defaults are batch mode, a page size of 100 envelopes, no retries, a 100 millisecond
156    /// initial retry delay, a `2.0` retry multiplier, a 30 second retry-delay cap, and a 1 second
157    /// continuous idle interval. Retry and continuous polling delays both use Tokio-backed
158    /// sleepers.
159    pub fn new(selection: ProjectionSelection) -> Self {
160        Self {
161            selection,
162            batch_size: BatchSize::new(100),
163            mode: PostgresProjectionMode::Batch,
164            retry_policy: ProjectionRetryPolicy {
165                max_retries: 0,
166                initial_delay: Duration::from_millis(100),
167                multiplier: 2.0,
168                maximum_delay: Duration::from_secs(30),
169            },
170            retry_sleeper: Arc::new(TokioProjectionRetrySleeper),
171            poll_sleeper: Arc::new(TokioProjectionPollSleeper),
172            continuous_poll_interval: Duration::from_secs(1),
173        }
174    }
175
176    /// Changes this configuration to continuously poll until the token is cancelled.
177    ///
178    /// Cancellation is observed only between completed catch-up cycles. It does not interrupt
179    /// application, a retry delay, commit, or after-commit work already in progress.
180    pub fn continuous(mut self, cancellation: CancellationToken) -> Self {
181        self.mode = PostgresProjectionMode::Continuous(cancellation);
182        self
183    }
184
185    /// Replaces the maximum number of source envelopes read in one page.
186    ///
187    /// A zero batch size is rejected with [`ProjectionConfigurationError::ZeroBatchSize`], because
188    /// an empty page would otherwise make the runner report false catch-up while selected
189    /// committed envelopes remain unapplied.
190    pub fn with_batch_size(
191        mut self,
192        batch_size: BatchSize,
193    ) -> Result<Self, ProjectionConfigurationError> {
194        if usize::from(batch_size) == 0 {
195            return Err(ProjectionConfigurationError::ZeroBatchSize);
196        }
197
198        self.batch_size = batch_size;
199        Ok(self)
200    }
201
202    /// Replaces the retry policy.
203    pub fn with_retry_policy(mut self, retry_policy: ProjectionRetryPolicy) -> Self {
204        self.retry_policy = retry_policy;
205        self
206    }
207
208    /// Replaces the asynchronous delay implementation used between retry attempts.
209    pub fn with_retry_sleeper(
210        mut self,
211        retry_sleeper: impl ProjectionRetrySleeper + 'static,
212    ) -> Self {
213        self.retry_sleeper = Arc::new(retry_sleeper);
214        self
215    }
216
217    /// Replaces the asynchronous delay implementation used between empty continuous polls.
218    pub fn with_poll_sleeper(mut self, poll_sleeper: impl ProjectionPollSleeper + 'static) -> Self {
219        self.poll_sleeper = Arc::new(poll_sleeper);
220        self
221    }
222
223    /// Replaces the continuous runner's idle interval.
224    ///
225    /// An idle interval begins after a catch-up cycle reaches its captured committed frontier and
226    /// ends when the next cycle polls for a new frontier. It is not a delay between envelopes or
227    /// pages within a catch-up cycle. A zero interval is rejected with
228    /// [`ProjectionConfigurationError::ZeroContinuousPollInterval`].
229    pub fn with_continuous_poll_interval(
230        mut self,
231        continuous_poll_interval: Duration,
232    ) -> Result<Self, ProjectionConfigurationError> {
233        if continuous_poll_interval.is_zero() {
234            return Err(ProjectionConfigurationError::ZeroContinuousPollInterval);
235        }
236
237        self.continuous_poll_interval = continuous_poll_interval;
238        Ok(self)
239    }
240
241    /// Returns the selected source contract.
242    pub fn selection(&self) -> &ProjectionSelection {
243        &self.selection
244    }
245
246    /// Returns the page size.
247    pub fn batch_size(&self) -> BatchSize {
248        self.batch_size
249    }
250
251    /// Returns the execution mode.
252    pub fn mode(&self) -> &PostgresProjectionMode {
253        &self.mode
254    }
255
256    /// Returns the retry policy.
257    pub fn retry_policy(&self) -> &ProjectionRetryPolicy {
258        &self.retry_policy
259    }
260
261    /// Returns the configured retry delay implementation.
262    pub fn retry_sleeper(&self) -> &(dyn ProjectionRetrySleeper + 'static) {
263        self.retry_sleeper.as_ref()
264    }
265
266    /// Returns the configured continuous idle delay implementation.
267    pub fn poll_sleeper(&self) -> &(dyn ProjectionPollSleeper + 'static) {
268        self.poll_sleeper.as_ref()
269    }
270
271    /// Returns the interval between empty continuous polls.
272    pub fn continuous_poll_interval(&self) -> Duration {
273        self.continuous_poll_interval
274    }
275}