Skip to content

Commit b189de0

Browse files
authored
feat(cron): deduplicate and protect scheduled executions (#601)
## Summary - add task-level queue protection for scheduled cron executions - deduplicate scheduled occurrences with a database-backed `(job_id, scheduled_at)` claim - add renewable run leases, heartbeat renewal, retry preservation, and restart recovery - claim occurrences before conversation preparation/creation - keep schedule calculation on the theoretical occurrence grid Closes #600 ## Why Conversation busy state alone cannot prevent duplicate callbacks from creating two independent conversations. The occurrence claim must happen at the task/database level before any conversation is materialized. ## Implementation - add `queue_enabled` to cron jobs - add `cron_job_runs` with a unique occurrence key and active-run index - use `BEGIN IMMEDIATE` for atomic cross-process claims - record duplicate, running, retrying, skipped, success, and error outcomes - use owner IDs and expiring leases for crash recovery - preserve `scheduled_at` through retries and scheduler callbacks - fence stale owners from updating job state after losing their lease ## Testing - `RUSTUP_TOOLCHAIN=stable cargo fmt --all -- --check` - `RUSTUP_TOOLCHAIN=stable cargo check -p aionui-cron -p aionui-db -p aionui-app` - `RUSTUP_TOOLCHAIN=stable cargo test -p aionui-cron -p aionui-db -p aionui-api-types` - `RUSTUP_TOOLCHAIN=stable cargo test -p aionui-app --test cron_e2e` All checks pass on the latest upstream `main`. ## Related UI The corresponding queue-protection UI and visual schedule builder are in iOfficeAI/AionUi#3552. --------- Co-authored-by: zk <>
1 parent f48cbc7 commit b189de0

16 files changed

Lines changed: 1071 additions & 59 deletions

File tree

crates/aionui-api-types/src/cron.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ pub struct CronJobStateDto {
123123
pub run_count: i64,
124124
pub retry_count: i64,
125125
pub max_retries: i64,
126+
pub queue_enabled: bool,
126127
}
127128

128129
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -160,6 +161,8 @@ pub struct CreateCronJobRequest {
160161
#[serde(default)]
161162
pub execution_mode: Option<String>,
162163
#[serde(default)]
164+
pub queue_enabled: bool,
165+
#[serde(default)]
163166
pub agent_config: Option<CronAgentConfigWriteDto>,
164167
}
165168

@@ -209,6 +212,8 @@ pub struct UpdateCronJobRequest {
209212
pub conversation_title: Option<String>,
210213
#[serde(default)]
211214
pub max_retries: Option<i64>,
215+
#[serde(default)]
216+
pub queue_enabled: Option<bool>,
212217
}
213218

214219
// ---------------------------------------------------------------------------
@@ -609,6 +614,7 @@ mod tests {
609614
run_count: 5,
610615
retry_count: 0,
611616
max_retries: 3,
617+
queue_enabled: false,
612618
},
613619
}
614620
}
@@ -677,6 +683,7 @@ mod tests {
677683
run_count: 0,
678684
retry_count: 0,
679685
max_retries: 3,
686+
queue_enabled: true,
680687
},
681688
};
682689
let json = serde_json::to_value(&resp).unwrap();

