#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use std::sync::Arc;
use polyc_eventlog::Event;
use super::*;
use crate::feed::test_commit;
fn turn_complete(turn: &str) -> Event {
Event::new(format!("{}:{turn}", kinds::TURN_COMPLETE), Vec::new())
}
fn turn_start(turn: &str) -> Event {
Event::new(format!("{}:{turn}", kinds::TURN_START), Vec::new())
}
fn user_msg(turn: &str) -> Event {
Event::new(format!("{}:{turn}", kinds::USER_MSG), b"hello".to_vec())
}
fn notify(marks: &CommitMarks, partition: &str, events: &[Event], positions: &[u64]) {
marks.note_commit(partition, &[test_commit(partition, events, positions)]);
}
fn observer() -> (CommitMarks, Arc<DirtySet>) {
let dirty = Arc::new(DirtySet::default());
(CommitMarks::new(Arc::clone(&dirty)), dirty)
}
const UUID_A: &str = "11111111-1111-4111-8111-111111111111";
const UUID_B: &str = "22222222-2222-4222-8222-222222222222";
#[test]
fn a_turn_complete_marks_the_partition_through_the_following_position() {
let (observer, dirty) = observer();
notify(
&observer,
"conv-a",
&[turn_start(UUID_A), user_msg(UUID_A), turn_complete(UUID_A)],
&[10, 11, 12],
);
assert_eq!(
dirty.drain().get("conv-a"),
Some(&Pending::IndexThrough(13))
);
}
#[test]
fn an_append_without_a_turn_complete_marks_nothing() {
let (observer, dirty) = observer();
notify(
&observer,
"conv-a",
&[turn_start(UUID_A), user_msg(UUID_A)],
&[0, 1],
);
assert!(
dirty.drain().is_empty(),
"an in-flight turn must not advance the committed boundary"
);
}
#[test]
fn the_last_turn_complete_in_one_batch_sets_the_boundary() {
let (observer, dirty) = observer();
notify(
&observer,
"conv-a",
&[
turn_complete(UUID_A),
user_msg(UUID_B),
turn_complete(UUID_B),
],
&[4, 5, 6],
);
assert_eq!(dirty.drain().get("conv-a"), Some(&Pending::IndexThrough(7)));
}
#[test]
fn later_appends_advance_the_boundary_and_never_retreat() {
let (observer, dirty) = observer();
notify(&observer, "conv-a", &[turn_complete(UUID_A)], &[20]);
notify(&observer, "conv-a", &[turn_complete(UUID_B)], &[5]);
assert_eq!(
dirty.drain().get("conv-a"),
Some(&Pending::IndexThrough(21)),
"merging must keep the higher boundary, never the most recent"
);
}
#[test]
fn a_reported_rewrite_forces_a_rebuild() {
let (observer, dirty) = observer();
observer.note_partition_change("conv-a", PartitionChange::Rewritten);
assert_eq!(
dirty.drain().get("conv-a"),
Some(&Pending::Rebuild),
"a rewrite must invalidate the watermark"
);
}
#[test]
fn a_bootstrap_marks_a_full_rebuild() {
let dirty = std::sync::Arc::new(DirtySet::default());
let marks = CommitMarks::new(std::sync::Arc::clone(&dirty));
marks.note_bootstrap("conv-a");
assert_eq!(
dirty.drain().get("conv-a"),
Some(&Pending::Rebuild),
"the prefix sits below any watermark a forward window would start from, so nothing short \
of a rebuild covers it"
);
}
#[test]
fn a_bootstrap_on_a_non_conversation_partition_marks_nothing() {
let dirty = std::sync::Arc::new(DirtySet::default());
let marks = CommitMarks::new(std::sync::Arc::clone(&dirty));
marks.note_bootstrap("persona-abc-mem");
assert!(dirty.drain().is_empty());
}
#[test]
fn reporting_the_same_change_twice_is_the_same_pending_state() {
let (observer, dirty) = observer();
observer.note_partition_change("conv-a", PartitionChange::Destroyed);
observer.note_partition_change("conv-a", PartitionChange::Destroyed);
assert_eq!(dirty.drain().get("conv-a"), Some(&Pending::Destroy));
}
#[test]
fn coverage_doubt_degrades_the_set() {
let (observer, dirty) = observer();
assert!(!dirty.degraded());
observer.note_coverage_doubt();
assert!(
dirty.degraded(),
"a subscription that stopped means the marks are no longer the whole story"
);
}
#[test]
fn destructive_changes_are_told_apart() {
for (change, expected) in [
(PartitionChange::Destroyed, Pending::Destroy),
(PartitionChange::MigratedAway, Pending::Remove),
] {
let (observer, dirty) = observer();
observer.note_partition_change("conv-a", change);
assert_eq!(
dirty.drain().get("conv-a"),
Some(&expected),
"{change:?} must reach the worker as {expected:?}"
);
}
}
#[test]
fn a_rebuild_outranks_a_forward_index_whichever_arrives_first() {
for (first, second) in [
(Pending::IndexThrough(9), Pending::Rebuild),
(Pending::Rebuild, Pending::IndexThrough(9)),
] {
let dirty = DirtySet::default();
dirty.mark("conv-a", first);
dirty.mark("conv-a", second);
assert_eq!(
dirty.drain().get("conv-a"),
Some(&Pending::Rebuild),
"a compaction must not be absorbed by a forward index"
);
}
}
#[test]
fn a_removal_outranks_everything_but_a_destroy() {
for other in [Pending::IndexThrough(9), Pending::Rebuild] {
let dirty = DirtySet::default();
dirty.mark("conv-a", other);
dirty.mark("conv-a", Pending::Remove);
assert_eq!(dirty.drain().get("conv-a"), Some(&Pending::Remove));
let dirty = DirtySet::default();
dirty.mark("conv-a", Pending::Remove);
dirty.mark("conv-a", other);
assert_eq!(
dirty.drain().get("conv-a"),
Some(&Pending::Remove),
"a partition whose journal moved cannot be indexed from where it no longer is"
);
}
}
#[test]
fn a_destroy_outranks_everything() {
for other in [Pending::IndexThrough(9), Pending::Rebuild, Pending::Remove] {
let dirty = DirtySet::default();
dirty.mark("conv-a", other);
dirty.mark("conv-a", Pending::Destroy);
assert_eq!(dirty.drain().get("conv-a"), Some(&Pending::Destroy));
let dirty = DirtySet::default();
dirty.mark("conv-a", Pending::Destroy);
dirty.mark("conv-a", other);
assert_eq!(
dirty.drain().get("conv-a"),
Some(&Pending::Destroy),
"a destroyed conversation cannot be rebuilt, indexed, or quietly erased"
);
}
}
#[test]
fn partitions_are_tracked_independently() {
let (observer, dirty) = observer();
notify(&observer, "conv-a", &[turn_complete(UUID_A)], &[0]);
notify(&observer, "conv-b", &[turn_complete(UUID_B)], &[7]);
let pending = dirty.drain();
assert_eq!(pending.get("conv-a"), Some(&Pending::IndexThrough(1)));
assert_eq!(pending.get("conv-b"), Some(&Pending::IndexThrough(8)));
}
#[test]
fn draining_leaves_the_set_empty() {
let (observer, dirty) = observer();
notify(&observer, "conv-a", &[turn_complete(UUID_A)], &[0]);
assert_eq!(dirty.drain().len(), 1);
assert!(dirty.drain().is_empty());
}
#[test]
fn overflow_degrades_the_index_rather_than_forgetting_a_partition() {
let dirty = DirtySet::default();
for i in 0..MAX_TRACKED_PARTITIONS {
dirty.mark(&format!("conv-{i}"), Pending::IndexThrough(1));
}
assert!(!dirty.degraded(), "the set must hold its stated capacity");
dirty.mark("conv-one-too-many", Pending::IndexThrough(1));
assert!(dirty.degraded(), "overflow must be loud, not silent");
}
#[test]
fn a_full_set_still_merges_a_partition_it_already_tracks() {
let dirty = DirtySet::default();
for i in 0..MAX_TRACKED_PARTITIONS {
dirty.mark(&format!("conv-{i}"), Pending::IndexThrough(1));
}
dirty.mark("conv-0", Pending::IndexThrough(99));
assert!(!dirty.degraded());
assert_eq!(
dirty.drain().get("conv-0"),
Some(&Pending::IndexThrough(99))
);
}
#[test]
fn draining_does_not_clear_the_degraded_flag() {
let dirty = DirtySet::default();
for i in 0..=MAX_TRACKED_PARTITIONS {
dirty.mark(&format!("conv-{i}"), Pending::IndexThrough(1));
}
assert!(dirty.degraded());
let _ = dirty.drain();
assert!(dirty.degraded(), "a drain is not a reconcile");
assert!(dirty.clear_degraded(dirty.degrade_count()));
assert!(!dirty.degraded());
}
#[test]
fn a_degrade_after_the_snapshot_blocks_the_clear() {
let dirty = DirtySet::default();
for i in 0..=MAX_TRACKED_PARTITIONS {
dirty.mark(&format!("conv-{i}"), Pending::IndexThrough(1));
}
let observed = dirty.degrade_count();
dirty.mark("conv-later", Pending::IndexThrough(1));
assert!(
!dirty.clear_degraded(observed),
"a sweep may only clear the degrade it actually swept for"
);
assert!(dirty.degraded());
assert!(
dirty.clear_degraded(dirty.degrade_count()),
"a later sweep that saw the newer count may clear it"
);
}
#[test]
fn only_a_degrade_moves_the_counter() {
let dirty = DirtySet::default();
let start = dirty.degrade_count();
dirty.mark("conv-a", Pending::IndexThrough(1));
dirty.mark("conv-a", Pending::Rebuild);
let _ = dirty.drain();
assert_eq!(dirty.degrade_count(), start);
}
#[test]
fn only_a_destroy_or_a_removal_counts_as_outstanding() {
for pending in [Pending::Destroy, Pending::Remove] {
let dirty = DirtySet::default();
dirty.mark("conv-a", pending);
assert!(
dirty.has_pending_removal(),
"{pending:?} is user text still on disk that the deployment was told to forget"
);
}
for pending in [Pending::Rebuild, Pending::IndexThrough(4)] {
let dirty = DirtySet::default();
dirty.mark("conv-a", pending);
assert!(
!dirty.has_pending_removal(),
"{pending:?} is work the sweep re-does for itself"
);
}
}
#[test]
fn a_poisoned_lock_degrades_instead_of_panicking() {
let dirty = Arc::new(DirtySet::default());
let poisoner = Arc::clone(&dirty);
let _ = std::thread::spawn(move || {
let _guard = poisoner.inner.lock().expect("first lock");
panic!("poison the lock");
})
.join();
dirty.mark("conv-a", Pending::IndexThrough(1));
assert!(
dirty.degraded(),
"state that may have lost an update must refuse, not answer"
);
}
#[test]
fn an_excision_append_forces_a_rebuild() {
let (observer, dirty) = observer();
notify(
&observer,
"conv-a",
&[Event::new(
kinds::TAINT_EXCISION.to_owned(),
b"marker".to_vec(),
)],
&[7],
);
assert_eq!(dirty.drain().get("conv-a"), Some(&Pending::Rebuild));
}
#[test]
fn an_excision_outranks_a_turn_completing_in_the_same_batch() {
let (observer, dirty) = observer();
notify(
&observer,
"conv-a",
&[
Event::new(kinds::TAINT_EXCISION.to_owned(), b"marker".to_vec()),
turn_complete(UUID_A),
],
&[7, 8],
);
assert_eq!(
dirty.drain().get("conv-a"),
Some(&Pending::Rebuild),
"a forward index cannot apply an excision naming earlier positions"
);
}
#[test]
fn a_non_conversation_partition_is_ignored_by_both_callbacks() {
for partition in [
"persona-abc-mem",
"admin-audit",
"skill-share-ledger",
"query-audit",
"conv-",
] {
let (observer, dirty) = observer();
notify(&observer, partition, &[turn_complete(UUID_A)], &[0]);
observer.note_partition_change(partition, PartitionChange::Destroyed);
assert!(
dirty.drain().is_empty(),
"{partition} is not a conversation and must never be tracked"
);
}
}
#[test]
fn conversation_partitions_are_still_tracked() {
for partition in [
"conv-01950000-0000-7000-8000-00000000aaaa",
"conv-web_01950000-0000-7000-8000-00000000aaaa",
] {
let (observer, dirty) = observer();
notify(&observer, partition, &[turn_complete(UUID_A)], &[0]);
assert_eq!(
dirty.drain().get(partition),
Some(&Pending::IndexThrough(1)),
"{partition} is a conversation and must be tracked"
);
}
}
#[test]
fn a_solo_excision_after_a_completed_turn_still_forces_a_rebuild() {
let (observer, dirty) = observer();
notify(&observer, "conv-a", &[turn_complete(UUID_A)], &[10]);
notify(
&observer,
"conv-a",
&[Event::new(
kinds::TAINT_EXCISION.to_owned(),
b"marker".to_vec(),
)],
&[11],
);
assert_eq!(
dirty.drain().get("conv-a"),
Some(&Pending::Rebuild),
"a forward index cannot apply an excision naming earlier positions"
);
}
#[test]
fn a_turn_completing_after_a_solo_excision_does_not_downgrade_it() {
let (observer, dirty) = observer();
notify(
&observer,
"conv-a",
&[Event::new(
kinds::TAINT_EXCISION.to_owned(),
b"marker".to_vec(),
)],
&[10],
);
notify(&observer, "conv-a", &[turn_complete(UUID_A)], &[11]);
assert_eq!(
dirty.drain().get("conv-a"),
Some(&Pending::Rebuild),
"a later forward index must not absorb a pending rebuild"
);
}