use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::sync::Mutex;
use mkit_core::hash::Hash;
use mkit_core::object::{EntryMode, Object, ObjectType};
use super::TakedownGate;
use super::resolve::{self, Budget, Env, Miss};
use crate::repo::RepoId;
use crate::{BlobStore, BoxFuture, MaybeSend, MaybeSync, NamespaceStore, ServerError};
const BATCH: usize = 256;
const MANIFEST_FIXED: u64 = 22;
const MAX_MANIFEST_CHUNKS: u64 = 1_000_000;
const SWEEP_MS: u64 = 1_000;
pub trait Reachability: MaybeSend + MaybeSync {
fn known_reachable<'a>(
&'a self,
repo: &'a RepoId,
id: &'a Hash,
now_ms: u64,
) -> BoxFuture<'a, Result<bool, ServerError>>;
fn record(&self, repo: &RepoId, id: &Hash, now_ms: u64);
fn invalidate(&self, repo: &RepoId);
}
#[derive(Debug)]
pub struct TtlReachability {
lag_ms: u64,
max_entries: usize,
table: Mutex<Table>,
}
#[derive(Debug, Default)]
struct Table {
rows: BTreeMap<(RepoId, Hash), u64>,
swept_ms: u64,
}
impl TtlReachability {
#[must_use]
pub fn new(lag_ms: u64, max_entries: usize) -> Self {
Self {
lag_ms,
max_entries,
table: Mutex::default(),
}
}
fn table(&self) -> std::sync::MutexGuard<'_, Table> {
self.table
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
impl Reachability for TtlReachability {
fn known_reachable<'a>(
&'a self,
repo: &'a RepoId,
id: &'a Hash,
now_ms: u64,
) -> BoxFuture<'a, Result<bool, ServerError>> {
Box::pin(async move {
Ok(self
.table()
.rows
.get(&(repo.clone(), *id))
.is_some_and(|expires| *expires > now_ms))
})
}
fn record(&self, repo: &RepoId, id: &Hash, now_ms: u64) {
let mut table = self.table();
if table.rows.len() >= self.max_entries && now_ms >= table.swept_ms.saturating_add(SWEEP_MS)
{
table.rows.retain(|_, expires| *expires > now_ms);
table.swept_ms = now_ms;
}
if table.rows.len() < self.max_entries {
table
.rows
.insert((repo.clone(), *id), now_ms.saturating_add(self.lag_ms));
}
}
fn invalidate(&self, repo: &RepoId) {
self.table()
.rows
.retain(|(row_repo, _), _| row_repo != repo);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Reach {
Reachable,
Unreachable,
Capped,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
Node,
Tree,
File,
}
fn manifest_sized(size: u64) -> bool {
(MANIFEST_FIXED..=MANIFEST_FIXED + 32 * MAX_MANIFEST_CHUNKS).contains(&size)
&& (size - MANIFEST_FIXED).is_multiple_of(32)
}
struct Frontier {
cap: usize,
seen: BTreeSet<Hash>,
nodes: VecDeque<Hash>,
work: VecDeque<(Hash, Kind)>,
incomplete: Option<Miss>,
}
impl Frontier {
fn push(&mut self, id: Hash, kind: Kind) {
if self.seen.contains(&id) {
return;
}
if self.seen.len() >= self.cap {
self.incomplete = Some(Miss::Capped);
return;
}
self.seen.insert(id);
match kind {
Kind::Node => self.nodes.push_back(id),
Kind::Tree | Kind::File => self.work.push_back((id, kind)),
}
}
fn batch(&mut self) -> Vec<(Hash, Kind)> {
let mut batch = Vec::new();
if self.work.is_empty() {
while batch.len() < BATCH {
let Some(id) = self.nodes.pop_front() else {
break;
};
batch.push((id, Kind::Node));
}
} else {
while batch.len() < BATCH {
let Some(item) = self.work.pop_front() else {
break;
};
batch.push(item);
}
}
batch
}
}
fn expand(object: &Object, frontier: &mut Frontier, mut reached: impl FnMut(Hash)) {
let mut visit = |child: Hash, kind: Option<Kind>| {
reached(child);
if let Some(kind) = kind {
frontier.push(child, kind);
}
};
match object {
Object::Commit(c) => {
visit(c.tree_hash, Some(Kind::Tree));
c.parents.iter().for_each(|p| visit(*p, Some(Kind::Node)));
}
Object::Remix(r) => {
visit(r.tree_hash, Some(Kind::Tree));
r.parents.iter().for_each(|p| visit(*p, Some(Kind::Node)));
}
Object::Tree(t) => {
for entry in &t.entries {
let kind = if entry.mode == EntryMode::Tree {
Kind::Tree
} else {
Kind::File
};
visit(entry.object_hash, Some(kind));
}
}
Object::ChunkedBlob(cb) => cb.chunks.iter().for_each(|c| visit(*c, None)),
Object::Tag(t) => visit(
t.target,
match t.target_type {
ObjectType::Tree => Some(Kind::Tree),
ObjectType::Blob | ObjectType::ChunkedBlob => Some(Kind::File),
ObjectType::Delta => None,
_ => Some(Kind::Node),
},
),
Object::Blob(_) | Object::Delta(_) => {}
}
}
pub(crate) async fn walk<B: BlobStore, N: NamespaceStore>(
env: &Env<'_, B, N>,
takedown: &dyn TakedownGate,
tips: &[Hash],
target: Hash,
budget: &mut Budget,
) -> Result<Reach, Miss> {
let targets = BTreeSet::from([target]);
let (reached, incomplete) = walk_many(env, takedown, tips, &targets, budget).await?;
Ok(if reached.contains(&target) {
Reach::Reachable
} else if incomplete == Some(Miss::Capped) {
Reach::Capped
} else {
Reach::Unreachable
})
}
pub(crate) async fn walk_many<B: BlobStore, N: NamespaceStore>(
env: &Env<'_, B, N>,
takedown: &dyn TakedownGate,
tips: &[Hash],
targets: &BTreeSet<Hash>,
budget: &mut Budget,
) -> Result<(BTreeSet<Hash>, Option<Miss>), Miss> {
let mut reached = BTreeSet::new();
let mut frontier = Frontier {
cap: env.cfg.max_walk_objects,
seen: BTreeSet::new(),
nodes: VecDeque::new(),
work: VecDeque::new(),
incomplete: None,
};
let mut record = |id| {
if targets.contains(&id) {
reached.insert(id);
}
};
for tip in tips {
record(*tip);
frontier.push(*tip, Kind::Node);
}
loop {
if reached.len() == targets.len() {
return Ok((reached, None));
}
let batch = frontier.batch();
if batch.is_empty() {
return Ok((reached, frontier.incomplete));
}
let ids: Vec<Hash> = batch.iter().map(|(id, _)| *id).collect();
let kinds: BTreeMap<Hash, Kind> = batch.into_iter().collect();
for (id, located) in resolve::locate_many(env, &ids).await? {
if crate::takedown::denial::denied(env.meta, &id)
.await
.map_err(|_| Miss::Unavailable)?
|| takedown.stops_descent(env.repo, &id)
{
continue;
}
if kinds[&id] == Kind::File && !manifest_sized(located.value.decoded_size) {
continue;
}
if env.no_reads.contains(&id) {
frontier.incomplete = Some(Miss::Capped);
continue;
}
let bytes = match resolve::load(env, id, located, budget).await {
Ok(bytes) => bytes,
Err(Miss::Capped) => {
frontier.incomplete = Some(Miss::Capped);
continue;
}
Err(Miss::NotFound) => {
frontier.incomplete = Some(Miss::NotFound);
return Ok((reached, frontier.incomplete));
}
Err(other) => return Err(other),
};
let object =
mkit_core::serialize::deserialize(&bytes).map_err(|_| Miss::Unavailable)?;
expand(&object, &mut frontier, |child| {
if targets.contains(&child) {
reached.insert(child);
}
});
if reached.len() == targets.len() {
return Ok((reached, None));
}
}
}
}
#[cfg(test)]
mod tests {
use futures_executor::block_on;
use super::*;
use crate::repo::{NamespaceKey, RepoName};
fn repo(name: &str) -> RepoId {
RepoId {
namespace: NamespaceKey::deployment_default(),
name: RepoName::new(name).unwrap(),
}
}
fn known(cache: &TtlReachability, repo: &RepoId, id: u8, now: u64) -> bool {
block_on(cache.known_reachable(repo, &[id; 32], now)).unwrap()
}
#[test]
fn a_proof_expires_after_the_lag_and_is_per_repository() {
let cache = TtlReachability::new(60_000, 8);
let (a, b) = (repo("a"), repo("b"));
cache.record(&a, &[1; 32], 1_000);
assert!(known(&cache, &a, 1, 60_999));
assert!(!known(&cache, &a, 1, 61_000));
assert!(!known(&cache, &b, 1, 1_000), "another repository's proof");
assert!(!known(&cache, &a, 2, 1_000));
cache.record(&a, &[2; 32], 1_000);
cache.record(&b, &[2; 32], 1_000);
cache.invalidate(&a);
assert!(!known(&cache, &a, 2, 1_001) && known(&cache, &b, 2, 1_001));
}
#[test]
fn a_full_table_is_bounded_and_sweeps_at_most_once_a_second() {
let cache = TtlReachability::new(10_000, 2);
let a = repo("a");
cache.record(&a, &[1; 32], 5_000);
cache.record(&a, &[2; 32], 5_000);
cache.record(&a, &[3; 32], 6_000);
assert!(!known(&cache, &a, 3, 6_000));
cache.record(&a, &[3; 32], 16_000);
assert!(known(&cache, &a, 3, 16_000));
assert!(!known(&cache, &a, 1, 16_000));
}
#[test]
fn only_a_manifest_shaped_file_is_ever_decoded() {
for (size, want) in [
(0, false),
(21, false),
(22, true),
(23, false),
(54, true),
(22 + 32 * 1_000_000, true),
(22 + 32 * 1_000_001, false),
(10 + 300 * 1024 * 1024, false),
] {
assert_eq!(manifest_sized(size), want, "{size}");
}
}
}