use std::sync::{Arc, Mutex};
use std::time::Duration;
use faucet_core::{FaucetError, RowOutcome, Sink, StateStore, Value, async_trait, json};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Event {
Write(usize),
WriteFailed(usize),
Flush,
FlushFailed,
IdempotentWrite { records: usize, token: String },
BeginOverwrite,
CommitOverwrite,
AbortOverwrite,
StatePut { key: String, value: Value },
StatePutFailed { key: String },
Cleanup(usize),
DlqWrite(usize),
}
#[derive(Debug, Clone, Default)]
pub struct EventLog(Arc<Mutex<Vec<Event>>>);
impl EventLog {
pub fn new() -> Self {
Self::default()
}
pub fn push(&self, event: Event) {
if let Ok(mut g) = self.0.lock() {
g.push(event);
}
}
pub fn events(&self) -> Vec<Event> {
self.0.lock().map(|g| g.clone()).unwrap_or_default()
}
pub fn records_written(&self) -> usize {
self.events()
.iter()
.map(|e| match e {
Event::Write(n) | Event::IdempotentWrite { records: n, .. } => *n,
_ => 0,
})
.sum()
}
pub fn bookmarks(&self) -> Vec<Value> {
self.events()
.iter()
.filter_map(|e| match e {
Event::StatePut { value, .. } => {
Some(faucet_core::state_version::peel_versioned(value))
}
_ => None,
})
.collect()
}
pub fn writes_before_each_bookmark(&self) -> Vec<usize> {
let mut confirmed = 0usize;
let mut out = Vec::new();
for e in self.events() {
match e {
Event::Write(n) | Event::IdempotentWrite { records: n, .. } => confirmed += n,
Event::StatePut { .. } => out.push(confirmed),
_ => {}
}
}
out
}
pub fn contains(&self, event: &Event) -> bool {
self.events().iter().any(|e| e == event)
}
pub fn any(&self, pred: impl Fn(&Event) -> bool) -> bool {
self.events().iter().any(pred)
}
pub fn position(&self, pred: impl Fn(&Event) -> bool) -> Option<usize> {
self.events().iter().position(pred)
}
}
pub fn assert_bookmarks_backed_by_writes(
log: &EventLog,
records_claimed: impl Fn(&Value) -> usize,
) {
let mut confirmed = 0usize;
for (i, event) in log.events().into_iter().enumerate() {
match event {
Event::Write(n) | Event::IdempotentWrite { records: n, .. } => confirmed += n,
Event::StatePut { ref value, .. } => {
let value = &faucet_core::state_version::peel_versioned(value);
let claimed = records_claimed(value);
assert!(
claimed <= confirmed,
"durability violated at event {i}: bookmark {value} claims {claimed} \
records are durable, but only {confirmed} had been confirmed by the sink. \
A resumed run would start after this bookmark and never re-read the \
difference.\nfull log: {:?}",
log.events()
);
}
_ => {}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum Boundary {
#[default]
Never,
Write(usize),
Flush,
StatePut(usize),
CommitOverwrite,
RowsInWrite {
batch: usize,
rows: RowMask,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RowMask(u64);
impl RowMask {
pub fn of(rows: &[usize]) -> Self {
Self(
rows.iter()
.filter(|r| **r < 64)
.fold(0, |m, r| m | (1 << r)),
)
}
pub fn just(row: usize) -> Self {
Self::of(&[row])
}
pub fn contains(self, row: usize) -> bool {
row < 64 && self.0 & (1 << row) != 0
}
pub fn count_within(self, len: usize) -> usize {
(0..len.min(64)).filter(|i| self.contains(*i)).count()
}
}
#[derive(Debug, Clone, Copy)]
enum BatchFailure {
None,
Outer,
Rows(RowMask),
}
#[derive(Debug)]
struct Script {
boundary: Boundary,
writes: usize,
state_puts: usize,
}
pub struct ScriptedSink {
log: EventLog,
script: Arc<Mutex<Script>>,
idempotent: bool,
replay_safe: bool,
keyed: bool,
overwrite: bool,
cleanup: bool,
tokens: Arc<Mutex<std::collections::HashMap<String, String>>>,
write_delay: Option<Duration>,
dlq: bool,
atomicity: faucet_core::BatchAtomicity,
}
impl ScriptedSink {
pub fn new(log: EventLog) -> Self {
Self {
log,
script: Arc::new(Mutex::new(Script {
boundary: Boundary::Never,
writes: 0,
state_puts: 0,
})),
idempotent: false,
replay_safe: false,
keyed: false,
overwrite: false,
cleanup: false,
tokens: Arc::new(Mutex::new(std::collections::HashMap::new())),
write_delay: None,
dlq: false,
atomicity: faucet_core::BatchAtomicity::PerRow,
}
}
pub fn as_dlq(mut self) -> Self {
self.dlq = true;
self
}
pub fn best_effort(mut self) -> Self {
self.atomicity = faucet_core::BatchAtomicity::BestEffort;
self
}
pub fn failing_at(mut self, boundary: Boundary) -> Self {
self.script = Arc::new(Mutex::new(Script {
boundary,
writes: 0,
state_puts: 0,
}));
self
}
pub fn idempotent(mut self) -> Self {
self.idempotent = true;
self
}
pub fn replay_safe(mut self) -> Self {
self.replay_safe = true;
self
}
pub fn keyed(mut self) -> Self {
self.keyed = true;
self.replay_safe = true;
self
}
pub fn overwrite(mut self) -> Self {
self.overwrite = true;
self
}
pub fn cleanup(mut self) -> Self {
self.cleanup = true;
self
}
pub fn with_write_delay(mut self, delay: Duration) -> Self {
self.write_delay = Some(delay);
self
}
pub fn rearm(&self, boundary: Boundary) {
let mut s = self.script.lock().expect("script lock");
s.boundary = boundary;
s.writes = 0;
s.state_puts = 0;
}
pub fn state_store(&self) -> ScriptedStateStore {
ScriptedStateStore {
log: self.log.clone(),
script: Arc::clone(&self.script),
values: Arc::new(Mutex::new(std::collections::HashMap::new())),
}
}
pub fn log(&self) -> EventLog {
self.log.clone()
}
fn should_fail_write(&self) -> bool {
let mut s = self.script.lock().expect("script lock");
let nth = s.writes;
s.writes += 1;
matches!(s.boundary, Boundary::Write(n) if n == nth)
}
fn should_fail_flush(&self) -> bool {
let s = self.script.lock().expect("script lock");
s.boundary == Boundary::Flush
}
fn should_fail_commit(&self) -> bool {
let s = self.script.lock().expect("script lock");
s.boundary == Boundary::CommitOverwrite
}
#[allow(clippy::disallowed_methods)] async fn record_write(&self, records: &[Value]) -> Result<usize, FaucetError> {
if let Some(d) = self.write_delay {
tokio::time::sleep(d).await;
}
if self.should_fail_write() {
self.log.push(Event::WriteFailed(records.len()));
return Err(FaucetError::Sink(format!(
"scripted sink: write of {} records failed at the scripted boundary",
records.len()
)));
}
self.log.push(if self.dlq {
Event::DlqWrite(records.len())
} else {
Event::Write(records.len())
});
Ok(records.len())
}
fn scripted_failure(&self) -> BatchFailure {
let mut s = self.script.lock().expect("script lock");
let nth = s.writes;
s.writes += 1;
match s.boundary {
Boundary::RowsInWrite { batch, rows } if batch == nth => BatchFailure::Rows(rows),
Boundary::Write(n) if n == nth => BatchFailure::Outer,
_ => BatchFailure::None,
}
}
}
#[async_trait]
impl Sink for ScriptedSink {
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
self.record_write(records).await
}
#[allow(clippy::disallowed_methods)] async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
if let Some(d) = self.write_delay {
tokio::time::sleep(d).await;
}
let failing = match self.scripted_failure() {
BatchFailure::None => {
self.log.push(if self.dlq {
Event::DlqWrite(records.len())
} else {
Event::Write(records.len())
});
return Ok(records.iter().map(|_| Ok(())).collect());
}
BatchFailure::Outer => {
self.log.push(Event::WriteFailed(records.len()));
return Err(FaucetError::Sink(format!(
"scripted sink: write of {} records failed at the scripted boundary",
records.len()
)));
}
BatchFailure::Rows(mask) => mask,
};
let failed = failing.count_within(records.len());
let ok = records.len() - failed;
self.log.push(Event::Write(ok));
self.log.push(Event::WriteFailed(failed));
Ok((0..records.len())
.map(|i| {
if failing.contains(i) {
Err(FaucetError::Sink(format!(
"scripted sink: row {i} failed at the scripted boundary"
)))
} else {
Ok(())
}
})
.collect())
}
async fn flush(&self) -> Result<(), FaucetError> {
if self.should_fail_flush() {
self.log.push(Event::FlushFailed);
return Err(FaucetError::Sink(
"scripted sink: flush failed at the scripted boundary".into(),
));
}
self.log.push(Event::Flush);
Ok(())
}
fn supports_idempotent_writes(&self) -> bool {
self.idempotent
}
fn write_batch_is_replay_safe(&self) -> bool {
self.replay_safe
}
fn dedups_by_key(&self) -> bool {
self.keyed
}
fn batch_atomicity(&self) -> faucet_core::BatchAtomicity {
self.atomicity
}
#[allow(clippy::disallowed_methods)] async fn write_batch_idempotent(
&self,
records: &[Value],
scope: &str,
token: &str,
) -> Result<usize, FaucetError> {
if let Some(d) = self.write_delay {
tokio::time::sleep(d).await;
}
if self.should_fail_write() {
self.log.push(Event::WriteFailed(records.len()));
return Err(FaucetError::Sink(
"scripted sink: idempotent write failed at the scripted boundary".into(),
));
}
self.tokens
.lock()
.expect("token lock")
.insert(scope.to_string(), token.to_string());
self.log.push(Event::IdempotentWrite {
records: records.len(),
token: token.to_string(),
});
Ok(records.len())
}
async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
Ok(self.tokens.lock().expect("token lock").get(scope).cloned())
}
fn is_overwrite(&self) -> bool {
self.overwrite
}
async fn begin_overwrite(&self) -> Result<(), FaucetError> {
self.log.push(Event::BeginOverwrite);
Ok(())
}
async fn commit_overwrite(&self) -> Result<(), FaucetError> {
if self.should_fail_commit() {
return Err(FaucetError::Sink(
"scripted sink: commit_overwrite failed at the scripted boundary".into(),
));
}
self.log.push(Event::CommitOverwrite);
Ok(())
}
async fn abort_overwrite(&self) -> Result<(), FaucetError> {
self.log.push(Event::AbortOverwrite);
Ok(())
}
fn supports_cleanup(&self) -> bool {
self.cleanup
}
async fn cleanup_scope(
&self,
_scope: &std::collections::BTreeMap<String, Value>,
seen: &faucet_core::cleanup::SeenKeys,
) -> Result<u64, FaucetError> {
self.log.push(Event::Cleanup(seen.keys().len()));
Ok(0)
}
fn config_schema(&self) -> Value {
json!({ "type": "object", "title": "ScriptedSink" })
}
fn connector_name(&self) -> &'static str {
"scripted"
}
}
pub struct PagedSource {
pages: usize,
per_page: usize,
fail_after: Option<usize>,
start_after: Arc<Mutex<Option<usize>>>,
emitted: Arc<Mutex<Vec<usize>>>,
}
impl PagedSource {
pub fn new(pages: usize, per_page: usize) -> Self {
Self {
pages,
per_page,
fail_after: None,
start_after: Arc::new(Mutex::new(None)),
emitted: Arc::new(Mutex::new(Vec::new())),
}
}
pub fn failing_after(mut self, pages: usize) -> Self {
self.fail_after = Some(pages);
self
}
pub fn emitted_pages(&self) -> Vec<usize> {
self.emitted.lock().expect("emitted lock").clone()
}
pub fn total_records(&self) -> usize {
self.pages * self.per_page
}
}
#[async_trait]
impl faucet_core::Source for PagedSource {
async fn fetch_with_context(
&self,
_context: &std::collections::HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
let start = self
.start_after
.lock()
.expect("start lock")
.map(|p| p + 1)
.unwrap_or(0);
let mut out = Vec::new();
for p in start..self.pages {
for i in 0..self.per_page {
out.push(json!({ "page": p, "n": p * self.per_page + i }));
}
}
Ok(out)
}
fn stream_pages<'a>(
&'a self,
_context: &'a std::collections::HashMap<String, Value>,
_batch_size: usize,
) -> std::pin::Pin<
Box<
dyn futures_core::Stream<Item = Result<faucet_core::StreamPage, FaucetError>>
+ Send
+ 'a,
>,
> {
let start = self
.start_after
.lock()
.expect("start lock")
.map(|p| p + 1)
.unwrap_or(0);
Box::pin(async_stream::try_stream! {
for (emitted_here, p) in (start..self.pages).enumerate() {
if self.fail_after == Some(emitted_here) {
Err(FaucetError::Source(format!(
"scripted source: the read failed after {emitted_here} page(s) \
(connection lost mid-stream)"
)))?;
return;
}
self.emitted.lock().expect("emitted lock").push(p);
let records: Vec<Value> = (0..self.per_page)
.map(|i| json!({ "page": p, "n": p * self.per_page + i }))
.collect();
yield faucet_core::StreamPage {
records,
bookmark: Some(json!({ "page": p })),
};
}
})
}
fn state_key(&self) -> Option<String> {
Some("scripted:paged".into())
}
async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
let bare = faucet_core::idempotency::unwrap_state(&bookmark)
.0
.unwrap_or(bookmark);
if let Some(p) = bare.get("page").and_then(|v| v.as_u64()) {
*self.start_after.lock().expect("start lock") = Some(p as usize);
}
Ok(())
}
fn supports_exactly_once(&self) -> bool {
true
}
fn config_schema(&self) -> Value {
json!({ "type": "object", "title": "PagedSource" })
}
fn connector_name(&self) -> &'static str {
"paged"
}
}
pub struct ScriptedStateStore {
log: EventLog,
script: Arc<Mutex<Script>>,
values: Arc<Mutex<std::collections::HashMap<String, Value>>>,
}
impl ScriptedStateStore {
pub fn new(log: EventLog) -> Self {
Self {
log,
script: Arc::new(Mutex::new(Script {
boundary: Boundary::Never,
writes: 0,
state_puts: 0,
})),
values: Arc::new(Mutex::new(std::collections::HashMap::new())),
}
}
pub fn seed(self, key: &str, value: Value) -> Self {
self.values
.lock()
.expect("value lock")
.insert(key.to_string(), value);
self
}
pub fn stored(&self, key: &str) -> Option<Value> {
self.values
.lock()
.expect("value lock")
.get(key)
.map(faucet_core::state_version::peel_versioned)
}
pub fn stored_raw(&self, key: &str) -> Option<Value> {
self.values.lock().expect("value lock").get(key).cloned()
}
fn should_fail_put(&self) -> bool {
let mut s = self.script.lock().expect("script lock");
let nth = s.state_puts;
s.state_puts += 1;
matches!(s.boundary, Boundary::StatePut(n) if n == nth)
}
}
#[async_trait]
impl StateStore for ScriptedStateStore {
async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
Ok(self.values.lock().expect("value lock").get(key).cloned())
}
async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
if self.should_fail_put() {
self.log.push(Event::StatePutFailed {
key: key.to_string(),
});
return Err(FaucetError::State(
"scripted state store: put failed at the scripted boundary".into(),
));
}
self.values
.lock()
.expect("value lock")
.insert(key.to_string(), value.clone());
self.log.push(Event::StatePut {
key: key.to_string(),
value: value.clone(),
});
Ok(())
}
async fn delete(&self, key: &str) -> Result<(), FaucetError> {
self.values.lock().expect("value lock").remove(key);
Ok(())
}
}
#[allow(deprecated)] #[cfg(test)]
mod tests {
use super::*;
fn rec(n: usize) -> Vec<Value> {
(0..n).map(|i| json!({ "n": i })).collect()
}
#[tokio::test]
async fn versioned_bookmarks_read_back_as_their_payload() {
let log = EventLog::new();
let store = ScriptedSink::new(log.clone()).state_store();
let envelope = faucet_core::state_version::wrap_versioned("paged", 0, &json!({"page": 3}));
store.put("k", &envelope).await.expect("put");
assert_eq!(store.stored("k"), Some(json!({"page": 3})));
assert_eq!(store.stored_raw("k"), Some(envelope));
assert_eq!(log.bookmarks(), vec![json!({"page": 3})]);
}
#[tokio::test]
async fn log_orders_writes_and_bookmarks_as_they_happen() {
let log = EventLog::new();
let sink = ScriptedSink::new(log.clone());
let store = sink.state_store();
sink.write_batch(&rec(2)).await.expect("write 1");
store.put("k", &json!({"at": 2})).await.expect("put 1");
sink.write_batch(&rec(3)).await.expect("write 2");
store.put("k", &json!({"at": 5})).await.expect("put 2");
assert_eq!(
log.events(),
vec![
Event::Write(2),
Event::StatePut {
key: "k".into(),
value: json!({"at": 2})
},
Event::Write(3),
Event::StatePut {
key: "k".into(),
value: json!({"at": 5})
},
]
);
assert_eq!(log.records_written(), 5);
assert_eq!(log.writes_before_each_bookmark(), vec![2, 5]);
}
#[tokio::test]
async fn write_boundary_fails_exactly_the_nth_batch() {
let log = EventLog::new();
let sink = ScriptedSink::new(log.clone()).failing_at(Boundary::Write(1));
assert_eq!(sink.write_batch(&rec(2)).await.expect("batch 0 ok"), 2);
sink.write_batch(&rec(3))
.await
.expect_err("batch 1 must fail");
assert_eq!(sink.write_batch(&rec(4)).await.expect("batch 2 ok"), 4);
assert_eq!(
log.events(),
vec![Event::Write(2), Event::WriteFailed(3), Event::Write(4)],
"only the scripted batch fails; the rest behave normally"
);
assert_eq!(log.records_written(), 6, "the failed batch is not counted");
}
#[tokio::test]
async fn state_put_boundary_fails_exactly_the_nth_put() {
let log = EventLog::new();
let sink = ScriptedSink::new(log.clone()).failing_at(Boundary::StatePut(1));
let store = sink.state_store();
store.put("k", &json!(1)).await.expect("put 0 ok");
store
.put("k", &json!(2))
.await
.expect_err("put 1 must fail");
store.put("k", &json!(3)).await.expect("put 2 ok");
assert_eq!(store.stored("k"), Some(json!(3)));
assert_eq!(log.bookmarks(), vec![json!(1), json!(3)]);
assert!(log.contains(&Event::StatePutFailed { key: "k".into() }));
}
#[tokio::test]
async fn flush_boundary_fails_only_flush() {
let log = EventLog::new();
let sink = ScriptedSink::new(log.clone()).failing_at(Boundary::Flush);
sink.write_batch(&rec(1)).await.expect("write succeeds");
sink.flush().await.expect_err("flush must fail");
assert_eq!(log.events(), vec![Event::Write(1), Event::FlushFailed]);
}
#[tokio::test]
async fn capabilities_are_advertised_independently() {
let log = EventLog::new();
let token_only = ScriptedSink::new(log.clone()).idempotent();
assert!(token_only.supports_idempotent_writes());
assert!(!token_only.write_batch_is_replay_safe());
let keyed = ScriptedSink::new(log.clone()).keyed();
assert!(keyed.dedups_by_key());
assert!(keyed.write_batch_is_replay_safe());
assert!(!keyed.supports_idempotent_writes());
}
#[tokio::test]
async fn idempotent_write_commits_records_and_token_together() {
let log = EventLog::new();
let sink = ScriptedSink::new(log.clone()).idempotent();
assert_eq!(sink.last_committed_token("s").await.expect("read"), None);
sink.write_batch_idempotent(&rec(3), "s", "t1")
.await
.expect("write");
assert_eq!(
sink.last_committed_token("s").await.expect("read"),
Some("t1".into())
);
assert!(log.contains(&Event::IdempotentWrite {
records: 3,
token: "t1".into()
}));
let failing = ScriptedSink::new(log.clone())
.idempotent()
.failing_at(Boundary::Write(0));
failing
.write_batch_idempotent(&rec(1), "s2", "t2")
.await
.expect_err("must fail");
assert_eq!(
failing.last_committed_token("s2").await.expect("read"),
None,
"a failed write must not leave a token behind"
);
}
#[tokio::test]
async fn overwrite_lifecycle_is_logged_and_commit_can_be_failed() {
let log = EventLog::new();
let sink = ScriptedSink::new(log.clone())
.overwrite()
.failing_at(Boundary::CommitOverwrite);
assert!(sink.is_overwrite());
sink.begin_overwrite().await.expect("begin");
sink.commit_overwrite().await.expect_err("commit must fail");
sink.abort_overwrite().await.expect("abort");
assert_eq!(
log.events(),
vec![Event::BeginOverwrite, Event::AbortOverwrite],
"a failed commit logs no CommitOverwrite — the swap did not happen"
);
}
#[tokio::test]
async fn never_boundary_is_the_control_arm() {
let log = EventLog::new();
let sink = ScriptedSink::new(log.clone());
for _ in 0..5 {
sink.write_batch(&rec(1)).await.expect("no failure");
}
sink.flush().await.expect("no failure");
assert!(!log.any(|e| matches!(
e,
Event::WriteFailed(_) | Event::FlushFailed | Event::StatePutFailed { .. }
)));
}
#[tokio::test]
async fn seeded_store_models_resuming_a_durable_bookmark() {
let store = ScriptedStateStore::new(EventLog::new()).seed("k", json!({"at": 7}));
assert_eq!(store.get("k").await.expect("get"), Some(json!({"at": 7})));
store.delete("k").await.expect("delete");
assert_eq!(store.get("k").await.expect("get"), None);
}
fn claims(v: &Value) -> usize {
v["at"].as_u64().unwrap_or(0) as usize
}
#[test]
fn guarantee_holds_when_the_bookmark_follows_its_writes() {
let log = EventLog::new();
log.push(Event::Write(3));
log.push(Event::StatePut {
key: "k".into(),
value: json!({"at": 3}),
});
log.push(Event::Write(2));
log.push(Event::StatePut {
key: "k".into(),
value: json!({"at": 5}),
});
assert_bookmarks_backed_by_writes(&log, claims);
}
#[test]
#[should_panic(expected = "durability violated")]
fn guarantee_fails_when_the_bookmark_precedes_its_writes() {
let log = EventLog::new();
log.push(Event::StatePut {
key: "k".into(),
value: json!({"at": 3}),
});
log.push(Event::Write(3));
assert_bookmarks_backed_by_writes(&log, claims);
}
#[test]
#[should_panic(expected = "durability violated")]
fn guarantee_fails_when_a_bookmark_overclaims_after_a_partial_write() {
let log = EventLog::new();
log.push(Event::Write(3));
log.push(Event::WriteFailed(3));
log.push(Event::StatePut {
key: "k".into(),
value: json!({"at": 6}),
});
assert_bookmarks_backed_by_writes(&log, claims);
}
#[test]
fn guarantee_counts_idempotent_writes_as_confirmation() {
let log = EventLog::new();
log.push(Event::IdempotentWrite {
records: 4,
token: "t".into(),
});
log.push(Event::StatePut {
key: "k".into(),
value: json!({"at": 4}),
});
assert_bookmarks_backed_by_writes(&log, claims);
}
#[test]
fn position_finds_the_first_match_for_order_assertions() {
let log = EventLog::new();
log.push(Event::Write(1));
log.push(Event::Flush);
log.push(Event::Write(2));
assert_eq!(log.position(|e| matches!(e, Event::Flush)), Some(1));
assert_eq!(log.position(|e| matches!(e, Event::Cleanup(_))), None);
}
#[test]
fn a_row_mask_holds_exactly_the_rows_it_was_given() {
let m = RowMask::of(&[0, 3, 63]);
assert!(m.contains(0) && m.contains(3) && m.contains(63));
assert!(!m.contains(1) && !m.contains(62));
assert!(!RowMask::of(&[64]).contains(64));
assert!(!RowMask::of(&[64]).contains(0));
assert_eq!(RowMask::of(&[0, 3, 63]).count_within(4), 2);
assert_eq!(RowMask::of(&[0, 3, 63]).count_within(64), 3);
assert_eq!(RowMask::just(2).count_within(3), 1);
assert_eq!(RowMask::default().count_within(10), 0);
}
#[tokio::test]
async fn rows_in_write_fails_exactly_those_rows_and_lands_the_rest() {
let log = EventLog::new();
let sink = ScriptedSink::new(log.clone()).failing_at(Boundary::RowsInWrite {
batch: 0,
rows: RowMask::of(&[1, 2]),
});
let page: Vec<Value> = (0..4).map(|i| json!({ "i": i })).collect();
let outcomes = sink.write_batch_partial(&page).await.expect("partial");
let failed: Vec<usize> = outcomes
.iter()
.enumerate()
.filter(|(_, o)| o.is_err())
.map(|(i, _)| i)
.collect();
assert_eq!(failed, vec![1, 2]);
assert!(log.contains(&Event::Write(2)), "{:?}", log.events());
assert!(log.contains(&Event::WriteFailed(2)), "{:?}", log.events());
let outcomes = sink.write_batch_partial(&page).await.expect("partial");
assert!(outcomes.iter().all(|o| o.is_ok()));
}
#[tokio::test]
async fn the_write_boundary_stays_an_outer_error_on_the_partial_path() {
let log = EventLog::new();
let sink = ScriptedSink::new(log.clone()).failing_at(Boundary::Write(0));
let page = vec![json!({ "i": 0 }), json!({ "i": 1 })];
assert!(sink.write_batch_partial(&page).await.is_err());
assert!(log.contains(&Event::WriteFailed(2)), "{:?}", log.events());
}
#[tokio::test]
async fn a_dlq_sink_logs_its_writes_distinctly_from_the_main_sink() {
let log = EventLog::new();
let main = ScriptedSink::new(log.clone());
let dlq = ScriptedSink::new(log.clone()).as_dlq();
main.write_batch(&[json!({})]).await.expect("main write");
dlq.write_batch(&[json!({}), json!({})])
.await
.expect("dlq write");
assert_eq!(
log.events(),
vec![Event::Write(1), Event::DlqWrite(2)],
"the log must order and distinguish both sinks"
);
dlq.write_batch_partial(&[json!({})]).await.expect("dlq");
assert!(log.contains(&Event::DlqWrite(1)));
}
}