eventcore_postgres/projections/
store.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ProjectionProgress {
21 source_id: DeliverySourceId,
22 selection_id: ProjectionSelectionId,
23 position: DeliveryPosition,
24}
25
26impl ProjectionProgress {
27 pub fn source_id(&self) -> &DeliverySourceId {
29 &self.source_id
30 }
31
32 pub fn selection_id(&self) -> &ProjectionSelectionId {
34 &self.selection_id
35 }
36
37 pub fn position(&self) -> DeliveryPosition {
39 self.position
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct PostgresProjectionStore {
46 pool: Pool<Postgres>,
47}
48
49impl PostgresProjectionStore {
50 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 pub fn from_pool(pool: Pool<Postgres>) -> Self {
68 Self { pool }
69 }
70
71 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 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 #[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}