Skip to main content

gwk_kernel/
store.rs

1//! The PostgreSQL [`EventStore`].
2//!
3//! Ordering is the whole job. `global_sequence` is assigned inside the append
4//! transaction, under the `gwk_internal.writer` row lock, and that lock is held
5//! until commit — so the order values are handed out IS the order transactions
6//! commit in. Unique and strictly increasing, deliberately not gapless: a
7//! rolled-back append gives its number back, a crashed one may not, and no
8//! reader is allowed to care.
9//!
10//! This is why the column is not `BIGSERIAL`. A sequence allocates at INSERT
11//! time while transactions commit in another order, so a reader paging an
12//! allocation-ordered column can see N+1 committed before N exists and then
13//! never see N at all. That hole cannot be patched by reading more carefully;
14//! it has to be closed at allocation.
15//!
16//! Writes are admitted through a bounded permit set and then serialize on that
17//! same row lock. The bound is what turns a flood into a refusal instead of an
18//! unbounded queue of connections waiting on one row.
19
20use gwk_domain::checkpoint::{CHECKPOINT_EVENT_INTERVAL, CHECKPOINT_INTERVAL_SECS};
21use gwk_domain::envelope::{Actor, EventEnvelope, INLINE_PAYLOAD_MAX_BYTES, Origin, PayloadRef};
22use gwk_domain::ids::{
23    AggregateId, CorrelationId, EventId, FenceToken, IdempotencyKey, ProjectId, Seq, Timestamp,
24};
25use gwk_domain::port::{AppendError, EventStore, MAX_READ_LIMIT, StorageError};
26use secrecy::{ExposeSecret, SecretString};
27use sqlx::postgres::PgPoolOptions;
28use sqlx::{Executor, PgConnection, PgPool, Row, postgres::PgRow};
29use tokio::sync::{Semaphore, SemaphorePermit};
30
31// Aliased, not imported bare: this module's signatures are the port's, whose
32// error types are AppendError/StorageError, so a `Result<T>` in scope that
33// means `Result<T, KernelError>` would shadow exactly the wrong thing.
34use crate::blob::store::PgBlobStore;
35use crate::checkpoint;
36use crate::error::Result as KernelResult;
37use crate::numeric::{from_numeric_text, to_numeric_text};
38use crate::writer::claim_epoch;
39
40/// How many appends may be in flight before the store refuses.
41///
42/// They all serialize on one row lock, so this is a queue depth, not a
43/// parallelism setting. Its job is to make overload a fast, typed refusal
44/// rather than a growing pile of connections blocked on the same row.
45pub const MAX_INFLIGHT_APPENDS: usize = 64;
46
47/// The wake-up channel. Carries the new watermark, but only as a hint: a
48/// consumer that never receives a single notification still recovers the whole
49/// suffix from its durable cursor, which is what makes this channel droppable.
50pub const EVENT_CHANNEL: &str = "gwk_event";
51
52/// Reported as "presented" when an append omitted a fence entirely. Real tokens
53/// start at 1, so this cannot collide with one.
54const NO_FENCE_PRESENTED: u64 = 0;
55
56/// Every column of an event, shaped for [`row_to_envelope`].
57///
58/// `seq` crosses as decimal text (see [`crate::numeric`]). Timestamps cross
59/// through `to_json`, which renders RFC 3339 with microseconds — exact, and
60/// stable because [`connect_pool`] pins every session to UTC.
61///
62/// A macro rather than a `const` so callers can `concat!` it into a real string
63/// LITERAL. sqlx 0.9 refuses a runtime-built query unless it is wrapped in
64/// `AssertSqlSafe`, and an assertion is a promise; `concat!` is a proof — the
65/// compiler can see there is no runtime data in these statements at all.
66macro_rules! event_columns {
67    () => {
68        "seq::text AS seq_text, event_id, project_id, aggregate_type, \
69         aggregate_id, aggregate_version, event_type, schema_version, \
70         to_json(occurred_at) #>> '{}' AS occurred_at, \
71         to_json(appended_at) #>> '{}' AS appended_at, \
72         actor, origin, causation_id, correlation_id, idempotency_key, payload, payload_ref"
73    };
74}
75
76/// Open a pool whose sessions are pinned to UTC.
77///
78/// Timestamps are read back through `to_json`, which renders in the session's
79/// time zone. Without this every deployment would embed its server's local
80/// offset in the log's text, and a rebuild on a differently configured host
81/// would not be byte-identical.
82pub async fn connect_pool(
83    database_url: &SecretString,
84    max_connections: u32,
85) -> KernelResult<PgPool> {
86    let pool = PgPoolOptions::new()
87        .max_connections(max_connections)
88        .after_connect(|conn, _meta| {
89            Box::pin(async move {
90                conn.execute("SET TIME ZONE 'UTC'").await?;
91                Ok(())
92            })
93        })
94        .connect(database_url.expose_secret())
95        .await?;
96    Ok(pool)
97}
98
99/// The PostgreSQL event store.
100pub struct PgEventStore {
101    pool: PgPool,
102    admission: Semaphore,
103    boot_epoch: i64,
104    /// Where a checkpoint's records go. `None` means this store takes no
105    /// snapshots — see [`PgEventStore::with_blobs`].
106    blobs: Option<PgBlobStore>,
107}
108
109impl PgEventStore {
110    /// Bind to an initialized database and claim this boot's writer epoch.
111    ///
112    /// Claiming here rather than lazily means a superseded process discovers it
113    /// at startup, not at the first append.
114    pub async fn open(pool: PgPool) -> KernelResult<Self> {
115        Self::with_capacity(pool, MAX_INFLIGHT_APPENDS).await
116    }
117
118    /// [`open`](Self::open) with an explicit admission bound.
119    ///
120    /// Exists so a test can saturate the queue deterministically — proving the
121    /// bound refuses rather than queues needs a bound small enough to fill.
122    pub async fn with_capacity(pool: PgPool, inflight: usize) -> KernelResult<Self> {
123        let boot_epoch = claim_epoch(&pool).await?;
124        Ok(Self {
125            pool,
126            admission: Semaphore::new(inflight),
127            boot_epoch,
128            blobs: None,
129        })
130    }
131
132    /// A store that can READ the log and cannot write it.
133    ///
134    /// It claims no epoch, and that is the whole point. `claim_epoch` BUMPS a
135    /// durable counter, so a verification path that opened an ordinary store
136    /// would fence the daemon it was verifying out of its own log — the
137    /// rebuild-into-scratch comparison is meant to run against a live system,
138    /// not to take it down.
139    ///
140    /// Epoch 0 is never a live epoch, so the fence every mutating transaction
141    /// already compares refuses this store's appends by construction. A reader
142    /// cannot write even by mistake, and the guarantee costs no new check.
143    pub fn open_reader(pool: PgPool) -> Self {
144        Self {
145            pool,
146            admission: Semaphore::new(MAX_INFLIGHT_APPENDS),
147            boot_epoch: 0,
148            blobs: None,
149        }
150    }
151
152    /// Attach the blob store a checkpoint writes its records to.
153    ///
154    /// Without it this store never checkpoints, and that is a real state rather
155    /// than a degraded one: `admin init` and the certifier drive the log with no
156    /// filesystem at all, and a snapshot they neither want nor could store is
157    /// not something to make them configure around. What must not happen is a
158    /// SERVING kernel in that state, which is why the daemon's own construction
159    /// takes the blob store rather than opting into it.
160    #[must_use]
161    pub fn with_blobs(mut self, blobs: PgBlobStore) -> Self {
162        self.blobs = Some(blobs);
163        self
164    }
165
166    /// The blob store checkpoints write through, if one is attached.
167    pub fn blobs(&self) -> Option<&PgBlobStore> {
168        self.blobs.as_ref()
169    }
170
171    /// The checkpoint barrier, if either bound has tripped.
172    ///
173    /// Called AFTER the caller has written this batch's projections, and that
174    /// ordering is the whole correctness argument. A checkpoint claims to
175    /// describe the state through a sequence; taken inside `append_locked` it
176    /// would run before the projections of the very events it names, producing
177    /// a snapshot one batch stale that then fails its own validation forever.
178    /// The test that caught it compares the stored hash against a re-read.
179    ///
180    /// Still inside the caller's transaction and still under the writer row
181    /// lock, which is what "writer barrier" means: every other appender is
182    /// blocked, so the snapshot sees this transaction's rows and nobody's
183    /// half-written ones.
184    ///
185    /// A snapshot that FAILS fails the append. Swallowing it would be worse by
186    /// far — a barrier that silently stops firing grows recovery time without
187    /// bound, and the first anyone hears of it is a restart that replays the
188    /// entire log.
189    pub(crate) async fn checkpoint_if_due(
190        &self,
191        tx: &mut PgConnection,
192        writer: &WriterState,
193        through: Seq,
194        created_at: &Timestamp,
195    ) -> Result<(), AppendError> {
196        let Some(blobs) = &self.blobs else {
197            return Ok(());
198        };
199        if !writer.checkpoint_due(through.value()) {
200            return Ok(());
201        }
202        checkpoint::snapshot(tx, blobs, through, created_at)
203            .await
204            .map(|_| ())
205            .map_err(|e| AppendError::Storage(format!("checkpoint: {e}")))
206    }
207
208    /// Snapshot the projections at the watermark, whether or not one is due.
209    ///
210    /// The one snapshot no append triggers. [`Self::checkpoint_if_due`] rides an
211    /// append's transaction and is therefore always AT that append's sequence; a
212    /// clean shutdown has no append to ride, so it takes the same writer barrier
213    /// itself — without it the hash would be taken over projections another
214    /// process is still moving, and a checkpoint that describes no single moment
215    /// is worse than none.
216    ///
217    /// This is what makes [`Verdict::Verified`](crate::recover::Verdict::Verified)
218    /// reachable in practice: it requires a checkpoint exactly at the watermark,
219    /// and the event-interval barrier only ever lands on a multiple of
220    /// [`CHECKPOINT_EVENT_INTERVAL`]. Without a snapshot on the way out, a
221    /// cleanly stopped kernel would restart into `Unverified` — honest, but a
222    /// weaker statement than it was entitled to make.
223    ///
224    /// `Ok(None)` means there was nothing to snapshot: no blob store to put the
225    /// records in, or an empty log.
226    pub async fn checkpoint_at_watermark(&self) -> Result<Option<Seq>, AppendError> {
227        let Some(blobs) = &self.blobs else {
228            return Ok(None);
229        };
230        let mut tx = self
231            .pool
232            .begin()
233            .await
234            .map_err(|e| append_storage("begin a shutdown checkpoint", e))?;
235        // The barrier, taken for the same reason every append takes it: nothing
236        // else may be writing while these rows are hashed.
237        let _writer = self.lock_writer(&mut tx).await?;
238        let at: (Option<String>, String) =
239            sqlx::query_as("SELECT max(seq)::text, to_json(now()) #>> '{}' FROM gwk.event")
240                .fetch_one(&mut *tx)
241                .await
242                .map_err(|e| append_storage("read the watermark", e))?;
243        let Some(watermark) = at.0 else {
244            // An empty log has no sequence to anchor a checkpoint at, and its
245            // projections are empty by construction.
246            return Ok(None);
247        };
248        let through =
249            Seq::new(from_numeric_text(&watermark).map_err(|e| append_storage("watermark", e))?);
250        checkpoint::snapshot(&mut tx, blobs, through, &Timestamp::new(at.1))
251            .await
252            .map_err(|e| AppendError::Storage(format!("shutdown checkpoint: {e}")))?;
253        tx.commit()
254            .await
255            .map_err(|e| append_storage("commit a shutdown checkpoint", e))?;
256        Ok(Some(through))
257    }
258
259    /// The epoch this process must present for the rest of its life.
260    pub fn boot_epoch(&self) -> i64 {
261        self.boot_epoch
262    }
263
264    pub fn pool(&self) -> &PgPool {
265        &self.pool
266    }
267
268    /// Take one admission permit, or refuse.
269    ///
270    /// `try_acquire` rather than `acquire`: under load the right answer is a
271    /// refusal the caller can act on, not a queue.
272    pub(crate) fn admit(&self) -> Result<SemaphorePermit<'_>, AppendError> {
273        self.admission.try_acquire().map_err(|_| {
274            AppendError::Storage(format!(
275                "append queue is full ({MAX_INFLIGHT_APPENDS} in flight)"
276            ))
277        })
278    }
279
280    /// Lock the writer row for the rest of this transaction and prove this
281    /// process still holds write authority.
282    ///
283    /// ONE locked read: the epoch to compare, the fence to check, the sequence
284    /// to allocate. The lock is held to commit, which is what makes allocation
285    /// order equal commit order — so a caller that will DECIDE something from a
286    /// read (the command path reading an aggregate's current version) must take
287    /// this lock BEFORE that read, or its decision races the writer it is about
288    /// to become.
289    pub(crate) async fn lock_writer(
290        &self,
291        tx: &mut PgConnection,
292    ) -> Result<WriterState, AppendError> {
293        let writer = sqlx::query(
294            "SELECT epoch, fence_token::text AS fence_text, next_seq::text AS next_seq_text, \
295                    checkpoint_seq::text AS checkpoint_seq_text, \
296                    now() - checkpoint_at >= make_interval(secs => $1::double precision) \
297                      AS checkpoint_overdue \
298             FROM gwk_internal.writer WHERE id = 1 FOR UPDATE",
299        )
300        .bind(CHECKPOINT_INTERVAL_SECS as f64)
301        .fetch_optional(&mut *tx)
302        .await
303        .map_err(|e| append_storage("lock writer row", e))?
304        .ok_or_else(|| {
305            AppendError::Storage(
306                "gwk_internal.writer has no singleton row: database not initialized".to_owned(),
307            )
308        })?;
309
310        let epoch: i64 = writer
311            .try_get("epoch")
312            .map_err(|e| append_storage("column epoch", e))?;
313        if epoch != self.boot_epoch {
314            // Terminal, not retryable: only a restart can claim a fresh epoch.
315            // The writer-lock probe cancels the server on the same event; this
316            // is the guard for work already in flight when that happened.
317            return Err(AppendError::Storage(format!(
318                "writer epoch {} was superseded by {epoch}: another kernel took write authority",
319                self.boot_epoch
320            )));
321        }
322
323        let current_fence: Option<u64> = writer
324            .try_get::<Option<String>, _>("fence_text")
325            .map_err(|e| append_storage("column fence_token", e))?
326            .map(|text| from_numeric_text(&text))
327            .transpose()
328            .map_err(|e| append_storage("column fence_token", e))?;
329        let next_seq = from_numeric_text(
330            &writer
331                .try_get::<String, _>("next_seq_text")
332                .map_err(|e| append_storage("column next_seq", e))?,
333        )
334        .map_err(|e| append_storage("column next_seq", e))?;
335
336        let checkpoint_seq = from_numeric_text(
337            &writer
338                .try_get::<String, _>("checkpoint_seq_text")
339                .map_err(|e| append_storage("column checkpoint_seq", e))?,
340        )
341        .map_err(|e| append_storage("column checkpoint_seq", e))?;
342        let checkpoint_overdue: bool = writer
343            .try_get("checkpoint_overdue")
344            .map_err(|e| append_storage("column checkpoint_at", e))?;
345
346        Ok(WriterState {
347            next_seq,
348            current_fence,
349            checkpoint_seq,
350            checkpoint_overdue,
351        })
352    }
353
354    /// Append one aggregate's batch inside a transaction that already holds the
355    /// writer row lock, WITHOUT committing.
356    ///
357    /// Split out so the command path can write its projections into the same
358    /// transaction: the plan requires events and projections to land together,
359    /// and duplicating this fence/idempotency/CAS/allocate sequence to get that
360    /// is how the two would drift.
361    pub(crate) async fn append_locked(
362        &self,
363        tx: &mut PgConnection,
364        writer: &WriterState,
365        expected_version: u32,
366        fence: Option<FenceToken>,
367        events: &[EventEnvelope],
368    ) -> Result<Appended, AppendError> {
369        validate_batch(expected_version, events)?;
370        let first = &events[0];
371        let aggregate_type = first.aggregate_type.clone();
372        let aggregate_id = first.aggregate_id.as_str().to_owned();
373
374        // Before the first grant any token is accepted, including none. After
375        // it, the current token is mandatory — omitting one must not be a way
376        // around the check.
377        if let Some(current) = writer.current_fence {
378            let presented = fence.map(|f| f.value()).unwrap_or(NO_FENCE_PRESENTED);
379            if presented != current {
380                return Err(AppendError::Fenced {
381                    presented: FenceToken::new(presented),
382                    current: FenceToken::new(current),
383                });
384            }
385        }
386
387        // Idempotency BEFORE the CAS. A retry presents the SAME
388        // `expected_version` it did the first time, which the CAS would now
389        // reject as a conflict — so a replay has to be recognized before the
390        // version check ever runs, or retry-stability is unreachable.
391        let keys: Vec<String> = events
392            .iter()
393            .filter_map(|e| e.idempotency_key.as_ref())
394            .map(|k| k.as_str().to_owned())
395            .collect();
396        if !keys.is_empty() {
397            let stored = sqlx::query(concat!(
398                "SELECT ",
399                event_columns!(),
400                " FROM gwk.event \
401                 WHERE aggregate_type = $1 AND aggregate_id = $2 AND idempotency_key = ANY($3) \
402                 ORDER BY seq"
403            ))
404            .bind(&aggregate_type)
405            .bind(&aggregate_id)
406            .bind(&keys)
407            .fetch_all(&mut *tx)
408            .await
409            .map_err(|e| append_storage("idempotency lookup", e))?;
410
411            if !stored.is_empty() {
412                if stored.len() == events.len() && keys.len() == events.len() {
413                    let stored = stored
414                        .iter()
415                        .map(row_to_envelope)
416                        .collect::<Result<Vec<_>, _>>()
417                        .map_err(|e| AppendError::Storage(e.0))?;
418                    // A count match is NOT a replay. This lookup is scoped to
419                    // the aggregate — the scope of the index it guards — so the
420                    // rows it found may belong to a different request entirely:
421                    // another project's, or the same project's under a reused
422                    // key. Answering those as a replay would hand the caller
423                    // someone else's events and report a command that never ran
424                    // as applied. The batch has to BE the stored one.
425                    if let Some((stored, presented)) = stored
426                        .iter()
427                        .zip(events)
428                        .find(|(stored, presented)| !is_replay_of(stored, presented))
429                    {
430                        return Err(AppendError::MalformedBatch(format!(
431                            "idempotency key {:?} already named {}/{} version {} in project {}: \
432                             a retry must present the identical batch",
433                            presented
434                                .idempotency_key
435                                .as_ref()
436                                .map(|k| k.as_str())
437                                .unwrap_or_default(),
438                            stored.aggregate_type,
439                            stored.aggregate_id,
440                            stored.aggregate_version,
441                            stored.project_id,
442                        )));
443                    }
444                    // Every event in this batch already landed under its key:
445                    // the original result, replayed.
446                    return Ok(Appended {
447                        events: stored,
448                        replayed: true,
449                    });
450                }
451                return Err(AppendError::MalformedBatch(format!(
452                    "{} of {} idempotency keys already landed for {aggregate_type}/{aggregate_id}: \
453                     a retry must present the identical batch",
454                    stored.len(),
455                    events.len()
456                )));
457            }
458        }
459
460        // CAS against the aggregate's current version.
461        let actual = current_aggregate_version(&mut *tx, &aggregate_type, &aggregate_id)
462            .await
463            .map_err(|e| AppendError::Storage(e.0))?;
464        if actual != expected_version {
465            return Err(AppendError::VersionConflict {
466                actual,
467                expected: expected_version,
468            });
469        }
470
471        let mut appended = Vec::with_capacity(events.len());
472        for (offset, event) in events.iter().enumerate() {
473            let seq = writer
474                .next_seq
475                .checked_add(offset as u64)
476                .ok_or_else(|| AppendError::Storage("global sequence exhausted".to_owned()))?;
477            // `now()` is transaction time, so every event in one commit shares
478            // an `appended_at` — which is the truth: they landed together.
479            let row = sqlx::query(concat!(
480                "INSERT INTO gwk.event ( \
481                   seq, event_id, project_id, aggregate_type, aggregate_id, aggregate_version, \
482                   event_type, schema_version, occurred_at, appended_at, actor, origin, \
483                   causation_id, correlation_id, idempotency_key, payload, payload_ref) \
484                 VALUES ($1::numeric, $2, $3, $4, $5, $6, $7, $8, $9::timestamptz, now(), \
485                   $10, $11, $12, $13, $14, $15, $16) \
486                 RETURNING ",
487                event_columns!()
488            ))
489            .bind(to_numeric_text(seq))
490            .bind(event.event_id.as_str())
491            .bind(event.project_id.as_str())
492            .bind(&event.aggregate_type)
493            .bind(event.aggregate_id.as_str())
494            .bind(i64::from(event.aggregate_version))
495            .bind(&event.event_type)
496            .bind(i64::from(event.schema_version))
497            .bind(event.occurred_at.as_str())
498            .bind(serde_json::to_value(&event.actor).map_err(|e| append_storage("actor", e))?)
499            .bind(serde_json::to_value(&event.origin).map_err(|e| append_storage("origin", e))?)
500            .bind(event.causation_id.as_ref().map(|v| v.as_str()))
501            .bind(event.correlation_id.as_ref().map(|v| v.as_str()))
502            .bind(event.idempotency_key.as_ref().map(|v| v.as_str()))
503            .bind(&event.payload)
504            .bind(
505                event
506                    .payload_ref
507                    .as_ref()
508                    .map(serde_json::to_value)
509                    .transpose()
510                    .map_err(|e| append_storage("payload_ref", e))?,
511            )
512            .fetch_one(&mut *tx)
513            .await
514            .map_err(|e| append_storage("insert event", e))?;
515            appended.push(row_to_envelope(&row).map_err(|e| AppendError::Storage(e.0))?);
516        }
517
518        let advanced = writer
519            .next_seq
520            .checked_add(events.len() as u64)
521            .ok_or_else(|| AppendError::Storage("global sequence exhausted".to_owned()))?;
522        sqlx::query("UPDATE gwk_internal.writer SET next_seq = $1::numeric WHERE id = 1")
523            .bind(to_numeric_text(advanced))
524            .execute(&mut *tx)
525            .await
526            .map_err(|e| append_storage("advance sequence", e))?;
527
528        // Queued inside the transaction, delivered by PostgreSQL only if it
529        // commits — so a wake-up never announces an append that rolled back.
530        sqlx::query("SELECT pg_notify($1, $2)")
531            .bind(EVENT_CHANNEL)
532            .bind(to_numeric_text(advanced - 1))
533            .execute(&mut *tx)
534            .await
535            .map_err(|e| append_storage("notify", e))?;
536
537        Ok(Appended {
538            events: appended,
539            replayed: false,
540        })
541    }
542}
543
544/// The writer row as read under its lock: what an append needs to allocate a
545/// sequence and prove it is still the writer.
546pub(crate) struct WriterState {
547    pub(crate) next_seq: u64,
548    pub(crate) current_fence: Option<u64>,
549    /// The sequence the last snapshot covered through.
550    pub(crate) checkpoint_seq: u64,
551    /// Whether [`CHECKPOINT_INTERVAL_SECS`] has passed since that snapshot.
552    /// Evaluated in the database, under the same lock, so every appender in
553    /// every process is reading one clock.
554    pub(crate) checkpoint_overdue: bool,
555}
556
557impl WriterState {
558    /// Is a snapshot due at `through`? Either bound trips it.
559    fn checkpoint_due(&self, through: u64) -> bool {
560        self.checkpoint_overdue
561            || through.saturating_sub(self.checkpoint_seq) >= CHECKPOINT_EVENT_INTERVAL
562    }
563}
564
565/// What an append produced, and whether it was a keyed retry.
566///
567/// `replayed` matters to a caller writing side rows in the same transaction:
568/// a replay's projections were written by the original append, and re-applying
569/// them would advance a CAS version the events do not account for.
570pub(crate) struct Appended {
571    pub(crate) events: Vec<EventEnvelope>,
572    pub(crate) replayed: bool,
573}
574
575/// Every event in a project that landed under one idempotency key.
576///
577/// Project-wide, not per-aggregate: one key names one request, so this is the
578/// lookup that tells a retry (same key, same target, same body) from a reuse
579/// (same key, anything else).
580///
581/// It spans BOTH unique indexes, because either one taking the key is enough to
582/// make the write impossible: `event_idempotency_project` on
583/// `(project_id, idempotency_key)`, and `event_idempotency` on
584/// `(aggregate_type, aggregate_id, idempotency_key)`. Neither contains the
585/// other — a key used on this same aggregate by a DIFFERENT project is invisible
586/// to the first and fatal to the second — so reading only one leaves the caller
587/// to discover the collision as a constraint violation instead of a typed
588/// refusal.
589pub(crate) async fn events_for_key(
590    conn: &mut PgConnection,
591    project_id: &str,
592    aggregate_type: &str,
593    aggregate_id: &str,
594    key: &str,
595) -> Result<Vec<EventEnvelope>, StorageError> {
596    let rows = sqlx::query(concat!(
597        "SELECT ",
598        event_columns!(),
599        " FROM gwk.event \
600         WHERE idempotency_key = $4 \
601           AND (project_id = $1 OR (aggregate_type = $2 AND aggregate_id = $3)) \
602         ORDER BY seq"
603    ))
604    .bind(project_id)
605    .bind(aggregate_type)
606    .bind(aggregate_id)
607    .bind(key)
608    .fetch_all(conn)
609    .await
610    .map_err(|e| storage("idempotency lookup", e))?;
611    rows.iter().map(row_to_envelope).collect()
612}
613
614/// The highest version an aggregate has reached, `0` when it has no events.
615pub(crate) async fn current_aggregate_version(
616    conn: &mut PgConnection,
617    aggregate_type: &str,
618    aggregate_id: &str,
619) -> Result<u32, StorageError> {
620    let actual: Option<i64> = sqlx::query_scalar(
621        "SELECT max(aggregate_version) FROM gwk.event \
622         WHERE aggregate_type = $1 AND aggregate_id = $2",
623    )
624    .bind(aggregate_type)
625    .bind(aggregate_id)
626    .fetch_one(conn)
627    .await
628    .map_err(|e| storage("read aggregate version", e))?;
629    u32::try_from(actual.unwrap_or(0)).map_err(|e| storage("aggregate_version out of range", e))
630}
631
632/// What a batch must be before it reaches the database.
633///
634/// Checked in Rust rather than left to constraint violations, so a caller gets
635/// one clear reason instead of whichever index happened to fire first.
636pub fn validate_batch(expected_version: u32, events: &[EventEnvelope]) -> Result<(), AppendError> {
637    let malformed = |reason: String| Err(AppendError::MalformedBatch(reason));
638    let Some(first) = events.first() else {
639        return malformed("empty batch".to_owned());
640    };
641    for (offset, event) in events.iter().enumerate() {
642        if event.aggregate_type != first.aggregate_type || event.aggregate_id != first.aggregate_id
643        {
644            return malformed(format!(
645                "batch mixes aggregates: {}/{} and {}/{}",
646                first.aggregate_type,
647                first.aggregate_id.as_str(),
648                event.aggregate_type,
649                event.aggregate_id.as_str()
650            ));
651        }
652        // Contiguous from expected_version + 1. Checked with u64 arithmetic so
653        // a batch that would run past u32::MAX is refused rather than wrapping.
654        let want = u64::from(expected_version) + 1 + offset as u64;
655        if want > u64::from(u32::MAX) {
656            return malformed(format!("aggregate_version would exceed {}", u32::MAX));
657        }
658        if u64::from(event.aggregate_version) != want {
659            return malformed(format!(
660                "aggregate_version {} at offset {offset}: expected {want} \
661                 (contiguous from expected_version + 1)",
662                event.aggregate_version
663            ));
664        }
665        // The column holds the envelope's full u32 range (bigint, CHECK-bounded
666        // to [1, 2^32-1]) — the only value left to refuse is the one below it.
667        // Caught here so a caller gets a named field instead of a CHECK
668        // violation, and refused rather than defaulted: the store may assign
669        // `global_sequence` and `appended_at` and nothing else, so inventing a
670        // schema_version would be the store inventing contract — permanently,
671        // in an append-only log, on the one field a decoder dispatches on.
672        if event.schema_version == 0 {
673            return malformed(format!(
674                "schema_version 0 at offset {offset} is not a version"
675            ));
676        }
677        // The log carries bounded metadata; anything larger belongs in a blob.
678        let inline = serde_json::to_vec(&event.payload)
679            .map(|bytes| bytes.len())
680            .unwrap_or(usize::MAX);
681        if inline > INLINE_PAYLOAD_MAX_BYTES {
682            return malformed(format!(
683                "inline payload at offset {offset} is {inline} bytes, over the \
684                 {INLINE_PAYLOAD_MAX_BYTES} bound — use payload_ref"
685            ));
686        }
687        // One key may identify at most one event per aggregate, so a batch that
688        // repeats one can never land. Caught here rather than left to the
689        // unique index, which would surface as an opaque storage error on the
690        // first attempt and a confusing partial-replay refusal on the retry.
691        if let Some(key) = &event.idempotency_key
692            && events[..offset]
693                .iter()
694                .any(|earlier| earlier.idempotency_key.as_ref() == Some(key))
695        {
696            return malformed(format!(
697                "idempotency key {:?} appears twice in one batch",
698                key.as_str()
699            ));
700        }
701    }
702    Ok(())
703}
704
705fn storage(context: &str, error: impl std::fmt::Display) -> StorageError {
706    StorageError(format!("{context}: {error}"))
707}
708
709fn append_storage(context: &str, error: impl std::fmt::Display) -> AppendError {
710    AppendError::Storage(format!("{context}: {error}"))
711}
712
713/// Rebuild an envelope from a row selected with [`EVENT_COLUMNS`].
714/// Read one page of the log through any executor.
715///
716/// Split out of [`EventStore::read_from`], which always reads from the pool,
717/// because recovery has to stream the log from inside its OWN snapshot
718/// transaction. A replay whose result is compared against a hash must see one
719/// fixed log; reading through a second connection would let the watermark move
720/// underneath it and turn a live append into a spurious divergence.
721pub(crate) async fn read_page<'e, E>(
722    executor: E,
723    cursor: Option<Seq>,
724    limit: usize,
725) -> Result<Vec<EventEnvelope>, StorageError>
726where
727    E: sqlx::PgExecutor<'e>,
728{
729    // Clamped, not refused: the port documents a huge limit as a request
730    // for "as much as you'll give me".
731    let limit = limit.min(MAX_READ_LIMIT) as i64;
732    // The `IS NULL OR` reads like it would defeat the primary-key index. It
733    // does not: the parameter is a constant at plan time, so PostgreSQL
734    // folds the branch away and plans an Index Only Scan with
735    // `Index Cond: (seq > $1)` — identical to writing the bare comparison.
736    // Checked on 16 with EXPLAIN; the null case folds to a plain ordered
737    // index scan, which is what "read from the beginning" wants anyway.
738    let rows = sqlx::query(concat!(
739        "SELECT ",
740        event_columns!(),
741        " FROM gwk.event \
742         WHERE $1::numeric IS NULL OR seq > $1::numeric \
743         ORDER BY seq LIMIT $2"
744    ))
745    .bind(cursor.map(|c| to_numeric_text(c.value())))
746    .bind(limit)
747    .fetch_all(executor)
748    .await
749    .map_err(|e| storage("read_from", e))?;
750    rows.iter().map(row_to_envelope).collect()
751}
752
753fn row_to_envelope(row: &PgRow) -> Result<EventEnvelope, StorageError> {
754    let get = |name: &str| -> Result<String, StorageError> {
755        row.try_get::<String, _>(name)
756            .map_err(|e| storage(&format!("column {name}"), e))
757    };
758    let opt = |name: &str| -> Result<Option<String>, StorageError> {
759        row.try_get::<Option<String>, _>(name)
760            .map_err(|e| storage(&format!("column {name}"), e))
761    };
762    let json = |name: &str| -> Result<serde_json::Value, StorageError> {
763        row.try_get::<serde_json::Value, _>(name)
764            .map_err(|e| storage(&format!("column {name}"), e))
765    };
766    let seq_text = get("seq_text")?;
767    let seq = from_numeric_text(&seq_text).map_err(|e| storage("column seq", e))?;
768    let aggregate_version: i64 = row
769        .try_get("aggregate_version")
770        .map_err(|e| storage("column aggregate_version", e))?;
771    // i64, not i32: the column is bigint so it can hold the envelope's whole u32
772    // range. Reading it narrow is not a silent truncation — the driver refuses
773    // the decode outright — but it does make every read of a valid log fail.
774    let schema_version: i64 = row
775        .try_get("schema_version")
776        .map_err(|e| storage("column schema_version", e))?;
777    let payload_ref: Option<serde_json::Value> = row
778        .try_get("payload_ref")
779        .map_err(|e| storage("column payload_ref", e))?;
780
781    Ok(EventEnvelope {
782        event_id: EventId::new(get("event_id")?),
783        project_id: ProjectId::new(get("project_id")?),
784        aggregate_type: get("aggregate_type")?,
785        aggregate_id: AggregateId::new(get("aggregate_id")?),
786        // The column is CHECK-bounded to the u32 range in the contract DDL, so
787        // a value outside it means the schema was tampered with, not that the
788        // conversion needs a fallback.
789        aggregate_version: u32::try_from(aggregate_version)
790            .map_err(|e| storage("aggregate_version out of range", e))?,
791        event_type: get("event_type")?,
792        schema_version: u32::try_from(schema_version)
793            .map_err(|e| storage("schema_version out of range", e))?,
794        global_sequence: Seq::new(seq),
795        occurred_at: Timestamp::new(get("occurred_at")?),
796        appended_at: Timestamp::new(get("appended_at")?),
797        actor: serde_json::from_value::<Actor>(json("actor")?)
798            .map_err(|e| storage("column actor", e))?,
799        origin: serde_json::from_value::<Origin>(json("origin")?)
800            .map_err(|e| storage("column origin", e))?,
801        causation_id: opt("causation_id")?.map(EventId::new),
802        correlation_id: opt("correlation_id")?.map(CorrelationId::new),
803        idempotency_key: opt("idempotency_key")?.map(IdempotencyKey::new),
804        payload: json("payload")?,
805        payload_ref: payload_ref
806            .map(serde_json::from_value::<PayloadRef>)
807            .transpose()
808            .map_err(|e| storage("column payload_ref", e))?,
809    })
810}
811
812/// Is `stored` the event that `presented` is re-presenting?
813///
814/// Compares the WHOLE envelope with the fields the caller does not control
815/// normalized away, rather than a hand-listed subset of the ones it does: a
816/// field added to [`EventEnvelope`] later joins this comparison automatically,
817/// where a list would quietly stop covering it and start passing a differing
818/// request off as a retry.
819///
820/// Three fields are excluded. `global_sequence` and `appended_at` are assigned
821/// by the store. `occurred_at` is normalized by the column: it goes in as
822/// whatever RFC 3339 spelling the caller used and comes back in PostgreSQL's,
823/// so `Z` becomes `+00:00` and an identical retry would otherwise never match.
824fn is_replay_of(stored: &EventEnvelope, presented: &EventEnvelope) -> bool {
825    let mut normalized = presented.clone();
826    normalized.global_sequence = stored.global_sequence;
827    normalized.appended_at = stored.appended_at.clone();
828    normalized.occurred_at = stored.occurred_at.clone();
829    normalized == *stored
830}
831
832impl EventStore for PgEventStore {
833    async fn append(
834        &self,
835        expected_version: u32,
836        fence: Option<FenceToken>,
837        events: Vec<EventEnvelope>,
838    ) -> Result<Vec<EventEnvelope>, AppendError> {
839        let _permit = self.admit()?;
840        let mut tx = self
841            .pool
842            .begin()
843            .await
844            .map_err(|e| append_storage("begin", e))?;
845        let writer = self.lock_writer(&mut tx).await?;
846        let appended = self
847            .append_locked(&mut tx, &writer, expected_version, fence, &events)
848            .await?;
849        // Committed even on a replay: nothing was written, so this only
850        // releases the writer lock the replay lookup was read under.
851        tx.commit().await.map_err(|e| append_storage("commit", e))?;
852        Ok(appended.events)
853    }
854
855    async fn read_from(
856        &self,
857        cursor: Option<Seq>,
858        limit: usize,
859    ) -> Result<Vec<EventEnvelope>, StorageError> {
860        read_page(&self.pool, cursor, limit).await
861    }
862
863    async fn watermark(&self) -> Result<Option<Seq>, StorageError> {
864        let text: Option<String> = sqlx::query_scalar("SELECT max(seq)::text FROM gwk.event")
865            .fetch_one(&self.pool)
866            .await
867            .map_err(|e| storage("watermark", e))?;
868        text.map(|t| from_numeric_text(&t))
869            .transpose()
870            .map(|opt| opt.map(Seq::new))
871            .map_err(|e| storage("watermark", e))
872    }
873
874    async fn grant_fence(&self) -> Result<FenceToken, StorageError> {
875        let text: String = sqlx::query_scalar(
876            "UPDATE gwk_internal.writer \
877             SET fence_token = coalesce(fence_token, 0) + 1 \
878             WHERE id = 1 RETURNING fence_token::text",
879        )
880        .fetch_optional(&self.pool)
881        .await
882        .map_err(|e| storage("grant_fence", e))?
883        .ok_or_else(|| StorageError("gwk_internal.writer has no singleton row".to_owned()))?;
884        from_numeric_text(&text)
885            .map(FenceToken::new)
886            .map_err(|e| storage("grant_fence", e))
887    }
888}
889
890#[cfg(test)]
891mod tests {
892    use gwk_domain::ids::Timestamp;
893
894    use super::*;
895
896    fn event(aggregate_id: &str, version: u32) -> EventEnvelope {
897        EventEnvelope {
898            event_id: EventId::new(format!("evt-{aggregate_id}-{version}")),
899            project_id: ProjectId::new("p"),
900            aggregate_type: "task".into(),
901            aggregate_id: AggregateId::new(aggregate_id),
902            aggregate_version: version,
903            event_type: "tick".into(),
904            schema_version: 1,
905            global_sequence: Seq::new(0),
906            occurred_at: Timestamp::new("2026-01-01T00:00:00Z"),
907            appended_at: Timestamp::new("2026-01-01T00:00:00Z"),
908            actor: Actor {
909                kind: "kernel".into(),
910                id: None,
911            },
912            origin: Origin {
913                system: "kernel".into(),
914                r#ref: None,
915            },
916            causation_id: None,
917            correlation_id: None,
918            idempotency_key: None,
919            payload: serde_json::json!({}),
920            payload_ref: None,
921        }
922    }
923
924    #[test]
925    fn a_batch_is_one_aggregate_contiguous_from_the_expected_version() {
926        validate_batch(0, &[event("a", 1), event("a", 2)]).expect("contiguous from 1");
927        validate_batch(7, &[event("a", 8)]).expect("contiguous from expected + 1");
928
929        for (expected, batch, why) in [
930            (0u32, vec![], "empty"),
931            (0, vec![event("a", 2)], "starts past expected + 1"),
932            (0, vec![event("a", 1), event("a", 3)], "gap"),
933            (0, vec![event("a", 1), event("a", 1)], "repeat"),
934            (5, vec![event("a", 1)], "ignores expected_version"),
935        ] {
936            let err = validate_batch(expected, &batch).expect_err(why);
937            assert!(
938                matches!(err, AppendError::MalformedBatch(_)),
939                "{why}: {err:?}"
940            );
941        }
942
943        let mixed = vec![event("a", 1), event("b", 2)];
944        let err = validate_batch(0, &mixed).expect_err("mixed aggregates");
945        let AppendError::MalformedBatch(reason) = err else {
946            panic!("expected MalformedBatch");
947        };
948        assert!(reason.contains("mixes aggregates"), "{reason}");
949    }
950
951    #[test]
952    fn a_batch_may_not_run_past_the_aggregate_version_ceiling() {
953        let mut last = event("a", u32::MAX);
954        last.aggregate_version = u32::MAX;
955        validate_batch(u32::MAX - 1, &[last.clone()]).expect("the final version is reachable");
956        // One more would need u32::MAX + 1, which must be refused rather than wrap.
957        let err = validate_batch(u32::MAX, &[last]).expect_err("past the ceiling");
958        let AppendError::MalformedBatch(reason) = err else {
959            panic!("expected MalformedBatch");
960        };
961        assert!(reason.contains("would exceed"), "{reason}");
962    }
963
964    #[test]
965    fn the_column_carries_every_schema_version_the_envelope_can_declare() {
966        // The column was `integer` and silently could not hold the top half of
967        // the envelope's u32 (3-prime NB-3). It is bigint now, so the whole
968        // declared range must round-trip through validation untouched.
969        for version in [1, i32::MAX as u32, i32::MAX as u32 + 1, u32::MAX] {
970            let mut e = event("a", 1);
971            e.schema_version = version;
972            validate_batch(0, &[e]).unwrap_or_else(|err| panic!("schema_version {version}: {err}"));
973        }
974
975        let mut zero = event("a", 1);
976        zero.schema_version = 0;
977        let err = validate_batch(0, &[zero]).expect_err("0 is not a version");
978        let AppendError::MalformedBatch(reason) = err else {
979            panic!("expected MalformedBatch");
980        };
981        assert!(reason.contains("schema_version"), "{reason}");
982    }
983
984    #[test]
985    fn one_key_may_not_appear_twice_in_a_batch() {
986        let key = |k: &str| Some(gwk_domain::ids::IdempotencyKey::new(k));
987        let mut a = event("a", 1);
988        let mut b = event("a", 2);
989        a.idempotency_key = key("same");
990        b.idempotency_key = key("same");
991        let err = validate_batch(0, &[a.clone(), b.clone()]).expect_err("repeated key");
992        let AppendError::MalformedBatch(reason) = err else {
993            panic!("expected MalformedBatch");
994        };
995        assert!(reason.contains("twice in one batch"), "{reason}");
996
997        // Distinct keys, and a keyed event beside an unkeyed one, are both fine.
998        b.idempotency_key = key("other");
999        validate_batch(0, &[a.clone(), b.clone()]).expect("distinct keys");
1000        b.idempotency_key = None;
1001        validate_batch(0, &[a, b]).expect("a mix of keyed and unkeyed");
1002    }
1003
1004    #[test]
1005    fn an_oversized_inline_payload_is_refused_before_it_reaches_the_log() {
1006        let mut big = event("a", 1);
1007        big.payload = serde_json::json!({ "blob": "x".repeat(INLINE_PAYLOAD_MAX_BYTES) });
1008        let err = validate_batch(0, &[big]).expect_err("oversized payload");
1009        let AppendError::MalformedBatch(reason) = err else {
1010            panic!("expected MalformedBatch");
1011        };
1012        assert!(reason.contains("payload_ref"), "{reason}");
1013    }
1014}