feat: add refstore-postgres, the Postgres RefStore backend
commit c450231
feat: add refstore-postgres, the Postgres RefStore backend
Implements WS4 of docs/scale-out.adoc: a cloud RefStore where
transaction() is one SQL transaction of conditional UPDATE/INSERT/DELETEs
(all-or-nothing CAS via RETURNING row counts), reflog rows land in the
same transaction, and watch() is LISTEN/NOTIFY fired on commit — a hint
only, per the trait contract; the effect queue table is the durable
at-least-once source of truth. Ships tables for refs, reflog, pack
registry, effect queue, and op records, plus minimal helpers over the
queue and op-record tables for future consumers (WS5/WS7, git-protocol).
Conformance runs the shared backend-conformance suite against a real
Postgres: GIT_ENTS_TEST_POSTGRES_URL if set, else a throwaway docker
container, else a visible skip (mirroring git-effect’s docker gate).
Ran successfully here against a local docker Postgres.
feat: add tokio-postgres to workspace dependencies (plain, no TLS)
Assisted-by: Claude:claude-sonnet-5
crates/refstore-postgres/migrations/0001_init.sql
@@ -1,0 +1,75 @@
+-- Schema for `refstore-postgres` (`docs/scale-out.adoc`, "RefStore" / WS4).
+-- Applied idempotently: every statement is guarded so running this file
+-- against an already-migrated database is a no-op.
+
+-- One row per ref. The primary key is also the prefix-iteration index: a
+-- `text_pattern_ops` index makes `LIKE 'prefix%'` scans (used by
+-- `iter_prefix`) index-backed regardless of the database's default locale,
+-- since that opclass compares raw bytes rather than collated text.
+CREATE TABLE IF NOT EXISTS git_ents_refs (
+ repo_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ oid TEXT NOT NULL,
+ PRIMARY KEY (repo_id, name)
+);
+
+CREATE INDEX IF NOT EXISTS git_ents_refs_prefix_idx
+ ON git_ents_refs (repo_id, name text_pattern_ops);
+
+-- Append-only reflog: one row per applied `RefEdit`, written in the same SQL
+-- transaction as the ref mutation it records.
+CREATE TABLE IF NOT EXISTS git_ents_reflog (
+ id BIGSERIAL PRIMARY KEY,
+ repo_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ old_oid TEXT,
+ new_oid TEXT,
+ message TEXT NOT NULL,
+ recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS git_ents_reflog_lookup_idx
+ ON git_ents_reflog (repo_id, name, id DESC);
+
+-- Pack registry: minimal columns only. WS5 (Tigris object store) consumes
+-- this to record promotion of staged packs; nothing here reads it yet.
+CREATE TABLE IF NOT EXISTS git_ents_pack_registry (
+ id BIGSERIAL PRIMARY KEY,
+ repo_id TEXT NOT NULL,
+ location_key TEXT NOT NULL,
+ promoted_at TIMESTAMPTZ
+);
+
+CREATE INDEX IF NOT EXISTS git_ents_pack_registry_repo_idx
+ ON git_ents_pack_registry (repo_id);
+
+-- Effect queue: the at-least-once source of truth `watch`'s NOTIFY hint
+-- points consumers back at (`docs/scale-out.adoc`, "RefStore": "the effect
+-- queue table ... is the source of truth").
+CREATE TABLE IF NOT EXISTS git_ents_effect_queue (
+ id BIGSERIAL PRIMARY KEY,
+ repo_id TEXT NOT NULL,
+ payload TEXT NOT NULL,
+ state TEXT NOT NULL DEFAULT 'enqueued'
+ CHECK (state IN ('enqueued', 'claimed', 'done')),
+ claimed_by TEXT,
+ enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ claimed_at TIMESTAMPTZ,
+ done_at TIMESTAMPTZ
+);
+
+CREATE INDEX IF NOT EXISTS git_ents_effect_queue_state_idx
+ ON git_ents_effect_queue (repo_id, state, id);
+
+-- Op records index: one row per accepted push, pointing at the op record
+-- object (the push-cert-plus-outcome artifact `git-protocol` builds via
+-- `attestation::build_op_record`) by OID.
+CREATE TABLE IF NOT EXISTS git_ents_op_records (
+ id BIGSERIAL PRIMARY KEY,
+ repo_id TEXT NOT NULL,
+ op_oid TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS git_ents_op_records_repo_idx
+ ON git_ents_op_records (repo_id, created_at DESC);
crates/refstore-postgres/src/lib.rs
@@ -1,0 +1,136 @@
+//! [`git_backend::RefStore`] over Postgres — the cloud default backend
+//! (`docs/scale-out.adoc`, "RefStore" / WS4).
+//!
+//! A row per ref, keyed `(repo_id, name)`; `transaction` is one SQL
+//! transaction of conditional `UPDATE`/`INSERT`/`DELETE`s, every edit's
+//! [`git_backend::Expected`] precondition checked by the statement's own
+//! `WHERE` clause and reported via `RETURNING` (see [`ref_store`]).
+//! `watch` is `LISTEN`/`NOTIFY`, fired on commit from `transaction`, and
+//! remains a wakeup hint only — the `git_ents_effect_queue` table (see
+//! [`queue`]) is what an at-least-once consumer actually drains (see
+//! [`notify`], and the trait contract on [`git_backend::RefStore::watch`]).
+//!
+//! # Q1: single write-primary
+//!
+//! This store assumes exactly one writable Postgres primary at a time. Fly
+//! managed-Postgres failover semantics must guarantee a fenced single
+//! primary or synchronous replication before this backend is deployed
+//! against it (`docs/scale-out.adoc`, Q1 / "Non-goals": "no distributed ref
+//! consensus"). A split-brain primary — two writers both believing they
+//! hold it — is the one unrecoverable failure mode for `RefStore`: two
+//! primaries would each serialize CAS locally and correctly, but the two
+//! serializations could disagree, which no amount of correct SQL here can
+//! detect or repair. Enforcing single-primary is deployment configuration
+//! (`fly-replay`, replica topology), not this crate's job — there is no
+//! fencing code here, deliberately.
+
+mod notify;
+mod queue;
+mod ref_store;
+
+pub use queue::{ClaimedEffect, EffectId};
+
+use git_backend::{Error, Result};
+
+/// Migration SQL applied idempotently by [`PostgresRefStore::migrate`]:
+/// refs, reflog, pack registry, effect queue, op records (`docs/
+/// scale-out.adoc`, WS4's schema list).
+const MIGRATION_SQL: &str = include_str!("../migrations/0001_init.sql");
+
+/// The reflog message every transaction's rows carry, mirroring
+/// `refstore-files`' fixed `LOG_MESSAGE` — a write through this backend is
+/// self-contained, not dependent on caller-supplied metadata.
+const LOG_MESSAGE: &str = "git-backend: transaction";
+
+/// [`git_backend::RefStore`] over a Postgres database: one row per ref,
+/// scoped to a single `repo_id` (`docs/scale-out.adoc`'s "namespace per
+/// repo" rule — no cross-repo query this store issues ever omits the
+/// `repo_id` filter).
+///
+/// Holds one [`tokio_postgres::Client`] behind a [`tokio::sync::Mutex`],
+/// per the dependency policy: no connection pool. `transaction` needs
+/// exclusive use of the connection for the duration of its SQL transaction
+/// (two overlapping `BEGIN`s on one session would corrupt each other), and
+/// serializing every other method through the same lock keeps the whole
+/// store's concurrency story in one place rather than reasoning about which
+/// methods are safe to interleave.
+///
+/// Owns a dedicated [`tokio::runtime::Runtime`] so [`git_backend::RefStore`]
+/// (a sync trait) can drive [`tokio_postgres`]'s async client; every trait
+/// method is a `block_on` call.
+pub struct PostgresRefStore {
+ runtime: tokio::runtime::Runtime,
+ client: tokio::sync::Mutex<tokio_postgres::Client>,
+ repo_id: String,
+ notify: tokio::sync::broadcast::Sender<String>,
+}
+
+/// Map a [`tokio_postgres::Error`] onto this crate's shared [`Error`] type.
+fn pg_err(error: tokio_postgres::Error) -> Error {
+ Error::RefStore(error.to_string())
+}
+
+impl PostgresRefStore {
+ /// Connect to `conninfo` (a libpq connection string, e.g. `"host=...
+ /// user=... dbname=..."`), scope every operation to `repo_id`, apply the
+ /// migration (see [`Self::migrate`]), and start listening for this
+ /// store's `NOTIFY` channel.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::RefStore`] if the connection, migration, or initial
+ /// `LISTEN` fails, or if the dedicated Tokio runtime cannot be created.
+ pub fn connect(conninfo: &str, repo_id: impl Into<String>) -> Result<Self> {
+ let runtime =
+ tokio::runtime::Runtime::new().map_err(|error| Error::RefStore(error.to_string()))?;
+ let (client, connection) = runtime
+ .block_on(tokio_postgres::connect(conninfo, tokio_postgres::NoTls))
+ .map_err(pg_err)?;
+
+ let (notify_tx, _receiver) = tokio::sync::broadcast::channel(notify::CHANNEL_CAPACITY);
+ runtime.spawn(notify::pump(connection, notify_tx.clone()));
+
+ let store = Self {
+ runtime,
+ client: tokio::sync::Mutex::new(client),
+ repo_id: repo_id.into(),
+ notify: notify_tx,
+ };
+ store.migrate()?;
+ store.listen()?;
+ Ok(store)
+ }
+
+ /// Apply the embedded migration SQL. Every statement is guarded (`CREATE
+ /// TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`), so calling this
+ /// again against an already-migrated database is a no-op. [`Self::connect`]
+ /// already calls this; exposed for callers that want migration as an
+ /// explicit, separately-timed step (e.g. a deploy hook run once ahead of
+ /// bringing up store instances).
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::RefStore`] if any migration statement fails.
+ pub fn migrate(&self) -> Result<()> {
+ self.runtime
+ .block_on(async {
+ let client = self.client.lock().await;
+ client.batch_execute(MIGRATION_SQL).await
+ })
+ .map_err(pg_err)
+ }
+
+ /// Issue this store's `LISTEN`, so `NOTIFY`s fired by any store (this
+ /// one or another process's) against the same channel reach this
+ /// connection's [`notify::pump`].
+ fn listen(&self) -> Result<()> {
+ self.runtime
+ .block_on(async {
+ let client = self.client.lock().await;
+ client
+ .batch_execute(&format!("LISTEN {}", notify::CHANNEL))
+ .await
+ })
+ .map_err(pg_err)
+ }
+}
crates/refstore-postgres/src/notify.rs
@@ -1,0 +1,91 @@
+//! `LISTEN`/`NOTIFY` plumbing behind [`crate::PostgresRefStore::watch`].
+//!
+//! Postgres delivers notifications as out-of-band messages on the very
+//! connection that issued `LISTEN`, surfaced through
+//! [`tokio_postgres::Connection::poll_message`] rather than through
+//! [`tokio_postgres::Client`]'s normal request/response methods. [`pump`]
+//! drives that connection for the lifetime of the store, broadcasting every
+//! payload to whichever [`crate::PostgresRefStore::watch`] calls are
+//! currently subscribed; nothing here is trusted as a delivery guarantee —
+//! see the trait-level contract on [`git_backend::RefStore::watch`].
+
+use tokio::sync::broadcast;
+use tokio_postgres::AsyncMessage;
+
+/// The fixed channel every store `LISTEN`s on and `NOTIFY`s through. One
+/// physical database can host many repositories' stores; each notification
+/// payload is prefixed with its repo id so listeners on this shared channel
+/// can filter out other repos' traffic (see [`decode`]).
+pub(crate) const CHANNEL: &str = "git_ents_refstore";
+
+/// How many undelivered payloads a slow [`git_backend::RefStore::watch`]
+/// subscriber tolerates before it starts missing them. `watch` is a hint
+/// only, so a lagging subscriber is told to assume something changed
+/// (see [`crate::ref_store::bridge_watch`]) rather than silently losing
+/// state.
+pub(crate) const CHANNEL_CAPACITY: usize = 256;
+
+/// Drive `connection`'s asynchronous messages for as long as the store
+/// lives, forwarding every `NOTIFY` payload to `sender`. Errors and a closed
+/// connection both end the pump silently: with nobody left to hand the
+/// error to, the only correct move is to stop, which simply turns every
+/// subsequent `watch` subscriber's hint stream quiet — never incorrect,
+/// per the trait's best-effort contract.
+pub(crate) async fn pump<S, T>(
+ mut connection: tokio_postgres::Connection<S, T>,
+ sender: broadcast::Sender<String>,
+) where
+ S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
+ T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
+{
+ loop {
+ let message = std::future::poll_fn(|cx| connection.poll_message(cx)).await;
+ match message {
+ Some(Ok(AsyncMessage::Notification(notification))) => {
+ let _ignored_no_subscribers = sender.send(notification.payload().to_owned());
+ }
+ Some(Ok(_)) => {}
+ Some(Err(_)) | None => break,
+ }
+ }
+}
+
+/// Build the payload one [`crate::ref_store`] transaction `NOTIFY`s with:
+/// the repo id, then one changed ref name per line. Ref names cannot
+/// contain control characters, so `\n` is a safe separator with no
+/// escaping needed.
+pub(crate) fn encode(repo_id: &str, changed_names: &[String]) -> String {
+ let mut payload = String::from(repo_id);
+ for name in changed_names {
+ payload.push('\n');
+ payload.push_str(name);
+ }
+ payload
+}
+
+/// Whether `payload` (as built by [`encode`]) reports a change for
+/// `repo_id` under `prefix`.
+pub(crate) fn matches(payload: &str, repo_id: &str, prefix: &str) -> bool {
+ let mut lines = payload.split('\n');
+ if lines.next() != Some(repo_id) {
+ return false;
+ }
+ lines.any(|name| name.starts_with(prefix))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{encode, matches};
+
+ #[test]
+ fn matches_filters_by_repo_and_prefix() {
+ let payload = encode(
+ "repo-a",
+ &["refs/heads/main".to_owned(), "refs/meta/x".to_owned()],
+ );
+ assert!(matches(&payload, "repo-a", "refs/heads/"));
+ assert!(matches(&payload, "repo-a", "refs/meta/"));
+ assert!(!matches(&payload, "repo-a", "refs/cache/"));
+ assert!(!matches(&payload, "repo-b", "refs/heads/"));
+ }
+}
crates/refstore-postgres/src/queue.rs
@@ -1,0 +1,167 @@
+//! Minimal Rust surface over `git_ents_effect_queue` and
+//! `git_ents_op_records` (`docs/scale-out.adoc`, WS4's schema list). Neither
+//! table is part of the [`git_backend::RefStore`] contract; these methods
+//! exist so the schema is exercised end-to-end rather than asserted only by
+//! its `CREATE TABLE` statements, and so a future dispatcher (WS7) and
+//! `git-protocol` (op records) have something to call.
+
+use git_backend::{Error, Result};
+use gix_hash::ObjectId;
+
+use crate::{PostgresRefStore, pg_err};
+
+/// The primary key of a row in `git_ents_effect_queue`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct EffectId(i64);
+
+/// One row claimed off the effect queue by [`PostgresRefStore::claim_effects`].
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ClaimedEffect {
+ /// The claimed row's id, needed to later call
+ /// [`PostgresRefStore::complete_effect`].
+ pub id: EffectId,
+ /// The effect payload enqueued by [`PostgresRefStore::enqueue_effect`].
+ pub payload: String,
+}
+
+impl PostgresRefStore {
+ /// Append one effect payload to this store's repo-scoped queue in state
+ /// `enqueued`, returning its id.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::RefStore`] if the insert fails.
+ pub fn enqueue_effect(&self, payload: &str) -> Result<EffectId> {
+ self.runtime
+ .block_on(async {
+ let client = self.client.lock().await;
+ client
+ .query_one(
+ "INSERT INTO git_ents_effect_queue (repo_id, payload)
+ VALUES ($1, $2) RETURNING id",
+ &[&self.repo_id, &payload],
+ )
+ .await
+ })
+ .map_err(pg_err)
+ .and_then(|row| row.try_get::<_, i64>(0).map(EffectId).map_err(pg_err))
+ }
+
+ /// Atomically claim up to `limit` of this store's oldest `enqueued`
+ /// rows for `claimed_by`, marking them `claimed` so a concurrent
+ /// dispatcher never double-claims them (`FOR UPDATE SKIP LOCKED`). This
+ /// is the at-least-once queue the doc mandates: rows survive here
+ /// independent of any `watch` subscriber's connection.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::RefStore`] if the claim query fails.
+ pub fn claim_effects(&self, claimed_by: &str, limit: i64) -> Result<Vec<ClaimedEffect>> {
+ let rows = self
+ .runtime
+ .block_on(async {
+ let client = self.client.lock().await;
+ client
+ .query(
+ "UPDATE git_ents_effect_queue
+ SET state = 'claimed', claimed_by = $1, claimed_at = now()
+ WHERE id IN (
+ SELECT id FROM git_ents_effect_queue
+ WHERE repo_id = $2 AND state = 'enqueued'
+ ORDER BY id
+ LIMIT $3
+ FOR UPDATE SKIP LOCKED
+ )
+ RETURNING id, payload",
+ &[&claimed_by, &self.repo_id, &limit],
+ )
+ .await
+ })
+ .map_err(pg_err)?;
+
+ rows.into_iter()
+ .map(|row| {
+ let id: i64 = row.try_get(0).map_err(pg_err)?;
+ let payload: String = row.try_get(1).map_err(pg_err)?;
+ Ok(ClaimedEffect {
+ id: EffectId(id),
+ payload,
+ })
+ })
+ .collect()
+ }
+
+ /// Mark a previously [`Self::claim_effects`]-claimed row `done`.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::RefStore`] if the update fails.
+ pub fn complete_effect(&self, id: EffectId) -> Result<()> {
+ self.runtime
+ .block_on(async {
+ let client = self.client.lock().await;
+ client
+ .execute(
+ "UPDATE git_ents_effect_queue SET state = 'done', done_at = now()
+ WHERE id = $1 AND repo_id = $2",
+ &[&id.0, &self.repo_id],
+ )
+ .await
+ })
+ .map_err(pg_err)
+ .map(|_rows_affected| ())
+ }
+
+ /// Record one accepted push's op record OID (`docs/scale-out.adoc`,
+ /// "Attested push": "Push ID = op record OID, uniformly").
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::RefStore`] if the insert fails.
+ pub fn record_op(&self, op_oid: ObjectId) -> Result<()> {
+ let hex = op_oid.to_hex().to_string();
+ self.runtime
+ .block_on(async {
+ let client = self.client.lock().await;
+ client
+ .execute(
+ "INSERT INTO git_ents_op_records (repo_id, op_oid) VALUES ($1, $2)",
+ &[&self.repo_id, &hex],
+ )
+ .await
+ })
+ .map_err(pg_err)
+ .map(|_rows_affected| ())
+ }
+
+ /// This store's recorded op record OIDs, most recent first.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::RefStore`] if the query fails, or if a stored OID is
+ /// not valid hex (a corrupted row, never written by
+ /// [`Self::record_op`]).
+ pub fn op_records(&self) -> Result<Vec<ObjectId>> {
+ let rows = self
+ .runtime
+ .block_on(async {
+ let client = self.client.lock().await;
+ client
+ .query(
+ "SELECT op_oid FROM git_ents_op_records
+ WHERE repo_id = $1 ORDER BY created_at DESC, id DESC",
+ &[&self.repo_id],
+ )
+ .await
+ })
+ .map_err(pg_err)?;
+
+ rows.into_iter()
+ .map(|row| {
+ let hex: String = row.try_get(0).map_err(pg_err)?;
+ ObjectId::from_hex(hex.as_bytes())
+ .map_err(|error| Error::RefStore(error.to_string()))
+ })
+ .collect()
+ }
+}
crates/refstore-postgres/src/ref_store.rs
@@ -1,0 +1,338 @@
+//! [`git_backend::RefStore`] for [`crate::PostgresRefStore`]: transaction =
+//! one SQL transaction of conditional `UPDATE`/`INSERT`/`DELETE`s, per
+//! `docs/scale-out.adoc`'s `refstore-postgres` row.
+
+use git_backend::{
+ Error, Expected, RefEdit, RefEventStream, RefIter, RefLogEntry, RefLogIter, RefName, RefStore,
+ Result, TxOutcome,
+};
+use gix_hash::ObjectId;
+use tokio_postgres::Transaction;
+
+use crate::{LOG_MESSAGE, PostgresRefStore, notify, pg_err};
+
+/// The outcome of applying one [`RefEdit`] inside a transaction: whether its
+/// [`Expected`] precondition held and, if so, what actually changed (for the
+/// reflog row appended alongside it).
+enum EditResult {
+ /// The precondition failed; the whole transaction must roll back.
+ Mismatch,
+ /// The precondition held but the edit was a no-op (e.g. `MustNotExist`
+ /// against a ref that already didn't exist) — nothing to log.
+ NoChange,
+ /// The precondition held and the ref's value changed from `old` to
+ /// `new`.
+ Changed {
+ /// The ref's hex oid before this edit, or `None` if it did not
+ /// exist.
+ old: Option<String>,
+ /// The ref's hex oid after this edit, or `None` if it was deleted.
+ new: Option<String>,
+ },
+}
+
+/// Parse a hex string read back from a `TEXT` oid column.
+fn parse_oid(hex: &str) -> Result<ObjectId> {
+ ObjectId::from_hex(hex.as_bytes()).map_err(|error| Error::RefStore(error.to_string()))
+}
+
+/// Escape `%`, `_`, and `\` in `prefix` for use in a `LIKE ... ESCAPE '\'`
+/// pattern, then append the wildcard `%`.
+fn like_pattern(prefix: &str) -> String {
+ let mut pattern = String::with_capacity(prefix.len().saturating_add(1));
+ for ch in prefix.chars() {
+ if matches!(ch, '\\' | '%' | '_') {
+ pattern.push('\\');
+ }
+ pattern.push(ch);
+ }
+ pattern.push('%');
+ pattern
+}
+
+/// Apply one [`RefEdit`] inside `tx`, returning what happened. Every branch
+/// is a single conditional statement whose row count (via `RETURNING`)
+/// reports whether the precondition held — no separate lock-then-check step
+/// is needed; Postgres's own row-level locking on the `UPDATE`/`DELETE`/
+/// unique-conflicting `INSERT` makes each branch atomic on its own.
+async fn apply_edit(
+ tx: &Transaction<'_>,
+ repo_id: &str,
+ edit: &RefEdit,
+) -> std::result::Result<EditResult, tokio_postgres::Error> {
+ let name = edit.name.as_str();
+ let new_hex = edit.new.map(|oid| oid.to_hex().to_string());
+
+ match (&edit.expected, &new_hex) {
+ (Expected::Any, Some(new)) => {
+ let old = tx
+ .query_opt(
+ "SELECT oid FROM git_ents_refs WHERE repo_id = $1 AND name = $2",
+ &[&repo_id, &name],
+ )
+ .await?
+ .map(|row| row.try_get::<_, String>(0))
+ .transpose()?;
+ tx.execute(
+ "INSERT INTO git_ents_refs (repo_id, name, oid) VALUES ($1, $2, $3)
+ ON CONFLICT (repo_id, name) DO UPDATE SET oid = EXCLUDED.oid",
+ &[&repo_id, &name, new],
+ )
+ .await?;
+ Ok(EditResult::Changed {
+ old,
+ new: Some(new.clone()),
+ })
+ }
+ (Expected::Any, None) => {
+ let old = tx
+ .query_opt(
+ "SELECT oid FROM git_ents_refs WHERE repo_id = $1 AND name = $2",
+ &[&repo_id, &name],
+ )
+ .await?
+ .map(|row| row.try_get::<_, String>(0))
+ .transpose()?;
+ if old.is_none() {
+ return Ok(EditResult::NoChange);
+ }
+ tx.execute(
+ "DELETE FROM git_ents_refs WHERE repo_id = $1 AND name = $2",
+ &[&repo_id, &name],
+ )
+ .await?;
+ Ok(EditResult::Changed { old, new: None })
+ }
+ (Expected::MustNotExist, Some(new)) => {
+ let row = tx
+ .query_opt(
+ "INSERT INTO git_ents_refs (repo_id, name, oid) VALUES ($1, $2, $3)
+ ON CONFLICT (repo_id, name) DO NOTHING RETURNING oid",
+ &[&repo_id, &name, new],
+ )
+ .await?;
+ Ok(match row {
+ Some(_) => EditResult::Changed {
+ old: None,
+ new: Some(new.clone()),
+ },
+ None => EditResult::Mismatch,
+ })
+ }
+ (Expected::MustNotExist, None) => {
+ let row = tx
+ .query_opt(
+ "SELECT 1 FROM git_ents_refs WHERE repo_id = $1 AND name = $2",
+ &[&repo_id, &name],
+ )
+ .await?;
+ Ok(if row.is_none() {
+ EditResult::NoChange
+ } else {
+ EditResult::Mismatch
+ })
+ }
+ (Expected::MustExistAndMatch(expected), Some(new)) => {
+ let expected_hex = expected.to_hex().to_string();
+ let row = tx
+ .query_opt(
+ "UPDATE git_ents_refs SET oid = $3 WHERE repo_id = $1 AND name = $2 AND oid = $4
+ RETURNING oid",
+ &[&repo_id, &name, new, &expected_hex],
+ )
+ .await?;
+ Ok(match row {
+ Some(_) => EditResult::Changed {
+ old: Some(expected_hex),
+ new: Some(new.clone()),
+ },
+ None => EditResult::Mismatch,
+ })
+ }
+ (Expected::MustExistAndMatch(expected), None) => {
+ let expected_hex = expected.to_hex().to_string();
+ let row = tx
+ .query_opt(
+ "DELETE FROM git_ents_refs WHERE repo_id = $1 AND name = $2 AND oid = $3
+ RETURNING oid",
+ &[&repo_id, &name, &expected_hex],
+ )
+ .await?;
+ Ok(match row {
+ Some(_) => EditResult::Changed {
+ old: Some(expected_hex),
+ new: None,
+ },
+ None => EditResult::Mismatch,
+ })
+ }
+ }
+}
+
+impl RefStore for PostgresRefStore {
+ fn get(&self, name: &RefName) -> Result<Option<ObjectId>> {
+ self.runtime.block_on(async {
+ let client = self.client.lock().await;
+ let row = client
+ .query_opt(
+ "SELECT oid FROM git_ents_refs WHERE repo_id = $1 AND name = $2",
+ &[&self.repo_id, &name.as_str()],
+ )
+ .await
+ .map_err(pg_err)?;
+ match row {
+ Some(row) => {
+ let hex: String = row.try_get(0).map_err(pg_err)?;
+ parse_oid(&hex).map(Some)
+ }
+ None => Ok(None),
+ }
+ })
+ }
+
+ fn iter_prefix(&self, prefix: &RefName) -> Result<RefIter> {
+ let pattern = like_pattern(prefix.as_str());
+ let rows = self
+ .runtime
+ .block_on(async {
+ let client = self.client.lock().await;
+ client
+ .query(
+ "SELECT name, oid FROM git_ents_refs
+ WHERE repo_id = $1 AND name LIKE $2 ESCAPE '\\'
+ ORDER BY name",
+ &[&self.repo_id, &pattern],
+ )
+ .await
+ })
+ .map_err(pg_err)?;
+
+ let mut out = Vec::with_capacity(rows.len());
+ for row in rows {
+ out.push((|| {
+ let name: String = row.try_get(0).map_err(pg_err)?;
+ let hex: String = row.try_get(1).map_err(pg_err)?;
+ Ok((RefName::new(name), parse_oid(&hex)?))
+ })());
+ }
+ Ok(RefIter::new(out.into_iter()))
+ }
+
+ fn transaction(&self, edits: &[RefEdit]) -> Result<TxOutcome> {
+ self.runtime.block_on(async {
+ let mut client = self.client.lock().await;
+ let tx = client.transaction().await.map_err(pg_err)?;
+
+ let mut changed_names = Vec::new();
+ for edit in edits {
+ match apply_edit(&tx, &self.repo_id, edit).await.map_err(pg_err)? {
+ EditResult::Mismatch => {
+ tx.rollback().await.map_err(pg_err)?;
+ return Ok(TxOutcome::Rejected {
+ name: edit.name.clone(),
+ });
+ }
+ EditResult::NoChange => {}
+ EditResult::Changed { old, new } => {
+ tx.execute(
+ "INSERT INTO git_ents_reflog
+ (repo_id, name, old_oid, new_oid, message)
+ VALUES ($1, $2, $3, $4, $5)",
+ &[&self.repo_id, &edit.name.as_str(), &old, &new, &LOG_MESSAGE],
+ )
+ .await
+ .map_err(pg_err)?;
+ changed_names.push(edit.name.as_str().to_owned());
+ }
+ }
+ }
+
+ if !changed_names.is_empty() {
+ let payload = notify::encode(&self.repo_id, &changed_names);
+ tx.execute("SELECT pg_notify($1, $2)", &[¬ify::CHANNEL, &payload])
+ .await
+ .map_err(pg_err)?;
+ }
+ tx.commit().await.map_err(pg_err)?;
+ Ok(TxOutcome::Applied)
+ })
+ }
+
+ fn watch(&self, prefix: &RefName) -> Result<RefEventStream> {
+ Ok(bridge_watch(
+ &self.runtime,
+ self.notify.subscribe(),
+ self.repo_id.clone(),
+ prefix.as_str().to_owned(),
+ ))
+ }
+
+ fn log(&self, name: &RefName) -> Result<RefLogIter> {
+ let rows = self
+ .runtime
+ .block_on(async {
+ let client = self.client.lock().await;
+ client
+ .query(
+ "SELECT old_oid, new_oid, message,
+ extract(epoch from recorded_at)::bigint
+ FROM git_ents_reflog
+ WHERE repo_id = $1 AND name = $2
+ ORDER BY id DESC",
+ &[&self.repo_id, &name.as_str()],
+ )
+ .await
+ })
+ .map_err(pg_err)?;
+
+ let mut entries = Vec::with_capacity(rows.len());
+ for row in rows {
+ entries.push((|| {
+ let old: Option<String> = row.try_get(0).map_err(pg_err)?;
+ let new: Option<String> = row.try_get(1).map_err(pg_err)?;
+ let message: String = row.try_get(2).map_err(pg_err)?;
+ let seconds: i64 = row.try_get(3).map_err(pg_err)?;
+ Ok(RefLogEntry {
+ old: old.as_deref().map(parse_oid).transpose()?,
+ new: new.as_deref().map(parse_oid).transpose()?,
+ message,
+ seconds: u64::try_from(seconds).unwrap_or(0),
+ })
+ })());
+ }
+ Ok(RefLogIter::new(entries.into_iter()))
+ }
+}
+
+/// Bridge a [`tokio::sync::broadcast::Receiver`] of raw `NOTIFY` payloads
+/// into the blocking [`RefEventStream`] the trait exposes: a background task
+/// filters every payload by `repo_id`/`prefix` and forwards a match as a
+/// [`git_backend::RefEvent`]. A lagged receiver (the subscriber fell behind
+/// and missed payloads) still emits a hint — per `watch`'s contract, a
+/// consumer never trusts hint precision, only that draining its own state
+/// is due.
+fn bridge_watch(
+ runtime: &tokio::runtime::Runtime,
+ mut receiver: tokio::sync::broadcast::Receiver<String>,
+ repo_id: String,
+ prefix: String,
+) -> RefEventStream {
+ let (sender, events) = std::sync::mpsc::channel();
+ runtime.spawn(async move {
+ loop {
+ match receiver.recv().await {
+ Ok(payload) => {
+ if !notify::matches(&payload, &repo_id, &prefix) {
+ continue;
+ }
+ }
+ Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
+ Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
+ }
+ if sender.send(git_backend::RefEvent).is_err() {
+ break;
+ }
+ }
+ });
+ RefEventStream::new(events)
+}
crates/refstore-postgres/tests/conformance.rs
@@ -1,0 +1,202 @@
+//! Conformance and targeted tests for `refstore-postgres`
+//! (`docs/scale-out.adoc`, WS4 / WS2), gated on a reachable Postgres:
+//!
+//! 1. `GIT_ENTS_TEST_POSTGRES_URL`, if set — an already-running Postgres.
+//! 2. A throwaway `docker run` Postgres container, if docker is available.
+//! 3. Otherwise: print a message and return — a visible skip (matching
+//! `git-effect`'s own `docker_backend_runs_a_trivial_effect` gate), not a
+//! silently-green test.
+
+#![allow(
+ clippy::unwrap_used,
+ clippy::expect_used,
+ reason = "test harness and assertions, not application code"
+)]
+
+use std::process::{Command, Stdio};
+use std::time::Duration;
+
+use backend_conformance::WithScratchRepo;
+use git_backend::{Expected, RefEdit, RefName, RefStore as _};
+use refstore_postgres::PostgresRefStore;
+
+/// A reachable test Postgres: either an externally supplied instance or a
+/// throwaway docker container this harness starts and stops.
+enum TestPostgres {
+ External(String),
+ Docker { container_id: String, url: String },
+}
+
+impl TestPostgres {
+ fn url(&self) -> &str {
+ match self {
+ Self::External(url) | Self::Docker { url, .. } => url,
+ }
+ }
+}
+
+impl Drop for TestPostgres {
+ fn drop(&mut self) {
+ if let Self::Docker { container_id, .. } = self {
+ let _ignored = Command::new("docker")
+ .args(["rm", "-f", container_id])
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status();
+ }
+ }
+}
+
+/// Obtain a test Postgres per the priority order in the module doc, or
+/// `None` if neither an external URL nor docker is available.
+fn test_postgres() -> Option<TestPostgres> {
+ if let Ok(url) = std::env::var("GIT_ENTS_TEST_POSTGRES_URL") {
+ return Some(TestPostgres::External(url));
+ }
+ if !docker_available() {
+ return None;
+ }
+ start_docker_postgres()
+}
+
+fn docker_available() -> bool {
+ Command::new("docker")
+ .arg("version")
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status()
+ .map(|status| status.success())
+ .unwrap_or(false)
+}
+
+fn start_docker_postgres() -> Option<TestPostgres> {
+ let output = Command::new("docker")
+ .args([
+ "run",
+ "-d",
+ "--rm",
+ "-e",
+ "POSTGRES_PASSWORD=postgres",
+ "-p",
+ "127.0.0.1::5432",
+ "postgres:16-alpine",
+ ])
+ .output()
+ .ok()?;
+ if !output.status.success() {
+ eprintln!(
+ "refstore-postgres tests: docker run failed: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ return None;
+ }
+ let container_id = String::from_utf8_lossy(&output.stdout).trim().to_owned();
+
+ for _ in 0..120 {
+ let ready = Command::new("docker")
+ .args(["exec", &container_id, "pg_isready", "-U", "postgres"])
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status()
+ .map(|status| status.success())
+ .unwrap_or(false);
+ if ready {
+ break;
+ }
+ std::thread::sleep(Duration::from_millis(250));
+ }
+
+ let port_output = Command::new("docker")
+ .args(["port", &container_id, "5432"])
+ .output()
+ .ok()?;
+ let mapping = String::from_utf8_lossy(&port_output.stdout);
+ let port = mapping
+ .lines()
+ .next()?
+ .rsplit(':')
+ .next()?
+ .trim()
+ .to_owned();
+
+ Some(TestPostgres::Docker {
+ url: format!("host=127.0.0.1 port={port} user=postgres password=postgres dbname=postgres"),
+ container_id,
+ })
+}
+
+/// Get a [`TestPostgres`], or print a skip message and return from the
+/// calling test — a visible skip rather than a silently-green pass.
+macro_rules! require_postgres {
+ ($name:literal) => {
+ match test_postgres() {
+ Some(pg) => pg,
+ None => {
+ eprintln!(concat!(
+ "skipping ",
+ $name,
+ ": set GIT_ENTS_TEST_POSTGRES_URL, or make docker available"
+ ));
+ return;
+ }
+ }
+ };
+}
+
+#[test]
+fn conforms_to_ref_store_properties() {
+ let pg = require_postgres!("conforms_to_ref_store_properties");
+ let url = pg.url().to_owned();
+ backend_conformance::ref_store_properties(move || {
+ let repo_id = format!("conformance-{}", uuid::Uuid::new_v4());
+ let url = url.clone();
+ WithScratchRepo::new(move |_path| PostgresRefStore::connect(&url, repo_id))
+ });
+}
+
+#[test]
+fn notify_hint_fires_on_a_transaction_commit() {
+ let pg = require_postgres!("notify_hint_fires_on_a_transaction_commit");
+ let repo_id = format!("notify-{}", uuid::Uuid::new_v4());
+ let store = PostgresRefStore::connect(pg.url(), repo_id).expect("connect");
+
+ let watcher = store.watch(&RefName::new("refs/")).expect("watch");
+ let oid = backend_conformance::distinct_oids(1)
+ .into_iter()
+ .next()
+ .expect("one oid");
+ store
+ .transaction(&[RefEdit {
+ name: RefName::new("refs/heads/watched"),
+ expected: Expected::MustNotExist,
+ new: Some(oid),
+ }])
+ .expect("transaction");
+
+ assert!(
+ watcher.recv_timeout(Duration::from_secs(10)).is_some(),
+ "expected a NOTIFY-driven wakeup hint after a committed transaction"
+ );
+}
+
+#[test]
+fn queue_table_survives_a_dropped_connection() {
+ let pg = require_postgres!("queue_table_survives_a_dropped_connection");
+ let repo_id = format!("queue-{}", uuid::Uuid::new_v4());
+
+ let id = {
+ let store = PostgresRefStore::connect(pg.url(), repo_id.clone()).expect("connect");
+ store.enqueue_effect("payload-a").expect("enqueue")
+ };
+ // `store` (and its one connection) is dropped here; the row must
+ // survive in Postgres regardless, per the queue table's at-least-once
+ // contract (`docs/scale-out.adoc`, "RefStore").
+
+ let store = PostgresRefStore::connect(pg.url(), repo_id).expect("reconnect");
+ let claimed = store.claim_effects("worker-1", 10).expect("claim");
+ assert_eq!(claimed.len(), 1);
+ let claimed_one = claimed.first().expect("one claimed row");
+ assert_eq!(claimed_one.id, id);
+ assert_eq!(claimed_one.payload, "payload-a");
+ store.complete_effect(id).expect("complete");
+}