use std::path::{Path, PathBuf};
use bytesize::ByteSize;
use uuid::Uuid;
use crate::api::requests::RepoNew;
use crate::error::OxenError;
use crate::lmdb::store::{LmdbSlot, LmdbStore};
use crate::sync_dir::NAME_TABLE_DIR;
pub mod seed;
const NAMES_DB_NAME: &str = "names";
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 {
lmdb: LmdbSlot,
}
impl NameTable {
pub fn new(sync_dir: &Path) -> Self {
NameTable {
lmdb: LmdbSlot::new(name_table_dir(sync_dir)),
}
}
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(())
})
}
pub fn rename(
&self,
namespace: &str,
name: &str,
to_name: &str,
repo_uuid: Uuid,
) -> Result<(), OxenError> {
self.write(|db, txn| {
let to = key(namespace, to_name);
if let Some(recorded) = db.get(txn, &to)?
&& parse_uuid(&recorded, namespace, to_name)? != repo_uuid
{
return Err(already_taken(namespace, to_name));
}
let from = key(namespace, name);
if from != to
&& let Some(recorded) = db.get(txn, &from)?
{
match parse_uuid(&recorded, namespace, name)? {
holder if holder == repo_uuid => {
db.delete(txn, &from)?;
}
holder => warn_held_by_another(namespace, name, holder, repo_uuid),
}
}
db.put(txn, &to, repo_uuid.to_string().as_bytes())?;
Ok(())
})
}
}
impl LmdbStore for NameTable {
const LMDB_MAP_SIZE: ByteSize = NAME_TABLE_MAP_SIZE;
const LMDB_DB_NAME: &'static str = NAMES_DB_NAME;
fn lmdb_slot(&self) -> &LmdbSlot {
&self.lmdb
}
}
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::new(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.rename("ZOO", "Cats", "dogs", next)?;
assert_eq!(
(table.get("zoo", "cats")?, table.get("zoo", "dogs")?),
(None, Some(next)),
"a rename frees the old name and records the new one"
);
let err = table
.rename("zoo", "birds", "DOGS", impostor)
.expect_err("a name another repository holds refuses the rename");
assert!(
matches!(err, OxenError::RepoAlreadyExists(_)),
"expected a name conflict, got {err:?}"
);
table.rename("zoo", "dogs", "eels", impostor)?;
assert_eq!(
(table.get("zoo", "dogs")?, table.get("zoo", "eels")?),
(Some(next), Some(impostor)),
"a rename leaves an old name another repository holds, and records the new one"
);
table.release("zoo", "eels", impostor)?;
table.rename("zoo", "dogs", "cats", next)?;
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::new(sync_dir).get("zoo", "cats")?,
Some(next),
"an entry outlives the env that recorded it"
);
Ok(())
})
}
}