Skip to main content

eventcore_postgres/
lib.rs

1//! PostgreSQL event store backend for EventCore.
2//!
3//! This crate provides a production-grade [`PostgresEventStore`] backed by PostgreSQL,
4//! with ACID transactions, advisory locks, and connection pooling via `sqlx`.
5//!
6//! Event immutability is enforced by PostgreSQL triggers. Stream pattern matching
7//! follows the conventions described in ADR-0047.
8//!
9//! `PostgresEventStore` implements [`EventStore`], [`EventReader`], [`CheckpointStore`],
10//! and [`ProjectorCoordinator`] from `eventcore-types`. Its normal migration
11//! creates the durable command-state snapshot table used by snapshot-enabled
12//! commands.
13//!
14//! # Getting Started
15//!
16//! ```no_run
17//! use eventcore_postgres::PostgresEventStore;
18//!
19//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
20//! let store = PostgresEventStore::new("postgres://user:pass@localhost/mydb").await?;
21//! # Ok(())
22//! # }
23//! ```
24
25use std::time::Duration;
26
27use eventcore_types::{
28    CheckpointStore, CommandStateReplayCheckpoint, CommandStateSnapshot, CommandStateSnapshotId,
29    Event, EventFilter, EventPage, EventReader, EventStore, EventStoreError, EventStream,
30    EventStreamSlice, Operation, ProjectorCoordinator, StreamId, StreamPosition, StreamVersion,
31    StreamWrites,
32};
33use futures::StreamExt;
34use nutype::nutype;
35use serde_json::value::RawValue;
36use serde_json::{Value, json};
37use sqlx::types::Json;
38use sqlx::{Pool, Postgres, QueryBuilder, Row, postgres::PgPoolOptions, query};
39use thiserror::Error;
40use tracing::{error, info, instrument, warn};
41use uuid::Uuid;
42
43/// Errors that can occur when creating a [`PostgresEventStore`].
44#[derive(Debug, Error)]
45pub enum PostgresEventStoreError {
46    /// Failed to create the postgres connection pool.
47    #[error("failed to create postgres connection pool")]
48    ConnectionFailed(#[source] sqlx::Error),
49}
50
51/// Maximum number of database connections in the pool.
52///
53/// MaxConnections represents the connection pool size limit. It must be at least 1,
54/// enforced by using NonZeroU32 as the underlying type.
55///
56/// # Examples
57///
58/// ```no_run
59/// use eventcore_postgres::MaxConnections;
60/// use std::num::NonZeroU32;
61///
62/// let small_pool = MaxConnections::new(NonZeroU32::new(5).expect("5 is non-zero"));
63/// let standard = MaxConnections::new(NonZeroU32::new(10).expect("10 is non-zero"));
64/// let large_pool = MaxConnections::new(NonZeroU32::new(50).expect("50 is non-zero"));
65///
66/// // Zero connections not allowed by type system
67/// // let zero = NonZeroU32::new(0); // Returns None
68/// ```
69#[nutype(derive(Debug, Clone, Copy, PartialEq, Eq, Display, AsRef, Into))]
70pub struct MaxConnections(std::num::NonZeroU32);
71
72/// Configuration for PostgresEventStore connection pool.
73#[derive(Debug, Clone)]
74pub struct PostgresConfig {
75    /// Maximum number of connections in the pool (default: 10)
76    pub max_connections: MaxConnections,
77    /// Timeout for acquiring a connection from the pool (default: 30 seconds)
78    pub acquire_timeout: Duration,
79    /// Idle timeout for connections in the pool (default: 10 minutes)
80    pub idle_timeout: Duration,
81}
82
83impl Default for PostgresConfig {
84    fn default() -> Self {
85        const DEFAULT_MAX_CONNECTIONS: std::num::NonZeroU32 = match std::num::NonZeroU32::new(10) {
86            Some(v) => v,
87            None => unreachable!(),
88        };
89
90        Self {
91            max_connections: MaxConnections::new(DEFAULT_MAX_CONNECTIONS),
92            acquire_timeout: Duration::from_secs(30),
93            idle_timeout: Duration::from_secs(600), // 10 minutes
94        }
95    }
96}
97
98/// PostgreSQL-backed event store implementing EventCore's storage traits.
99///
100/// Provides atomic multi-stream event writes with optimistic concurrency control,
101/// advisory locks for projector coordination, and connection pooling.
102///
103/// Use [`PostgresEventStore::new`] (or [`PostgresEventStore::with_config`]) with a
104/// PostgreSQL connection URL to create an instance.
105#[derive(Debug, Clone)]
106pub struct PostgresEventStore {
107    pool: Pool<Postgres>,
108}
109
110impl PostgresEventStore {
111    /// Create a new PostgresEventStore with default configuration.
112    pub async fn new<S: Into<String>>(
113        connection_string: S,
114    ) -> Result<Self, PostgresEventStoreError> {
115        Self::with_config(connection_string, PostgresConfig::default()).await
116    }
117
118    /// Create a new PostgresEventStore with custom configuration.
119    pub async fn with_config<S: Into<String>>(
120        connection_string: S,
121        config: PostgresConfig,
122    ) -> Result<Self, PostgresEventStoreError> {
123        let connection_string = connection_string.into();
124        let max_connections: std::num::NonZeroU32 = config.max_connections.into();
125        let pool = PgPoolOptions::new()
126            .max_connections(max_connections.get())
127            .acquire_timeout(config.acquire_timeout)
128            .idle_timeout(config.idle_timeout)
129            .connect(&connection_string)
130            .await
131            .map_err(PostgresEventStoreError::ConnectionFailed)?;
132        Ok(Self { pool })
133    }
134
135    /// Create a PostgresEventStore from an existing connection pool.
136    ///
137    /// Use this when you need full control over pool configuration or want to
138    /// share a pool across multiple components.
139    pub fn from_pool(pool: Pool<Postgres>) -> Self {
140        Self { pool }
141    }
142
143    /// Verify connectivity by issuing a trivial `SELECT 1` query.
144    ///
145    /// # Panics
146    ///
147    /// Panics if the database is unreachable or the query fails.
148    #[cfg_attr(test, mutants::skip)] // infallible: panics on failure
149    pub async fn ping(&self) {
150        let _ = query("SELECT 1")
151            .execute(&self.pool)
152            .await
153            .expect("postgres ping failed");
154    }
155
156    /// Apply the bundled schema migrations via `sqlx::migrate!("./migrations")`.
157    ///
158    /// This creates the tables EventCore requires, including the
159    /// `eventcore_subscription_versions` table used by [`PostgresCheckpointStore`].
160    ///
161    /// # Panics
162    ///
163    /// Panics if applying the migrations fails.
164    #[cfg_attr(test, mutants::skip)] // infallible: panics on failure
165    pub async fn migrate(&self) {
166        sqlx::migrate!("./migrations")
167            .run(&self.pool)
168            .await
169            .expect("postgres migration failed");
170    }
171}
172
173impl EventStore for PostgresEventStore {
174    #[instrument(name = "postgres.read_stream", skip(self))]
175    async fn read_stream<E: Event>(
176        &self,
177        stream_id: StreamId,
178    ) -> Result<EventStream<E>, EventStoreError> {
179        info!(
180            stream = %stream_id,
181            "[postgres.read_stream] reading events from postgres"
182        );
183
184        // Clone the pool (an `Arc` internally) so the returned stream owns its
185        // connection handle and can be `'static`. The query uses sqlx's lazy
186        // `fetch`, which pulls rows from the database incrementally rather than
187        // buffering the entire result set with `fetch_all` — the real memory
188        // win behind #364 for large streams.
189        let pool = self.pool.clone();
190
191        let stream = async_stream::stream! {
192            let mut rows = query(
193                "SELECT event_data FROM eventcore_events WHERE stream_id = $1 ORDER BY stream_version ASC",
194            )
195            .bind(stream_id.as_ref())
196            .fetch(&pool);
197
198            while let Some(row) = rows.next().await {
199                let row = match row {
200                    Ok(row) => row,
201                    Err(error) => {
202                        yield Err(map_sqlx_error(error, Operation::ReadStream));
203                        break;
204                    }
205                };
206
207                let payload: Value = match row.try_get("event_data") {
208                    Ok(payload) => payload,
209                    Err(error) => {
210                        yield Err(map_sqlx_error(error, Operation::ReadStream));
211                        break;
212                    }
213                };
214
215                // A per-row decode failure surfaces as an `Err` item, matching
216                // the previous behavior where a type mismatch failed the read
217                // (see the read_stream_errors_on_type_mismatch contract test).
218                match serde_json::from_value::<E>(payload) {
219                    Ok(event) => yield Ok(event),
220                    Err(error) => {
221                        yield Err(EventStoreError::DeserializationFailed {
222                            stream_id: stream_id.clone(),
223                            detail: error.to_string(),
224                        });
225                        break;
226                    }
227                }
228            }
229        };
230
231        Ok(EventStream::new(stream))
232    }
233
234    #[instrument(name = "postgres.read_stream_after", skip(self))]
235    async fn read_stream_after<E: Event>(
236        &self,
237        stream_id: StreamId,
238        exclusive_version: StreamVersion,
239    ) -> Result<EventStream<E>, EventStoreError> {
240        let pool = self.pool.clone();
241        let version: usize = exclusive_version.into();
242
243        let stream = async_stream::stream! {
244            let mut rows = query(
245                "SELECT event_data FROM eventcore_events \
246                 WHERE stream_id = $1 AND stream_version > $2 \
247                 ORDER BY stream_version ASC",
248            )
249            .bind(stream_id.as_ref())
250            .bind(version as i64)
251            .fetch(&pool);
252
253            while let Some(row) = rows.next().await {
254                let row = match row {
255                    Ok(row) => row,
256                    Err(error) => {
257                        yield Err(map_sqlx_error(error, Operation::ReadStream));
258                        break;
259                    }
260                };
261
262                let payload: Value = match row.try_get("event_data") {
263                    Ok(payload) => payload,
264                    Err(error) => {
265                        yield Err(map_sqlx_error(error, Operation::ReadStream));
266                        break;
267                    }
268                };
269
270                match serde_json::from_value::<E>(payload) {
271                    Ok(event) => yield Ok(event),
272                    Err(error) => {
273                        yield Err(EventStoreError::DeserializationFailed {
274                            stream_id: stream_id.clone(),
275                            detail: error.to_string(),
276                        });
277                        break;
278                    }
279                }
280            }
281        };
282
283        Ok(EventStream::new(stream))
284    }
285
286    #[instrument(name = "postgres.append_events", skip(self, writes))]
287    async fn append_events(
288        &self,
289        writes: StreamWrites,
290    ) -> Result<EventStreamSlice, EventStoreError> {
291        let expected_versions = writes.expected_versions().clone();
292        let entries = writes.into_entries();
293
294        if entries.is_empty() {
295            return Ok(EventStreamSlice);
296        }
297
298        info!(
299            stream_count = expected_versions.len(),
300            event_count = entries.len(),
301            "[postgres.append_events] appending events to postgres"
302        );
303
304        // Build expected versions JSON for the trigger
305        let expected_versions_json: Value = expected_versions
306            .iter()
307            .map(|(stream_id, version)| {
308                (stream_id.as_ref().to_string(), json!(version.into_inner()))
309            })
310            .collect();
311
312        let mut tx = self
313            .pool
314            .begin()
315            .await
316            .map_err(|error| map_sqlx_error(error, Operation::BeginTransaction))?;
317
318        // Set expected versions in session config for trigger validation
319        let _ = query("SELECT set_config('eventcore.expected_versions', $1, true)")
320            .bind(expected_versions_json.to_string())
321            .execute(&mut *tx)
322            .await
323            .map_err(|error| map_sqlx_error(error, Operation::SetExpectedVersions))?;
324
325        // Insert all events with a single multi-row INSERT per chunk. The
326        // BEFORE INSERT trigger still assigns gap-free stream versions and
327        // enforces optimistic concurrency for each row in VALUES order,
328        // exactly as it did for the previous per-event loop — replacing N
329        // round-trips with one statement. Chunking keeps the bound-parameter
330        // count well under Postgres' 65535-parameter limit (5 binds/event).
331        // Drop the type-erased event payload (only the in-memory store needs
332        // it) and keep just the Send + Sync fields used for SQL binding, so the
333        // borrows held by the insert loop stay Send across the awaits.
334        let rows: Vec<(StreamId, &'static str, Box<RawValue>)> = entries
335            .into_iter()
336            .map(|entry| (entry.stream_id, entry.event_type, entry.event_data))
337            .collect();
338
339        const MAX_EVENTS_PER_INSERT: usize = 1000;
340        for chunk in rows.chunks(MAX_EVENTS_PER_INSERT) {
341            let mut builder = QueryBuilder::<Postgres>::new(
342                "INSERT INTO eventcore_events (event_id, stream_id, event_type, event_data, metadata) ",
343            );
344            let _ = builder.push_values(chunk, |mut row, (stream_id, event_type, event_data)| {
345                let _ = row
346                    .push_bind(Uuid::now_v7())
347                    .push_bind(stream_id.as_ref())
348                    .push_bind(*event_type)
349                    .push_bind(Json(event_data))
350                    .push_bind(Json(json!({})));
351            });
352            let _ = builder
353                .build()
354                .execute(&mut *tx)
355                .await
356                .map_err(|error| map_sqlx_error(error, Operation::AppendEvents))?;
357        }
358
359        tx.commit()
360            .await
361            .map_err(|error| map_sqlx_error(error, Operation::CommitTransaction))?;
362
363        Ok(EventStreamSlice)
364    }
365
366    async fn load_command_state_snapshot(
367        &self,
368        snapshot_id: CommandStateSnapshotId,
369    ) -> Result<Option<CommandStateSnapshot>, EventStoreError> {
370        let row = query(
371            "SELECT state, stream_versions, replay_checkpoints \
372             FROM eventcore_command_state_snapshots \
373             WHERE snapshot_id = $1",
374        )
375        .bind(snapshot_id.as_ref())
376        .fetch_optional(&self.pool)
377        .await
378        .map_err(|error| map_sqlx_error(error, Operation::ReadStream))?;
379
380        row.map(|row| {
381            let state: Json<Value> = row
382                .try_get("state")
383                .map_err(|error| map_sqlx_error(error, Operation::ReadStream))?;
384            let stream_versions: Json<std::collections::HashMap<StreamId, StreamVersion>> = row
385                .try_get("stream_versions")
386                .map_err(|error| map_sqlx_error(error, Operation::ReadStream))?;
387            let replay_checkpoints: Json<Vec<CommandStateReplayCheckpoint>> = row
388                .try_get("replay_checkpoints")
389                .map_err(|error| map_sqlx_error(error, Operation::ReadStream))?;
390
391            Ok(CommandStateSnapshot::new(state.0, stream_versions.0)
392                .with_replay_checkpoints(replay_checkpoints.0))
393        })
394        .transpose()
395    }
396
397    async fn save_command_state_snapshot(
398        &self,
399        snapshot_id: CommandStateSnapshotId,
400        snapshot: CommandStateSnapshot,
401    ) -> Result<(), EventStoreError> {
402        // The conflict predicate is evaluated while PostgreSQL holds the
403        // conflicting row lock. This means concurrent refreshes can never let
404        // a stale vector replace one that includes more events.
405        let _ = query(
406            "INSERT INTO eventcore_command_state_snapshots \
407                 (snapshot_id, state, stream_versions, replay_checkpoints, updated_at) \
408             VALUES ($1, $2, $3, $4, NOW()) \
409             ON CONFLICT (snapshot_id) DO UPDATE \
410             SET state = EXCLUDED.state, \
411                 stream_versions = EXCLUDED.stream_versions, \
412                 replay_checkpoints = EXCLUDED.replay_checkpoints, \
413                 updated_at = NOW() \
414             WHERE NOT EXISTS ( \
415                 SELECT 1 \
416                 FROM jsonb_each_text(eventcore_command_state_snapshots.stream_versions) \
417                     AS current(stream_id, stream_version) \
418                 WHERE NOT (EXCLUDED.stream_versions ? current.stream_id) \
419                    OR (EXCLUDED.stream_versions ->> current.stream_id)::BIGINT \
420                       < current.stream_version::BIGINT \
421             )",
422        )
423        .bind(snapshot_id.as_ref())
424        .bind(Json(snapshot.state))
425        .bind(Json(snapshot.stream_versions))
426        .bind(Json(snapshot.replay_checkpoints))
427        .execute(&self.pool)
428        .await
429        .map_err(|error| map_sqlx_error(error, Operation::AppendEvents))?;
430
431        Ok(())
432    }
433}
434
435impl CheckpointStore for PostgresEventStore {
436    type Error = PostgresCheckpointError;
437
438    async fn load(&self, name: &str) -> Result<Option<StreamPosition>, Self::Error> {
439        let row = query("SELECT last_position FROM eventcore_subscription_versions WHERE subscription_name = $1")
440            .bind(name)
441            .fetch_optional(&self.pool)
442            .await
443            .map_err(PostgresCheckpointError::DatabaseError)?;
444
445        match row {
446            Some(row) => {
447                let position: Uuid = row.get("last_position");
448                Ok(Some(StreamPosition::new(position)))
449            }
450            None => Ok(None),
451        }
452    }
453
454    async fn save(&self, name: &str, position: StreamPosition) -> Result<(), Self::Error> {
455        let position_uuid: Uuid = position.into_inner();
456        let _ = query(
457            "INSERT INTO eventcore_subscription_versions (subscription_name, last_position, updated_at)
458             VALUES ($1, $2, NOW())
459             ON CONFLICT (subscription_name) DO UPDATE SET last_position = $2, updated_at = NOW()",
460        )
461        .bind(name)
462        .bind(position_uuid)
463        .execute(&self.pool)
464        .await
465        .map_err(PostgresCheckpointError::DatabaseError)?;
466
467        Ok(())
468    }
469}
470
471impl EventReader for PostgresEventStore {
472    type Error = EventStoreError;
473
474    async fn read_events<E: Event>(
475        &self,
476        filter: EventFilter,
477        page: EventPage,
478    ) -> Result<Vec<(E, StreamPosition)>, Self::Error> {
479        // Query events ordered by event_id (UUID7, monotonically increasing).
480        // Use event_id directly as the global position - no need for ROW_NUMBER.
481        let after_event_id: Option<Uuid> = page.after_position().map(|p| p.into_inner());
482        let limit: i64 = page.limit().into_inner() as i64;
483
484        // Filter by event_type in SQL so non-matching types don't consume
485        // batch slots (fixes issue #372). Use explicit filter if set,
486        // otherwise derive from E::event_type_name().
487        let type_filter = filter.event_type().unwrap_or_else(|| E::event_type_name());
488
489        // Glob pattern pushdown translates the pattern to an anchored POSIX
490        // regex matched with the `~` operator (ADR-0047). The translation
491        // escapes all regex metacharacters in literal segments so user input
492        // cannot inject regex syntax.
493        let pattern_regex = filter
494            .stream_pattern()
495            .map(|p| glob_to_anchored_regex(p.as_ref()));
496
497        // Build the query dynamically so prefix XOR pattern, the optional
498        // cursor, and the event_type predicate compose without a combinatorial
499        // explosion of hand-written query strings.
500        let mut builder = QueryBuilder::<Postgres>::new(
501            "SELECT event_id, event_data, stream_id FROM eventcore_events WHERE event_type = ",
502        );
503        let _ = builder.push_bind(type_filter);
504
505        if let Some(after_id) = after_event_id {
506            let _ = builder.push(" AND event_id > ").push_bind(after_id);
507        }
508
509        if let Some(prefix) = filter.stream_prefix() {
510            let _ = builder
511                .push(" AND stream_id LIKE ")
512                .push_bind(prefix.as_ref().to_string())
513                .push(" || '%'");
514        } else if let Some(regex) = pattern_regex {
515            let _ = builder.push(" AND stream_id ~ ").push_bind(regex);
516        }
517
518        let _ = builder.push(" ORDER BY event_id LIMIT ").push_bind(limit);
519
520        let rows = builder
521            .build()
522            .fetch_all(&self.pool)
523            .await
524            .map_err(|error| map_sqlx_error(error, Operation::ReadStream))?;
525
526        let events: Vec<(E, StreamPosition)> = rows
527            .into_iter()
528            .filter_map(|row| {
529                let event_data: Json<Value> = row.get("event_data");
530                let event_id: Uuid = row.get("event_id");
531                serde_json::from_value::<E>(event_data.0)
532                    .ok()
533                    .map(|e| (e, StreamPosition::new(event_id)))
534            })
535            .collect();
536
537        Ok(events)
538    }
539}
540
541/// Translate a POSIX glob pattern into an anchored POSIX regular expression
542/// suitable for PostgreSQL's `~` operator (ADR-0047).
543///
544/// Mapping:
545/// - `*` → `.*` (matches any sequence, including the `/` separator)
546/// - `?` → `.` (matches exactly one character)
547/// - `[...]` / `[!...]` → a regex character class (`[!` is normalized to the
548///   regex negation `[^`); the class contents are copied verbatim so ranges
549///   like `[0-9]` and `[a-z]` work, while a closing `]` ends the class
550/// - every other character is a literal and is regex-escaped
551///
552/// The result is anchored with `^...$` so the whole stream ID must match,
553/// mirroring `glob::Pattern::matches`. Because the pattern has already been
554/// validated as a compilable `glob::Pattern`, brackets are balanced; a stray
555/// `[` (impossible for a valid pattern) is treated as a literal.
556fn glob_to_anchored_regex(glob: &str) -> String {
557    let mut regex = String::with_capacity(glob.len() + 2);
558    regex.push('^');
559
560    let mut chars = glob.chars().peekable();
561    while let Some(c) = chars.next() {
562        match c {
563            '*' => regex.push_str(".*"),
564            '?' => regex.push('.'),
565            '[' => {
566                // Collect the bracket expression up to the closing ']'.
567                let mut class = String::new();
568                let mut closed = false;
569                if matches!(chars.peek(), Some('!')) {
570                    let _ = chars.next();
571                    class.push('^');
572                }
573                for inner in chars.by_ref() {
574                    if inner == ']' {
575                        closed = true;
576                        break;
577                    }
578                    class.push(inner);
579                }
580                if closed {
581                    regex.push('[');
582                    regex.push_str(&class);
583                    regex.push(']');
584                } else {
585                    // Not a real class (a validated glob never reaches here);
586                    // treat the '[' and collected chars as literals.
587                    regex.push_str(&regex_escape("["));
588                    regex.push_str(&regex_escape(&class));
589                }
590            }
591            other => regex.push_str(&regex_escape(&other.to_string())),
592        }
593    }
594
595    regex.push('$');
596    regex
597}
598
599/// Escape every POSIX-regex metacharacter in a literal segment so it matches
600/// itself, preventing regex injection from stream-pattern input.
601fn regex_escape(literal: &str) -> String {
602    const METACHARACTERS: &[char] = &[
603        '.', '^', '$', '*', '+', '?', '(', ')', '[', ']', '{', '}', '|', '\\',
604    ];
605    let mut escaped = String::with_capacity(literal.len());
606    for c in literal.chars() {
607        if METACHARACTERS.contains(&c) {
608            escaped.push('\\');
609        }
610        escaped.push(c);
611    }
612    escaped
613}
614
615fn map_sqlx_error(error: sqlx::Error, operation: Operation) -> EventStoreError {
616    if let sqlx::Error::Database(db_error) = &error {
617        let code = db_error.code();
618        let code_str = code.as_deref();
619        // P0001: Custom error from trigger (version_conflict)
620        // 23505: Unique constraint violation (fallback for version conflict)
621        if code_str == Some("P0001") || code_str == Some("23505") {
622            warn!(
623                error = %db_error,
624                "[postgres.version_conflict] optimistic concurrency check failed"
625            );
626            return parse_version_conflict_from_db_error(db_error.message());
627        }
628    }
629
630    error!(
631        error = %error,
632        operation = %operation,
633        "[postgres.database_error] database operation failed"
634    );
635    EventStoreError::StoreFailure { operation }
636}
637
638/// Parse version conflict details from the PostgreSQL trigger error message.
639///
640/// The trigger produces messages like:
641///   `version_conflict: stream "my-stream" expected version 0, actual 1`
642///
643/// If parsing fails, falls back to a VersionConflict with a sentinel stream_id
644/// indicating the details could not be extracted.
645fn parse_version_conflict_from_db_error(message: &str) -> EventStoreError {
646    // Pattern: version_conflict: stream "STREAM_ID" expected version EXPECTED, actual ACTUAL
647    if let Some(parsed) = try_parse_conflict_message(message) {
648        return parsed;
649    }
650
651    // Fallback: unique constraint violation (23505) or unparseable trigger message.
652    // Use a sentinel stream_id since we don't have the details.
653    let fallback_stream_id =
654        StreamId::try_new("unknown-conflict-stream").expect("static stream id is valid");
655    EventStoreError::VersionConflict {
656        stream_id: fallback_stream_id,
657        expected: StreamVersion::new(0),
658        actual: StreamVersion::new(0),
659    }
660}
661
662fn try_parse_conflict_message(message: &str) -> Option<EventStoreError> {
663    let rest = message.strip_prefix("version_conflict: stream \"")?;
664    let stream_end = rest.find('"')?;
665    let stream_id_str = &rest[..stream_end];
666    let after_stream = &rest[stream_end..];
667
668    let expected_str = after_stream
669        .strip_prefix("\" expected version ")?
670        .split(',')
671        .next()?;
672    let actual_str = after_stream.rsplit("actual ").next()?;
673
674    let expected = expected_str.trim().parse::<usize>().ok()?;
675    let actual = actual_str.trim().parse::<usize>().ok()?;
676    let stream_id = StreamId::try_new(stream_id_str).ok()?;
677
678    Some(EventStoreError::VersionConflict {
679        stream_id,
680        expected: StreamVersion::new(expected),
681        actual: StreamVersion::new(actual),
682    })
683}
684
685/// Error type for PostgresCheckpointStore operations.
686#[derive(Debug, Error)]
687pub enum PostgresCheckpointError {
688    /// Failed to create connection pool.
689    #[error("failed to create postgres connection pool")]
690    ConnectionFailed(#[source] sqlx::Error),
691
692    /// Database operation failed.
693    #[error("database operation failed: {0}")]
694    DatabaseError(#[source] sqlx::Error),
695}
696
697/// Postgres-backed checkpoint store for tracking projection progress.
698///
699/// `PostgresCheckpointStore` stores checkpoint positions in a PostgreSQL table,
700/// providing durability across process restarts. It implements the `CheckpointStore`
701/// trait from eventcore-types.
702///
703/// # Schema
704///
705/// The store uses the `eventcore_subscription_versions` table with:
706/// - `subscription_name`: Unique identifier for each projector/subscription
707/// - `last_position`: UUID7 representing the global stream position
708/// - `updated_at`: Timestamp of the last checkpoint update
709#[derive(Debug, Clone)]
710pub struct PostgresCheckpointStore {
711    pool: Pool<Postgres>,
712}
713
714impl PostgresCheckpointStore {
715    /// Create a new PostgresCheckpointStore with default configuration.
716    pub async fn new<S: Into<String>>(
717        connection_string: S,
718    ) -> Result<Self, PostgresCheckpointError> {
719        Self::with_config(connection_string, PostgresConfig::default()).await
720    }
721
722    /// Create a new PostgresCheckpointStore with custom configuration.
723    pub async fn with_config<S: Into<String>>(
724        connection_string: S,
725        config: PostgresConfig,
726    ) -> Result<Self, PostgresCheckpointError> {
727        let connection_string = connection_string.into();
728        let max_connections: std::num::NonZeroU32 = config.max_connections.into();
729        let pool = PgPoolOptions::new()
730            .max_connections(max_connections.get())
731            .acquire_timeout(config.acquire_timeout)
732            .idle_timeout(config.idle_timeout)
733            .connect(&connection_string)
734            .await
735            .map_err(PostgresCheckpointError::ConnectionFailed)?;
736
737        // Run migrations to ensure table exists
738        sqlx::migrate!("./migrations")
739            .run(&pool)
740            .await
741            .map_err(|e| {
742                PostgresCheckpointError::DatabaseError(sqlx::Error::Migrate(Box::new(e)))
743            })?;
744
745        Ok(Self { pool })
746    }
747
748    /// Create a PostgresCheckpointStore from an existing connection pool.
749    ///
750    /// Use this when you need full control over pool configuration or want to
751    /// share a pool across multiple components.
752    pub fn from_pool(pool: Pool<Postgres>) -> Self {
753        Self { pool }
754    }
755}
756
757impl CheckpointStore for PostgresCheckpointStore {
758    type Error = PostgresCheckpointError;
759
760    async fn load(&self, name: &str) -> Result<Option<StreamPosition>, Self::Error> {
761        let row = query("SELECT last_position FROM eventcore_subscription_versions WHERE subscription_name = $1")
762            .bind(name)
763            .fetch_optional(&self.pool)
764            .await
765            .map_err(PostgresCheckpointError::DatabaseError)?;
766
767        match row {
768            Some(row) => {
769                let position: Uuid = row.get("last_position");
770                Ok(Some(StreamPosition::new(position)))
771            }
772            None => Ok(None),
773        }
774    }
775
776    async fn save(&self, name: &str, position: StreamPosition) -> Result<(), Self::Error> {
777        let position_uuid: Uuid = position.into_inner();
778        let _ = query(
779            "INSERT INTO eventcore_subscription_versions (subscription_name, last_position, updated_at)
780             VALUES ($1, $2, NOW())
781             ON CONFLICT (subscription_name) DO UPDATE SET last_position = $2, updated_at = NOW()",
782        )
783        .bind(name)
784        .bind(position_uuid)
785        .execute(&self.pool)
786        .await
787        .map_err(PostgresCheckpointError::DatabaseError)?;
788
789        Ok(())
790    }
791}
792
793// ============================================================================
794// PostgresProjectorCoordinator - Distributed projector coordination via Postgres
795// ============================================================================
796
797/// Error type for projector coordination operations.
798#[derive(Debug, Error)]
799pub enum CoordinationError {
800    /// Leadership could not be acquired (another instance holds the lock).
801    #[error(
802        "leadership not acquired for subscription '{subscription_name}': another instance holds the lock"
803    )]
804    LeadershipNotAcquired { subscription_name: String },
805
806    /// Database operation failed.
807    #[error("database operation failed: {0}")]
808    DatabaseError(#[source] sqlx::Error),
809}
810
811/// Guard type that releases leadership when dropped.
812///
813/// Holds the advisory lock key and the actual database connection that acquired
814/// the lock. This is critical because PostgreSQL advisory locks are session-scoped:
815/// the unlock must happen on the same connection that acquired the lock.
816///
817/// # Lock Release Behavior
818///
819/// The guard attempts to explicitly release the advisory lock when dropped:
820///
821/// - **Multi-threaded runtime**: Uses `block_in_place` to synchronously release
822///   the lock before the guard is fully dropped.
823///
824/// - **Single-threaded runtime**: Spawns a task to release the lock asynchronously.
825///   This task may not execute before process shutdown, in which case the lock is
826///   released when the PostgreSQL session ends (connection closes).
827///
828/// # PostgreSQL Session-Scoped Locks
829///
830/// PostgreSQL advisory locks acquired with `pg_try_advisory_lock` are session-scoped
831/// and automatically released when the database connection closes. This provides a
832/// safety net: even if explicit unlock fails or is skipped, the lock will be released
833/// when:
834/// - The connection is returned to the pool and recycled
835/// - The connection pool is shut down
836/// - The database connection times out
837///
838/// For production deployments, configure appropriate connection pool idle timeouts
839/// to ensure timely lock release on ungraceful shutdown.
840pub struct CoordinationGuard {
841    lock_key: i64,
842    /// The actual connection that holds the advisory lock.
843    /// Must be Option so we can take ownership in Drop.
844    connection: Option<sqlx::pool::PoolConnection<Postgres>>,
845}
846
847impl std::fmt::Debug for CoordinationGuard {
848    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
849        f.debug_struct("CoordinationGuard")
850            .field("lock_key", &self.lock_key)
851            .finish_non_exhaustive()
852    }
853}
854
855impl Drop for CoordinationGuard {
856    fn drop(&mut self) {
857        // Take ownership of the connection - we need the same connection that acquired the lock
858        if let Some(mut connection) = self.connection.take() {
859            let lock_key = self.lock_key;
860
861            // Check runtime flavor to determine the appropriate unlock strategy.
862            // block_in_place panics on single-threaded runtimes, so we must check first.
863            let handle = tokio::runtime::Handle::current();
864            let is_multi_thread =
865                handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread;
866
867            if is_multi_thread {
868                // Multi-threaded runtime: use block_in_place for synchronous unlock
869                tokio::task::block_in_place(|| {
870                    handle.block_on(async {
871                        // Unlock on the SAME connection that acquired the lock
872                        if let Err(e) = query("SELECT pg_advisory_unlock($1)")
873                            .bind(lock_key)
874                            .execute(&mut *connection)
875                            .await
876                        {
877                            warn!(
878                                lock_key = lock_key,
879                                error = %e,
880                                "failed to release advisory lock on drop"
881                            );
882                        }
883                        // Connection is returned to pool when dropped here
884                    });
885                });
886            } else {
887                // Single-threaded runtime: spawn a task for async unlock.
888                // Note: This task may not execute before process shutdown. In that case,
889                // the advisory lock is released when the PostgreSQL session ends (the
890                // connection closes). See struct-level documentation for details.
891                drop(tokio::spawn(async move {
892                    if let Err(e) = query("SELECT pg_advisory_unlock($1)")
893                        .bind(lock_key)
894                        .execute(&mut *connection)
895                        .await
896                    {
897                        warn!(
898                            lock_key = lock_key,
899                            error = %e,
900                            "failed to release advisory lock on drop (async)"
901                        );
902                    }
903                }));
904            }
905        }
906    }
907}
908
909/// Postgres-backed projector coordinator for distributed leadership.
910///
911/// `PostgresProjectorCoordinator` uses PostgreSQL advisory locks to ensure
912/// only one projector instance processes events for a given subscription
913/// at a time, preventing duplicate processing in distributed deployments.
914#[derive(Debug, Clone)]
915pub struct PostgresProjectorCoordinator {
916    pool: Pool<Postgres>,
917}
918
919impl PostgresProjectorCoordinator {
920    /// Create a new PostgresProjectorCoordinator with default configuration.
921    pub async fn new<S: Into<String>>(connection_string: S) -> Result<Self, CoordinationError> {
922        Self::with_config(connection_string, PostgresConfig::default()).await
923    }
924
925    /// Create a new PostgresProjectorCoordinator with custom configuration.
926    pub async fn with_config<S: Into<String>>(
927        connection_string: S,
928        config: PostgresConfig,
929    ) -> Result<Self, CoordinationError> {
930        let connection_string = connection_string.into();
931        let max_connections: std::num::NonZeroU32 = config.max_connections.into();
932        let pool = PgPoolOptions::new()
933            .max_connections(max_connections.get())
934            .acquire_timeout(config.acquire_timeout)
935            .idle_timeout(config.idle_timeout)
936            .connect(&connection_string)
937            .await
938            .map_err(CoordinationError::DatabaseError)?;
939
940        Ok(Self { pool })
941    }
942
943    /// Create a PostgresProjectorCoordinator from an existing connection pool.
944    pub fn from_pool(pool: Pool<Postgres>) -> Self {
945        Self { pool }
946    }
947}
948
949/// Compute a stable FNV-1a hash of the subscription name to derive an advisory lock key.
950///
951/// This uses the FNV-1a algorithm (64-bit) which produces deterministic output
952/// across Rust versions, unlike `DefaultHasher` which is explicitly not stable.
953fn advisory_lock_key(subscription_name: &str) -> i64 {
954    const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
955    const FNV_PRIME: u64 = 0x00000100000001B3;
956
957    let mut hash = FNV_OFFSET_BASIS;
958    for byte in subscription_name.as_bytes() {
959        hash ^= *byte as u64;
960        hash = hash.wrapping_mul(FNV_PRIME);
961    }
962    hash as i64
963}
964
965/// Try to acquire a PostgreSQL advisory lock for the given subscription name
966/// using the provided connection pool.
967async fn try_acquire_advisory_lock(
968    pool: &Pool<Postgres>,
969    subscription_name: &str,
970) -> Result<CoordinationGuard, CoordinationError> {
971    let lock_key = advisory_lock_key(subscription_name);
972
973    // Acquire a dedicated connection from the pool.
974    // This connection MUST be kept for the lifetime of the guard because
975    // PostgreSQL advisory locks are session-scoped.
976    let mut connection = pool
977        .acquire()
978        .await
979        .map_err(CoordinationError::DatabaseError)?;
980
981    // Attempt to acquire advisory lock (non-blocking) on this specific connection
982    let row = query("SELECT pg_try_advisory_lock($1)")
983        .bind(lock_key)
984        .fetch_one(&mut *connection)
985        .await
986        .map_err(CoordinationError::DatabaseError)?;
987
988    let acquired: bool = row.get(0);
989
990    if acquired {
991        Ok(CoordinationGuard {
992            lock_key,
993            connection: Some(connection),
994        })
995    } else {
996        // Lock not acquired - connection will be returned to pool here
997        Err(CoordinationError::LeadershipNotAcquired {
998            subscription_name: subscription_name.to_string(),
999        })
1000    }
1001}
1002
1003impl ProjectorCoordinator for PostgresProjectorCoordinator {
1004    type Error = CoordinationError;
1005    type Guard = CoordinationGuard;
1006
1007    async fn try_acquire(&self, subscription_name: &str) -> Result<Self::Guard, Self::Error> {
1008        try_acquire_advisory_lock(&self.pool, subscription_name).await
1009    }
1010}
1011
1012impl ProjectorCoordinator for PostgresEventStore {
1013    type Error = CoordinationError;
1014    type Guard = CoordinationGuard;
1015
1016    async fn try_acquire(&self, subscription_name: &str) -> Result<Self::Guard, Self::Error> {
1017        try_acquire_advisory_lock(&self.pool, subscription_name).await
1018    }
1019}
1020
1021#[cfg(test)]
1022#[path = "lib.test.rs"]
1023mod tests;