1use std::{
10 cmp::Reverse,
11 collections::{BTreeMap, BTreeSet, BinaryHeap, HashSet},
12 path::PathBuf,
13 sync::Arc,
14};
15
16use async_graphql::InputType as _;
17use futures::{stream::StreamExt, FutureExt};
18use linera_base::{
19 data_types::{TimeDelta, Timestamp},
20 identifiers::{ApplicationId, ChainId},
21 task_processor::{ProcessorActions, Task, TaskOutcome},
22};
23use linera_core::{
24 client::ChainClient, data_types::ClientOutcome, node::NotificationStream, worker::Reason,
25};
26use serde_json::json;
27use tokio::{io::AsyncWriteExt, process::Command, select, sync::mpsc};
28use tokio_util::sync::CancellationToken;
29use tracing::{debug, error, info, warn};
30
31use crate::controller::Update;
32
33#[cfg(with_metrics)]
34pub(crate) mod metrics {
35 use linera_base::prometheus_util::register_int_counter;
36 use prometheus::IntCounter;
37
38 linera_base::declare_metrics! {
39 pub static SLOW_TASK_GROUPS: IntCounter = register_int_counter(
45 "slow_task_groups_total",
46 "Number of task groups that outlived the slow-group threshold"
47 );
48 }
49}
50
51pub type OperatorMap = Arc<BTreeMap<String, PathBuf>>;
53
54pub fn parse_operator(s: &str) -> Result<(String, PathBuf), String> {
57 if let Some((name, path)) = s.split_once('=') {
58 Ok((name.to_string(), PathBuf::from(path)))
59 } else {
60 Ok((s.to_string(), PathBuf::from(s)))
61 }
62}
63
64type Deadline = Reverse<(Timestamp, Option<ApplicationId>)>;
65
66#[derive(Clone, Copy, Debug)]
68pub struct TaskProcessorConfig {
69 pub retry_delay: TimeDelta,
71 pub slow_group_threshold: TimeDelta,
73}
74
75struct GroupResult {
77 application_id: ApplicationId,
78 group: Option<String>,
80 retry_at: Option<Timestamp>,
82}
83
84pub struct TaskProcessor<Env: linera_core::Environment> {
86 chain_id: ChainId,
87 application_ids: Vec<ApplicationId>,
88 cursors: BTreeMap<ApplicationId, String>,
89 chain_client: ChainClient<Env>,
90 cancellation_token: CancellationToken,
91 notifications: NotificationStream,
92 result_sender: mpsc::UnboundedSender<GroupResult>,
93 result_receiver: mpsc::UnboundedReceiver<GroupResult>,
94 update_receiver: mpsc::UnboundedReceiver<Update>,
95 deadlines: BinaryHeap<Deadline>,
96 operators: OperatorMap,
97 config: TaskProcessorConfig,
98 in_flight_groups: BTreeSet<(ApplicationId, Option<String>)>,
101}
102
103impl<Env: linera_core::Environment> TaskProcessor<Env> {
104 pub fn new(
106 chain_id: ChainId,
107 application_ids: Vec<ApplicationId>,
108 chain_client: ChainClient<Env>,
109 cancellation_token: CancellationToken,
110 operators: OperatorMap,
111 config: TaskProcessorConfig,
112 update_receiver: Option<mpsc::UnboundedReceiver<Update>>,
113 ) -> Self {
114 let notifications = chain_client.subscribe().expect("client subscription");
115 let (result_sender, result_receiver) = mpsc::unbounded_channel();
116 let update_receiver = update_receiver.unwrap_or_else(|| mpsc::unbounded_channel().1);
117 Self {
118 chain_id,
119 application_ids,
120 cursors: BTreeMap::new(),
121 chain_client,
122 cancellation_token,
123 notifications,
124 result_sender,
125 result_receiver,
126 update_receiver,
127 deadlines: BinaryHeap::new(),
128 operators,
129 config,
130 in_flight_groups: BTreeSet::new(),
131 }
132 }
133
134 pub async fn run(mut self) {
136 info!("Watching for notifications for chain {}", self.chain_id);
137 self.process_actions(self.application_ids.clone()).await;
138 loop {
139 select! {
140 Some(notification) = self.notifications.next() => {
141 if let Reason::NewBlock { .. } = notification.reason {
142 debug!(%self.chain_id, "Processing notification");
143 self.process_actions(self.application_ids.clone()).await;
144 }
145 }
146 _ = tokio::time::sleep(Self::duration_until_next_deadline(&self.deadlines)) => {
147 debug!("Processing event");
148 let application_ids = self.process_events();
149 self.process_actions(application_ids).await;
150 }
151 Some(result) = self.result_receiver.recv() => {
152 self.in_flight_groups.remove(&(result.application_id, result.group));
153 if self.application_ids.contains(&result.application_id) {
156 if let Some(retry_at) = result.retry_at {
157 self.deadlines.push(Reverse((
158 retry_at,
159 Some(result.application_id),
160 )));
161 } else {
162 self.process_actions(vec![result.application_id]).await;
164 }
165 }
166 }
167 Some(update) = self.update_receiver.recv() => {
168 self.apply_update(update).await;
169 }
170 _ = self.cancellation_token.cancelled().fuse() => {
171 break;
172 }
173 }
174 }
175 debug!("Notification stream ended.");
176 }
177
178 fn duration_until_next_deadline(deadlines: &BinaryHeap<Deadline>) -> tokio::time::Duration {
179 deadlines
180 .peek()
181 .map_or(tokio::time::Duration::MAX, |Reverse((x, _))| {
182 x.delta_since(Timestamp::now()).as_duration()
183 })
184 }
185
186 async fn apply_update(&mut self, update: Update) {
187 info!(
188 "Applying update for chain {}: {:?}",
189 self.chain_id, update.application_ids
190 );
191
192 let new_app_set: BTreeSet<_> = update.application_ids.iter().cloned().collect();
193 let old_app_set: BTreeSet<_> = self.application_ids.iter().cloned().collect();
194
195 self.cursors
196 .retain(|app_id, _| new_app_set.contains(app_id));
197 self.in_flight_groups
198 .retain(|(app_id, _)| new_app_set.contains(app_id));
199
200 self.application_ids = update.application_ids;
202
203 let new_apps = self
205 .application_ids
206 .iter()
207 .filter(|app_id| !old_app_set.contains(app_id))
208 .cloned()
209 .collect::<Vec<_>>();
210 if !new_apps.is_empty() {
211 self.process_actions(new_apps).await;
212 }
213 }
214
215 fn process_events(&mut self) -> Vec<ApplicationId> {
218 let now = Timestamp::now();
219 let mut application_ids = Vec::new();
220 let mut seen = HashSet::new();
221 while let Some(deadline) = self.deadlines.pop() {
222 if let Reverse((_, Some(id))) = deadline {
223 if seen.insert(id) {
224 application_ids.push(id);
225 }
226 }
227 let Some(Reverse((ts, _))) = self.deadlines.peek() else {
228 break;
229 };
230 if *ts > now {
231 break;
232 }
233 }
234 application_ids
235 }
236
237 async fn process_actions(&mut self, application_ids: Vec<ApplicationId>) {
238 for application_id in application_ids {
239 if !self.application_ids.contains(&application_id) {
240 debug!("Skipping {application_id}: it's no longer assigned to this processor");
241 continue;
242 }
243 debug!("Processing actions for {application_id}");
244 let now = Timestamp::now();
245 let app_cursor = self.cursors.get(&application_id).cloned();
246 let actions = match self.query_actions(application_id, app_cursor, now).await {
247 Ok(actions) => actions,
248 Err(error) => {
249 error!(%application_id, %error, "Error reading application actions");
250 self.deadlines.push(Reverse((
252 now.saturating_add(TimeDelta::from_secs(60)),
253 Some(application_id),
254 )));
255 continue;
256 }
257 };
258 if let Some(timestamp) = actions.request_callback {
259 self.deadlines
260 .push(Reverse((timestamp, Some(application_id))));
261 }
262 if let Some(cursor) = actions.set_cursor {
263 self.cursors.insert(application_id, cursor);
264 }
265 for (group, tasks) in group_tasks(actions.execute_tasks) {
269 if !self
270 .in_flight_groups
271 .insert((application_id, group.clone()))
272 {
273 debug!(%application_id, ?group, "Skipping group: tasks already in flight");
274 continue;
275 }
276 let chain_client = self.chain_client.clone();
277 let result_sender = self.result_sender.clone();
278 let config = self.config;
279 let operators = self.operators.clone();
280 tokio::spawn(async move {
281 let handle = tokio::spawn(Self::process_group(
285 application_id,
286 group.clone(),
287 tasks,
288 chain_client,
289 operators,
290 config,
291 ));
292 let retry_at = await_group(application_id, &group, handle, config).await;
293 if result_sender
294 .send(GroupResult {
295 application_id,
296 group,
297 retry_at,
298 })
299 .is_err()
300 {
301 error!(%application_id, "Result receiver dropped");
302 }
303 });
304 }
305 }
306 }
307
308 async fn process_group(
319 application_id: ApplicationId,
320 group: Option<String>,
321 tasks: Vec<Task>,
322 chain_client: ChainClient<Env>,
323 operators: OperatorMap,
324 config: TaskProcessorConfig,
325 ) -> Option<Timestamp> {
326 let mut handles = Vec::with_capacity(tasks.len());
327 for task in tasks {
328 handles.push(tokio::spawn(execute_task(
329 application_id,
330 task,
331 operators.clone(),
332 )));
333 }
334 for handle in handles {
335 let outcome = match handle.await {
336 Ok(Ok(outcome)) => outcome,
337 Ok(Err(error)) => {
338 error!(%application_id, ?group, %error, "Error executing task");
339 return Some(Timestamp::now().saturating_add(config.retry_delay));
340 }
341 Err(error) => {
342 error!(%application_id, ?group, %error, "Task panicked");
343 return Some(Timestamp::now().saturating_add(config.retry_delay));
344 }
345 };
346 if let Err(timestamp) = Self::submit_task_outcome(
347 &chain_client,
348 application_id,
349 &outcome,
350 config.retry_delay,
351 )
352 .await
353 {
354 return Some(timestamp);
355 }
356 }
357 None
358 }
359
360 #[expect(clippy::needless_pass_by_ref_mut)]
363 async fn query_actions(
364 &mut self,
365 application_id: ApplicationId,
366 cursor: Option<String>,
367 now: Timestamp,
368 ) -> Result<ProcessorActions, anyhow::Error> {
369 let query = format!(
370 "query {{ nextActions(cursor: {}, now: {}) }}",
371 cursor.to_value(),
372 now.to_value(),
373 );
374 let bytes = serde_json::to_vec(&json!({"query": query}))?;
375 let query = linera_execution::Query::User {
376 application_id,
377 bytes,
378 };
379 let (
380 linera_execution::QueryOutcome {
381 response,
382 operations: _,
383 },
384 _,
385 ) = self.chain_client.query_application(query, None).await?;
386 let linera_execution::QueryResponse::User(response) = response else {
387 anyhow::bail!("cannot get a system response for a user query");
388 };
389 let mut response: serde_json::Value = serde_json::from_slice(&response)?;
390 let actions: ProcessorActions =
391 serde_json::from_value(response["data"]["nextActions"].take())?;
392 Ok(actions)
393 }
394
395 async fn submit_task_outcome(
398 chain_client: &ChainClient<Env>,
399 application_id: ApplicationId,
400 task_outcome: &TaskOutcome,
401 retry_delay: TimeDelta,
402 ) -> Result<(), Timestamp> {
403 info!("Submitting task outcome for {application_id}: {task_outcome:?}");
404 let group = &task_outcome.id;
406 let retry_with_delay = || Timestamp::now().saturating_add(retry_delay);
407 let query = task_outcome_query(task_outcome);
408 let bytes = serde_json::to_vec(&json!({"query": query})).map_err(|error| {
409 error!(%application_id, ?group, %error, "Error serializing task outcome query");
410 retry_with_delay()
411 })?;
412 let query = linera_execution::Query::User {
413 application_id,
414 bytes,
415 };
416 let (
417 linera_execution::QueryOutcome {
418 response: _,
419 operations,
420 },
421 _,
422 ) = chain_client
423 .query_application(query, None)
424 .await
425 .map_err(|error| {
426 error!(%application_id, ?group, %error, "Error querying application");
427 retry_with_delay()
428 })?;
429 if !operations.is_empty() {
430 match chain_client
431 .execute_operations(operations, vec![])
432 .await
433 .map_err(|error| {
434 error!(%application_id, ?group, %error, "Error executing operations");
435 retry_with_delay()
436 })? {
437 ClientOutcome::Committed(_) => {}
438 ClientOutcome::WaitForTimeout(timeout) => {
439 error!(%application_id, ?group, "Not the round leader, retrying after {}", timeout.timestamp);
440 return Err(timeout.timestamp);
441 }
442 ClientOutcome::Conflict(_) => {
443 debug!(%application_id, ?group, "Block conflict, retrying immediately");
444 return Err(Timestamp::now());
445 }
446 }
447 }
448 Ok(())
449 }
450}
451
452async fn execute_task(
454 application_id: ApplicationId,
455 task: Task,
456 operators: OperatorMap,
457) -> Result<TaskOutcome, anyhow::Error> {
458 let Task {
459 id,
460 operator,
461 input,
462 } = task;
463 let binary_path = operators
464 .get(&operator)
465 .ok_or_else(|| anyhow::anyhow!("unsupported operator: {operator}"))?;
466 debug!("Executing task {operator} ({binary_path:?}) for {application_id}");
467 let mut child = Command::new(binary_path)
468 .stdin(std::process::Stdio::piped())
469 .stdout(std::process::Stdio::piped())
470 .spawn()?;
471
472 let mut stdin = child.stdin.take().expect("stdin should be configured");
473 stdin.write_all(input.as_bytes()).await?;
474 drop(stdin);
475
476 let output = child.wait_with_output().await?;
477 anyhow::ensure!(
478 output.status.success(),
479 "operator {} exited with status: {}",
480 operator,
481 output.status
482 );
483 let outcome = TaskOutcome {
484 id,
485 operator,
486 output: String::from_utf8_lossy(&output.stdout).into(),
487 };
488 debug!("Done executing task for {application_id}");
489 Ok(outcome)
490}
491
492async fn await_group(
495 application_id: ApplicationId,
496 group: &Option<String>,
497 mut handle: tokio::task::JoinHandle<Option<Timestamp>>,
498 config: TaskProcessorConfig,
499) -> Option<Timestamp> {
500 let on_panic = |error| {
501 error!(%application_id, ?group, %error, "Task group panicked");
502 Some(Timestamp::now().saturating_add(config.retry_delay))
503 };
504 let threshold = config.slow_group_threshold.as_duration();
505 match tokio::time::timeout(threshold, &mut handle).await {
506 Ok(result) => result.unwrap_or_else(on_panic),
507 Err(_) => {
508 warn!(
509 %application_id, ?group,
510 "Task group still running after {threshold:?}; leaving it to finish"
511 );
512 #[cfg(with_metrics)]
513 metrics::SLOW_TASK_GROUPS.inc();
514 handle.await.unwrap_or_else(on_panic)
515 }
516 }
517}
518
519fn group_tasks(tasks: Vec<Task>) -> Vec<(Option<String>, Vec<Task>)> {
524 let mut groups = BTreeMap::<Option<String>, Vec<Task>>::new();
525 for task in tasks {
526 groups.entry(task.id.clone()).or_default().push(task);
527 }
528 groups.into_iter().collect()
529}
530
531fn task_outcome_query(task_outcome: &TaskOutcome) -> String {
533 format!(
534 "query {{ processTaskOutcome(outcome: {}) }}",
535 task_outcome.to_value()
536 )
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542
543 fn outcome(id: Option<&str>, output: &str) -> TaskOutcome {
544 TaskOutcome {
545 id: id.map(str::to_string),
546 operator: "echo".to_string(),
547 output: output.to_string(),
548 }
549 }
550
551 fn task(id: Option<&str>, input: &str) -> Task {
552 Task {
553 id: id.map(str::to_string),
554 operator: "echo".to_string(),
555 input: input.to_string(),
556 }
557 }
558
559 fn inputs(groups: Vec<(Option<String>, Vec<Task>)>) -> Vec<(Option<String>, Vec<String>)> {
561 groups
562 .into_iter()
563 .map(|(group, tasks)| (group, tasks.into_iter().map(|task| task.input).collect()))
564 .collect()
565 }
566
567 fn group(id: Option<&str>, inputs: &[&str]) -> (Option<String>, Vec<String>) {
568 (
569 id.map(str::to_string),
570 inputs.iter().copied().map(str::to_string).collect(),
571 )
572 }
573
574 #[test]
575 fn test_group_tasks_keeps_distinctly_identified_tasks_apart() {
576 let tasks = vec![task(Some("1"), "first"), task(Some("2"), "second")];
577 assert_eq!(
578 inputs(group_tasks(tasks)),
579 vec![group(Some("1"), &["first"]), group(Some("2"), &["second"])]
580 );
581 }
582
583 #[test]
584 fn test_group_tasks_gathers_the_unidentified_ones() {
585 let tasks = vec![
586 task(None, "first"),
587 task(Some("1"), "second"),
588 task(None, "third"),
589 ];
590 assert_eq!(
591 inputs(group_tasks(tasks)),
592 vec![
593 group(None, &["first", "third"]),
594 group(Some("1"), &["second"])
595 ]
596 );
597 }
598
599 #[test]
600 fn test_group_tasks_gathers_the_ones_sharing_an_id() {
601 let tasks = vec![
602 task(Some("dup"), "first"),
603 task(Some("other"), "second"),
604 task(Some("dup"), "third"),
605 ];
606 assert_eq!(
607 inputs(group_tasks(tasks)),
608 vec![
609 group(Some("dup"), &["first", "third"]),
610 group(Some("other"), &["second"])
611 ]
612 );
613 }
614
615 fn operator_running(script: &str, dir: &tempfile::TempDir) -> OperatorMap {
617 use std::os::unix::fs::PermissionsExt as _;
618
619 let path = dir.path().join("operator");
620 std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap();
621 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
622 Arc::new(BTreeMap::from([("op".to_string(), path)]))
623 }
624
625 fn app_id() -> ApplicationId {
628 ApplicationId::new("00".repeat(32).parse().unwrap())
629 }
630
631 fn timed_task() -> Task {
632 Task {
633 id: None,
634 operator: "op".to_string(),
635 input: String::new(),
636 }
637 }
638
639 #[tokio::test]
640 async fn execute_task_returns_the_operator_output() {
641 let dir = tempfile::tempdir().unwrap();
642 let operators = operator_running("echo done", &dir);
643 let outcome = execute_task(app_id(), timed_task(), operators)
644 .await
645 .unwrap();
646 assert_eq!(outcome.output.trim(), "done");
647 }
648
649 #[tokio::test]
650 async fn a_group_that_outlives_the_threshold_is_reported_but_still_awaited() {
651 let expected = Some(Timestamp::from(4_242));
652 let handle = tokio::spawn(async move {
653 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
654 expected
655 });
656 let config = TaskProcessorConfig {
657 retry_delay: TimeDelta::from_secs(5),
658 slow_group_threshold: TimeDelta::from_millis(10),
660 };
661 let retry_at = await_group(app_id(), &Some("group".to_string()), handle, config).await;
664 assert_eq!(retry_at, expected);
665 }
666
667 #[test]
668 fn test_task_outcome_query() {
669 assert_eq!(
670 task_outcome_query(&outcome(None, "hello")),
671 r#"query { processTaskOutcome(outcome: {operator: "echo", output: "hello"}) }"#
672 );
673 assert_eq!(
674 task_outcome_query(&outcome(Some("42"), "hello")),
675 r#"query { processTaskOutcome(outcome: {id: "42", operator: "echo", output: "hello"}) }"#
676 );
677 }
678}