Skip to main content

planetary_db/
postgres.rs

1//! Implementation of a TES database using PostgreSQL.
2
3use std::time::Duration;
4
5use anyhow::Context;
6use anyhow::Result;
7use anyhow::anyhow;
8use chrono::DateTime;
9use chrono::Utc;
10use diesel::Connection;
11use diesel_async::AsyncConnection;
12use diesel_async::AsyncPgConnection;
13use diesel_async::pooled_connection::AsyncDieselConnectionManager;
14use diesel_async::pooled_connection::deadpool::Pool;
15use diesel_migrations::EmbeddedMigrations;
16use diesel_migrations::HarnessWithOutput;
17use diesel_migrations::MigrationHarness;
18use diesel_migrations::embed_migrations;
19use futures::future::BoxFuture;
20use secrecy::ExposeSecret;
21use secrecy::SecretString;
22use tes::v1::types::requests::DEFAULT_PAGE_SIZE;
23use tes::v1::types::requests::GetTaskParams;
24use tes::v1::types::requests::ListTasksParams;
25use tes::v1::types::requests::Task as RequestTask;
26use tes::v1::types::requests::View;
27use tes::v1::types::responses::ExecutorLog;
28use tes::v1::types::responses::OutputFile;
29use tes::v1::types::responses::Task;
30use tes::v1::types::responses::TaskLog;
31use tes::v1::types::responses::TaskResponse;
32use tes::v1::types::task::State;
33use tracing::debug;
34use tracing::info;
35
36use super::Database;
37use super::DatabaseResult;
38use crate::TaskTemplateData;
39use crate::TerminatedContainer;
40use crate::normalize_cpu_seconds;
41
42pub(crate) mod models;
43
44/// Loads a batch of tasks' per-container resource usage, grouped by task.
45///
46/// The result is ordered to match `tasks` (via [`GroupedBy::grouped_by`]),
47/// so it can be `zip`-ed directly against it.
48macro_rules! load_container_usage {
49    ($conn:expr, $tasks:expr) => {{
50        use diesel::*;
51        use diesel_async::RunQueryDsl;
52
53        models::ContainerUsage::belonging_to($tasks)
54            .select(models::ContainerUsage::as_select())
55            .order_by(schema::task_container_usage::container_name)
56            .load($conn)
57            .await
58            .map_err(Error::Diesel)?
59            .grouped_by($tasks)
60    }};
61}
62
63/// Loads a single task's per-container resource usage.
64macro_rules! load_task_container_usage {
65    ($conn:expr, $task:expr) => {{
66        use diesel::*;
67        use diesel_async::RunQueryDsl;
68
69        models::ContainerUsage::belonging_to($task)
70            .select(models::ContainerUsage::as_select())
71            .order_by(schema::task_container_usage::container_name)
72            .load($conn)
73            .await
74            .map_err(Error::Diesel)?
75    }};
76}
77
78#[allow(clippy::missing_docs_in_private_items)]
79pub(crate) mod schema;
80mod usage;
81
82/// Used to embed the migrations into the binary so they can be applied at
83/// runtime.
84const MIGRATIONS: EmbeddedMigrations = embed_migrations!("src/postgres/migrations");
85
86/// The interval between attempts to retain unexpired connections from the
87/// connection pool.
88const POOL_RETAIN_INTERVAL: Duration = Duration::from_secs(30);
89
90/// The maximum age a database connection will remain in the pool since it was
91/// last used.
92const MAX_CONNECTION_AGE: Duration = Duration::from_secs(60);
93
94/// The maximum number of connections in the connection pool.
95///
96/// This is currently a fixed-size limit as we keep a connection pool per-pod.
97const MAX_POOL_SIZE: usize = 10;
98
99/// Folds observed memory samples into per-container usage aggregates.
100const MEMORY_USAGE_FOLD_SQL: &str = r#"
101    INSERT INTO task_container_usage (
102        task_id,
103        container_name,
104        peak_memory_bytes,
105        memory_total_bytes,
106        memory_sample_count
107    )
108    SELECT
109        tasks.id,
110        samples.container_name,
111        MAX(samples.memory_bytes),
112        SUM(samples.memory_bytes),
113        COUNT(samples.memory_bytes)
114    FROM UNNEST($1::text[], $2::text[], $3::bigint[])
115        AS samples(tes_id, container_name, memory_bytes)
116    JOIN tasks ON tasks.tes_id = samples.tes_id
117    WHERE samples.memory_bytes IS NOT NULL
118    GROUP BY tasks.id, samples.container_name
119    ORDER BY tasks.id, samples.container_name
120    ON CONFLICT (task_id, container_name) DO UPDATE SET
121        peak_memory_bytes = GREATEST(
122            COALESCE(task_container_usage.peak_memory_bytes, 0),
123            EXCLUDED.peak_memory_bytes
124        ),
125        memory_total_bytes =
126            COALESCE(task_container_usage.memory_total_bytes, 0)
127            + EXCLUDED.memory_total_bytes,
128        memory_sample_count =
129            COALESCE(task_container_usage.memory_sample_count, 0)
130            + EXCLUDED.memory_sample_count
131"#;
132
133/// Folds CPU counter deltas into usage aggregates and advances their baselines.
134const CPU_USAGE_FOLD_SQL: &str = r#"
135    WITH samples AS (
136        SELECT
137            tasks.id AS task_id,
138            observations.pod_name,
139            observations.container_name,
140            observations.cpu_seconds,
141            observations.start_time_seconds
142        FROM UNNEST($1::text[], $2::text[], $3::text[], $4::float8[], $5::float8[])
143            AS observations(
144                tes_id,
145                pod_name,
146                container_name,
147                cpu_seconds,
148                start_time_seconds
149            )
150        JOIN tasks ON tasks.tes_id = observations.tes_id
151        WHERE observations.cpu_seconds IS NOT NULL
152    ),
153    deltas AS (
154        SELECT
155            samples.*,
156            CASE
157                WHEN baseline.cpu_seconds IS NULL THEN samples.cpu_seconds
158                WHEN baseline.start_time_seconds
159                        IS NOT DISTINCT FROM samples.start_time_seconds
160                    AND samples.cpu_seconds >= baseline.cpu_seconds
161                    THEN samples.cpu_seconds - baseline.cpu_seconds
162                ELSE samples.cpu_seconds
163            END AS cpu_delta_seconds
164        FROM samples
165        LEFT JOIN task_container_baseline AS baseline
166            USING (task_id, pod_name, container_name)
167    ),
168    -- Baseline advancement and aggregation must stay atomic for retry idempotence.
169    advanced AS (
170        INSERT INTO task_container_baseline (
171            task_id,
172            pod_name,
173            container_name,
174            start_time_seconds,
175            cpu_seconds
176        )
177        SELECT
178            task_id,
179            pod_name,
180            container_name,
181            start_time_seconds,
182            cpu_seconds
183        FROM deltas
184        ON CONFLICT (task_id, pod_name, container_name) DO UPDATE SET
185            start_time_seconds = EXCLUDED.start_time_seconds,
186            cpu_seconds = EXCLUDED.cpu_seconds
187    )
188    INSERT INTO task_container_usage (
189        task_id,
190        container_name,
191        cpu_seconds
192    )
193    SELECT
194        task_id,
195        container_name,
196        SUM(cpu_delta_seconds)
197    FROM deltas
198    -- Multiple pods can map to one row, so group before ON CONFLICT.
199    GROUP BY task_id, container_name
200    ORDER BY task_id, container_name
201    ON CONFLICT (task_id, container_name) DO UPDATE SET
202        cpu_seconds =
203            COALESCE(task_container_usage.cpu_seconds, 0.0)
204            + EXCLUDED.cpu_seconds
205"#;
206
207/// Helper for zipping two uneven iterators.
208///
209/// The shorter iterator will yield default values after it terminates.
210fn zip_longest<A, B>(a: A, b: B) -> impl Iterator<Item = (A::Item, B::Item)>
211where
212    A: IntoIterator,
213    A::Item: Default,
214    B: IntoIterator,
215    B::Item: Default,
216{
217    let mut a = a.into_iter();
218    let mut b = b.into_iter();
219    std::iter::from_fn(move || match (a.next(), b.next()) {
220        (None, None) => None,
221        (a, b) => Some((a.unwrap_or_default(), b.unwrap_or_default())),
222    })
223}
224
225/// Formats the Postgres database URL.
226pub fn format_database_url(
227    user: &str,
228    password: &SecretString,
229    host: &str,
230    port: i32,
231    database_name: &str,
232    app_name: &str,
233) -> String {
234    format!(
235        "postgres://{user}:{password}@{host}:{port}/{database_name}?application_name={app_name}",
236        password = password.expose_secret(),
237    )
238}
239
240/// Represents a PostgreSQL database error.
241#[derive(Debug, thiserror::Error)]
242pub enum Error {
243    /// The provided TES task identifier was not found.
244    #[error("task `{0}` was not found")]
245    TaskNotFound(String),
246    /// A diesel connection pool error occurred.
247    #[error(transparent)]
248    Pool(#[from] diesel_async::pooled_connection::deadpool::PoolError),
249    /// A diesel error occurred.
250    #[error(transparent)]
251    Diesel(#[from] diesel::result::Error),
252}
253
254/// Converts a task model into a TES task.
255fn into_task<T, C>(task: T, containers: Vec<C>, usage: Vec<models::ContainerUsage>) -> Task
256where
257    T: Into<(Task, Vec<OutputFile>, Vec<String>)>,
258    C: Into<ExecutorLog>,
259{
260    let (mut task, outputs, system_logs) = task.into();
261    let metadata = usage::resource_usage_metadata(&usage);
262    let executor_logs: Vec<_> = containers.into_iter().map(Into::into).collect();
263
264    if !outputs.is_empty()
265        || !executor_logs.is_empty()
266        || !system_logs.is_empty()
267        || metadata.is_some()
268    {
269        let start_time = executor_logs.first().and_then(|e| e.start_time);
270        let end_time = executor_logs.last().and_then(|e| e.end_time);
271
272        task.logs = Some(vec![TaskLog {
273            logs: executor_logs,
274            metadata,
275            start_time,
276            end_time,
277            outputs,
278            system_logs: if system_logs.is_empty() {
279                None
280            } else {
281                Some(system_logs)
282            },
283        }]);
284    }
285
286    task
287}
288
289/// Implements a planetary database using a PostgreSQL server.
290pub struct PostgresDatabase {
291    /// The database URL.
292    url: SecretString,
293    /// The database connection pool.
294    pool: Pool<AsyncPgConnection>,
295}
296
297impl PostgresDatabase {
298    /// Constructs a new PostgreSQL database with the given database URL.
299    pub fn new(url: SecretString) -> Result<Self> {
300        let config = AsyncDieselConnectionManager::new(url.expose_secret());
301        debug!("creating database connection pool with {MAX_POOL_SIZE} slots");
302
303        let pool = Pool::builder(config)
304            .max_size(MAX_POOL_SIZE)
305            .build()
306            .context("failed to initialize PostgreSQL connection pool")?;
307
308        let p = pool.clone();
309
310        // Span a task that is responsible for removing connections from the
311        // pool that exceed
312        tokio::spawn(async move {
313            loop {
314                tokio::time::sleep(POOL_RETAIN_INTERVAL).await;
315
316                let res = p.retain(|_, metrics| metrics.last_used() < MAX_CONNECTION_AGE);
317
318                debug!(
319                    "removed {removed} and retained {retained} connections(s) from the database \
320                     connection pool",
321                    removed = res.removed.len(),
322                    retained = res.retained
323                );
324            }
325        });
326
327        Ok(Self { url, pool })
328    }
329
330    /// Runs any pending migrations for the database.
331    pub async fn run_pending_migrations(&self) -> Result<()> {
332        struct Writer;
333        impl std::io::Write for Writer {
334            fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
335                let buf = String::from_utf8_lossy(buf);
336                info!("{buf}", buf = buf.trim_end());
337                Ok(buf.len())
338            }
339
340            fn flush(&mut self) -> std::io::Result<()> {
341                Ok(())
342            }
343        }
344
345        // Required to use a direct connection here as `diesel-migration`
346        // doesn't support async
347        let mut conn = diesel::pg::PgConnection::establish(self.url.expose_secret())?;
348        HarnessWithOutput::new(&mut conn, std::io::LineWriter::new(Writer))
349            .run_pending_migrations(MIGRATIONS)
350            .map_err(|e| anyhow!("failed to run pending database migrations: {e}"))?;
351
352        Ok(())
353    }
354}
355
356#[async_trait::async_trait]
357impl Database for PostgresDatabase {
358    async fn insert_task(&self, username: &str, task: &RequestTask) -> DatabaseResult<String> {
359        use diesel_async::RunQueryDsl;
360
361        let task = models::NewTask::new(username, task);
362
363        // Insert the task
364        let mut conn = self.pool.get().await.map_err(Error::Pool)?;
365        diesel::insert_into(schema::tasks::table)
366            .values(&task)
367            .execute(&mut conn)
368            .await
369            .map_err(Error::Diesel)?;
370
371        Ok(task.tes_id)
372    }
373
374    async fn get_task(
375        &self,
376        username: &str,
377        tes_id: &str,
378        params: GetTaskParams,
379    ) -> DatabaseResult<TaskResponse> {
380        use diesel::*;
381        use diesel_async::RunQueryDsl;
382
383        let mut conn = self.pool.get().await.map_err(Error::Pool)?;
384
385        match params.view {
386            View::Minimal => Ok(TaskResponse::Minimal(
387                schema::tasks::table
388                    .select(models::MinimalTask::as_select())
389                    .filter(
390                        schema::tasks::username
391                            .eq(username)
392                            .and(schema::tasks::tes_id.eq(tes_id)),
393                    )
394                    .first(&mut conn)
395                    .await
396                    .optional()
397                    .map_err(Error::Diesel)?
398                    .ok_or_else(|| Error::TaskNotFound(tes_id.to_string()))?
399                    .into(),
400            )),
401            View::Basic => {
402                let task = schema::tasks::table
403                    .select(models::BasicTask::as_select())
404                    .filter(
405                        schema::tasks::username
406                            .eq(username)
407                            .and(schema::tasks::tes_id.eq(tes_id)),
408                    )
409                    .first(&mut conn)
410                    .await
411                    .optional()
412                    .map_err(Error::Diesel)?
413                    .ok_or_else(|| Error::TaskNotFound(tes_id.to_string()))?;
414
415                let containers = models::BasicContainer::belonging_to(&task)
416                    .select(models::BasicContainer::as_select())
417                    .filter(schema::containers::executor_index.is_not_null())
418                    .order_by(schema::containers::executor_index)
419                    .load(&mut conn)
420                    .await
421                    .map_err(Error::Diesel)?;
422
423                let usage = load_task_container_usage!(&mut conn, &task);
424
425                Ok(TaskResponse::Basic(into_task(task, containers, usage)))
426            }
427            View::Full => {
428                let task = schema::tasks::table
429                    .select(models::FullTask::as_select())
430                    .filter(
431                        schema::tasks::username
432                            .eq(username)
433                            .and(schema::tasks::tes_id.eq(tes_id)),
434                    )
435                    .first(&mut conn)
436                    .await
437                    .optional()
438                    .map_err(Error::Diesel)?
439                    .ok_or_else(|| Error::TaskNotFound(tes_id.to_string()))?;
440
441                let containers = models::FullContainer::belonging_to(&task)
442                    .select(models::FullContainer::as_select())
443                    .filter(schema::containers::executor_index.is_not_null())
444                    .order_by(schema::containers::executor_index)
445                    .load(&mut conn)
446                    .await
447                    .map_err(Error::Diesel)?;
448
449                let usage = load_task_container_usage!(&mut conn, &task);
450
451                Ok(TaskResponse::Full(into_task(task, containers, usage)))
452            }
453        }
454    }
455
456    async fn get_task_template_data(&self, tes_id: &str) -> DatabaseResult<TaskTemplateData> {
457        use diesel::*;
458        use diesel_async::RunQueryDsl;
459
460        let mut conn = self.pool.get().await.map_err(Error::Pool)?;
461        Ok(schema::tasks::table
462            .select(models::TaskTemplateData::as_select())
463            .filter(schema::tasks::tes_id.eq(tes_id))
464            .first(&mut conn)
465            .await
466            .optional()
467            .map_err(Error::Diesel)?
468            .ok_or_else(|| Error::TaskNotFound(tes_id.to_string()))?
469            .into())
470    }
471
472    async fn get_tasks(
473        &self,
474        username: &str,
475        params: ListTasksParams,
476    ) -> DatabaseResult<(Vec<TaskResponse>, Option<String>)> {
477        use diesel::*;
478        use diesel_async::RunQueryDsl;
479
480        let mut query = schema::tasks::table.into_boxed();
481
482        query = query.filter(schema::tasks::username.eq(username));
483
484        // Add the name prefix to the query
485        if let Some(prefix) = &params.name_prefix {
486            query = query.filter(schema::tasks::name.like(format!("{prefix}%")));
487        }
488
489        // Add the state to the query
490        if let Some(state) = params.state {
491            query = query.filter(schema::tasks::state.eq(models::TaskState::from(state)));
492        }
493
494        // Add the page token to the query
495        let offset = if let Some(page_token) = params.page_token {
496            let offset: i64 = page_token
497                .parse()
498                .map_err(|_| super::Error::InvalidPageToken(page_token.clone()))?;
499
500            if offset < 0 {
501                return Err(super::Error::InvalidPageToken(page_token));
502            }
503
504            query = query.offset(offset);
505            offset
506        } else {
507            0
508        };
509
510        // Add the tags to the query
511        for (k, v) in zip_longest(
512            params.tag_keys.unwrap_or_default(),
513            params.tag_values.unwrap_or_default(),
514        ) {
515            if !v.is_empty() {
516                query = query.filter(
517                    schema::tasks::tags.contains(models::Json(models::TagFilter::new(k, v))),
518                );
519            } else {
520                query = query.filter(schema::tasks::tags.has_key(k));
521            }
522        }
523
524        // Add the page size to the query and order by the id
525        let page_size = params.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
526        query = query.limit(page_size as i64).order_by(schema::tasks::id);
527
528        let mut conn = self.pool.get().await.map_err(Error::Pool)?;
529
530        match params.view.unwrap_or_default() {
531            View::Minimal => {
532                let tasks = query
533                    .select(models::MinimalTask::as_select())
534                    .load(&mut conn)
535                    .await
536                    .map_err(Error::Diesel)?;
537
538                let token = if tasks.len() < page_size as usize {
539                    None
540                } else {
541                    Some((offset as usize + tasks.len()).to_string())
542                };
543
544                Ok((
545                    tasks
546                        .into_iter()
547                        .map(|t| TaskResponse::Minimal(t.into()))
548                        .collect(),
549                    token,
550                ))
551            }
552            View::Basic => {
553                let tasks: Vec<_> = query
554                    .select(models::BasicTask::as_select())
555                    .load(&mut conn)
556                    .await
557                    .map_err(Error::Diesel)?
558                    .into_iter()
559                    .collect();
560
561                let token = if tasks.len() < page_size as usize {
562                    None
563                } else {
564                    Some((offset as usize + tasks.len()).to_string())
565                };
566
567                let usage = load_container_usage!(&mut conn, &tasks);
568
569                Ok((
570                    models::BasicContainer::belonging_to(&tasks)
571                        .select(models::BasicContainer::as_select())
572                        .filter(schema::containers::executor_index.is_not_null())
573                        .order_by(schema::containers::executor_index)
574                        .load(&mut conn)
575                        .await
576                        .map_err(Error::Diesel)?
577                        .grouped_by(&tasks)
578                        .into_iter()
579                        .zip(usage)
580                        .zip(tasks)
581                        .map(|((containers, usage), task)| {
582                            TaskResponse::Basic(into_task(task, containers, usage))
583                        })
584                        .collect(),
585                    token,
586                ))
587            }
588            View::Full => {
589                let tasks: Vec<_> = query
590                    .select(models::FullTask::as_select())
591                    .load(&mut conn)
592                    .await
593                    .map_err(Error::Diesel)?
594                    .into_iter()
595                    .collect();
596
597                let token = if tasks.len() < page_size as usize {
598                    None
599                } else {
600                    Some((offset as usize + tasks.len()).to_string())
601                };
602
603                let usage = load_container_usage!(&mut conn, &tasks);
604
605                Ok((
606                    models::FullContainer::belonging_to(&tasks)
607                        .select(models::FullContainer::as_select())
608                        .filter(schema::containers::executor_index.is_not_null())
609                        .order_by(schema::containers::executor_index)
610                        .load(&mut conn)
611                        .await
612                        .map_err(Error::Diesel)?
613                        .grouped_by(&tasks)
614                        .into_iter()
615                        .zip(usage)
616                        .zip(tasks)
617                        .map(|((containers, usage), task)| {
618                            TaskResponse::Full(into_task(task, containers, usage))
619                        })
620                        .collect(),
621                    token,
622                ))
623            }
624        }
625    }
626
627    async fn get_in_progress_tasks(&self, before: DateTime<Utc>) -> DatabaseResult<Vec<String>> {
628        use diesel::pg::sql_types::Timestamptz;
629        use diesel::*;
630        use diesel_async::RunQueryDsl;
631        use models::TaskState;
632
633        let mut conn = self.pool.get().await.map_err(Error::Pool)?;
634
635        Ok(schema::tasks::table
636            .select(schema::tasks::tes_id)
637            .filter(
638                schema::tasks::state
639                    .eq_any(&[
640                        TaskState::Unknown,
641                        TaskState::Queued,
642                        TaskState::Initializing,
643                        TaskState::Running,
644                    ])
645                    .and(schema::tasks::creation_time.le(before.into_sql::<Timestamptz>())),
646            )
647            .get_results(&mut conn)
648            .await
649            .map_err(Error::Diesel)?)
650    }
651
652    async fn update_task_state<'a>(
653        &self,
654        tes_id: &str,
655        state: State,
656        messages: &[&str],
657        containers: Option<BoxFuture<'a, Result<Vec<TerminatedContainer<'a>>>>>,
658        outputs: Option<&[OutputFile]>,
659    ) -> DatabaseResult<bool> {
660        use diesel::pg::sql_types::Array;
661        use diesel::sql_types::Text;
662        use diesel::*;
663        use diesel_async::RunQueryDsl;
664        use models::TaskState;
665
666        /// Helper for getting the id for an updated task.
667        /// This is required because `sql_query` returns data by name, not
668        /// index.
669        #[derive(QueryableByName)]
670        #[diesel(table_name = schema::tasks)]
671        #[diesel(check_for_backend(diesel::pg::Pg))]
672        struct UpdatedTask {
673            /// The id of the updated task.
674            id: i32,
675        }
676
677        // Determine the allowed previous state for the task.
678        let previous: &[TaskState] = match state {
679            // Unknown has no previous state and paused isn't supported
680            State::Unknown | State::Paused => {
681                return Ok(false);
682            }
683            // Unknown -> Queued
684            State::Queued => &[TaskState::Unknown],
685            // [Unknown | Queued] -> Initializing
686            State::Initializing => &[TaskState::Unknown, TaskState::Queued],
687            // [Unknown | Queued | Initializing] -> Running
688            State::Running => &[
689                TaskState::Unknown,
690                TaskState::Queued,
691                TaskState::Initializing,
692            ],
693            // [Unknown | Queued | Initializing | Running] -> [Complete | ExecutorError]
694            State::Complete | State::ExecutorError => &[
695                TaskState::Unknown,
696                TaskState::Queued,
697                TaskState::Initializing,
698                TaskState::Running,
699            ],
700            // [Unknown | Queued | Initializing | Running] -> [SystemError | Canceling]
701            State::SystemError | State::Canceling => &[
702                TaskState::Unknown,
703                TaskState::Queued,
704                TaskState::Initializing,
705                TaskState::Running,
706            ],
707            // Canceling -> Canceled
708            State::Canceled => &[TaskState::Canceling],
709            // [Unknown | Queued | Initializing | Running] -> Preempted
710            State::Preempted => &[
711                TaskState::Unknown,
712                TaskState::Queued,
713                TaskState::Initializing,
714                TaskState::Running,
715            ],
716        };
717
718        let mut conn = self.pool.get().await.map_err(Error::Pool)?;
719
720        let updated = conn
721            .transaction(async |conn| {
722                // TODO: currently diesel hasn't released support for the
723                // PostgreSQL `array_cat` function; remove the
724                // raw query when diesel supports it
725                let updated: Option<UpdatedTask> = sql_query(
726                    "UPDATE tasks SET state = $1, system_logs = array_cat(system_logs, $2) WHERE \
727                     tes_id = $3 AND state = ANY ($4) RETURNING id",
728                )
729                .bind::<schema::sql_types::TaskState, _>(TaskState::from(state))
730                .bind::<Array<Text>, _>(messages)
731                .bind::<Text, _>(tes_id)
732                .bind::<Array<schema::sql_types::TaskState>, _>(previous)
733                .get_result(conn)
734                .await
735                .optional()
736                .map_err(Error::Diesel)?;
737
738                match updated {
739                    Some(UpdatedTask { id }) => {
740                        if let Some(outputs) = outputs {
741                            diesel::update(schema::tasks::table)
742                                .filter(
743                                    schema::tasks::tes_id
744                                        .eq(tes_id)
745                                        .and(schema::tasks::output_files.is_null()),
746                                )
747                                .set(schema::tasks::output_files.eq(models::Json(outputs)))
748                                .execute(conn)
749                                .await
750                                .map_err(Error::Diesel)?;
751                        }
752
753                        if let Some(containers) = containers {
754                            // Insert the containers
755                            let containers = containers.await?;
756                            diesel::insert_into(schema::containers::table)
757                                .values(
758                                    containers
759                                        .into_iter()
760                                        .map(|c| models::NewContainer::new(id, c))
761                                        .collect::<Vec<_>>(),
762                                )
763                                .on_conflict_do_nothing()
764                                .execute(conn)
765                                .await
766                                .map_err(Error::Diesel)?;
767                        }
768
769                        anyhow::Ok(true)
770                    }
771                    None => Ok(false),
772                }
773            })
774            .await?;
775
776        Ok(updated)
777    }
778
779    async fn append_system_log(&self, tes_id: &str, messages: &[&str]) -> DatabaseResult<()> {
780        use diesel::pg::sql_types::Array;
781        use diesel::sql_types::Text;
782        use diesel::*;
783        use diesel_async::RunQueryDsl;
784
785        let mut conn = self.pool.get().await.map_err(Error::Pool)?;
786
787        // Append the log entry
788        // TODO: currently diesel hasn't released support for the PostgreSQL
789        // `array_cat` function; remove the raw query when diesel supports it
790        sql_query("UPDATE tasks SET system_logs = array_cat(system_logs, $1) WHERE tes_id = $2")
791            .bind::<Array<Text>, _>(messages)
792            .bind::<Text, _>(tes_id)
793            .execute(&mut conn)
794            .await
795            .map_err(Error::Diesel)?;
796
797        Ok(())
798    }
799
800    async fn add_task_resource_usage_samples(
801        &self,
802        samples: &[crate::ContainerUsageSample],
803    ) -> DatabaseResult<()> {
804        use diesel::pg::sql_types::Array;
805        use diesel::sql_types::BigInt;
806        use diesel::sql_types::Double;
807        use diesel::sql_types::Nullable;
808        use diesel::sql_types::Text;
809        use diesel::*;
810        use diesel_async::RunQueryDsl;
811
812        if samples.is_empty() {
813            return Ok(());
814        }
815
816        let mut ids = Vec::with_capacity(samples.len());
817        let mut pods = Vec::with_capacity(samples.len());
818        let mut names = Vec::with_capacity(samples.len());
819        let mut memory = Vec::with_capacity(samples.len());
820        let mut cpu = Vec::with_capacity(samples.len());
821        let mut start = Vec::with_capacity(samples.len());
822        for sample in samples {
823            ids.push(sample.tes_id.as_str());
824            pods.push(sample.pod_name.as_str());
825            names.push(sample.container_name.as_str());
826            memory.push(sample.memory_bytes);
827            cpu.push(sample.cpu_seconds.and_then(normalize_cpu_seconds));
828            start.push(sample.start_time_seconds);
829        }
830
831        let mut conn = self.pool.get().await.map_err(Error::Pool)?;
832
833        // See `Database::add_task_resource_usage_samples` for the full
834        // idempotency contract these queries implement.
835        conn.transaction(async |conn| {
836            sql_query(MEMORY_USAGE_FOLD_SQL)
837                .bind::<Array<Text>, _>(&ids)
838                .bind::<Array<Text>, _>(&names)
839                .bind::<Array<Nullable<BigInt>>, _>(&memory)
840                .execute(conn)
841                .await
842                .map_err(Error::Diesel)?;
843
844            sql_query(CPU_USAGE_FOLD_SQL)
845                .bind::<Array<Text>, _>(&ids)
846                .bind::<Array<Text>, _>(&pods)
847                .bind::<Array<Text>, _>(&names)
848                .bind::<Array<Nullable<Double>>, _>(&cpu)
849                .bind::<Array<Nullable<Double>>, _>(&start)
850                .execute(conn)
851                .await
852                .map_err(Error::Diesel)?;
853
854            Ok::<(), Error>(())
855        })
856        .await?;
857
858        Ok(())
859    }
860
861    async fn insert_error(
862        &self,
863        source: &str,
864        tes_id: Option<&str>,
865        message: &str,
866    ) -> DatabaseResult<()> {
867        use diesel::*;
868        use diesel_async::RunQueryDsl;
869
870        let mut conn = self.pool.get().await.map_err(Error::Pool)?;
871
872        conn.transaction(async |conn| {
873            // Lookup the associated task id, if there is one
874            let task_id = if let Some(tes_id) = tes_id {
875                Some(
876                    schema::tasks::table
877                        .select(schema::tasks::id)
878                        .filter(schema::tasks::tes_id.eq(tes_id))
879                        .for_update()
880                        .first(conn)
881                        .await
882                        .optional()
883                        .map_err(Error::Diesel)?
884                        .ok_or_else(|| Error::TaskNotFound(tes_id.to_string()))?,
885                )
886            } else {
887                None
888            };
889
890            // Insert the new error
891            diesel::insert_into(schema::errors::table)
892                .values(models::NewError {
893                    source,
894                    task_id,
895                    message,
896                })
897                .execute(conn)
898                .await
899                .map_err(Error::Diesel)
900        })
901        .await?;
902
903        Ok(())
904    }
905}