Skip to main content

linera_service/
task_processor.rs

1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Task processor for executing off-chain operators on behalf of on-chain applications.
5//!
6//! The task processor watches specified applications for requests to execute off-chain tasks,
7//! runs external operator binaries, and submits the results back to the chain.
8
9use 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        /// Task groups that have outlived [`TaskProcessorConfig::slow_group_threshold`].
40        ///
41        /// A group counted here is still running and may still succeed. It is worth alerting on
42        /// because nothing else reports it: the process stays healthy while the chain the group
43        /// serves stops advancing, so a group that never returns is otherwise silent.
44        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
51/// A map from operator names to their binary paths.
52pub type OperatorMap = Arc<BTreeMap<String, PathBuf>>;
53
54/// Parse an operator mapping in the format `name=path` or just `name`.
55/// If only `name` is provided, the path defaults to the name itself.
56pub 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/// Timing limits applied to operator tasks.
67#[derive(Clone, Copy, Debug)]
68pub struct TaskProcessorConfig {
69    /// How long to wait before retrying a task group that failed.
70    pub retry_delay: TimeDelta,
71    /// How long a task group may run before it is reported as slow. The group keeps running.
72    pub slow_group_threshold: TimeDelta,
73}
74
75/// Message sent from a background task group to the main loop on completion.
76struct GroupResult {
77    application_id: ApplicationId,
78    /// The group's id, as returned by [`group_tasks`].
79    group: Option<String>,
80    /// If set, the group failed and should be retried at this timestamp.
81    retry_at: Option<Timestamp>,
82}
83
84/// A task processor that watches applications and executes off-chain operators.
85pub 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    /// The groups currently running, so that a second copy is never started while one is in
99    /// flight.
100    in_flight_groups: BTreeSet<(ApplicationId, Option<String>)>,
101}
102
103impl<Env: linera_core::Environment> TaskProcessor<Env> {
104    /// Creates a new task processor.
105    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    /// Runs the task processor until the cancellation token is triggered.
135    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                    // The application could have been unassigned from this processor
154                    // in the meantime - do not retry if that is the case.
155                    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                            // Re-process immediately to pick up new tasks.
163                            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        // Update the application_ids
201        self.application_ids = update.application_ids;
202
203        // Process actions for newly added applications
204        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    /// Returns the applications whose deadlines have come, ordered by deadline and without
216    /// repetitions.
217    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                    // Retry in at most 1 minute.
251                    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            // Start each group that is not already running. Group outcomes commute, so a group
266            // that is slow, stuck or failing neither delays the others nor keeps this application
267            // from being polled again for their sake.
268            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                    // Run the group on its own task: a panic inside it must surface here and
282                    // become a retry. An unreported panic sends no result message, and the group
283                    // then stays marked in flight forever.
284                    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    /// Runs the tasks of one group, submitting their outcomes in order and stopping at the
309    /// first failure: the outcomes of a group are matched by position, so the application must
310    /// never see a gap in the sequence.
311    ///
312    /// Only the submissions are ordered. They contend for the chain's proposal lock, so
313    /// running a task only once its predecessor is committed would make every query wait
314    /// behind the block production of unrelated groups.
315    ///
316    /// Tasks are assumed idempotent, so whatever is left unsubmitted is recomputed by the next
317    /// call to `nextActions`. Returns the timestamp at which to retry the group, if it failed.
318    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    // Keeping `&mut self` avoids borrowing `TaskProcessor` through `&self` across `.await`,
361    // which would make the spawned future require `TaskProcessor: Sync`.
362    #[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    /// Submits a task outcome on-chain. On success returns `Ok(())`. On failure, logs the
396    /// error and returns `Err(retry_at)` with the timestamp at which to retry.
397    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        // An outcome's id is the group it belongs to.
405        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
452/// Runs one task's operator binary to completion.
453async 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
492/// Awaits a task group, reporting it once if it outlives `slow_group_threshold` and then
493/// continuing to wait for it.
494async 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
519/// Groups the tasks of a batch by id, keeping their relative order.
520///
521/// Tasks sharing an id, and all the tasks without one, can only be told apart by position, so
522/// they belong to the same group. A distinctly identified task is a group of its own.
523fn 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
531/// Builds the GraphQL query submitting `task_outcome` to its application.
532fn 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    /// The inputs of each group, keyed by the group's id.
560    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    /// Writes an executable shell script and returns an operator map pointing at it.
616    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    /// A syntactically valid application id. `CryptoHash`'s `FromStr` is hex over 32 bytes and,
626    /// unlike `test_hash`, is not gated behind `with_testing`.
627    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            // Far below what the group takes, so the slow path is the one exercised.
659            slow_group_threshold: TimeDelta::from_millis(10),
660        };
661        // Crossing the threshold reports the group without cutting it short, so the value it
662        // produces still comes back.
663        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}