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},
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};
30
31use crate::controller::Update;
32
33/// A map from operator names to their binary paths.
34pub type OperatorMap = Arc<BTreeMap<String, PathBuf>>;
35
36/// Parse an operator mapping in the format `name=path` or just `name`.
37/// If only `name` is provided, the path defaults to the name itself.
38pub fn parse_operator(s: &str) -> Result<(String, PathBuf), String> {
39    if let Some((name, path)) = s.split_once('=') {
40        Ok((name.to_string(), PathBuf::from(path)))
41    } else {
42        Ok((s.to_string(), PathBuf::from(s)))
43    }
44}
45
46type Deadline = Reverse<(Timestamp, Option<ApplicationId>)>;
47
48/// Message sent from a background batch task to the main loop on completion.
49struct BatchResult {
50    application_id: ApplicationId,
51    /// If set, the batch failed and should be retried at this timestamp.
52    retry_at: Option<Timestamp>,
53}
54
55/// A task processor that watches applications and executes off-chain operators.
56pub struct TaskProcessor<Env: linera_core::Environment> {
57    chain_id: ChainId,
58    application_ids: Vec<ApplicationId>,
59    cursors: BTreeMap<ApplicationId, String>,
60    chain_client: ChainClient<Env>,
61    cancellation_token: CancellationToken,
62    notifications: NotificationStream,
63    batch_sender: mpsc::UnboundedSender<BatchResult>,
64    batch_receiver: mpsc::UnboundedReceiver<BatchResult>,
65    update_receiver: mpsc::UnboundedReceiver<Update>,
66    deadlines: BinaryHeap<Deadline>,
67    operators: OperatorMap,
68    retry_delay: TimeDelta,
69    in_flight_apps: BTreeSet<ApplicationId>,
70}
71
72impl<Env: linera_core::Environment> TaskProcessor<Env> {
73    /// Creates a new task processor.
74    pub fn new(
75        chain_id: ChainId,
76        application_ids: Vec<ApplicationId>,
77        chain_client: ChainClient<Env>,
78        cancellation_token: CancellationToken,
79        operators: OperatorMap,
80        retry_delay: TimeDelta,
81        update_receiver: Option<mpsc::UnboundedReceiver<Update>>,
82    ) -> Self {
83        let notifications = chain_client.subscribe().expect("client subscription");
84        let (batch_sender, batch_receiver) = mpsc::unbounded_channel();
85        let update_receiver = update_receiver.unwrap_or_else(|| mpsc::unbounded_channel().1);
86        Self {
87            chain_id,
88            application_ids,
89            cursors: BTreeMap::new(),
90            chain_client,
91            cancellation_token,
92            notifications,
93            batch_sender,
94            batch_receiver,
95            update_receiver,
96            deadlines: BinaryHeap::new(),
97            operators,
98            retry_delay,
99            in_flight_apps: BTreeSet::new(),
100        }
101    }
102
103    /// Runs the task processor until the cancellation token is triggered.
104    pub async fn run(mut self) {
105        info!("Watching for notifications for chain {}", self.chain_id);
106        self.process_actions(self.application_ids.clone()).await;
107        loop {
108            select! {
109                Some(notification) = self.notifications.next() => {
110                    if let Reason::NewBlock { .. } = notification.reason {
111                        debug!(%self.chain_id, "Processing notification");
112                        self.process_actions(self.application_ids.clone()).await;
113                    }
114                }
115                _ = tokio::time::sleep(Self::duration_until_next_deadline(&self.deadlines)) => {
116                    debug!("Processing event");
117                    let application_ids = self.process_events();
118                    self.process_actions(application_ids).await;
119                }
120                Some(result) = self.batch_receiver.recv() => {
121                    self.in_flight_apps.remove(&result.application_id);
122                    // The application could have been unassigned from this processor
123                    // in the meantime - do not retry if that is the case.
124                    if self.application_ids.contains(&result.application_id) {
125                        if let Some(retry_at) = result.retry_at {
126                            self.deadlines.push(Reverse((
127                                retry_at,
128                                Some(result.application_id),
129                            )));
130                        } else {
131                            // Re-process immediately to pick up new tasks.
132                            self.process_actions(vec![result.application_id]).await;
133                        }
134                    }
135                }
136                Some(update) = self.update_receiver.recv() => {
137                    self.apply_update(update).await;
138                }
139                _ = self.cancellation_token.cancelled().fuse() => {
140                    break;
141                }
142            }
143        }
144        debug!("Notification stream ended.");
145    }
146
147    fn duration_until_next_deadline(deadlines: &BinaryHeap<Deadline>) -> tokio::time::Duration {
148        deadlines
149            .peek()
150            .map_or(tokio::time::Duration::MAX, |Reverse((x, _))| {
151                x.delta_since(Timestamp::now()).as_duration()
152            })
153    }
154
155    async fn apply_update(&mut self, update: Update) {
156        info!(
157            "Applying update for chain {}: {:?}",
158            self.chain_id, update.application_ids
159        );
160
161        let new_app_set: BTreeSet<_> = update.application_ids.iter().cloned().collect();
162        let old_app_set: BTreeSet<_> = self.application_ids.iter().cloned().collect();
163
164        self.cursors
165            .retain(|app_id, _| new_app_set.contains(app_id));
166        self.in_flight_apps
167            .retain(|app_id| new_app_set.contains(app_id));
168
169        // Update the application_ids
170        self.application_ids = update.application_ids;
171
172        // Process actions for newly added applications
173        let new_apps = self
174            .application_ids
175            .iter()
176            .filter(|app_id| !old_app_set.contains(app_id))
177            .cloned()
178            .collect::<Vec<_>>();
179        if !new_apps.is_empty() {
180            self.process_actions(new_apps).await;
181        }
182    }
183
184    fn process_events(&mut self) -> Vec<ApplicationId> {
185        let now = Timestamp::now();
186        let mut application_ids = Vec::new();
187        while let Some(deadline) = self.deadlines.pop() {
188            if let Reverse((_, Some(id))) = deadline {
189                application_ids.push(id);
190            }
191            let Some(Reverse((ts, _))) = self.deadlines.peek() else {
192                break;
193            };
194            if *ts > now {
195                break;
196            }
197        }
198        application_ids
199    }
200
201    async fn process_actions(&mut self, application_ids: Vec<ApplicationId>) {
202        for application_id in application_ids {
203            if !self.application_ids.contains(&application_id) {
204                debug!("Skipping {application_id}: it's no longer assigned to this processor");
205                continue;
206            }
207            if self.in_flight_apps.contains(&application_id) {
208                debug!("Skipping {application_id}: tasks already in flight");
209                continue;
210            }
211            debug!("Processing actions for {application_id}");
212            let now = Timestamp::now();
213            let app_cursor = self.cursors.get(&application_id).cloned();
214            let actions = match self.query_actions(application_id, app_cursor, now).await {
215                Ok(actions) => actions,
216                Err(error) => {
217                    error!(%application_id, %error, "Error reading application actions");
218                    // Retry in at most 1 minute.
219                    self.deadlines.push(Reverse((
220                        now.saturating_add(TimeDelta::from_secs(60)),
221                        Some(application_id),
222                    )));
223                    continue;
224                }
225            };
226            if let Some(timestamp) = actions.request_callback {
227                self.deadlines
228                    .push(Reverse((timestamp, Some(application_id))));
229            }
230            if let Some(cursor) = actions.set_cursor {
231                self.cursors.insert(application_id, cursor);
232            }
233            if !actions.execute_tasks.is_empty() {
234                self.in_flight_apps.insert(application_id);
235                let chain_client = self.chain_client.clone();
236                let batch_sender = self.batch_sender.clone();
237                let retry_delay = self.retry_delay;
238                let operators = self.operators.clone();
239                tokio::spawn(async move {
240                    // Run each group concurrently, so that a slow or failing group never
241                    // delays the outcomes of the others.
242                    let mut handles = Vec::new();
243                    for (group, tasks) in group_tasks(actions.execute_tasks) {
244                        handles.push((
245                            group.clone(),
246                            tokio::spawn(Self::process_group(
247                                application_id,
248                                group,
249                                tasks,
250                                chain_client.clone(),
251                                operators.clone(),
252                                retry_delay,
253                            )),
254                        ));
255                    }
256                    // `None` sorts before any timestamp, so the maximum is the latest retry
257                    // any group asked for: a task failing on every attempt cannot shorten the
258                    // delay protecting the operator.
259                    let mut retry_at = None;
260                    for (group, handle) in handles {
261                        retry_at = retry_at.max(handle.await.unwrap_or_else(|error| {
262                            error!(%application_id, ?group, %error, "Task group panicked");
263                            Some(Timestamp::now().saturating_add(retry_delay))
264                        }));
265                    }
266                    if batch_sender
267                        .send(BatchResult {
268                            application_id,
269                            retry_at,
270                        })
271                        .is_err()
272                    {
273                        error!(%application_id, "Batch receiver dropped");
274                    }
275                });
276            }
277        }
278    }
279
280    /// Runs the tasks of one group, submitting their outcomes in order and stopping at the
281    /// first failure: the outcomes of a group are matched by position, so the application must
282    /// never see a gap in the sequence.
283    ///
284    /// Only the submissions are ordered. They contend for the chain's proposal lock, so
285    /// running a task only once its predecessor is committed would make every query wait
286    /// behind the block production of unrelated groups.
287    ///
288    /// Tasks are assumed idempotent, so whatever is left unsubmitted is recomputed by the next
289    /// call to `nextActions`. Returns the timestamp at which to retry the group, if it failed.
290    async fn process_group(
291        application_id: ApplicationId,
292        group: Option<String>,
293        tasks: Vec<Task>,
294        chain_client: ChainClient<Env>,
295        operators: OperatorMap,
296        retry_delay: TimeDelta,
297    ) -> Option<Timestamp> {
298        let mut handles = Vec::with_capacity(tasks.len());
299        for task in tasks {
300            handles.push(tokio::spawn(Self::execute_task(
301                application_id,
302                task,
303                operators.clone(),
304            )));
305        }
306        for handle in handles {
307            let outcome = match handle.await {
308                Ok(Ok(outcome)) => outcome,
309                Ok(Err(error)) => {
310                    error!(%application_id, ?group, %error, "Error executing task");
311                    return Some(Timestamp::now().saturating_add(retry_delay));
312                }
313                Err(error) => {
314                    error!(%application_id, ?group, %error, "Task panicked");
315                    return Some(Timestamp::now().saturating_add(retry_delay));
316                }
317            };
318            if let Err(timestamp) =
319                Self::submit_task_outcome(&chain_client, application_id, &outcome, retry_delay)
320                    .await
321            {
322                return Some(timestamp);
323            }
324        }
325        None
326    }
327
328    async fn execute_task(
329        application_id: ApplicationId,
330        task: Task,
331        operators: OperatorMap,
332    ) -> Result<TaskOutcome, anyhow::Error> {
333        let Task {
334            id,
335            operator,
336            input,
337        } = task;
338        let binary_path = operators
339            .get(&operator)
340            .ok_or_else(|| anyhow::anyhow!("unsupported operator: {operator}"))?;
341        debug!("Executing task {operator} ({binary_path:?}) for {application_id}");
342        let mut child = Command::new(binary_path)
343            .stdin(std::process::Stdio::piped())
344            .stdout(std::process::Stdio::piped())
345            .spawn()?;
346
347        let mut stdin = child.stdin.take().expect("stdin should be configured");
348        stdin.write_all(input.as_bytes()).await?;
349        drop(stdin);
350
351        let output = child.wait_with_output().await?;
352        anyhow::ensure!(
353            output.status.success(),
354            "operator {} exited with status: {}",
355            operator,
356            output.status
357        );
358        let outcome = TaskOutcome {
359            id,
360            operator,
361            output: String::from_utf8_lossy(&output.stdout).into(),
362        };
363        debug!("Done executing task for {application_id}");
364        Ok(outcome)
365    }
366
367    // Keeping `&mut self` avoids borrowing `TaskProcessor` through `&self` across `.await`,
368    // which would make the spawned future require `TaskProcessor: Sync`.
369    #[expect(clippy::needless_pass_by_ref_mut)]
370    async fn query_actions(
371        &mut self,
372        application_id: ApplicationId,
373        cursor: Option<String>,
374        now: Timestamp,
375    ) -> Result<ProcessorActions, anyhow::Error> {
376        let query = format!(
377            "query {{ nextActions(cursor: {}, now: {}) }}",
378            cursor.to_value(),
379            now.to_value(),
380        );
381        let bytes = serde_json::to_vec(&json!({"query": query}))?;
382        let query = linera_execution::Query::User {
383            application_id,
384            bytes,
385        };
386        let (
387            linera_execution::QueryOutcome {
388                response,
389                operations: _,
390            },
391            _,
392        ) = self.chain_client.query_application(query, None).await?;
393        let linera_execution::QueryResponse::User(response) = response else {
394            anyhow::bail!("cannot get a system response for a user query");
395        };
396        let mut response: serde_json::Value = serde_json::from_slice(&response)?;
397        let actions: ProcessorActions =
398            serde_json::from_value(response["data"]["nextActions"].take())?;
399        Ok(actions)
400    }
401
402    /// Submits a task outcome on-chain. On success returns `Ok(())`. On failure, logs the
403    /// error and returns `Err(retry_at)` with the timestamp at which to retry.
404    async fn submit_task_outcome(
405        chain_client: &ChainClient<Env>,
406        application_id: ApplicationId,
407        task_outcome: &TaskOutcome,
408        retry_delay: TimeDelta,
409    ) -> Result<(), Timestamp> {
410        info!("Submitting task outcome for {application_id}: {task_outcome:?}");
411        // An outcome's id is the group it belongs to.
412        let group = &task_outcome.id;
413        let retry_with_delay = || Timestamp::now().saturating_add(retry_delay);
414        let query = task_outcome_query(task_outcome);
415        let bytes = serde_json::to_vec(&json!({"query": query})).map_err(|error| {
416            error!(%application_id, ?group, %error, "Error serializing task outcome query");
417            retry_with_delay()
418        })?;
419        let query = linera_execution::Query::User {
420            application_id,
421            bytes,
422        };
423        let (
424            linera_execution::QueryOutcome {
425                response: _,
426                operations,
427            },
428            _,
429        ) = chain_client
430            .query_application(query, None)
431            .await
432            .map_err(|error| {
433                error!(%application_id, ?group, %error, "Error querying application");
434                retry_with_delay()
435            })?;
436        if !operations.is_empty() {
437            match chain_client
438                .execute_operations(operations, vec![])
439                .await
440                .map_err(|error| {
441                    error!(%application_id, ?group, %error, "Error executing operations");
442                    retry_with_delay()
443                })? {
444                ClientOutcome::Committed(_) => {}
445                ClientOutcome::WaitForTimeout(timeout) => {
446                    error!(%application_id, ?group, "Not the round leader, retrying after {}", timeout.timestamp);
447                    return Err(timeout.timestamp);
448                }
449                ClientOutcome::Conflict(_) => {
450                    debug!(%application_id, ?group, "Block conflict, retrying immediately");
451                    return Err(Timestamp::now());
452                }
453            }
454        }
455        Ok(())
456    }
457}
458
459/// Groups the tasks of a batch by id, keeping their relative order.
460///
461/// Tasks sharing an id, and all the tasks without one, can only be told apart by position, so
462/// they belong to the same group. A distinctly identified task is a group of its own.
463fn group_tasks(tasks: Vec<Task>) -> Vec<(Option<String>, Vec<Task>)> {
464    let mut groups = BTreeMap::<Option<String>, Vec<Task>>::new();
465    for task in tasks {
466        groups.entry(task.id.clone()).or_default().push(task);
467    }
468    groups.into_iter().collect()
469}
470
471/// Builds the GraphQL query submitting `task_outcome` to its application.
472fn task_outcome_query(task_outcome: &TaskOutcome) -> String {
473    format!(
474        "query {{ processTaskOutcome(outcome: {}) }}",
475        task_outcome.to_value()
476    )
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    fn outcome(id: Option<&str>, output: &str) -> TaskOutcome {
484        TaskOutcome {
485            id: id.map(str::to_string),
486            operator: "echo".to_string(),
487            output: output.to_string(),
488        }
489    }
490
491    fn task(id: Option<&str>, input: &str) -> Task {
492        Task {
493            id: id.map(str::to_string),
494            operator: "echo".to_string(),
495            input: input.to_string(),
496        }
497    }
498
499    /// The inputs of each group, keyed by the group's id.
500    fn inputs(groups: Vec<(Option<String>, Vec<Task>)>) -> Vec<(Option<String>, Vec<String>)> {
501        groups
502            .into_iter()
503            .map(|(group, tasks)| (group, tasks.into_iter().map(|task| task.input).collect()))
504            .collect()
505    }
506
507    fn group(id: Option<&str>, inputs: &[&str]) -> (Option<String>, Vec<String>) {
508        (
509            id.map(str::to_string),
510            inputs.iter().copied().map(str::to_string).collect(),
511        )
512    }
513
514    #[test]
515    fn test_group_tasks_keeps_distinctly_identified_tasks_apart() {
516        let tasks = vec![task(Some("1"), "first"), task(Some("2"), "second")];
517        assert_eq!(
518            inputs(group_tasks(tasks)),
519            vec![group(Some("1"), &["first"]), group(Some("2"), &["second"])]
520        );
521    }
522
523    #[test]
524    fn test_group_tasks_gathers_the_unidentified_ones() {
525        let tasks = vec![
526            task(None, "first"),
527            task(Some("1"), "second"),
528            task(None, "third"),
529        ];
530        assert_eq!(
531            inputs(group_tasks(tasks)),
532            vec![
533                group(None, &["first", "third"]),
534                group(Some("1"), &["second"])
535            ]
536        );
537    }
538
539    #[test]
540    fn test_group_tasks_gathers_the_ones_sharing_an_id() {
541        let tasks = vec![
542            task(Some("dup"), "first"),
543            task(Some("other"), "second"),
544            task(Some("dup"), "third"),
545        ];
546        assert_eq!(
547            inputs(group_tasks(tasks)),
548            vec![
549                group(Some("dup"), &["first", "third"]),
550                group(Some("other"), &["second"])
551            ]
552        );
553    }
554
555    #[test]
556    fn test_task_outcome_query() {
557        assert_eq!(
558            task_outcome_query(&outcome(None, "hello")),
559            r#"query { processTaskOutcome(outcome: {operator: "echo", output: "hello"}) }"#
560        );
561        assert_eq!(
562            task_outcome_query(&outcome(Some("42"), "hello")),
563            r#"query { processTaskOutcome(outcome: {id: "42", operator: "echo", output: "hello"}) }"#
564        );
565    }
566}