use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use alkgit::{
validate_ref_name, GitRefs, RefLine, RefOutcome, RefUpdate, RepoRecord, StorageError,
ATOMIC_PUSH_FAILURE_REASON, FUNNY_REFNAME_REASON,
};
use async_trait::async_trait;
use gix_hash::ObjectId;
use crate::liveness::{fingerprint_rows, RefRow, RefRowState, RefRows, RefRowsSnapshot};
pub const CAS_STALE_REASON: &str = "stale info";
#[derive(Clone, Debug, PartialEq, Eq)]
enum RowState {
Oid(ObjectId),
Symref(String),
}
impl RowState {
fn oid(&self) -> Option<ObjectId> {
match self {
RowState::Oid(oid) => Some(*oid),
RowState::Symref(_) => None,
}
}
}
enum CasWrite {
Set { oid: ObjectId },
Delete,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CasDenial {
FunnyRefname,
Stale,
}
impl CasDenial {
fn reason(self) -> &'static str {
match self {
CasDenial::FunnyRefname => FUNNY_REFNAME_REASON,
CasDenial::Stale => CAS_STALE_REASON,
}
}
}
fn cas_decide(
current: Option<RowState>,
update: &RefUpdate,
) -> Result<Option<CasWrite>, CasDenial> {
if validate_ref_name(&update.name).is_err() {
return Err(CasDenial::FunnyRefname);
}
let current_oid = current.as_ref().and_then(RowState::oid);
let matched = match (¤t, update.expected) {
(None, None) => true,
(None, Some(_)) => false,
(Some(_), None) => false,
(Some(_), Some(_)) => current_oid.is_some() && update.expected == current_oid,
};
if !matched {
return Err(CasDenial::Stale);
}
Ok(match update.new {
Some(new) => Some(CasWrite::Set { oid: new }),
None => Some(CasWrite::Delete),
})
}
#[cfg(any(feature = "sqlite", feature = "postgres"))]
fn row_state_from_columns(
repo_id: &str,
name: &str,
oid_hex: Option<String>,
symref_target: Option<String>,
) -> Result<RowState, StorageError> {
match (oid_hex, symref_target) {
(Some(hex), None) => {
let oid = ObjectId::from_hex(hex.as_bytes()).map_err(|_| {
StorageError::Invalid(format!(
"git_refs row {repo_id}/{name}: oid_hex is not a hex sha1"
))
})?;
Ok(RowState::Oid(oid))
}
(None, Some(target)) => Ok(RowState::Symref(target)),
(None, None) => Err(StorageError::Invalid(format!(
"git_refs row {repo_id}/{name}: neither oid nor symref target — structurally invalid row state"
))),
(Some(_), Some(_)) => Err(StorageError::Invalid(format!(
"git_refs row {repo_id}/{name}: both oid and symref target — structurally invalid row state"
))),
}
}
fn row_to_line(name: &str, state: &RowState) -> RefLine {
match state {
RowState::Oid(oid) => RefLine {
name: name.to_owned(),
oid: Some(*oid),
peeled: None,
symref_target: None,
},
RowState::Symref(target) => RefLine {
name: name.to_owned(),
oid: None,
peeled: None,
symref_target: Some(target.clone()),
},
}
}
trait CasTable {
async fn state(&mut self, name: &str) -> Result<Option<RowState>, StorageError>;
async fn write(&mut self, name: &str, op: &CasWrite) -> Result<(), StorageError>;
async fn finish(&mut self, abort: bool) -> Result<(), StorageError>;
}
async fn run_cas<T: CasTable + ?Sized>(
table: &mut T,
updates: &[RefUpdate],
atomic: bool,
) -> Result<Vec<RefOutcome>, StorageError> {
let mut any_denial = false;
let mut outcomes = Vec::with_capacity(updates.len());
for update in updates {
let outcome = match cas_decide(table.state(&update.name).await?, update) {
Err(denial) => {
any_denial = true;
RefOutcome {
name: update.name.clone(),
result: Err(denial.reason().to_owned()),
}
}
Ok(None) => RefOutcome {
name: update.name.clone(),
result: Ok(()),
},
Ok(Some(op)) => {
table.write(&update.name, &op).await?;
RefOutcome {
name: update.name.clone(),
result: Ok(()),
}
}
};
outcomes.push(outcome);
}
table.finish(atomic && any_denial).await?;
if atomic && any_denial {
for outcome in &mut outcomes {
if outcome.result.is_ok() {
outcome.result = Err(ATOMIC_PUSH_FAILURE_REASON.to_owned());
}
}
}
Ok(outcomes)
}
pub struct InMemoryRefTable {
rows: tokio::sync::RwLock<BTreeMap<String, BTreeMap<String, RowState>>>,
}
impl Default for InMemoryRefTable {
fn default() -> Self {
Self::new()
}
}
impl InMemoryRefTable {
pub fn new() -> Self {
InMemoryRefTable {
rows: tokio::sync::RwLock::new(BTreeMap::new()),
}
}
pub(crate) async fn seed_head(&self, repo_id: &str, target: &str) {
self.rows
.write()
.await
.entry(repo_id.to_owned())
.or_default()
.insert("HEAD".to_owned(), RowState::Symref(target.to_owned()));
}
pub(crate) async fn drop_repo_rows(&self, repo_id: &str) {
self.rows.write().await.remove(repo_id);
}
}
struct InMemoryCasTable {
working: BTreeMap<String, RowState>,
aborted: bool,
}
impl CasTable for InMemoryCasTable {
async fn state(&mut self, name: &str) -> Result<Option<RowState>, StorageError> {
Ok(self.working.get(name).cloned())
}
async fn write(&mut self, name: &str, op: &CasWrite) -> Result<(), StorageError> {
match op {
CasWrite::Set { oid } => {
self.working.insert(name.to_owned(), RowState::Oid(*oid));
}
CasWrite::Delete => {
self.working.remove(name);
}
}
Ok(())
}
async fn finish(&mut self, abort: bool) -> Result<(), StorageError> {
if abort {
self.working.clear();
self.aborted = true;
}
Ok(())
}
}
#[async_trait]
impl GitRefs for InMemoryRefTable {
async fn list_refs(&self, repo: &RepoRecord) -> Result<Vec<RefLine>, StorageError> {
let guard = self.rows.read().await;
match guard.get(&repo.repo_id) {
None => Ok(Vec::new()),
Some(tier) => Ok(tier
.iter()
.map(|(name, state)| row_to_line(name, state))
.collect()),
}
}
async fn apply_updates(
&self,
repo: &RepoRecord,
updates: Vec<RefUpdate>,
atomic: bool,
) -> Result<Vec<RefOutcome>, StorageError> {
let mut rows = self.rows.write().await;
let mut table = InMemoryCasTable {
working: rows.get(&repo.repo_id).cloned().unwrap_or_default(),
aborted: false,
};
let outcomes = run_cas(&mut table, &updates, atomic).await?;
if !table.aborted && (!table.working.is_empty() || rows.contains_key(&repo.repo_id)) {
rows.insert(repo.repo_id.clone(), table.working);
}
Ok(outcomes)
}
}
#[cfg(feature = "sqlite")]
pub struct SqliteRefTable {
handle: crate::migrations::CoTenantSqlite,
}
#[cfg(feature = "sqlite")]
impl SqliteRefTable {
pub fn new(handle: crate::migrations::CoTenantSqlite) -> Self {
SqliteRefTable { handle }
}
}
#[cfg(feature = "sqlite")]
mod sqlite_twin {
use super::*;
struct SqliteCasTable<'a> {
tx: Option<rusqlite::Transaction<'a>>,
repo_id: String,
}
impl SqliteCasTable<'_> {
fn tx(&self) -> Result<&rusqlite::Transaction<'_>, StorageError> {
self.tx.as_ref().ok_or_else(|| {
StorageError::Io("refs CAS transaction already finalized".to_owned())
})
}
fn tx_take(&mut self) -> Result<rusqlite::Transaction<'_>, StorageError> {
self.tx.take().ok_or_else(|| {
StorageError::Io("refs CAS transaction already finalized".to_owned())
})
}
}
impl CasTable for SqliteCasTable<'_> {
async fn state(&mut self, name: &str) -> Result<Option<RowState>, StorageError> {
let tx = self.tx()?;
let queried = tx.query_row(
"SELECT oid_hex, symref_target FROM git_refs WHERE repo_id = ?1 AND name = ?2",
rusqlite::params![self.repo_id, name],
|row| {
Ok((
row.get::<_, Option<String>>(0)?,
row.get::<_, Option<String>>(1)?,
))
},
);
match queried {
Ok((oid_hex, symref_target)) => {
let state =
row_state_from_columns(&self.repo_id, name, oid_hex, symref_target)?;
Ok(Some(state))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(storage_io("refs CAS state read", e)),
}
}
async fn write(&mut self, name: &str, op: &CasWrite) -> Result<(), StorageError> {
let tx = self.tx()?;
match op {
CasWrite::Set { oid } => {
tx.execute(
"INSERT INTO git_refs (repo_id, name, oid_hex, symref_target) \
VALUES (?1, ?2, ?3, NULL) \
ON CONFLICT(repo_id, name) DO UPDATE SET oid_hex = ?3, symref_target = NULL",
rusqlite::params![self.repo_id, name, oid.to_hex().to_string()],
)
.map_err(|e| storage_io("refs CAS row set", e))?;
}
CasWrite::Delete => {
tx.execute(
"DELETE FROM git_refs WHERE repo_id = ?1 AND name = ?2",
rusqlite::params![self.repo_id, name],
)
.map_err(|e| storage_io("refs CAS row delete", e))?;
}
}
Ok(())
}
async fn finish(&mut self, abort: bool) -> Result<(), StorageError> {
let tx = self.tx_take()?;
if abort {
tx.rollback()
.map_err(|e| storage_io("refs CAS rollback", e))
} else {
tx.commit().map_err(|e| storage_io("refs CAS commit", e))
}
}
}
fn storage_io(context: &str, error: rusqlite::Error) -> StorageError {
StorageError::Io(format!("{context}: {error}"))
}
#[async_trait]
impl GitRefs for SqliteRefTable {
async fn list_refs(&self, repo: &RepoRecord) -> Result<Vec<RefLine>, StorageError> {
let handle = self.handle.clone();
let repo_id = repo.repo_id.clone();
let listed = tokio::task::spawn_blocking(move || {
let conn = handle.lock();
let mut stmt = conn
.prepare(
"SELECT name, oid_hex, symref_target FROM git_refs \
WHERE repo_id = ?1 ORDER BY name",
)
.map_err(|e| storage_io("refs listing prepare", e))?;
let rows = stmt
.query_map(rusqlite::params![repo_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, Option<String>>(2)?,
))
})
.map_err(|e| storage_io("refs listing query", e))?;
let mut lines = Vec::new();
for row in rows {
let (name, oid_hex, symref_target) =
row.map_err(|e| storage_io("refs listing row", e))?;
let state = row_state_from_columns(&repo_id, &name, oid_hex, symref_target)?;
lines.push(row_to_line(&name, &state));
}
Ok(lines)
})
.await
.map_err(|e| StorageError::Io(format!("refs listing blocking task: {e}")))?;
listed
}
async fn apply_updates(
&self,
repo: &RepoRecord,
updates: Vec<RefUpdate>,
atomic: bool,
) -> Result<Vec<RefOutcome>, StorageError> {
let handle = self.handle.clone();
let repo_id = repo.repo_id.clone();
let runtime = tokio::runtime::Handle::current();
let applied = tokio::task::spawn_blocking(move || {
let mut conn = handle.lock();
let tx = conn
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
.map_err(|e| storage_io("refs CAS BEGIN IMMEDIATE", e))?;
let mut table = SqliteCasTable {
tx: Some(tx),
repo_id,
};
runtime.block_on(run_cas(&mut table, &updates, atomic))
})
.await
.map_err(|e| StorageError::Io(format!("refs CAS blocking task: {e}")))?;
applied
}
}
#[async_trait]
impl RefRows for SqliteRefTable {
async fn snapshot(&self) -> Result<RefRowsSnapshot, StorageError> {
let handle = self.handle.clone();
let rows = tokio::task::spawn_blocking(move || {
let conn = handle.lock();
let mut stmt = conn
.prepare(
"SELECT repo_id, name, oid_hex, symref_target FROM git_refs \
ORDER BY repo_id, name",
)
.map_err(|e| storage_io("refs rows snapshot prepare", e))?;
let queried = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
))
})
.map_err(|e| storage_io("refs rows snapshot query", e))?;
let mut rows = Vec::new();
for row in queried {
let (repo_id, name, oid_hex, symref_target) =
row.map_err(|e| storage_io("refs rows snapshot row", e))?;
let state = row_state_from_columns(&repo_id, &name, oid_hex, symref_target)?;
rows.push(RefRow {
repo_id,
name,
state: row_state_to_seam(&state),
});
}
Ok(rows)
})
.await
.map_err(|e| StorageError::Io(format!("refs rows snapshot task: {e}")))?;
let rows = rows?;
Ok(RefRowsSnapshot {
fingerprint: fingerprint_rows(&rows),
rows,
})
}
}
}
#[cfg(feature = "postgres")]
pub struct PgRefTable {
handle: crate::postgres::CoTenantPg,
}
#[cfg(feature = "postgres")]
impl PgRefTable {
pub fn new(handle: crate::postgres::CoTenantPg) -> Self {
PgRefTable { handle }
}
}
#[cfg(feature = "postgres")]
mod pg_twin {
use super::*;
use crate::migrations::{git_refs_lock_key, qualify};
use tokio_postgres::Transaction;
fn pg_io(context: &str, error: tokio_postgres::Error) -> StorageError {
StorageError::Io(format!("{context}: {error}"))
}
struct PgCasTable<'a> {
tx: Option<Transaction<'a>>,
repo_id: String,
schema: String,
}
impl PgCasTable<'_> {
fn tx(&self) -> Result<&Transaction<'_>, StorageError> {
self.tx.as_ref().ok_or_else(|| {
StorageError::Io("refs CAS transaction already finalized".to_owned())
})
}
}
impl CasTable for PgCasTable<'_> {
async fn state(&mut self, name: &str) -> Result<Option<RowState>, StorageError> {
let select = format!(
"SELECT oid_hex, symref_target FROM {} \
WHERE repo_id = $1 AND name = $2",
qualify(&self.schema, "git_refs")
);
let tx = self.tx()?;
match tx.query_opt(&select, &[&self.repo_id, &name]).await {
Ok(Some(row)) => {
let oid_hex: Option<String> = row
.try_get(0)
.map_err(|e| pg_io("refs CAS state read", e))?;
let symref_target: Option<String> = row
.try_get(1)
.map_err(|e| pg_io("refs CAS state read", e))?;
let state =
row_state_from_columns(&self.repo_id, name, oid_hex, symref_target)?;
Ok(Some(state))
}
Ok(None) => Ok(None),
Err(e) => Err(pg_io("refs CAS state read", e)),
}
}
async fn write(&mut self, name: &str, op: &CasWrite) -> Result<(), StorageError> {
let tx = self.tx()?;
match op {
CasWrite::Set { oid } => {
let upsert = format!(
"INSERT INTO {} (repo_id, name, oid_hex, symref_target) \
VALUES ($1, $2, $3, NULL) \
ON CONFLICT (repo_id, name) DO UPDATE \
SET oid_hex = $3, symref_target = NULL",
qualify(&self.schema, "git_refs")
);
let oid_hex = oid.to_hex().to_string();
tx.execute(&upsert, &[&self.repo_id, &name, &oid_hex])
.await
.map_err(|e| pg_io("refs CAS row set", e))?;
}
CasWrite::Delete => {
let delete = format!(
"DELETE FROM {} WHERE repo_id = $1 AND name = $2",
qualify(&self.schema, "git_refs")
);
tx.execute(&delete, &[&self.repo_id, &name])
.await
.map_err(|e| pg_io("refs CAS row delete", e))?;
}
}
Ok(())
}
async fn finish(&mut self, abort: bool) -> Result<(), StorageError> {
let tx = self.tx.take().ok_or_else(|| {
StorageError::Io("refs CAS transaction already finalized".to_owned())
})?;
if abort {
tx.rollback()
.await
.map_err(|e| pg_io("refs CAS rollback", e))
} else {
tx.commit().await.map_err(|e| pg_io("refs CAS commit", e))
}
}
}
#[async_trait]
impl GitRefs for PgRefTable {
async fn list_refs(&self, repo: &RepoRecord) -> Result<Vec<RefLine>, StorageError> {
let select = format!(
"SELECT name, oid_hex, symref_target FROM {} \
WHERE repo_id = $1 ORDER BY name",
qualify(self.handle.schema(), "git_refs")
);
let guard = self.handle.lock().await;
let rows = guard
.query(&select, &[&repo.repo_id])
.await
.map_err(|e| pg_io("refs listing query", e))?;
let mut lines = Vec::with_capacity(rows.len());
for row in &rows {
let name: String = row.try_get(0).map_err(|e| pg_io("refs listing row", e))?;
let oid_hex: Option<String> =
row.try_get(1).map_err(|e| pg_io("refs listing row", e))?;
let symref_target: Option<String> =
row.try_get(2).map_err(|e| pg_io("refs listing row", e))?;
let state = row_state_from_columns(&repo.repo_id, &name, oid_hex, symref_target)?;
lines.push(row_to_line(&name, &state));
}
Ok(lines)
}
async fn apply_updates(
&self,
repo: &RepoRecord,
updates: Vec<RefUpdate>,
atomic: bool,
) -> Result<Vec<RefOutcome>, StorageError> {
let mut guard = self.handle.lock().await;
let repo_id = repo.repo_id.clone();
let schema = self.handle.schema().to_owned();
let tx = guard
.transaction()
.await
.map_err(|e| pg_io("refs CAS BEGIN", e))?;
tx.execute(
"SELECT pg_advisory_xact_lock($1)",
&[&git_refs_lock_key(&schema)],
)
.await
.map_err(|e| pg_io("refs CAS advisory lock", e))?;
let mut table = PgCasTable {
tx: Some(tx),
repo_id,
schema,
};
run_cas(&mut table, &updates, atomic).await
}
}
#[async_trait]
impl RefRows for PgRefTable {
async fn snapshot(&self) -> Result<RefRowsSnapshot, StorageError> {
let select = format!(
"SELECT repo_id, name, oid_hex, symref_target FROM {} \
ORDER BY repo_id, name",
qualify(self.handle.schema(), "git_refs")
);
let guard = self.handle.lock().await;
let rows = guard
.query(&select, &[])
.await
.map_err(|e| pg_io("refs rows snapshot query", e))?;
let mut snapshot = Vec::new();
for row in &rows {
let repo_id: String = row
.try_get(0)
.map_err(|e| pg_io("refs rows snapshot row", e))?;
let name: String = row
.try_get(1)
.map_err(|e| pg_io("refs rows snapshot row", e))?;
let oid_hex: Option<String> = row
.try_get(2)
.map_err(|e| pg_io("refs rows snapshot row", e))?;
let symref_target: Option<String> = row
.try_get(3)
.map_err(|e| pg_io("refs rows snapshot row", e))?;
let state = row_state_from_columns(&repo_id, &name, oid_hex, symref_target)?;
snapshot.push(RefRow {
repo_id,
name,
state: row_state_to_seam(&state),
});
}
drop(guard);
Ok(RefRowsSnapshot {
fingerprint: fingerprint_rows(&snapshot),
rows: snapshot,
})
}
}
}
fn row_state_to_seam(state: &RowState) -> RefRowState {
match state {
RowState::Oid(oid) => RefRowState::Oid(*oid),
RowState::Symref(target) => RefRowState::Symref(target.clone()),
}
}
#[async_trait]
impl RefRows for InMemoryRefTable {
async fn snapshot(&self) -> Result<RefRowsSnapshot, StorageError> {
let guard = self.rows.read().await;
let rows: Vec<RefRow> = guard
.iter()
.flat_map(|(repo_id, tier)| {
tier.iter().map(move |(name, state)| RefRow {
repo_id: repo_id.clone(),
name: name.clone(),
state: row_state_to_seam(state),
})
})
.collect();
Ok(RefRowsSnapshot {
fingerprint: fingerprint_rows(&rows),
rows,
})
}
}
pub struct VersionedRefTable<T: ?Sized> {
inner: Arc<T>,
version: Arc<AtomicU64>,
}
impl<T: ?Sized> VersionedRefTable<T> {
pub fn new(inner: Arc<T>, version: Arc<AtomicU64>) -> Self {
VersionedRefTable { inner, version }
}
pub fn inner(&self) -> &Arc<T> {
&self.inner
}
pub fn version(&self) -> u64 {
self.version.load(Ordering::Relaxed)
}
}
#[async_trait]
impl<T> GitRefs for VersionedRefTable<T>
where
T: GitRefs + ?Sized,
{
async fn list_refs(&self, repo: &RepoRecord) -> Result<Vec<RefLine>, StorageError> {
self.inner.list_refs(repo).await
}
async fn apply_updates(
&self,
repo: &RepoRecord,
updates: Vec<RefUpdate>,
atomic: bool,
) -> Result<Vec<RefOutcome>, StorageError> {
let outcomes = self.inner.apply_updates(repo, updates, atomic).await?;
self.version.fetch_add(1, Ordering::Relaxed);
Ok(outcomes)
}
}
#[async_trait]
impl<T> RefRows for VersionedRefTable<T>
where
T: RefRows + ?Sized,
{
async fn snapshot(&self) -> Result<RefRowsSnapshot, StorageError> {
self.inner.snapshot().await
}
}
#[cfg(test)]
pub(crate) mod battery {
use alkgit::{
GitRefs, RefLine, RefOutcome, RefUpdate, RepoRecord, ATOMIC_PUSH_FAILURE_REASON,
FUNNY_REFNAME_REASON,
};
use gix_hash::ObjectId;
use super::CAS_STALE_REASON;
fn oid(b: u8) -> ObjectId {
ObjectId::from([b; 20])
}
fn up(name: &str, expected: Option<u8>, new: Option<u8>) -> RefUpdate {
RefUpdate {
name: name.to_owned(),
expected: expected.map(oid),
new: new.map(oid),
}
}
fn ok(name: &str) -> RefOutcome {
RefOutcome {
name: name.to_owned(),
result: Ok(()),
}
}
fn deny(name: &str, reason: &str) -> RefOutcome {
RefOutcome {
name: name.to_owned(),
result: Err(reason.to_owned()),
}
}
async fn line_of(table: &dyn GitRefs, repo: &RepoRecord, name: &str) -> Option<RefLine> {
table
.list_refs(repo)
.await
.unwrap()
.into_iter()
.find(|line| line.name == name)
}
pub(super) async fn empty_shapes(table: &dyn GitRefs, repo: &RepoRecord) {
assert!(table.list_refs(repo).await.unwrap().is_empty());
for atomic in [false, true] {
let outcomes = table.apply_updates(repo, Vec::new(), atomic).await.unwrap();
assert!(outcomes.is_empty(), "atomic={atomic}");
}
assert!(table.list_refs(repo).await.unwrap().is_empty());
}
pub(super) async fn stale_shapes_deny_as_outcomes(table: &dyn GitRefs, repo: &RepoRecord) {
table
.apply_updates(repo, vec![up("refs/stale/main", None, Some(1))], false)
.await
.unwrap();
for (shape, update) in [
(
"wrong-expected update",
up("refs/stale/main", Some(2), Some(3)),
),
("create over existing", up("refs/stale/main", None, Some(3))),
(
"wrong-expected delete",
up("refs/stale/main", Some(9), None),
),
] {
let outcomes = table
.apply_updates(repo, vec![update.clone()], false)
.await
.unwrap();
assert_eq!(
outcomes,
vec![deny("refs/stale/main", CAS_STALE_REASON)],
"{shape} denies as the stale outcome"
);
assert_eq!(
line_of(table, repo, "refs/stale/main").await.unwrap().oid,
Some(oid(1)),
"{shape} left the row untouched"
);
}
}
pub(super) async fn delete_applies_and_force_has_no_fast_forward_check(
table: &dyn GitRefs,
repo: &RepoRecord,
) {
table
.apply_updates(repo, vec![up("refs/force/main", None, Some(1))], false)
.await
.unwrap();
let outcomes = table
.apply_updates(repo, vec![up("refs/force/main", Some(1), None)], false)
.await
.unwrap();
assert_eq!(outcomes, vec![ok("refs/force/main")]);
assert!(line_of(table, repo, "refs/force/main").await.is_none());
table
.apply_updates(repo, vec![up("refs/force/head", None, Some(4))], false)
.await
.unwrap();
let outcomes = table
.apply_updates(repo, vec![up("refs/force/head", Some(4), Some(5))], false)
.await
.unwrap();
assert_eq!(outcomes, vec![ok("refs/force/head")]);
assert_eq!(
line_of(table, repo, "refs/force/head").await.unwrap().oid,
Some(oid(5)),
"the non-fast-forward new oid applied — no FF check at the policy leg"
);
}
pub(super) async fn non_atomic_reports_per_ref_with_in_call_visibility(
table: &dyn GitRefs,
repo: &RepoRecord,
) {
table
.apply_updates(repo, vec![up("refs/na/main", None, Some(1))], false)
.await
.unwrap();
let updates = vec![
up("refs/na/main", Some(1), Some(2)),
up("refs/na/topic", Some(3), Some(4)),
up("refs/na/parked", None, Some(5)),
];
let names = updates.iter().map(|u| u.name.clone()).collect::<Vec<_>>();
let outcomes = table.apply_updates(repo, updates, false).await.unwrap();
assert_eq!(
outcomes.iter().map(|o| o.name.clone()).collect::<Vec<_>>(),
names,
"outcomes arrive in request order"
);
assert_eq!(outcomes[0].result, Ok(()));
assert_eq!(outcomes[1].result, Err(CAS_STALE_REASON.to_owned()));
assert_eq!(outcomes[2].result, Ok(()));
assert_eq!(
line_of(table, repo, "refs/na/main").await.unwrap().oid,
Some(oid(2)),
"the ok applied even though a sibling was denied"
);
assert!(
line_of(table, repo, "refs/na/topic").await.is_none(),
"the denied update applied nothing"
);
assert!(line_of(table, repo, "refs/na/parked").await.is_some());
let outcomes = table
.apply_updates(
repo,
vec![
up("refs/na/inline", None, Some(1)),
up("refs/na/inline", Some(1), Some(2)),
],
false,
)
.await
.unwrap();
assert!(outcomes.iter().all(|outcome| outcome.result.is_ok()));
assert_eq!(
line_of(table, repo, "refs/na/inline").await.unwrap().oid,
Some(oid(2)),
"the second update saw the first's applied row (in-call visibility)"
);
}
pub(super) async fn atomic_denials_abort_and_others_report_the_atomic_reason(
table: &dyn GitRefs,
repo: &RepoRecord,
) {
table
.apply_updates(repo, vec![up("refs/atomic/base", None, Some(1))], false)
.await
.unwrap();
let updates = vec![
up("refs/atomic/base", Some(2), Some(3)),
up("refs/atomic/topic", None, Some(4)),
up("refs/atomic/parked", None, Some(5)),
];
let outcomes = table.apply_updates(repo, updates, true).await.unwrap();
assert_eq!(outcomes.len(), 3);
assert_eq!(outcomes[0].result, Err(CAS_STALE_REASON.to_owned()));
assert_eq!(
outcomes[1].result,
Err(ATOMIC_PUSH_FAILURE_REASON.to_owned())
);
assert_eq!(
outcomes[2].result,
Err(ATOMIC_PUSH_FAILURE_REASON.to_owned())
);
assert!(
line_of(table, repo, "refs/atomic/topic").await.is_none(),
"the atomic abort left the applied leg discarded"
);
assert!(
line_of(table, repo, "refs/atomic/parked").await.is_none(),
"the atomic abort left the table untouched"
);
assert_eq!(
line_of(table, repo, "refs/atomic/base").await.unwrap().oid,
Some(oid(1))
);
let outcomes = table
.apply_updates(
repo,
vec![
up("refs/atomic/base", Some(1), None),
up("refs/atomic/topic", None, Some(4)),
],
true,
)
.await
.unwrap();
assert!(outcomes.iter().all(|outcome| outcome.result.is_ok()));
assert!(line_of(table, repo, "refs/atomic/base").await.is_none());
assert_eq!(
line_of(table, repo, "refs/atomic/topic").await.unwrap().oid,
Some(oid(4)),
"an all-ok atomic call committed whole"
);
table
.apply_updates(repo, vec![up("refs/atomic/topic", Some(4), None)], false)
.await
.unwrap();
}
pub(super) async fn invalid_names_deny_as_outcomes(table: &dyn GitRefs, repo: &RepoRecord) {
table
.apply_updates(repo, vec![up("refs/funny/main", None, Some(1))], false)
.await
.unwrap();
let outcomes = table
.apply_updates(
repo,
vec![
up("refs/funny/main.lock", None, Some(2)),
up("refs/alkgit/host", Some(1), None),
up("refs/funny/main", Some(1), Some(2)),
],
false,
)
.await
.unwrap();
assert_eq!(
outcomes[0],
deny("refs/funny/main.lock", FUNNY_REFNAME_REASON),
"the .lock deny-list rule denies at the table layer"
);
assert_eq!(
outcomes[1],
deny("refs/alkgit/host", FUNNY_REFNAME_REASON),
"the reserved namespace denies at the table layer"
);
assert_eq!(outcomes[2].result, Ok(()));
assert_eq!(
line_of(table, repo, "refs/funny/main").await.unwrap().oid,
Some(oid(2)),
"the valid ref of the non-atomic call applied"
);
let before = table.list_refs(repo).await.unwrap();
let outcomes = table
.apply_updates(
repo,
vec![
up("refs/funny/main.lock", None, Some(9)),
up("refs/funny/valid", None, Some(3)),
],
true,
)
.await
.unwrap();
assert_eq!(
outcomes[0],
deny("refs/funny/main.lock", FUNNY_REFNAME_REASON)
);
assert_eq!(
outcomes[1],
deny("refs/funny/valid", ATOMIC_PUSH_FAILURE_REASON)
);
assert_eq!(
table.list_refs(repo).await.unwrap(),
before,
"nothing applied"
);
}
pub(super) async fn unborn_head_lists_the_unborn_line_sorted(
table: &dyn GitRefs,
repo: &RepoRecord,
) {
assert_eq!(
table.list_refs(repo).await.unwrap(),
vec![RefLine {
name: "HEAD".to_owned(),
oid: None,
peeled: None,
symref_target: Some("refs/heads/main".to_owned()),
}]
);
table
.apply_updates(repo, vec![up("refs/heads/zebra", None, Some(1))], false)
.await
.unwrap();
table
.apply_updates(repo, vec![up("refs/heads/alpaca", None, Some(2))], false)
.await
.unwrap();
let listed = table.list_refs(repo).await.unwrap();
assert_eq!(
listed
.iter()
.map(|line| line.name.as_str())
.collect::<Vec<_>>(),
vec!["HEAD", "refs/heads/alpaca", "refs/heads/zebra"],
"deterministic name-sorted listing, the unborn HEAD first"
);
assert_eq!(listed[0].oid, None);
assert_eq!(listed[0].symref_target.as_deref(), Some("refs/heads/main"));
assert_eq!(listed[1].oid, Some(oid(2)));
assert_eq!(listed[2].oid, Some(oid(1)));
}
pub(super) async fn symref_row_is_cas_unreachable(table: &dyn GitRefs, repo: &RepoRecord) {
for shape in [
up("HEAD", None, Some(1)),
up("HEAD", Some(1), Some(2)),
up("HEAD", Some(1), None),
] {
let outcomes = table.apply_updates(repo, vec![shape], false).await.unwrap();
assert_eq!(
outcomes,
vec![deny("HEAD", CAS_STALE_REASON)],
"the HEAD symref row never matches a CAS expectation"
);
}
let head = table
.list_refs(repo)
.await
.unwrap()
.into_iter()
.find(|line| line.name == "HEAD")
.expect("the HEAD symref row survived every denied shape");
assert_eq!(head.oid, None);
assert_eq!(head.symref_target.as_deref(), Some("refs/heads/main"));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use crate::testutil::private_record as repo;
#[cfg(feature = "sqlite")]
use crate::testutil::unique_db_path;
fn oid(b: u8) -> ObjectId {
ObjectId::from([b; 20])
}
#[test]
fn the_denial_and_atomic_reasons_are_the_pinned_texts() {
assert_eq!(CAS_STALE_REASON, "stale info");
assert_eq!(alkgit::ATOMIC_PUSH_FAILURE_REASON, "atomic push failure");
assert_eq!(alkgit::FUNNY_REFNAME_REASON, "funny refname");
}
#[tokio::test]
async fn inmemory_table_carries_the_cas_battery() {
let table: Arc<dyn GitRefs> = Arc::new(InMemoryRefTable::new());
let doubled = repo("owner/doubled");
battery::empty_shapes(table.as_ref(), &doubled).await;
battery::stale_shapes_deny_as_outcomes(table.as_ref(), &doubled).await;
battery::delete_applies_and_force_has_no_fast_forward_check(table.as_ref(), &doubled).await;
battery::non_atomic_reports_per_ref_with_in_call_visibility(table.as_ref(), &doubled).await;
battery::atomic_denials_abort_and_others_report_the_atomic_reason(table.as_ref(), &doubled)
.await;
battery::invalid_names_deny_as_outcomes(table.as_ref(), &doubled).await;
}
#[tokio::test]
async fn inmemory_unborn_head_row_lists_and_holds_cas() {
let table = Arc::new(InMemoryRefTable::new());
let unborn = repo("owner/unborn");
table.rows.write().await.insert(
unborn.repo_id.clone(),
[(
"HEAD".to_owned(),
RowState::Symref("refs/heads/main".to_owned()),
)]
.into_iter()
.collect(),
);
battery::unborn_head_lists_the_unborn_line_sorted(table.as_ref(), &unborn).await;
battery::symref_row_is_cas_unreachable(table.as_ref(), &unborn).await;
}
#[tokio::test]
async fn inmemory_committed_delete_all_empties_the_tier() {
let table = InMemoryRefTable::new();
let doubled = repo("owner/delete-all");
table
.apply_updates(
&doubled,
vec![
RefUpdate {
name: "refs/heads/main".to_owned(),
expected: None,
new: Some(oid(1)),
},
RefUpdate {
name: "refs/other".to_owned(),
expected: None,
new: Some(oid(2)),
},
],
false,
)
.await
.unwrap();
assert_eq!(table.list_refs(&doubled).await.unwrap().len(), 2);
table
.apply_updates(
&doubled,
vec![
RefUpdate {
name: "refs/heads/main".to_owned(),
expected: Some(oid(1)),
new: None,
},
RefUpdate {
name: "refs/other".to_owned(),
expected: Some(oid(2)),
new: None,
},
],
false,
)
.await
.unwrap();
assert!(
table.list_refs(&doubled).await.unwrap().is_empty(),
"the delete-only call emptied the tier"
);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn sqlite_table_carries_the_cas_battery() {
use crate::migrations::CoTenantSqlite;
let path = unique_db_path("refs", "battery");
let handle = CoTenantSqlite::open(&path).unwrap();
let table: Arc<dyn GitRefs> = Arc::new(SqliteRefTable::new(handle.clone()));
let doubled = repo("owner/doubled");
battery::empty_shapes(table.as_ref(), &doubled).await;
battery::stale_shapes_deny_as_outcomes(table.as_ref(), &doubled).await;
battery::delete_applies_and_force_has_no_fast_forward_check(table.as_ref(), &doubled).await;
battery::non_atomic_reports_per_ref_with_in_call_visibility(table.as_ref(), &doubled).await;
battery::atomic_denials_abort_and_others_report_the_atomic_reason(table.as_ref(), &doubled)
.await;
battery::invalid_names_deny_as_outcomes(table.as_ref(), &doubled).await;
let _ = std::fs::remove_file(&path);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn sqlite_unborn_head_row_lists_and_holds_cas() {
use crate::migrations::CoTenantSqlite;
let path = unique_db_path("refs", "unborn");
let handle = CoTenantSqlite::open(&path).unwrap();
let unborn = repo("owner/unborn");
handle
.lock()
.execute(
"INSERT INTO git_refs (repo_id, name, oid_hex, symref_target) \
VALUES (?1, 'HEAD', NULL, 'refs/heads/main')",
rusqlite::params![unborn.repo_id],
)
.unwrap();
let table: Arc<dyn GitRefs> = Arc::new(SqliteRefTable::new(handle.clone()));
battery::unborn_head_lists_the_unborn_line_sorted(table.as_ref(), &unborn).await;
battery::symref_row_is_cas_unreachable(table.as_ref(), &unborn).await;
let _ = std::fs::remove_file(&path);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn sqlite_two_clashing_applies_from_two_connections() {
use crate::migrations::CoTenantSqlite;
let path = unique_db_path("refs", "clash");
let first =
Arc::new(SqliteRefTable::new(CoTenantSqlite::open(&path).unwrap())) as Arc<dyn GitRefs>;
let second =
Arc::new(SqliteRefTable::new(CoTenantSqlite::open(&path).unwrap())) as Arc<dyn GitRefs>;
let clashers = repo("owner/clash");
let a = tokio::spawn({
let first = first.clone();
let clashers = clashers.clone();
async move {
first
.apply_updates(
&clashers,
vec![RefUpdate {
name: "refs/heads/main".to_owned(),
expected: None,
new: Some(oid(1)),
}],
false,
)
.await
}
});
let b = tokio::spawn({
let second = second.clone();
let clashers = clashers.clone();
async move {
second
.apply_updates(
&clashers,
vec![RefUpdate {
name: "refs/heads/main".to_owned(),
expected: None,
new: Some(oid(2)),
}],
false,
)
.await
}
});
let (a_out, b_out) = tokio::join!(a, b);
let (a_out, b_out) = (a_out.unwrap().unwrap(), b_out.unwrap().unwrap());
let a_won = a_out[0].result.is_ok();
assert!(
a_won != b_out[0].result.is_ok(),
"two clashing creates on one row: exactly one wins"
);
let loser = if a_won {
b_out[0].clone()
} else {
a_out[0].clone()
};
assert_eq!(loser.result, Err(CAS_STALE_REASON.to_owned()));
let winner_oid = if a_won { Some(oid(1)) } else { Some(oid(2)) };
let winner = if a_won { first.clone() } else { second.clone() };
let winner: Arc<dyn GitRefs> = winner;
assert_eq!(
winner.list_refs(&clashers).await.unwrap()[0].oid,
winner_oid,
"the committed winner's row is the row the table carries"
);
drop(first);
drop(second);
let _ = std::fs::remove_file(&path);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn sqlite_corrupted_row_states_map_storage_invalid() {
use crate::migrations::CoTenantSqlite;
let path = unique_db_path("refs", "corrupt");
let handle = CoTenantSqlite::open(&path).unwrap();
let table = SqliteRefTable::new(handle.clone());
let corrupt = repo("owner/corrupt");
handle
.lock()
.execute(
"INSERT INTO git_refs (repo_id, name, oid_hex, symref_target) \
VALUES (?1, 'refs/heads/bent', NULL, NULL)",
rusqlite::params![corrupt.repo_id],
)
.unwrap();
assert!(matches!(
table.list_refs(&corrupt).await.unwrap_err(),
StorageError::Invalid(_)
));
handle
.lock()
.execute(
"UPDATE git_refs SET oid_hex = ?1, symref_target = 'refs/heads/main' \
WHERE repo_id = ?2 AND name = 'refs/heads/bent'",
rusqlite::params![oid(1).to_string(), corrupt.repo_id],
)
.unwrap();
assert!(matches!(
table.list_refs(&corrupt).await.unwrap_err(),
StorageError::Invalid(_)
));
handle
.lock()
.execute(
"UPDATE git_refs SET oid_hex = 'not-hex', symref_target = NULL \
WHERE repo_id = ?1 AND name = 'refs/heads/bent'",
rusqlite::params![corrupt.repo_id],
)
.unwrap();
assert!(matches!(
table.list_refs(&corrupt).await.unwrap_err(),
StorageError::Invalid(_)
));
drop(table);
drop(handle);
let _ = std::fs::remove_file(&path);
}
}
#[cfg(all(test, feature = "postgres"))]
mod pg_twin_tests {
use crate::postgres::live_harness::{drop_schema, live_pg, unique_schema};
use crate::testutil::private_record as repo;
use alkgit::{GitRefs, StorageError};
use gix_hash::ObjectId;
use super::battery;
fn oid(b: u8) -> ObjectId {
ObjectId::from([b; 20])
}
use super::PgRefTable;
#[tokio::test]
async fn pg_table_carries_the_cas_battery() {
let dsn = live_pg!();
let schema = unique_schema("refs-battery");
let table = PgRefTable::new(
crate::postgres::CoTenantPg::open(&dsn, &schema)
.await
.unwrap(),
);
let doubled = repo("owner/doubled");
let table = std::sync::Arc::new(table);
let table: std::sync::Arc<dyn GitRefs> = table;
battery::empty_shapes(table.as_ref(), &doubled).await;
battery::stale_shapes_deny_as_outcomes(table.as_ref(), &doubled).await;
battery::delete_applies_and_force_has_no_fast_forward_check(table.as_ref(), &doubled).await;
battery::non_atomic_reports_per_ref_with_in_call_visibility(table.as_ref(), &doubled).await;
battery::atomic_denials_abort_and_others_report_the_atomic_reason(table.as_ref(), &doubled)
.await;
battery::invalid_names_deny_as_outcomes(table.as_ref(), &doubled).await;
drop(table);
drop_schema(&dsn, &schema).await;
}
#[tokio::test]
async fn pg_unborn_head_row_lists_and_holds_cas() {
let dsn = live_pg!();
let schema = unique_schema("refs-unborn");
let handle = crate::postgres::CoTenantPg::open(&dsn, &schema)
.await
.unwrap();
let unborn = repo("owner/unborn");
handle
.lock()
.await
.execute(
&format!(
"INSERT INTO {} (repo_id, name, oid_hex, symref_target) \
VALUES ($1, 'HEAD', NULL, 'refs/heads/main')",
crate::migrations::qualify(&schema, "git_refs")
),
&[&unborn.repo_id],
)
.await
.unwrap();
let table = std::sync::Arc::new(PgRefTable::new(handle));
let table: std::sync::Arc<dyn GitRefs> = table;
battery::unborn_head_lists_the_unborn_line_sorted(table.as_ref(), &unborn).await;
battery::symref_row_is_cas_unreachable(table.as_ref(), &unborn).await;
drop(table);
drop_schema(&dsn, &schema).await;
}
#[tokio::test]
async fn pg_two_clashing_applies_from_two_connections() {
use super::CAS_STALE_REASON;
let dsn = live_pg!();
let schema = unique_schema("refs-clash");
let first = std::sync::Arc::new(PgRefTable::new(
crate::postgres::CoTenantPg::open(&dsn, &schema)
.await
.unwrap(),
)) as std::sync::Arc<dyn GitRefs>;
let second = std::sync::Arc::new(PgRefTable::new(
crate::postgres::CoTenantPg::open(&dsn, &schema)
.await
.unwrap(),
)) as std::sync::Arc<dyn GitRefs>;
let clashers = repo("owner/clash");
let a = tokio::spawn({
let first = first.clone();
let clashers = clashers.clone();
async move {
first
.apply_updates(
&clashers,
vec![alkgit::RefUpdate {
name: "refs/heads/main".to_owned(),
expected: None,
new: Some(oid(1)),
}],
false,
)
.await
}
});
let b = tokio::spawn({
let second = second.clone();
let clashers = clashers.clone();
async move {
second
.apply_updates(
&clashers,
vec![alkgit::RefUpdate {
name: "refs/heads/main".to_owned(),
expected: None,
new: Some(oid(2)),
}],
false,
)
.await
}
});
let (a_out, b_out) = tokio::join!(a, b);
let (a_out, b_out) = (a_out.unwrap().unwrap(), b_out.unwrap().unwrap());
let a_won = a_out[0].result.is_ok();
assert!(
a_won != b_out[0].result.is_ok(),
"two clashing creates on one row: exactly one wins"
);
let loser = if a_won {
b_out[0].clone()
} else {
a_out[0].clone()
};
assert_eq!(loser.result, Err(CAS_STALE_REASON.to_owned()));
let winner_oid = if a_won { oid(1) } else { oid(2) };
let winner = if a_won { first } else { second };
assert_eq!(
winner.list_refs(&clashers).await.unwrap()[0].oid,
Some(winner_oid),
"the committed winner's row is the row the table carries"
);
drop(winner);
drop_schema(&dsn, &schema).await;
}
#[tokio::test]
async fn pg_corrupted_row_states_map_storage_invalid() {
let dsn = live_pg!();
let schema = unique_schema("refs-corrupt");
let handle = crate::postgres::CoTenantPg::open(&dsn, &schema)
.await
.unwrap();
let table = PgRefTable::new(handle.clone());
let corrupt = repo("owner/corrupt");
let table_name = crate::migrations::qualify(&schema, "git_refs");
let second = crate::postgres::live_harness::connect_direct(&dsn).await;
second
.execute(
&format!(
"INSERT INTO {table_name} (repo_id, name, oid_hex, symref_target) \
VALUES ($1, 'refs/heads/bent', NULL, NULL)"
),
&[&corrupt.repo_id],
)
.await
.unwrap();
assert!(
matches!(
table.list_refs(&corrupt).await.unwrap_err(),
StorageError::Invalid(_)
),
"neither column set is structurally invalid"
);
second
.execute(
&format!(
"UPDATE {table_name} SET oid_hex = $1, symref_target = 'refs/heads/main' \
WHERE repo_id = $2 AND name = 'refs/heads/bent'"
),
&[&oid(1).to_string(), &corrupt.repo_id],
)
.await
.unwrap();
assert!(
matches!(
table.list_refs(&corrupt).await.unwrap_err(),
StorageError::Invalid(_)
),
"both columns set is structurally invalid"
);
second
.execute(
&format!(
"UPDATE {table_name} SET oid_hex = 'not-hex', symref_target = NULL \
WHERE repo_id = $1 AND name = 'refs/heads/bent'"
),
&[&corrupt.repo_id],
)
.await
.unwrap();
assert!(matches!(
table.list_refs(&corrupt).await.unwrap_err(),
StorageError::Invalid(_)
));
drop(second);
drop_schema(&dsn, &schema).await;
}
#[tokio::test]
async fn pg_snapshot_matches_the_reference_impl_canonically() {
use super::InMemoryRefTable;
use crate::liveness::RefRows;
let dsn = live_pg!();
let schema = unique_schema("refs-parity");
let handle = crate::postgres::CoTenantPg::open(&dsn, &schema)
.await
.unwrap();
let seeded = repo("owner/parity");
let sister = repo("owner/parity-sister");
handle
.lock()
.await
.execute(
&format!(
"INSERT INTO {} (repo_id, name, oid_hex, symref_target) \
VALUES ($1, 'HEAD', NULL, 'refs/heads/main'), \
($1, 'refs/heads/main', $2, NULL)",
crate::migrations::qualify(&schema, "git_refs")
),
&[&seeded.repo_id, &oid(1).to_string()],
)
.await
.unwrap();
handle
.lock()
.await
.execute(
&format!(
"INSERT INTO {} (repo_id, name, oid_hex, symref_target) \
VALUES ($1, 'refs/tags/v1', $2, NULL)",
crate::migrations::qualify(&schema, "git_refs")
),
&[&sister.repo_id, &oid(9).to_string()],
)
.await
.unwrap();
let mem = InMemoryRefTable::new();
{
use super::RowState;
let mut rows = mem.rows.write().await;
rows.insert(
seeded.repo_id.clone(),
[
(
"HEAD".to_owned(),
RowState::Symref("refs/heads/main".to_owned()),
),
("refs/heads/main".to_owned(), RowState::Oid(oid(1))),
]
.into_iter()
.collect(),
);
rows.insert(
sister.repo_id.clone(),
[("refs/tags/v1".to_owned(), RowState::Oid(oid(9)))]
.into_iter()
.collect(),
);
}
let pg = PgRefTable::new(handle);
assert_eq!(
pg.snapshot().await.unwrap(),
mem.snapshot().await.unwrap(),
"the same rows snapshot identically — one canonical form, \
one fingerprint, whatever the engine"
);
assert_eq!(
pg.list_refs(&seeded).await.unwrap(),
mem.list_refs(&seeded).await.unwrap()
);
drop(pg);
drop(mem);
drop_schema(&dsn, &schema).await;
}
}