use std::path::{Path, PathBuf};
use std::sync::Arc;
use bytesize::ByteSize;
use uuid::Uuid;
use crate::api::requests::RepoNew;
use crate::error::OxenError;
use crate::lmdb::store::LmdbStore;
use crate::lmdb::{LmdbDb, LmdbEnv, LmdbEnvConfig, open_db, open_shared_env};
use crate::sync_dir::NAME_TABLE_DIR;
pub mod seed;
const NAMES_DB_NAME: &str = "names";
const MAX_DBS: u32 = 1;
const NAME_TABLE_MAP_SIZE: ByteSize = ByteSize::gib(8);
fn name_table_dir(sync_dir: &Path) -> PathBuf {
sync_dir.join(NAME_TABLE_DIR)
}
pub struct NameTable {
env: Arc<LmdbEnv>,
db: LmdbDb,
}
impl NameTable {
pub fn open(sync_dir: &Path) -> Result<Self, OxenError> {
let config = LmdbEnvConfig::new(MAX_DBS, NAME_TABLE_MAP_SIZE);
let env = open_shared_env(&name_table_dir(sync_dir), &config)?;
let db = open_db(&env, NAMES_DB_NAME)?;
Ok(NameTable { env, db })
}
pub fn get(&self, namespace: &str, name: &str) -> Result<Option<Uuid>, OxenError> {
self.read(|db, txn| match db.get(txn, &key(namespace, name))? {
Some(recorded) => Ok(Some(parse_uuid(&recorded, namespace, name)?)),
None => Ok(None),
})
}
pub fn claim(&self, namespace: &str, name: &str, repo_uuid: Uuid) -> Result<bool, OxenError> {
self.write(|db, txn| {
let key = key(namespace, name);
if let Some(recorded) = db.get(txn, &key)? {
return match parse_uuid(&recorded, namespace, name)? {
holder if holder == repo_uuid => Ok(false),
_ => Err(already_taken(namespace, name)),
};
}
db.put(txn, &key, repo_uuid.to_string().as_bytes())?;
Ok(true)
})
}
pub fn release(&self, namespace: &str, name: &str, repo_uuid: Uuid) -> Result<(), OxenError> {
self.write(|db, txn| {
let key = key(namespace, name);
if let Some(recorded) = db.get(txn, &key)? {
let holder = parse_uuid(&recorded, namespace, name)?;
if holder != repo_uuid {
warn_held_by_another(namespace, name, holder, repo_uuid);
return Ok(());
}
}
db.delete(txn, &key)?;
Ok(())
})
}
pub fn move_to_namespace(
&self,
namespace: &str,
name: &str,
to_namespace: &str,
repo_uuid: Uuid,
) -> Result<(), OxenError> {
self.write(|db, txn| {
let from = key(namespace, name);
let Some(recorded) = db.get(txn, &from)? else {
return Ok(());
};
let holder = parse_uuid(&recorded, namespace, name)?;
if holder != repo_uuid {
warn_held_by_another(namespace, name, holder, repo_uuid);
return Ok(());
}
let to = key(to_namespace, name);
if to != from && db.contains(txn, &to)? {
return Err(already_taken(to_namespace, name));
}
db.delete(txn, &from)?;
db.put(txn, &to, &recorded)?;
Ok(())
})
}
}
impl LmdbStore for NameTable {
fn lmdb_env(&self) -> &LmdbEnv {
&self.env
}
fn lmdb_db(&self) -> &LmdbDb {
&self.db
}
}
fn key(namespace: &str, name: &str) -> Vec<u8> {
format!(
"{}/{}",
namespace.to_ascii_lowercase(),
name.to_ascii_lowercase()
)
.into_bytes()
}
fn parse_uuid(recorded: &[u8], namespace: &str, name: &str) -> Result<Uuid, OxenError> {
let recorded = std::str::from_utf8(recorded)
.map_err(|err| OxenError::internal_error(format!("{namespace}/{name}: {err}")))?;
Uuid::parse_str(recorded).map_err(|err| {
OxenError::internal_error(format!(
"{namespace}/{name} is recorded as '{recorded}', which is not a UUID: {err}"
))
})
}
fn warn_held_by_another(namespace: &str, name: &str, holder: Uuid, repo_uuid: Uuid) {
log::warn!("Leaving {namespace}/{name} alone: it names {holder}, not {repo_uuid}");
}
fn already_taken(namespace: &str, name: &str) -> OxenError {
OxenError::RepoAlreadyExists(Box::new(RepoNew::from_namespace_name(
namespace, name, None,
)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test;
#[test]
fn a_name_belongs_to_one_repository_and_follows_it() -> Result<(), OxenError> {
test::run_empty_dir_test(|sync_dir| {
let table = NameTable::open(sync_dir)?;
let cats = Uuid::new_v4();
assert!(table.claim("ox", "cats", cats)?, "a free name is recorded");
assert_eq!(table.get("ox", "cats")?, Some(cats));
assert_eq!(
table.get("OX", "Cats")?,
Some(cats),
"a name resolves whatever case it is asked for in"
);
assert_eq!(table.get("ox", "dogs")?, None);
assert_eq!(
table.get("cow", "cats")?,
None,
"the namespace is half of the name, so one repository name is free in another"
);
let err = table
.claim("OX", "CATS", Uuid::new_v4())
.expect_err("a name one repository holds cannot be claimed by another");
assert!(
matches!(err, OxenError::RepoAlreadyExists(_)),
"expected the conflict the create path reports as 409, got {err:?}"
);
assert_eq!(
table.get("ox", "cats")?,
Some(cats),
"a refused claim leaves the holder in place"
);
assert!(
!table.claim("OX", "CATS", cats)?,
"a repeat of a claim records nothing, and reports so"
);
assert_eq!(
table.get("ox", "cats")?,
Some(cats),
"the repository already holding a name may claim it again"
);
let impostor = Uuid::new_v4();
table.claim("cow", "cats", impostor)?;
let err = table
.move_to_namespace("ox", "cats", "cow", cats)
.expect_err("a name the destination holds refuses the move");
assert!(
matches!(err, OxenError::RepoAlreadyExists(_)),
"expected a name conflict, got {err:?}"
);
assert_eq!(
(table.get("ox", "cats")?, table.get("cow", "cats")?),
(Some(cats), Some(impostor)),
"a refused move leaves both names as they were"
);
table.release("cow", "cats", cats)?;
table.move_to_namespace("cow", "cats", "zoo", cats)?;
assert_eq!(
(table.get("cow", "cats")?, table.get("zoo", "cats")?),
(Some(impostor), None),
"a name another repository holds is neither released nor moved out from under it"
);
table.move_to_namespace("ox", "cats", "zoo", cats)?;
assert_eq!(table.get("zoo", "cats")?, Some(cats));
assert_eq!(
table.get("ox", "cats")?,
None,
"a move frees the name it came from"
);
table.move_to_namespace("ZOO", "cats", "ZOO", cats)?;
assert_eq!(
table.get("zoo", "cats")?,
Some(cats),
"a move into the namespace a repository already sits in leaves it recorded"
);
table.move_to_namespace("ox", "cats", "zoo", cats)?;
assert_eq!(
table.get("zoo", "cats")?,
Some(cats),
"moving a name no repository holds leaves the table alone"
);
table.release("ZOO", "cats", cats)?;
assert_eq!(table.get("zoo", "cats")?, None);
table.release("zoo", "cats", cats)?;
let next = Uuid::new_v4();
table.claim("zoo", "cats", next)?;
assert_eq!(
table.get("zoo", "cats")?,
Some(next),
"a released name is free for the next repository"
);
table.write(|db, txn| {
db.put(txn, &key("ox", "dogs"), b"not a uuid")?;
Ok::<(), OxenError>(())
})?;
let err = table
.get("ox", "dogs")
.expect_err("a value that is not a UUID cannot resolve to a repository");
assert!(
err.to_string().contains("ox/dogs"),
"the error should name the entry it read, got {err}"
);
drop(table);
assert_eq!(
NameTable::open(sync_dir)?.get("zoo", "cats")?,
Some(next),
"an entry outlives the env that recorded it"
);
Ok(())
})
}
}