From 3d7fea3ff2adf1f6edfe8f164910f21db7ce7157 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 2 Jul 2026 23:26:19 -0500 Subject: [PATCH] refactor: collapse postgres/sqlite repositories into shared generic layer Extends the existing SqlxReadModelBackend / lock/sqlx_common dialect-trait pattern to the event-store/snapshot/outbox/inbox layers. postgres_repo and sqlite_repo shrink from ~1700 lines each to ~460-line dialect shims over a shared SqlxRepository/SqlxOutboxStore in src/sqlx_repo/repo.rs. Outbox `claim` stays per-backend (postgres SKIP LOCKED CTE vs sqlite scan-loop). Squashed from 14 commits for a single rebase reconciliation onto main (after - fix: surface malformed sqlite timestamps as errors (was silent UNIX_EPOCH); align null-bind error message with postgres (includes field_name). - refactor: share commit-batch validation across all backends (fixes hashmap vs SQL snapshot-identity drift). - feat!: widen postgres integer columns to BIGINT so both backends decode i64; deletes the width-conversion helpers. - perf: batch snapshot loads for get_all hydration (fixes N+1); get_streams is a GetStream default method. - perf: batch the commit_batch concurrency pre-check into one query. - perf!: borrow events in PreparedEventAppend instead of cloning. - feat!: bound outbox status listings (messages_by_status/pending) with a limit. - perf!: store EventRecord.payload_codec as Cow<'static, str>. - feat: run migrations through sqlx's Migrator with a _sqlx_migrations ledger. - fix: chunk postgres event/outbox inserts under the 65535 bind-param cap. - fix: classify postgres 57P01/57P02/57P03 and class-08 SQLSTATEs as transient. Merge-reconciliation notes: - table/ is now the canonical vocabulary (#109); backend shims import the renamed types via local aliases (TableColumn as ColumnDef, TableStoreError as ReadModelError) to keep the collapsed bodies unchanged. - #106's batched OutboxStore::complete_many overrides lived in the old backend files and were collapsed away; SqlxOutboxStore inherits the serial default (correct, conformance-tested). Re-adding a batched override to the shared layer is a follow-up (see tasks/outbox-sqlx-batched-complete-many). REBASED onto main+#110 (9f34f0a): #110 already merged, so its fault-injection test that pinned the OLD 57P01 (mis)classification is flipped here to `assert!(err.is_retryable())` to match this PR's classification fix; #110's shared outbox test helpers are updated for the bounded messages_by_status(limit) API. Main stays green on merge. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DzYSVLas93c7LbgHJWsW7n --- Cargo.toml | 4 +- migrations/postgres/0001_initial.sql | 12 +- src/aggregate/repository.rs | 33 +- src/entity/event_record.rs | 18 +- src/hashmap_repo/repository.rs | 65 +- src/microsvc/runtime.rs | 16 +- src/outbox/commit.rs | 14 +- src/outbox_worker/outbox_source.rs | 2 +- src/outbox_worker/store.rs | 16 +- src/postgres_repo/mod.rs | 1694 +++--------------------- src/queued_repo/repository.rs | 7 + src/repository/mod.rs | 5 +- src/repository/traits.rs | 58 +- src/repository/validation.rs | 50 +- src/snapshot/in_memory.rs | 16 + src/snapshot/repository.rs | 42 +- src/sqlite_repo/mod.rs | 1582 +++------------------- src/sqlx_repo/mod.rs | 139 +- src/sqlx_repo/read_model.rs | 2 +- src/sqlx_repo/repo.rs | 1822 ++++++++++++++++++++++++++ tests/bomberman/main.rs | 2 +- tests/distributed_read_model/main.rs | 2 +- tests/durable_enqueue_sqlite/main.rs | 6 +- tests/microsvc/convention.rs | 6 +- tests/postgres_repository/main.rs | 16 +- tests/sourced_snapshot/main.rs | 7 +- tests/sqlite_repository/main.rs | 2 +- tests/support/outbox.rs | 2 +- tests/todos/main.rs | 25 +- tests/transport_conformance/mod.rs | 3 +- 30 files changed, 2574 insertions(+), 3094 deletions(-) create mode 100644 src/sqlx_repo/repo.rs diff --git a/Cargo.toml b/Cargo.toml index be28e44b..66e8e556 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,8 +31,8 @@ default = [] emitter = ["dep:event-emitter-rs"] http = ["dep:axum", "dep:reqwest", "dep:tokio"] grpc = ["dep:tonic", "dep:tonic-prost", "dep:prost", "dep:tokio"] -postgres = ["dep:sqlx", "dep:tokio", "sqlx/postgres", "sqlx/runtime-tokio"] -sqlite = ["dep:sqlx", "dep:tokio", "sqlx/runtime-tokio", "sqlx/sqlite"] +postgres = ["dep:sqlx", "dep:tokio", "sqlx/postgres", "sqlx/migrate", "sqlx/runtime-tokio"] +sqlite = ["dep:sqlx", "dep:tokio", "sqlx/migrate", "sqlx/runtime-tokio", "sqlx/sqlite"] nats = ["dep:async-nats", "dep:futures", "dep:tokio"] rabbitmq = ["dep:lapin", "dep:futures", "dep:tokio"] kafka = ["dep:rdkafka", "dep:tokio"] diff --git a/migrations/postgres/0001_initial.sql b/migrations/postgres/0001_initial.sql index 81b94126..ed4f75f5 100644 --- a/migrations/postgres/0001_initial.sql +++ b/migrations/postgres/0001_initial.sql @@ -3,10 +3,10 @@ CREATE TABLE IF NOT EXISTS aggregate_events ( aggregate_id text NOT NULL, sequence bigint NOT NULL, event_name text NOT NULL, - event_version integer NOT NULL DEFAULT 1, + event_version bigint NOT NULL DEFAULT 1, payload bytea NOT NULL, payload_codec text NOT NULL, - payload_codec_version integer NOT NULL, + payload_codec_version bigint NOT NULL, metadata jsonb NOT NULL DEFAULT '{}'::jsonb, recorded_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (aggregate_type, aggregate_id, sequence), @@ -28,10 +28,10 @@ CREATE TABLE IF NOT EXISTS aggregate_snapshots ( aggregate_type text NOT NULL, aggregate_id text NOT NULL, version bigint NOT NULL, - snapshot_version integer NOT NULL, + snapshot_version bigint NOT NULL, payload bytea NOT NULL, payload_codec text NOT NULL, - payload_codec_version integer NOT NULL, + payload_codec_version bigint NOT NULL, metadata jsonb NOT NULL DEFAULT '{}'::jsonb, recorded_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), @@ -49,7 +49,7 @@ CREATE TABLE IF NOT EXISTS outbox_messages ( event_type text NOT NULL, payload bytea NOT NULL, payload_codec text NOT NULL, - payload_codec_version integer NOT NULL, + payload_codec_version bigint NOT NULL, destination text, metadata jsonb NOT NULL DEFAULT '{}'::jsonb, status text NOT NULL, @@ -57,7 +57,7 @@ CREATE TABLE IF NOT EXISTS outbox_messages ( next_available_at timestamptz NOT NULL, claimed_by text, claimed_until timestamptz, - attempts integer NOT NULL DEFAULT 0, + attempts bigint NOT NULL DEFAULT 0, last_error text, published_at timestamptz, failed_at timestamptz, diff --git a/src/aggregate/repository.rs b/src/aggregate/repository.rs index eab3f03e..a2dc949d 100644 --- a/src/aggregate/repository.rs +++ b/src/aggregate/repository.rs @@ -42,8 +42,11 @@ pub(crate) struct SnapshotPolicy { /// Build a snapshot cache record for the aggregate when one is due. record: fn(&A, u64) -> Result, RepositoryError>, /// Hydrate an already-loaded entity from the cache record (if any). Used on - /// load paths that have the full stream in hand (batch loads, locked reads). + /// load paths that have the full stream in hand (locked reads). hydrate: HydrateFn, + /// Hydrate a batch of already-loaded entities, reading all cache records in + /// one round trip. Used by batch loads (`get_all`). + hydrate_all: HydrateAllFn, /// Own the whole load: read the snapshot first, then fetch only the tail of /// the stream (skipping already-snapshotted I/O), falling back to a full /// load on a cache miss. Used by the single-aggregate `get` hot path. @@ -57,6 +60,12 @@ type HydrateFn = Entity, ) -> Pin> + Send + 'a>>; +type HydrateAllFn = + for<'a> fn( + &'a R, + Vec<(StreamIdentity, Entity)>, + ) -> Pin, RepositoryError>> + Send + 'a>>; + type LoadFn = for<'a> fn( &'a R, &'a StreamIdentity, @@ -71,12 +80,14 @@ impl SnapshotPolicy { frequency: u64, record: fn(&A, u64) -> Result, RepositoryError>, hydrate: HydrateFn, + hydrate_all: HydrateAllFn, load: LoadFn, ) -> Self { Self { frequency, record, hydrate, + hydrate_all, load, } } @@ -223,15 +234,21 @@ impl AggregateRepository where A: Aggregate + Send, { - /// Hydrate a batch of entities, deriving each identity from the entity id so - /// the snapshot cache can be consulted per aggregate. + /// Hydrate a batch of entities, deriving each identity from the entity id. + /// With a snapshot policy the cache records for the whole batch are read in + /// one round trip; without one, a plain per-entity replay. async fn hydrate_entities(&self, entities: Vec) -> Result, RepositoryError> { - let mut aggregates = Vec::with_capacity(entities.len()); - for entity in entities { - let identity = stream_identity_for::(entity.id())?; - aggregates.push(self.hydrate_entity(&identity, entity).await?); + match &self.snapshot { + Some(policy) => { + let mut pairs = Vec::with_capacity(entities.len()); + for entity in entities { + let identity = stream_identity_for::(entity.id())?; + pairs.push((identity, entity)); + } + (policy.hydrate_all)(&self.repo, pairs).await + } + None => entities.into_iter().map(hydrate::).collect(), } - Ok(aggregates) } } diff --git a/src/entity/event_record.rs b/src/entity/event_record.rs index 47ee806a..7048e273 100644 --- a/src/entity/event_record.rs +++ b/src/entity/event_record.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::collections::HashMap; use std::fmt; use std::time::SystemTime; @@ -90,8 +91,8 @@ fn default_event_version() -> u64 { fn is_version_one(v: &u64) -> bool { *v == 1 } -fn default_payload_codec() -> String { - BITCODE_PAYLOAD_CODEC.to_string() +fn default_payload_codec() -> Cow<'static, str> { + Cow::Borrowed(BITCODE_PAYLOAD_CODEC) } fn default_payload_codec_version() -> u16 { BITCODE_PAYLOAD_CODEC_VERSION @@ -105,8 +106,11 @@ fn default_payload_codec_version() -> u16 { #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] pub struct EventRecord { pub event_name: String, + /// Payload codec name. `Cow` because virtually every event carries the + /// crate's own codec constant — the borrowed variant avoids one heap + /// allocation per event created and per row decoded. #[serde(default = "default_payload_codec")] - pub payload_codec: String, + pub payload_codec: Cow<'static, str>, #[serde(default = "default_payload_codec_version")] pub payload_codec_version: u16, #[serde(with = "payload_serde")] @@ -146,7 +150,7 @@ impl EventRecord { pub fn new(event_name: impl Into, payload: Vec, sequence: u64) -> Self { EventRecord { event_name: event_name.into(), - payload_codec: BITCODE_PAYLOAD_CODEC.to_string(), + payload_codec: Cow::Borrowed(BITCODE_PAYLOAD_CODEC), payload_codec_version: BITCODE_PAYLOAD_CODEC_VERSION, payload, event_version: 1, @@ -165,7 +169,7 @@ impl EventRecord { ) -> Self { EventRecord { event_name: event_name.into(), - payload_codec: BITCODE_PAYLOAD_CODEC.to_string(), + payload_codec: Cow::Borrowed(BITCODE_PAYLOAD_CODEC), payload_codec_version: BITCODE_PAYLOAD_CODEC_VERSION, payload, event_version: version, @@ -184,7 +188,7 @@ impl EventRecord { ) -> Self { EventRecord { event_name: event_name.into(), - payload_codec: BITCODE_PAYLOAD_CODEC.to_string(), + payload_codec: Cow::Borrowed(BITCODE_PAYLOAD_CODEC), payload_codec_version: BITCODE_PAYLOAD_CODEC_VERSION, payload, event_version: 1, @@ -292,7 +296,7 @@ mod tests { #[test] fn decode_unknown_codec_returns_error() { let mut event_record = EventRecord::new("test_event", vec![], 1); - event_record.payload_codec = "json".to_string(); + event_record.payload_codec = "json".into(); let err = event_record.decode::<()>().unwrap_err(); assert!(err.message.contains("unsupported payload codec `json`")); diff --git a/src/hashmap_repo/repository.rs b/src/hashmap_repo/repository.rs index 87b7c179..104b4dcf 100644 --- a/src/hashmap_repo/repository.rs +++ b/src/hashmap_repo/repository.rs @@ -14,11 +14,9 @@ use crate::read_model::{ InMemoryReadModelStore, ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities, }; use crate::repository::{ - reject_duplicate_outbox_messages, reject_duplicate_streams, - validate_entity_id_matches_identity, validate_prepared_appends, validate_snapshot_identity, - CommitBatch, GetStream, InboxStore, PreparedEventAppend, ReadModelWritePlanStore, - RelationalReadModelQueryStore, RepositoryError, SnapshotStore, SnapshotWrite, StreamIdentity, - TransactionalCommit, + validate_commit_batch, validate_snapshot_identity, CommitBatch, GetStream, InboxStore, + ReadModelWritePlanStore, RelationalReadModelQueryStore, RepositoryError, SnapshotStore, + SnapshotWrite, StreamIdentity, TransactionalCommit, }; use crate::snapshot::{InMemorySnapshotStore, SnapshotRecord}; use crate::table::{TableAdapterCapabilities, TableCommitOutcome, TableStoreError, TableWritePlan}; @@ -140,21 +138,6 @@ impl GetStream for HashMapRepository { } } } - - fn get_streams<'a>( - &'a self, - identities: &'a [StreamIdentity], - ) -> impl Future, RepositoryError>> + Send + 'a { - async move { - let mut entities = Vec::with_capacity(identities.len()); - for identity in identities { - if let Some(entity) = self.get_stream(identity).await? { - entities.push(entity); - } - } - Ok(entities) - } - } } impl TransactionalCommit for HashMapRepository { @@ -163,18 +146,7 @@ impl TransactionalCommit for HashMapRepository { batch: CommitBatch<'a>, ) -> impl Future> + Send + 'a { async move { - reject_duplicate_streams(&batch.streams)?; - validate_entity_id_matches_identity(&batch.streams)?; - let prepared = batch - .streams - .iter() - .map(PreparedEventAppend::from_stream_write) - .collect::>(); - validate_prepared_appends(&prepared)?; - for write in &batch.snapshots { - validate_snapshot_write(write)?; - } - reject_duplicate_outbox_messages(&batch.outbox_messages)?; + let prepared = validate_commit_batch(&batch)?; // All-or-nothing without cloning the stores: every fallible check // below runs against the live maps (reads) or a staging copy scoped @@ -232,7 +204,9 @@ impl TransactionalCommit for HashMapRepository { staged_rows.insert(key.clone(), row.clone()); } } - for plan in batch.read_model_plans { + // Clone plans: `prepared` borrows `batch` through the append loop + // below, so `batch.read_model_plans` cannot be moved out here. + for plan in batch.read_model_plans.iter().cloned() { apply_read_model_write_plan(plan, &mut staged_rows)?; } debug_assert!( @@ -273,7 +247,7 @@ impl TransactionalCommit for HashMapRepository { storage .entry(append.identity.storage_key()) .or_insert_with(Vec::new) - .extend(append.events); + .extend_from_slice(append.events); } for key in touched_rows { @@ -331,12 +305,6 @@ impl InboxStore for HashMapRepository { } } -fn validate_snapshot_write(write: &SnapshotWrite) -> Result<(), RepositoryError> { - match write { - SnapshotWrite::Save { identity, record } => validate_snapshot_identity(identity, record), - } -} - fn stored_stream_version(events: Option<&Vec>) -> u64 { // A missing stream has committed version 0; the first appended event will // occupy sequence 1. @@ -384,6 +352,23 @@ impl SnapshotStore for HashMapRepository { } } + fn get_snapshots<'a>( + &'a self, + identities: &'a [StreamIdentity], + ) -> impl Future, RepositoryError>> + Send + 'a { + async move { + let storage = self + .snapshot_store + .storage + .read() + .map_err(|_| RepositoryError::LockPoisoned("async snapshot read"))?; + Ok(identities + .iter() + .filter_map(|identity| storage.get(&identity.storage_key()).cloned()) + .collect()) + } + } + fn save_snapshot<'a>( &'a self, identity: &'a StreamIdentity, diff --git a/src/microsvc/runtime.rs b/src/microsvc/runtime.rs index 5d685962..bf23bc42 100644 --- a/src/microsvc/runtime.rs +++ b/src/microsvc/runtime.rs @@ -196,7 +196,7 @@ mod tests { .unwrap(); let published_a = store_a - .messages_by_status(OutboxMessageStatus::Published) + .messages_by_status(OutboxMessageStatus::Published, usize::MAX) .await .unwrap(); assert_eq!( @@ -205,10 +205,10 @@ mod tests { "first route bundle should publish at commit time" ); assert_eq!(published_a[0].id(), "evt-1"); - assert!(store_a.pending().await.unwrap().is_empty()); + assert!(store_a.pending(usize::MAX).await.unwrap().is_empty()); let published_b = store_b - .messages_by_status(OutboxMessageStatus::Published) + .messages_by_status(OutboxMessageStatus::Published, usize::MAX) .await .unwrap(); assert_eq!( @@ -217,7 +217,7 @@ mod tests { "second route bundle should publish at commit time" ); assert_eq!(published_b[0].id(), "evt-1"); - assert!(store_b.pending().await.unwrap().is_empty()); + assert!(store_b.pending(usize::MAX).await.unwrap().is_empty()); } #[tokio::test] @@ -275,12 +275,12 @@ mod tests { .unwrap(); let published = store - .messages_by_status(OutboxMessageStatus::Published) + .messages_by_status(OutboxMessageStatus::Published, usize::MAX) .await .unwrap(); assert_eq!(published.len(), 1, "row should be published immediately"); assert_eq!(published[0].id(), "evt-1"); - assert!(store.pending().await.unwrap().is_empty()); + assert!(store.pending(usize::MAX).await.unwrap().is_empty()); } #[tokio::test] @@ -304,7 +304,7 @@ mod tests { service.run(RunOptions::idempotent()).await.unwrap(); let published = store - .messages_by_status(OutboxMessageStatus::Published) + .messages_by_status(OutboxMessageStatus::Published, usize::MAX) .await .unwrap(); assert_eq!( @@ -362,7 +362,7 @@ mod tests { .unwrap(); let published = store - .messages_by_status(OutboxMessageStatus::Published) + .messages_by_status(OutboxMessageStatus::Published, usize::MAX) .await .unwrap(); assert_eq!( diff --git a/src/outbox/commit.rs b/src/outbox/commit.rs index 107acc06..8d7d0941 100644 --- a/src/outbox/commit.rs +++ b/src/outbox/commit.rs @@ -267,7 +267,12 @@ mod tests { assert!(receipt.has_outbox_messages()); assert_eq!(receipt.outbox_message_ids(), ["msg-1".to_string()]); - let pending = repo.repo().outbox_store().pending().await.unwrap(); + let pending = repo + .repo() + .outbox_store() + .pending(usize::MAX) + .await + .unwrap(); assert_eq!(pending.len(), 1); assert_eq!(pending[0].id(), "msg-1"); } @@ -404,7 +409,12 @@ mod tests { ); // 2) outbox row present (pending — no bus attached here) - let pending = repo.repo().outbox_store().pending().await.unwrap(); + let pending = repo + .repo() + .outbox_store() + .pending(usize::MAX) + .await + .unwrap(); assert_eq!(pending.len(), 1); assert_eq!(pending[0].id(), "evt-c1"); diff --git a/src/outbox_worker/outbox_source.rs b/src/outbox_worker/outbox_source.rs index 37284d24..869695de 100644 --- a/src/outbox_worker/outbox_source.rs +++ b/src/outbox_worker/outbox_source.rs @@ -199,7 +199,7 @@ mod tests { ] .into_iter() .find(|status| { - block_on(store.messages_by_status(status.clone())) + block_on(store.messages_by_status(status.clone(), usize::MAX)) .unwrap() .iter() .any(|m| m.id() == id) diff --git a/src/outbox_worker/store.rs b/src/outbox_worker/store.rs index 7e0eea89..c83c6bb8 100644 --- a/src/outbox_worker/store.rs +++ b/src/outbox_worker/store.rs @@ -95,15 +95,27 @@ impl OutboxClaimRef { /// Store capability for claiming and updating durable outbox messages. pub trait OutboxStore: Send + Sync { + /// List up to `limit` messages with the given status, in claim order + /// (created-at, then message id). The listing is a diagnostic/ops read, so + /// the bound is mandatory: an outbox can grow far beyond what any caller + /// should page into memory at once. fn messages_by_status( &self, status: OutboxMessageStatus, + limit: usize, ) -> impl Future, RepositoryError>> + Send + '_; + /// List up to `limit` pending messages (see [`messages_by_status`]). + /// + /// [`messages_by_status`]: OutboxStore::messages_by_status fn pending( &self, + limit: usize, ) -> impl Future, RepositoryError>> + Send + '_ { - async move { self.messages_by_status(OutboxMessageStatus::Pending).await } + async move { + self.messages_by_status(OutboxMessageStatus::Pending, limit) + .await + } } fn claim<'a>( @@ -259,6 +271,7 @@ impl OutboxStore for HashMapOutboxStore { fn messages_by_status( &self, status: OutboxMessageStatus, + limit: usize, ) -> impl Future, RepositoryError>> + Send + '_ { async move { let storage = self @@ -272,6 +285,7 @@ impl OutboxStore for HashMapOutboxStore { .cloned() .collect::>(); sort_by_claim_order(&mut messages); + messages.truncate(limit); Ok(messages) } } diff --git a/src/postgres_repo/mod.rs b/src/postgres_repo/mod.rs index 470480b0..de459e74 100644 --- a/src/postgres_repo/mod.rs +++ b/src/postgres_repo/mod.rs @@ -1,553 +1,205 @@ -//! Postgres-backed async repository and transactional relational read-model writes. +//! Postgres backend for the shared SQLx repository. //! -//! This adapter is the production-oriented SQL event-store path. It is -//! feature-gated behind `postgres` and async-only. - -#![expect( - clippy::manual_async_fn, - reason = "async trait impls return impl Future + Send to preserve public Send bounds" -)] - -use std::collections::BTreeMap; -use std::future::Future; -use std::sync::{Arc, RwLock}; +//! The event-store/snapshot/outbox/inbox logic lives once in +//! [`crate::sqlx_repo::repo`]; this module carries only what is genuinely +//! Postgres-specific: the schema SQL, the epoch-`f64`/`to_timestamp()` +//! timestamp codec, the unique-violation predicate, and the CTE + +//! `FOR UPDATE SKIP LOCKED` outbox claim. It is feature-gated behind +//! `postgres` and async-only. + +use std::sync::LazyLock; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use sqlx::postgres::{PgPoolOptions, PgRow}; -use sqlx::{PgPool, Postgres, QueryBuilder, Row, Transaction}; +use sqlx::migrate::Migrator; +use sqlx::query_builder::Separated; +use sqlx::{PgPool, Postgres, QueryBuilder, Row}; -use crate::entity::Entity; -use crate::entity::EventRecord; use crate::outbox::{OutboxMessage, OutboxMessageStatus}; -use crate::outbox_worker::{ensure_active_claim, ClaimOutboxMessages, OutboxClaimRef, OutboxStore}; -use crate::read_model::{ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities}; -use crate::repository::{ - reject_duplicate_outbox_messages, reject_duplicate_streams, - validate_entity_id_matches_identity, validate_prepared_appends, validate_snapshot_identity, - validate_supported_event_codec, CommitBatch, GetStream, InboxReceipt, InboxStore, - PreparedEventAppend, ReadModelWritePlanStore, RelationalReadModelQueryStore, RepositoryError, - SnapshotStore, SnapshotWrite, StreamIdentity, TransactionalCommit, -}; -use crate::snapshot::SnapshotRecord; -use crate::sqlx_repo::read_model::{ - apply_read_model_write_plan_in_tx, commit_read_model_write_plan, empty_string_as_none, - load_read_model_graph, quote_identifier, remember_read_model_schemas, - sql_read_model_capabilities, validate_sql_write_plan, +use crate::outbox_worker::ClaimOutboxMessages; +use crate::table::{ColumnType, RowValue, TableColumn as ColumnDef, TableStoreError as ReadModelError}; +use crate::repository::RepositoryError; +use crate::sqlx_repo::read_model::quote_identifier; +use crate::sqlx_repo::repo::{ + embedded_migrator, outbox_message_from_row, system_time_epoch_secs, SqlxOutboxStore, + SqlxRepository, }; use crate::sqlx_repo::{ - self, audited_table_schema_sql, deserialize_event_metadata, is_postgres_unique_violation, - read_model_i64_from_u64 as sqlx_read_model_i64_from_u64, + self, is_postgres_unique_violation, read_model_i64_from_u64 as sqlx_read_model_i64_from_u64, read_model_u64_from_i64 as sqlx_read_model_u64_from_i64, - repository_i32_from_u64 as sqlx_repository_i32_from_u64, repository_i64_from_u64 as sqlx_repository_i64_from_u64, - repository_u16_from_i32 as sqlx_repository_u16_from_i32, - repository_u64_from_i32 as sqlx_repository_u64_from_i32, - repository_u64_from_i64 as sqlx_repository_u64_from_i64, serialize_event_metadata, -}; -use crate::table::{ - generate_table_migration_artifacts, table_schema_bootstrap_result, table_schema_statements, - TableMigrationArtifact, TableSchemaBootstrap, TableSchemaRegistry, TableSqlDialect, - TableSqlSchemaAdapter, TableStoreError, -}; -use crate::table::{ - ColumnType, RowValue, TableAdapterCapabilities, TableColumn, TableCommitOutcome, TableWritePlan, }; - -const POSTGRES_SCHEMA: &str = include_str!("../../migrations/postgres/0001_initial.sql"); +use crate::table::TableSqlDialect; + +static POSTGRES_MIGRATOR: LazyLock = LazyLock::new(|| { + embedded_migrator(&[( + 1, + "initial", + include_str!("../../migrations/postgres/0001_initial.sql"), + )]) +}); const POSTGRES_BACKEND: &str = "postgres"; const BIGINT_STORAGE: &str = "bigint storage"; -const INTEGER_STORAGE: &str = "integer storage"; /// Postgres-backed async repository. -#[derive(Clone)] -pub struct PostgresRepository { - pool: PgPool, - read_model_schemas: Arc>, -} +pub type PostgresRepository = SqlxRepository; /// Postgres-backed outbox table store. -#[derive(Clone)] -pub struct PostgresOutboxStore { - pool: PgPool, -} - -impl PostgresRepository { - /// Create a repository from an existing migrated pool. - pub fn new(pool: PgPool) -> Self { - Self { - pool, - read_model_schemas: Arc::new(RwLock::new(TableSchemaRegistry::new())), - } - } - - /// Open a Postgres pool without applying migrations. - pub async fn connect(database_url: &str) -> Result { - let pool = PgPoolOptions::new() - .max_connections(5) - .connect(database_url) - .await - .map_err(|err| repository_storage_error("connect", err))?; - Ok(Self::new(pool)) - } - - /// Open a Postgres pool and apply the explicit Postgres migrations. - pub async fn connect_and_migrate(database_url: &str) -> Result { - let repo = Self::connect(database_url).await?; - repo.migrate().await?; - Ok(repo) - } - - /// Apply Postgres migrations to this repository's pool. - pub async fn migrate(&self) -> Result<(), RepositoryError> { - Self::migrate_pool(&self.pool).await - } - - /// Apply Postgres migrations to an existing pool. - pub async fn migrate_pool(pool: &PgPool) -> Result<(), RepositoryError> { - for statement in POSTGRES_SCHEMA.split(';') { - let statement = statement.trim(); - if statement.is_empty() { - continue; - } - sqlx::query(statement) - .execute(pool) - .await - .map_err(|err| repository_storage_error("migrate", err))?; - } - Ok(()) - } - - /// Access the underlying SQLx pool for application-specific setup or tests. - pub fn pool(&self) -> &PgPool { - &self.pool - } - - /// SQL artifact adapter for registered table/read-model schemas. - pub fn table_schema_adapter(&self) -> TableSqlSchemaAdapter { - TableSqlSchemaAdapter::postgres() - } - - /// Generate SQL statements for registered table/read-model schemas. - pub fn generate_table_migration_artifacts( - &self, - registry: &TableSchemaRegistry, - ) -> Result, TableStoreError> { - generate_table_migration_artifacts(registry, TableSqlDialect::Postgres) - } - - /// Explicit dev/test bootstrap for registered table/read-model schemas. - pub async fn bootstrap_table_schema_for_dev( - &self, - registry: &TableSchemaRegistry, - ) -> Result { - for statement in table_schema_statements(registry, TableSqlDialect::Postgres)? { - sqlx::query(audited_table_schema_sql(statement)) - .execute(&self.pool) - .await - .map_err(|err| table_schema_storage_error("bootstrap table schema", err))?; - } - remember_read_model_schemas(&self.read_model_schemas, registry)?; - Ok(table_schema_bootstrap_result(registry)) - } - - /// Access an outbox-store handle backed by this repository's pool. - pub fn outbox_store(&self) -> PostgresOutboxStore { - PostgresOutboxStore { - pool: self.pool.clone(), - } - } -} - -impl PostgresOutboxStore { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub fn pool(&self) -> &PgPool { - &self.pool - } - - /// SQL artifact adapter for registered table/read-model schemas. - pub fn table_schema_adapter(&self) -> TableSqlSchemaAdapter { - TableSqlSchemaAdapter::postgres() - } - - /// Generate SQL statements for registered table/read-model schemas. - pub fn generate_table_migration_artifacts( - &self, - registry: &TableSchemaRegistry, - ) -> Result, TableStoreError> { - generate_table_migration_artifacts(registry, TableSqlDialect::Postgres) - } - - /// Explicit dev/test bootstrap for registered table/read-model schemas. - pub async fn bootstrap_table_schema_for_dev( - &self, - registry: &TableSchemaRegistry, - ) -> Result { - for statement in table_schema_statements(registry, TableSqlDialect::Postgres)? { - sqlx::query(audited_table_schema_sql(statement)) - .execute(&self.pool) - .await - .map_err(|err| table_schema_storage_error("bootstrap table schema", err))?; - } - Ok(table_schema_bootstrap_result(registry)) - } -} - -impl GetStream for PostgresRepository { - fn get_stream<'a>( - &'a self, - identity: &'a StreamIdentity, - ) -> impl Future, RepositoryError>> + Send + 'a { - async move { - let rows = sqlx::query( - r#" - SELECT event_name, - event_version, - payload, - payload_codec, - payload_codec_version, - metadata::text AS metadata, - sequence, - EXTRACT(EPOCH FROM recorded_at)::double precision AS recorded_at_epoch - FROM aggregate_events - WHERE aggregate_type = $1 AND aggregate_id = $2 - ORDER BY sequence ASC - "#, - ) - .bind(identity.aggregate_type()) - .bind(identity.aggregate_id()) - .fetch_all(&self.pool) - .await - .map_err(|err| repository_storage_error("load stream", err))?; - - if rows.is_empty() { - return Ok(None); - } - - let mut events = Vec::with_capacity(rows.len()); - for row in rows { - events.push(event_from_row(row)?); - } - - let mut entity = Entity::new(); - entity.set_id(identity.aggregate_id()); - entity.load_from_history(events); - Ok(Some(entity)) - } - } - - fn get_streams<'a>( - &'a self, - identities: &'a [StreamIdentity], - ) -> impl Future, RepositoryError>> + Send + 'a { - async move { - if identities.is_empty() { - return Ok(Vec::new()); - } - - // Group ids by aggregate type so each type is one `aggregate_id = - // ANY($2)` round trip instead of a query per identity. `get_all` - // builds single-type batches, so the common case is one query; the - // grouping only exists to keep arbitrary mixed-type inputs correct. - let mut ids_by_type: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); - for identity in identities { - ids_by_type - .entry(identity.aggregate_type()) - .or_default() - .push(identity.aggregate_id()); - } - - let mut entities = Vec::with_capacity(identities.len()); - for (aggregate_type, aggregate_ids) in ids_by_type { - // Ordering by aggregate_id then sequence lets us slice the flat - // result into per-aggregate entities in one pass. Callers of - // `get_all` accept storage-order results. - let rows = sqlx::query( - r#" - SELECT aggregate_id, - event_name, - event_version, - payload, - payload_codec, - payload_codec_version, - metadata::text AS metadata, - sequence, - EXTRACT(EPOCH FROM recorded_at)::double precision AS recorded_at_epoch - FROM aggregate_events - WHERE aggregate_type = $1 AND aggregate_id = ANY($2) - ORDER BY aggregate_id ASC, sequence ASC - "#, - ) - .bind(aggregate_type) - .bind(&aggregate_ids) - .fetch_all(&self.pool) - .await - .map_err(|err| repository_storage_error("load streams", err))?; - - let mut current_id: Option = None; - let mut current_events: Vec = Vec::new(); - for row in rows { - let row_id: String = row - .try_get("aggregate_id") - .map_err(|err| repository_storage_error("decode aggregate id row", err))?; - let event = event_from_row(row)?; - match ¤t_id { - Some(id) if id == &row_id => current_events.push(event), - _ => { - if let Some(id) = current_id.take() { - entities.push(entity_from_events( - id, - std::mem::take(&mut current_events), - )); - } - current_id = Some(row_id); - current_events.push(event); - } - } - } - if let Some(id) = current_id.take() { - entities.push(entity_from_events(id, current_events)); - } - } - - Ok(entities) - } - } - - fn get_stream_tail<'a>( - &'a self, - identity: &'a StreamIdentity, - after_version: u64, - ) -> impl Future, RepositoryError>> + Send + 'a { - async move { - // Fetch only the post-snapshot tail. `after_version` is the snapshot - // version (an event sequence); `sequence > $3` skips already-folded - // rows so a fresh snapshot over a long stream no longer reads and - // decodes the entire history. - let after = sqlx_repository_i64_from_u64( - POSTGRES_BACKEND, - after_version, - "snapshot tail lower bound", - BIGINT_STORAGE, - )?; - let rows = sqlx::query( - r#" - SELECT event_name, - event_version, - payload, - payload_codec, - payload_codec_version, - metadata::text AS metadata, - sequence, - EXTRACT(EPOCH FROM recorded_at)::double precision AS recorded_at_epoch - FROM aggregate_events - WHERE aggregate_type = $1 AND aggregate_id = $2 AND sequence > $3 - ORDER BY sequence ASC - "#, - ) - .bind(identity.aggregate_type()) - .bind(identity.aggregate_id()) - .bind(after) - .fetch_all(&self.pool) - .await - .map_err(|err| repository_storage_error("load stream tail", err))?; - - // An empty tail is ambiguous from this query alone (no rows could - // mean "snapshot is current" or "stream does not exist"). The - // snapshot hydrate path only calls this after confirming a snapshot - // exists for the identity, so an empty tail means the snapshot is - // current. Return an entity at exactly `after_version`. - let mut events = Vec::with_capacity(rows.len()); - for row in rows { - events.push(event_from_row(row)?); - } - - let mut entity = Entity::new(); - entity.set_id(identity.aggregate_id()); - entity.load_tail_from_history(events, after_version); - Ok(Some(entity)) - } - } -} - -impl TransactionalCommit for PostgresRepository { - fn commit_batch<'a>( - &'a self, - batch: CommitBatch<'a>, - ) -> impl Future> + Send + 'a { - async move { - reject_duplicate_streams(&batch.streams)?; - reject_duplicate_outbox_messages(&batch.outbox_messages)?; - validate_entity_id_matches_identity(&batch.streams)?; - - let prepared = batch - .streams - .iter() - .map(PreparedEventAppend::from_stream_write) - .collect::>(); - validate_prepared_appends(&prepared)?; - - for plan in &batch.read_model_plans { - validate_sql_write_plan(plan)?; - } - - let mut tx = self - .pool - .begin() - .await - .map_err(|err| repository_storage_error("begin commit transaction", err))?; - - for append in &prepared { - let actual = stream_version_in_tx(&mut tx, &append.identity).await?; - if actual != append.expected_version { - return Err(RepositoryError::ConcurrentWrite { - id: append.identity.to_string(), - expected: append.expected_version, - actual, - }); - } - } - - insert_events_in_tx(&self.pool, &mut tx, &prepared).await?; - - insert_outbox_messages_in_tx(&mut tx, &batch.outbox_messages).await?; - - for plan in batch.read_model_plans { - apply_read_model_write_plan_in_tx(&mut tx, plan).await?; - } - - for write in batch.snapshots { - match write { - SnapshotWrite::Save { identity, record } => { - save_snapshot_in_tx(&mut tx, &identity, record).await?; - } - } - } - - for receipt in &batch.inbox_receipts { - insert_inbox_receipt_in_tx(&mut tx, receipt).await?; - } - - tx.commit() - .await - .map_err(|err| repository_storage_error("commit transaction", err))?; - - for stream in batch.streams { - stream.entity.mark_committed(); - } - - Ok(()) - } - } -} - -impl InboxStore for PostgresRepository { - fn inbox_contains<'a>( - &'a self, - consumer: &'a str, - message_id: &'a str, - ) -> impl Future> + Send + 'a { - async move { - let row = sqlx::query( - "SELECT 1 FROM consumer_inbox WHERE consumer = $1 AND message_id = $2 LIMIT 1", - ) - .bind(consumer) - .bind(message_id) - .fetch_optional(&self.pool) - .await - .map_err(|err| repository_storage_error("query consumer inbox", err))?; - Ok(row.is_some()) - } +pub type PostgresOutboxStore = SqlxOutboxStore; + +impl crate::sqlx_repo::repo::SqlxRepoBackend for Postgres { + fn migrator() -> &'static Migrator { + &POSTGRES_MIGRATOR + } + // The Postgres extended-query protocol caps bind parameters at 65535 per + // statement. The previous unchunked insert would fail outright on a + // commit batch above ~6500 events; the shared chunking makes such batches + // just work. + const MAX_BIND_PARAMS: usize = 65000; + // A failed statement aborts the Postgres transaction, so conflict recovery + // must re-read stream versions over the pool (a separate connection). + const CONFLICT_REREAD_IN_TX: bool = false; + const NOW: &'static str = "now()"; + const EVENT_SELECT: &'static str = "event_name, \ + event_version, \ + payload, \ + payload_codec, \ + payload_codec_version, \ + metadata::text AS metadata, \ + sequence, \ + EXTRACT(EPOCH FROM recorded_at)::double precision AS recorded_at"; + const SNAPSHOT_SELECT: &'static str = "aggregate_type, \ + aggregate_id, \ + version, \ + snapshot_version, \ + payload, \ + payload_codec, \ + payload_codec_version, \ + metadata::text AS metadata, \ + EXTRACT(EPOCH FROM recorded_at)::double precision AS recorded_at"; + const OUTBOX_SELECT: &'static str = "message_id, \ + event_type, \ + payload, \ + payload_codec, \ + payload_codec_version, \ + metadata::text AS metadata, \ + status, \ + EXTRACT(EPOCH FROM created_at)::double precision AS created_at, \ + claimed_by, \ + EXTRACT(EPOCH FROM claimed_until)::double precision AS claimed_until, \ + attempts, \ + last_error, \ + destination, \ + source_aggregate_type, \ + source_aggregate_id, \ + source_sequence, \ + correlation_id, \ + causation_id"; + const ORDER_BY_CREATED_AT: &'static str = "created_at"; + const TABLE_DIALECT: TableSqlDialect = TableSqlDialect::Postgres; + + /// Epoch seconds; stored via `to_timestamp(...)` into `timestamptz`. + type TimestampValue = f64; + + fn is_unique_violation(err: &sqlx::Error) -> bool { + is_postgres_unique_violation(err) + } + + fn timestamp_value(timestamp: SystemTime) -> Result { + system_time_epoch_secs::(timestamp) + } + + fn push_timestamp(sep: &mut Separated<'_, Postgres, &'static str>, value: &f64) { + sep.push("to_timestamp(") + .push_bind_unseparated(*value) + .push_unseparated(")"); } - fn purge_inbox_older_than( - &self, - age: std::time::Duration, - ) -> impl Future> + Send { - async move { - // Compare against the database clock (`now()`) to avoid client/server - // skew; `make_interval` takes the cutoff age in whole seconds. - let secs = age.as_secs() as f64; - let result = sqlx::query( - "DELETE FROM consumer_inbox \ - WHERE processed_at < now() - make_interval(secs => $1)", - ) - .bind(secs) - .execute(&self.pool) - .await - .map_err(|err| repository_storage_error("purge consumer inbox", err))?; - Ok(result.rows_affected()) - } + fn push_optional_timestamp( + sep: &mut Separated<'_, Postgres, &'static str>, + value: Option<&f64>, + ) { + // NULL stays NULL through to_timestamp; the cast keeps `$n` typed. + sep.push("to_timestamp(") + .push_bind_unseparated(value.copied()) + .push_unseparated("::double precision)"); } -} -impl ReadModelWritePlanStore for PostgresRepository { - fn read_model_capabilities(&self) -> TableAdapterCapabilities { - sql_read_model_capabilities() + fn push_timestamp_assign(builder: &mut QueryBuilder, value: &f64) { + builder.push("to_timestamp("); + builder.push_bind(*value); + builder.push(")"); } - fn commit_write_plan( - &self, - plan: TableWritePlan, - ) -> impl Future> + Send + '_ { - async move { commit_read_model_write_plan(&self.pool, plan).await } + fn push_timestamp_cmp( + builder: &mut QueryBuilder, + column: &'static str, + op: &'static str, + epoch_secs: f64, + ) { + builder.push(column); + builder.push(" "); + builder.push(op); + builder.push(" to_timestamp("); + builder.push_bind(epoch_secs); + builder.push(")"); + } + + fn decode_timestamp( + row: &sqlx::postgres::PgRow, + column: &'static str, + ) -> Result { + system_time_from_epoch_secs(row.try_get(column).map_err(|err| { + repository_storage_error(&format!("decode {column} timestamp row"), err) + })?) + } + + fn decode_optional_timestamp( + row: &sqlx::postgres::PgRow, + column: &'static str, + ) -> Result, RepositoryError> { + row.try_get::, _>(column) + .map_err(|err| { + repository_storage_error(&format!("decode {column} timestamp row"), err) + })? + .map(system_time_from_epoch_secs) + .transpose() } -} -impl RelationalReadModelQueryStore for PostgresRepository { - fn read_model_query_capabilities(&self) -> ReadModelQueryCapabilities { - ReadModelQueryCapabilities::relationship_includes() + fn push_metadata(sep: &mut Separated<'_, Postgres, &'static str>, json: &str) { + sep.push_bind(json).push_unseparated("::jsonb"); } - fn load_graph( - &self, - request: ReadModelLoadRequest, - ) -> impl Future> + Send + '_ { - async move { - load_read_model_graph( - &self.pool, - &self.read_model_schemas, - request, - self.read_model_query_capabilities(), - ) - .await - } + fn push_id_filter(builder: &mut QueryBuilder, ids: &[&str]) { + builder.push("aggregate_id = ANY("); + builder.push_bind(ids.to_vec()); + builder.push(")"); } -} -impl OutboxStore for PostgresOutboxStore { - fn messages_by_status( - &self, - status: OutboxMessageStatus, - ) -> impl Future, RepositoryError>> + Send + '_ { - async move { - let rows = sqlx::query(outbox_message_select_by_status_sql()) - .bind(status.as_str()) - .fetch_all(&self.pool) - .await - .map_err(|err| repository_storage_error("load outbox messages by status", err))?; - - rows.into_iter().map(outbox_message_from_row).collect() - } + fn inbox_purge_query(age: Duration) -> QueryBuilder { + // `make_interval` takes the cutoff age in whole seconds. + let mut builder = QueryBuilder::new( + "DELETE FROM consumer_inbox WHERE processed_at < now() - make_interval(secs => ", + ); + builder.push_bind(age.as_secs() as f64); + builder.push(")"); + builder } - fn claim<'a>( - &'a self, + async fn claim_outbox( + pool: &PgPool, request: ClaimOutboxMessages, - ) -> impl Future, RepositoryError>> + Send + 'a { - async move { + ) -> Result, RepositoryError> { + { if request.batch_size == 0 { return Ok(Vec::new()); } let now = SystemTime::now(); - let now_epoch = system_time_to_epoch_secs(now)?; + let now_epoch = system_time_epoch_secs::(now)?; let claimed_until = now.checked_add(request.lease).ok_or_else(|| { RepositoryError::Model("failed to compute outbox lease deadline".into()) })?; - let claimed_until_epoch = system_time_to_epoch_secs(claimed_until)?; + let claimed_until_epoch = system_time_epoch_secs::(claimed_until)?; let limit = sqlx_repository_i64_from_u64( POSTGRES_BACKEND, request.batch_size as u64, @@ -555,10 +207,10 @@ impl OutboxStore for PostgresOutboxStore { BIGINT_STORAGE, )?; - let mut tx = - self.pool.begin().await.map_err(|err| { - repository_storage_error("begin outbox claim transaction", err) - })?; + let mut tx = pool + .begin() + .await + .map_err(|err| repository_storage_error("begin outbox claim transaction", err))?; let rows = sqlx::query( r#" @@ -590,9 +242,9 @@ impl OutboxStore for PostgresOutboxStore { message.payload_codec_version, message.metadata::text AS metadata, message.status, - EXTRACT(EPOCH FROM message.created_at)::double precision AS created_at_epoch, + EXTRACT(EPOCH FROM message.created_at)::double precision AS created_at, message.claimed_by, - EXTRACT(EPOCH FROM message.claimed_until)::double precision AS claimed_until_epoch, + EXTRACT(EPOCH FROM message.claimed_until)::double precision AS claimed_until, message.attempts, message.last_error, message.destination, @@ -620,343 +272,10 @@ impl OutboxStore for PostgresOutboxStore { .await .map_err(|err| repository_storage_error("commit outbox claim transaction", err))?; - rows.into_iter().map(outbox_message_from_row).collect() - } - } - - fn complete<'a>( - &'a self, - claim: &'a OutboxClaimRef, - ) -> impl Future> + Send + 'a { - async move { - let now = SystemTime::now(); - let now_epoch = system_time_to_epoch_secs(now)?; - let result = sqlx::query( - r#" - UPDATE outbox_messages - SET status = $1, - claimed_by = NULL, - claimed_until = NULL, - published_at = to_timestamp($2), - updated_at = now() - WHERE message_id = $3 - AND status = $4 - AND claimed_by = $5 - AND claimed_until IS NOT NULL - AND claimed_until > to_timestamp($6) - AND attempts = $7 - "#, - ) - .bind(OutboxMessageStatus::Published.as_str()) - .bind(now_epoch) - .bind(&claim.message_id) - .bind(OutboxMessageStatus::InFlight.as_str()) - .bind(&claim.worker_id) - .bind(now_epoch) - .bind(sqlx_repository_i32_from_u64( - POSTGRES_BACKEND, - u64::from(claim.attempt), - "outbox claim attempt", - INTEGER_STORAGE, - )?) - .execute(&self.pool) - .await - .map_err(|err| repository_storage_error("complete outbox message", err))?; - - ensure_outbox_update_applied( - &self.pool, - result.rows_affected(), - &claim.message_id, - |message| ensure_active_claim(message, Some(claim), now), - ) - .await - } - } - - /// Batched complete: settles every claim in one `UPDATE ... FROM unnest` - /// statement instead of one round trip per row. Per-claim validation - /// (worker, unexpired lease, attempt) matches [`complete`]; a claim the - /// statement did not apply surfaces the same `NotFound`/`InvalidState` - /// error, while the claims that did match stay completed. - /// - /// [`complete`]: OutboxStore::complete - fn complete_many<'a>( - &'a self, - claims: &'a [OutboxClaimRef], - ) -> impl Future> + Send + 'a { - async move { - if claims.is_empty() { - return Ok(()); - } - let now = SystemTime::now(); - let now_epoch = system_time_to_epoch_secs(now)?; - let mut message_ids = Vec::with_capacity(claims.len()); - let mut worker_ids = Vec::with_capacity(claims.len()); - let mut attempts = Vec::with_capacity(claims.len()); - for claim in claims { - message_ids.push(claim.message_id.clone()); - worker_ids.push(claim.worker_id.clone()); - attempts.push(sqlx_repository_i32_from_u64( - POSTGRES_BACKEND, - u64::from(claim.attempt), - "outbox claim attempt", - INTEGER_STORAGE, - )?); - } - - let completed: Vec = sqlx::query_scalar( - r#" - UPDATE outbox_messages AS message - SET status = $1, - claimed_by = NULL, - claimed_until = NULL, - published_at = to_timestamp($2), - updated_at = now() - FROM unnest($3::text[], $4::text[], $5::integer[]) - AS claim(message_id, worker_id, attempts) - WHERE message.message_id = claim.message_id - AND message.status = $6 - AND message.claimed_by = claim.worker_id - AND message.claimed_until IS NOT NULL - AND message.claimed_until > to_timestamp($2) - AND message.attempts = claim.attempts - RETURNING message.message_id - "#, - ) - .bind(OutboxMessageStatus::Published.as_str()) - .bind(now_epoch) - .bind(&message_ids) - .bind(&worker_ids) - .bind(&attempts) - .bind(OutboxMessageStatus::InFlight.as_str()) - .fetch_all(&self.pool) - .await - .map_err(|err| repository_storage_error("complete outbox messages", err))?; - - if completed.len() == claims.len() { - return Ok(()); - } - // Surface the same NotFound/InvalidState error `complete` would - // return for each claim the statement did not apply. - for claim in claims { - if !completed.iter().any(|id| id == &claim.message_id) { - ensure_outbox_update_applied(&self.pool, 0, &claim.message_id, |message| { - ensure_active_claim(message, Some(claim), now) - }) - .await?; - } - } - Ok(()) - } - } - - fn release<'a>( - &'a self, - claim: &'a OutboxClaimRef, - error: &'a str, - ) -> impl Future> + Send + 'a { - async move { - let now = SystemTime::now(); - let now_epoch = system_time_to_epoch_secs(now)?; - let result = sqlx::query( - r#" - UPDATE outbox_messages - SET status = $1, - claimed_by = NULL, - claimed_until = NULL, - next_available_at = to_timestamp($2), - last_error = $3, - updated_at = now() - WHERE message_id = $4 - AND status = $5 - AND claimed_by = $6 - AND claimed_until IS NOT NULL - AND claimed_until > to_timestamp($7) - AND attempts = $8 - "#, - ) - .bind(OutboxMessageStatus::Pending.as_str()) - .bind(now_epoch) - .bind(empty_string_as_none(error)) - .bind(&claim.message_id) - .bind(OutboxMessageStatus::InFlight.as_str()) - .bind(&claim.worker_id) - .bind(now_epoch) - .bind(sqlx_repository_i32_from_u64( - POSTGRES_BACKEND, - u64::from(claim.attempt), - "outbox claim attempt", - INTEGER_STORAGE, - )?) - .execute(&self.pool) - .await - .map_err(|err| repository_storage_error("release outbox message", err))?; - - ensure_outbox_update_applied( - &self.pool, - result.rows_affected(), - &claim.message_id, - |message| ensure_active_claim(message, Some(claim), now), - ) - .await - } - } - - fn fail<'a>( - &'a self, - claim: &'a OutboxClaimRef, - error: &'a str, - ) -> impl Future> + Send + 'a { - async move { - let now = SystemTime::now(); - let now_epoch = system_time_to_epoch_secs(now)?; - let result = sqlx::query( - r#" - UPDATE outbox_messages - SET status = $1, - claimed_by = NULL, - claimed_until = NULL, - last_error = $2, - failed_at = to_timestamp($3), - updated_at = now() - WHERE message_id = $4 - AND status = $5 - AND claimed_by = $6 - AND claimed_until IS NOT NULL - AND claimed_until > to_timestamp($7) - AND attempts = $8 - "#, - ) - .bind(OutboxMessageStatus::Failed.as_str()) - .bind(empty_string_as_none(error)) - .bind(now_epoch) - .bind(&claim.message_id) - .bind(OutboxMessageStatus::InFlight.as_str()) - .bind(&claim.worker_id) - .bind(now_epoch) - .bind(sqlx_repository_i32_from_u64( - POSTGRES_BACKEND, - u64::from(claim.attempt), - "outbox claim attempt", - INTEGER_STORAGE, - )?) - .execute(&self.pool) - .await - .map_err(|err| repository_storage_error("fail outbox message", err))?; - - ensure_outbox_update_applied( - &self.pool, - result.rows_affected(), - &claim.message_id, - |message| ensure_active_claim(message, Some(claim), now), - ) - .await - } - } -} - -impl SnapshotStore for PostgresRepository { - fn get_snapshot<'a>( - &'a self, - identity: &'a StreamIdentity, - ) -> impl Future, RepositoryError>> + Send + 'a { - async move { - let row = sqlx::query( - r#" - SELECT aggregate_type, - aggregate_id, - version, - snapshot_version, - payload, - payload_codec, - payload_codec_version, - metadata::text AS metadata, - EXTRACT(EPOCH FROM recorded_at)::double precision AS recorded_at_epoch - FROM aggregate_snapshots - WHERE aggregate_type = $1 AND aggregate_id = $2 - "#, - ) - .bind(identity.aggregate_type()) - .bind(identity.aggregate_id()) - .fetch_optional(&self.pool) - .await - .map_err(|err| repository_storage_error("load snapshot", err))?; - - let Some(row) = row else { - return Ok(None); - }; - - Ok(Some(snapshot_from_row(row)?)) - } - } - - fn save_snapshot<'a>( - &'a self, - identity: &'a StreamIdentity, - record: SnapshotRecord, - ) -> impl Future> + Send + 'a { - async move { - let mut tx = self - .pool - .begin() - .await - .map_err(|err| repository_storage_error("begin snapshot transaction", err))?; - save_snapshot_in_tx(&mut tx, identity, record).await?; - tx.commit() - .await - .map_err(|err| repository_storage_error("commit snapshot transaction", err))?; - Ok(()) - } - } - - fn delete_snapshot<'a>( - &'a self, - identity: &'a StreamIdentity, - ) -> impl Future> + Send + 'a { - async move { - let result = sqlx::query( - r#" - DELETE FROM aggregate_snapshots - WHERE aggregate_type = $1 AND aggregate_id = $2 - "#, - ) - .bind(identity.aggregate_type()) - .bind(identity.aggregate_id()) - .execute(&self.pool) - .await - .map_err(|err| repository_storage_error("delete snapshot", err))?; - - Ok(result.rows_affected() > 0) - } - } -} - -/// Record a consumer inbox receipt in the commit transaction. The -/// `(consumer, message_id)` primary key is the dedupe gate: a unique violation -/// means the message was already processed, so the whole batch rolls back and the -/// effects are not double-applied. `processed_at` defaults server-side. -async fn insert_inbox_receipt_in_tx( - tx: &mut Transaction<'_, Postgres>, - receipt: &InboxReceipt, -) -> Result<(), RepositoryError> { - receipt.validate()?; - let result = sqlx::query("INSERT INTO consumer_inbox (consumer, message_id) VALUES ($1, $2)") - .bind(&receipt.consumer) - .bind(&receipt.message_id) - .execute(&mut **tx) - .await; - match result { - Ok(_) => Ok(()), - Err(err) if is_postgres_unique_violation(&err) => { - Err(RepositoryError::DuplicateInboxReceipt { - consumer: receipt.consumer.clone(), - message_id: receipt.message_id.clone(), - }) + rows.into_iter() + .map(outbox_message_from_row::) + .collect() } - Err(err) => Err(repository_storage_error( - "insert consumer inbox receipt", - err, - )), } } @@ -967,8 +286,8 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Postgres { fn push_row_value_bind( builder: &mut QueryBuilder, value: RowValue, - column: &TableColumn, - ) -> Result<(), TableStoreError> { + column: &ColumnDef, + ) -> Result<(), ReadModelError> { match value { RowValue::Null => Self::push_null_bind(builder, column)?, RowValue::Bool(value) => { @@ -996,7 +315,7 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Postgres { } RowValue::Json(value) => { let payload = serde_json::to_string(&value) - .map_err(|err| TableStoreError::Serde(err.to_string()))?; + .map_err(|err| ReadModelError::Serde(err.to_string()))?; builder.push_bind(payload); } } @@ -1006,8 +325,8 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Postgres { fn push_null_bind( builder: &mut QueryBuilder, - column: &TableColumn, - ) -> Result<(), TableStoreError> { + column: &ColumnDef, + ) -> Result<(), ReadModelError> { match &column.column_type { ColumnType::Text | ColumnType::Json | ColumnType::Timestamp => { builder.push_bind(Option::::None); @@ -1025,7 +344,7 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Postgres { builder.push_bind(Option::>::None); } ColumnType::Unsupported(type_name) => { - return Err(TableStoreError::Metadata(format!( + return Err(ReadModelError::Metadata(format!( "read model `{}` column `{}` has unsupported type `{}`", column.field_name, column.column_name, type_name ))); @@ -1038,7 +357,7 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Postgres { result.rows_affected() } - fn push_select_column(builder: &mut QueryBuilder, column: &TableColumn) { + fn push_select_column(builder: &mut QueryBuilder, column: &ColumnDef) { builder.push(quote_identifier(&column.column_name)); if matches!(column.column_type, ColumnType::Json | ColumnType::Timestamp) { builder.push("::text"); @@ -1047,7 +366,10 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Postgres { builder.push(quote_identifier(&column.column_name)); } - fn row_value(row: &PgRow, column: &TableColumn) -> Result { + fn row_value( + row: &sqlx::postgres::PgRow, + column: &ColumnDef, + ) -> Result { Ok(match column.column_type { ColumnType::Text | ColumnType::Timestamp => row .try_get::, _>(column.column_name.as_str()) @@ -1095,12 +417,12 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Postgres { .map(|payload| { serde_json::from_str(&payload) .map(RowValue::Json) - .map_err(|err| TableStoreError::Serde(err.to_string())) + .map_err(|err| ReadModelError::Serde(err.to_string())) }) .transpose()? .unwrap_or(RowValue::Null), ColumnType::Unsupported(ref type_name) => { - return Err(TableStoreError::Metadata(format!( + return Err(ReadModelError::Metadata(format!( "read model `{}` column `{}` has unsupported type `{}`", column.field_name, column.column_name, type_name ))); @@ -1109,7 +431,7 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Postgres { } } -fn push_postgres_type_cast(builder: &mut QueryBuilder, column: &TableColumn) { +fn push_postgres_type_cast(builder: &mut QueryBuilder, column: &ColumnDef) { match column.column_type { ColumnType::Json => { builder.push("::jsonb"); @@ -1121,644 +443,6 @@ fn push_postgres_type_cast(builder: &mut QueryBuilder, column: &TableC } } -async fn stream_version_in_tx( - tx: &mut Transaction<'_, Postgres>, - identity: &StreamIdentity, -) -> Result { - let row = sqlx::query( - r#" - SELECT MAX(sequence) AS version - FROM aggregate_events - WHERE aggregate_type = $1 AND aggregate_id = $2 - "#, - ) - .bind(identity.aggregate_type()) - .bind(identity.aggregate_id()) - .fetch_one(&mut **tx) - .await - .map_err(|err| repository_storage_error("load stream version", err))?; - - let version: Option = row - .try_get("version") - .map_err(|err| repository_storage_error("decode stream version row", err))?; - version - .map(|value| sqlx_repository_u64_from_i64(POSTGRES_BACKEND, value, "sequence")) - .unwrap_or(Ok(0)) -} - -async fn stream_version_pool( - pool: &PgPool, - identity: &StreamIdentity, -) -> Result { - let row = sqlx::query( - r#" - SELECT MAX(sequence) AS version - FROM aggregate_events - WHERE aggregate_type = $1 AND aggregate_id = $2 - "#, - ) - .bind(identity.aggregate_type()) - .bind(identity.aggregate_id()) - .fetch_one(pool) - .await - .map_err(|err| repository_storage_error("load stream version", err))?; - - let version: Option = row - .try_get("version") - .map_err(|err| repository_storage_error("decode stream version row", err))?; - version - .map(|value| sqlx_repository_u64_from_i64(POSTGRES_BACKEND, value, "sequence")) - .unwrap_or(Ok(0)) -} - -/// Insert every event across all prepared appends in a single multi-row INSERT. -/// -/// Conflict detection is unchanged from the per-row path: the `(aggregate_type, -/// aggregate_id, sequence)` primary key is the contiguity gate, and a unique -/// violation still surfaces as `ConcurrentWrite`. Because a failed statement -/// aborts the transaction, the conflicting stream's actual version is re-read -/// over the pool (a separate connection), exactly as the per-row path did. -async fn insert_events_in_tx( - pool: &PgPool, - tx: &mut Transaction<'_, Postgres>, - prepared: &[PreparedEventAppend], -) -> Result<(), RepositoryError> { - if prepared.iter().all(|append| append.events.is_empty()) { - return Ok(()); - } - - // Each row carries pre-validated bind values; build them before the query so - // any conversion error surfaces before we touch the database. - struct EventRow<'a> { - aggregate_type: &'a str, - aggregate_id: &'a str, - sequence: i64, - event_name: &'a str, - event_version: i32, - payload: &'a [u8], - payload_codec: &'a str, - payload_codec_version: i32, - metadata: String, - recorded_at: f64, - } - - let mut rows = Vec::new(); - for append in prepared { - for event in &append.events { - rows.push(EventRow { - aggregate_type: append.identity.aggregate_type(), - aggregate_id: append.identity.aggregate_id(), - sequence: sqlx_repository_i64_from_u64( - POSTGRES_BACKEND, - event.sequence, - "sequence", - BIGINT_STORAGE, - )?, - event_name: &event.event_name, - event_version: sqlx_repository_i32_from_u64( - POSTGRES_BACKEND, - event.event_version, - "event_version", - INTEGER_STORAGE, - )?, - payload: &event.payload, - payload_codec: &event.payload_codec, - payload_codec_version: i32::from(event.payload_codec_version), - metadata: serialize_event_metadata(&event.metadata)?, - recorded_at: system_time_to_epoch_secs(event.timestamp)?, - }); - } - } - - let mut builder = QueryBuilder::::new( - "INSERT INTO aggregate_events (\ - aggregate_type, aggregate_id, sequence, event_name, event_version, \ - payload, payload_codec, payload_codec_version, metadata, recorded_at) ", - ); - builder.push_values(rows, |mut row, event| { - row.push_bind(event.aggregate_type) - .push_bind(event.aggregate_id) - .push_bind(event.sequence) - .push_bind(event.event_name) - .push_bind(event.event_version) - .push_bind(event.payload) - .push_bind(event.payload_codec) - .push_bind(event.payload_codec_version) - .push_bind(event.metadata) - .push_unseparated("::jsonb"); - // recorded_at is stored from epoch seconds via to_timestamp(...). - row.push("to_timestamp(") - .push_bind_unseparated(event.recorded_at) - .push_unseparated(")"); - }); - - let result = builder.build().execute(&mut **tx).await; - - match result { - Ok(_) => Ok(()), - Err(err) if is_postgres_unique_violation(&err) => { - Err(concurrent_write_from_conflict(pool, prepared).await) - } - Err(err) => Err(repository_storage_error("insert events", err)), - } -} - -/// After an event-insert unique violation, find the stream whose actual version -/// no longer matches its expected version and report it as `ConcurrentWrite`. -/// Falls back to the first append if a concurrent writer's effect cannot be -/// pinned down (the violation still indicates a conflicting write). -async fn concurrent_write_from_conflict( - pool: &PgPool, - prepared: &[PreparedEventAppend], -) -> RepositoryError { - for append in prepared { - match stream_version_pool(pool, &append.identity).await { - Ok(actual) if actual != append.expected_version => { - return RepositoryError::ConcurrentWrite { - id: append.identity.to_string(), - expected: append.expected_version, - actual, - }; - } - Ok(_) => {} - Err(err) => return err, - } - } - - let append = &prepared[0]; - match stream_version_pool(pool, &append.identity).await { - Ok(actual) => RepositoryError::ConcurrentWrite { - id: append.identity.to_string(), - expected: append.expected_version, - actual, - }, - Err(err) => err, - } -} - -/// Insert every outbox message in a single multi-row INSERT. A unique violation -/// on `message_id` still maps to `DuplicateOutboxMessageInBatch`. -async fn insert_outbox_messages_in_tx( - tx: &mut Transaction<'_, Postgres>, - messages: &[OutboxMessage], -) -> Result<(), RepositoryError> { - if messages.is_empty() { - return Ok(()); - } - - struct OutboxRow<'a> { - message_id: &'a str, - event_type: &'a str, - payload: &'a [u8], - payload_codec: &'a str, - payload_codec_version: i32, - destination: Option<&'a str>, - metadata: String, - status: &'a str, - created_at: f64, - worker_id: Option<&'a str>, - leased_until: Option, - attempts: i32, - last_error: Option<&'a str>, - source_aggregate_type: Option<&'a str>, - source_aggregate_id: Option<&'a str>, - source_sequence: Option, - correlation_id: Option<&'a str>, - causation_id: Option<&'a str>, - } - - let mut rows = Vec::with_capacity(messages.len()); - for message in messages { - rows.push(OutboxRow { - message_id: message.id(), - event_type: &message.event_type, - payload: &message.payload, - payload_codec: &message.payload_codec, - payload_codec_version: i32::from(message.payload_codec_version), - destination: message.destination.as_deref(), - metadata: serialize_event_metadata(&message.metadata)?, - status: message.status.as_str(), - created_at: system_time_to_epoch_secs(message.created_at)?, - worker_id: message.worker_id.as_deref(), - leased_until: message - .leased_until - .map(system_time_to_epoch_secs) - .transpose()?, - attempts: sqlx_repository_i32_from_u64( - POSTGRES_BACKEND, - u64::from(message.attempts), - "outbox attempts", - INTEGER_STORAGE, - )?, - last_error: message.last_error.as_deref(), - source_aggregate_type: message.source_aggregate_type.as_deref(), - source_aggregate_id: message.source_aggregate_id.as_deref(), - source_sequence: message - .source_sequence - .map(|value| { - sqlx_repository_i64_from_u64( - POSTGRES_BACKEND, - value, - "outbox source sequence", - BIGINT_STORAGE, - ) - }) - .transpose()?, - correlation_id: message.correlation_id(), - causation_id: message.causation_id(), - }); - } - - let mut builder = QueryBuilder::::new( - "INSERT INTO outbox_messages (\ - message_id, event_type, payload, payload_codec, payload_codec_version, \ - destination, metadata, status, created_at, next_available_at, \ - claimed_by, claimed_until, attempts, last_error, source_aggregate_type, \ - source_aggregate_id, source_sequence, correlation_id, causation_id) ", - ); - builder.push_values(rows, |mut row, message| { - row.push_bind(message.message_id) - .push_bind(message.event_type) - .push_bind(message.payload) - .push_bind(message.payload_codec) - .push_bind(message.payload_codec_version) - .push_bind(message.destination) - .push_bind(message.metadata) - .push_unseparated("::jsonb") - .push_bind(message.status); - // created_at and next_available_at share the same epoch-seconds value. - row.push("to_timestamp(") - .push_bind_unseparated(message.created_at) - .push_unseparated(")"); - row.push("to_timestamp(") - .push_bind_unseparated(message.created_at) - .push_unseparated(")"); - row.push_bind(message.worker_id); - // claimed_until: NULL stays NULL through to_timestamp, matching the - // per-row path's `to_timestamp($n::double precision)`. - row.push("to_timestamp(") - .push_bind_unseparated(message.leased_until) - .push_unseparated("::double precision)"); - row.push_bind(message.attempts) - .push_bind(message.last_error) - .push_bind(message.source_aggregate_type) - .push_bind(message.source_aggregate_id) - .push_bind(message.source_sequence) - .push_bind(message.correlation_id) - .push_bind(message.causation_id); - }); - - let result = builder.build().execute(&mut **tx).await; - - match result { - Ok(_) => Ok(()), - Err(err) if is_postgres_unique_violation(&err) => { - // The batch was already deduped (reject_duplicate_outbox_messages), - // so a violation means the id collides with a previously committed - // row. Report the first message id as the offender, matching the - // per-row path's contract. - Err(RepositoryError::DuplicateOutboxMessageInBatch { - id: messages[0].id().to_string(), - }) - } - Err(err) => Err(repository_storage_error("insert outbox messages", err)), - } -} - -async fn outbox_message_by_id_pool( - pool: &PgPool, - message_id: &str, -) -> Result, RepositoryError> { - let row = sqlx::query(outbox_message_select_by_id_sql()) - .bind(message_id) - .fetch_optional(pool) - .await - .map_err(|err| repository_storage_error("load outbox message", err))?; - row.map(outbox_message_from_row).transpose() -} - -fn outbox_message_select_by_status_sql() -> &'static str { - r#" - SELECT message_id, - event_type, - payload, - payload_codec, - payload_codec_version, - metadata::text AS metadata, - status, - EXTRACT(EPOCH FROM created_at)::double precision AS created_at_epoch, - claimed_by, - EXTRACT(EPOCH FROM claimed_until)::double precision AS claimed_until_epoch, - attempts, - last_error, - destination, - source_aggregate_type, - source_aggregate_id, - source_sequence, - correlation_id, - causation_id - FROM outbox_messages - WHERE status = $1 - ORDER BY created_at ASC, message_id ASC - "# -} - -fn outbox_message_select_by_id_sql() -> &'static str { - r#" - SELECT message_id, - event_type, - payload, - payload_codec, - payload_codec_version, - metadata::text AS metadata, - status, - EXTRACT(EPOCH FROM created_at)::double precision AS created_at_epoch, - claimed_by, - EXTRACT(EPOCH FROM claimed_until)::double precision AS claimed_until_epoch, - attempts, - last_error, - destination, - source_aggregate_type, - source_aggregate_id, - source_sequence, - correlation_id, - causation_id - FROM outbox_messages - WHERE message_id = $1 - "# -} - -fn outbox_message_from_row(row: PgRow) -> Result { - let status_text: String = row - .try_get("status") - .map_err(|err| repository_storage_error("decode outbox status row", err))?; - let status = status_text.parse::().map_err(|_| { - RepositoryError::Model(format!("postgres outbox status `{status_text}` is invalid")) - })?; - let metadata_json: String = row - .try_get("metadata") - .map_err(|err| repository_storage_error("decode outbox metadata row", err))?; - let attempts: i32 = row - .try_get("attempts") - .map_err(|err| repository_storage_error("decode outbox attempts row", err))?; - let source_sequence = row - .try_get::, _>("source_sequence") - .map_err(|err| repository_storage_error("decode outbox source sequence row", err))? - .map(|value| { - sqlx_repository_u64_from_i64(POSTGRES_BACKEND, value, "outbox source sequence") - }) - .transpose()?; - let mut metadata = deserialize_event_metadata(&metadata_json)?; - if let Some(correlation_id) = row - .try_get::, _>("correlation_id") - .map_err(|err| repository_storage_error("decode outbox correlation_id row", err))? - { - metadata.insert("correlation_id".into(), correlation_id); - } - if let Some(causation_id) = row - .try_get::, _>("causation_id") - .map_err(|err| repository_storage_error("decode outbox causation_id row", err))? - { - metadata.insert("causation_id".into(), causation_id); - } - - Ok(OutboxMessage { - id: row - .try_get("message_id") - .map_err(|err| repository_storage_error("decode outbox message id row", err))?, - event_type: row - .try_get("event_type") - .map_err(|err| repository_storage_error("decode outbox event type row", err))?, - payload: row - .try_get("payload") - .map_err(|err| repository_storage_error("decode outbox payload row", err))?, - payload_codec: row - .try_get("payload_codec") - .map_err(|err| repository_storage_error("decode outbox payload codec row", err))?, - payload_codec_version: sqlx_repository_u16_from_i32( - POSTGRES_BACKEND, - row.try_get("payload_codec_version").map_err(|err| { - repository_storage_error("decode outbox payload codec version row", err) - })?, - "outbox payload codec version", - )?, - metadata, - status, - created_at: system_time_from_epoch_secs( - row.try_get("created_at_epoch") - .map_err(|err| repository_storage_error("decode outbox created_at row", err))?, - )?, - worker_id: row - .try_get("claimed_by") - .map_err(|err| repository_storage_error("decode outbox claimed_by row", err))?, - leased_until: row - .try_get::, _>("claimed_until_epoch") - .map_err(|err| repository_storage_error("decode outbox claimed_until row", err))? - .map(system_time_from_epoch_secs) - .transpose()?, - attempts: u32::try_from(attempts).map_err(|_| { - RepositoryError::Model(format!( - "postgres outbox attempts value {attempts} is invalid" - )) - })?, - last_error: row - .try_get("last_error") - .map_err(|err| repository_storage_error("decode outbox last_error row", err))?, - destination: row - .try_get("destination") - .map_err(|err| repository_storage_error("decode outbox destination row", err))?, - source_aggregate_type: row.try_get("source_aggregate_type").map_err(|err| { - repository_storage_error("decode outbox source aggregate type row", err) - })?, - source_aggregate_id: row.try_get("source_aggregate_id").map_err(|err| { - repository_storage_error("decode outbox source aggregate id row", err) - })?, - source_sequence, - }) -} - -async fn ensure_outbox_update_applied( - pool: &PgPool, - rows_affected: u64, - message_id: &str, - validate: impl FnOnce(&OutboxMessage) -> Result<(), RepositoryError>, -) -> Result<(), RepositoryError> { - if rows_affected > 0 { - return Ok(()); - } - - let message = outbox_message_by_id_pool(pool, message_id) - .await? - .ok_or_else(|| RepositoryError::NotFound { - id: message_id.to_string(), - })?; - validate(&message) -} - -fn entity_from_events(aggregate_id: String, events: Vec) -> Entity { - let mut entity = Entity::new(); - entity.set_id(aggregate_id); - entity.load_from_history(events); - entity -} - -fn event_from_row(row: PgRow) -> Result { - let payload_codec: String = row - .try_get("payload_codec") - .map_err(|err| repository_storage_error("decode payload codec row", err))?; - let payload_codec_version = sqlx_repository_u16_from_i32( - POSTGRES_BACKEND, - row.try_get("payload_codec_version") - .map_err(|err| repository_storage_error("decode payload codec version row", err))?, - "payload_codec_version", - )?; - let metadata_json: String = row - .try_get("metadata") - .map_err(|err| repository_storage_error("decode metadata row", err))?; - let metadata = deserialize_event_metadata(&metadata_json)?; - let event = EventRecord { - event_name: row - .try_get("event_name") - .map_err(|err| repository_storage_error("decode event name row", err))?, - payload_codec, - payload_codec_version, - payload: row - .try_get("payload") - .map_err(|err| repository_storage_error("decode payload row", err))?, - event_version: sqlx_repository_u64_from_i32( - POSTGRES_BACKEND, - row.try_get("event_version") - .map_err(|err| repository_storage_error("decode event version row", err))?, - "event_version", - )?, - sequence: sqlx_repository_u64_from_i64( - POSTGRES_BACKEND, - row.try_get("sequence") - .map_err(|err| repository_storage_error("decode sequence row", err))?, - "sequence", - )?, - timestamp: system_time_from_epoch_secs( - row.try_get("recorded_at_epoch") - .map_err(|err| repository_storage_error("decode recorded_at row", err))?, - )?, - metadata, - }; - validate_supported_event_codec(&event)?; - Ok(event) -} - -async fn save_snapshot_in_tx( - tx: &mut Transaction<'_, Postgres>, - identity: &StreamIdentity, - record: SnapshotRecord, -) -> Result<(), RepositoryError> { - validate_snapshot_identity(identity, &record)?; - - sqlx::query( - r#" - INSERT INTO aggregate_snapshots ( - aggregate_type, - aggregate_id, - version, - snapshot_version, - payload, - payload_codec, - payload_codec_version, - metadata, - recorded_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, to_timestamp($9)) - ON CONFLICT(aggregate_type, aggregate_id) DO UPDATE SET - version = excluded.version, - snapshot_version = excluded.snapshot_version, - payload = excluded.payload, - payload_codec = excluded.payload_codec, - payload_codec_version = excluded.payload_codec_version, - metadata = excluded.metadata, - recorded_at = excluded.recorded_at, - updated_at = now() - "#, - ) - .bind(identity.aggregate_type()) - .bind(identity.aggregate_id()) - .bind(sqlx_repository_i64_from_u64( - POSTGRES_BACKEND, - record.version, - "snapshot version", - BIGINT_STORAGE, - )?) - .bind(sqlx_repository_i32_from_u64( - POSTGRES_BACKEND, - record.snapshot_version, - "snapshot payload version", - INTEGER_STORAGE, - )?) - .bind(&record.payload) - .bind(&record.payload_codec) - .bind(i32::from(record.payload_codec_version)) - .bind(serialize_event_metadata(&record.metadata)?) - .bind(system_time_to_epoch_secs(record.recorded_at)?) - .execute(&mut **tx) - .await - .map_err(|err| repository_storage_error("save snapshot", err))?; - - Ok(()) -} - -fn snapshot_from_row(row: PgRow) -> Result { - let metadata_json: String = row - .try_get("metadata") - .map_err(|err| repository_storage_error("decode snapshot metadata row", err))?; - Ok(SnapshotRecord { - aggregate_type: row - .try_get("aggregate_type") - .map_err(|err| repository_storage_error("decode snapshot aggregate type row", err))?, - aggregate_id: row - .try_get("aggregate_id") - .map_err(|err| repository_storage_error("decode snapshot aggregate id row", err))?, - version: sqlx_repository_u64_from_i64( - POSTGRES_BACKEND, - row.try_get("version") - .map_err(|err| repository_storage_error("decode snapshot version row", err))?, - "snapshot version", - )?, - snapshot_version: sqlx_repository_u64_from_i32( - POSTGRES_BACKEND, - row.try_get("snapshot_version").map_err(|err| { - repository_storage_error("decode snapshot payload version row", err) - })?, - "snapshot payload version", - )?, - payload_codec: row - .try_get("payload_codec") - .map_err(|err| repository_storage_error("decode snapshot payload codec row", err))?, - payload_codec_version: sqlx_repository_u16_from_i32( - POSTGRES_BACKEND, - row.try_get("payload_codec_version").map_err(|err| { - repository_storage_error("decode snapshot payload codec version row", err) - })?, - "snapshot payload codec version", - )?, - payload: row - .try_get("payload") - .map_err(|err| repository_storage_error("decode snapshot payload row", err))?, - metadata: deserialize_event_metadata(&metadata_json)?, - recorded_at: system_time_from_epoch_secs( - row.try_get("recorded_at_epoch") - .map_err(|err| repository_storage_error("decode snapshot recorded_at row", err))?, - )?, - }) -} - -fn system_time_to_epoch_secs(timestamp: SystemTime) -> Result { - let duration = timestamp.duration_since(UNIX_EPOCH).map_err(|err| { - RepositoryError::Model(format!( - "event timestamp before UNIX epoch cannot be stored in postgres: {err}" - )) - })?; - Ok(duration.as_secs_f64()) -} - fn system_time_from_epoch_secs(value: f64) -> Result { if !value.is_finite() || value < 0.0 { return Err(RepositoryError::Model(format!( @@ -1772,10 +456,6 @@ fn repository_storage_error(operation: &str, err: sqlx::Error) -> RepositoryErro sqlx_repo::repository_storage_error(POSTGRES_BACKEND, operation, err) } -fn read_model_storage_error(operation: &str, err: sqlx::Error) -> TableStoreError { +fn read_model_storage_error(operation: &str, err: sqlx::Error) -> ReadModelError { sqlx_repo::read_model_storage_error(POSTGRES_BACKEND, operation, err) } - -fn table_schema_storage_error(operation: &str, err: sqlx::Error) -> TableStoreError { - TableStoreError::Storage(format!("{POSTGRES_BACKEND} {operation} failed: {err}")) -} diff --git a/src/queued_repo/repository.rs b/src/queued_repo/repository.rs index 4ba00078..2147f661 100644 --- a/src/queued_repo/repository.rs +++ b/src/queued_repo/repository.rs @@ -217,6 +217,13 @@ where self.inner.get_snapshot(identity) } + fn get_snapshots<'a>( + &'a self, + identities: &'a [StreamIdentity], + ) -> impl Future, RepositoryError>> + Send + 'a { + self.inner.get_snapshots(identities) + } + fn save_snapshot<'a>( &'a self, identity: &'a StreamIdentity, diff --git a/src/repository/mod.rs b/src/repository/mod.rs index 5a1d2eda..7ed5d177 100644 --- a/src/repository/mod.rs +++ b/src/repository/mod.rs @@ -14,7 +14,4 @@ pub use traits::{ }; #[cfg(any(feature = "postgres", feature = "sqlite"))] pub(crate) use validation::validate_supported_event_codec; -pub(crate) use validation::{ - reject_duplicate_outbox_messages, reject_duplicate_streams, - validate_entity_id_matches_identity, validate_prepared_appends, validate_snapshot_identity, -}; +pub(crate) use validation::{validate_commit_batch, validate_snapshot_identity}; diff --git a/src/repository/traits.rs b/src/repository/traits.rs index ad2b9aa2..55476418 100644 --- a/src/repository/traits.rs +++ b/src/repository/traits.rs @@ -57,20 +57,22 @@ impl<'a> CommitBatch<'a> { } } -/// Owned append data prepared from a borrowed stream write before async I/O. +/// Append data prepared from a borrowed stream write before async I/O. Events +/// are borrowed from the staged entity — backends bind them by reference, so +/// preparing a batch never clones event payloads. #[derive(Clone, Debug)] -pub struct PreparedEventAppend { +pub struct PreparedEventAppend<'a> { pub identity: StreamIdentity, pub expected_version: u64, - pub events: Vec, + pub events: &'a [EventRecord], } -impl PreparedEventAppend { - pub fn from_stream_write(write: &StreamWrite<'_>) -> Self { +impl<'a> PreparedEventAppend<'a> { + pub fn from_stream_write(write: &'a StreamWrite<'_>) -> Self { Self { identity: write.identity.clone(), expected_version: write.entity.committed_version(), - events: write.entity.new_events().to_vec(), + events: write.entity.new_events(), } } } @@ -82,10 +84,28 @@ pub trait GetStream: Send + Sync { identity: &'a StreamIdentity, ) -> impl Future, RepositoryError>> + Send + 'a; + /// Load the streams for the provided identities, skipping missing ones. + /// + /// The default loads each stream with [`get_stream`], which is always + /// correct, just one round trip per identity. Backends with a queryable + /// store (Postgres, SQLite) override this with a single grouped query. + /// Backends may return entities in storage order rather than input order. + /// + /// [`get_stream`]: GetStream::get_stream fn get_streams<'a>( &'a self, identities: &'a [StreamIdentity], - ) -> impl Future, RepositoryError>> + Send + 'a; + ) -> impl Future, RepositoryError>> + Send + 'a { + async move { + let mut entities = Vec::with_capacity(identities.len()); + for identity in identities { + if let Some(entity) = self.get_stream(identity).await? { + entities.push(entity); + } + } + Ok(entities) + } + } /// Load only the events with `sequence > after_version` as a tail-only /// [`Entity`] (see [`Entity::load_tail_from_history`]). @@ -189,6 +209,30 @@ pub trait SnapshotStore: Send + Sync { identity: &'a StreamIdentity, ) -> impl Future, RepositoryError>> + Send + 'a; + /// Load the snapshots for the provided identities, skipping identities + /// without one. Each returned record carries its own aggregate type/id, so + /// callers can pair records back to identities. + /// + /// The default loads each snapshot with [`get_snapshot`], which is always + /// correct, just one round trip per identity. Backends with a queryable + /// store (Postgres, SQLite) override this with a single grouped query. + /// + /// [`get_snapshot`]: SnapshotStore::get_snapshot + fn get_snapshots<'a>( + &'a self, + identities: &'a [StreamIdentity], + ) -> impl Future, RepositoryError>> + Send + 'a { + async move { + let mut records = Vec::with_capacity(identities.len()); + for identity in identities { + if let Some(record) = self.get_snapshot(identity).await? { + records.push(record); + } + } + Ok(records) + } + } + fn save_snapshot<'a>( &'a self, identity: &'a StreamIdentity, diff --git a/src/repository/validation.rs b/src/repository/validation.rs index de5210e1..eada1e7e 100644 --- a/src/repository/validation.rs +++ b/src/repository/validation.rs @@ -15,9 +15,43 @@ use crate::entity::{ use crate::outbox::{validate_outbox_message_table_write, OutboxMessage}; use crate::snapshot::SnapshotRecord; -use super::{PreparedEventAppend, RepositoryError, StreamIdentity, StreamWrite}; +use super::{ + CommitBatch, PreparedEventAppend, RepositoryError, SnapshotWrite, StreamIdentity, StreamWrite, +}; + +/// Validate a full [`CommitBatch`] and prepare its event appends. +/// +/// This is the single validation preamble every backend runs before touching +/// storage: duplicate-stream and duplicate-outbox rejection, entity/identity +/// agreement, sequence-contiguity of the prepared appends, and snapshot +/// identity agreement. Backends must not add or skip batch-shape checks +/// locally — extending this function is what keeps them from drifting. +pub(crate) fn validate_commit_batch<'a>( + batch: &'a CommitBatch<'_>, +) -> Result>, RepositoryError> { + reject_duplicate_streams(&batch.streams)?; + reject_duplicate_outbox_messages(&batch.outbox_messages)?; + validate_entity_id_matches_identity(&batch.streams)?; + + let prepared = batch + .streams + .iter() + .map(PreparedEventAppend::from_stream_write) + .collect::>(); + validate_prepared_appends(&prepared)?; + + for write in &batch.snapshots { + match write { + SnapshotWrite::Save { identity, record } => { + validate_snapshot_identity(identity, record)?; + } + } + } + + Ok(prepared) +} -pub(crate) fn reject_duplicate_streams(streams: &[StreamWrite<'_>]) -> Result<(), RepositoryError> { +fn reject_duplicate_streams(streams: &[StreamWrite<'_>]) -> Result<(), RepositoryError> { let mut seen = HashSet::with_capacity(streams.len()); for stream in streams { let key = stream.identity.storage_key(); @@ -30,9 +64,7 @@ pub(crate) fn reject_duplicate_streams(streams: &[StreamWrite<'_>]) -> Result<() Ok(()) } -pub(crate) fn reject_duplicate_outbox_messages( - messages: &[OutboxMessage], -) -> Result<(), RepositoryError> { +fn reject_duplicate_outbox_messages(messages: &[OutboxMessage]) -> Result<(), RepositoryError> { let mut seen = HashSet::with_capacity(messages.len()); for message in messages { validate_outbox_message_table_write(message) @@ -55,9 +87,7 @@ pub(crate) fn reject_duplicate_outbox_messages( Ok(()) } -pub(crate) fn validate_entity_id_matches_identity( - streams: &[StreamWrite<'_>], -) -> Result<(), RepositoryError> { +fn validate_entity_id_matches_identity(streams: &[StreamWrite<'_>]) -> Result<(), RepositoryError> { for stream in streams { if stream.entity.id() != stream.identity.aggregate_id() { return Err(RepositoryError::Model(format!( @@ -70,9 +100,7 @@ pub(crate) fn validate_entity_id_matches_identity( Ok(()) } -pub(crate) fn validate_prepared_appends( - appends: &[PreparedEventAppend], -) -> Result<(), RepositoryError> { +fn validate_prepared_appends(appends: &[PreparedEventAppend<'_>]) -> Result<(), RepositoryError> { for append in appends { for (offset, event) in append.events.iter().enumerate() { validate_supported_event_codec(event)?; diff --git a/src/snapshot/in_memory.rs b/src/snapshot/in_memory.rs index 7750eadf..0d94c91b 100644 --- a/src/snapshot/in_memory.rs +++ b/src/snapshot/in_memory.rs @@ -48,6 +48,22 @@ impl SnapshotStore for InMemorySnapshotStore { } } + fn get_snapshots<'a>( + &'a self, + identities: &'a [StreamIdentity], + ) -> impl Future, RepositoryError>> + Send + 'a { + async move { + let storage = self + .storage + .read() + .map_err(|_| RepositoryError::LockPoisoned("async snapshot read"))?; + Ok(identities + .iter() + .filter_map(|identity| storage.get(&identity.storage_key()).cloned()) + .collect()) + } + } + fn save_snapshot<'a>( &'a self, identity: &'a StreamIdentity, diff --git a/src/snapshot/repository.rs b/src/snapshot/repository.rs index d28fc100..91aaacab 100644 --- a/src/snapshot/repository.rs +++ b/src/snapshot/repository.rs @@ -229,6 +229,7 @@ where frequency, snapshot_record_if_due::, hydrate_from_store::, + hydrate_all_from_store::, load_from_store::, )); self @@ -268,6 +269,41 @@ where }) } +/// Load the snapshot cache records for a whole batch in one round trip and +/// hydrate each entity from its record (if any). Captured as the `hydrate_all` +/// hook of a `SnapshotPolicy` — fixes the N+1 of consulting the cache per +/// aggregate on batch loads. +fn hydrate_all_from_store<'a, R, A>( + repo: &'a R, + entities: Vec<(StreamIdentity, Entity)>, +) -> Pin, RepositoryError>> + Send + 'a>> +where + R: SnapshotStore + Sync, + A: Snapshottable + Send, +{ + Box::pin(async move { + let identities: Vec = entities + .iter() + .map(|(identity, _)| identity.clone()) + .collect(); + let mut snapshots = std::collections::HashMap::with_capacity(identities.len()); + for record in repo.get_snapshots(&identities).await? { + let key = + StreamIdentity::new(&record.aggregate_type, &record.aggregate_id)?.storage_key(); + snapshots.insert(key, record); + } + entities + .into_iter() + .map(|(identity, entity)| { + hydrate_with_optional_snapshot::( + entity, + snapshots.remove(&identity.storage_key()), + ) + }) + .collect() + }) +} + /// Own the whole load, reading the snapshot **first** so only the post-snapshot /// tail of the stream is fetched. Captured as the `load` hook of a /// `SnapshotPolicy`. @@ -394,12 +430,6 @@ mod tests { ) -> Result, RepositoryError> { Ok(None) } - async fn get_streams( - &self, - _identities: &[StreamIdentity], - ) -> Result, RepositoryError> { - Ok(Vec::new()) - } } impl SnapshotStore for FailingSnapshotRepo { diff --git a/src/sqlite_repo/mod.rs b/src/sqlite_repo/mod.rs index bafcaf59..06ba263c 100644 --- a/src/sqlite_repo/mod.rs +++ b/src/sqlite_repo/mod.rs @@ -1,550 +1,196 @@ -//! SQLite-backed async repository and transactional relational read-model writes. +//! SQLite backend for the shared SQLx repository. //! -//! This adapter is a local SQL persistence backend for the async repository -//! boundary. It is feature-gated behind `sqlite` and is intentionally async-only. - -#![expect( - clippy::manual_async_fn, - reason = "async trait impls return impl Future + Send to preserve public Send bounds" -)] - -use std::collections::BTreeMap; -use std::future::Future; -use std::sync::{Arc, RwLock}; +//! The event-store/snapshot/outbox/inbox logic lives once in +//! [`crate::sqlx_repo::repo`]; this module carries only what is genuinely +//! SQLite-specific: the schema SQL, the `"secs.nanos"` text timestamp codec, +//! bind-parameter chunking, the unique-constraint predicate, and the +//! candidate-scan outbox claim (SQLite has no row locks). It is feature-gated +//! behind `sqlite` and async-only. + +use std::sync::LazyLock; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use sqlx::sqlite::{SqlitePoolOptions, SqliteRow}; -use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool, Transaction}; +use sqlx::migrate::Migrator; +use sqlx::query_builder::Separated; +use sqlx::sqlite::SqliteRow; +use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool}; -use crate::entity::{Entity, EventRecord}; use crate::outbox::{OutboxMessage, OutboxMessageStatus}; -use crate::outbox_worker::{ensure_active_claim, ClaimOutboxMessages, OutboxClaimRef, OutboxStore}; -use crate::read_model::{ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities}; -use crate::repository::{ - reject_duplicate_outbox_messages, reject_duplicate_streams, - validate_entity_id_matches_identity, validate_prepared_appends, validate_snapshot_identity, - validate_supported_event_codec, CommitBatch, GetStream, InboxReceipt, InboxStore, - PreparedEventAppend, ReadModelWritePlanStore, RelationalReadModelQueryStore, RepositoryError, - SnapshotStore, SnapshotWrite, StreamIdentity, TransactionalCommit, -}; -use crate::snapshot::SnapshotRecord; -use crate::sqlx_repo::read_model::{ - apply_read_model_write_plan_in_tx, commit_read_model_write_plan, empty_string_as_none, - load_read_model_graph, quote_identifier, remember_read_model_schemas, - sql_read_model_capabilities, validate_sql_write_plan, +use crate::outbox_worker::ClaimOutboxMessages; +use crate::table::{ColumnType, RowValue, TableColumn as ColumnDef, TableStoreError as ReadModelError}; +use crate::repository::RepositoryError; +use crate::sqlx_repo::read_model::quote_identifier; +use crate::sqlx_repo::repo::{ + embedded_migrator, outbox_message_by_id, system_time_epoch_secs, SqlxOutboxStore, + SqlxRepository, }; use crate::sqlx_repo::{ - self, audited_table_schema_sql, deserialize_event_metadata, is_sqlite_unique_constraint, - read_model_i64_from_u64 as sqlx_read_model_i64_from_u64, + self, is_sqlite_unique_constraint, read_model_i64_from_u64 as sqlx_read_model_i64_from_u64, read_model_u64_from_i64 as sqlx_read_model_u64_from_i64, repository_i64_from_u64 as sqlx_repository_i64_from_u64, - repository_u16_from_i64 as sqlx_repository_u16_from_i64, - repository_u64_from_i64 as sqlx_repository_u64_from_i64, serialize_event_metadata, -}; -use crate::table::{ - generate_table_migration_artifacts, table_schema_bootstrap_result, table_schema_statements, - TableMigrationArtifact, TableSchemaBootstrap, TableSchemaRegistry, TableSqlDialect, - TableSqlSchemaAdapter, TableStoreError, -}; -use crate::table::{ - ColumnType, RowValue, TableAdapterCapabilities, TableColumn, TableCommitOutcome, TableWritePlan, }; - -const SQLITE_SCHEMA: &str = include_str!("../../migrations/sqlite/0001_initial.sql"); +use crate::table::TableSqlDialect; + +static SQLITE_MIGRATOR: LazyLock = LazyLock::new(|| { + embedded_migrator(&[( + 1, + "initial", + include_str!("../../migrations/sqlite/0001_initial.sql"), + )]) +}); const SQLITE_BACKEND: &str = "sqlite"; const SIGNED_INTEGER_STORAGE: &str = "signed integer storage"; /// SQLite-backed async repository. -#[derive(Clone)] -pub struct SqliteRepository { - pool: SqlitePool, - read_model_schemas: Arc>, -} +pub type SqliteRepository = SqlxRepository; /// SQLite-backed outbox table store. -#[derive(Clone)] -pub struct SqliteOutboxStore { - pool: SqlitePool, -} - -impl SqliteRepository { - /// Create a repository from an existing migrated pool. - pub fn new(pool: SqlitePool) -> Self { - Self { - pool, - read_model_schemas: Arc::new(RwLock::new(TableSchemaRegistry::new())), +pub type SqliteOutboxStore = SqlxOutboxStore; + +impl crate::sqlx_repo::repo::SqlxRepoBackend for Sqlite { + fn migrator() -> &'static Migrator { + &SQLITE_MIGRATOR + } + // SQLite's historical bound-parameter limit is 999; staying under it keeps + // the batched inserts portable across SQLite builds. + const MAX_BIND_PARAMS: usize = 900; + // SQLite does not abort the transaction on a constraint error, so conflict + // recovery re-reads stream versions in the same transaction. + const CONFLICT_REREAD_IN_TX: bool = true; + const NOW: &'static str = "CURRENT_TIMESTAMP"; + const EVENT_SELECT: &'static str = "event_name, event_version, payload, payload_codec, \ + payload_codec_version, metadata, sequence, recorded_at"; + const SNAPSHOT_SELECT: &'static str = "aggregate_type, aggregate_id, version, \ + snapshot_version, payload, payload_codec, payload_codec_version, metadata, recorded_at"; + const OUTBOX_SELECT: &'static str = "message_id, event_type, payload, payload_codec, \ + payload_codec_version, metadata, status, created_at, claimed_by, claimed_until, \ + attempts, last_error, destination, source_aggregate_type, source_aggregate_id, \ + source_sequence, correlation_id, causation_id"; + const ORDER_BY_CREATED_AT: &'static str = "CAST(created_at AS REAL)"; + const TABLE_DIALECT: TableSqlDialect = TableSqlDialect::Sqlite; + + /// `"secs.nanos"` text, sortable/comparable via `CAST(... AS REAL)`. + type TimestampValue = String; + + fn default_pool_size(database_url: &str) -> u32 { + if database_url.contains(":memory:") { + 1 + } else { + 5 } } - /// Open a SQLite pool without applying migrations. - pub async fn connect(database_url: &str) -> Result { - let pool = SqlitePoolOptions::new() - .max_connections(default_pool_size(database_url)) - .connect(database_url) - .await - .map_err(|err| repository_storage_error("connect", err))?; - Ok(Self::new(pool)) + fn is_unique_violation(err: &sqlx::Error) -> bool { + is_sqlite_unique_constraint(err) } - /// Open a SQLite pool and apply the explicit SQLite migrations. - pub async fn connect_and_migrate(database_url: &str) -> Result { - let repo = Self::connect(database_url).await?; - repo.migrate().await?; - Ok(repo) - } - - /// Apply SQLite migrations to this repository's pool. - pub async fn migrate(&self) -> Result<(), RepositoryError> { - Self::migrate_pool(&self.pool).await - } - - /// Apply SQLite migrations to an existing pool. - pub async fn migrate_pool(pool: &SqlitePool) -> Result<(), RepositoryError> { - for statement in SQLITE_SCHEMA.split(';') { - let statement = statement.trim(); - if statement.is_empty() { - continue; - } - sqlx::query(statement) - .execute(pool) - .await - .map_err(|err| repository_storage_error("migrate", err))?; - } - Ok(()) + fn timestamp_value(timestamp: SystemTime) -> Result { + system_time_to_storage(timestamp) } - /// Access the underlying SQLx pool for application-specific setup or tests. - pub fn pool(&self) -> &SqlitePool { - &self.pool + fn push_timestamp(sep: &mut Separated<'_, Sqlite, &'static str>, value: &String) { + sep.push_bind(value.as_str()); } - /// SQL artifact adapter for registered table/read-model schemas. - pub fn table_schema_adapter(&self) -> TableSqlSchemaAdapter { - TableSqlSchemaAdapter::sqlite() + fn push_optional_timestamp( + sep: &mut Separated<'_, Sqlite, &'static str>, + value: Option<&String>, + ) { + sep.push_bind(value.map(String::as_str)); } - /// Generate SQL statements for registered table/read-model schemas. - pub fn generate_table_migration_artifacts( - &self, - registry: &TableSchemaRegistry, - ) -> Result, TableStoreError> { - generate_table_migration_artifacts(registry, TableSqlDialect::Sqlite) + fn push_timestamp_assign(builder: &mut QueryBuilder, value: &String) { + builder.push_bind(value.as_str()); } - /// Explicit dev/test bootstrap for registered table/read-model schemas. - pub async fn bootstrap_table_schema_for_dev( - &self, - registry: &TableSchemaRegistry, - ) -> Result { - for statement in table_schema_statements(registry, TableSqlDialect::Sqlite)? { - sqlx::query(audited_table_schema_sql(statement)) - .execute(&self.pool) - .await - .map_err(|err| table_schema_storage_error("bootstrap table schema", err))?; - } - remember_read_model_schemas(&self.read_model_schemas, registry)?; - Ok(table_schema_bootstrap_result(registry)) - } - - /// Access an outbox-store handle backed by this repository's pool. - pub fn outbox_store(&self) -> SqliteOutboxStore { - SqliteOutboxStore { - pool: self.pool.clone(), - } - } -} - -impl SqliteOutboxStore { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } - - pub fn pool(&self) -> &SqlitePool { - &self.pool - } - - /// SQL artifact adapter for registered table/read-model schemas. - pub fn table_schema_adapter(&self) -> TableSqlSchemaAdapter { - TableSqlSchemaAdapter::sqlite() - } - - /// Generate SQL statements for registered table/read-model schemas. - pub fn generate_table_migration_artifacts( - &self, - registry: &TableSchemaRegistry, - ) -> Result, TableStoreError> { - generate_table_migration_artifacts(registry, TableSqlDialect::Sqlite) + fn push_timestamp_cmp( + builder: &mut QueryBuilder, + column: &'static str, + op: &'static str, + epoch_secs: f64, + ) { + builder.push("CAST("); + builder.push(column); + builder.push(" AS REAL) "); + builder.push(op); + builder.push(" "); + builder.push_bind(epoch_secs); + } + + fn decode_timestamp( + row: &SqliteRow, + column: &'static str, + ) -> Result { + system_time_from_storage( + row.try_get::(column) + .map_err(|err| { + repository_storage_error(&format!("decode {column} timestamp row"), err) + })? + .as_str(), + ) } - /// Explicit dev/test bootstrap for registered table/read-model schemas. - pub async fn bootstrap_table_schema_for_dev( - &self, - registry: &TableSchemaRegistry, - ) -> Result { - for statement in table_schema_statements(registry, TableSqlDialect::Sqlite)? { - sqlx::query(audited_table_schema_sql(statement)) - .execute(&self.pool) - .await - .map_err(|err| table_schema_storage_error("bootstrap table schema", err))?; - } - Ok(table_schema_bootstrap_result(registry)) + fn decode_optional_timestamp( + row: &SqliteRow, + column: &'static str, + ) -> Result, RepositoryError> { + row.try_get::, _>(column) + .map_err(|err| { + repository_storage_error(&format!("decode {column} timestamp row"), err) + })? + .as_deref() + .map(system_time_from_storage) + .transpose() } -} - -impl GetStream for SqliteRepository { - fn get_stream<'a>( - &'a self, - identity: &'a StreamIdentity, - ) -> impl Future, RepositoryError>> + Send + 'a { - async move { - let rows = sqlx::query( - r#" - SELECT event_name, event_version, payload, payload_codec, - payload_codec_version, metadata, sequence, recorded_at - FROM aggregate_events - WHERE aggregate_type = ? AND aggregate_id = ? - ORDER BY sequence ASC - "#, - ) - .bind(identity.aggregate_type()) - .bind(identity.aggregate_id()) - .fetch_all(&self.pool) - .await - .map_err(|err| repository_storage_error("load stream", err))?; - - if rows.is_empty() { - return Ok(None); - } - let mut events = Vec::with_capacity(rows.len()); - for row in rows { - events.push(event_from_row(row)?); - } - - let mut entity = Entity::new(); - entity.set_id(identity.aggregate_id()); - entity.load_from_history(events); - Ok(Some(entity)) - } + fn push_metadata(sep: &mut Separated<'_, Sqlite, &'static str>, json: &str) { + sep.push_bind(json); } - fn get_streams<'a>( - &'a self, - identities: &'a [StreamIdentity], - ) -> impl Future, RepositoryError>> + Send + 'a { - async move { - if identities.is_empty() { - return Ok(Vec::new()); - } - - // Group ids by aggregate type so each type is one `aggregate_id IN - // (...)` round trip instead of a query per identity. SQLite has no - // array type, so the id list is built as bound placeholders. - // `get_all` builds single-type batches, so the common case is one - // query. - let mut ids_by_type: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); - for identity in identities { - ids_by_type - .entry(identity.aggregate_type()) - .or_default() - .push(identity.aggregate_id()); + fn push_id_filter(builder: &mut QueryBuilder, ids: &[&str]) { + // SQLite has no array type, so the id list is built as bound + // placeholders. + builder.push("aggregate_id IN ("); + { + let mut separated = builder.separated(", "); + for id in ids { + separated.push_bind(*id); } - - let mut entities = Vec::with_capacity(identities.len()); - for (aggregate_type, aggregate_ids) in ids_by_type { - // Ordering by aggregate_id then sequence lets us slice the flat - // result into per-aggregate entities in one pass. Callers of - // `get_all` accept storage-order results. - let mut builder = QueryBuilder::::new( - "SELECT aggregate_id, event_name, event_version, payload, \ - payload_codec, payload_codec_version, metadata, sequence, recorded_at \ - FROM aggregate_events WHERE aggregate_type = ", - ); - builder.push_bind(aggregate_type); - builder.push(" AND aggregate_id IN ("); - let mut separated = builder.separated(", "); - for id in &aggregate_ids { - separated.push_bind(*id); - } - builder.push(") ORDER BY aggregate_id ASC, sequence ASC"); - - let rows = builder - .build() - .fetch_all(&self.pool) - .await - .map_err(|err| repository_storage_error("load streams", err))?; - - let mut current_id: Option = None; - let mut current_events: Vec = Vec::new(); - for row in rows { - let row_id: String = row - .try_get("aggregate_id") - .map_err(|err| repository_storage_error("decode aggregate id row", err))?; - let event = event_from_row(row)?; - match ¤t_id { - Some(id) if id == &row_id => current_events.push(event), - _ => { - if let Some(id) = current_id.take() { - entities.push(entity_from_events( - id, - std::mem::take(&mut current_events), - )); - } - current_id = Some(row_id); - current_events.push(event); - } - } - } - if let Some(id) = current_id.take() { - entities.push(entity_from_events(id, current_events)); - } - } - - Ok(entities) } + builder.push(")"); } - fn get_stream_tail<'a>( - &'a self, - identity: &'a StreamIdentity, - after_version: u64, - ) -> impl Future, RepositoryError>> + Send + 'a { - async move { - // Fetch only the post-snapshot tail. `after_version` is the snapshot - // version (an event sequence); `sequence > ?` skips already-folded - // rows so a fresh snapshot over a long stream no longer reads and - // decodes the entire history. - let after = sqlx_repository_i64_from_u64( - SQLITE_BACKEND, - after_version, - "snapshot tail lower bound", - SIGNED_INTEGER_STORAGE, - )?; - let rows = sqlx::query( - r#" - SELECT event_name, event_version, payload, payload_codec, - payload_codec_version, metadata, sequence, recorded_at - FROM aggregate_events - WHERE aggregate_type = ? AND aggregate_id = ? AND sequence > ? - ORDER BY sequence ASC - "#, - ) - .bind(identity.aggregate_type()) - .bind(identity.aggregate_id()) - .bind(after) - .fetch_all(&self.pool) - .await - .map_err(|err| repository_storage_error("load stream tail", err))?; - - // An empty tail is ambiguous from this query alone (no rows could - // mean "snapshot is current" or "stream does not exist"). The - // snapshot hydrate path only calls this after confirming a snapshot - // exists for the identity, so an empty tail means the snapshot is - // current. Return an entity at exactly `after_version`. - let mut events = Vec::with_capacity(rows.len()); - for row in rows { - events.push(event_from_row(row)?); - } - - let mut entity = Entity::new(); - entity.set_id(identity.aggregate_id()); - entity.load_tail_from_history(events, after_version); - Ok(Some(entity)) - } + fn inbox_purge_query(age: Duration) -> QueryBuilder { + // `processed_at` defaults to CURRENT_TIMESTAMP (UTC `YYYY-MM-DD + // HH:MM:SS`), so compare against the database clock via + // `datetime('now', '-N seconds')`. + let mut builder = + QueryBuilder::new("DELETE FROM consumer_inbox WHERE processed_at < datetime('now', "); + builder.push_bind(format!("-{} seconds", age.as_secs())); + builder.push(")"); + builder } -} - -impl TransactionalCommit for SqliteRepository { - fn commit_batch<'a>( - &'a self, - batch: CommitBatch<'a>, - ) -> impl Future> + Send + 'a { - async move { - reject_duplicate_streams(&batch.streams)?; - reject_duplicate_outbox_messages(&batch.outbox_messages)?; - validate_entity_id_matches_identity(&batch.streams)?; - let prepared = batch - .streams - .iter() - .map(PreparedEventAppend::from_stream_write) - .collect::>(); - validate_prepared_appends(&prepared)?; - - for plan in &batch.read_model_plans { - validate_sql_write_plan(plan)?; - } - - let mut tx = self - .pool - .begin() - .await - .map_err(|err| repository_storage_error("begin commit transaction", err))?; - - for append in &prepared { - let actual = stream_version_in_tx(&mut tx, &append.identity).await?; - if actual != append.expected_version { - return Err(RepositoryError::ConcurrentWrite { - id: append.identity.to_string(), - expected: append.expected_version, - actual, - }); - } - } - - insert_events_in_tx(&mut tx, &prepared).await?; - - insert_outbox_messages_in_tx(&mut tx, &batch.outbox_messages).await?; - - for plan in batch.read_model_plans { - apply_read_model_write_plan_in_tx(&mut tx, plan).await?; - } - - for write in batch.snapshots { - match write { - SnapshotWrite::Save { identity, record } => { - save_snapshot_in_tx(&mut tx, &identity, record).await?; - } - } - } - - for receipt in &batch.inbox_receipts { - insert_inbox_receipt_in_tx(&mut tx, receipt).await?; - } - - tx.commit() - .await - .map_err(|err| repository_storage_error("commit transaction", err))?; - - for stream in batch.streams { - stream.entity.mark_committed(); - } - - Ok(()) - } - } -} - -impl InboxStore for SqliteRepository { - fn inbox_contains<'a>( - &'a self, - consumer: &'a str, - message_id: &'a str, - ) -> impl Future> + Send + 'a { - async move { - let row = sqlx::query( - "SELECT 1 FROM consumer_inbox WHERE consumer = ? AND message_id = ? LIMIT 1", - ) - .bind(consumer) - .bind(message_id) - .fetch_optional(&self.pool) - .await - .map_err(|err| repository_storage_error("query consumer inbox", err))?; - Ok(row.is_some()) - } - } - - fn purge_inbox_older_than( - &self, - age: std::time::Duration, - ) -> impl Future> + Send { - async move { - // `processed_at` defaults to CURRENT_TIMESTAMP (UTC `YYYY-MM-DD - // HH:MM:SS`), so compare against the database clock via - // `datetime('now', '-N seconds')` — no client/server skew. - let modifier = format!("-{} seconds", age.as_secs()); - let result = - sqlx::query("DELETE FROM consumer_inbox WHERE processed_at < datetime('now', ?)") - .bind(modifier) - .execute(&self.pool) - .await - .map_err(|err| repository_storage_error("purge consumer inbox", err))?; - Ok(result.rows_affected()) - } - } -} - -impl ReadModelWritePlanStore for SqliteRepository { - fn read_model_capabilities(&self) -> TableAdapterCapabilities { - sql_read_model_capabilities() - } - - fn commit_write_plan( - &self, - plan: TableWritePlan, - ) -> impl Future> + Send + '_ { - async move { commit_read_model_write_plan(&self.pool, plan).await } - } -} - -impl RelationalReadModelQueryStore for SqliteRepository { - fn read_model_query_capabilities(&self) -> ReadModelQueryCapabilities { - ReadModelQueryCapabilities::relationship_includes() - } - - fn load_graph( - &self, - request: ReadModelLoadRequest, - ) -> impl Future> + Send + '_ { - async move { - load_read_model_graph( - &self.pool, - &self.read_model_schemas, - request, - self.read_model_query_capabilities(), - ) - .await - } - } -} - -impl OutboxStore for SqliteOutboxStore { - fn messages_by_status( - &self, - status: OutboxMessageStatus, - ) -> impl Future, RepositoryError>> + Send + '_ { - async move { - let rows = sqlx::query( - r#" - SELECT message_id, event_type, payload, payload_codec, payload_codec_version, - metadata, status, created_at, - claimed_by, claimed_until, attempts, last_error, destination, - source_aggregate_type, source_aggregate_id, source_sequence, - correlation_id, causation_id - FROM outbox_messages - WHERE status = ? - ORDER BY CAST(created_at AS REAL) ASC, message_id ASC - "#, - ) - .bind(status.as_str()) - .fetch_all(&self.pool) - .await - .map_err(|err| repository_storage_error("load outbox messages by status", err))?; - - rows.into_iter().map(outbox_message_from_row).collect() - } - } - - fn claim<'a>( - &'a self, + async fn claim_outbox( + pool: &SqlitePool, request: ClaimOutboxMessages, - ) -> impl Future, RepositoryError>> + Send + 'a { - async move { + ) -> Result, RepositoryError> { + { if request.batch_size == 0 { return Ok(Vec::new()); } let now = SystemTime::now(); - let now_epoch = system_time_to_epoch_secs(now)?; + let now_epoch = system_time_epoch_secs::(now)?; let claimed_until = now.checked_add(request.lease).ok_or_else(|| { RepositoryError::Model("failed to compute outbox lease deadline".into()) })?; let claimed_until_storage = system_time_to_storage(claimed_until)?; - let mut tx = - self.pool.begin().await.map_err(|err| { - repository_storage_error("begin outbox claim transaction", err) - })?; + let mut tx = pool + .begin() + .await + .map_err(|err| repository_storage_error("begin outbox claim transaction", err))?; // Explicit ids (after-commit immediate dispatch) bypass the ordered // candidate scan; the per-id conditional UPDATE below still enforces @@ -634,7 +280,7 @@ impl OutboxStore for SqliteOutboxStore { continue; } - if let Some(message) = outbox_message_by_id_in_tx(&mut tx, &message_id).await? { + if let Some(message) = outbox_message_by_id(&mut *tx, &message_id).await? { claimed.push(message); } } @@ -645,787 +291,6 @@ impl OutboxStore for SqliteOutboxStore { Ok(claimed) } } - - fn complete<'a>( - &'a self, - claim: &'a OutboxClaimRef, - ) -> impl Future> + Send + 'a { - async move { - let now = SystemTime::now(); - let now_epoch = system_time_to_epoch_secs(now)?; - let result = sqlx::query( - r#" - UPDATE outbox_messages - SET status = ?, - claimed_by = NULL, - claimed_until = NULL, - published_at = ?, - updated_at = CURRENT_TIMESTAMP - WHERE message_id = ? - AND status = ? - AND claimed_by = ? - AND claimed_until IS NOT NULL - AND CAST(claimed_until AS REAL) > ? - AND attempts = ? - "#, - ) - .bind(OutboxMessageStatus::Published.as_str()) - .bind(system_time_to_storage(now)?) - .bind(&claim.message_id) - .bind(OutboxMessageStatus::InFlight.as_str()) - .bind(&claim.worker_id) - .bind(now_epoch) - .bind(sqlx_repository_i64_from_u64( - SQLITE_BACKEND, - u64::from(claim.attempt), - "outbox claim attempt", - SIGNED_INTEGER_STORAGE, - )?) - .execute(&self.pool) - .await - .map_err(|err| repository_storage_error("complete outbox message", err))?; - - ensure_outbox_update_applied( - &self.pool, - result.rows_affected(), - &claim.message_id, - |message| ensure_active_claim(message, Some(claim), now), - ) - .await - } - } - - /// Batched complete: one transaction (one commit/fsync) for the whole - /// batch instead of one per row. Rows are still validated per claim with - /// the same predicate as [`complete`]; the first claim that does not - /// apply stops the loop, the claims already applied are committed (as in - /// the serial loop), and the same `NotFound`/`InvalidState` diagnosis is - /// surfaced. - /// - /// [`complete`]: OutboxStore::complete - fn complete_many<'a>( - &'a self, - claims: &'a [OutboxClaimRef], - ) -> impl Future> + Send + 'a { - async move { - if claims.is_empty() { - return Ok(()); - } - let now = SystemTime::now(); - let now_epoch = system_time_to_epoch_secs(now)?; - let published_at = system_time_to_storage(now)?; - - let mut tx = self.pool.begin().await.map_err(|err| { - repository_storage_error("begin outbox complete transaction", err) - })?; - let mut unapplied = None; - for claim in claims { - let result = sqlx::query( - r#" - UPDATE outbox_messages - SET status = ?, - claimed_by = NULL, - claimed_until = NULL, - published_at = ?, - updated_at = CURRENT_TIMESTAMP - WHERE message_id = ? - AND status = ? - AND claimed_by = ? - AND claimed_until IS NOT NULL - AND CAST(claimed_until AS REAL) > ? - AND attempts = ? - "#, - ) - .bind(OutboxMessageStatus::Published.as_str()) - .bind(&published_at) - .bind(&claim.message_id) - .bind(OutboxMessageStatus::InFlight.as_str()) - .bind(&claim.worker_id) - .bind(now_epoch) - .bind(sqlx_repository_i64_from_u64( - SQLITE_BACKEND, - u64::from(claim.attempt), - "outbox claim attempt", - SIGNED_INTEGER_STORAGE, - )?) - .execute(&mut *tx) - .await - .map_err(|err| repository_storage_error("complete outbox message", err))?; - - if result.rows_affected() == 0 { - unapplied = Some(claim); - break; - } - } - tx.commit().await.map_err(|err| { - repository_storage_error("commit outbox complete transaction", err) - })?; - - if let Some(claim) = unapplied { - ensure_outbox_update_applied(&self.pool, 0, &claim.message_id, |message| { - ensure_active_claim(message, Some(claim), now) - }) - .await?; - } - Ok(()) - } - } - - fn release<'a>( - &'a self, - claim: &'a OutboxClaimRef, - error: &'a str, - ) -> impl Future> + Send + 'a { - async move { - let now = SystemTime::now(); - let now_epoch = system_time_to_epoch_secs(now)?; - let now_storage = system_time_to_storage(now)?; - let result = sqlx::query( - r#" - UPDATE outbox_messages - SET status = ?, - claimed_by = NULL, - claimed_until = NULL, - next_available_at = ?, - last_error = ?, - updated_at = CURRENT_TIMESTAMP - WHERE message_id = ? - AND status = ? - AND claimed_by = ? - AND claimed_until IS NOT NULL - AND CAST(claimed_until AS REAL) > ? - AND attempts = ? - "#, - ) - .bind(OutboxMessageStatus::Pending.as_str()) - .bind(now_storage) - .bind(empty_string_as_none(error)) - .bind(&claim.message_id) - .bind(OutboxMessageStatus::InFlight.as_str()) - .bind(&claim.worker_id) - .bind(now_epoch) - .bind(sqlx_repository_i64_from_u64( - SQLITE_BACKEND, - u64::from(claim.attempt), - "outbox claim attempt", - SIGNED_INTEGER_STORAGE, - )?) - .execute(&self.pool) - .await - .map_err(|err| repository_storage_error("release outbox message", err))?; - - ensure_outbox_update_applied( - &self.pool, - result.rows_affected(), - &claim.message_id, - |message| ensure_active_claim(message, Some(claim), now), - ) - .await - } - } - - fn fail<'a>( - &'a self, - claim: &'a OutboxClaimRef, - error: &'a str, - ) -> impl Future> + Send + 'a { - async move { - let now = SystemTime::now(); - let now_epoch = system_time_to_epoch_secs(now)?; - let result = sqlx::query( - r#" - UPDATE outbox_messages - SET status = ?, - claimed_by = NULL, - claimed_until = NULL, - last_error = ?, - failed_at = ?, - updated_at = CURRENT_TIMESTAMP - WHERE message_id = ? - AND status = ? - AND claimed_by = ? - AND claimed_until IS NOT NULL - AND CAST(claimed_until AS REAL) > ? - AND attempts = ? - "#, - ) - .bind(OutboxMessageStatus::Failed.as_str()) - .bind(empty_string_as_none(error)) - .bind(system_time_to_storage(now)?) - .bind(&claim.message_id) - .bind(OutboxMessageStatus::InFlight.as_str()) - .bind(&claim.worker_id) - .bind(now_epoch) - .bind(sqlx_repository_i64_from_u64( - SQLITE_BACKEND, - u64::from(claim.attempt), - "outbox claim attempt", - SIGNED_INTEGER_STORAGE, - )?) - .execute(&self.pool) - .await - .map_err(|err| repository_storage_error("fail outbox message", err))?; - - ensure_outbox_update_applied( - &self.pool, - result.rows_affected(), - &claim.message_id, - |message| ensure_active_claim(message, Some(claim), now), - ) - .await - } - } -} - -impl SnapshotStore for SqliteRepository { - fn get_snapshot<'a>( - &'a self, - identity: &'a StreamIdentity, - ) -> impl Future, RepositoryError>> + Send + 'a { - async move { - let row = sqlx::query( - r#" - SELECT aggregate_type, aggregate_id, version, - snapshot_version, payload, payload_codec, - payload_codec_version, metadata, recorded_at - FROM aggregate_snapshots - WHERE aggregate_type = ? AND aggregate_id = ? - "#, - ) - .bind(identity.aggregate_type()) - .bind(identity.aggregate_id()) - .fetch_optional(&self.pool) - .await - .map_err(|err| repository_storage_error("load snapshot", err))?; - - let Some(row) = row else { - return Ok(None); - }; - - Ok(Some(snapshot_from_row(row)?)) - } - } - - fn save_snapshot<'a>( - &'a self, - identity: &'a StreamIdentity, - record: SnapshotRecord, - ) -> impl Future> + Send + 'a { - async move { - let mut tx = self - .pool - .begin() - .await - .map_err(|err| repository_storage_error("begin snapshot transaction", err))?; - save_snapshot_in_tx(&mut tx, identity, record).await?; - tx.commit() - .await - .map_err(|err| repository_storage_error("commit snapshot transaction", err))?; - Ok(()) - } - } - - fn delete_snapshot<'a>( - &'a self, - identity: &'a StreamIdentity, - ) -> impl Future> + Send + 'a { - async move { - let result = sqlx::query( - r#" - DELETE FROM aggregate_snapshots - WHERE aggregate_type = ? AND aggregate_id = ? - "#, - ) - .bind(identity.aggregate_type()) - .bind(identity.aggregate_id()) - .execute(&self.pool) - .await - .map_err(|err| repository_storage_error("delete snapshot", err))?; - - Ok(result.rows_affected() > 0) - } - } -} - -/// Record a consumer inbox receipt in the commit transaction. The -/// `(consumer, message_id)` primary key is the dedupe gate: a unique violation -/// means the message was already processed, so the whole batch rolls back and the -/// effects are not double-applied. `processed_at` defaults to `CURRENT_TIMESTAMP`. -async fn insert_inbox_receipt_in_tx( - tx: &mut Transaction<'_, Sqlite>, - receipt: &InboxReceipt, -) -> Result<(), RepositoryError> { - receipt.validate()?; - let result = sqlx::query("INSERT INTO consumer_inbox (consumer, message_id) VALUES (?, ?)") - .bind(&receipt.consumer) - .bind(&receipt.message_id) - .execute(&mut **tx) - .await; - match result { - Ok(_) => Ok(()), - Err(err) if is_sqlite_unique_constraint(&err) => { - Err(RepositoryError::DuplicateInboxReceipt { - consumer: receipt.consumer.clone(), - message_id: receipt.message_id.clone(), - }) - } - Err(err) => Err(repository_storage_error( - "insert consumer inbox receipt", - err, - )), - } -} - -/// Insert every outbox message with multi-row INSERTs (chunked to respect -/// SQLite's bound-parameter limit). A unique constraint violation on -/// `message_id` still maps to `DuplicateOutboxMessageInBatch`. -async fn insert_outbox_messages_in_tx( - tx: &mut Transaction<'_, Sqlite>, - messages: &[OutboxMessage], -) -> Result<(), RepositoryError> { - struct OutboxRow<'a> { - message_id: &'a str, - event_type: &'a str, - payload: &'a [u8], - payload_codec: &'a str, - payload_codec_version: i64, - destination: Option<&'a str>, - metadata: String, - status: &'a str, - created_at: String, - worker_id: Option<&'a str>, - leased_until: Option, - attempts: i64, - last_error: Option<&'a str>, - source_aggregate_type: Option<&'a str>, - source_aggregate_id: Option<&'a str>, - source_sequence: Option, - correlation_id: Option<&'a str>, - causation_id: Option<&'a str>, - } - - let mut rows = Vec::with_capacity(messages.len()); - for message in messages { - rows.push(OutboxRow { - message_id: message.id(), - event_type: &message.event_type, - payload: &message.payload, - payload_codec: &message.payload_codec, - payload_codec_version: i64::from(message.payload_codec_version), - destination: message.destination.as_deref(), - metadata: serialize_event_metadata(&message.metadata)?, - status: message.status.as_str(), - created_at: system_time_to_storage(message.created_at)?, - worker_id: message.worker_id.as_deref(), - leased_until: message - .leased_until - .map(system_time_to_storage) - .transpose()?, - attempts: i64::from(message.attempts), - last_error: message.last_error.as_deref(), - source_aggregate_type: message.source_aggregate_type.as_deref(), - source_aggregate_id: message.source_aggregate_id.as_deref(), - source_sequence: message - .source_sequence - .map(|value| { - sqlx_repository_i64_from_u64( - SQLITE_BACKEND, - value, - "outbox source sequence", - SIGNED_INTEGER_STORAGE, - ) - }) - .transpose()?, - correlation_id: message.correlation_id(), - causation_id: message.causation_id(), - }); - } - - for chunk in rows.chunks(SQLITE_MAX_BIND_PARAMS / OUTBOX_BIND_COLUMNS) { - let mut builder = QueryBuilder::::new( - "INSERT INTO outbox_messages (\ - message_id, event_type, payload, payload_codec, payload_codec_version, \ - destination, metadata, status, created_at, next_available_at, \ - claimed_by, claimed_until, attempts, last_error, source_aggregate_type, \ - source_aggregate_id, source_sequence, correlation_id, causation_id) ", - ); - builder.push_values(chunk, |mut row, message| { - row.push_bind(message.message_id) - .push_bind(message.event_type) - .push_bind(message.payload) - .push_bind(message.payload_codec) - .push_bind(message.payload_codec_version) - .push_bind(message.destination) - .push_bind(message.metadata.as_str()) - .push_bind(message.status) - .push_bind(message.created_at.as_str()) - // created_at and next_available_at share the same value. - .push_bind(message.created_at.as_str()) - .push_bind(message.worker_id) - .push_bind(message.leased_until.as_deref()) - .push_bind(message.attempts) - .push_bind(message.last_error) - .push_bind(message.source_aggregate_type) - .push_bind(message.source_aggregate_id) - .push_bind(message.source_sequence) - .push_bind(message.correlation_id) - .push_bind(message.causation_id); - }); - - let result = builder.build().execute(&mut **tx).await; - if let Err(err) = result { - if is_sqlite_unique_constraint(&err) { - // The batch was already deduped, so a violation means the id - // collides with a previously committed row. Report the first id - // in the chunk, matching the per-row path's contract. - return Err(RepositoryError::DuplicateOutboxMessageInBatch { - id: chunk[0].message_id.to_string(), - }); - } - return Err(repository_storage_error("insert outbox messages", err)); - } - } - - Ok(()) -} - -async fn outbox_message_by_id_pool( - pool: &SqlitePool, - message_id: &str, -) -> Result, RepositoryError> { - let row = sqlx::query(outbox_message_select_sql()) - .bind(message_id) - .fetch_optional(pool) - .await - .map_err(|err| repository_storage_error("load outbox message", err))?; - row.map(outbox_message_from_row).transpose() -} - -async fn outbox_message_by_id_in_tx( - tx: &mut Transaction<'_, Sqlite>, - message_id: &str, -) -> Result, RepositoryError> { - let row = sqlx::query(outbox_message_select_sql()) - .bind(message_id) - .fetch_optional(&mut **tx) - .await - .map_err(|err| repository_storage_error("load outbox message", err))?; - row.map(outbox_message_from_row).transpose() -} - -fn outbox_message_select_sql() -> &'static str { - r#" - SELECT message_id, event_type, payload, payload_codec, payload_codec_version, - metadata, status, created_at, - claimed_by, claimed_until, attempts, last_error, destination, - source_aggregate_type, source_aggregate_id, source_sequence, - correlation_id, causation_id - FROM outbox_messages - WHERE message_id = ? - "# -} - -fn outbox_message_from_row(row: sqlx::sqlite::SqliteRow) -> Result { - let status_text: String = row - .try_get("status") - .map_err(|err| repository_storage_error("decode outbox status row", err))?; - let status = status_text.parse::().map_err(|_| { - RepositoryError::Model(format!("sqlite outbox status `{status_text}` is invalid")) - })?; - let metadata_json: String = row - .try_get("metadata") - .map_err(|err| repository_storage_error("decode outbox metadata row", err))?; - let mut message = OutboxMessage::new(); - let message_id: String = row - .try_get("message_id") - .map_err(|err| repository_storage_error("decode outbox message id row", err))?; - message.id = message_id; - message.event_type = row - .try_get("event_type") - .map_err(|err| repository_storage_error("decode outbox event type row", err))?; - message.payload = row - .try_get("payload") - .map_err(|err| repository_storage_error("decode outbox payload row", err))?; - message.payload_codec = row - .try_get("payload_codec") - .map_err(|err| repository_storage_error("decode outbox payload codec row", err))?; - let payload_codec_version: i64 = row - .try_get("payload_codec_version") - .map_err(|err| repository_storage_error("decode outbox payload codec version row", err))?; - message.payload_codec_version = sqlx_repository_u16_from_i64( - SQLITE_BACKEND, - payload_codec_version, - "outbox payload codec version", - )?; - message.metadata = deserialize_event_metadata(&metadata_json)?; - message.status = status; - message.created_at = system_time_from_storage( - row.try_get::("created_at") - .map_err(|err| repository_storage_error("decode outbox created_at row", err))? - .as_str(), - ); - message.worker_id = row - .try_get("claimed_by") - .map_err(|err| repository_storage_error("decode outbox claimed_by row", err))?; - message.leased_until = row - .try_get::, _>("claimed_until") - .map_err(|err| repository_storage_error("decode outbox claimed_until row", err))? - .as_deref() - .map(system_time_from_storage); - let attempts: i64 = row - .try_get("attempts") - .map_err(|err| repository_storage_error("decode outbox attempts row", err))?; - message.attempts = u32::try_from(attempts).map_err(|_| { - RepositoryError::Model(format!( - "sqlite outbox attempts value {attempts} is invalid" - )) - })?; - message.last_error = row - .try_get("last_error") - .map_err(|err| repository_storage_error("decode outbox last_error row", err))?; - message.destination = row - .try_get("destination") - .map_err(|err| repository_storage_error("decode outbox destination row", err))?; - message.source_aggregate_type = row - .try_get("source_aggregate_type") - .map_err(|err| repository_storage_error("decode outbox source aggregate type row", err))?; - message.source_aggregate_id = row - .try_get("source_aggregate_id") - .map_err(|err| repository_storage_error("decode outbox source aggregate id row", err))?; - message.source_sequence = row - .try_get::, _>("source_sequence") - .map_err(|err| repository_storage_error("decode outbox source sequence row", err))? - .map(|value| sqlx_repository_u64_from_i64(SQLITE_BACKEND, value, "outbox source sequence")) - .transpose()?; - if let Some(correlation_id) = row - .try_get::, _>("correlation_id") - .map_err(|err| repository_storage_error("decode outbox correlation_id row", err))? - { - message.set_correlation_id(correlation_id); - } - if let Some(causation_id) = row - .try_get::, _>("causation_id") - .map_err(|err| repository_storage_error("decode outbox causation_id row", err))? - { - message.set_causation_id(causation_id); - } - Ok(message) -} - -async fn ensure_outbox_update_applied( - pool: &SqlitePool, - rows_affected: u64, - message_id: &str, - validate: impl FnOnce(&OutboxMessage) -> Result<(), RepositoryError>, -) -> Result<(), RepositoryError> { - if rows_affected > 0 { - return Ok(()); - } - - let message = outbox_message_by_id_pool(pool, message_id) - .await? - .ok_or_else(|| RepositoryError::NotFound { - id: message_id.to_string(), - })?; - validate(&message) -} - -fn default_pool_size(database_url: &str) -> u32 { - if database_url.contains(":memory:") { - 1 - } else { - 5 - } -} - -async fn stream_version_in_tx( - tx: &mut Transaction<'_, Sqlite>, - identity: &StreamIdentity, -) -> Result { - let row = sqlx::query( - r#" - SELECT MAX(sequence) AS version - FROM aggregate_events - WHERE aggregate_type = ? AND aggregate_id = ? - "#, - ) - .bind(identity.aggregate_type()) - .bind(identity.aggregate_id()) - .fetch_one(&mut **tx) - .await - .map_err(|err| repository_storage_error("load stream version", err))?; - - let version: Option = row - .try_get("version") - .map_err(|err| repository_storage_error("decode stream version row", err))?; - version - .map(|value| sqlx_repository_u64_from_i64(SQLITE_BACKEND, value, "sequence")) - .unwrap_or(Ok(0)) -} - -/// Maximum bound parameters per statement. SQLite's historical limit is 999, so -/// staying under it keeps the batched inserts portable across SQLite builds. -const SQLITE_MAX_BIND_PARAMS: usize = 900; - -/// Bound parameters per `aggregate_events` row. -const EVENT_BIND_COLUMNS: usize = 10; - -/// Bound parameters per `outbox_messages` row. -const OUTBOX_BIND_COLUMNS: usize = 19; - -/// Insert every event across all prepared appends with multi-row INSERTs -/// (chunked to respect SQLite's bound-parameter limit). -/// -/// Conflict detection is unchanged from the per-row path: the `(aggregate_type, -/// aggregate_id, sequence)` primary key is the contiguity gate, and a unique -/// constraint violation still surfaces as `ConcurrentWrite`. SQLite does not -/// abort the transaction on a constraint error, so the conflicting stream's -/// actual version is re-read in the same transaction, exactly as before. -async fn insert_events_in_tx( - tx: &mut Transaction<'_, Sqlite>, - prepared: &[PreparedEventAppend], -) -> Result<(), RepositoryError> { - struct EventRow<'a> { - identity: &'a StreamIdentity, - expected_version: u64, - sequence: i64, - event_name: &'a str, - event_version: i64, - payload: &'a [u8], - payload_codec: &'a str, - payload_codec_version: i64, - metadata: String, - recorded_at: String, - } - - let mut rows = Vec::new(); - for append in prepared { - for event in &append.events { - rows.push(EventRow { - identity: &append.identity, - expected_version: append.expected_version, - sequence: sqlx_repository_i64_from_u64( - SQLITE_BACKEND, - event.sequence, - "sequence", - SIGNED_INTEGER_STORAGE, - )?, - event_name: &event.event_name, - event_version: sqlx_repository_i64_from_u64( - SQLITE_BACKEND, - event.event_version, - "event_version", - SIGNED_INTEGER_STORAGE, - )?, - payload: &event.payload, - payload_codec: &event.payload_codec, - payload_codec_version: i64::from(event.payload_codec_version), - metadata: serialize_event_metadata(&event.metadata)?, - recorded_at: system_time_to_storage(event.timestamp)?, - }); - } - } - - for chunk in rows.chunks(SQLITE_MAX_BIND_PARAMS / EVENT_BIND_COLUMNS) { - let mut builder = QueryBuilder::::new( - "INSERT INTO aggregate_events (\ - aggregate_type, aggregate_id, sequence, event_name, event_version, \ - payload, payload_codec, payload_codec_version, metadata, recorded_at) ", - ); - builder.push_values(chunk, |mut row, event| { - row.push_bind(event.identity.aggregate_type()) - .push_bind(event.identity.aggregate_id()) - .push_bind(event.sequence) - .push_bind(event.event_name) - .push_bind(event.event_version) - .push_bind(event.payload) - .push_bind(event.payload_codec) - .push_bind(event.payload_codec_version) - .push_bind(event.metadata.as_str()) - .push_bind(event.recorded_at.as_str()); - }); - - let result = builder.build().execute(&mut **tx).await; - if let Err(err) = result { - if is_sqlite_unique_constraint(&err) { - // Find the conflicting stream (its actual version no longer - // matches its expected version) and report it. - for event in chunk { - let actual = stream_version_in_tx(tx, event.identity).await?; - if actual != event.expected_version { - return Err(RepositoryError::ConcurrentWrite { - id: event.identity.to_string(), - expected: event.expected_version, - actual, - }); - } - } - let event = &chunk[0]; - let actual = stream_version_in_tx(tx, event.identity).await?; - return Err(RepositoryError::ConcurrentWrite { - id: event.identity.to_string(), - expected: event.expected_version, - actual, - }); - } - return Err(repository_storage_error("insert events", err)); - } - } - - Ok(()) -} - -fn entity_from_events(aggregate_id: String, events: Vec) -> Entity { - let mut entity = Entity::new(); - entity.set_id(aggregate_id); - entity.load_from_history(events); - entity -} - -fn event_from_row(row: sqlx::sqlite::SqliteRow) -> Result { - let payload_codec: String = row - .try_get("payload_codec") - .map_err(|err| repository_storage_error("decode payload codec row", err))?; - let payload_codec_version = sqlx_repository_u16_from_i64( - SQLITE_BACKEND, - row.try_get("payload_codec_version") - .map_err(|err| repository_storage_error("decode payload codec version row", err))?, - "payload_codec_version", - )?; - let metadata_json: String = row - .try_get("metadata") - .map_err(|err| repository_storage_error("decode metadata row", err))?; - let metadata = deserialize_event_metadata(&metadata_json)?; - let event = EventRecord { - event_name: row - .try_get("event_name") - .map_err(|err| repository_storage_error("decode event name row", err))?, - payload_codec, - payload_codec_version, - payload: row - .try_get("payload") - .map_err(|err| repository_storage_error("decode payload row", err))?, - event_version: sqlx_repository_u64_from_i64( - SQLITE_BACKEND, - row.try_get("event_version") - .map_err(|err| repository_storage_error("decode event version row", err))?, - "event_version", - )?, - sequence: sqlx_repository_u64_from_i64( - SQLITE_BACKEND, - row.try_get("sequence") - .map_err(|err| repository_storage_error("decode sequence row", err))?, - "sequence", - )?, - timestamp: system_time_from_storage( - row.try_get::("recorded_at") - .map_err(|err| repository_storage_error("decode recorded_at row", err))? - .as_str(), - ), - metadata, - }; - validate_supported_event_codec(&event)?; - Ok(event) } impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Sqlite { @@ -1435,8 +300,8 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Sqlite { fn push_row_value_bind( builder: &mut QueryBuilder, value: RowValue, - column: &TableColumn, - ) -> Result<(), TableStoreError> { + column: &ColumnDef, + ) -> Result<(), ReadModelError> { match value { RowValue::Null => Self::push_null_bind(builder, column)?, RowValue::Bool(value) => { @@ -1464,7 +329,7 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Sqlite { } RowValue::Json(value) => { let payload = serde_json::to_string(&value) - .map_err(|err| TableStoreError::Serde(err.to_string()))?; + .map_err(|err| ReadModelError::Serde(err.to_string()))?; builder.push_bind(payload); } } @@ -1473,8 +338,8 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Sqlite { fn push_null_bind( builder: &mut QueryBuilder, - column: &TableColumn, - ) -> Result<(), TableStoreError> { + column: &ColumnDef, + ) -> Result<(), ReadModelError> { match &column.column_type { ColumnType::Text | ColumnType::Json | ColumnType::Timestamp => { builder.push_bind(Option::::None); @@ -1489,9 +354,9 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Sqlite { builder.push_bind(Option::>::None); } ColumnType::Unsupported(type_name) => { - return Err(TableStoreError::Metadata(format!( - "read model column `{}` has unsupported type `{}`", - column.column_name, type_name + return Err(ReadModelError::Metadata(format!( + "read model `{}` column `{}` has unsupported type `{}`", + column.field_name, column.column_name, type_name ))); } } @@ -1502,11 +367,11 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Sqlite { result.rows_affected() } - fn push_select_column(builder: &mut QueryBuilder, column: &TableColumn) { + fn push_select_column(builder: &mut QueryBuilder, column: &ColumnDef) { builder.push(quote_identifier(&column.column_name)); } - fn row_value(row: &SqliteRow, column: &TableColumn) -> Result { + fn row_value(row: &SqliteRow, column: &ColumnDef) -> Result { Ok(match column.column_type { ColumnType::Text | ColumnType::Timestamp => row .try_get::, _>(column.column_name.as_str()) @@ -1550,12 +415,12 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Sqlite { .map(|payload| { serde_json::from_str(&payload) .map(RowValue::Json) - .map_err(|err| TableStoreError::Serde(err.to_string())) + .map_err(|err| ReadModelError::Serde(err.to_string())) }) .transpose()? .unwrap_or(RowValue::Null), ColumnType::Unsupported(ref type_name) => { - return Err(TableStoreError::Metadata(format!( + return Err(ReadModelError::Metadata(format!( "read model `{}` column `{}` has unsupported type `{}`", column.field_name, column.column_name, type_name ))); @@ -1564,110 +429,6 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Sqlite { } } -async fn save_snapshot_in_tx( - tx: &mut Transaction<'_, Sqlite>, - identity: &StreamIdentity, - record: SnapshotRecord, -) -> Result<(), RepositoryError> { - validate_snapshot_identity(identity, &record)?; - - sqlx::query( - r#" - INSERT INTO aggregate_snapshots ( - aggregate_type, - aggregate_id, - version, - snapshot_version, - payload, - payload_codec, - payload_codec_version, - metadata, - recorded_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(aggregate_type, aggregate_id) DO UPDATE SET - version = excluded.version, - snapshot_version = excluded.snapshot_version, - payload = excluded.payload, - payload_codec = excluded.payload_codec, - payload_codec_version = excluded.payload_codec_version, - metadata = excluded.metadata, - recorded_at = excluded.recorded_at, - updated_at = CURRENT_TIMESTAMP - "#, - ) - .bind(identity.aggregate_type()) - .bind(identity.aggregate_id()) - .bind(sqlx_repository_i64_from_u64( - SQLITE_BACKEND, - record.version, - "snapshot version", - SIGNED_INTEGER_STORAGE, - )?) - .bind(sqlx_repository_i64_from_u64( - SQLITE_BACKEND, - record.snapshot_version, - "snapshot payload version", - SIGNED_INTEGER_STORAGE, - )?) - .bind(&record.payload) - .bind(&record.payload_codec) - .bind(i64::from(record.payload_codec_version)) - .bind(serialize_event_metadata(&record.metadata)?) - .bind(system_time_to_storage(record.recorded_at)?) - .execute(&mut **tx) - .await - .map_err(|err| repository_storage_error("save snapshot", err))?; - - Ok(()) -} - -fn snapshot_from_row(row: sqlx::sqlite::SqliteRow) -> Result { - let metadata_json: String = row - .try_get("metadata") - .map_err(|err| repository_storage_error("decode snapshot metadata row", err))?; - Ok(SnapshotRecord { - aggregate_type: row - .try_get("aggregate_type") - .map_err(|err| repository_storage_error("decode snapshot aggregate type row", err))?, - aggregate_id: row - .try_get("aggregate_id") - .map_err(|err| repository_storage_error("decode snapshot aggregate id row", err))?, - version: sqlx_repository_u64_from_i64( - SQLITE_BACKEND, - row.try_get("version") - .map_err(|err| repository_storage_error("decode snapshot version row", err))?, - "snapshot version", - )?, - snapshot_version: sqlx_repository_u64_from_i64( - SQLITE_BACKEND, - row.try_get("snapshot_version").map_err(|err| { - repository_storage_error("decode snapshot payload version row", err) - })?, - "snapshot payload version", - )?, - payload_codec: row - .try_get("payload_codec") - .map_err(|err| repository_storage_error("decode snapshot payload codec row", err))?, - payload_codec_version: sqlx_repository_u16_from_i64( - SQLITE_BACKEND, - row.try_get("payload_codec_version").map_err(|err| { - repository_storage_error("decode snapshot payload codec version row", err) - })?, - "snapshot payload codec version", - )?, - payload: row - .try_get("payload") - .map_err(|err| repository_storage_error("decode snapshot payload row", err))?, - metadata: deserialize_event_metadata(&metadata_json)?, - recorded_at: system_time_from_storage( - row.try_get::("recorded_at") - .map_err(|err| repository_storage_error("decode snapshot recorded_at row", err))? - .as_str(), - ), - }) -} - fn system_time_to_storage(timestamp: SystemTime) -> Result { let duration = timestamp.duration_since(UNIX_EPOCH).map_err(|err| { RepositoryError::Model(format!( @@ -1681,39 +442,22 @@ fn system_time_to_storage(timestamp: SystemTime) -> Result Result { - let duration = timestamp.duration_since(UNIX_EPOCH).map_err(|err| { - RepositoryError::Model(format!( - "event timestamp before UNIX epoch cannot be compared in sqlite: {err}" - )) - })?; - Ok(duration.as_secs_f64()) -} - -fn system_time_from_storage(value: &str) -> SystemTime { - let Some((secs, nanos)) = value.split_once('.') else { - return UNIX_EPOCH; - }; - let Ok(secs) = secs.parse::() else { - return UNIX_EPOCH; - }; - let Ok(nanos) = nanos.parse::() else { - return UNIX_EPOCH; - }; +fn system_time_from_storage(value: &str) -> Result { + let invalid = + || RepositoryError::Model(format!("sqlite stored timestamp `{value}` is invalid")); + let (secs, nanos) = value.split_once('.').ok_or_else(invalid)?; + let secs = secs.parse::().map_err(|_| invalid())?; + let nanos = nanos.parse::().map_err(|_| invalid())?; if nanos >= 1_000_000_000 { - return UNIX_EPOCH; + return Err(invalid()); } - UNIX_EPOCH + Duration::new(secs, nanos) + Ok(UNIX_EPOCH + Duration::new(secs, nanos)) } fn repository_storage_error(operation: &str, err: sqlx::Error) -> RepositoryError { sqlx_repo::repository_storage_error(SQLITE_BACKEND, operation, err) } -fn read_model_storage_error(operation: &str, err: sqlx::Error) -> TableStoreError { +fn read_model_storage_error(operation: &str, err: sqlx::Error) -> ReadModelError { sqlx_repo::read_model_storage_error(SQLITE_BACKEND, operation, err) } - -fn table_schema_storage_error(operation: &str, err: sqlx::Error) -> TableStoreError { - TableStoreError::Storage(format!("{SQLITE_BACKEND} {operation} failed: {err}")) -} diff --git a/src/sqlx_repo/mod.rs b/src/sqlx_repo/mod.rs index 0d12ac02..207ad616 100644 --- a/src/sqlx_repo/mod.rs +++ b/src/sqlx_repo/mod.rs @@ -6,6 +6,8 @@ use crate::table::TableStoreError; #[cfg(any(feature = "postgres", feature = "sqlite"))] pub(crate) mod read_model; +#[cfg(any(feature = "postgres", feature = "sqlite"))] +pub(crate) mod repo; pub(crate) fn serialize_event_metadata( metadata: &HashMap, @@ -32,18 +34,6 @@ pub(crate) fn repository_i64_from_u64( }) } -#[cfg(feature = "postgres")] -pub(crate) fn repository_i32_from_u64( - backend: &str, - value: u64, - field: &str, - storage: &str, -) -> Result { - i32::try_from(value).map_err(|_| { - RepositoryError::Model(format!("{backend} {field} value {value} exceeds {storage}")) - }) -} - pub(crate) fn repository_u64_from_i64( backend: &str, value: i64, @@ -53,17 +43,7 @@ pub(crate) fn repository_u64_from_i64( .map_err(|_| RepositoryError::Model(format!("{backend} {field} value {value} is negative"))) } -#[cfg(feature = "postgres")] -pub(crate) fn repository_u64_from_i32( - backend: &str, - value: i32, - field: &str, -) -> Result { - u64::try_from(value) - .map_err(|_| RepositoryError::Model(format!("{backend} {field} value {value} is negative"))) -} - -#[cfg(feature = "sqlite")] +#[cfg(any(feature = "postgres", feature = "sqlite"))] pub(crate) fn repository_u16_from_i64( backend: &str, value: i64, @@ -73,16 +53,6 @@ pub(crate) fn repository_u16_from_i64( .map_err(|_| RepositoryError::Model(format!("{backend} {field} value {value} is invalid"))) } -#[cfg(feature = "postgres")] -pub(crate) fn repository_u16_from_i32( - backend: &str, - value: i32, - field: &str, -) -> Result { - u16::try_from(value) - .map_err(|_| RepositoryError::Model(format!("{backend} {field} value {value} is invalid"))) -} - #[cfg(any(feature = "postgres", feature = "sqlite"))] pub(crate) fn read_model_i64_from_u64( backend: &str, @@ -184,12 +154,24 @@ pub(crate) fn is_sqlx_transient(err: &sqlx::Error) -> bool { if is_sqlite_busy(err) { return true; } - // Postgres serialization_failure (40001) / deadlock_detected (40P01): the - // transaction lost a write race and should be retried, not handed to the - // failure policy. SQLite never carries these SQLSTATEs, so no feature gate. + // Postgres SQLSTATEs that name transient conditions. SQLite never carries + // these codes (its codes are plain integers), so no feature gate. + // - 40001 serialization_failure / 40P01 deadlock_detected: the transaction + // lost a write race and should be retried, not handed to the failure + // policy. + // - 57P01 admin_shutdown / 57P02 crash_shutdown / 57P03 cannot_connect_now: + // the backend was terminated (pg_terminate_backend, failover, restart); + // the statement may succeed once the server recovers. + // - class 08 (connection_exception): the connection died mid-statement. if let sqlx::Error::Database(db_err) = err { - if matches!(db_err.code().as_deref(), Some("40001" | "40P01")) { - return true; + if let Some(code) = db_err.code() { + if matches!( + code.as_ref(), + "40001" | "40P01" | "57P01" | "57P02" | "57P03" + ) || code.starts_with("08") + { + return true; + } } } false @@ -216,3 +198,84 @@ pub(crate) fn read_model_storage_error( ) -> TableStoreError { TableStoreError::Storage(format!("{backend} {operation} failed: {err}")) } + +#[cfg(test)] +mod tests { + use super::is_sqlx_transient; + use std::borrow::Cow; + use std::fmt; + + #[derive(Debug)] + struct StubDatabaseError(&'static str); + + impl fmt::Display for StubDatabaseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "stub database error ({})", self.0) + } + } + + impl std::error::Error for StubDatabaseError {} + + impl sqlx::error::DatabaseError for StubDatabaseError { + fn message(&self) -> &str { + "stub database error" + } + + fn code(&self) -> Option> { + Some(Cow::Borrowed(self.0)) + } + + fn as_error(&self) -> &(dyn std::error::Error + Send + Sync + 'static) { + self + } + + fn as_error_mut(&mut self) -> &mut (dyn std::error::Error + Send + Sync + 'static) { + self + } + + fn into_error(self: Box) -> Box { + self + } + + fn kind(&self) -> sqlx::error::ErrorKind { + sqlx::error::ErrorKind::Other + } + } + + fn database_error(code: &'static str) -> sqlx::Error { + sqlx::Error::Database(Box::new(StubDatabaseError(code))) + } + + #[test] + fn write_races_are_transient() { + assert!(is_sqlx_transient(&database_error("40001"))); + assert!(is_sqlx_transient(&database_error("40P01"))); + } + + #[test] + fn server_shutdown_and_connection_loss_are_transient() { + // pg_terminate_backend / failover / restart-in-progress. + assert!(is_sqlx_transient(&database_error("57P01"))); + assert!(is_sqlx_transient(&database_error("57P02"))); + assert!(is_sqlx_transient(&database_error("57P03"))); + // connection_exception class. + assert!(is_sqlx_transient(&database_error("08000"))); + assert!(is_sqlx_transient(&database_error("08006"))); + } + + #[test] + fn deterministic_failures_are_permanent() { + // unique_violation: re-running the identical statement cannot succeed. + assert!(!is_sqlx_transient(&database_error("23505"))); + // query_canceled (57014) is a deliberate cancellation, not recovery. + assert!(!is_sqlx_transient(&database_error("57014"))); + // RowNotFound-style decode errors are permanent. + assert!(!is_sqlx_transient(&sqlx::Error::RowNotFound)); + } + + #[test] + fn pool_and_io_failures_are_transient() { + assert!(is_sqlx_transient(&sqlx::Error::PoolTimedOut)); + assert!(is_sqlx_transient(&sqlx::Error::PoolClosed)); + } +} diff --git a/src/sqlx_repo/read_model.rs b/src/sqlx_repo/read_model.rs index 8e4b0570..f9bb904c 100644 --- a/src/sqlx_repo/read_model.rs +++ b/src/sqlx_repo/read_model.rs @@ -343,7 +343,7 @@ use super::{read_model_i64_from_u64, read_model_storage_error, read_model_u64_fr /// SQLite stores booleans as `i64` and collapses integer/bool `NULL`s) and the /// backend/storage labels used in numeric-conversion error messages. Those — /// and nothing else — live behind this trait, implemented once per backend. -pub(crate) trait SqlxReadModelBackend: Database { +pub trait SqlxReadModelBackend: Database { /// Backend name used in numeric-conversion error messages (`"postgres"`/`"sqlite"`). const BACKEND: &'static str; /// Human-readable storage label for the signed-64-bit version column. diff --git a/src/sqlx_repo/repo.rs b/src/sqlx_repo/repo.rs new file mode 100644 index 00000000..85a472c0 --- /dev/null +++ b/src/sqlx_repo/repo.rs @@ -0,0 +1,1822 @@ +//! Backend-agnostic event-store, snapshot, outbox, and inbox logic shared by +//! the Postgres and SQLite repositories. +//! +//! This extends the [`SqlxReadModelBackend`](super::read_model::SqlxReadModelBackend) +//! pattern to the whole repository surface: the SQL statements and row codecs +//! are identical across the two backends because `QueryBuilder` renders the +//! right placeholder dialect. What genuinely differs — schema SQL, bind-param +//! chunking, the timestamp codec (Postgres epoch-`f64`/`to_timestamp()` vs +//! SQLite `"secs.nanos"` text), the unique-violation predicate, conflict +//! recovery (SQLite can re-read in the failed transaction; a failed Postgres +//! statement aborts it), and the outbox `claim` strategy — lives behind the +//! [`SqlxRepoBackend`] trait, implemented once per backend. + +#![expect( + clippy::manual_async_fn, + reason = "async trait impls return impl Future + Send to preserve public Send bounds" +)] + +use std::borrow::Cow; +use std::collections::{BTreeMap, HashMap}; +use std::future::Future; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use sqlx::migrate::{Migrate, Migration, MigrationType, Migrator}; +use sqlx::pool::PoolOptions; +use sqlx::query_builder::Separated; +use sqlx::{Encode, Executor, IntoArguments, Pool, QueryBuilder, Row, Transaction, Type}; + +use crate::entity::{Entity, EventRecord, BITCODE_PAYLOAD_CODEC}; +use crate::outbox::{OutboxMessage, OutboxMessageStatus}; +use crate::outbox_worker::{ensure_active_claim, ClaimOutboxMessages, OutboxClaimRef, OutboxStore}; +use crate::read_model::{ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities}; +use crate::table::{ + TableAdapterCapabilities as ReadModelAdapterCapabilities, + TableCommitOutcome as ReadModelCommitOutcome, TableStoreError as ReadModelError, + TableWritePlan as ReadModelWritePlan, +}; +use crate::repository::{ + validate_commit_batch, validate_snapshot_identity, validate_supported_event_codec, CommitBatch, + GetStream, InboxReceipt, InboxStore, PreparedEventAppend, ReadModelWritePlanStore, + RelationalReadModelQueryStore, RepositoryError, SnapshotStore, SnapshotWrite, StreamIdentity, + TransactionalCommit, +}; +use crate::snapshot::SnapshotRecord; +use crate::sqlx_repo::read_model::{ + apply_read_model_write_plan_in_tx, commit_read_model_write_plan, empty_string_as_none, + load_read_model_graph, remember_read_model_schemas, sql_read_model_capabilities, + validate_sql_write_plan, SqlxReadModelBackend, +}; +use crate::sqlx_repo::{ + audited_table_schema_sql, deserialize_event_metadata, repository_i64_from_u64, + repository_u16_from_i64, repository_u64_from_i64, serialize_event_metadata, +}; +use crate::table::{ + generate_table_migration_artifacts, table_schema_bootstrap_result, table_schema_statements, + TableMigrationArtifact, TableSchemaBootstrap, TableSchemaRegistry, TableSqlDialect, + TableSqlSchemaAdapter, TableStoreError, +}; + +/// Build an embedded migrator from statically included migration files +/// (`(version, description, sql)` per file, in order). sqlx's `migrate!` +/// macro would assemble this at compile time but drags in the whole +/// proc-macro stack; here the checksums are computed once at first use, so +/// keep each backend's list in sync with its `migrations/` directory. +pub(crate) fn embedded_migrator(files: &[(i64, &'static str, &'static str)]) -> Migrator { + Migrator::with_migrations( + files + .iter() + .map(|&(version, description, sql)| { + Migration::new( + version, + description.into(), + MigrationType::Simple, + sqlx::SqlSafeStr::into_sql_str(sql), + false, + ) + }) + .collect(), + ) +} + +/// Group stream identities by aggregate type so each type is one id-list +/// round trip instead of a query per identity. Callers issue single-type +/// batches in the common case, so this usually yields one group; the grouping +/// only exists to keep arbitrary mixed-type inputs correct. +fn ids_by_type(identities: &[StreamIdentity]) -> BTreeMap<&str, Vec<&str>> { + let mut groups: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for identity in identities { + groups + .entry(identity.aggregate_type()) + .or_default() + .push(identity.aggregate_id()); + } + groups +} + +/// Bound parameters per `aggregate_events` row. +const EVENT_BIND_COLUMNS: usize = 10; + +/// Bound parameters per `outbox_messages` row. +const OUTBOX_BIND_COLUMNS: usize = 19; + +/// Dialect surface for the shared repository path (event store, snapshots, +/// outbox lifecycle, consumer inbox, schema bootstrap). +/// +/// Everything the two SQL backends genuinely disagree on is an item here; the +/// free functions and the [`SqlxRepository`]/[`SqlxOutboxStore`] impls in this +/// module are the single copy of everything they agree on. +pub trait SqlxRepoBackend: SqlxReadModelBackend { + /// Embedded migrations applied by `migrate_pool`. Runs through + /// `sqlx::migrate::Migrator`, which keeps a `_sqlx_migrations` ledger and + /// executes each migration file whole (the previous hand-rolled runner + /// split files on `;`, which breaks on function bodies and string + /// literals, and kept no record of what had been applied). + fn migrator() -> &'static Migrator; + /// Maximum bound parameters per statement. Multi-row inserts are chunked to + /// stay under this; Postgres is effectively unlimited so chunking collapses + /// to a single statement and both backends share one code path. + const MAX_BIND_PARAMS: usize; + /// Whether event-insert conflict recovery re-reads stream versions inside + /// the failed transaction. SQLite does not abort a transaction on a + /// constraint error, so the re-read can (and must, to see this tx's own + /// earlier chunks) happen in-tx. A failed Postgres statement aborts the + /// transaction, so the re-read runs on a separate pool connection. + const CONFLICT_REREAD_IN_TX: bool; + /// SQL expression producing the database's current timestamp, used for + /// server-side `updated_at` maintenance. + const NOW: &'static str; + /// `SELECT` list for `aggregate_events` rows. The recorded-at column must + /// surface as `recorded_at` in whatever representation + /// [`decode_timestamp`](Self::decode_timestamp) reads. + const EVENT_SELECT: &'static str; + /// `SELECT` list for `aggregate_snapshots` rows (recorded-at as above). + const SNAPSHOT_SELECT: &'static str; + /// `SELECT` list for `outbox_messages` rows (`created_at`/`claimed_until` + /// in the representation `decode_timestamp` reads). + const OUTBOX_SELECT: &'static str; + /// ORDER BY expression for outbox `created_at` ordering (SQLite stores + /// timestamps as text and must cast for numeric ordering). + const ORDER_BY_CREATED_AT: &'static str; + /// Dialect for table/read-model schema artifact generation. + const TABLE_DIALECT: TableSqlDialect; + + /// Owned bind value for a stored timestamp (Postgres: epoch seconds `f64`; + /// SQLite: `"secs.nanos"` text). + type TimestampValue: Send + Sync + 'static; + + /// Pool size when connecting from a database URL. + fn default_pool_size(database_url: &str) -> u32 { + let _ = database_url; + 5 + } + + /// Whether a `sqlx::Error` is this backend's unique-violation error. + fn is_unique_violation(err: &sqlx::Error) -> bool; + + /// Encode a [`SystemTime`] into this backend's stored representation. + fn timestamp_value(timestamp: SystemTime) -> Result; + + /// Push a timestamp value into a separated bind list (Postgres wraps the + /// bind in `to_timestamp(...)`). + fn push_timestamp(sep: &mut Separated<'_, Self, &'static str>, value: &Self::TimestampValue); + + /// Push an optional timestamp value into a separated bind list, binding a + /// typed `NULL` when absent. + fn push_optional_timestamp( + sep: &mut Separated<'_, Self, &'static str>, + value: Option<&Self::TimestampValue>, + ); + + /// Push ` = ` right-hand side into a builder. + fn push_timestamp_assign(builder: &mut QueryBuilder, value: &Self::TimestampValue); + + /// Push ` ` comparing a stored timestamp column against + /// epoch seconds (Postgres: `column op to_timestamp($n)`; SQLite: + /// `CAST(column AS REAL) op ?`). + fn push_timestamp_cmp( + builder: &mut QueryBuilder, + column: &'static str, + op: &'static str, + epoch_secs: f64, + ); + + /// Decode a stored timestamp column into a [`SystemTime`]. + fn decode_timestamp( + row: &Self::Row, + column: &'static str, + ) -> Result; + + /// Decode a nullable stored timestamp column. + fn decode_optional_timestamp( + row: &Self::Row, + column: &'static str, + ) -> Result, RepositoryError>; + + /// Push a metadata JSON bind (Postgres casts to `::jsonb`). + fn push_metadata(sep: &mut Separated<'_, Self, &'static str>, json: &str); + + /// Push an `aggregate_id` filter for an id list (Postgres: `= ANY($n)` + /// array bind; SQLite: `IN (?, ?, ...)`). + fn push_id_filter(builder: &mut QueryBuilder, ids: &[&str]); + + /// Build the consumer-inbox retention `DELETE` for a cutoff age, evaluated + /// against the database clock. + fn inbox_purge_query(age: Duration) -> QueryBuilder; + + /// Claim up to `batch_size` outbox messages. This is the one genuinely + /// divergent operation: Postgres uses a CTE with `FOR UPDATE SKIP LOCKED`; + /// SQLite (no row locks) scans candidates and claims them with per-id + /// conditional updates. + fn claim_outbox<'a>( + pool: &'a Pool, + request: ClaimOutboxMessages, + ) -> impl Future, RepositoryError>> + Send + 'a; +} + +/// SQL-backed async repository generic over the SQLx backend. +/// +/// Use through the public aliases: [`PostgresRepository`](crate::PostgresRepository) +/// and [`SqliteRepository`](crate::SqliteRepository). +pub struct SqlxRepository { + pool: Pool, + read_model_schemas: Arc>, +} + +impl Clone for SqlxRepository { + fn clone(&self) -> Self { + Self { + pool: self.pool.clone(), + read_model_schemas: Arc::clone(&self.read_model_schemas), + } + } +} + +/// SQL-backed outbox table store generic over the SQLx backend. +/// +/// Use through the public aliases: [`PostgresOutboxStore`](crate::PostgresOutboxStore) +/// and [`SqliteOutboxStore`](crate::SqliteOutboxStore). +pub struct SqlxOutboxStore { + pool: Pool, +} + +impl Clone for SqlxOutboxStore { + fn clone(&self) -> Self { + Self { + pool: self.pool.clone(), + } + } +} + +impl SqlxRepository +where + DB: SqlxRepoBackend, + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + for<'c> &'c Pool: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, +{ + /// Create a repository from an existing migrated pool. + pub fn new(pool: Pool) -> Self { + Self { + pool, + read_model_schemas: Arc::new(RwLock::new(TableSchemaRegistry::new())), + } + } + + /// Open a pool without applying migrations. + pub async fn connect(database_url: &str) -> Result { + let pool = PoolOptions::::new() + .max_connections(DB::default_pool_size(database_url)) + .connect(database_url) + .await + .map_err(|err| repository_storage_error::("connect", err))?; + Ok(Self::new(pool)) + } + + /// Open a pool and apply this backend's explicit migrations. + pub async fn connect_and_migrate(database_url: &str) -> Result + where + DB::Connection: Migrate, + { + let repo = Self::connect(database_url).await?; + repo.migrate().await?; + Ok(repo) + } + + /// Apply this backend's migrations to the repository's pool. + pub async fn migrate(&self) -> Result<(), RepositoryError> + where + DB::Connection: Migrate, + { + Self::migrate_pool(&self.pool).await + } + + /// Apply this backend's migrations to an existing pool. Applied versions + /// are recorded in the `_sqlx_migrations` ledger, so re-running is a no-op + /// and an edited already-applied migration fails its checksum comparison + /// instead of silently diverging. + pub async fn migrate_pool(pool: &Pool) -> Result<(), RepositoryError> + where + DB::Connection: Migrate, + { + DB::migrator() + .run(pool) + .await + .map_err(|err| RepositoryError::Storage { + operation: format!("{} migrate", DB::BACKEND), + retryable: false, + source: Some(Box::new(err)), + }) + } + + /// Access the underlying SQLx pool for application-specific setup or tests. + pub fn pool(&self) -> &Pool { + &self.pool + } + + /// SQL artifact adapter for registered table/read-model schemas. + pub fn table_schema_adapter(&self) -> TableSqlSchemaAdapter { + table_schema_adapter::() + } + + /// Generate SQL statements for registered table/read-model schemas. + pub fn generate_table_migration_artifacts( + &self, + registry: &TableSchemaRegistry, + ) -> Result, TableStoreError> { + generate_table_migration_artifacts(registry, DB::TABLE_DIALECT) + } + + /// Explicit dev/test bootstrap for registered table/read-model schemas. + pub async fn bootstrap_table_schema_for_dev( + &self, + registry: &TableSchemaRegistry, + ) -> Result { + bootstrap_table_schema(&self.pool, registry).await?; + remember_read_model_schemas(&self.read_model_schemas, registry)?; + Ok(table_schema_bootstrap_result(registry)) + } + + /// Access an outbox-store handle backed by this repository's pool. + pub fn outbox_store(&self) -> SqlxOutboxStore { + SqlxOutboxStore { + pool: self.pool.clone(), + } + } +} + +impl SqlxOutboxStore +where + DB: SqlxRepoBackend, + for<'c> &'c Pool: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, +{ + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + pub fn pool(&self) -> &Pool { + &self.pool + } + + /// SQL artifact adapter for registered table/read-model schemas. + pub fn table_schema_adapter(&self) -> TableSqlSchemaAdapter { + table_schema_adapter::() + } + + /// Generate SQL statements for registered table/read-model schemas. + pub fn generate_table_migration_artifacts( + &self, + registry: &TableSchemaRegistry, + ) -> Result, TableStoreError> { + generate_table_migration_artifacts(registry, DB::TABLE_DIALECT) + } + + /// Explicit dev/test bootstrap for registered table/read-model schemas. + pub async fn bootstrap_table_schema_for_dev( + &self, + registry: &TableSchemaRegistry, + ) -> Result { + bootstrap_table_schema(&self.pool, registry).await?; + Ok(table_schema_bootstrap_result(registry)) + } +} + +fn table_schema_adapter() -> TableSqlSchemaAdapter { + match DB::TABLE_DIALECT { + TableSqlDialect::Postgres => TableSqlSchemaAdapter::postgres(), + TableSqlDialect::Sqlite => TableSqlSchemaAdapter::sqlite(), + } +} + +async fn bootstrap_table_schema( + pool: &Pool, + registry: &TableSchemaRegistry, +) -> Result<(), TableStoreError> +where + DB: SqlxRepoBackend, + for<'c> &'c Pool: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, +{ + for statement in table_schema_statements(registry, DB::TABLE_DIALECT)? { + sqlx::query(audited_table_schema_sql(statement)) + .execute(pool) + .await + .map_err(|err| { + TableStoreError::Storage(format!( + "{} bootstrap table schema failed: {err}", + DB::BACKEND + )) + })?; + } + Ok(()) +} + +impl GetStream for SqlxRepository +where + DB: SqlxRepoBackend, + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + for<'c> &'c Pool: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> f64: Encode<'q, DB> + Type, + for<'q> String: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> Vec: Type + sqlx::Decode<'q, DB>, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'q> &'q [u8]: Encode<'q, DB> + Type, + for<'r> &'r str: sqlx::ColumnIndex, +{ + fn get_stream<'a>( + &'a self, + identity: &'a StreamIdentity, + ) -> impl Future, RepositoryError>> + Send + 'a { + async move { + let mut builder = QueryBuilder::::new("SELECT "); + builder.push(DB::EVENT_SELECT); + builder.push(" FROM aggregate_events WHERE aggregate_type = "); + builder.push_bind(identity.aggregate_type()); + builder.push(" AND aggregate_id = "); + builder.push_bind(identity.aggregate_id()); + builder.push(" ORDER BY sequence ASC"); + let rows = builder + .build() + .fetch_all(&self.pool) + .await + .map_err(|err| repository_storage_error::("load stream", err))?; + + if rows.is_empty() { + return Ok(None); + } + + let mut events = Vec::with_capacity(rows.len()); + for row in rows { + events.push(event_from_row::(row)?); + } + + let mut entity = Entity::new(); + entity.set_id(identity.aggregate_id()); + entity.load_from_history(events); + Ok(Some(entity)) + } + } + + fn get_streams<'a>( + &'a self, + identities: &'a [StreamIdentity], + ) -> impl Future, RepositoryError>> + Send + 'a { + async move { + if identities.is_empty() { + return Ok(Vec::new()); + } + + let mut entities = Vec::with_capacity(identities.len()); + for (aggregate_type, aggregate_ids) in ids_by_type(identities) { + // Ordering by aggregate_id then sequence lets us slice the flat + // result into per-aggregate entities in one pass. Callers of + // `get_all` accept storage-order results. + let mut builder = QueryBuilder::::new("SELECT aggregate_id, "); + builder.push(DB::EVENT_SELECT); + builder.push(" FROM aggregate_events WHERE aggregate_type = "); + builder.push_bind(aggregate_type); + builder.push(" AND "); + DB::push_id_filter(&mut builder, &aggregate_ids); + builder.push(" ORDER BY aggregate_id ASC, sequence ASC"); + + let rows = builder + .build() + .fetch_all(&self.pool) + .await + .map_err(|err| repository_storage_error::("load streams", err))?; + + let mut current_id: Option = None; + let mut current_events: Vec = Vec::new(); + for row in rows { + let row_id: String = row.try_get("aggregate_id").map_err(|err| { + repository_storage_error::("decode aggregate id row", err) + })?; + let event = event_from_row::(row)?; + match ¤t_id { + Some(id) if id == &row_id => current_events.push(event), + _ => { + if let Some(id) = current_id.take() { + entities.push(entity_from_events( + id, + std::mem::take(&mut current_events), + )); + } + current_id = Some(row_id); + current_events.push(event); + } + } + } + if let Some(id) = current_id.take() { + entities.push(entity_from_events(id, current_events)); + } + } + + Ok(entities) + } + } + + fn get_stream_tail<'a>( + &'a self, + identity: &'a StreamIdentity, + after_version: u64, + ) -> impl Future, RepositoryError>> + Send + 'a { + async move { + // Fetch only the post-snapshot tail. `after_version` is the snapshot + // version (an event sequence); `sequence > ?` skips already-folded + // rows so a fresh snapshot over a long stream no longer reads and + // decodes the entire history. + let after = repository_i64_from_u64( + DB::BACKEND, + after_version, + "snapshot tail lower bound", + DB::INTEGER_STORAGE, + )?; + let mut builder = QueryBuilder::::new("SELECT "); + builder.push(DB::EVENT_SELECT); + builder.push(" FROM aggregate_events WHERE aggregate_type = "); + builder.push_bind(identity.aggregate_type()); + builder.push(" AND aggregate_id = "); + builder.push_bind(identity.aggregate_id()); + builder.push(" AND sequence > "); + builder.push_bind(after); + builder.push(" ORDER BY sequence ASC"); + let rows = builder + .build() + .fetch_all(&self.pool) + .await + .map_err(|err| repository_storage_error::("load stream tail", err))?; + + // An empty tail is ambiguous from this query alone (no rows could + // mean "snapshot is current" or "stream does not exist"). The + // snapshot hydrate path only calls this after confirming a snapshot + // exists for the identity, so an empty tail means the snapshot is + // current. Return an entity at exactly `after_version`. + let mut events = Vec::with_capacity(rows.len()); + for row in rows { + events.push(event_from_row::(row)?); + } + + let mut entity = Entity::new(); + entity.set_id(identity.aggregate_id()); + entity.load_tail_from_history(events, after_version); + Ok(Some(entity)) + } + } +} + +impl TransactionalCommit for SqlxRepository +where + DB: SqlxRepoBackend, + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + for<'c> &'c Pool: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> f64: Encode<'q, DB> + Type, + for<'q> String: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> Vec: Type + sqlx::Decode<'q, DB>, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'q> &'q [u8]: Encode<'q, DB> + Type, + for<'q> Option: Encode<'q, DB> + Type, + for<'q> Option<&'q str>: Encode<'q, DB> + Type, + for<'r> &'r str: sqlx::ColumnIndex, +{ + fn commit_batch<'a>( + &'a self, + batch: CommitBatch<'a>, + ) -> impl Future> + Send + 'a { + async move { + let prepared = validate_commit_batch(&batch)?; + + for plan in &batch.read_model_plans { + validate_sql_write_plan(plan)?; + } + + let mut tx = + self.pool.begin().await.map_err(|err| { + repository_storage_error::("begin commit transaction", err) + })?; + + // One grouped round trip for the whole batch's optimistic + // concurrency pre-check instead of a MAX(sequence) query per stream. + let versions = stream_versions_in_tx(&mut tx, &prepared).await?; + for append in &prepared { + let actual = versions + .get(&append.identity.storage_key()) + .copied() + .unwrap_or(0); + if actual != append.expected_version { + return Err(RepositoryError::ConcurrentWrite { + id: append.identity.to_string(), + expected: append.expected_version, + actual, + }); + } + } + + insert_events_in_tx(&self.pool, &mut tx, &prepared).await?; + + insert_outbox_messages_in_tx(&mut tx, &batch.outbox_messages).await?; + + for plan in batch.read_model_plans { + apply_read_model_write_plan_in_tx(&mut tx, plan).await?; + } + + for write in batch.snapshots { + match write { + SnapshotWrite::Save { identity, record } => { + save_snapshot_in_tx(&mut tx, &identity, record).await?; + } + } + } + + for receipt in &batch.inbox_receipts { + insert_inbox_receipt_in_tx(&mut tx, receipt).await?; + } + + tx.commit() + .await + .map_err(|err| repository_storage_error::("commit transaction", err))?; + + for stream in batch.streams { + stream.entity.mark_committed(); + } + + Ok(()) + } + } +} + +impl InboxStore for SqlxRepository +where + DB: SqlxRepoBackend, + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + for<'c> &'c Pool: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> String: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'r> &'r str: sqlx::ColumnIndex, +{ + fn inbox_contains<'a>( + &'a self, + consumer: &'a str, + message_id: &'a str, + ) -> impl Future> + Send + 'a { + async move { + let mut builder = + QueryBuilder::::new("SELECT 1 FROM consumer_inbox WHERE consumer = "); + builder.push_bind(consumer); + builder.push(" AND message_id = "); + builder.push_bind(message_id); + builder.push(" LIMIT 1"); + let row = builder + .build() + .fetch_optional(&self.pool) + .await + .map_err(|err| repository_storage_error::("query consumer inbox", err))?; + Ok(row.is_some()) + } + } + + fn purge_inbox_older_than( + &self, + age: std::time::Duration, + ) -> impl Future> + Send { + async move { + // Compare against the database clock to avoid client/server skew; + // the backend renders the cutoff expression. + let mut builder = DB::inbox_purge_query(age); + let result = builder + .build() + .execute(&self.pool) + .await + .map_err(|err| repository_storage_error::("purge consumer inbox", err))?; + Ok(DB::rows_affected(&result)) + } + } +} + +impl ReadModelWritePlanStore for SqlxRepository +where + DB: SqlxRepoBackend, + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'r> &'r str: sqlx::ColumnIndex, +{ + fn read_model_capabilities(&self) -> ReadModelAdapterCapabilities { + sql_read_model_capabilities() + } + + fn commit_write_plan( + &self, + plan: ReadModelWritePlan, + ) -> impl Future> + Send + '_ { + async move { commit_read_model_write_plan(&self.pool, plan).await } + } +} + +impl RelationalReadModelQueryStore for SqlxRepository +where + DB: SqlxRepoBackend, + for<'c> &'c Pool: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'r> &'r str: sqlx::ColumnIndex, +{ + fn read_model_query_capabilities(&self) -> ReadModelQueryCapabilities { + ReadModelQueryCapabilities::relationship_includes() + } + + fn load_graph( + &self, + request: ReadModelLoadRequest, + ) -> impl Future> + Send + '_ { + async move { + load_read_model_graph( + &self.pool, + &self.read_model_schemas, + request, + self.read_model_query_capabilities(), + ) + .await + } + } +} + +impl SnapshotStore for SqlxRepository +where + DB: SqlxRepoBackend, + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + for<'c> &'c Pool: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> String: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> Vec: Type + sqlx::Decode<'q, DB>, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'q> &'q [u8]: Encode<'q, DB> + Type, + for<'r> &'r str: sqlx::ColumnIndex, +{ + fn get_snapshot<'a>( + &'a self, + identity: &'a StreamIdentity, + ) -> impl Future, RepositoryError>> + Send + 'a { + async move { + let mut builder = QueryBuilder::::new("SELECT "); + builder.push(DB::SNAPSHOT_SELECT); + builder.push(" FROM aggregate_snapshots WHERE aggregate_type = "); + builder.push_bind(identity.aggregate_type()); + builder.push(" AND aggregate_id = "); + builder.push_bind(identity.aggregate_id()); + let row = builder + .build() + .fetch_optional(&self.pool) + .await + .map_err(|err| repository_storage_error::("load snapshot", err))?; + + let Some(row) = row else { + return Ok(None); + }; + + Ok(Some(snapshot_from_row::(row)?)) + } + } + + fn get_snapshots<'a>( + &'a self, + identities: &'a [StreamIdentity], + ) -> impl Future, RepositoryError>> + Send + 'a { + async move { + if identities.is_empty() { + return Ok(Vec::new()); + } + + let mut records = Vec::with_capacity(identities.len()); + for (aggregate_type, aggregate_ids) in ids_by_type(identities) { + let mut builder = QueryBuilder::::new("SELECT "); + builder.push(DB::SNAPSHOT_SELECT); + builder.push(" FROM aggregate_snapshots WHERE aggregate_type = "); + builder.push_bind(aggregate_type); + builder.push(" AND "); + DB::push_id_filter(&mut builder, &aggregate_ids); + let rows = builder + .build() + .fetch_all(&self.pool) + .await + .map_err(|err| repository_storage_error::("load snapshots", err))?; + for row in rows { + records.push(snapshot_from_row::(row)?); + } + } + Ok(records) + } + } + + fn save_snapshot<'a>( + &'a self, + identity: &'a StreamIdentity, + record: SnapshotRecord, + ) -> impl Future> + Send + 'a { + async move { + let mut tx = + self.pool.begin().await.map_err(|err| { + repository_storage_error::("begin snapshot transaction", err) + })?; + save_snapshot_in_tx(&mut tx, identity, record).await?; + tx.commit().await.map_err(|err| { + repository_storage_error::("commit snapshot transaction", err) + })?; + Ok(()) + } + } + + fn delete_snapshot<'a>( + &'a self, + identity: &'a StreamIdentity, + ) -> impl Future> + Send + 'a { + async move { + let mut builder = + QueryBuilder::::new("DELETE FROM aggregate_snapshots WHERE aggregate_type = "); + builder.push_bind(identity.aggregate_type()); + builder.push(" AND aggregate_id = "); + builder.push_bind(identity.aggregate_id()); + let result = builder + .build() + .execute(&self.pool) + .await + .map_err(|err| repository_storage_error::("delete snapshot", err))?; + + Ok(DB::rows_affected(&result) > 0) + } + } +} + +/// One claimed-message lifecycle transition (the `UPDATE` shape is shared; only +/// the assignments differ). +enum OutboxTransition<'a> { + Complete, + Release { error: &'a str }, + Fail { error: &'a str }, +} + +impl OutboxTransition<'_> { + fn target_status(&self) -> OutboxMessageStatus { + match self { + OutboxTransition::Complete => OutboxMessageStatus::Published, + OutboxTransition::Release { .. } => OutboxMessageStatus::Pending, + OutboxTransition::Fail { .. } => OutboxMessageStatus::Failed, + } + } + + fn operation(&self) -> &'static str { + match self { + OutboxTransition::Complete => "complete outbox message", + OutboxTransition::Release { .. } => "release outbox message", + OutboxTransition::Fail { .. } => "fail outbox message", + } + } +} + +impl OutboxStore for SqlxOutboxStore +where + DB: SqlxRepoBackend, + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + for<'c> &'c Pool: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> f64: Encode<'q, DB> + Type, + for<'q> String: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> Vec: Type + sqlx::Decode<'q, DB>, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'q> &'q [u8]: Encode<'q, DB> + Type, + for<'q> Option: Encode<'q, DB> + Type, + for<'q> Option<&'q str>: Encode<'q, DB> + Type, + for<'r> &'r str: sqlx::ColumnIndex, +{ + fn messages_by_status( + &self, + status: OutboxMessageStatus, + limit: usize, + ) -> impl Future, RepositoryError>> + Send + '_ { + async move { + let mut builder = QueryBuilder::::new("SELECT "); + builder.push(DB::OUTBOX_SELECT); + builder.push(" FROM outbox_messages WHERE status = "); + builder.push_bind(status.as_str()); + builder.push(" ORDER BY "); + builder.push(DB::ORDER_BY_CREATED_AT); + builder.push(" ASC, message_id ASC LIMIT "); + // usize::MAX means "no practical bound"; clamp to what the column + // type can carry. + builder.push_bind(i64::try_from(limit).unwrap_or(i64::MAX)); + let rows = builder.build().fetch_all(&self.pool).await.map_err(|err| { + repository_storage_error::("load outbox messages by status", err) + })?; + + rows.into_iter() + .map(outbox_message_from_row::) + .collect() + } + } + + fn claim<'a>( + &'a self, + request: ClaimOutboxMessages, + ) -> impl Future, RepositoryError>> + Send + 'a { + DB::claim_outbox(&self.pool, request) + } + + fn complete<'a>( + &'a self, + claim: &'a OutboxClaimRef, + ) -> impl Future> + Send + 'a { + transition_claimed_outbox_message(&self.pool, claim, OutboxTransition::Complete) + } + + fn release<'a>( + &'a self, + claim: &'a OutboxClaimRef, + error: &'a str, + ) -> impl Future> + Send + 'a { + transition_claimed_outbox_message(&self.pool, claim, OutboxTransition::Release { error }) + } + + fn fail<'a>( + &'a self, + claim: &'a OutboxClaimRef, + error: &'a str, + ) -> impl Future> + Send + 'a { + transition_claimed_outbox_message(&self.pool, claim, OutboxTransition::Fail { error }) + } +} + +/// Apply one claimed-message lifecycle transition (complete / release / fail). +/// +/// The conditional `UPDATE` only applies while the caller still holds the +/// active claim (`status`, `claimed_by`, unexpired `claimed_until`, and +/// matching `attempts`); when no row is updated, the message is re-read to +/// produce the precise claim error. +async fn transition_claimed_outbox_message<'a, DB>( + pool: &'a Pool, + claim: &'a OutboxClaimRef, + transition: OutboxTransition<'a>, +) -> Result<(), RepositoryError> +where + DB: SqlxRepoBackend, + for<'c> &'c Pool: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> f64: Encode<'q, DB> + Type, + for<'q> String: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> Vec: Type + sqlx::Decode<'q, DB>, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'q> Option: Encode<'q, DB> + Type, + for<'q> Option<&'q str>: Encode<'q, DB> + Type, + for<'r> &'r str: sqlx::ColumnIndex, +{ + let now = SystemTime::now(); + let now_epoch = system_time_epoch_secs::(now)?; + let now_value = DB::timestamp_value(now)?; + + let mut builder = QueryBuilder::::new("UPDATE outbox_messages SET status = "); + builder.push_bind(transition.target_status().as_str()); + builder.push(", claimed_by = NULL, claimed_until = NULL, "); + match &transition { + OutboxTransition::Complete => { + builder.push("published_at = "); + DB::push_timestamp_assign(&mut builder, &now_value); + } + OutboxTransition::Release { error } => { + builder.push("next_available_at = "); + DB::push_timestamp_assign(&mut builder, &now_value); + builder.push(", last_error = "); + builder.push_bind(empty_string_as_none(error)); + } + OutboxTransition::Fail { error } => { + builder.push("last_error = "); + builder.push_bind(empty_string_as_none(error)); + builder.push(", failed_at = "); + DB::push_timestamp_assign(&mut builder, &now_value); + } + } + builder.push(", updated_at = "); + builder.push(DB::NOW); + builder.push(" WHERE message_id = "); + builder.push_bind(claim.message_id.as_str()); + builder.push(" AND status = "); + builder.push_bind(OutboxMessageStatus::InFlight.as_str()); + builder.push(" AND claimed_by = "); + builder.push_bind(claim.worker_id.as_str()); + builder.push(" AND claimed_until IS NOT NULL AND "); + DB::push_timestamp_cmp(&mut builder, "claimed_until", ">", now_epoch); + builder.push(" AND attempts = "); + builder.push_bind(repository_i64_from_u64( + DB::BACKEND, + u64::from(claim.attempt), + "outbox claim attempt", + DB::INTEGER_STORAGE, + )?); + + let result = builder + .build() + .execute(pool) + .await + .map_err(|err| repository_storage_error::(transition.operation(), err))?; + + ensure_outbox_update_applied( + pool, + DB::rows_affected(&result), + &claim.message_id, + |message| ensure_active_claim(message, Some(claim), now), + ) + .await +} + +/// Load an outbox message by id through any executor (pool or transaction). +pub(crate) async fn outbox_message_by_id<'e, DB, E>( + executor: E, + message_id: &str, +) -> Result, RepositoryError> +where + DB: SqlxRepoBackend, + E: Executor<'e, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> String: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> Vec: Type + sqlx::Decode<'q, DB>, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'r> &'r str: sqlx::ColumnIndex, +{ + let mut builder = QueryBuilder::::new("SELECT "); + builder.push(DB::OUTBOX_SELECT); + builder.push(" FROM outbox_messages WHERE message_id = "); + builder.push_bind(message_id); + let row = builder + .build() + .fetch_optional(executor) + .await + .map_err(|err| repository_storage_error::("load outbox message", err))?; + row.map(outbox_message_from_row::).transpose() +} + +pub(crate) async fn ensure_outbox_update_applied( + pool: &Pool, + rows_affected: u64, + message_id: &str, + validate: impl FnOnce(&OutboxMessage) -> Result<(), RepositoryError>, +) -> Result<(), RepositoryError> +where + DB: SqlxRepoBackend, + for<'c> &'c Pool: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> String: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> Vec: Type + sqlx::Decode<'q, DB>, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'r> &'r str: sqlx::ColumnIndex, +{ + if rows_affected > 0 { + return Ok(()); + } + + let message = outbox_message_by_id(pool, message_id) + .await? + .ok_or_else(|| RepositoryError::NotFound { + id: message_id.to_string(), + })?; + validate(&message) +} + +/// Record a consumer inbox receipt in the commit transaction. The +/// `(consumer, message_id)` primary key is the dedupe gate: a unique violation +/// means the message was already processed, so the whole batch rolls back and +/// the effects are not double-applied. `processed_at` defaults server-side. +async fn insert_inbox_receipt_in_tx( + tx: &mut Transaction<'_, DB>, + receipt: &InboxReceipt, +) -> Result<(), RepositoryError> +where + DB: SqlxRepoBackend, + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> &'q str: Encode<'q, DB> + Type, +{ + receipt.validate()?; + let mut builder = + QueryBuilder::::new("INSERT INTO consumer_inbox (consumer, message_id) VALUES ("); + builder.push_bind(receipt.consumer.as_str()); + builder.push(", "); + builder.push_bind(receipt.message_id.as_str()); + builder.push(")"); + let result = builder.build().execute(&mut **tx).await; + match result { + Ok(_) => Ok(()), + Err(err) if DB::is_unique_violation(&err) => Err(RepositoryError::DuplicateInboxReceipt { + consumer: receipt.consumer.clone(), + message_id: receipt.message_id.clone(), + }), + Err(err) => Err(repository_storage_error::( + "insert consumer inbox receipt", + err, + )), + } +} + +/// One `aggregate_events` row with pre-validated bind values, built before the +/// query so any conversion error surfaces before we touch the database. The +/// stream identity and expected version ride along for conflict recovery. +struct EventRow<'a, DB: SqlxRepoBackend> { + identity: &'a StreamIdentity, + expected_version: u64, + sequence: i64, + event_name: &'a str, + event_version: i64, + payload: &'a [u8], + payload_codec: &'a str, + payload_codec_version: i64, + metadata: String, + recorded_at: DB::TimestampValue, +} + +/// Insert every event across all prepared appends with multi-row INSERTs, +/// chunked to respect the backend's bound-parameter limit (Postgres is +/// effectively unlimited, so its chunking collapses to one statement). +/// +/// Conflict detection is unchanged from the per-row path: the `(aggregate_type, +/// aggregate_id, sequence)` primary key is the contiguity gate, and a unique +/// violation still surfaces as `ConcurrentWrite`. Recovery re-reads stream +/// versions in-tx or over the pool depending on +/// [`SqlxRepoBackend::CONFLICT_REREAD_IN_TX`]. +async fn insert_events_in_tx( + pool: &Pool, + tx: &mut Transaction<'_, DB>, + prepared: &[PreparedEventAppend<'_>], +) -> Result<(), RepositoryError> +where + DB: SqlxRepoBackend, + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + for<'c> &'c Pool: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'q> &'q [u8]: Encode<'q, DB> + Type, + for<'r> &'r str: sqlx::ColumnIndex, +{ + let mut rows = Vec::new(); + for append in prepared { + for event in append.events { + rows.push(EventRow:: { + identity: &append.identity, + expected_version: append.expected_version, + sequence: repository_i64_from_u64( + DB::BACKEND, + event.sequence, + "sequence", + DB::INTEGER_STORAGE, + )?, + event_name: &event.event_name, + event_version: repository_i64_from_u64( + DB::BACKEND, + event.event_version, + "event_version", + DB::INTEGER_STORAGE, + )?, + payload: &event.payload, + payload_codec: &event.payload_codec, + payload_codec_version: i64::from(event.payload_codec_version), + metadata: serialize_event_metadata(&event.metadata)?, + recorded_at: DB::timestamp_value(event.timestamp)?, + }); + } + } + + for chunk in rows.chunks(DB::MAX_BIND_PARAMS / EVENT_BIND_COLUMNS) { + let mut builder = QueryBuilder::::new( + "INSERT INTO aggregate_events (\ + aggregate_type, aggregate_id, sequence, event_name, event_version, \ + payload, payload_codec, payload_codec_version, metadata, recorded_at) ", + ); + builder.push_values(chunk, |mut row, event| { + row.push_bind(event.identity.aggregate_type()) + .push_bind(event.identity.aggregate_id()) + .push_bind(event.sequence) + .push_bind(event.event_name) + .push_bind(event.event_version) + .push_bind(event.payload) + .push_bind(event.payload_codec) + .push_bind(event.payload_codec_version); + DB::push_metadata(&mut row, event.metadata.as_str()); + DB::push_timestamp(&mut row, &event.recorded_at); + }); + + let result = builder.build().execute(&mut **tx).await; + match result { + Ok(_) => {} + Err(err) if DB::is_unique_violation(&err) => { + return Err(if DB::CONFLICT_REREAD_IN_TX { + // The transaction survives the constraint error: re-read in + // the same tx, scoped to this chunk (earlier chunks were + // already inserted in this tx and would skew the versions + // of their streams). + let mut seen = std::collections::HashSet::new(); + let candidates: Vec<_> = chunk + .iter() + .filter(|event| seen.insert(event.identity.storage_key())) + .map(|event| (event.identity, event.expected_version)) + .collect(); + concurrent_write_from_conflict(&mut **tx, &candidates).await + } else { + // The failed statement aborted the transaction: re-read the + // conflicting streams' actual versions on a separate + // connection, across the whole batch. + let candidates: Vec<_> = prepared + .iter() + .map(|append| (&append.identity, append.expected_version)) + .collect(); + match pool.acquire().await { + Ok(mut conn) => { + concurrent_write_from_conflict(&mut conn, &candidates).await + } + Err(err) => repository_storage_error::( + "acquire conflict re-read connection", + err, + ), + } + }); + } + Err(err) => return Err(repository_storage_error::("insert events", err)), + } + } + + Ok(()) +} + +/// After an event-insert unique violation, find the candidate stream whose +/// actual version no longer matches its expected version and report it as +/// `ConcurrentWrite`. Falls back to the first candidate if a concurrent +/// writer's effect cannot be pinned down (the violation still indicates a +/// conflicting write). Candidates must be non-empty and deduplicated. +async fn concurrent_write_from_conflict( + conn: &mut DB::Connection, + candidates: &[(&StreamIdentity, u64)], +) -> RepositoryError +where + DB: SqlxRepoBackend, + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'r> &'r str: sqlx::ColumnIndex, +{ + for &(identity, expected) in candidates { + match stream_version(&mut *conn, identity).await { + Ok(actual) if actual != expected => { + return RepositoryError::ConcurrentWrite { + id: identity.to_string(), + expected, + actual, + }; + } + Ok(_) => {} + Err(err) => return err, + } + } + + let (identity, expected) = candidates[0]; + match stream_version(&mut *conn, identity).await { + Ok(actual) => RepositoryError::ConcurrentWrite { + id: identity.to_string(), + expected, + actual, + }, + Err(err) => err, + } +} + +/// Current committed version (`MAX(sequence)`, 0 for a missing stream) through +/// any executor (pool or transaction). +async fn stream_version<'e, DB, E>( + executor: E, + identity: &StreamIdentity, +) -> Result +where + DB: SqlxRepoBackend, + E: Executor<'e, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'r> &'r str: sqlx::ColumnIndex, +{ + let mut builder = QueryBuilder::::new( + "SELECT MAX(sequence) AS version FROM aggregate_events WHERE aggregate_type = ", + ); + builder.push_bind(identity.aggregate_type()); + builder.push(" AND aggregate_id = "); + builder.push_bind(identity.aggregate_id()); + let row = builder + .build() + .fetch_one(executor) + .await + .map_err(|err| repository_storage_error::("load stream version", err))?; + + let version: Option = row + .try_get("version") + .map_err(|err| repository_storage_error::("decode stream version row", err))?; + version + .map(|value| repository_u64_from_i64(DB::BACKEND, value, "sequence")) + .unwrap_or(Ok(0)) +} + +/// Current committed versions for every stream in the batch, in one grouped +/// query (`MAX(sequence)` per stream; missing streams simply have no row and +/// default to 0 at the call site). Chunked so a very large batch stays under +/// the backend's bound-parameter limit (two binds per stream). +async fn stream_versions_in_tx( + tx: &mut Transaction<'_, DB>, + prepared: &[PreparedEventAppend<'_>], +) -> Result, RepositoryError> +where + DB: SqlxRepoBackend, + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> String: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'r> &'r str: sqlx::ColumnIndex, +{ + let mut versions = HashMap::with_capacity(prepared.len()); + if prepared.is_empty() { + return Ok(versions); + } + + for chunk in prepared.chunks(DB::MAX_BIND_PARAMS / 2) { + let mut builder = QueryBuilder::::new( + "SELECT aggregate_type, aggregate_id, MAX(sequence) AS version \ + FROM aggregate_events WHERE ", + ); + let mut first = true; + for append in chunk { + if !first { + builder.push(" OR "); + } + first = false; + builder.push("(aggregate_type = "); + builder.push_bind(append.identity.aggregate_type()); + builder.push(" AND aggregate_id = "); + builder.push_bind(append.identity.aggregate_id()); + builder.push(")"); + } + builder.push(" GROUP BY aggregate_type, aggregate_id"); + + let rows = builder + .build() + .fetch_all(&mut **tx) + .await + .map_err(|err| repository_storage_error::("load stream versions", err))?; + + for row in rows { + let aggregate_type: String = row.try_get("aggregate_type").map_err(|err| { + repository_storage_error::("decode stream version aggregate type row", err) + })?; + let aggregate_id: String = row.try_get("aggregate_id").map_err(|err| { + repository_storage_error::("decode stream version aggregate id row", err) + })?; + let version: i64 = row + .try_get("version") + .map_err(|err| repository_storage_error::("decode stream version row", err))?; + versions.insert( + StreamIdentity::new(&aggregate_type, &aggregate_id)?.storage_key(), + repository_u64_from_i64(DB::BACKEND, version, "sequence")?, + ); + } + } + + Ok(versions) +} + +/// One `outbox_messages` row with pre-validated bind values. +struct OutboxRow<'a, DB: SqlxRepoBackend> { + message_id: &'a str, + event_type: &'a str, + payload: &'a [u8], + payload_codec: &'a str, + payload_codec_version: i64, + destination: Option<&'a str>, + metadata: String, + status: &'a str, + created_at: DB::TimestampValue, + worker_id: Option<&'a str>, + leased_until: Option, + attempts: i64, + last_error: Option<&'a str>, + source_aggregate_type: Option<&'a str>, + source_aggregate_id: Option<&'a str>, + source_sequence: Option, + correlation_id: Option<&'a str>, + causation_id: Option<&'a str>, +} + +/// Insert every outbox message with multi-row INSERTs (chunked to respect the +/// backend's bound-parameter limit). A unique violation on `message_id` still +/// maps to `DuplicateOutboxMessageInBatch`. +async fn insert_outbox_messages_in_tx( + tx: &mut Transaction<'_, DB>, + messages: &[OutboxMessage], +) -> Result<(), RepositoryError> +where + DB: SqlxRepoBackend, + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type, + for<'q> Option: Encode<'q, DB> + Type, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'q> Option<&'q str>: Encode<'q, DB> + Type, + for<'q> &'q [u8]: Encode<'q, DB> + Type, +{ + if messages.is_empty() { + return Ok(()); + } + + let mut rows = Vec::with_capacity(messages.len()); + for message in messages { + rows.push(OutboxRow:: { + message_id: message.id(), + event_type: &message.event_type, + payload: &message.payload, + payload_codec: &message.payload_codec, + payload_codec_version: i64::from(message.payload_codec_version), + destination: message.destination.as_deref(), + metadata: serialize_event_metadata(&message.metadata)?, + status: message.status.as_str(), + created_at: DB::timestamp_value(message.created_at)?, + worker_id: message.worker_id.as_deref(), + leased_until: message.leased_until.map(DB::timestamp_value).transpose()?, + attempts: i64::from(message.attempts), + last_error: message.last_error.as_deref(), + source_aggregate_type: message.source_aggregate_type.as_deref(), + source_aggregate_id: message.source_aggregate_id.as_deref(), + source_sequence: message + .source_sequence + .map(|value| { + repository_i64_from_u64( + DB::BACKEND, + value, + "outbox source sequence", + DB::INTEGER_STORAGE, + ) + }) + .transpose()?, + correlation_id: message.correlation_id(), + causation_id: message.causation_id(), + }); + } + + for chunk in rows.chunks(DB::MAX_BIND_PARAMS / OUTBOX_BIND_COLUMNS) { + let mut builder = QueryBuilder::::new( + "INSERT INTO outbox_messages (\ + message_id, event_type, payload, payload_codec, payload_codec_version, \ + destination, metadata, status, created_at, next_available_at, \ + claimed_by, claimed_until, attempts, last_error, source_aggregate_type, \ + source_aggregate_id, source_sequence, correlation_id, causation_id) ", + ); + builder.push_values(chunk, |mut row, message| { + row.push_bind(message.message_id) + .push_bind(message.event_type) + .push_bind(message.payload) + .push_bind(message.payload_codec) + .push_bind(message.payload_codec_version) + .push_bind(message.destination); + DB::push_metadata(&mut row, message.metadata.as_str()); + row.push_bind(message.status); + // created_at and next_available_at share the same value. + DB::push_timestamp(&mut row, &message.created_at); + DB::push_timestamp(&mut row, &message.created_at); + row.push_bind(message.worker_id); + DB::push_optional_timestamp(&mut row, message.leased_until.as_ref()); + row.push_bind(message.attempts) + .push_bind(message.last_error) + .push_bind(message.source_aggregate_type) + .push_bind(message.source_aggregate_id) + .push_bind(message.source_sequence) + .push_bind(message.correlation_id) + .push_bind(message.causation_id); + }); + + let result = builder.build().execute(&mut **tx).await; + if let Err(err) = result { + if DB::is_unique_violation(&err) { + // The batch was already deduped (validate_commit_batch), so a + // violation means the id collides with a previously committed + // row. Report the first id in the chunk, matching the per-row + // path's contract. + return Err(RepositoryError::DuplicateOutboxMessageInBatch { + id: chunk[0].message_id.to_string(), + }); + } + return Err(repository_storage_error::( + "insert outbox messages", + err, + )); + } + } + + Ok(()) +} + +async fn save_snapshot_in_tx( + tx: &mut Transaction<'_, DB>, + identity: &StreamIdentity, + record: SnapshotRecord, +) -> Result<(), RepositoryError> +where + DB: SqlxRepoBackend, + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> i64: Encode<'q, DB> + Type, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'q> &'q [u8]: Encode<'q, DB> + Type, +{ + validate_snapshot_identity(identity, &record)?; + + let metadata = serialize_event_metadata(&record.metadata)?; + let recorded_at = DB::timestamp_value(record.recorded_at)?; + let version = repository_i64_from_u64( + DB::BACKEND, + record.version, + "snapshot version", + DB::INTEGER_STORAGE, + )?; + let snapshot_version = repository_i64_from_u64( + DB::BACKEND, + record.snapshot_version, + "snapshot payload version", + DB::INTEGER_STORAGE, + )?; + + let mut builder = QueryBuilder::::new( + "INSERT INTO aggregate_snapshots (\ + aggregate_type, aggregate_id, version, snapshot_version, payload, \ + payload_codec, payload_codec_version, metadata, recorded_at) VALUES (", + ); + { + let mut row = builder.separated(", "); + row.push_bind(identity.aggregate_type()) + .push_bind(identity.aggregate_id()) + .push_bind(version) + .push_bind(snapshot_version) + .push_bind(record.payload.as_slice()) + .push_bind(record.payload_codec.as_str()) + .push_bind(i64::from(record.payload_codec_version)); + DB::push_metadata(&mut row, metadata.as_str()); + DB::push_timestamp(&mut row, &recorded_at); + } + builder.push( + ") ON CONFLICT(aggregate_type, aggregate_id) DO UPDATE SET \ + version = excluded.version, \ + snapshot_version = excluded.snapshot_version, \ + payload = excluded.payload, \ + payload_codec = excluded.payload_codec, \ + payload_codec_version = excluded.payload_codec_version, \ + metadata = excluded.metadata, \ + recorded_at = excluded.recorded_at, \ + updated_at = ", + ); + builder.push(DB::NOW); + + builder + .build() + .execute(&mut **tx) + .await + .map_err(|err| repository_storage_error::("save snapshot", err))?; + + Ok(()) +} + +fn entity_from_events(aggregate_id: String, events: Vec) -> Entity { + let mut entity = Entity::new(); + entity.set_id(aggregate_id); + entity.load_from_history(events); + entity +} + +pub(crate) fn event_from_row(row: DB::Row) -> Result +where + DB: SqlxRepoBackend, + for<'q> i64: Type + sqlx::Decode<'q, DB>, + for<'q> String: Type + sqlx::Decode<'q, DB>, + for<'q> Vec: Type + sqlx::Decode<'q, DB>, + for<'r> &'r str: sqlx::ColumnIndex, +{ + let payload_codec: String = row + .try_get("payload_codec") + .map_err(|err| repository_storage_error::("decode payload codec row", err))?; + // Nearly every row carries the crate's own codec constant; borrow it + // instead of keeping a per-event allocation. + let payload_codec = if payload_codec == BITCODE_PAYLOAD_CODEC { + Cow::Borrowed(BITCODE_PAYLOAD_CODEC) + } else { + Cow::Owned(payload_codec) + }; + let payload_codec_version = repository_u16_from_i64( + DB::BACKEND, + row.try_get("payload_codec_version").map_err(|err| { + repository_storage_error::("decode payload codec version row", err) + })?, + "payload_codec_version", + )?; + let metadata_json: String = row + .try_get("metadata") + .map_err(|err| repository_storage_error::("decode metadata row", err))?; + let metadata = deserialize_event_metadata(&metadata_json)?; + let event = EventRecord { + event_name: row + .try_get("event_name") + .map_err(|err| repository_storage_error::("decode event name row", err))?, + payload_codec, + payload_codec_version, + payload: row + .try_get("payload") + .map_err(|err| repository_storage_error::("decode payload row", err))?, + event_version: repository_u64_from_i64( + DB::BACKEND, + row.try_get("event_version") + .map_err(|err| repository_storage_error::("decode event version row", err))?, + "event_version", + )?, + sequence: repository_u64_from_i64( + DB::BACKEND, + row.try_get("sequence") + .map_err(|err| repository_storage_error::("decode sequence row", err))?, + "sequence", + )?, + timestamp: DB::decode_timestamp(&row, "recorded_at")?, + metadata, + }; + validate_supported_event_codec(&event)?; + Ok(event) +} + +fn snapshot_from_row(row: DB::Row) -> Result +where + DB: SqlxRepoBackend, + for<'q> i64: Type + sqlx::Decode<'q, DB>, + for<'q> String: Type + sqlx::Decode<'q, DB>, + for<'q> Vec: Type + sqlx::Decode<'q, DB>, + for<'r> &'r str: sqlx::ColumnIndex, +{ + let metadata_json: String = row + .try_get("metadata") + .map_err(|err| repository_storage_error::("decode snapshot metadata row", err))?; + Ok(SnapshotRecord { + aggregate_type: row.try_get("aggregate_type").map_err(|err| { + repository_storage_error::("decode snapshot aggregate type row", err) + })?, + aggregate_id: row.try_get("aggregate_id").map_err(|err| { + repository_storage_error::("decode snapshot aggregate id row", err) + })?, + version: repository_u64_from_i64( + DB::BACKEND, + row.try_get("version").map_err(|err| { + repository_storage_error::("decode snapshot version row", err) + })?, + "snapshot version", + )?, + snapshot_version: repository_u64_from_i64( + DB::BACKEND, + row.try_get("snapshot_version").map_err(|err| { + repository_storage_error::("decode snapshot payload version row", err) + })?, + "snapshot payload version", + )?, + payload_codec: row.try_get("payload_codec").map_err(|err| { + repository_storage_error::("decode snapshot payload codec row", err) + })?, + payload_codec_version: repository_u16_from_i64( + DB::BACKEND, + row.try_get("payload_codec_version").map_err(|err| { + repository_storage_error::("decode snapshot payload codec version row", err) + })?, + "snapshot payload codec version", + )?, + payload: row + .try_get("payload") + .map_err(|err| repository_storage_error::("decode snapshot payload row", err))?, + metadata: deserialize_event_metadata(&metadata_json)?, + recorded_at: DB::decode_timestamp(&row, "recorded_at")?, + }) +} + +pub(crate) fn outbox_message_from_row(row: DB::Row) -> Result +where + DB: SqlxRepoBackend, + for<'q> i64: Type + sqlx::Decode<'q, DB>, + for<'q> String: Type + sqlx::Decode<'q, DB>, + for<'q> Vec: Type + sqlx::Decode<'q, DB>, + for<'r> &'r str: sqlx::ColumnIndex, +{ + let status_text: String = row + .try_get("status") + .map_err(|err| repository_storage_error::("decode outbox status row", err))?; + let status = status_text.parse::().map_err(|_| { + RepositoryError::Model(format!( + "{} outbox status `{status_text}` is invalid", + DB::BACKEND + )) + })?; + let metadata_json: String = row + .try_get("metadata") + .map_err(|err| repository_storage_error::("decode outbox metadata row", err))?; + let attempts: i64 = row + .try_get("attempts") + .map_err(|err| repository_storage_error::("decode outbox attempts row", err))?; + let source_sequence = row + .try_get::, _>("source_sequence") + .map_err(|err| repository_storage_error::("decode outbox source sequence row", err))? + .map(|value| repository_u64_from_i64(DB::BACKEND, value, "outbox source sequence")) + .transpose()?; + let mut metadata = deserialize_event_metadata(&metadata_json)?; + if let Some(correlation_id) = row + .try_get::, _>("correlation_id") + .map_err(|err| repository_storage_error::("decode outbox correlation_id row", err))? + { + metadata.insert("correlation_id".into(), correlation_id); + } + if let Some(causation_id) = row + .try_get::, _>("causation_id") + .map_err(|err| repository_storage_error::("decode outbox causation_id row", err))? + { + metadata.insert("causation_id".into(), causation_id); + } + + Ok(OutboxMessage { + id: row + .try_get("message_id") + .map_err(|err| repository_storage_error::("decode outbox message id row", err))?, + event_type: row + .try_get("event_type") + .map_err(|err| repository_storage_error::("decode outbox event type row", err))?, + payload: row + .try_get("payload") + .map_err(|err| repository_storage_error::("decode outbox payload row", err))?, + payload_codec: row.try_get("payload_codec").map_err(|err| { + repository_storage_error::("decode outbox payload codec row", err) + })?, + payload_codec_version: repository_u16_from_i64( + DB::BACKEND, + row.try_get("payload_codec_version").map_err(|err| { + repository_storage_error::("decode outbox payload codec version row", err) + })?, + "outbox payload codec version", + )?, + metadata, + status, + created_at: DB::decode_timestamp(&row, "created_at")?, + worker_id: row + .try_get("claimed_by") + .map_err(|err| repository_storage_error::("decode outbox claimed_by row", err))?, + leased_until: DB::decode_optional_timestamp(&row, "claimed_until")?, + attempts: u32::try_from(attempts).map_err(|_| { + RepositoryError::Model(format!( + "{} outbox attempts value {attempts} is invalid", + DB::BACKEND + )) + })?, + last_error: row + .try_get("last_error") + .map_err(|err| repository_storage_error::("decode outbox last_error row", err))?, + destination: row + .try_get("destination") + .map_err(|err| repository_storage_error::("decode outbox destination row", err))?, + source_aggregate_type: row.try_get("source_aggregate_type").map_err(|err| { + repository_storage_error::("decode outbox source aggregate type row", err) + })?, + source_aggregate_id: row.try_get("source_aggregate_id").map_err(|err| { + repository_storage_error::("decode outbox source aggregate id row", err) + })?, + source_sequence, + }) +} + +/// Convert a [`SystemTime`] to epoch seconds for database-side comparisons. +pub(crate) fn system_time_epoch_secs( + timestamp: SystemTime, +) -> Result { + let duration = timestamp.duration_since(UNIX_EPOCH).map_err(|err| { + RepositoryError::Model(format!( + "timestamp before UNIX epoch cannot be stored in {}: {err}", + DB::BACKEND + )) + })?; + Ok(duration.as_secs_f64()) +} + +pub(crate) fn repository_storage_error( + operation: &str, + err: sqlx::Error, +) -> RepositoryError { + crate::sqlx_repo::repository_storage_error(DB::BACKEND, operation, err) +} diff --git a/tests/bomberman/main.rs b/tests/bomberman/main.rs index e694427e..93b1c6f5 100644 --- a/tests/bomberman/main.rs +++ b/tests/bomberman/main.rs @@ -211,7 +211,7 @@ async fn player_killed_by_bomb() { use distributed::{OutboxMessageStatus, OutboxStore}; let pending = repo2 .outbox_store() - .messages_by_status(OutboxMessageStatus::Pending) + .messages_by_status(OutboxMessageStatus::Pending, usize::MAX) .await .unwrap(); assert!(!pending.is_empty()); diff --git a/tests/distributed_read_model/main.rs b/tests/distributed_read_model/main.rs index 9890f3c4..482b1f50 100644 --- a/tests/distributed_read_model/main.rs +++ b/tests/distributed_read_model/main.rs @@ -361,7 +361,7 @@ where S: OutboxStore + Send + Sync, { let pending = store - .pending() + .pending(usize::MAX) .await .expect("pending outbox messages should load"); assert!( diff --git a/tests/durable_enqueue_sqlite/main.rs b/tests/durable_enqueue_sqlite/main.rs index a925ca98..3568d808 100644 --- a/tests/durable_enqueue_sqlite/main.rs +++ b/tests/durable_enqueue_sqlite/main.rs @@ -70,13 +70,13 @@ async fn commit_publishes_immediately_over_sqlite() { .unwrap(); let published = store - .messages_by_status(OutboxMessageStatus::Published) + .messages_by_status(OutboxMessageStatus::Published, usize::MAX) .await .unwrap(); assert_eq!(published.len(), 1, "row should be published immediately"); assert_eq!(published[0].id(), "evt-c1"); assert!( - store.pending().await.unwrap().is_empty(), + store.pending(usize::MAX).await.unwrap().is_empty(), "nothing should be left for the poller" ); } @@ -101,7 +101,7 @@ async fn run_consumes_command_and_publishes_over_sqlite() { service.run(RunOptions::idempotent()).await.unwrap(); let published = store - .messages_by_status(OutboxMessageStatus::Published) + .messages_by_status(OutboxMessageStatus::Published, usize::MAX) .await .unwrap(); assert_eq!(published.len(), 1); diff --git a/tests/microsvc/convention.rs b/tests/microsvc/convention.rs index 044058dc..3ce5fff0 100644 --- a/tests/microsvc/convention.rs +++ b/tests/microsvc/convention.rs @@ -121,7 +121,7 @@ async fn create_persists_outbox_message() { assert_eq!(counter.value, 0); // Outbox message was persisted - let pending = store.outbox_store().pending().await.unwrap(); + let pending = store.outbox_store().pending(usize::MAX).await.unwrap(); assert_eq!(pending.len(), 1); assert_eq!(pending[0].event_type, "counter.initialized"); } @@ -145,7 +145,7 @@ async fn duplicate_create_leaves_single_outbox_message() { .await; assert!(result.is_err()); - let pending = store.outbox_store().pending().await.unwrap(); + let pending = store.outbox_store().pending(usize::MAX).await.unwrap(); assert_eq!(pending.len(), 1); } @@ -184,7 +184,7 @@ async fn increment_persists_outbox_message() { assert_eq!(counter.value, 7); // Both outbox messages were persisted - let pending = store.outbox_store().pending().await.unwrap(); + let pending = store.outbox_store().pending(usize::MAX).await.unwrap(); assert_eq!(pending.len(), 2); let mut event_types: Vec<&str> = pending.iter().map(|m| m.event_type.as_str()).collect(); event_types.sort(); diff --git a/tests/postgres_repository/main.rs b/tests/postgres_repository/main.rs index 96684fb0..100f7992 100644 --- a/tests/postgres_repository/main.rs +++ b/tests/postgres_repository/main.rs @@ -522,7 +522,7 @@ async fn outbox_metadata_columns_round_trip_into_message_metadata() { let stored = repo .outbox_store() - .messages_by_status(OutboxMessageStatus::Pending) + .messages_by_status(OutboxMessageStatus::Pending, usize::MAX) .await .unwrap() .into_iter() @@ -613,15 +613,13 @@ async fn backend_termination_mid_commit_rolls_back_and_nothing_persists() { matches!(&err, RepositoryError::Storage { .. }), "the terminated commit surfaces as a storage error, got {err:?}" ); - // KNOWN CLASSIFICATION GAP (documented in the PR, not fixed here): the - // termination surfaces as SQLSTATE 57P01, which `is_sqlx_transient` - // classifies as permanent — it whitelists only 40001/40P01 among - // `Database` errors. Losing the connection is an infrastructure hiccup, - // so this SHOULD be retryable; when the classification is fixed, flip - // this to `assert!(err.is_retryable())`. + // The termination surfaces as SQLSTATE 57P01; `is_sqlx_transient` now + // classifies 57P01/57P02/57P03 and class-08 connection failures as + // transient (losing the connection is an infrastructure hiccup), so the + // error is retryable. assert!( - !err.is_retryable(), - "pinned current (mis)classification of 57P01 — see comment above; got {err:?}" + err.is_retryable(), + "57P01 (admin shutdown) must be retryable; got {err:?}" ); // Release the lock and prove the whole transaction rolled back. diff --git a/tests/sourced_snapshot/main.rs b/tests/sourced_snapshot/main.rs index bebc386b..55dfeb88 100644 --- a/tests/sourced_snapshot/main.rs +++ b/tests/sourced_snapshot/main.rs @@ -260,7 +260,12 @@ async fn domain_event_commits_with_outbox() { let loaded = repo.get("t1").await.unwrap().unwrap(); assert_eq!(loaded.snapshot().task, "Ship it"); - let pending = repo.repo().outbox_store().pending().await.unwrap(); + let pending = repo + .repo() + .outbox_store() + .pending(usize::MAX) + .await + .unwrap(); assert_eq!(pending.len(), 1); assert!(pending[0].is_pending()); } diff --git a/tests/sqlite_repository/main.rs b/tests/sqlite_repository/main.rs index fe66dde0..cf417d49 100644 --- a/tests/sqlite_repository/main.rs +++ b/tests/sqlite_repository/main.rs @@ -564,7 +564,7 @@ async fn outbox_metadata_columns_round_trip_into_message_metadata() { let stored = repo .outbox_store() - .messages_by_status(OutboxMessageStatus::Pending) + .messages_by_status(OutboxMessageStatus::Pending, usize::MAX) .await .unwrap() .into_iter() diff --git a/tests/support/outbox.rs b/tests/support/outbox.rs index da1e4cf4..832a9d0e 100644 --- a/tests/support/outbox.rs +++ b/tests/support/outbox.rs @@ -17,7 +17,7 @@ where OutboxMessageStatus::Failed, ] { let messages = outbox - .messages_by_status(status) + .messages_by_status(status, usize::MAX) .await .expect("outbox status lookup should succeed"); if let Some(message) = messages.into_iter().find(|message| message.id() == id) { diff --git a/tests/todos/main.rs b/tests/todos/main.rs index 0a5c80e9..3f5f0a57 100644 --- a/tests/todos/main.rs +++ b/tests/todos/main.rs @@ -77,7 +77,7 @@ async fn load_outbox_message(repo: &HashMapRepository, id: &str) -> OutboxMessag OutboxMessageStatus::Failed, ] { if let Some(message) = store - .messages_by_status(status) + .messages_by_status(status, usize::MAX) .await .unwrap() .into_iter() @@ -104,7 +104,13 @@ async fn todos() { // Verify the outbox event was captured { - let pending = repo.repo().inner().outbox_store().pending().await.unwrap(); + let pending = repo + .repo() + .inner() + .outbox_store() + .pending(usize::MAX) + .await + .unwrap(); assert_eq!(pending.len(), 1); assert_eq!(pending[0].event_type, "todo.initialized"); } @@ -125,7 +131,13 @@ async fn todos() { .expect("completed todo outbox commit should succeed"); { - let pending = repo.repo().inner().outbox_store().pending().await.unwrap(); + let pending = repo + .repo() + .inner() + .outbox_store() + .pending(usize::MAX) + .await + .unwrap(); assert_eq!(pending.len(), 2); assert!(pending .iter() @@ -223,7 +235,7 @@ async fn outbox_records_persisted() { repo.outbox(message).commit(&mut todo).await.unwrap(); // Check pending outbox messages - let pending = repo.outbox_store().pending().await.unwrap(); + let pending = repo.outbox_store().pending(usize::MAX).await.unwrap(); assert_eq!(pending.len(), 1); assert_eq!(pending[0].event_type, "todo.initialized"); @@ -510,7 +522,10 @@ async fn outbox_dispatch_drains_one_row_at_a_time() { let message = load_outbox_message(&repo, id).await; assert!(message.is_published()); } - assert_eq!(repo.outbox_store().pending().await.unwrap().len(), 0); + assert_eq!( + repo.outbox_store().pending(usize::MAX).await.unwrap().len(), + 0 + ); assert_eq!(dispatcher.publisher().messages().len(), 3); } diff --git a/tests/transport_conformance/mod.rs b/tests/transport_conformance/mod.rs index 368b3037..bb820474 100644 --- a/tests/transport_conformance/mod.rs +++ b/tests/transport_conformance/mod.rs @@ -455,9 +455,10 @@ impl OutboxStore for CompleteOnceFailingStore { fn messages_by_status( &self, status: OutboxMessageStatus, + limit: usize, ) -> impl std::future::Future, RepositoryError>> + Send + '_ { - self.inner.messages_by_status(status) + self.inner.messages_by_status(status, limit) } fn claim(