glean-core 70.2.0

A modern Telemetry library
Documentation
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

//! The SQLite database schema.

use std::num::NonZeroU32;

use rusqlite::{config::DbConfig, named_params, OptionalExtension, Transaction};

use super::connection::ConnectionOpener;

/// The schema for a physical SQLite database that contains many
/// named logical databases.
#[derive(Debug)]
pub struct Schema;

fn table_schema(schema: Option<&str>) -> String {
    let separator = if schema.is_some() { "." } else { "" };
    let schema = schema.unwrap_or("");
    let table_name = "telemetry";
    format!(
        "
         CREATE TABLE {schema}{separator}{table_name}(
           id TEXT NOT NULL,
           ping TEXT NOT NULL,
           lifetime TEXT NOT NULL,
           labels TEXT NOT NULL, -- can't be null or ON CONFLICT won't work
           value BLOB,
           UNIQUE(id, ping, labels)
         );
        "
    )
}

impl ConnectionOpener for Schema {
    const MAX_SCHEMA_VERSION: u32 = 3;

    type Error = SchemaError;

    fn setup(conn: &mut rusqlite::Connection) -> Result<(), Self::Error> {
        conn.execute_batch(
            "
             -- we unconditionally want write-ahead-logging mode
             PRAGMA journal_mode = WAL;
             -- Sync at the most criticial moments, but not with every write
             PRAGMA synchronous = NORMAL;
             -- limit size of the journal. TODO(bug 2049290): value currently arbitrary.
             -- needs refinement.
             PRAGMA journal_size_limit = 512000; -- 512 KB.
             -- We don't care about temp tables being persisted to disk
             PRAGMA temp_store = MEMORY;
             -- allows adding incremental cleanup later
             PRAGMA auto_vacuum = INCREMENTAL;
             -- How long to wait for a lock before returning SQLITE_BUSY (in ms)
             PRAGMA busy_timeout = 5000;
            ",
        )?;

        // Set hardening flags.
        conn.set_db_config(DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true)?;

        // Turn off misfeatures: double-quoted strings and untrusted schemas.
        conn.set_db_config(DbConfig::SQLITE_DBCONFIG_DQS_DML, false)?;
        conn.set_db_config(DbConfig::SQLITE_DBCONFIG_DQS_DDL, false)?;
        conn.set_db_config(DbConfig::SQLITE_DBCONFIG_TRUSTED_SCHEMA, true)?;

        Ok(())
    }

    fn create(tx: &mut Transaction<'_>) -> Result<(), Self::Error> {
        tx.execute_batch(&format!(
            "
             {}
             CREATE TABLE migration(id INTEGER PRIMARY KEY, state TEXT NOT NULL);
             CREATE TABLE submitted_pings(
               document_id TEXT PRIMARY KEY,
               ping TEXT NOT NULL,
               date_submitted INTEGER NOT NULL,
               date_uploaded INTEGER,
               upload_failed INTEGER,
               payload BLOB
             );
             CREATE INDEX submitted_pings_ping on submitted_pings(ping);
            ",
            table_schema(None)
        ))?;
        Ok(())
    }

    fn upgrade(tx: &mut Transaction<'_>, to_version: NonZeroU32) -> Result<(), Self::Error> {
        match to_version.get() {
            2 => {
                log::info!("Upgrading user_version to 2");
                // Clients upgrading to schema 2 don't have the table.
                // But they did run through the migration.
                tx.execute_batch(
                    "CREATE TABLE migration(id INTEGER PRIMARY KEY, state TEXT NOT NULL);",
                )?;

                let cid_exists: Option<i32> = tx
                    .query_row(
                        "SELECT 1 FROM telemetry WHERE id = 'client_id'",
                        [],
                        |row| row.get(0),
                    )
                    .optional()?;
                if cid_exists.is_some() {
                    log::info!("Client ID already exists. Marking migration as done.");
                    tx.execute("INSERT INTO migration (id, state) VALUES (1, 'done') ON CONFLICT(id) DO UPDATE SET state = excluded.state", [])?;
                }
                Ok(())
            }
            3 => {
                log::info!("Upgrading user_version to 3");
                // Clients upgrading to schema 3 don't have the table or index
                tx.execute_batch(
                    "
                    CREATE TABLE submitted_pings(
                        document_id TEXT PRIMARY KEY,
                        ping TEXT NOT NULL,
                        date_submitted INTEGER NOT NULL,
                        date_uploaded INTEGER,
                        upload_failed INTEGER,
                        payload TEXT
                    );
                    CREATE INDEX submitted_pings_ping on submitted_pings(ping);
                    ",
                )?;
                Ok(())
            }
            to_version => Err(SchemaError::UnsupportedSchemaVersion(to_version)),
        }
    }

    fn validate(tx: &mut Transaction<'_>) -> Result<(), Self::Error> {
        // A query selecting every field, it doesn't need to return anything.
        tx.execute_batch("SELECT id, ping, lifetime, labels, value FROM telemetry WHERE 1 = 0")?;
        Ok(())
    }
}

#[derive(thiserror::Error, Debug)]
pub enum SchemaError {
    #[error("unsupported schema version: {0}")]
    UnsupportedSchemaVersion(u32),
    #[error("sqlite: {0}")]
    Sqlite(#[from] rusqlite::Error),
}

pub fn create_in_memory_table(
    tx: &mut Transaction<'_>,
    database: &str,
) -> Result<(), rusqlite::Error> {
    tx.execute(
        "ATTACH DATABASE ':memory:' AS :database",
        named_params! {":database": database},
    )?;
    // This must remain in sync with the schema for the main table listed above.
    // Otherwise bad things will happen.
    // TODO(bug 2070883): Ensure this is the same with a test or similar.
    tx.execute(&table_schema(Some(database)), [])?;
    Ok(())
}