Skip to content

Commit 6b0d7fe

Browse files
committed
feat: add job worker machinery
1 parent 0f98192 commit 6b0d7fe

2 files changed

Lines changed: 118 additions & 1 deletion

File tree

crates/event-sorcery/src/job.rs

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ pub type Storage<J> = SqliteStorage<J, JsonCodec<CompactType>, SqliteFetcher>;
201201

202202
/// The apalis worker handler. Deserializes the job, logs, and runs
203203
/// [`perform`](Job::perform) against the injected input.
204-
#[cfg(not(feature = "test-support"))]
204+
#[cfg(not(any(test, feature = "test-support")))]
205205
pub async fn work<J>(
206206
job: J,
207207
input: Data<Arc<J::Input>>,
@@ -220,6 +220,22 @@ where
220220
})
221221
}
222222

223+
/// Test-support [`work`] variant that routes execution through a
224+
/// [`FailureInjector`] so end-to-end tests can force a terminal
225+
/// failure for a targeted job.
226+
#[cfg(any(test, feature = "test-support"))]
227+
pub async fn work<J>(
228+
job: J,
229+
input: Data<Arc<J::Input>>,
230+
injector: Data<FailureInjector>,
231+
attempt: Attempt,
232+
) -> Result<J::Output, JobError>
233+
where
234+
J: Job + Sync,
235+
{
236+
injector.perform::<J>(&job, &input, attempt.current()).await
237+
}
238+
223239
/// Worker event handler that stops the worker and notifies the
224240
/// supervisor on a terminal (retries-exhausted) failure.
225241
pub fn on_terminal_failure(
@@ -250,6 +266,81 @@ fn log_processing(label: &Label, attempt: usize) {
250266
debug!(target: "job", %label, attempt, "processing job");
251267
}
252268

269+
/// Fault-injection registry for end-to-end terminal-failure tests.
270+
///
271+
/// [`arm`](Self::arm) marks a [`Job::KIND`] so the next job of that
272+
/// kind fails terminally; the failure then sticks to that specific
273+
/// job instance (by [`Label`]) so retries of it keep failing while
274+
/// other instances of the same kind run normally.
275+
#[cfg(any(test, feature = "test-support"))]
276+
#[derive(Clone, Debug, Default)]
277+
pub struct FailureInjector {
278+
states: Arc<std::sync::Mutex<std::collections::HashMap<&'static str, InjectionState>>>,
279+
}
280+
281+
#[cfg(any(test, feature = "test-support"))]
282+
#[derive(Debug, Default)]
283+
enum InjectionState {
284+
#[default]
285+
Idle,
286+
Armed,
287+
Targeted(String),
288+
}
289+
290+
#[cfg(any(test, feature = "test-support"))]
291+
impl FailureInjector {
292+
/// Creates an injector with no kinds armed.
293+
#[must_use]
294+
pub fn new() -> Self {
295+
Self::default()
296+
}
297+
298+
/// Arms injection for the next job of `kind`.
299+
pub fn arm(&self, kind: &'static str) {
300+
self.lock().insert(kind, InjectionState::Armed);
301+
}
302+
303+
fn should_inject(&self, kind: &'static str, label: &Label) -> bool {
304+
let mut guard = self.lock();
305+
let state = guard.entry(kind).or_default();
306+
let inject = match state {
307+
InjectionState::Idle => false,
308+
InjectionState::Armed => {
309+
*state = InjectionState::Targeted(label.as_str().to_owned());
310+
true
311+
}
312+
InjectionState::Targeted(target) => target == label.as_str(),
313+
};
314+
drop(guard);
315+
inject
316+
}
317+
318+
fn lock(
319+
&self,
320+
) -> std::sync::MutexGuard<'_, std::collections::HashMap<&'static str, InjectionState>> {
321+
self.states
322+
.lock()
323+
.unwrap_or_else(std::sync::PoisonError::into_inner)
324+
}
325+
326+
async fn perform<J: Job + Sync>(
327+
&self,
328+
job: &J,
329+
input: &J::Input,
330+
attempt: usize,
331+
) -> Result<J::Output, JobError> {
332+
let label = job.label();
333+
if self.should_inject(J::KIND, &label) {
334+
return Err(JobError::Injected);
335+
}
336+
log_processing(&label, attempt);
337+
job.perform(input).await.map_err(|source| JobError::Failed {
338+
label,
339+
source: Box::new(source),
340+
})
341+
}
342+
}
343+
253344
#[cfg(test)]
254345
mod tests {
255346
use serde::{Deserialize, Serialize};
@@ -295,4 +386,28 @@ mod tests {
295386
assert_eq!(welcome.label().to_string(), "welcome:a@example.com");
296387
assert_eq!(reminder.label().to_string(), "reminder:b@example.com");
297388
}
389+
390+
#[tokio::test]
391+
async fn injector_targets_first_armed_instance_and_spares_others() {
392+
let injector = FailureInjector::new();
393+
injector.arm(SendEmail::KIND);
394+
395+
let welcome = SendEmail::Welcome {
396+
address: "a@example.com".to_string(),
397+
};
398+
let reminder = SendEmail::Reminder {
399+
address: "b@example.com".to_string(),
400+
};
401+
402+
let first = injector.perform(&welcome, &(), 1).await;
403+
assert!(matches!(first, Err(JobError::Injected)));
404+
405+
let retry = injector.perform(&welcome, &(), 2).await;
406+
assert!(
407+
matches!(retry, Err(JobError::Injected)),
408+
"the injected failure must stick to the targeted instance across retries"
409+
);
410+
411+
injector.perform(&reminder, &(), 1).await.unwrap();
412+
}
298413
}

crates/event-sorcery/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ use std::str::FromStr;
104104
pub use dependency::Cons;
105105
pub use dependency::Nil;
106106
pub use dependency::{Dependent, EntityList, Fold, HasEntity, OneOf};
107+
#[cfg(any(test, feature = "test-support"))]
108+
pub use job::FailureInjector;
107109
#[doc(hidden)]
108110
pub use job::{
109111
ExponentialBackoff, FAIL_STOP_RECOVERY_TIMEOUT, RETRY_BACKOFF, Storage, on_terminal_failure,

0 commit comments

Comments
 (0)