use std::collections::HashSet;
use std::fmt::Display;
use std::path::Path;
use super::{NameTable, key, parse_uuid, warn_held_by_another};
use crate::error::OxenError;
use crate::lmdb::store::LmdbStore;
use crate::repositories::held_name_in_config;
use crate::sync_dir;
const SEEDED_KEY: &[u8] = b"\0seeded";
const NAMED_IN_A_WARNING: usize = 10;
impl NameTable {
fn is_seeded(&self) -> Result<bool, OxenError> {
self.read(|db, txn| Ok(db.contains(txn, SEEDED_KEY)?))
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct Seeded {
pub covered: usize,
pub recorded: usize,
pub uncovered: usize,
pub unlistable: usize,
pub unclaimed: usize,
}
impl Seeded {
pub fn complete(&self) -> bool {
self.uncovered == 0 && self.unlistable == 0
}
}
pub fn run_if_incomplete(sync_dir: &Path) -> Result<Option<Seeded>, OxenError> {
if NameTable::open(sync_dir)?.is_seeded()? {
return Ok(None);
}
run(sync_dir).map(Some)
}
pub fn run(sync_dir: &Path) -> Result<Seeded, OxenError> {
let mut repo_dirs = Vec::new();
let mut unlistable = 0;
for namespace_dir in sync_dir::namespace_dirs(sync_dir)? {
match sync_dir::repo_dirs(&namespace_dir) {
Ok(in_namespace) => repo_dirs.extend(in_namespace),
Err(err) => {
log::warn!(
"Leaving the repositories in {namespace_dir:?} out of the name table, since \
it cannot be listed: {err}"
);
unlistable += 1;
}
}
}
let mut held = Vec::new();
let mut nameless = Vec::new();
for repo_dir in repo_dirs {
match held_name_in_config(&repo_dir) {
Some(name) => held.push(name),
None => nameless.push(repo_dir),
}
}
if !nameless.is_empty() {
log::warn!(
"Leaving {} of {} repositories out of the name table, since the server reads no \
whole name for them. The optional backfill_repo_identity migration records one for \
a repository addressed by name: {}",
nameless.len(),
nameless.len() + held.len(),
named(nameless.iter().map(|path| path.display()))
);
}
NameTable::open(sync_dir)?.write(|db, txn| {
let mut recorded = 0;
let mut disputed = 0;
let mut claimed = HashSet::new();
for (namespace, name, repo_uuid) in &held {
let key = key(namespace, name);
match db.get(txn, &key)? {
None => {
db.put(txn, &key, repo_uuid.to_string().as_bytes())?;
recorded += 1;
claimed.insert(key);
}
Some(entry) => match parse_uuid(&entry, namespace, name) {
Ok(holder) if holder == *repo_uuid => {
claimed.insert(key);
}
Ok(holder) => {
warn_held_by_another(namespace, name, holder, *repo_uuid);
disputed += 1;
}
Err(err) => {
log::warn!("Leaving {namespace}/{name} alone: {err}");
disputed += 1;
}
},
}
}
let mut unclaimed = Vec::new();
for key in db.iter_keys(txn)? {
let key = key?;
if key != SEEDED_KEY && !claimed.contains(key) {
unclaimed.push(String::from_utf8_lossy(key).into_owned());
}
}
if !unclaimed.is_empty() {
log::warn!(
"Leaving {} of {} names in the name table taken, though no repository's config \
records them, as with a repository removed by hand, a create that did not \
finish, or a config that cannot be read: {}",
unclaimed.len(),
claimed.len() + unclaimed.len(),
named(unclaimed.iter())
);
}
let seeded = Seeded {
covered: held.len() - disputed,
recorded,
uncovered: nameless.len() + disputed,
unlistable,
unclaimed: unclaimed.len(),
};
if seeded.complete() {
db.put(txn, SEEDED_KEY, b"")?;
} else {
db.delete(txn, SEEDED_KEY)?;
}
Ok(seeded)
})
}
fn named(items: impl ExactSizeIterator<Item = impl Display>) -> String {
let total = items.len();
let named: Vec<String> = items
.take(NAMED_IN_A_WARNING)
.map(|item| item.to_string())
.collect();
match total - named.len() {
0 => named.join(", "),
rest => format!("{}, and {rest} more", named.join(", ")),
}
}
#[cfg(test)]
mod tests {
use uuid::Uuid;
use super::*;
use crate::config::RepositoryConfig;
use crate::model::RepoIdentity;
use crate::test;
use crate::util;
fn repo_recording(
sync_dir: &Path,
namespace: &str,
name: &str,
identity: Option<RepoIdentity>,
) -> Result<(), OxenError> {
let repo_dir = sync_dir.join(namespace).join(name);
util::fs::create_dir_all(util::fs::oxen_hidden_dir(&repo_dir))?;
RepositoryConfig {
identity,
..Default::default()
}
.save(util::fs::config_filepath(&repo_dir))?;
Ok(())
}
#[test]
fn a_walk_records_the_name_every_repository_holds() -> Result<(), OxenError> {
test::run_empty_dir_test(|sync_dir| {
let cats = RepoIdentity::minted("ox", "cats");
repo_recording(sync_dir, "ox", "cats", Some(cats.clone()))?;
repo_recording(
sync_dir,
"ox",
"dogs",
Some(RepoIdentity::hintless(Uuid::new_v4())),
)?;
repo_recording(sync_dir, "cow", "birds", None)?;
assert_eq!(
run(sync_dir)?,
Seeded {
covered: 1,
recorded: 1,
uncovered: 2,
unclaimed: 0,
unlistable: 0
},
"a repository holding half a name, and one holding no identity, are left out"
);
let table = NameTable::open(sync_dir)?;
assert_eq!(
table.get("ox", "cats")?,
Some(cats.repo_uuid),
"a repository holding a whole name is recorded under it"
);
assert!(!table.is_seeded()?);
assert_eq!(
run(sync_dir)?,
Seeded {
covered: 1,
recorded: 0,
uncovered: 2,
unclaimed: 0,
unlistable: 0
},
"a walk records nothing the table already holds"
);
let dogs = RepoIdentity::minted("ox", "dogs");
repo_recording(sync_dir, "ox", "dogs", Some(dogs.clone()))?;
repo_recording(
sync_dir,
"cow",
"birds",
Some(RepoIdentity::minted("cow", "birds")),
)?;
assert_eq!(
run(sync_dir)?,
Seeded {
covered: 3,
recorded: 2,
uncovered: 0,
unclaimed: 0,
unlistable: 0
},
"the repositories left out of one walk are what the next one records"
);
assert_eq!(table.get("ox", "dogs")?, Some(dogs.repo_uuid));
assert!(table.is_seeded()?);
assert_eq!(
run_if_incomplete(sync_dir)?,
None,
"a table covering every repository is not walked again"
);
repo_recording(
sync_dir,
"zoo",
"cats",
Some(RepoIdentity {
repo_uuid: Uuid::new_v4(),
..cats.clone()
}),
)?;
assert_eq!(
run(sync_dir)?,
Seeded {
covered: 3,
recorded: 0,
uncovered: 1,
unclaimed: 0,
unlistable: 0
},
"a name a different repository holds is left with it"
);
assert_eq!(
table.get("ox", "cats")?,
Some(cats.repo_uuid),
"the repository already recorded under a name keeps it"
);
assert!(
run_if_incomplete(sync_dir)?.is_some(),
"a table that has stopped covering every repository is walked again"
);
util::fs::remove_dir_all(sync_dir.join("cow").join("birds"))?;
assert_eq!(
run(sync_dir)?,
Seeded {
covered: 2,
recorded: 0,
uncovered: 1,
unclaimed: 1,
unlistable: 0
},
"an entry for a repository removed by hand is reported"
);
assert!(
table.get("cow", "birds")?.is_some(),
"an entry no config claims is left in place"
);
Ok(())
})
}
}