Skip to main content

alkgit_substrate/
lfs.rs

1//! The LFS pointer-lifecycle row store (wave 9) — the durable
2//! reference map ADR-022 §4 places alkgit-side: one row per reference,
3//! `(repo_id, oid, size)`, over the co-tenant `lfs_objects` table that
4//! ships day-one in both engine step sets (ADR-024 §5 — the table is
5//! unconditional in the schema, the `lfs` feature gates this *surface*
6//! consuming it, so the stamp stays unit-shaped). The repo_id is the
7//! registry-resolved id (the ops namespace on the batch's ACL path);
8//! the oid is the LFS media oid (64 lowercase hex — the raw sha256 of
9//! the content, byte-exact the `GitObjectSha256` digest under alkblobs
10//! ADR-018 §1, though the row store itself carries no key arithmetic);
11//! the surface is consumed by the batch adapter (assertion at
12//! check/upload time, per-repo reads) and the LFS liveness source (the
13//! full row read feeding the sweep chain).
14//!
15//! # The reference surface ([`LfsRows`])
16//!
17//! - [`LfsRows::assert`] — insert a reference, or the refcount-aware
18//!   upsert: re-asserting the same `(repo_id, oid)` pair in one repo
19//!   is one row (the pair is the primary key; no second row, no
20//!   refcount bump) and the row's size refreshes to the asserted
21//!   value (the oid is the content digest, so honest traffic asserts
22//!   the same size — the last assertion's metadata wins).
23//! - [`LfsRows::remove`] — remove one reference row. An absent row is
24//!   a success no-op (removal is a cleanup op — no error path there).
25//! - [`LfsRows::remove_repo`] — the repo-deletion leg: the repo's
26//!   rows go with it. The composition's record-removal transactions
27//!   carry the twins' in-SQL copies of this leg unconditionally
28//!   (lfs.md §3: rows go with the repo, same removal as
29//!   refs/registry); the mem shape's leg rides its record-transaction
30//!   legs in the composition entry ([`crate::compose`] — the
31//!   `MemSeededBackend` pattern). The unconditional posture: the
32//!   table is unconditional in both step sets, the rows only exist
33//!   when the `lfs` surface wrote them, and a mixed deployment that
34//!   turned the feature off must not leak a removed repo's rows.
35//! - [`LfsRows::rows`] / [`LfsRows::repo_rows`] — the liveness read
36//!   (all rows / per-repo rows, canonical `(repo_id, oid)` order):
37//!   the shape the LFS liveness source and the batch adapter consume.
38//!
39//! # The refcount semantics (the cross-reference re-protection
40//! contract, ADR-022 §4)
41//!
42//! The CAS pool is global — the same content referenced by two repos
43//! is one key with two rows — so the liveness source must re-protect
44//! the key while *any* row references it (POC-L's refcount
45//! requirement, replacing the POC's crude second live-row scan). The
46//! `refcount` column carries that data:
47//!
48//! - **What counts**: a row's refcount is the number of rows
49//!   referencing the row's oid (itself included) — with the pair
50//!   primary key, the number of distinct repos referencing the oid.
51//! - **What bumps it**: exactly one event — a newly asserted row
52//!   (a previously-unrepresented `(repo_id, oid)` pair). The
53//!   refcount-aware upsert (a same-pair re-assert) changes nothing.
54//! - **What decrements it**: exactly one event — a removed row
55//!   ([`LfsRows::remove`], or the repo-deletion legs). Every
56//!   surviving row of the oid carries the recomputed count, so every
57//!   row of an oid reads the same current count whatever the engine.
58//! - **What reads it**: the liveness reads ([`LfsRow::refcount`) —
59//!   consumed by the wave-9 liveness source (the re-protection
60//!   contract's data: while a key's count is ≥ 1 the key stays
61//!   referenced; the last row's removal makes the media
62//!   sweep-eligible) and the OQ-20 deferred per-oid pruning's future
63//!   queries.
64//!
65//! The engine twins maintain the column transactionally: one
66//! transaction per call (the refs-rows posture, ADR-019 §2 — the
67//! sqlite twin's `BEGIN IMMEDIATE`, the pg twin's transaction *and*
68//! its schema advisory lock, the sqlite write-lock class's analog
69//! serializing the same-oid recompute across co-tenant connections),
70//! recompute-after-write so concurrent mutations converge on exact
71//! counts. The in-memory impl recomputes from the row map —
72//! exact-by-construction.
73//!
74//! # Repo-lifetime scoping v1
75//!
76//! Rows live from assertion (batch check/upload — the moment the
77//! server learns the pair) to repo deletion; no per-oid pruning —
78//! OQ-20 stands deferred (first concrete row-volume need). There is
79//! no sweep, no TTL, and *no* mutation of rows anywhere outside this
80//! surface's ops and the two removal legs.
81//!
82//! # Errors
83//!
84//! The `StorageError` family (the refs tables' posture — the
85//! co-tenant row stores speak the core's object-storage errors):
86//! table-layer input validation is [`StorageError::Invalid`] (a
87//! non-64-hex-lowercase oid, an empty repo id, a size over the
88//! engine's integer range), engine failures are
89//! [`StorageError::Io`] (message-only — no engine source types on
90//! the surface).
91
92use std::collections::BTreeMap;
93use std::sync::atomic::{AtomicU64, Ordering};
94use std::sync::Arc;
95
96use alkgit::StorageError;
97use async_trait::async_trait;
98
99/// One LFS pointer-lifecycle row as the surface reads it back: the
100/// resolved repo id, the 64-hex LFS oid, the asserted size, and the
101/// oid's current refcount (the module doc's refcount semantics).
102#[derive(Clone, Debug, PartialEq, Eq)]
103pub struct LfsRow {
104    /// The registry-resolved repo id the reference belongs to.
105    pub repo_id: String,
106    /// The LFS media oid (64 lowercase hex — the raw sha256 of the
107    /// content).
108    pub oid: String,
109    /// The asserted media size (the batch's mandatory size; the last
110    /// assertion's value).
111    pub size: u64,
112    /// The oid's current reference count — rows referencing the oid,
113    /// itself included (the re-protection contract's data).
114    pub refcount: u64,
115}
116
117/// The LFS pointer-lifecycle row surface (substrate.md §public API's
118/// "the LFS liveness/pointer-lifecycle surface under `lfs`" — the
119/// row-op half; the exact names this module pins are the OQ-03
120/// substrate-side inventory of the wave-9 rows task). Three impls in
121/// the refs-tables' pattern:
122/// [`InMemoryLfsRows`] (the reference impl, engine-free), the sqlite
123/// twin ([`SqliteLfsRows`], the `sqlite` feature), and the postgres
124/// twin ([`PgLfsRows`], the `postgres` + `lfs` features).
125#[async_trait]
126pub trait LfsRows: Send + Sync {
127    /// Assert a reference: insert the `(repo_id, oid)` row with the
128    /// asserted size, or — when the pair already has a row — refresh
129    /// that row's size and change nothing else (the refcount-aware
130    /// upsert: one oid re-asserted in one repo is one row).
131    async fn assert(&self, repo_id: &str, oid: &str, size: u64) -> Result<(), StorageError>;
132
133    /// Remove one reference row. An absent row is a success no-op;
134    /// the oid's surviving rows recompute their refcount.
135    async fn remove(&self, repo_id: &str, oid: &str) -> Result<(), StorageError>;
136
137    /// The repo-deletion leg: the repo's rows go with the repo (its
138    /// oids' surviving rows in other repos recompute their refcount).
139    async fn remove_repo(&self, repo_id: &str) -> Result<(), StorageError>;
140
141    /// The liveness read — all rows, canonical `(repo_id, oid)`
142    /// order.
143    async fn rows(&self) -> Result<Vec<LfsRow>, StorageError>;
144
145    /// The liveness read — one repo's rows, canonical oid order.
146    async fn repo_rows(&self, repo_id: &str) -> Result<Vec<LfsRow>, StorageError>;
147}
148
149/// The table-layer input validation (the pinned mapping):
150/// the repo id is the registry-resolved id (non-empty),
151/// the oid is a 64-lowercase-hex sha256 form, and the size fits the
152/// engine twins' integer range. Everything else the caller's surface
153/// layer validated.
154fn validated_repo_id(repo_id: &str) -> Result<(), StorageError> {
155    if repo_id.is_empty() {
156        return Err(StorageError::Invalid(
157            "lfs reference assertion: the repo id is empty (the registry id is resolved per \
158             call)"
159                .to_owned(),
160        ));
161    }
162    Ok(())
163}
164
165fn validated_oid(oid: &str) -> Result<(), StorageError> {
166    let hex_ok = oid.len() == 64 && oid.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
167    if !hex_ok {
168        return Err(StorageError::Invalid(format!(
169            "lfs reference assertion: the oid is not a 64-lowercase-hex sha256 form: {oid:?}"
170        )));
171    }
172    Ok(())
173}
174
175fn validated_size(size: u64) -> Result<(), StorageError> {
176    if size > i64::MAX as u64 {
177        return Err(StorageError::Invalid(format!(
178            "lfs reference assertion: the size exceeds the engine integer range: {size}"
179        )));
180    }
181    Ok(())
182}
183
184/// The in-memory LFS row store — the semantically exact reference
185/// impl, engine-free and single-process in the crate's base (the
186/// refs-tables' pattern: no engine dependency, no feature beyond the
187/// `lfs` gate this surface carries — ADR-025's placement rule). Rows
188/// live in a `(repo_id, oid) → size` sorted map (canonical order
189/// falls out of the BTreeMap); the refcounts recompute from the map
190/// on every mutation (exact-by-construction). The whole call runs
191/// under one short-held write lock (the in-memory "transaction"; the
192/// async `tokio::sync::RwLock`) over **both** maps — the rows and
193/// the refcounts mutate in the same locked section, so a concurrent
194/// pair of same-oid mutations serializes with exact recomputes (a
195/// two-lock split would let interleaved phases land in reversed
196/// order: a stale one-off undercount — the wave-9 gate's fix).
197pub struct InMemoryLfsRows {
198    state: tokio::sync::RwLock<InMemoryLfsState>,
199}
200
201#[derive(Default)]
202struct InMemoryLfsState {
203    rows: BTreeMap<(String, String), u64>,
204    refcounts: BTreeMap<String, u64>,
205}
206
207impl Default for InMemoryLfsRows {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213impl InMemoryLfsRows {
214    /// A fresh, empty store.
215    pub fn new() -> Self {
216        InMemoryLfsRows {
217            state: tokio::sync::RwLock::new(InMemoryLfsState::default()),
218        }
219    }
220
221    /// The oid's recomputed count after a mutation (the map's rows
222    /// referencing the oid).
223    fn recount(rows: &BTreeMap<(String, String), u64>, oid: &str) -> u64 {
224        rows.keys().filter(|(_, o)| o == oid).count() as u64
225    }
226}
227
228#[async_trait]
229impl LfsRows for InMemoryLfsRows {
230    async fn assert(&self, repo_id: &str, oid: &str, size: u64) -> Result<(), StorageError> {
231        validated_repo_id(repo_id)?;
232        validated_oid(oid)?;
233        validated_size(size)?;
234        let mut state = self.state.write().await;
235        let newly = state
236            .rows
237            .insert((repo_id.to_owned(), oid.to_owned()), size)
238            .is_none();
239        if newly {
240            let count = Self::recount(&state.rows, oid);
241            state.refcounts.insert(oid.to_owned(), count);
242        }
243        Ok(())
244    }
245
246    async fn remove(&self, repo_id: &str, oid: &str) -> Result<(), StorageError> {
247        validated_repo_id(repo_id)?;
248        validated_oid(oid)?;
249        let mut state = self.state.write().await;
250        if state
251            .rows
252            .remove(&(repo_id.to_owned(), oid.to_owned()))
253            .is_some()
254        {
255            let count = Self::recount(&state.rows, oid);
256            if count == 0 {
257                state.refcounts.remove(oid);
258            } else {
259                state.refcounts.insert(oid.to_owned(), count);
260            }
261        }
262        Ok(())
263    }
264
265    async fn remove_repo(&self, repo_id: &str) -> Result<(), StorageError> {
266        validated_repo_id(repo_id)?;
267        let mut state = self.state.write().await;
268        let affected: Vec<String> = state
269            .rows
270            .keys()
271            .filter(|(r, _)| r == repo_id)
272            .map(|(_, o)| o.clone())
273            .collect();
274        state.rows.retain(|r, _| r.0 != repo_id);
275        for oid in affected {
276            let count = Self::recount(&state.rows, &oid);
277            if count == 0 {
278                state.refcounts.remove(&oid);
279            } else {
280                state.refcounts.insert(oid, count);
281            }
282        }
283        Ok(())
284    }
285
286    async fn rows(&self) -> Result<Vec<LfsRow>, StorageError> {
287        Ok(self.read_all().await)
288    }
289
290    async fn repo_rows(&self, repo_id: &str) -> Result<Vec<LfsRow>, StorageError> {
291        validated_repo_id(repo_id)?;
292        Ok(self
293            .read_all()
294            .await
295            .into_iter()
296            .filter(|row| row.repo_id == repo_id)
297            .collect())
298    }
299}
300
301impl InMemoryLfsRows {
302    async fn read_all(&self) -> Vec<LfsRow> {
303        let state = self.state.read().await;
304        state
305            .rows
306            .iter()
307            .map(|((repo_id, oid), size)| LfsRow {
308                repo_id: repo_id.clone(),
309                oid: oid.clone(),
310                size: *size,
311                refcount: state.refcounts.get(oid).copied().unwrap_or(0),
312            })
313            .collect()
314    }
315}
316
317/// The sqlite row twin over the co-tenant `lfs_objects` rows
318/// (substrate.md §refs' row-store pattern, ADR-019 §2): every write
319/// call ([`LfsRows::assert`], [`LfsRows::remove`],
320/// [`LfsRows::remove_repo`]) runs its check-and-writes inside **one**
321/// `BEGIN IMMEDIATE` transaction on the co-tenant connection — two
322/// clashing calls (cross-connection included) serialize on the write
323/// lock and the refcount recompute reads the committed winner. The
324/// refcount recompute is the transaction's trailing statement — the
325/// touched oid's rows all carry the same current count at commit.
326/// Blocking SQL bridges off the async executor
327/// (`tokio::task::spawn_blocking`) per the composition's concurrency
328/// model; construction takes the already-migrated
329/// ([`CoTenantSqlite::open`](crate::migrations::CoTenantSqlite::open))
330/// handle — no construction I/O here. The record-removal leg that
331/// takes a repo's rows with the record rides the registry twin's own
332/// in-SQL copy ([`crate::sqlite`] — unconditional, see the module
333/// doc); this impl's [`LfsRows::remove_repo`] is the surface's own
334/// leg for the same row set.
335#[cfg(feature = "sqlite")]
336pub struct SqliteLfsRows {
337    handle: crate::migrations::CoTenantSqlite,
338}
339
340#[cfg(feature = "sqlite")]
341impl SqliteLfsRows {
342    /// Mount the row store over a co-tenant handle (the same handle
343    /// the sqlite registry and refs twins consume).
344    pub fn new(handle: crate::migrations::CoTenantSqlite) -> Self {
345        SqliteLfsRows { handle }
346    }
347}
348
349#[cfg(feature = "sqlite")]
350mod sqlite_twin {
351    use super::*;
352
353    fn storage_io(context: &str, error: rusqlite::Error) -> StorageError {
354        StorageError::Io(format!("{context}: {error}"))
355    }
356
357    /// The refcount recompute — the touched oid's rows all carry the
358    /// count of rows referencing the oid at commit (the module doc's
359    /// "what decrements" half; trailing statement of the write
360    /// transaction).
361    fn recount_sql() -> &'static str {
362        "UPDATE lfs_objects \
363         SET refcount = (SELECT count(*) FROM lfs_objects AS c WHERE c.oid = lfs_objects.oid) \
364         WHERE oid = ?1"
365    }
366
367    /// The repo-deletion recompute: the affected oids are exactly the
368    /// ones any other repo may share, so one recount statement over
369    /// the whole table is the exact single-statement form (no capture
370    /// needed — the deleted rows' oids are recomputed wherever rows
371    /// survive).
372    fn recount_all_sql() -> &'static str {
373        "UPDATE lfs_objects \
374         SET refcount = (SELECT count(*) FROM lfs_objects AS c WHERE c.oid = lfs_objects.oid)"
375    }
376
377    fn read_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<LfsRow> {
378        Ok(LfsRow {
379            repo_id: row.get(0)?,
380            oid: row.get(1)?,
381            size: row.get::<_, i64>(2)? as u64,
382            refcount: row.get::<_, i64>(3)? as u64,
383        })
384    }
385
386    async fn run_blocking<T: Send + 'static>(
387        handle: crate::migrations::CoTenantSqlite,
388        work: impl FnOnce(&mut rusqlite::Connection) -> Result<T, StorageError> + Send + 'static,
389    ) -> Result<T, StorageError> {
390        tokio::task::spawn_blocking(move || {
391            let mut conn = handle.lock();
392            work(&mut conn)
393        })
394        .await
395        .map_err(|e| StorageError::Io(format!("lfs rows blocking task: {e}")))?
396    }
397
398    async fn run_transaction<T: Send + 'static>(
399        handle: crate::migrations::CoTenantSqlite,
400        work: impl FnOnce(&rusqlite::Transaction<'_>) -> Result<T, StorageError> + Send + 'static,
401    ) -> Result<T, StorageError> {
402        run_blocking(handle, move |conn| {
403            let tx = conn
404                .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
405                .map_err(|e| storage_io("lfs rows BEGIN IMMEDIATE", e))?;
406            let value = work(&tx)?;
407            tx.commit().map_err(|e| storage_io("lfs rows commit", e))?;
408            Ok(value)
409        })
410        .await
411    }
412
413    #[async_trait]
414    impl LfsRows for SqliteLfsRows {
415        async fn assert(&self, repo_id: &str, oid: &str, size: u64) -> Result<(), StorageError> {
416            validated_repo_id(repo_id)?;
417            validated_oid(oid)?;
418            validated_size(size)?;
419            let handle = self.handle.clone();
420            let (repo_id, oid) = (repo_id.to_owned(), oid.to_owned());
421            run_transaction(handle, move |tx| {
422                tx.execute(
423                    "INSERT INTO lfs_objects (repo_id, oid, size, refcount) \
424                     VALUES (?1, ?2, ?3, 1) \
425                     ON CONFLICT(repo_id, oid) DO UPDATE SET size = excluded.size",
426                    rusqlite::params![repo_id, oid, size as i64],
427                )
428                .map_err(|e| storage_io("lfs assert upsert", e))?;
429                tx.execute(recount_sql(), rusqlite::params![oid])
430                    .map_err(|e| storage_io("lfs assert refcount recount", e))?;
431                Ok(())
432            })
433            .await
434        }
435
436        async fn remove(&self, repo_id: &str, oid: &str) -> Result<(), StorageError> {
437            validated_repo_id(repo_id)?;
438            validated_oid(oid)?;
439            let handle = self.handle.clone();
440            let (repo_id, oid) = (repo_id.to_owned(), oid.to_owned());
441            run_transaction(handle, move |tx| {
442                tx.execute(
443                    "DELETE FROM lfs_objects WHERE repo_id = ?1 AND oid = ?2",
444                    rusqlite::params![repo_id, oid],
445                )
446                .map_err(|e| storage_io("lfs reference remove", e))?;
447                tx.execute(recount_sql(), rusqlite::params![oid])
448                    .map_err(|e| storage_io("lfs remove refcount recount", e))?;
449                Ok(())
450            })
451            .await
452        }
453
454        async fn remove_repo(&self, repo_id: &str) -> Result<(), StorageError> {
455            validated_repo_id(repo_id)?;
456            let handle = self.handle.clone();
457            let repo_id = repo_id.to_owned();
458            run_transaction(handle, move |tx| {
459                tx.execute(
460                    "DELETE FROM lfs_objects WHERE repo_id = ?1",
461                    rusqlite::params![repo_id],
462                )
463                .map_err(|e| storage_io("lfs repo removal", e))?;
464                tx.execute(recount_all_sql(), [])
465                    .map_err(|e| storage_io("lfs repo removal recount", e))?;
466                Ok(())
467            })
468            .await
469        }
470
471        async fn rows(&self) -> Result<Vec<LfsRow>, StorageError> {
472            let handle = self.handle.clone();
473            run_blocking(handle, move |conn| {
474                let mut stmt = conn
475                    .prepare(
476                        "SELECT repo_id, oid, size, refcount FROM lfs_objects \
477                              ORDER BY repo_id, oid",
478                    )
479                    .map_err(|e| storage_io("lfs rows listing prepare", e))?;
480                let rows = stmt
481                    .query_map([], read_row)
482                    .map_err(|e| storage_io("lfs rows listing query", e))?;
483                rows.collect::<Result<Vec<_>, _>>()
484                    .map_err(|e| storage_io("lfs rows listing row", e))
485            })
486            .await
487        }
488
489        async fn repo_rows(&self, repo_id: &str) -> Result<Vec<LfsRow>, StorageError> {
490            validated_repo_id(repo_id)?;
491            let handle = self.handle.clone();
492            let repo_id = repo_id.to_owned();
493            run_blocking(handle, move |conn| {
494                let mut stmt = conn
495                    .prepare(
496                        "SELECT repo_id, oid, size, refcount FROM lfs_objects \
497                              WHERE repo_id = ?1 ORDER BY oid",
498                    )
499                    .map_err(|e| storage_io("lfs rows listing prepare", e))?;
500                let rows = stmt
501                    .query_map(rusqlite::params![repo_id], read_row)
502                    .map_err(|e| storage_io("lfs rows listing query", e))?;
503                rows.collect::<Result<Vec<_>, _>>()
504                    .map_err(|e| storage_io("lfs rows listing row", e))
505            })
506            .await
507        }
508    }
509}
510
511/// The postgres row twin over the co-tenant `lfs_objects` rows
512/// (substrate.md §refs' row-store pattern, ADR-019 §3): every write
513/// call runs its check-and-writes inside **one** transaction that
514/// takes the schema's advisory row-store lock
515/// (`pg_advisory_xact_lock` — the sqlite twin's `BEGIN IMMEDIATE`
516/// write-lock class's whole-fleet analog), so the refcount recompute
517/// of two clashing calls — cross-connection included — serializes
518/// before their reads and every surviving row of an oid carries the
519/// same current count at commit. Natively async — every leg is a
520/// straight await on the co-tenant handle's client (the
521/// tokio-postgres model). Construction takes the already-migrated
522/// ([`CoTenantPg::open`](crate::postgres::CoTenantPg::open)) handle —
523/// no construction I/O here. The record-removal leg's in-SQL copy
524/// rides [`crate::postgres`]'s registry removal transaction
525/// (unconditional, see the module doc).
526#[cfg(feature = "postgres")]
527pub struct PgLfsRows {
528    handle: crate::postgres::CoTenantPg,
529}
530
531#[cfg(feature = "postgres")]
532impl PgLfsRows {
533    /// Mount the row store over a co-tenant handle (the same handle
534    /// the pg registry and refs twins consume; one instance per
535    /// schema serves all three).
536    pub fn new(handle: crate::postgres::CoTenantPg) -> Self {
537        PgLfsRows { handle }
538    }
539}
540
541#[cfg(feature = "postgres")]
542mod pg_twin {
543    use super::*;
544    use crate::migrations::{lfs_objects_lock_key, qualify};
545
546    fn pg_io(context: &str, error: tokio_postgres::Error) -> StorageError {
547        StorageError::Io(format!("{context}: {error}"))
548    }
549
550    /// The refcount recompute — the touched oid's rows all carry the
551    /// count at commit (the sqlite twin's trailing statement,
552    /// engine-swapped; the target alias keeps the outer reference
553    /// unambiguous). The qualified table comes in as a parameter —
554    /// the statement composes from it, no string surgery.
555    fn recount_sql(table: &str) -> String {
556        format!(
557            "UPDATE {table} AS t0 \
558             SET refcount = (SELECT count(*) FROM {table} c WHERE c.oid = t0.oid) \
559             WHERE t0.oid = $1"
560        )
561    }
562
563    fn recount_all_sql(table: &str) -> String {
564        format!(
565            "UPDATE {table} AS t0 \
566             SET refcount = (SELECT count(*) FROM {table} c WHERE c.oid = t0.oid)"
567        )
568    }
569
570    #[async_trait]
571    impl LfsRows for PgLfsRows {
572        async fn assert(&self, repo_id: &str, oid: &str, size: u64) -> Result<(), StorageError> {
573            validated_repo_id(repo_id)?;
574            validated_oid(oid)?;
575            validated_size(size)?;
576            let schema = self.handle.schema().to_owned();
577            let size = size as i64;
578            let (repo_id, oid) = (repo_id.to_owned(), oid.to_owned());
579            let mut guard = self.handle.lock().await;
580            let tx = guard
581                .transaction()
582                .await
583                .map_err(|e| pg_io("lfs rows BEGIN", e))?;
584            tx.execute(
585                "SELECT pg_advisory_xact_lock($1)",
586                &[&lfs_objects_lock_key(&schema)],
587            )
588            .await
589            .map_err(|e| pg_io("lfs rows advisory lock", e))?;
590            let upsert = format!(
591                "INSERT INTO {} (repo_id, oid, size, refcount) \
592                 VALUES ($1, $2, $3, 1) \
593                 ON CONFLICT (repo_id, oid) DO UPDATE SET size = excluded.size",
594                qualify(&schema, "lfs_objects")
595            );
596            tx.execute(&upsert, &[&repo_id, &oid, &size])
597                .await
598                .map_err(|e| pg_io("lfs assert upsert", e))?;
599            tx.execute(&recount_sql(&qualify(&schema, "lfs_objects")), &[&oid])
600                .await
601                .map_err(|e| pg_io("lfs assert refcount recount", e))?;
602            tx.commit().await.map_err(|e| pg_io("lfs rows commit", e))?;
603            Ok(())
604        }
605
606        async fn remove(&self, repo_id: &str, oid: &str) -> Result<(), StorageError> {
607            validated_repo_id(repo_id)?;
608            validated_oid(oid)?;
609            let schema = self.handle.schema().to_owned();
610            let (repo_id, oid) = (repo_id.to_owned(), oid.to_owned());
611            let mut guard = self.handle.lock().await;
612            let tx = guard
613                .transaction()
614                .await
615                .map_err(|e| pg_io("lfs rows BEGIN", e))?;
616            tx.execute(
617                "SELECT pg_advisory_xact_lock($1)",
618                &[&lfs_objects_lock_key(&schema)],
619            )
620            .await
621            .map_err(|e| pg_io("lfs rows advisory lock", e))?;
622            let delete = format!(
623                "DELETE FROM {} WHERE repo_id = $1 AND oid = $2",
624                qualify(&schema, "lfs_objects")
625            );
626            tx.execute(&delete, &[&repo_id, &oid])
627                .await
628                .map_err(|e| pg_io("lfs reference remove", e))?;
629            tx.execute(&recount_sql(&qualify(&schema, "lfs_objects")), &[&oid])
630                .await
631                .map_err(|e| pg_io("lfs remove refcount recount", e))?;
632            tx.commit().await.map_err(|e| pg_io("lfs rows commit", e))?;
633            Ok(())
634        }
635
636        async fn remove_repo(&self, repo_id: &str) -> Result<(), StorageError> {
637            validated_repo_id(repo_id)?;
638            let schema = self.handle.schema().to_owned();
639            let repo_id = repo_id.to_owned();
640            let mut guard = self.handle.lock().await;
641            let tx = guard
642                .transaction()
643                .await
644                .map_err(|e| pg_io("lfs rows BEGIN", e))?;
645            tx.execute(
646                "SELECT pg_advisory_xact_lock($1)",
647                &[&lfs_objects_lock_key(&schema)],
648            )
649            .await
650            .map_err(|e| pg_io("lfs rows advisory lock", e))?;
651            let delete = format!(
652                "DELETE FROM {} WHERE repo_id = $1",
653                qualify(&schema, "lfs_objects")
654            );
655            tx.execute(&delete, &[&repo_id])
656                .await
657                .map_err(|e| pg_io("lfs repo removal", e))?;
658            let recount = recount_all_sql(&qualify(&schema, "lfs_objects"));
659            tx.execute(&recount, &[])
660                .await
661                .map_err(|e| pg_io("lfs repo removal recount", e))?;
662            tx.commit().await.map_err(|e| pg_io("lfs rows commit", e))?;
663            Ok(())
664        }
665
666        async fn rows(&self) -> Result<Vec<LfsRow>, StorageError> {
667            let select = format!(
668                "SELECT repo_id, oid, size, refcount FROM {} ORDER BY repo_id, oid",
669                qualify(self.handle.schema(), "lfs_objects")
670            );
671            let guard = self.handle.lock().await;
672            let rows = guard
673                .query(&select, &[])
674                .await
675                .map_err(|e| pg_io("lfs rows listing query", e))?;
676            flatten_rows(&rows)
677        }
678
679        async fn repo_rows(&self, repo_id: &str) -> Result<Vec<LfsRow>, StorageError> {
680            validated_repo_id(repo_id)?;
681            let select = format!(
682                "SELECT repo_id, oid, size, refcount FROM {} WHERE repo_id = $1 ORDER BY oid",
683                qualify(self.handle.schema(), "lfs_objects")
684            );
685            let guard = self.handle.lock().await;
686            let rows = guard
687                .query(&select, &[&repo_id])
688                .await
689                .map_err(|e| pg_io("lfs rows listing query", e))?;
690            flatten_rows(&rows)
691        }
692    }
693
694    fn flatten_rows(rows: &[tokio_postgres::Row]) -> Result<Vec<LfsRow>, StorageError> {
695        rows.iter()
696            .map(|row| {
697                Ok(LfsRow {
698                    repo_id: row
699                        .try_get(0)
700                        .map_err(|e| pg_io("lfs rows listing row", e))?,
701                    oid: row
702                        .try_get(1)
703                        .map_err(|e| pg_io("lfs rows listing row", e))?,
704                    size: row
705                        .try_get::<_, i64>(2)
706                        .map_err(|e| pg_io("lfs rows listing row", e))?
707                        as u64,
708                    refcount: row
709                        .try_get::<_, i64>(3)
710                        .map_err(|e| pg_io("lfs rows listing row", e))?
711                        as u64,
712                })
713            })
714            .collect()
715    }
716}
717
718#[cfg(test)]
719pub(crate) mod battery {
720    use super::*;
721
722    /// The pinned oid fixture: a valid 64-hex form (the digits spell
723    /// out the row's identity in the tests).
724    pub(crate) fn oid_hex(tag: u8) -> String {
725        let base = format!("{tag:02x}").repeat(32);
726        base
727    }
728
729    /// Assertion + the refcount-aware upsert: a new pair inserts one
730    /// row with refcount 1; a same-pair re-assert is the same row
731    /// (refcount unmoved, the asserted size refreshed); a different
732    /// repo asserting the same oid bumps both rows' refcount.
733    pub(crate) async fn assert_and_the_refcount_aware_upsert(
734        table: &dyn LfsRows,
735    ) -> Result<(), StorageError> {
736        table.assert("repo/a", &oid_hex(1), 128).await?;
737        table.assert("repo/a", &oid_hex(1), 128).await?;
738        assert_eq!(
739            table.repo_rows("repo/a").await?,
740            vec![LfsRow {
741                repo_id: "repo/a".to_owned(),
742                oid: oid_hex(1),
743                size: 128,
744                refcount: 1,
745            }],
746            "the same oid re-asserted in one repo is one row"
747        );
748
749        table.assert("repo/a", &oid_hex(1), 256).await?;
750        assert_eq!(
751            table.repo_rows("repo/a").await?[0].size,
752            256,
753            "the re-assert refreshes the row's size"
754        );
755        assert_eq!(table.repo_rows("repo/a").await?[0].refcount, 1);
756
757        // A second repo references the same content: two rows, one
758        // key — both carry the current count of 2.
759        table.assert("repo/b", &oid_hex(1), 256).await?;
760        let shared: Vec<LfsRow> = table
761            .rows()
762            .await?
763            .into_iter()
764            .filter(|row| row.oid == oid_hex(1))
765            .collect();
766        assert_eq!(shared.len(), 2);
767        for row in &shared {
768            assert_eq!(
769                row.refcount, 2,
770                "every row of an oid reads the same count: {row:?}"
771            );
772        }
773        Ok(())
774    }
775
776    /// The re-protection contract, test-pinned (ADR-022 §4): two
777    /// repos referencing one oid — one row removed ⇒ the key stays
778    /// referenced by the survivor (its refcount decremented to 1);
779    /// all rows removed ⇒ nothing references the key (sweep-eligible
780    /// — no rows; the liveness source's sweep contract realizes the
781    /// deletion end-to-end, wave-9 liveness task).
782    pub(crate) async fn cross_reference_reprotection(
783        table: &dyn LfsRows,
784    ) -> Result<(), StorageError> {
785        table.assert("repo/one", &oid_hex(2), 64).await?;
786        table.assert("repo/two", &oid_hex(2), 64).await?;
787        assert_eq!(
788            table
789                .rows()
790                .await?
791                .into_iter()
792                .filter(|row| row.oid == oid_hex(2))
793                .count(),
794            2
795        );
796
797        table.remove("repo/one", &oid_hex(2)).await?;
798        let survivor = table.repo_rows("repo/two").await?;
799        assert_eq!(
800            survivor,
801            vec![LfsRow {
802                repo_id: "repo/two".to_owned(),
803                oid: oid_hex(2),
804                size: 64,
805                refcount: 1,
806            }],
807            "one row removed — the survivor carries the decremented count; the key stays referenced"
808        );
809
810        table.remove("repo/two", &oid_hex(2)).await?;
811        assert!(
812            table
813                .rows()
814                .await?
815                .into_iter()
816                .all(|row| row.oid != oid_hex(2)),
817            "all rows removed — nothing references the key (sweep-eligible)"
818        );
819        Ok(())
820    }
821
822    /// Removing an absent row: a success no-op — no error path, no
823    /// refcount disturbance.
824    pub(crate) async fn removal_of_an_absent_row_is_a_noop(
825        table: &dyn LfsRows,
826    ) -> Result<(), StorageError> {
827        table.assert("repo/noop", &oid_hex(3), 16).await?;
828        table.remove("repo/absent", &oid_hex(3)).await?;
829        table.remove("repo/noop", &oid_hex(0x3f)).await?;
830        let rows = table.repo_rows("repo/noop").await?;
831        assert_eq!(rows.len(), 1);
832        assert_eq!(
833            rows[0].refcount, 1,
834            "the no-op removals left the count exact"
835        );
836        Ok(())
837    }
838
839    /// The repo-deletion leg: the repo's rows go with it (all of
840    /// them — multi-oid), the surviving repos' shared oids recompute,
841    /// a shared oid fully owned by the repo leaves no refcount entry.
842    pub(crate) async fn repo_deletion_takes_the_rows_and_recounts(
843        table: &dyn LfsRows,
844    ) -> Result<(), StorageError> {
845        table.assert("victim", &oid_hex(4), 32).await?;
846        table.assert("victim", &oid_hex(5), 48).await?;
847        table.assert("survivor", &oid_hex(4), 32).await?;
848        table.assert("survivor", &oid_hex(9), 72).await?;
849
850        table.remove_repo("victim").await?;
851
852        let touched: Vec<LfsRow> = table
853            .rows()
854            .await?
855            .into_iter()
856            .filter(|row| row.repo_id == "survivor" || row.repo_id == "victim")
857            .collect();
858        assert_eq!(
859            touched,
860            vec![
861                LfsRow {
862                    repo_id: "survivor".to_owned(),
863                    oid: oid_hex(4),
864                    size: 32,
865                    refcount: 1,
866                },
867                LfsRow {
868                    repo_id: "survivor".to_owned(),
869                    oid: oid_hex(9),
870                    size: 72,
871                    refcount: 1,
872                },
873            ],
874            "the victim's rows went with the repo; the survivor's shared oid recomputed to 1"
875        );
876
877        // Re-asserting the victim's name starts a fresh row set.
878        table.assert("victim", &oid_hex(4), 32).await?;
879        assert_eq!(
880            table.repo_rows("victim").await?[0].refcount,
881            2,
882            "a shared oid re-referenced by the former victim counts both rows again"
883        );
884        Ok(())
885    }
886
887    /// The liveness read's shape: canonical `(repo_id, oid)` order
888    /// over all rows; `repo_rows` filters one repo's view.
889    pub(crate) async fn the_reads_list_canonically(
890        table: &dyn LfsRows,
891    ) -> Result<(), StorageError> {
892        table.assert("repo/z", &oid_hex(6), 8).await?;
893        table.assert("repo/z", &oid_hex(7), 9).await?;
894        table.assert("repo/a", &oid_hex(8), 10).await?;
895
896        let all = table.rows().await?;
897        let keys: Vec<(String, String)> = all
898            .iter()
899            .map(|r| (r.repo_id.clone(), r.oid.clone()))
900            .collect();
901        let mut sorted = keys.clone();
902        sorted.sort();
903        assert_eq!(
904            keys, sorted,
905            "all rows arrive in canonical (repo_id, oid) order"
906        );
907
908        let mine = table.repo_rows("repo/z").await?;
909        assert_eq!(
910            mine.iter().map(|r| r.oid.clone()).collect::<Vec<_>>(),
911            vec![oid_hex(6), oid_hex(7)],
912            "the per-repo view filters and stays oid-ordered"
913        );
914        Ok(())
915    }
916
917    /// The table-layer validation (the pinned mapping): oid shape,
918    /// repo id, and the engine integer range are
919    /// [`StorageError::Invalid`]; the row state is untouched.
920    pub(crate) async fn validation_rejects_as_storage_invalid(
921        table: &dyn LfsRows,
922    ) -> Result<(), StorageError> {
923        let before = table.rows().await?.len();
924
925        for oid in [
926            "",
927            "nothex",
928            &"AB".repeat(32),
929            &"1".repeat(63),
930            &"1".repeat(65),
931        ] {
932            assert!(
933                matches!(
934                    table.assert("repo/a", oid, 8).await,
935                    Err(StorageError::Invalid(_))
936                ),
937                "the oid shape is a table-layer input: {oid:?}"
938            );
939        }
940        assert!(
941            matches!(
942                table.assert("", &oid_hex(1), 8).await,
943                Err(StorageError::Invalid(_))
944            ),
945            "an empty repo id is table-layer invalid"
946        );
947        assert!(
948            matches!(
949                table
950                    .assert("repo/a", &oid_hex(1), (i64::MAX as u64) + 1)
951                    .await,
952                Err(StorageError::Invalid(_))
953            ),
954            "a size over the engine integer range is table-layer invalid"
955        );
956        assert_eq!(
957            table.rows().await?.len(),
958            before,
959            "the rejected inputs wrote nothing"
960        );
961        Ok(())
962    }
963}
964
965#[cfg(test)]
966mod tests {
967    #[cfg(feature = "sqlite")]
968    use std::sync::Arc;
969
970    use super::battery;
971    use super::*;
972
973    #[cfg(feature = "sqlite")]
974    use crate::testutil::scratch_dir;
975
976    async fn in_memory_reference() -> InMemoryLfsRows {
977        InMemoryLfsRows::new()
978    }
979
980    /// The reference impl runs the full battery — the semantics
981    /// pinned here are exactly what the engine twins re-run
982    /// engine-swapped.
983    #[tokio::test]
984    async fn the_in_memory_reference_runs_the_battery() {
985        let table = in_memory_reference().await;
986        battery::assert_and_the_refcount_aware_upsert(&table)
987            .await
988            .unwrap();
989        battery::cross_reference_reprotection(&table).await.unwrap();
990        battery::removal_of_an_absent_row_is_a_noop(&table)
991            .await
992            .unwrap();
993        battery::repo_deletion_takes_the_rows_and_recounts(&table)
994            .await
995            .unwrap();
996        battery::the_reads_list_canonically(&table).await.unwrap();
997        battery::validation_rejects_as_storage_invalid(&table)
998            .await
999            .unwrap();
1000    }
1001
1002    /// The refcount reads back through the rows read exactly as the
1003    /// engine twins' recomputed SQL column would (the shared read
1004    /// shape) — pinned over the maintained map, including the
1005    /// empty-oid absence (no rows ⇒ no entry).
1006    #[tokio::test]
1007    async fn the_in_memory_refcounts_track_the_rows() {
1008        let table = in_memory_reference().await;
1009        let refcount_of = |rows: &Vec<LfsRow>, repo: &str| -> u64 {
1010            rows.iter()
1011                .find(|row| row.repo_id == repo)
1012                .map(|row| row.refcount)
1013                .expect("the row exists")
1014        };
1015        table.assert("r", &battery::oid_hex(1), 8).await.unwrap();
1016        table.assert("r", &battery::oid_hex(1), 8).await.unwrap();
1017        assert_eq!(
1018            refcount_of(&table.rows().await.unwrap(), "r"),
1019            1,
1020            "the re-assert did not bump the count"
1021        );
1022        table.assert("r2", &battery::oid_hex(1), 8).await.unwrap();
1023        assert_eq!(refcount_of(&table.rows().await.unwrap(), "r2"), 2);
1024        table.remove("r", &battery::oid_hex(1)).await.unwrap();
1025        assert_eq!(refcount_of(&table.rows().await.unwrap(), "r2"), 1);
1026        table.remove("r2", &battery::oid_hex(1)).await.unwrap();
1027        assert!(
1028            table.rows().await.unwrap().is_empty(),
1029            "the last removal leaves no row, no refcount entry"
1030        );
1031    }
1032
1033    /// The versioned wrapper forwards the battery exactly and bumps
1034    /// the shared counter per **committed** `assert` — re-asserts
1035    /// included — while rejected asserts and removals read back
1036    /// without a bump: the epoch discipline's bump-after-commit half
1037    /// (a bump must never land ahead of the rows it covers, or a
1038    /// mark's load-before-read consult could pair the bump with a
1039    /// rows read that missed the in-flight commit). A rejected assert
1040    /// makes no coverage change, so a missed bump is exact there, and
1041    /// the mark's next rebuild sees the truth either way.
1042    #[tokio::test]
1043    async fn the_versioned_wrapper_forwards_and_bumps_on_assert() {
1044        let inner = Arc::new(InMemoryLfsRows::new());
1045        let counter = Arc::new(AtomicU64::new(0));
1046        let wrapped = VersionedLfsRows::new(inner.clone(), Arc::clone(&counter));
1047        let count = || counter.load(Ordering::Relaxed);
1048
1049        battery::assert_and_the_refcount_aware_upsert(&wrapped)
1050            .await
1051            .unwrap();
1052        assert_eq!(
1053            count(),
1054            4,
1055            "every assert bumps — same-pair re-asserts included"
1056        );
1057
1058        let before = count();
1059        battery::cross_reference_reprotection(&wrapped)
1060            .await
1061            .unwrap();
1062        assert_eq!(
1063            count() - before,
1064            2,
1065            "the two asserts bumped; the two removals read back without one"
1066        );
1067
1068        let before = count();
1069        battery::removal_of_an_absent_row_is_a_noop(&wrapped)
1070            .await
1071            .unwrap();
1072        assert_eq!(
1073            count() - before,
1074            1,
1075            "the one assert bumped — the no-op removals did not"
1076        );
1077        let before = count();
1078        battery::the_reads_list_canonically(&wrapped).await.unwrap();
1079        assert_eq!(count() - before, 3, "the reads themselves do not bump");
1080
1081        let before = count();
1082        battery::validation_rejects_as_storage_invalid(&wrapped)
1083            .await
1084            .unwrap();
1085        assert_eq!(
1086            count() - before,
1087            0,
1088            "the rejected asserts bump nothing (no rows landed — the bump-after-commit discipline)"
1089        );
1090
1091        let before = count();
1092        battery::removal_of_an_absent_row_is_a_noop(&wrapped)
1093            .await
1094            .unwrap();
1095        assert_eq!(count(), before + 1, "removals never bump");
1096    }
1097
1098    /// The sqlite twin runs the same battery over the co-tenant
1099    /// `lfs_objects` rows (one transaction per call).
1100    #[cfg(feature = "sqlite")]
1101    #[tokio::test]
1102    async fn the_sqlite_twin_runs_the_battery() {
1103        let dir = scratch_dir("lfs", "sqlite-battery");
1104        let handle = crate::migrations::CoTenantSqlite::open(dir.join("rows.sqlite3")).unwrap();
1105        let table = SqliteLfsRows::new(handle);
1106
1107        battery::assert_and_the_refcount_aware_upsert(&table)
1108            .await
1109            .unwrap();
1110        battery::cross_reference_reprotection(&table).await.unwrap();
1111        battery::removal_of_an_absent_row_is_a_noop(&table)
1112            .await
1113            .unwrap();
1114        battery::repo_deletion_takes_the_rows_and_recounts(&table)
1115            .await
1116            .unwrap();
1117        battery::the_reads_list_canonically(&table).await.unwrap();
1118        battery::validation_rejects_as_storage_invalid(&table)
1119            .await
1120            .unwrap();
1121
1122        std::fs::remove_dir_all(&dir).unwrap_or_default();
1123    }
1124
1125    /// The recomputed refcount column is exact across concurrent
1126    /// same-oid assertion storms (two connections asserting the same
1127    /// oid — the write lock serializes, every row ends at the same
1128    /// exact count).
1129    #[cfg(feature = "sqlite")]
1130    #[tokio::test(flavor = "multi_thread")]
1131    async fn the_sqlite_concurrent_asserts_converge_on_exact_counts() {
1132        let dir = scratch_dir("lfs", "sqlite-race");
1133        let path = dir.join("race.sqlite3");
1134        let first = Arc::new(SqliteLfsRows::new(
1135            crate::migrations::CoTenantSqlite::open(&path).unwrap(),
1136        ));
1137        let second = Arc::new(SqliteLfsRows::new(
1138            crate::migrations::CoTenantSqlite::open(&path).unwrap(),
1139        ));
1140
1141        let oid = battery::oid_hex(2);
1142        let mut joins = Vec::new();
1143        for store in [first.clone(), second.clone()] {
1144            for repo in ["repo/a", "repo/b", "repo/c", "repo/d"] {
1145                joins.push(tokio::spawn({
1146                    let store = store.clone();
1147                    let oid = oid.clone();
1148                    let repo = repo.to_owned();
1149                    async move { store.assert(&repo, &oid, 64).await }
1150                }));
1151            }
1152        }
1153        for join in joins {
1154            join.await.unwrap().expect("every assert lands");
1155        }
1156
1157        let rows = first.rows().await.unwrap();
1158        assert_eq!(rows.len(), 4, "four repos — four rows, one key");
1159        for row in &rows {
1160            assert_eq!(
1161                row.refcount, 4,
1162                "every row carries the exact count: {row:?}"
1163            );
1164        }
1165
1166        std::fs::remove_dir_all(&dir).unwrap_or_default();
1167    }
1168}
1169
1170/// The LFS rows' write counter — the LFS liveness source's in-process
1171/// invalidation half ([`crate::liveness::LfsMedia`]'s arbitration
1172/// epoch signal): bumped on every `assert` through the composition's
1173/// rows surface, read by the source's arbitration consult. See
1174/// [`VersionedLfsRows`].
1175///
1176/// The counter's discipline is narrower than the refs counter's
1177/// ([`crate::refs::VersionedRefTable`]): `assert` bumps, removals read
1178/// back without one. The bump covers the one dangerous direction — a
1179/// row landing
1180/// between a sweep's mark and a staged candidate's arbitration (the
1181/// media is *newly* referenced; the stale index says unreferenced; the
1182/// moved counter says unknown-is-live and cancels). A removal moves
1183/// coverage only downward — a stale "live" answer is the safe side and
1184/// a stale "dead" answer is exact for a candidate whose rows left —
1185/// and the mark's rebuild-per-mark consult sees the truth at the next
1186/// sweep either way.
1187///
1188/// The bump follows the inner call (the epoch discipline's
1189/// bump-after-commit half, shared with
1190/// [`crate::refs::VersionedRefTable`]): the mark consult's counter
1191/// load rides **ahead** of its rows read, so only the
1192/// load-before-read / bump-after-commit pairing is sound — bump
1193/// lands strictly after the rows commit, and any write whose rows
1194/// the mark's read missed necessarily moves the counter past the
1195/// memo's captured epoch (the arbitration consult answers
1196/// conservatively-live). The bump-before shape was reviewed out at
1197/// the wave-9 gate: it let an assert's bump land while its sqlite/pg
1198/// write transaction was still in flight, letting a mark's row read
1199/// miss the uncommitted rows while catching the bump — a memo keyed
1200/// on pre-assert rows and a post-bump epoch, the poisoned shape that
1201/// answers exact-dead for just-referenced media once the commit
1202/// lands.
1203///
1204/// The twins' record-removal legs (the in-SQL copies inside the
1205/// registry removal transactions) never touch the counter — they are
1206/// removals (coverage downward, above); the mem shape's
1207/// [`LfsRows::remove_repo`] leg rides this surface and follows the
1208/// same discipline.
1209pub struct VersionedLfsRows<T: ?Sized> {
1210    inner: Arc<T>,
1211    version: Arc<AtomicU64>,
1212}
1213
1214impl<T: ?Sized> VersionedLfsRows<T> {
1215    /// The wrapper over the rows store and the shared counter.
1216    pub fn new(inner: Arc<T>, version: Arc<AtomicU64>) -> Self {
1217        VersionedLfsRows { inner, version }
1218    }
1219
1220    /// The wrapped rows store.
1221    pub fn inner(&self) -> &Arc<T> {
1222        &self.inner
1223    }
1224
1225    /// The counter's current value (the liveness source's arbitration
1226    /// epoch signal).
1227    pub fn version(&self) -> u64 {
1228        self.version.load(Ordering::Relaxed)
1229    }
1230}
1231
1232#[async_trait]
1233impl<T> LfsRows for VersionedLfsRows<T>
1234where
1235    T: LfsRows + ?Sized,
1236{
1237    async fn assert(&self, repo_id: &str, oid: &str, size: u64) -> Result<(), StorageError> {
1238        self.inner.assert(repo_id, oid, size).await?;
1239        self.version.fetch_add(1, Ordering::Relaxed);
1240        Ok(())
1241    }
1242
1243    async fn remove(&self, repo_id: &str, oid: &str) -> Result<(), StorageError> {
1244        self.inner.remove(repo_id, oid).await
1245    }
1246
1247    async fn remove_repo(&self, repo_id: &str) -> Result<(), StorageError> {
1248        self.inner.remove_repo(repo_id).await
1249    }
1250
1251    async fn rows(&self) -> Result<Vec<LfsRow>, StorageError> {
1252        self.inner.rows().await
1253    }
1254
1255    async fn repo_rows(&self, repo_id: &str) -> Result<Vec<LfsRow>, StorageError> {
1256        self.inner.repo_rows(repo_id).await
1257    }
1258}
1259
1260/// The pg twin's battery — the same script the reference and the
1261/// sqlite twin run, over the live-pg harness (the
1262/// `ALKBLOBS_PG_*`/`ALKGIT_SUBSTRATE_PG_*` convention; clean skip
1263/// when the host env is absent, so `--all-features` stays green
1264/// everywhere). Works in a unique, freshly-migrated schema and drops
1265/// it after.
1266#[cfg(all(test, feature = "postgres"))]
1267mod pg_twin_tests {
1268    use std::sync::Arc;
1269
1270    use crate::postgres::live_harness::{drop_schema, live_pg, unique_schema};
1271
1272    use super::battery;
1273    use super::*;
1274
1275    /// The battery over the pg row twin — the semantics pinned on the
1276    /// reference impl, re-run engine-swapped.
1277    #[tokio::test]
1278    async fn pg_rows_twin_carries_the_battery() {
1279        let dsn = live_pg!();
1280        let schema = unique_schema("lfs-battery");
1281        let table = PgLfsRows::new(
1282            crate::postgres::CoTenantPg::open(&dsn, &schema)
1283                .await
1284                .unwrap(),
1285        );
1286
1287        battery::assert_and_the_refcount_aware_upsert(&table)
1288            .await
1289            .unwrap();
1290        battery::cross_reference_reprotection(&table).await.unwrap();
1291        battery::removal_of_an_absent_row_is_a_noop(&table)
1292            .await
1293            .unwrap();
1294        battery::repo_deletion_takes_the_rows_and_recounts(&table)
1295            .await
1296            .unwrap();
1297        battery::the_reads_list_canonically(&table).await.unwrap();
1298        battery::validation_rejects_as_storage_invalid(&table)
1299            .await
1300            .unwrap();
1301
1302        drop(table);
1303        drop_schema(&dsn, &schema).await;
1304    }
1305
1306    /// The advisory lock serializes the concurrent same-oid
1307    /// assertions from two co-tenant connections — every row ends at
1308    /// the exact count (the fleet-shape convergence).
1309    #[tokio::test]
1310    async fn pg_concurrent_asserts_converge_on_exact_counts() {
1311        let dsn = live_pg!();
1312        let schema = unique_schema("lfs-race");
1313        let oid = battery::oid_hex(2);
1314        let mut joins = Vec::new();
1315        for index in 0..4 {
1316            let table = Arc::new(PgLfsRows::new(
1317                crate::postgres::CoTenantPg::open(&dsn, &schema)
1318                    .await
1319                    .unwrap(),
1320            ));
1321            let oid = oid.clone();
1322            joins.push(tokio::spawn(async move {
1323                table
1324                    .assert(&format!("repo/{index}"), &oid, 64)
1325                    .await
1326                    .expect("the assert lands")
1327            }));
1328        }
1329        for join in joins {
1330            join.await.unwrap();
1331        }
1332
1333        let table = PgLfsRows::new(
1334            crate::postgres::CoTenantPg::open(&dsn, &schema)
1335                .await
1336                .unwrap(),
1337        );
1338        for row in table.rows().await.unwrap() {
1339            assert_eq!(
1340                row.refcount, 4,
1341                "every row carries the exact count: {row:?}"
1342            );
1343        }
1344
1345        drop(table);
1346        drop_schema(&dsn, &schema).await;
1347    }
1348}