diff options
| -rw-r--r-- | .sqlx/query-31e741181f0d09540063ef29117e7e70d3df2beaa1b1e2e081b0d362c07ceae8.json | 12 | ||||
| -rw-r--r-- | .sqlx/query-642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305.json | 26 | ||||
| -rw-r--r-- | .sqlx/query-676a7dda6314cae4d13ff51241dc35263ccfee9f3424111e7fa6014071f98a1e.json | 26 | ||||
| -rw-r--r-- | .sqlx/query-b67d56f20dab413e31a64842bfc6c82b1464afa98845e537e850d05deb328f06.json | 12 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | docs/internal-server-errors.md | 30 | ||||
| -rw-r--r-- | src/app.rs | 9 | ||||
| -rw-r--r-- | src/bin/hi-recanonicalize.rs | 9 | ||||
| -rw-r--r-- | src/bin/hi.rs (renamed from src/main.rs) | 0 | ||||
| -rw-r--r-- | src/channel/app.rs | 8 | ||||
| -rw-r--r-- | src/channel/repo.rs | 32 | ||||
| -rw-r--r-- | src/cli/mod.rs (renamed from src/cli.rs) | 2 | ||||
| -rw-r--r-- | src/cli/recanonicalize.rs | 86 | ||||
| -rw-r--r-- | src/login/app.rs | 22 | ||||
| -rw-r--r-- | src/login/mod.rs | 1 | ||||
| -rw-r--r-- | src/login/repo.rs | 33 |
16 files changed, 304 insertions, 5 deletions
diff --git a/.sqlx/query-31e741181f0d09540063ef29117e7e70d3df2beaa1b1e2e081b0d362c07ceae8.json b/.sqlx/query-31e741181f0d09540063ef29117e7e70d3df2beaa1b1e2e081b0d362c07ceae8.json new file mode 100644 index 0000000..1105391 --- /dev/null +++ b/.sqlx/query-31e741181f0d09540063ef29117e7e70d3df2beaa1b1e2e081b0d362c07ceae8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n update channel_name\n set canonical_name = $1\n where id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "31e741181f0d09540063ef29117e7e70d3df2beaa1b1e2e081b0d362c07ceae8" +} diff --git a/.sqlx/query-642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305.json b/.sqlx/query-642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305.json new file mode 100644 index 0000000..5442790 --- /dev/null +++ b/.sqlx/query-642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n display_name as \"display_name: String\"\n from channel_name\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false + ] + }, + "hash": "642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305" +} diff --git a/.sqlx/query-676a7dda6314cae4d13ff51241dc35263ccfee9f3424111e7fa6014071f98a1e.json b/.sqlx/query-676a7dda6314cae4d13ff51241dc35263ccfee9f3424111e7fa6014071f98a1e.json new file mode 100644 index 0000000..fd601e9 --- /dev/null +++ b/.sqlx/query-676a7dda6314cae4d13ff51241dc35263ccfee9f3424111e7fa6014071f98a1e.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n display_name as \"display_name: String\"\n from login\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false + ] + }, + "hash": "676a7dda6314cae4d13ff51241dc35263ccfee9f3424111e7fa6014071f98a1e" +} diff --git a/.sqlx/query-b67d56f20dab413e31a64842bfc6c82b1464afa98845e537e850d05deb328f06.json b/.sqlx/query-b67d56f20dab413e31a64842bfc6c82b1464afa98845e537e850d05deb328f06.json new file mode 100644 index 0000000..677495b --- /dev/null +++ b/.sqlx/query-b67d56f20dab413e31a64842bfc6c82b1464afa98845e537e850d05deb328f06.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n update login\n set canonical_name = $1\n where id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "b67d56f20dab413e31a64842bfc6c82b1464afa98845e537e850d05deb328f06" +} @@ -3,6 +3,7 @@ name = "hi" version = "0.1.0" edition = "2021" rust-version = "1.82" +default-run = "hi" [dependencies] argon2 = "0.5.3" diff --git a/docs/internal-server-errors.md b/docs/internal-server-errors.md new file mode 100644 index 0000000..4f679b7 --- /dev/null +++ b/docs/internal-server-errors.md @@ -0,0 +1,30 @@ +# Internal Server Errors + +When `hi` encounters a problem that prevents a request from completing, it may report a `500 Internal Server Error` to clients, along with an error code. The actual error will be printed to standard error, with the error code. The following sections describe errors we've encountered, the likely operational consequences, and recommend approaches for addressing them. + +## database is locked + +The server attempted two write transactions at the same time, and encountered [sqlite's write locks](https://www.sqlite.org/rescode.html#busy). This is unfortunately unavoidable, but generally only occurs as a result of extremely bad luck, or very high load. + +This error will almost always resolve itself if clients re-try their requests; no further action is needed. + +This is a known issue. If you are encountering this consistently (or if you can trigger it on demand), let us know. We are aware of sqlite's features for mitigating this issue but have been unsuccessful in applying them; we're working on it, but patches _are_ welcome, if you have the opportunity. + +## stored canonical form […] does not match computed canonical form […] for name […] + +When `hi` applies the `migrations/20241019191531_canonical_names.sql` migration (from commit `3f9648eed48cd8b6cd35d0ae2ee5bbe25fa735ac`), this can leave existing names in a state where the stored canonical form is not the correct canonicalization of the stored display names of channels and logins. `hi` will abort requests when it encounters this situation, to avoid incorrect behaviours such as duplicate channels or duplicate logins. + +As channel and login names may be presented during client startup, this can render the service unusable until repaired. Treat this as an immediate outage if you see it. + +You can verify that login names are unique by running the following commands as the user the `hi` server runs as: + +* `sqlite3 .hi 'select display_name from login'` +* `sqlite3 .hi 'select display_name from channel_name'` + +Substitute `.hi` with the path to your `hi` database if it differs from the default. + +If the names are unique, you can repair the database: + +* Stop the `hi` server. +* Run `hi-recanonicalize`, as the same user the `hi` server runs as, with the same database options. +* Start the `hi` server. @@ -5,14 +5,12 @@ use crate::{ channel::app::Channels, event::{self, app::Events}, invite::app::Invites, + login::app::Logins, message::app::Messages, setup::app::Setup, token::{self, app::Tokens}, }; -#[cfg(test)] -use crate::login::app::Logins; - #[derive(Clone)] pub struct App { db: SqlitePool, @@ -49,6 +47,11 @@ impl App { Invites::new(&self.db) } + #[cfg(not(test))] + pub const fn logins(&self) -> Logins { + Logins::new(&self.db) + } + #[cfg(test)] pub const fn logins(&self) -> Logins { Logins::new(&self.db, &self.events) diff --git a/src/bin/hi-recanonicalize.rs b/src/bin/hi-recanonicalize.rs new file mode 100644 index 0000000..4081276 --- /dev/null +++ b/src/bin/hi-recanonicalize.rs @@ -0,0 +1,9 @@ +use clap::Parser; + +use hi::cli; + +#[tokio::main] +async fn main() -> Result<(), cli::recanonicalize::Error> { + let args = cli::recanonicalize::Args::parse(); + args.run().await +} diff --git a/src/main.rs b/src/bin/hi.rs index d0830ff..d0830ff 100644 --- a/src/main.rs +++ b/src/bin/hi.rs diff --git a/src/channel/app.rs b/src/channel/app.rs index b8ceeb0..7bfa0f7 100644 --- a/src/channel/app.rs +++ b/src/channel/app.rs @@ -133,6 +133,14 @@ impl<'a> Channels<'a> { Ok(()) } + + pub async fn recanonicalize(&self) -> Result<(), sqlx::Error> { + let mut tx = self.db.begin().await?; + tx.channels().recanonicalize().await?; + tx.commit().await?; + + Ok(()) + } } #[derive(Debug, thiserror::Error)] diff --git a/src/channel/repo.rs b/src/channel/repo.rs index 4baa95b..e26ac2b 100644 --- a/src/channel/repo.rs +++ b/src/channel/repo.rs @@ -300,6 +300,38 @@ impl<'c> Channels<'c> { Ok(channels) } + + pub async fn recanonicalize(&mut self) -> Result<(), sqlx::Error> { + let channels = sqlx::query!( + r#" + select + id as "id: Id", + display_name as "display_name: String" + from channel_name + "#, + ) + .fetch_all(&mut *self.0) + .await?; + + for channel in channels { + let name = Name::from(channel.display_name); + let canonical_name = name.canonical(); + + sqlx::query!( + r#" + update channel_name + set canonical_name = $1 + where id = $2 + "#, + canonical_name, + channel.id, + ) + .execute(&mut *self.0) + .await?; + } + + Ok(()) + } } #[derive(Debug, thiserror::Error)] diff --git a/src/cli.rs b/src/cli/mod.rs index 0659851..c75ce2b 100644 --- a/src/cli.rs +++ b/src/cli/mod.rs @@ -22,6 +22,8 @@ use crate::{ ui, }; +pub mod recanonicalize; + /// Command-line entry point for running the `hi` server. /// /// This is intended to be used as a Clap [Parser], to capture command-line diff --git a/src/cli/recanonicalize.rs b/src/cli/recanonicalize.rs new file mode 100644 index 0000000..5f8a1db --- /dev/null +++ b/src/cli/recanonicalize.rs @@ -0,0 +1,86 @@ +use sqlx::sqlite::SqlitePool; + +use crate::{app::App, db}; + +/// Command-line entry point for repairing canonical names in the `hi` database. +/// This command may be necessary after an upgrade, if the canonical forms of +/// names has changed. It will re-calculate the canonical form of each name in +/// the database, based on its display form, and store the results back to the +/// database. +/// +/// This is intended to be used as a Clap [Parser], to capture command-line +/// arguments for the `hi-recanonicalize` command: +/// +/// ```no_run +/// # use hi::recanonicalize::cli::Error; +/// # +/// # #[tokio::main] +/// # async fn main() -> Result<(), Error> { +/// use clap::Parser; +/// use hi::cli::recanonicalize::Args; +/// +/// let args = Args::parse(); +/// args.run().await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(clap::Parser)] +#[command( + version, + about = "Recanonicalize names in the `hi` database.", + long_about = r#"Recanonicalize names in the `hi` database. + +The `hi` server must not be running while this command is run. + +The database at `--database-url` will also be created, or upgraded, automatically."# +)] +pub struct Args { + /// Sqlite URL or path for the `hi` database + #[arg(short, long, env, default_value = "sqlite://.hi")] + database_url: String, + + /// Sqlite URL or path for a backup of the `hi` database during upgrades + #[arg(short = 'D', long, env, default_value = "sqlite://.hi.backup")] + backup_database_url: String, +} + +impl Args { + /// Recanonicalizes the `hi` database, using the parsed configuation in + /// `self`. + /// + /// This will perform the following tasks: + /// + /// * Migrate the `hi` database (at `--database-url`). + /// * Recanonicalize names in the `login` and `channel` tables. + /// + /// # Errors + /// + /// Will return `Err` if the canonicalization or database upgrade processes + /// fail. The specific [`Error`] variant will expose the cause + /// of the failure. + pub async fn run(self) -> Result<(), Error> { + let pool = self.pool().await?; + + let app = App::from(pool); + app.logins().recanonicalize().await?; + app.channels().recanonicalize().await?; + + Ok(()) + } + + async fn pool(&self) -> Result<SqlitePool, db::Error> { + db::prepare(&self.database_url, &self.backup_database_url).await + } +} + +/// Errors that can be raised by [`Args::run`]. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum Error { + // /// Failure due to `io::Error`. See [`io::Error`]. + // Io(#[from] io::Error), + /// Failure due to a database initialization error. See [`db::Error`]. + Database(#[from] db::Error), + /// Failure due to a data manipulation error. See [`sqlx::Error`]. + Sqlx(#[from] sqlx::Error), +} diff --git a/src/login/app.rs b/src/login/app.rs index 37f1249..2f5896f 100644 --- a/src/login/app.rs +++ b/src/login/app.rs @@ -1,6 +1,10 @@ use sqlx::sqlite::SqlitePool; -use super::{repo::Provider as _, Login, Password}; +use super::repo::Provider as _; + +#[cfg(test)] +use super::{Login, Password}; +#[cfg(test)] use crate::{ clock::DateTime, event::{repo::Provider as _, Broadcaster, Event}, @@ -9,14 +13,22 @@ use crate::{ pub struct Logins<'a> { db: &'a SqlitePool, + #[cfg(test)] events: &'a Broadcaster, } impl<'a> Logins<'a> { + #[cfg(not(test))] + pub const fn new(db: &'a SqlitePool) -> Self { + Self { db } + } + + #[cfg(test)] pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self { Self { db, events } } + #[cfg(test)] pub async fn create( &self, name: &Name, @@ -35,6 +47,14 @@ impl<'a> Logins<'a> { Ok(login.as_created()) } + + pub async fn recanonicalize(&self) -> Result<(), sqlx::Error> { + let mut tx = self.db.begin().await?; + tx.logins().recanonicalize().await?; + tx.commit().await?; + + Ok(()) + } } #[derive(Debug, thiserror::Error)] diff --git a/src/login/mod.rs b/src/login/mod.rs index 98cc3d7..64a3698 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -1,4 +1,3 @@ -#[cfg(test)] pub mod app; pub mod event; pub mod extract; diff --git a/src/login/repo.rs b/src/login/repo.rs index 6021f26..c6bc734 100644 --- a/src/login/repo.rs +++ b/src/login/repo.rs @@ -89,6 +89,7 @@ impl<'c> Logins<'c> { Ok(logins) } + pub async fn replay(&mut self, resume_at: ResumePoint) -> Result<Vec<History>, LoadError> { let logins = sqlx::query!( r#" @@ -119,6 +120,38 @@ impl<'c> Logins<'c> { Ok(logins) } + + pub async fn recanonicalize(&mut self) -> Result<(), sqlx::Error> { + let logins = sqlx::query!( + r#" + select + id as "id: Id", + display_name as "display_name: String" + from login + "#, + ) + .fetch_all(&mut *self.0) + .await?; + + for login in logins { + let name = Name::from(login.display_name); + let canonical_name = name.canonical(); + + sqlx::query!( + r#" + update login + set canonical_name = $1 + where id = $2 + "#, + canonical_name, + login.id, + ) + .execute(&mut *self.0) + .await?; + } + + Ok(()) + } } #[derive(Debug, thiserror::Error)] |
