use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Barrier};
use gunnar_core::{
AuthTable, BlobId, ControlStore, DocVersion, Fingerprint, Grant, Level, NewRepo, Principal,
PrincipalKind, RedbControlStore, RepoCatalog, RepoRef, Scope, Visibility,
};
#[derive(Default)]
struct Counts {
blob_reads: AtomicUsize,
blob_read_bytes: AtomicUsize,
blob_writes: AtomicUsize,
blob_write_bytes: AtomicUsize,
doc_reads: AtomicUsize,
}
impl Counts {
fn reset(&self) {
self.blob_reads.store(0, Ordering::SeqCst);
self.blob_read_bytes.store(0, Ordering::SeqCst);
self.blob_writes.store(0, Ordering::SeqCst);
self.blob_write_bytes.store(0, Ordering::SeqCst);
self.doc_reads.store(0, Ordering::SeqCst);
}
fn object_reads(&self) -> usize {
self.blob_reads.load(Ordering::SeqCst)
}
fn object_read_bytes(&self) -> usize {
self.blob_read_bytes.load(Ordering::SeqCst)
}
fn object_write_bytes(&self) -> usize {
self.blob_write_bytes.load(Ordering::SeqCst)
}
fn ref_reads(&self) -> usize {
self.doc_reads.load(Ordering::SeqCst)
}
}
struct CountedStore {
inner: RedbControlStore,
counts: Arc<Counts>,
clobber: bool,
}
impl ControlStore for CountedStore {
fn put_blob(&self, bytes: &[u8]) -> gunnar_core::Result<BlobId> {
self.counts.blob_writes.fetch_add(1, Ordering::SeqCst);
self.counts
.blob_write_bytes
.fetch_add(bytes.len(), Ordering::SeqCst);
self.inner.put_blob(bytes)
}
fn get_blob(&self, id: &BlobId) -> gunnar_core::Result<Option<Vec<u8>>> {
self.counts.blob_reads.fetch_add(1, Ordering::SeqCst);
let out = self.inner.get_blob(id)?;
if let Some(bytes) = &out {
self.counts
.blob_read_bytes
.fetch_add(bytes.len(), Ordering::SeqCst);
}
Ok(out)
}
fn read_doc(&self, name: &str) -> gunnar_core::Result<Option<BlobId>> {
self.counts.doc_reads.fetch_add(1, Ordering::SeqCst);
self.inner.read_doc(name)
}
fn commit_doc(
&self,
name: &str,
expected: Option<BlobId>,
new: BlobId,
) -> gunnar_core::Result<bool> {
let expected = if self.clobber {
self.inner.read_doc(name)?
} else {
expected
};
self.inner.commit_doc(name, expected, new)
}
fn history(&self, name: &str) -> gunnar_core::Result<Vec<DocVersion>> {
self.inner.history(name)
}
}
struct Counted {
counts: Arc<Counts>,
store: Arc<dyn ControlStore>,
auth: AuthTable,
catalog: RepoCatalog,
}
impl Counted {
fn new() -> Self {
Self::with_clobber(false)
}
fn with_clobber(clobber: bool) -> Self {
let counts = Arc::new(Counts::default());
let store: Arc<dyn ControlStore> = Arc::new(CountedStore {
inner: RedbControlStore::in_memory().expect("control store"),
counts: Arc::clone(&counts),
clobber,
});
Counted {
auth: AuthTable::new(Arc::clone(&store)).expect("auth table"),
catalog: RepoCatalog::new(Arc::clone(&store)).expect("catalog"),
counts,
store,
}
}
fn cold_handles(&self) -> (AuthTable, RepoCatalog) {
(
AuthTable::new(Arc::clone(&self.store)).expect("auth table"),
RepoCatalog::new(Arc::clone(&self.store)).expect("catalog"),
)
}
}
fn b64(bytes: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let b = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
out.push(ALPHABET[(n >> 18) as usize & 63] as char);
out.push(ALPHABET[(n >> 12) as usize & 63] as char);
out.push(if chunk.len() > 1 {
ALPHABET[(n >> 6) as usize & 63] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
ALPHABET[n as usize & 63] as char
} else {
'='
});
}
out
}
fn synthetic_key(i: usize) -> String {
let mut blob = Vec::new();
blob.extend_from_slice(&11u32.to_be_bytes());
blob.extend_from_slice(b"ssh-ed25519");
blob.extend_from_slice(&32u32.to_be_bytes());
let mut material = [0u8; 32];
let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ (i as u64);
for slot in material.chunks_mut(8) {
h = h.wrapping_mul(0x0100_0000_01b3).rotate_left(13) ^ (i as u64);
slot.copy_from_slice(&h.to_be_bytes()[..slot.len()]);
}
blob.extend_from_slice(&material);
format!("ssh-ed25519 {} synthetic-{i}", b64(&blob))
}
fn populate(fixture: &Counted, n: usize) -> (Fingerprint, RepoRef) {
fixture.catalog.create_namespace("ns").expect("namespace");
fixture.catalog.create_namespace("bulk").expect("namespace");
let target = RepoRef::new("ns", "app").expect("repo ref");
fixture
.catalog
.create(NewRepo::new(target.clone()).visibility(Visibility::Private))
.expect("create the target repository");
for i in 0..n {
let id = format!("p{i:06}");
fixture
.auth
.add_principal(Principal {
id: id.clone(),
name: format!("principal {i}"),
kind: PrincipalKind::User,
created: 0,
})
.expect("add principal");
fixture
.auth
.register_key(&id, &synthetic_key(i), None)
.expect("register key");
fixture
.auth
.grant(Grant {
principal: id.clone(),
scope: Scope::Repo(RepoRef::new("ns", "app").expect("repo ref")),
level: Level::Write,
})
.expect("grant");
fixture
.catalog
.create(NewRepo::new(
RepoRef::new("bulk", &format!("r{i:06}")).expect("repo ref"),
))
.expect("create repository");
}
let fingerprint = Fingerprint::of_openssh(&synthetic_key(0)).expect("fingerprint");
(fingerprint, target)
}
fn connection_sequence(auth: &AuthTable, catalog: &RepoCatalog, fp: &Fingerprint, path: &RepoRef) {
let principal = auth
.principal_by_fingerprint(fp)
.expect("identity lookup")
.expect("the fixture registered this key");
let repo = catalog
.get(path)
.expect("catalog lookup")
.expect("the fixture created this repository");
let mut level = None;
for want in [Level::Admin, Level::Write, Level::Read] {
if auth.may(&principal.id, &repo, want).expect("acl") {
level = Some(want);
break;
}
}
assert_eq!(
level,
Some(Level::Write),
"the fixture grants Write; a sequence that answers anything else measured the wrong thing"
);
}
#[test]
fn a_warm_connection_reads_no_objects_at_any_table_size() {
let mut sizes = Vec::new();
for n in [10usize, 1000] {
let fixture = Counted::new();
let (fp, path) = populate(&fixture, n);
connection_sequence(&fixture.auth, &fixture.catalog, &fp, &path);
fixture.counts.reset();
connection_sequence(&fixture.auth, &fixture.catalog, &fp, &path);
sizes.push((
n,
fixture.counts.object_reads(),
fixture.counts.object_read_bytes(),
fixture.counts.ref_reads(),
));
}
for (n, reads, bytes, refs) in &sizes {
assert!(
*refs > 0,
"at N={n} the sequence read no references at all. It is not consulting storage, so \
a zero object count proves nothing"
);
assert_eq!(
*reads, 0,
"at N={n} one warm connection still read {reads} objects ({bytes} bytes) from the \
control documents"
);
}
assert_eq!(
sizes[0].3, sizes[1].3,
"the number of reference reads per connection changed between N=10 and N=1000: {sizes:?}"
);
}
#[test]
fn a_warm_handle_sees_another_writers_change() {
let fixture = Counted::new();
let (_, _) = populate(&fixture, 4);
let (other_auth, other_catalog) = fixture.cold_handles();
assert!(fixture.auth.principal("p000001").expect("read").is_some());
assert!(other_auth.principal("p000001").expect("read").is_some());
assert!(fixture
.catalog
.contains(&RepoRef::new("ns", "app").unwrap())
.unwrap());
assert!(other_catalog
.contains(&RepoRef::new("ns", "app").unwrap())
.unwrap());
other_auth
.add_principal(Principal {
id: "latecomer".to_owned(),
name: "arrived after the cache was warm".to_owned(),
kind: PrincipalKind::Machine,
created: 0,
})
.expect("add principal");
other_catalog
.create(NewRepo::new(RepoRef::new("ns", "late").unwrap()))
.expect("create repository");
assert!(
fixture.auth.principal("latecomer").expect("read").is_some(),
"a warm identity table did not see a principal another handle added"
);
assert!(
fixture
.catalog
.contains(&RepoRef::new("ns", "late").unwrap())
.expect("read"),
"a warm catalog did not see a repository another handle created"
);
other_auth.remove_principal("latecomer").expect("remove");
assert!(
fixture.auth.principal("latecomer").expect("read").is_none(),
"a warm identity table still reports a principal that was removed"
);
}
#[test]
fn a_cold_connection_reads_a_bounded_slice_of_the_table() {
let mut bytes = Vec::new();
for n in [10usize, 1000] {
let fixture = Counted::new();
let (fp, path) = populate(&fixture, n);
let (auth, catalog) = fixture.cold_handles();
fixture.counts.reset();
connection_sequence(&auth, &catalog, &fp, &path);
bytes.push((n, fixture.counts.object_read_bytes()));
}
assert!(
bytes[0].1 > 0,
"a cold connection read nothing at all; the measurement is vacuous: {bytes:?}"
);
let ratio = bytes[1].1 as f64 / bytes[0].1 as f64;
assert!(
ratio < 10.0,
"a 100× larger control plane made one cold connection read {ratio:.1}× more bytes \
({bytes:?}), so the lookup is still reading the whole table"
);
}
#[test]
fn an_acl_answer_does_not_cost_the_whole_grant_table() {
let mut bytes = Vec::new();
for n in [10usize, 1000] {
let fixture = Counted::new();
let (_, path) = populate(&fixture, n);
let repo = fixture.catalog.open(&path).expect("repo");
let (auth, _) = fixture.cold_handles();
fixture.counts.reset();
assert!(
auth.may("p000000", &repo, Level::Write).expect("acl"),
"the fixture grants Write to p000000"
);
bytes.push((n, fixture.counts.object_read_bytes()));
}
assert!(
bytes[0].1 > 0,
"the ACL answer read nothing at all; the measurement is vacuous: {bytes:?}"
);
let ratio = bytes[1].1 as f64 / bytes[0].1 as f64;
assert!(
ratio < 10.0,
"a 100× larger grant table made one ACL answer read {ratio:.1}× more bytes ({bytes:?}) \
and may() is still scanning every grant"
);
}
#[test]
fn creating_a_repository_does_not_rewrite_the_catalog() {
let mut per_create = Vec::new();
for n in [100usize, 1000] {
let fixture = Counted::new();
fixture.catalog.create_namespace("bulk").expect("namespace");
fixture.counts.reset();
for i in 0..n {
fixture
.catalog
.create(NewRepo::new(
RepoRef::new("bulk", &format!("r{i:06}")).expect("repo ref"),
))
.expect("create repository");
}
per_create.push((n, fixture.counts.object_write_bytes() / n));
}
assert!(
per_create[0].1 > 0,
"creating repositories wrote nothing; the measurement is vacuous: {per_create:?}"
);
let ratio = per_create[1].1 as f64 / per_create[0].1 as f64;
assert!(
ratio < 3.0,
"creating a repository into a 10× larger catalog cost {ratio:.1}× more bytes written \
({per_create:?} bytes per create), so the whole catalog is still being rewritten"
);
}
fn concurrent_principal_adds(clobber: bool) -> (usize, usize) {
const THREADS: usize = 8;
const EACH: usize = 25;
let fixture = Counted::with_clobber(clobber);
let barrier = Arc::new(Barrier::new(THREADS));
let mut handles = Vec::new();
for t in 0..THREADS {
let auth = AuthTable::new(Arc::clone(&fixture.store)).expect("auth table");
let barrier = Arc::clone(&barrier);
handles.push(std::thread::spawn(move || {
let mut acknowledged = Vec::new();
barrier.wait();
for i in 0..EACH {
let id = format!("t{t}n{i:03}");
if auth
.add_principal(Principal {
id: id.clone(),
name: id.clone(),
kind: PrincipalKind::User,
created: 0,
})
.is_ok()
{
acknowledged.push(id);
}
}
acknowledged
}));
}
let acknowledged: Vec<String> = handles
.into_iter()
.flat_map(|handle| handle.join().expect("writer thread"))
.collect();
let survivors: std::collections::BTreeSet<String> = fixture
.auth
.principals()
.expect("read the table back")
.into_iter()
.map(|p| p.id)
.collect();
let kept = acknowledged
.iter()
.filter(|id| survivors.contains(*id))
.count();
(acknowledged.len(), kept)
}
#[test]
fn a_broken_compare_and_swap_loses_acknowledged_updates() {
let (acknowledged, kept) = concurrent_principal_adds(true);
assert!(
kept < acknowledged,
"a reference store that ignores the CAS expectation kept all {acknowledged} \
acknowledged writes; the test below cannot be trusted, because it would pass on a \
broken store"
);
}
#[test]
fn concurrent_writers_do_not_lose_an_update() {
let (acknowledged, kept) = concurrent_principal_adds(false);
assert!(
acknowledged > 0,
"no writer succeeded at all; the measurement is vacuous"
);
assert_eq!(
kept,
acknowledged,
"{} of {acknowledged} acknowledged concurrent additions were lost",
acknowledged - kept
);
}
#[test]
fn a_document_this_gunnar_cannot_read_is_an_error_not_an_empty_table() {
let fixture = Counted::new();
let legacy =
br#"{"principals":{"a":{"id":"a","name":"a","kind":"User"}},"keys":{},"grants":[]}"#;
let id = fixture
.store
.put_blob(legacy)
.expect("write the legacy document");
let current = fixture
.store
.read_doc(gunnar_core::AUTH_REF)
.expect("read the document");
assert!(fixture
.store
.commit_doc(gunnar_core::AUTH_REF, current, id)
.expect("point the document at it"));
let (auth, _) = fixture.cold_handles();
let err = auth
.principals()
.expect_err("a document in a layout this gunnar does not write must not read as empty");
let text = err.to_string();
assert!(
text.contains(gunnar_core::AUTH_REF),
"the error must name the document it could not read, got: {text}"
);
}