Skip to main content

eventcore_postgres/projections/
store.rs

1use std::num::NonZeroU64;
2
3use eventcore_types::{DeliveryPosition, DeliverySourceId, ProjectionSelectionId, ProjectorName};
4use sqlx::{
5    Acquire, Pool, Postgres, Row, Transaction, pool::PoolConnection, postgres::PgPoolOptions,
6    query, query_scalar,
7};
8
9use super::{TransactionalProjectionError, migration::run_component_migration};
10use crate::PostgresConfig;
11
12const PROJECTION_DESTINATION_COMPONENT: &str = "projection-destination";
13const PROJECTION_DESTINATION_VERSION: i64 = 2;
14const PROJECTION_DESTINATION_MIGRATION: &str =
15    include_str!("../../projection-migrations/0002_projection_destination.sql");
16const PROJECTION_LEADER_NAMESPACE: &str = "eventcore:transactional-projection:";
17
18/// Durable progress committed beside a projection's read-model effects.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ProjectionProgress {
21    source_id: DeliverySourceId,
22    selection_id: ProjectionSelectionId,
23    position: DeliveryPosition,
24}
25
26impl ProjectionProgress {
27    /// Returns the delivery source identity bound to this progress row.
28    pub fn source_id(&self) -> &DeliverySourceId {
29        &self.source_id
30    }
31
32    /// Returns the selection identity bound to this progress row.
33    pub fn selection_id(&self) -> &ProjectionSelectionId {
34        &self.selection_id
35    }
36
37    /// Returns the last transactionally committed delivery position.
38    pub fn position(&self) -> DeliveryPosition {
39        self.position
40    }
41}
42
43/// PostgreSQL destination containing a transactional projection's read model and progress.
44#[derive(Debug, Clone)]
45pub struct PostgresProjectionStore {
46    pool: Pool<Postgres>,
47}
48
49impl PostgresProjectionStore {
50    /// Connects a destination pool with the standard EventCore PostgreSQL configuration.
51    pub async fn new<S: Into<String>>(
52        connection_string: S,
53    ) -> Result<Self, TransactionalProjectionError> {
54        let config = PostgresConfig::default();
55        let max_connections: std::num::NonZeroU32 = config.max_connections.into();
56        let pool = PgPoolOptions::new()
57            .max_connections(max_connections.get())
58            .acquire_timeout(config.acquire_timeout)
59            .idle_timeout(config.idle_timeout)
60            .connect(&connection_string.into())
61            .await
62            .map_err(progress_error)?;
63        Ok(Self::from_pool(pool))
64    }
65
66    /// Creates a projection destination from an application-owned pool.
67    pub fn from_pool(pool: Pool<Postgres>) -> Self {
68        Self { pool }
69    }
70
71    /// Applies the destination-owned progress migration through the separate projection ledger.
72    pub async fn migrate(&self) -> Result<(), TransactionalProjectionError> {
73        run_component_migration(
74            &self.pool,
75            PROJECTION_DESTINATION_COMPONENT,
76            PROJECTION_DESTINATION_VERSION,
77            PROJECTION_DESTINATION_MIGRATION,
78        )
79        .await
80        .map_err(progress_error)
81    }
82
83    /// Reads public durable progress for one projector, if it has committed an event.
84    pub async fn progress(
85        &self,
86        projector: &ProjectorName,
87    ) -> Result<Option<ProjectionProgress>, TransactionalProjectionError> {
88        let row = query(
89            "SELECT source_id, selection_id, last_position \
90             FROM eventcore_projection_progress WHERE projector_name = $1",
91        )
92        .bind(projector.as_ref())
93        .fetch_optional(&self.pool)
94        .await
95        .map_err(progress_error)?;
96
97        row.map(progress_from_row).transpose()
98    }
99
100    pub(crate) async fn acquire_leader(
101        &self,
102        projector: &ProjectorName,
103    ) -> Result<ProjectionLeader, TransactionalProjectionError> {
104        let mut connection = self.pool.acquire().await.map_err(leadership_error)?;
105        connection.close_on_drop();
106        let lock_key = projection_lock_key(projector);
107        let acquired: bool = query_scalar("SELECT pg_try_advisory_lock($1)")
108            .bind(lock_key)
109            .fetch_one(&mut *connection)
110            .await
111            .map_err(leadership_error)?;
112        if !acquired {
113            let _ = connection.close().await;
114            return Err(TransactionalProjectionError::LeadershipBusy);
115        }
116
117        Ok(ProjectionLeader {
118            connection: Some(connection),
119            lock_key,
120        })
121    }
122}
123
124pub(crate) struct ProjectionLeader {
125    connection: Option<PoolConnection<Postgres>>,
126    lock_key: i64,
127}
128
129impl ProjectionLeader {
130    pub(crate) async fn begin(
131        &mut self,
132    ) -> Result<Transaction<'_, Postgres>, TransactionalProjectionError> {
133        self.connection_mut()?
134            .begin()
135            .await
136            .map_err(leadership_error)
137    }
138
139    pub(crate) async fn load_progress(
140        transaction: &mut Transaction<'_, Postgres>,
141        projector: &ProjectorName,
142    ) -> Result<Option<ProjectionProgress>, TransactionalProjectionError> {
143        let row = query(
144            "SELECT source_id, selection_id, last_position \
145             FROM eventcore_projection_progress WHERE projector_name = $1 FOR UPDATE",
146        )
147        .bind(projector.as_ref())
148        .fetch_optional(&mut **transaction)
149        .await
150        .map_err(progress_error)?;
151        row.map(progress_from_row).transpose()
152    }
153
154    pub(crate) async fn advance_progress(
155        transaction: &mut Transaction<'_, Postgres>,
156        projector: &ProjectorName,
157        source_id: &DeliverySourceId,
158        selection_id: &ProjectionSelectionId,
159        position: DeliveryPosition,
160    ) -> Result<(), TransactionalProjectionError> {
161        let position = i64::try_from(position.get()).map_err(|error| {
162            TransactionalProjectionError::ProgressStore {
163                source: Box::new(error),
164            }
165        })?;
166        let _ = query(
167            "INSERT INTO eventcore_projection_progress \
168             (projector_name, source_id, selection_id, last_position) VALUES ($1, $2, $3, $4) \
169             ON CONFLICT (projector_name) DO UPDATE SET \
170             source_id = EXCLUDED.source_id, selection_id = EXCLUDED.selection_id, \
171             last_position = EXCLUDED.last_position, updated_at = NOW()",
172        )
173        .bind(projector.as_ref())
174        .bind(source_id.as_ref())
175        .bind(selection_id.as_ref())
176        .bind(position)
177        .execute(&mut **transaction)
178        .await
179        .map_err(progress_error)?;
180        Ok(())
181    }
182
183    pub(crate) async fn delete_progress(
184        transaction: &mut Transaction<'_, Postgres>,
185        projector: &ProjectorName,
186    ) -> Result<(), TransactionalProjectionError> {
187        let _ = query("DELETE FROM eventcore_projection_progress WHERE projector_name = $1")
188            .bind(projector.as_ref())
189            .execute(&mut **transaction)
190            .await
191            .map_err(progress_error)?;
192        Ok(())
193    }
194
195    pub(crate) async fn release(mut self) -> Result<(), TransactionalProjectionError> {
196        let Some(mut connection) = self.connection.take() else {
197            return Ok(());
198        };
199        let _: bool = query_scalar("SELECT pg_advisory_unlock($1)")
200            .bind(self.lock_key)
201            .fetch_one(&mut *connection)
202            .await
203            .map_err(leadership_error)?;
204        connection.close().await.map_err(leadership_error)
205    }
206
207    fn connection_mut(
208        &mut self,
209    ) -> Result<&mut PoolConnection<Postgres>, TransactionalProjectionError> {
210        self.connection
211            .as_mut()
212            .ok_or_else(|| TransactionalProjectionError::LeadershipLost {
213                source: Box::new(std::io::Error::new(
214                    std::io::ErrorKind::BrokenPipe,
215                    "projection leader connection is unavailable",
216                )),
217            })
218    }
219}
220
221fn progress_from_row(
222    row: sqlx::postgres::PgRow,
223) -> Result<ProjectionProgress, TransactionalProjectionError> {
224    let source_id: String = row.try_get("source_id").map_err(progress_error)?;
225    let selection_id: String = row.try_get("selection_id").map_err(progress_error)?;
226    let position: i64 = row.try_get("last_position").map_err(progress_error)?;
227    let source_id = DeliverySourceId::try_new(source_id).map_err(|error| {
228        TransactionalProjectionError::ProgressStore {
229            source: Box::new(error),
230        }
231    })?;
232    let selection_id = ProjectionSelectionId::try_new(selection_id).map_err(|error| {
233        TransactionalProjectionError::ProgressStore {
234            source: Box::new(error),
235        }
236    })?;
237    let position = u64::try_from(position)
238        .ok()
239        .and_then(NonZeroU64::new)
240        .map(DeliveryPosition::new)
241        .ok_or_else(|| TransactionalProjectionError::ProgressStore {
242            source: Box::new(std::io::Error::new(
243                std::io::ErrorKind::InvalidData,
244                "projection progress position must be positive",
245            )),
246        })?;
247
248    Ok(ProjectionProgress {
249        source_id,
250        selection_id,
251        position,
252    })
253}
254
255fn progress_error(error: sqlx::Error) -> TransactionalProjectionError {
256    TransactionalProjectionError::ProgressStore {
257        source: Box::new(error),
258    }
259}
260
261fn leadership_error(error: sqlx::Error) -> TransactionalProjectionError {
262    TransactionalProjectionError::LeadershipLost {
263        source: Box::new(error),
264    }
265}
266
267fn projection_lock_key(projector: &ProjectorName) -> i64 {
268    stable_fnv1a(&format!("{PROJECTION_LEADER_NAMESPACE}{projector}"))
269}
270
271fn stable_fnv1a(value: &str) -> i64 {
272    const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
273    const FNV_PRIME: u64 = 0x00000100000001B3;
274
275    let mut hash = FNV_OFFSET_BASIS;
276    for byte in value.as_bytes() {
277        hash ^= u64::from(*byte);
278        hash = hash.wrapping_mul(FNV_PRIME);
279    }
280    hash as i64
281}
282
283#[cfg(test)]
284mod tests {
285    use eventcore_types::ProjectorName;
286
287    use super::projection_lock_key;
288
289    // Break caught: combining bytes with OR instead of XOR maps these two ordinary projector
290    // names to the same database-wide advisory lock and creates a false leadership conflict.
291    #[test]
292    fn distinct_projector_names_do_not_share_their_leadership_key() {
293        let left = ProjectorName::try_new("b").expect("left projector name should be valid");
294        let right = ProjectorName::try_new("c").expect("right projector name should be valid");
295
296        assert_ne!(projection_lock_key(&left), projection_lock_key(&right));
297    }
298}