crates/aionui-app/src/router/state.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -706,11 +706,11 @@ pub fn build_cron_state(services: &AppServices) -> CronRouterState {
706706
let tick_service_ref: Arc<CronServiceTickRef> = Arc::new(CronServiceTickRef::default());
707707
let tick_ref = tick_service_ref.clone();
708708
let scheduler = Arc::new(aionui_cron::scheduler::CronScheduler::new(Arc::new(
709-
move |job_id: String| {
709+
move |tick: aionui_cron::scheduler::ScheduledTick| {
710710
let svc = tick_ref.0.lock().unwrap().clone();
711711
tokio::spawn(async move {
712712
if let Some(svc) = svc {
713-
svc.tick(&job_id).await;
713+
svc.tick(&tick.job_id, tick.scheduled_at).await;
714714
}
715715
});
716716
},

crates/aionui-app/tests/cron_e2e.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ async fn cj5b_run_now_legacy_workspace_with_whitespace_succeeds() {
402402
run_count: 0,
403403
retry_count: 0,
404404
max_retries: 3,
405+
queue_enabled: false,
405406
})
406407
.await
407408
.unwrap();

crates/aionui-cron/src/artifacts.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ mod tests {
125125
run_count: 0,
126126
retry_count: 0,
127127
max_retries: 3,
128+
queue_enabled: false,
128129
}
129130
}
130131

crates/aionui-cron/src/events.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ mod tests {
138138
run_count: 0,
139139
retry_count: 0,
140140
max_retries: 3,
141+
queue_enabled: false,
141142
},
142143
}
143144
}

crates/aionui-cron/src/executor.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,24 @@ impl JobExecutor {
112112
return Err(ExecutionResult::Error { message: e.to_string() });
113113
}
114114

115+
if job.queue_enabled && job.execution_mode == ExecutionMode::NewConversation {
116+
match self.find_active_cron_conversation(job).await {
117+
Ok(Some(conversation_id)) => {
118+
info!(
119+
job_id = %job.id,
120+
conversation_id,
121+
"Cron queue is enabled and a previous execution is still active; skipping trigger"
122+
);
123+
return Err(ExecutionResult::Skipped);
124+
}
125+
Ok(None) => {}
126+
Err(e) => {
127+
error!(job_id = %job.id, error = %e, "Failed to inspect previous cron executions");
128+
return Err(ExecutionResult::Error { message: e.to_string() });
129+
}
130+
}
131+
}
132+
115133
let conversation_id = match self.resolve_conversation(job, saved_skill.as_ref()).await {
116134
Ok(id) => id,
117135
Err(e) => {
@@ -444,6 +462,11 @@ impl JobExecutor {
444462

445463
impl JobExecutor {
446464
fn handle_busy(&self, job: &CronJob) -> ExecutionResult {
465+
if job.queue_enabled {
466+
info!(job_id = %job.id, "Cron queue is enabled; skipping overlapping execution");
467+
return ExecutionResult::Skipped;
468+
}
469+
447470
let max_retries = job.max_retries;
448471
let current_retry = job.retry_count;
449472

@@ -504,6 +527,15 @@ impl JobExecutor {
504527
}
505528
}
506529

530+
async fn find_active_cron_conversation(&self, job: &CronJob) -> Result<Option<String>, CronError> {
531+
let user_id = self.resolve_conversation_owner_user_id(job).await?;
532+
let conversations = self.conversation_repo.list_by_cron_job(&user_id, &job.id).await?;
533+
Ok(conversations
534+
.into_iter()
535+
.find(|conversation| self.is_conversation_claimed(&conversation.id))
536+
.map(|conversation| conversation.id))
537+
}
538+
507539
async fn create_new_conversation(
508540
&self,
509541
job: &CronJob,
@@ -1243,6 +1275,7 @@ mod tests {
12431275
run_count: 0,
12441276
retry_count: 0,
12451277
max_retries: 3,
1278+
queue_enabled: false,
12461279
}
12471280
}
12481281

@@ -1268,6 +1301,7 @@ mod tests {
12681301
let job = CronJob {
12691302
retry_count: 1,
12701303
max_retries: 3,
1304+
queue_enabled: false,
12711305
..sample_job()
12721306
};
12731307
let result = executor.handle_busy(&job);
@@ -1281,6 +1315,7 @@ mod tests {
12811315
let job = CronJob {
12821316
retry_count: 3,
12831317
max_retries: 3,
1318+
queue_enabled: false,
12841319
..sample_job()
12851320
};
12861321
let result = executor.handle_busy(&job);
@@ -1294,6 +1329,7 @@ mod tests {
12941329
let job = CronJob {
12951330
retry_count: 5,
12961331
max_retries: 3,
1332+
queue_enabled: false,
12971333
..sample_job()
12981334
};
12991335
let result = executor.handle_busy(&job);
@@ -1307,12 +1343,26 @@ mod tests {
13071343
let job = CronJob {
13081344
retry_count: 0,
13091345
max_retries: 3,
1346+
queue_enabled: false,
13101347
..sample_job()
13111348
};
13121349
let result = executor.handle_busy(&job);
13131350
assert_eq!(result, ExecutionResult::Retrying { attempt: 1 });
13141351
}
13151352

1353+
#[tokio::test]
1354+
async fn handle_busy_returns_skipped_when_queue_is_enabled() {
1355+
let executor = make_executor_for_busy_tests();
1356+
let job = CronJob {
1357+
retry_count: 0,
1358+
max_retries: 3,
1359+
queue_enabled: true,
1360+
..sample_job()
1361+
};
1362+
1363+
assert_eq!(executor.handle_busy(&job), ExecutionResult::Skipped);
1364+
}
1365+
13161366
#[tokio::test]
13171367
async fn execute_returns_retrying_when_runtime_state_is_already_claimed() {
13181368
let agent = Arc::new(RecordingAgent::new("conv_1", "default", true));
@@ -1332,6 +1382,27 @@ mod tests {
13321382
drop(claim);
13331383
}
13341384

1385+
#[tokio::test]
1386+
async fn execute_returns_skipped_when_queue_is_enabled_and_conversation_is_claimed() {
1387+
let agent = Arc::new(RecordingAgent::new("conv_1", "default", true));
1388+
let executor = make_executor_with_agent(AgentInstance::Mock(agent.clone()));
1389+
let job = CronJob {
1390+
queue_enabled: true,
1391+
..sample_job()
1392+
};
1393+
let claim = executor
1394+
.conversation_service
1395+
.runtime_state()
1396+
.try_claim_turn(&job.conversation_id, "turn-existing")
1397+
.expect("runtime claim should succeed");
1398+
1399+
let result = executor.execute(&job).await;
1400+
1401+
assert_eq!(result, ExecutionResult::Skipped);
1402+
assert_eq!(agent.send_calls(), 0, "queue protection should avoid send attempts");
1403+
drop(claim);
1404+
}
1405+
13351406
#[tokio::test]
13361407
async fn prepare_run_now_returns_active_conversation_when_runtime_state_is_already_claimed() {
13371408
let agent = Arc::new(RecordingAgent::new("conv_1", "default", true));

0 commit comments

Comments
 (0)