use crate::{KbSlug, ObjectPath, Storage, StorageError, is_internal_path};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexedEtag {
pub key: String,
pub etag: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ReconcileReport {
pub objects_on_disk: usize,
pub unchanged: usize,
pub changed: usize,
pub orphaned: usize,
}
impl ReconcileReport {
#[must_use]
pub fn is_clean(&self) -> bool {
self.changed == 0 && self.orphaned == 0
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Reconciliation {
pub report: ReconcileReport,
pub keys: Vec<ObjectPath>,
}
pub fn compare(
in_storage: impl IntoIterator<Item = (String, Option<String>)>,
indexed: &[IndexedEtag],
prefix: Option<&str>,
) -> Reconciliation {
let in_storage: Vec<(String, Option<String>)> = in_storage
.into_iter()
.filter(|(key, _)| !is_internal_path(key))
.filter(|(key, _)| is_actionable(key))
.collect();
debug_assert!(
in_storage.windows(2).all(|pair| pair[0].0 < pair[1].0),
"the storage side must be sorted by key"
);
let indexed: Vec<&IndexedEtag> = indexed
.iter()
.filter(|entry| prefix.is_none_or(|prefix| entry.key.starts_with(prefix)))
.filter(|entry| is_actionable(&entry.key))
.collect();
debug_assert!(
indexed.windows(2).all(|pair| pair[0].key <= pair[1].key),
"the index side must be sorted by key"
);
let mut report = ReconcileReport {
objects_on_disk: in_storage.len(),
..ReconcileReport::default()
};
let mut keys = Vec::new();
let mut storage = in_storage.iter();
let mut index = indexed.into_iter();
let mut next_storage = storage.next();
let mut next_index = index.next();
loop {
let outcome = match (next_storage, next_index) {
(None, None) => break,
(Some((key, etag)), Some(entry)) => match key.as_str().cmp(entry.key.as_str()) {
std::cmp::Ordering::Equal => {
let same = etag.as_deref() == Some(entry.etag.as_str());
next_storage = storage.next();
next_index = index.next();
if same {
report.unchanged += 1;
continue;
}
report.changed += 1;
key.clone()
}
std::cmp::Ordering::Less => {
report.changed += 1;
let key = key.clone();
next_storage = storage.next();
key
}
std::cmp::Ordering::Greater => {
report.orphaned += 1;
let key = entry.key.clone();
next_index = index.next();
key
}
},
(Some((key, _)), None) => {
report.changed += 1;
let key = key.clone();
next_storage = storage.next();
key
}
(None, Some(entry)) => {
report.orphaned += 1;
let key = entry.key.clone();
next_index = index.next();
key
}
};
if let Ok(key) = ObjectPath::try_from(outcome.as_str()) {
keys.push(key);
} else {
debug_assert!(false, "an unvalidated key reached the merge: {outcome}");
tracing::warn!(key = %outcome, "skipping a key that is not a valid object path");
}
}
Reconciliation { report, keys }
}
fn is_actionable(key: &str) -> bool {
if ObjectPath::try_from(key).is_ok() {
return true;
}
tracing::warn!(key = %key, "skipping a key that is not a valid object path");
false
}
const WALK_PAGE: u32 = 1000;
pub async fn walk_etags(
storage: &dyn Storage,
kb: &KbSlug,
prefix: Option<&str>,
) -> Result<Vec<(String, Option<String>)>, StorageError> {
walk_etags_paged(storage, kb, prefix, WALK_PAGE).await
}
pub async fn walk_etags_paged(
storage: &dyn Storage,
kb: &KbSlug,
prefix: Option<&str>,
page: u32,
) -> Result<Vec<(String, Option<String>)>, StorageError> {
let mut out = Vec::new();
let mut cursor: Option<String> = None;
loop {
let listing = storage
.list_objects(kb, prefix, page, cursor.as_deref())
.await?;
out.extend(
listing
.objects
.into_iter()
.map(|object| (object.key, object.etag)),
);
match advance(listing.truncated, listing.next_cursor, cursor.as_deref())? {
Advance::Done => return Ok(out),
Advance::Next(next) => cursor = Some(next),
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum Advance {
Done,
Next(String),
}
fn advance(
truncated: bool,
next_cursor: Option<String>,
sent: Option<&str>,
) -> Result<Advance, StorageError> {
match (truncated, next_cursor) {
(false, _) => Ok(Advance::Done),
(true, None) => Err(StorageError::BackendUnavailable {
message: "listing reported more pages but supplied no cursor".to_string(),
}),
(true, Some(next)) if Some(next.as_str()) == sent => {
Err(StorageError::BackendUnavailable {
message: "listing repeated the cursor it was given".to_string(),
})
}
(true, Some(next)) => Ok(Advance::Next(next)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ConditionalHeaders;
use crate::testing::InMemoryStorage;
use bytes::Bytes;
fn indexed(entries: &[(&str, &str)]) -> Vec<IndexedEtag> {
entries
.iter()
.map(|(key, etag)| IndexedEtag {
key: (*key).to_string(),
etag: (*etag).to_string(),
})
.collect()
}
fn stored(entries: &[(&str, &str)]) -> Vec<(String, Option<String>)> {
entries
.iter()
.map(|(key, etag)| ((*key).to_string(), Some((*etag).to_string())))
.collect()
}
fn keys(reconciliation: &Reconciliation) -> Vec<&str> {
reconciliation.keys.iter().map(ObjectPath::as_str).collect()
}
#[test]
fn a_fully_indexed_store_is_clean() {
let r = compare(
stored(&[("a.md", "\"1\""), ("b.md", "\"2\"")]),
&indexed(&[("a.md", "\"1\""), ("b.md", "\"2\"")]),
None,
);
assert!(r.report.is_clean());
assert_eq!(
r.report,
ReconcileReport {
objects_on_disk: 2,
unchanged: 2,
changed: 0,
orphaned: 0
}
);
assert!(r.keys.is_empty());
}
#[test]
fn new_changed_and_orphaned_keys_are_reported_once_each_in_key_order() {
let r = compare(
stored(&[("a.md", "\"1\""), ("c.md", "\"new\""), ("d.md", "\"3\"")]),
&indexed(&[("a.md", "\"1\""), ("b.md", "\"gone\""), ("d.md", "\"old\"")]),
None,
);
assert_eq!(
r.report,
ReconcileReport {
objects_on_disk: 3,
unchanged: 1,
changed: 2,
orphaned: 1
}
);
assert_eq!(keys(&r), ["b.md", "c.md", "d.md"]);
}
#[test]
fn an_empty_store_orphans_every_indexed_key() {
let r = compare(
Vec::new(),
&indexed(&[("a.md", "\"1\""), ("b.md", "\"2\"")]),
None,
);
assert_eq!(r.report.orphaned, 2);
assert_eq!(r.report.objects_on_disk, 0);
assert_eq!(keys(&r), ["a.md", "b.md"]);
}
#[test]
fn a_prefix_narrows_the_index_side() {
let r = compare(
stored(&[("docs/a.md", "\"1\"")]),
&indexed(&[
("docs/a.md", "\"1\""),
("docs/b.md", "\"2\""),
("other/z.md", "\"9\""),
]),
Some("docs/"),
);
assert_eq!(r.report.unchanged, 1);
assert_eq!(r.report.orphaned, 1);
assert_eq!(keys(&r), ["docs/b.md"]);
}
#[test]
fn the_private_prefix_is_dropped_from_storage_but_not_from_the_index() {
let r = compare(
stored(&[(".notedthat/manifest.json", "\"m\""), ("a.md", "\"1\"")]),
&indexed(&[(".notedthat/manifest.json", "\"m\""), ("a.md", "\"1\"")]),
None,
);
assert_eq!(r.report.objects_on_disk, 1, "the manifest is not an object");
assert_eq!(
r.report.orphaned, 1,
"a stray index entry is reported so it gets tombstoned"
);
assert_eq!(keys(&r), [".notedthat/manifest.json"]);
}
#[test]
fn a_listing_without_an_etag_counts_as_changed() {
let r = compare(
vec![("a.md".to_string(), None)],
&indexed(&[("a.md", "\"1\"")]),
None,
);
assert_eq!(r.report.changed, 1);
assert_eq!(keys(&r), ["a.md"]);
}
#[test]
fn a_key_that_is_not_an_object_path_is_dropped_before_it_is_counted() {
let r = compare(
stored(&[("a.md", "\"1\"")]),
&indexed(&[("../escape.md", "\"x\"")]),
None,
);
assert_eq!(r.report.changed, 1);
assert_eq!(
r.report.orphaned, 0,
"nothing can re-read it, so counting it would keep every pass dirty"
);
assert_eq!(keys(&r), ["a.md"], "and it is never handed to a consumer");
}
#[test]
fn a_directory_marker_in_the_bucket_leaves_the_pass_clean() {
let r = compare(
vec![
("docs/".to_string(), Some("\"d41d8c\"".to_string())),
("docs/a.md".to_string(), Some("\"1\"".to_string())),
],
&indexed(&[("docs/a.md", "\"1\"")]),
None,
);
assert_eq!(r.report.objects_on_disk, 1, "the marker is not an object");
assert_eq!(r.report.unchanged, 1);
assert_eq!(r.report.changed, 0);
assert_eq!(r.report.orphaned, 0);
assert!(r.report.is_clean());
assert!(keys(&r).is_empty());
}
fn kb() -> KbSlug {
KbSlug::try_new("notes").unwrap()
}
async fn seeded(keys: &[&str]) -> InMemoryStorage {
let storage = InMemoryStorage::with_kbs([&kb()]);
for key in keys {
storage
.put_object(
&kb(),
&ObjectPath::try_from(*key).unwrap(),
Bytes::from(format!("body of {key}")),
Some("text/markdown"),
ConditionalHeaders::default(),
)
.await
.unwrap();
}
storage
}
#[tokio::test]
async fn the_walk_follows_the_cursor_and_reports_each_etag() {
let storage = seeded(&["a.md", "b.md", "c.md", "d.md", "e.md"]).await;
let walked = walk_etags_paged(&storage, &kb(), None, 2).await.unwrap();
let keys: Vec<&str> = walked.iter().map(|(key, _)| key.as_str()).collect();
assert_eq!(keys, ["a.md", "b.md", "c.md", "d.md", "e.md"]);
for (key, etag) in &walked {
let head = storage
.head_object(
&kb(),
&ObjectPath::try_from(key.as_str()).unwrap(),
ConditionalHeaders::default(),
)
.await
.unwrap();
assert_eq!(etag.as_deref(), head.etag.as_deref(), "{key}");
}
}
#[tokio::test]
async fn the_walk_honours_the_prefix() {
let storage = seeded(&["docs/a.md", "docs/b.md", "other/z.md"]).await;
let walked = walk_etags_paged(&storage, &kb(), Some("docs/"), 1)
.await
.unwrap();
let keys: Vec<&str> = walked.iter().map(|(key, _)| key.as_str()).collect();
assert_eq!(keys, ["docs/a.md", "docs/b.md"]);
}
#[tokio::test]
async fn the_walk_reports_a_missing_bucket() {
let storage = InMemoryStorage::default();
let error = walk_etags(&storage, &kb(), None).await.unwrap_err();
assert!(
matches!(error, StorageError::BucketNotFound { .. }),
"{error}"
);
}
#[test]
fn an_untruncated_page_ends_the_walk() {
assert_eq!(advance(false, None, None).unwrap(), Advance::Done);
assert_eq!(
advance(false, Some("ignored".to_string()), None).unwrap(),
Advance::Done
);
}
#[test]
fn a_truncated_page_with_a_cursor_asks_for_the_next_one() {
assert_eq!(
advance(true, Some("page-2".to_string()), Some("page-1")).unwrap(),
Advance::Next("page-2".to_string())
);
}
#[test]
fn a_truncated_page_without_a_cursor_fails_the_walk() {
let error = advance(true, None, None).unwrap_err();
assert!(
matches!(&error, StorageError::BackendUnavailable { message }
if message.contains("supplied no cursor")),
"{error}"
);
}
#[test]
fn a_repeated_cursor_fails_the_walk() {
let error = advance(true, Some("page-1".to_string()), Some("page-1")).unwrap_err();
assert!(
matches!(&error, StorageError::BackendUnavailable { message }
if message.contains("repeated the cursor")),
"{error}"
);
}
}