use crate::location::{Location, read_end};
use object_store::ObjectStore;
use spate_core::clock::tokio::Clock;
use spate_core::error::{ErrorClass, SourceError};
use spate_core::feed::{
DepthOf, DepthReading, EndAction, EndCheck, EndOutcome, Feed, FeedWaker, MarkerRead,
};
use spate_core::source::StopSignal;
use spate_core::task::{self, TaskHandle};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::oneshot;
pub(crate) enum Finish {
Marker {
store: Arc<dyn ObjectStore>,
location: Location,
window: Duration,
},
Solo { finished: bool },
}
type MarkerAnswer = oneshot::Receiver<Result<Option<String>, SourceError>>;
pub(crate) struct JobEnd {
finish: Finish,
check: Option<EndCheck>,
clock: Arc<dyn Clock>,
handle: tokio::runtime::Handle,
stop: StopSignal,
waker: Option<FeedWaker>,
marker_read: Option<(TaskHandle<()>, MarkerAnswer)>,
depth_read: Option<DepthReading>,
depth_of: Option<DepthOf>,
due: Option<Instant>,
closed: bool,
}
impl JobEnd {
pub(crate) fn new(
finish: Finish,
clock: Arc<dyn Clock>,
handle: tokio::runtime::Handle,
stop: StopSignal,
) -> JobEnd {
JobEnd {
finish,
check: None,
clock,
handle,
stop,
waker: None,
marker_read: None,
depth_read: None,
depth_of: None,
due: None,
closed: false,
}
}
pub(crate) fn park_within(&self, timeout: Duration) -> Duration {
self.due.map_or(timeout, |due| {
timeout.min(due.saturating_duration_since(self.clock.now().into_std()))
})
}
pub(crate) fn set_waker(&mut self, waker: FeedWaker) {
self.waker = Some(waker);
}
pub(crate) fn lister_finished(&mut self) {
if let Finish::Solo { finished } = &mut self.finish {
*finished = true;
}
}
pub(crate) fn busy(&mut self) {
self.due = None;
self.closed = false;
if let Some(check) = &mut self.check {
check.restart(self.clock.now().into_std());
self.marker_read = None;
self.depth_read = None;
}
}
pub(crate) fn receive_failed(&mut self) {
if self.closed {
self.busy();
}
}
pub(crate) fn idle(
&mut self,
feed: &dyn Feed,
mut receive: impl FnMut() -> Result<Option<usize>, SourceError>,
) -> Result<bool, SourceError> {
self.due = None;
if self.stop.is_set() {
return Ok(false);
}
let now = self.clock.now().into_std();
if self.check.is_none() {
self.check = Some(match &self.finish {
Finish::Marker { window, .. } => EndCheck::windowed(*window, now),
Finish::Solo { finished: true } => EndCheck::exact(now),
Finish::Solo { finished: false } => return Ok(false),
});
}
let Some(check) = &mut self.check else {
return Ok(false);
};
if let Some((_, answer)) = &mut self.marker_read
&& let Some(outcome) = match answer.try_recv() {
Ok(outcome) => Some(Some(outcome)),
Err(oneshot::error::TryRecvError::Empty) => None,
Err(oneshot::error::TryRecvError::Closed) => Some(None),
}
{
self.marker_read = None;
let read = match outcome {
Some(Ok(Some(run))) => MarkerRead::Present(run),
Some(Ok(None)) => MarkerRead::Absent,
Some(Err(e)) if is_retryable(&e) => {
tracing::warn!(error = %e, "reading the end marker failed; reading it again");
MarkerRead::Failed
}
Some(Err(e)) => return Err(e),
None => {
tracing::warn!("the end marker read ended unanswered; reading it again");
MarkerRead::Failed
}
};
check.marker_read(now, read);
}
if let Some(reading) = &mut self.depth_read
&& let Some(outcome) = reading.try_outcome()
{
self.depth_read = None;
check.depth_read(now, outcome);
}
loop {
match check.next(now) {
EndAction::ReadMarker => {
let Finish::Marker {
store, location, ..
} = &self.finish
else {
unreachable!("an exact check reads no marker");
};
let store = Arc::clone(store);
let location = location.clone();
let waker = self.waker.clone();
let (answer, answered) = oneshot::channel();
let read = task::spawn_on(
"s3-end-marker-read",
async move {
let _ = answer.send(read_end(store.as_ref(), &location).await);
if let Some(waker) = waker {
waker.wake();
}
},
&self.handle,
);
self.marker_read = Some((read, answered));
}
EndAction::ReadDepth(of) => {
self.depth_of = Some(of);
let Some(mut reading) = feed.read_depth(of) else {
return Err(no_depth(feed));
};
match reading.try_outcome() {
Some(outcome) => check.depth_read(now, outcome),
None => self.depth_read = Some(reading),
}
}
EndAction::Receive => {
let took = receive()?.is_none_or(|took| took > 0);
check.received(now, took);
if took {
return Ok(false);
}
self.closed = true;
}
EndAction::Ended(EndOutcome::Completed) => {
tracing::info!(
"the bounded job has read all of its work and its dead-letter \
destination is empty; ending the run"
);
return Ok(true);
}
EndAction::Ended(EndOutcome::Stalled(depth)) => {
let next = match self.finish {
Finish::Marker { .. } => "copy them into a re-run's feed",
Finish::Solo { .. } => "run the job again",
};
return Err(SourceError::Client {
class: ErrorClass::Fatal,
reason: format!(
"bounded feed job stalled: the dead-letter destination holds units \
({depth}); fix their cause, then {next}"
),
});
}
EndAction::Ended(EndOutcome::Failed(e)) => {
return Err(SourceError::Client {
class: ErrorClass::Fatal,
reason: if self.depth_of == Some(DepthOf::DeadLetters) {
format!("reading the depth of the feed's dead-letter destination: {e}")
} else {
format!("reading the feed's depth: {e}")
},
});
}
EndAction::Wait(due) => {
self.due = due;
return Ok(false);
}
_ => return Ok(false),
}
}
}
}
pub(crate) fn no_depth(feed: &dyn Feed) -> SourceError {
SourceError::Client {
class: ErrorClass::Fatal,
reason: format!(
"the `{}` feed reports no depth, so a bounded job on it cannot tell when its work \
is done; use a feed that counts its units, or run the job without `location`",
feed.backend()
),
}
}
fn is_retryable(e: &SourceError) -> bool {
matches!(
e,
SourceError::Client {
class: ErrorClass::Retryable,
..
}
)
}
#[cfg(test)]
mod tests {
use super::*;
use spate_core::clock::tokio::TestClock;
use spate_core::feed::wake_channel;
use std::cell::Cell;
fn runtime() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.unwrap()
}
fn solo(rt: &tokio::runtime::Runtime) -> JobEnd {
let mut end = JobEnd::new(
Finish::Solo { finished: true },
TestClock::frozen(),
rt.handle().clone(),
StopSignal::default(),
);
end.lister_finished();
end
}
#[test]
fn a_closing_receive_that_takes_a_unit_restarts_the_check() {
let rt = runtime();
let (feed, _script) = spate_test::scripted_feed();
let mut end = solo(&rt);
let receives = Cell::new(0);
let took_one = end.idle(&feed, || {
receives.set(receives.get() + 1);
Ok(Some(1))
});
assert!(
!took_one.unwrap(),
"completed though the receive took a unit"
);
assert_eq!(receives.get(), 1, "one receive per poll");
let took_none = end.idle(&feed, || {
receives.set(receives.get() + 1);
Ok(Some(0))
});
assert!(took_none.unwrap(), "an empty receive lets the job complete");
assert_eq!(receives.get(), 2);
}
#[test]
fn a_finished_marker_read_wakes_the_reader() {
let rt = runtime();
let store: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
let location = Location::parse("s3://ops/jobs/a/", "s3://data/events/").unwrap();
let mut end = JobEnd::new(
Finish::Marker {
store,
location,
window: Duration::from_secs(60),
},
TestClock::frozen(),
rt.handle().clone(),
StopSignal::default(),
);
let (waker, park) = wake_channel();
end.set_waker(waker);
let (feed, _script) = spate_test::scripted_feed();
assert!(!end.idle(&feed, || Ok(Some(0))).unwrap());
assert!(
park.park(Duration::from_secs(30)),
"the marker read did not wake the reader"
);
}
#[test]
fn the_poll_a_marker_read_wakes_reads_its_answer() {
use object_store::ObjectStoreExt as _;
let rt = runtime();
let store: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
let location = Location::parse("s3://ops/jobs/a/", "s3://data/events/").unwrap();
rt.block_on(store.put(
&location.end(),
b"run=0123456789abcdef0123456789abcdef\n".to_vec().into(),
))
.unwrap();
let mut end = JobEnd::new(
Finish::Marker {
store,
location,
window: Duration::from_secs(60),
},
TestClock::frozen(),
rt.handle().clone(),
StopSignal::default(),
);
let (waker, park) = wake_channel();
end.set_waker(waker);
let (feed, script) = spate_test::scripted_feed();
assert!(!end.idle(&feed, || Ok(Some(0))).unwrap());
assert!(
park.park(Duration::from_secs(30)),
"the marker read finishes"
);
assert!(!end.idle(&feed, || Ok(Some(0))).unwrap());
assert_eq!(
script.depth_reads(DepthOf::Feed),
1,
"the woken poll did not find the marker read's answer"
);
let long = Duration::from_secs(60);
let parked = end.park_within(long);
assert!(
parked > Duration::ZERO && parked <= Duration::from_secs(15),
"{parked:?}"
);
end.busy();
assert_eq!(end.park_within(long), long);
}
#[test]
fn a_fatal_marker_read_fails_the_run() {
use object_store::ObjectStoreExt as _;
let rt = runtime();
let store: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
let location = Location::parse("s3://ops/jobs/a/", "s3://data/events/").unwrap();
rt.block_on(store.put(&location.end(), b"not a marker".to_vec().into()))
.unwrap();
let mut end = JobEnd::new(
Finish::Marker {
store,
location,
window: Duration::from_secs(60),
},
TestClock::frozen(),
rt.handle().clone(),
StopSignal::default(),
);
let (waker, park) = wake_channel();
end.set_waker(waker);
let (feed, _script) = spate_test::scripted_feed();
assert!(!end.idle(&feed, || Ok(Some(0))).unwrap());
assert!(
park.park(Duration::from_secs(30)),
"the marker read finishes"
);
let err = end.idle(&feed, || Ok(Some(0))).unwrap_err().to_string();
assert!(err.contains("not a lister run's end marker"), "{err}");
}
#[test]
fn busy_aborts_a_marker_read_in_flight() {
use futures_util::StreamExt as _;
use std::sync::atomic::{AtomicBool, Ordering};
static STARTED: AtomicBool = AtomicBool::new(false);
static DROPPED: AtomicBool = AtomicBool::new(false);
struct Dropped;
impl Drop for Dropped {
fn drop(&mut self) {
DROPPED.store(true, Ordering::SeqCst);
}
}
fn hanging_get(
path: &object_store::path::Path,
) -> Option<object_store::Result<object_store::GetResult>> {
STARTED.store(true, Ordering::SeqCst);
let guard = Dropped;
let body = futures_util::stream::poll_fn(move |_| {
let _ = &guard;
std::task::Poll::<Option<object_store::Result<bytes::Bytes>>>::Pending
})
.boxed();
let meta = object_store::ObjectMeta {
location: path.clone(),
last_modified: chrono::Utc::now(),
size: 10,
e_tag: None,
version: None,
};
Some(Ok(object_store::GetResult {
payload: object_store::GetResultPayload::Stream(body),
range: 0..meta.size,
meta,
attributes: Default::default(),
extensions: Default::default(),
}))
}
let rt = runtime();
let store: Arc<dyn ObjectStore> = Arc::new(crate::testutil::HookStore {
get: Some(hanging_get),
..crate::testutil::HookStore::default()
});
let location = Location::parse("s3://ops/jobs/a/", "s3://data/events/").unwrap();
let mut end = JobEnd::new(
Finish::Marker {
store,
location,
window: Duration::from_secs(60),
},
TestClock::frozen(),
rt.handle().clone(),
StopSignal::default(),
);
let (feed, _script) = spate_test::scripted_feed();
assert!(!end.idle(&feed, || Ok(Some(0))).unwrap());
spate_test::wait_until(Duration::from_secs(30), "the marker read starts", || {
STARTED.load(Ordering::SeqCst)
});
assert!(!DROPPED.load(Ordering::SeqCst), "the read ended on its own");
end.busy();
spate_test::wait_until(
Duration::from_secs(30),
"the marker read is aborted",
|| DROPPED.load(Ordering::SeqCst),
);
}
}