From c52bb97df7a7da578e542dbc2ede54a96908addc Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Thu, 27 Apr 2023 15:04:20 -0700 Subject: [PATCH 01/20] extract out vdaf --- src/entity/task.rs | 75 +++------------------------ src/entity/task/vdaf.rs | 109 ++++++++++++++++++++++++++++++++++++++++ tests/tasks.rs | 7 ++- 3 files changed, 121 insertions(+), 70 deletions(-) create mode 100644 src/entity/task/vdaf.rs diff --git a/src/entity/task.rs b/src/entity/task.rs index ba9838c7..db124ee2 100644 --- a/src/entity/task.rs +++ b/src/entity/task.rs @@ -5,9 +5,11 @@ use crate::{ }; use sea_orm::{entity::prelude::*, ActiveValue::Set, IntoActiveModel}; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; use time::OffsetDateTime; -use validator::{Validate, ValidationError, ValidationErrors}; +use validator::{Validate, ValidationError}; + +mod vdaf; +pub use vdaf::{Histogram, Sum, Vdaf}; mod url; use self::url::Url; @@ -21,7 +23,7 @@ pub struct Model { pub name: String, pub leader_url: Url, pub helper_url: Url, - pub vdaf: Json, + pub vdaf: Vdaf, pub min_batch_size: i64, pub max_batch_size: Option, pub is_leader: bool, @@ -124,75 +126,12 @@ pub struct HpkeConfig { fn in_the_future(time: &TimeDateTimeWithTimeZone) -> Result<(), ValidationError> { if time < &TimeDateTimeWithTimeZone::now_utc() { - return Err(ValidationError::new("past")); - } - Ok(()) -} - -#[derive(Serialize, Deserialize, Validate, Debug, Clone)] -pub struct Histogram { - #[validate(required, custom = "sorted", custom = "unique")] - pub buckets: Option>, -} - -fn sorted(buckets: &Vec) -> Result<(), ValidationError> { - let mut buckets_cloned = buckets.clone(); - buckets_cloned.sort_unstable(); - if &buckets_cloned == buckets { - Ok(()) + Err(ValidationError::new("past")) } else { - Err(ValidationError::new("sorted")) - } -} - -fn unique(buckets: &Vec) -> Result<(), ValidationError> { - if buckets.iter().collect::>().len() == buckets.len() { Ok(()) - } else { - Err(ValidationError::new("unique")) } } -#[derive(Serialize, Deserialize, Validate, Debug, Clone, Copy)] -pub struct Sum { - #[validate(required)] - pub bits: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "snake_case", tag = "type")] -pub enum Vdaf { - #[serde(rename = "count")] - Count, - - #[serde(rename = "histogram")] - Histogram(Histogram), - - #[serde(rename = "sum")] - Sum(Sum), // 128 is ceiling - - #[serde(other)] - Unrecognized, -} - -impl Validate for Vdaf { - fn validate(&self) -> Result<(), ValidationErrors> { - match self { - Vdaf::Count => Ok(()), - Vdaf::Histogram(h) => h.validate(), - Vdaf::Sum(s) => s.validate(), - Vdaf::Unrecognized => { - let mut errors = ValidationErrors::new(); - errors.add("type", ValidationError::new("unknown")); - Err(errors) - } - } - } -} - -//add query type -//max batch query count - #[derive(Deserialize, Validate, Debug)] pub struct UpdateTask { #[validate(required, length(min = 1))] @@ -215,7 +154,7 @@ pub fn build_task(mut task: NewTask, api_response: TaskResponse, account: &Accou name: Set(task.name.take().unwrap()), leader_url: Set(api_response.aggregator_endpoints[0].clone().into()), helper_url: Set(api_response.aggregator_endpoints[1].clone().into()), - vdaf: Set(serde_json::to_value(Vdaf::from(api_response.vdaf)).unwrap()), + vdaf: Set(Vdaf::from(api_response.vdaf)), min_batch_size: Set(api_response.min_batch_size.try_into().unwrap()), max_batch_size: Set(api_response.query_type.into()), is_leader: Set(matches!(api_response.role, Role::Leader)), diff --git a/src/entity/task/vdaf.rs b/src/entity/task/vdaf.rs new file mode 100644 index 00000000..2ab6df34 --- /dev/null +++ b/src/entity/task/vdaf.rs @@ -0,0 +1,109 @@ +use sea_orm::{ + entity::prelude::*, + sea_query::{ArrayType, Nullable, ValueType, ValueTypeErr}, + TryGetError, TryGetable, Value, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use validator::{Validate, ValidationError, ValidationErrors}; + +#[derive(Serialize, Deserialize, Validate, Debug, Clone, Eq, PartialEq)] +pub struct Histogram { + #[validate(required, custom = "sorted", custom = "unique")] + pub buckets: Option>, +} + +fn sorted(buckets: &Vec) -> Result<(), ValidationError> { + let mut buckets_cloned = buckets.clone(); + buckets_cloned.sort_unstable(); + if &buckets_cloned == buckets { + Ok(()) + } else { + Err(ValidationError::new("sorted")) + } +} + +fn unique(buckets: &Vec) -> Result<(), ValidationError> { + if buckets.iter().collect::>().len() == buckets.len() { + Ok(()) + } else { + Err(ValidationError::new("unique")) + } +} + +#[derive(Serialize, Deserialize, Validate, Debug, Clone, Copy, Eq, PartialEq)] +pub struct Sum { + #[validate(required)] + pub bits: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] +#[serde(rename_all = "snake_case", tag = "type")] +pub enum Vdaf { + #[serde(rename = "count")] + Count, + + #[serde(rename = "histogram")] + Histogram(Histogram), + + #[serde(rename = "sum")] + Sum(Sum), // 128 is ceiling + + #[serde(other)] + Unrecognized, +} + +impl From for Value { + fn from(value: Vdaf) -> Self { + Value::Json(serde_json::to_value(&value).ok().map(Box::new)) + } +} + +impl TryGetable for Vdaf { + fn try_get_by(res: &QueryResult, idx: I) -> Result { + let json = res.try_get_by(idx).map_err(TryGetError::DbErr)?; + serde_json::from_value(json).map_err(|e| TryGetError::DbErr(DbErr::Json(e.to_string()))) + } +} + +impl ValueType for Vdaf { + fn try_from(v: Value) -> Result { + match v { + Value::Json(Some(x)) => serde_json::from_value(*x).map_err(|_| ValueTypeErr), + _ => Err(ValueTypeErr), + } + } + + fn type_name() -> String { + stringify!(Vdaf).to_owned() + } + + fn array_type() -> ArrayType { + ArrayType::Json + } + + fn column_type() -> ColumnType { + ColumnType::Json + } +} + +impl Nullable for Vdaf { + fn null() -> Value { + Value::Json(Some(Box::new(Json::Null))) + } +} + +impl Validate for Vdaf { + fn validate(&self) -> Result<(), ValidationErrors> { + match self { + Vdaf::Count => Ok(()), + Vdaf::Histogram(h) => h.validate(), + Vdaf::Sum(s) => s.validate(), + Vdaf::Unrecognized => { + let mut errors = ValidationErrors::new(); + errors.add("type", ValidationError::new("unknown")); + Err(errors) + } + } + } +} diff --git a/tests/tasks.rs b/tests/tasks.rs index 7cd0913a..5537cfbd 100644 --- a/tests/tasks.rs +++ b/tests/tasks.rs @@ -84,7 +84,10 @@ mod index { } mod create { - use divviup_api::{aggregator_api_mock::random_hpke_config, entity::task::HpkeConfig}; + use divviup_api::{ + aggregator_api_mock::random_hpke_config, + entity::task::{HpkeConfig, Vdaf}, + }; use super::{test, *}; @@ -114,7 +117,7 @@ mod create { let task: Task = conn.response_json().await; assert_eq!(task.helper_url, "https://dap.partner.url/"); assert_eq!(task.leader_url, "https://dap.divviup.test/"); - assert_eq!(task.vdaf, json!({"type": "count"})); + assert_eq!(task.vdaf, Vdaf::Count); assert_eq!(task.min_batch_size, 500); assert!(task.is_leader); assert_eq!(task.time_precision_seconds, 60); From e579eb5a831072897f4e8d71783461d8a112ea9c Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Tue, 2 May 2023 13:42:39 -0700 Subject: [PATCH 02/20] add postgres-based job queue --- Cargo.lock | 83 +-------- Cargo.toml | 2 +- migration/src/lib.rs | 2 + .../src/m20230427_221953_create_queue.rs | 82 +++++++++ src/bin.rs | 9 +- src/clients.rs | 15 +- src/clients/auth0_client.rs | 47 +++--- src/entity.rs | 4 + src/entity/macros.rs | 51 ++++++ src/entity/queue.rs | 68 ++++++++ src/entity/task/vdaf.rs | 46 +---- src/lib.rs | 2 + src/queue.rs | 148 +++++++++++++++++ src/queue/job.rs | 113 +++++++++++++ src/queue/job/v1.rs | 32 ++++ src/queue/job/v1/create_user.rs | 56 +++++++ src/queue/job/v1/reset_password.rs | 41 +++++ src/queue/job/v1/send_invitation_email.rs | 66 ++++++++ src/routes/memberships.rs | 16 +- tests/harness/api_mocks.rs | 157 ++++++++++++++++++ tests/harness/mod.rs | 66 +++----- tests/jobs.rs | 126 ++++++++++++++ tests/memberships.rs | 10 +- 23 files changed, 1038 insertions(+), 204 deletions(-) create mode 100644 migration/src/m20230427_221953_create_queue.rs create mode 100644 src/entity/macros.rs create mode 100644 src/entity/queue.rs create mode 100644 src/queue.rs create mode 100644 src/queue/job.rs create mode 100644 src/queue/job/v1.rs create mode 100644 src/queue/job/v1/create_user.rs create mode 100644 src/queue/job/v1/reset_password.rs create mode 100644 src/queue/job/v1/send_invitation_email.rs create mode 100644 tests/harness/api_mocks.rs create mode 100644 tests/jobs.rs diff --git a/Cargo.lock b/Cargo.lock index a0df10d3..f000c27f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -674,16 +674,6 @@ dependencies = [ "os_str_bytes", ] -[[package]] -name = "codespan-reporting" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" -dependencies = [ - "termcolor", - "unicode-width", -] - [[package]] name = "colored" version = "2.0.0" @@ -841,50 +831,6 @@ dependencies = [ "cipher", ] -[[package]] -name = "cxx" -version = "1.0.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f61f1b6389c3fe1c316bf8a4dccc90a38208354b330925bce1f74a6c4756eb93" -dependencies = [ - "cc", - "cxxbridge-flags", - "cxxbridge-macro", - "link-cplusplus", -] - -[[package]] -name = "cxx-build" -version = "1.0.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12cee708e8962df2aeb38f594aae5d827c022b6460ac71a7a3e2c3c2aae5a07b" -dependencies = [ - "cc", - "codespan-reporting", - "once_cell", - "proc-macro2", - "quote", - "scratch", - "syn 2.0.15", -] - -[[package]] -name = "cxxbridge-flags" -version = "1.0.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7944172ae7e4068c533afbb984114a56c46e9ccddda550499caa222902c7f7bb" - -[[package]] -name = "cxxbridge-macro" -version = "1.0.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2345488264226bf682893e25de0769f3360aac9957980ec49361b083ddaa5bc5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.15", -] - [[package]] name = "dashmap" version = "5.4.0" @@ -1446,12 +1392,11 @@ dependencies = [ [[package]] name = "iana-time-zone-haiku" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "cxx", - "cxx-build", + "cc", ] [[package]] @@ -1614,15 +1559,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "link-cplusplus" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecd207c9c713c34f95a097a5b029ac2ce6010530c7b49d7fea24d977dede04f5" -dependencies = [ - "cc", -] - [[package]] name = "linux-raw-sys" version = "0.3.7" @@ -2493,12 +2429,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" -[[package]] -name = "scratch" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1792db035ce95be60c3f8853017b3999209281c24e2ba5bc8e59bf97a0c590c1" - [[package]] name = "sct" version = "0.7.0" @@ -3432,6 +3362,7 @@ dependencies = [ "log", "memmem", "mime", + "serde", "smallvec", "smartcow", "smartstring", @@ -3645,12 +3576,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" -[[package]] -name = "unicode-width" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" - [[package]] name = "unicode_categories" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index d33727a0..b3eee445 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,7 @@ trillium-compression = "0.1.0" trillium-conn-id = "0.2.1" trillium-cookies = "0.4.0" trillium-forwarding = "0.2.1" -trillium-http = { version = "0.3.2", features = ["http-compat"] } +trillium-http = { version = "0.3.2", features = ["http-compat", "serde"] } trillium-logger = "0.4.2" trillium-macros = "0.0.4" trillium-prometheus = "0.1.0" diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 759d9c33..7494e679 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -5,6 +5,7 @@ mod m20230211_224853_create_sessions; mod m20230211_233835_create_accounts; mod m20230217_211422_create_memberships; mod m20230322_223043_add_fields_to_task; +mod m20230427_221953_create_queue; mod m20230512_200213_make_task_max_batch_size_a_big_integer; mod m20230512_202411_add_two_urls_to_every_task; @@ -19,6 +20,7 @@ impl MigratorTrait for Migrator { Box::new(m20230211_233835_create_accounts::Migration), Box::new(m20230217_211422_create_memberships::Migration), Box::new(m20230322_223043_add_fields_to_task::Migration), + Box::new(m20230427_221953_create_queue::Migration), Box::new(m20230512_200213_make_task_max_batch_size_a_big_integer::Migration), Box::new(m20230512_202411_add_two_urls_to_every_task::Migration), ] diff --git a/migration/src/m20230427_221953_create_queue.rs b/migration/src/m20230427_221953_create_queue.rs new file mode 100644 index 00000000..022bd787 --- /dev/null +++ b/migration/src/m20230427_221953_create_queue.rs @@ -0,0 +1,82 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Queue::Table) + .if_not_exists() + .col(ColumnDef::new(Queue::Id).uuid().not_null().primary_key()) + .col( + ColumnDef::new(Queue::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(Queue::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(Queue::ScheduledAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Queue::FailureCount) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(Queue::Status) + .integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(Queue::Job).json().not_null()) + .col(ColumnDef::new(Queue::Result).json().null()) + .col(ColumnDef::new(Queue::ParentId).uuid().null()) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("index-queue-on-updated-at") + .table(Queue::Table) + .col(Queue::UpdatedAt) + .index_type(IndexType::BTree) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Queue::Table).to_owned()) + .await + } +} + +#[derive(Iden)] +enum Queue { + Table, + Id, + CreatedAt, + UpdatedAt, + ScheduledAt, + FailureCount, + Status, + Job, + Result, + ParentId, +} diff --git a/src/bin.rs b/src/bin.rs index 606ca9ee..967f527f 100644 --- a/src/bin.rs +++ b/src/bin.rs @@ -27,10 +27,17 @@ async fn main() { .run_async(divviup_api::aggregator_api_mock::aggregator_api()), ); } + let app = DivviupApi::new(config).await; + + let db = app.db().clone(); + let config = app.config().clone(); + tokio::task::spawn(async move { + divviup_api::queue::run(db, config).await; + }); trillium_tokio::config() .with_stopper(stopper) - .run_async(DivviupApi::new(config).await) + .run_async(app) .await; if let Err(e) = metrics_task_handle.await { diff --git a/src/clients.rs b/src/clients.rs index 61f7a68c..71af96ae 100644 --- a/src/clients.rs +++ b/src/clients.rs @@ -7,11 +7,15 @@ pub use auth0_client::Auth0Client; pub use postmark_client::PostmarkClient; use trillium::{async_trait, Status}; use trillium_client::{ClientSerdeError, Conn}; +use trillium_http::Method; +use url::Url; #[derive(thiserror::Error, Debug)] pub enum ClientError { - #[error("unexpected api client http status {status:?}: {body}")] + #[error("unexpected http status {method} {url} {status:?}: {body}")] HttpStatusNotSuccess { + method: Method, + url: Url, status: Option, body: String, }, @@ -37,8 +41,15 @@ impl ClientConnExt for Conn { Ok(conn) => Ok(conn), Err(mut error) => { let status = error.status(); + let url = error.url().clone(); + let method = error.method(); let body = error.response_body().await?; - Err(ClientError::HttpStatusNotSuccess { status, body }) + Err(ClientError::HttpStatusNotSuccess { + method, + url, + status, + body, + }) } } } diff --git a/src/clients/auth0_client.rs b/src/clients/auth0_client.rs index 9bc01d10..7d07bc32 100644 --- a/src/clients/auth0_client.rs +++ b/src/clients/auth0_client.rs @@ -5,7 +5,7 @@ use std::{ sync::Arc, time::{Duration, SystemTime}, }; -use tokio::task::spawn; + use trillium::{ Conn, KnownHeaderName::{Accept, Authorization, ContentType}, @@ -16,7 +16,6 @@ use url::Url; use crate::{ clients::{ClientConnExt, ClientError, PostmarkClient}, - entity::{Account, Membership}, ApiConfig, }; @@ -49,15 +48,13 @@ impl Auth0Client { } } - pub async fn invite(&self, email: &str, account_name: &str) -> Result<(), ClientError> { - let user = self.create_user(email).await?; - let user_id = user - .get("user_id") - .ok_or_else(|| ClientError::Other("expected user_id".into()))? - .as_str() - .unwrap(); - let reset = self.password_reset(user_id).await?; - + pub async fn invite( + &self, + email: &str, + account_name: &str, + ) -> Result<(String, Url), ClientError> { + let user_id = self.create_user(email).await?; + let reset = self.password_reset(&user_id).await?; self.postmark_client .send_email_template( email, @@ -69,7 +66,8 @@ impl Auth0Client { }), ) .await?; - Ok(()) + + Ok((user_id.to_string(), reset)) } pub async fn password_reset(&self, user_id: &str) -> Result { @@ -84,32 +82,25 @@ impl Auth0Client { .ok_or(ClientError::Other("password reset".to_string())) } - pub async fn create_user(&self, email: &str) -> Result { - self.post("/api/v2/users", &json!({ + pub async fn create_user(&self, email: &str) -> Result { + let user: serde_json::Value = self.post("/api/v2/users", &json!({ "connection": "Username-Password-Authentication", "email": email, "password": std::iter::repeat_with(fastrand::alphanumeric).take(60).collect::(), "verify_email": false - })).await + })).await?; + + user.get("user_id") + .ok_or_else(|| ClientError::Other("expected user_id".into()))? + .as_str() + .ok_or_else(|| ClientError::Other("expected user_id to be a string".into())) + .map(String::from) } pub async fn users(&self) -> Result, ClientError> { self.get("/api/v2/users").await } - pub fn spawn_invitation_task(&self, membership: Membership, account: Account) { - let client = self.clone(); - spawn(async move { - match client.invite(&membership.user_email, &account.name).await { - Ok(()) => log::info!("sent email regarding {membership:?}"), - - Err(e) => log::error!( - "error while sending email regarding membership {membership:?}: \n\n{e}" - ), - } - }); - } - // private below here async fn get_new_token(&self) -> Result { diff --git a/src/entity.rs b/src/entity.rs index 9b8d1407..701182a6 100644 --- a/src/entity.rs +++ b/src/entity.rs @@ -1,8 +1,12 @@ pub mod account; pub mod membership; +pub mod queue; pub mod session; pub mod task; +#[macro_use] +pub mod macros; + pub use account::{ Column as AccountColumn, Entity as Accounts, Model as Account, NewAccount, UpdateAccount, }; diff --git a/src/entity/macros.rs b/src/entity/macros.rs new file mode 100644 index 00000000..7aa33b9d --- /dev/null +++ b/src/entity/macros.rs @@ -0,0 +1,51 @@ +#[macro_export] +macro_rules! json_newtype { + ($type:ty) => { + json_newtype!($type where); + }; + + ($type:ty where $($generic_type_name:ident $(: $generic_type_bound:ident $(+ $generic_type_bound2:ident)*)?),*) => { + impl<$($generic_type_name $(: $generic_type_bound $(+ $generic_type_bound2)*)?),*> From<$type> for sea_orm::Value { + fn from(value: $type) -> Self { + sea_orm::Value::Json(serde_json::to_value(&value).ok().map(Box::new)) + } + } + + impl<$($generic_type_name $(: $generic_type_bound $(+ $generic_type_bound2)*)?),*> sea_orm::TryGetable for $type { + fn try_get_by(res: &sea_orm::QueryResult, idx: I) -> Result { + match res.try_get_by(idx).map_err(sea_orm::TryGetError::DbErr)? { + serde_json::Value::Null => Err(sea_orm::TryGetError::Null(format!("{idx:?}"))), + json => serde_json::from_value(json).map_err(|e| sea_orm::TryGetError::DbErr(sea_orm::DbErr::Json(e.to_string()))) + } + } + } + + impl<$($generic_type_name $(: $generic_type_bound $(+ $generic_type_bound2)*)?),*> sea_orm::sea_query::ValueType for $type { + fn try_from(v: sea_orm::Value) -> Result { + match v { + sea_orm::Value::Json(Some(x)) => serde_json::from_value(*x).map_err(|_| sea_orm::sea_query::ValueTypeErr), + _ => Err(sea_orm::sea_query::ValueTypeErr), + } + } + + fn type_name() -> String { + stringify!($type).to_owned() + } + + fn array_type() -> sea_orm::sea_query::ArrayType { + sea_orm::sea_query::ArrayType::Json + } + + fn column_type() -> sea_orm::entity::ColumnType { + sea_orm::entity::ColumnType::Json + } + } + + impl<$($generic_type_name $(: $generic_type_bound $(+ $generic_type_bound2)*)?),*> sea_orm::sea_query::Nullable for $type { + fn null() -> sea_orm::Value { + sea_orm::Value::Json(Some(Box::new(serde_json::Value::Null))) + } + } + }; + +} diff --git a/src/entity/queue.rs b/src/entity/queue.rs new file mode 100644 index 00000000..fbc616a2 --- /dev/null +++ b/src/entity/queue.rs @@ -0,0 +1,68 @@ +use crate::{ + json_newtype, + queue::{Job, JobError}, +}; +use sea_orm::{entity::prelude::*, Set}; +use serde::{Deserialize, Serialize}; +use std::fmt::Debug; +use time::OffsetDateTime; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum JobResult { + Complete, + Child(Uuid), + Error(JobError), +} + +json_newtype!(JobResult); +json_newtype!(Job); + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "queue")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + #[serde(with = "time::serde::iso8601")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::iso8601")] + pub updated_at: OffsetDateTime, + #[serde(default, with = "time::serde::iso8601::option")] + pub scheduled_at: Option, + pub failure_count: i32, + pub status: JobStatus, + pub job: Job, + pub result: Option, + pub parent_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "i32", db_type = "Integer")] +pub enum JobStatus { + #[sea_orm(num_value = 0)] + Pending, + #[sea_orm(num_value = 1)] + Success, + #[sea_orm(num_value = 2)] + Failed, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +impl From for ActiveModel { + fn from(job: Job) -> Self { + Self { + id: Set(Uuid::new_v4()), + created_at: Set(OffsetDateTime::now_utc()), + updated_at: Set(OffsetDateTime::now_utc()), + scheduled_at: Set(None), + failure_count: Set(0), + status: Set(JobStatus::Pending), + job: Set(job), + result: Set(None), + parent_id: Set(None), + } + } +} diff --git a/src/entity/task/vdaf.rs b/src/entity/task/vdaf.rs index 2ab6df34..ee30f32b 100644 --- a/src/entity/task/vdaf.rs +++ b/src/entity/task/vdaf.rs @@ -1,8 +1,4 @@ -use sea_orm::{ - entity::prelude::*, - sea_query::{ArrayType, Nullable, ValueType, ValueTypeErr}, - TryGetError, TryGetable, Value, -}; +use crate::json_newtype; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use validator::{Validate, ValidationError, ValidationErrors}; @@ -53,45 +49,7 @@ pub enum Vdaf { Unrecognized, } -impl From for Value { - fn from(value: Vdaf) -> Self { - Value::Json(serde_json::to_value(&value).ok().map(Box::new)) - } -} - -impl TryGetable for Vdaf { - fn try_get_by(res: &QueryResult, idx: I) -> Result { - let json = res.try_get_by(idx).map_err(TryGetError::DbErr)?; - serde_json::from_value(json).map_err(|e| TryGetError::DbErr(DbErr::Json(e.to_string()))) - } -} - -impl ValueType for Vdaf { - fn try_from(v: Value) -> Result { - match v { - Value::Json(Some(x)) => serde_json::from_value(*x).map_err(|_| ValueTypeErr), - _ => Err(ValueTypeErr), - } - } - - fn type_name() -> String { - stringify!(Vdaf).to_owned() - } - - fn array_type() -> ArrayType { - ArrayType::Json - } - - fn column_type() -> ColumnType { - ColumnType::Json - } -} - -impl Nullable for Vdaf { - fn null() -> Value { - Value::Json(Some(Box::new(Json::Null))) - } -} +json_newtype!(Vdaf); impl Validate for Vdaf { fn validate(&self) -> Result<(), ValidationErrors> { diff --git a/src/lib.rs b/src/lib.rs index ef556079..f26b61e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,8 +11,10 @@ pub mod aggregator_api_mock; pub mod clients; mod config; mod db; +#[macro_use] pub mod entity; pub mod handler; +pub mod queue; mod routes; pub mod telemetry; mod user; diff --git a/src/queue.rs b/src/queue.rs new file mode 100644 index 00000000..0e7b31d7 --- /dev/null +++ b/src/queue.rs @@ -0,0 +1,148 @@ +mod job; +pub use crate::entity::queue::{JobResult, JobStatus}; +pub use job::*; + +use crate::{ + entity::queue::{ActiveModel, Entity, Model}, + ApiConfig, Db, +}; +use sea_orm::{ + ActiveModelTrait, ConnectionTrait, DatabaseBackend, DatabaseTransaction, DbErr, EntityTrait, + IntoActiveModel, Set, Statement, TransactionTrait, +}; +use std::{ops::Range, sync::Arc, time::Duration}; +use time::OffsetDateTime; +use tokio::{task::JoinSet, time::sleep}; + +/* +These configuration variables may eventually be useful to put on ApiConfig +*/ +const MAX_RETRY: i32 = 5; +const QUEUE_CHECK_INTERVAL: Range = 10..20; +const QUEUE_WORKER_COUNT: u8 = 5; + +fn schedule_based_on_failure_count(failure_count: i32) -> Option { + if failure_count >= MAX_RETRY { + None + } else { + Some( + OffsetDateTime::now_utc() + + Duration::from_millis( + 4000_u64.pow(failure_count.try_into().unwrap()) + fastrand::u64(0..15000), + ), + ) + } +} + +pub async fn one(db: &Db, job_state: &SharedJobState) -> Result, DbErr> { + let tx = db.begin().await?; + let model = if let Some(model) = next(&tx).await? { + let mut active_model = model.into_active_model(); + let mut job = active_model + .job + .take() + .expect("queue jobs should always have a Job"); + let result = job.perform(job_state, &tx).await; + active_model.job = Set(job); + active_model.updated_at = Set(OffsetDateTime::now_utc()); + match result { + Ok(Some(job)) => { + active_model.status = Set(JobStatus::Success); + active_model.scheduled_at = Set(None); + let mut next_job = ActiveModel::from(job); + next_job.parent_id = Set(Some(*active_model.id.as_ref())); + let next_job = next_job.insert(&tx).await?; + active_model.result = Set(Some(JobResult::Child(next_job.id))); + } + + Ok(None) => { + active_model.scheduled_at = Set(None); + active_model.status = Set(JobStatus::Success); + active_model.result = Set(Some(JobResult::Complete)); + } + + Err(e) if e.is_retryable() => { + active_model.failure_count = Set(active_model.failure_count.as_ref() + 1); + let reschedule = + schedule_based_on_failure_count(*active_model.failure_count.as_ref()); + active_model.status = + Set(reschedule.map_or(JobStatus::Failed, |_| JobStatus::Pending)); + active_model.scheduled_at = Set(reschedule); + active_model.result = Set(Some(JobResult::Error(e))); + } + + Err(e) => { + active_model.failure_count = Set(active_model.failure_count.as_ref() + 1); + active_model.scheduled_at = Set(None); + active_model.status = Set(JobStatus::Failed); + active_model.result = Set(Some(JobResult::Error(e))); + } + } + Some(active_model.update(&tx).await?) + } else { + None + }; + tx.commit().await?; + Ok(model) +} + +fn spawn(join_set: &mut JoinSet<()>, db: &Db, job_state: &Arc) { + let db = db.clone(); + let job_state = Arc::clone(job_state); + join_set.spawn(async move { + loop { + match one(&db, &job_state).await { + Err(e) => { + eprintln!("job error {e}"); + } + + Ok(Some(_)) => {} + + Ok(None) => { + sleep(Duration::from_secs(fastrand::u64(QUEUE_CHECK_INTERVAL))).await; + } + } + } + }); +} + +pub async fn run(db: Db, config: ApiConfig) { + let mut join_set = JoinSet::new(); + let job_state = Arc::new(SharedJobState::from(&config)); + for _ in 0..QUEUE_WORKER_COUNT { + spawn(&mut join_set, &db, &job_state); + } + + while join_set.join_next().await.is_some() { + eprintln!("Worker task shut down. Restarting."); + spawn(&mut join_set, &db, &job_state); + } +} + +async fn next(tx: &DatabaseTransaction) -> Result, DbErr> { + let select = match tx.get_database_backend() { + backend @ DatabaseBackend::Postgres => Statement::from_sql_and_values( + backend, + r#"SELECT * FROM queue + WHERE status = $1 AND (scheduled_at IS NULL OR scheduled_at < $2) + ORDER BY updated_at ASC + FOR UPDATE + SKIP LOCKED + LIMIT 1"#, + [JobStatus::Pending.into(), OffsetDateTime::now_utc().into()], + ), + + backend @ DatabaseBackend::Sqlite => Statement::from_sql_and_values( + backend, + r#"SELECT * FROM queue + WHERE status = $1 AND (scheduled_at IS NULL OR scheduled_at < $2) + ORDER BY updated_at ASC + LIMIT 1"#, + [JobStatus::Pending.into(), OffsetDateTime::now_utc().into()], + ), + + _ => unimplemented!(), + }; + + Entity::find().from_raw_sql(select).one(tx).await +} diff --git a/src/queue/job.rs b/src/queue/job.rs new file mode 100644 index 00000000..42d33f85 --- /dev/null +++ b/src/queue/job.rs @@ -0,0 +1,113 @@ +use crate::{ + clients::{Auth0Client, ClientError, PostmarkClient}, + entity::Membership, + ApiConfig, +}; +use sea_orm::{ActiveModelTrait, ConnectionTrait, DbErr}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use trillium::{Method, Status}; +use url::Url; + +mod v1; +pub use v1::{CreateUser, ResetPassword, SendInvitationEmail, V1}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub enum Job { + V1(V1), +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Error)] +pub enum JobError { + #[error("{0}")] + Db(String), + + #[error("{0} with id {1} was not found")] + MissingRecord(String, String), + + #[error("{0}")] + ClientOther(String), + + #[error("unexpected http status {method} {url} {status:?}: {body}")] + HttpStatusNotSuccess { + method: Method, + url: Url, + status: Option, + body: String, + }, +} + +impl JobError { + pub fn is_retryable(&self) -> bool { + matches!( + self, + Self::Db(_) | Self::ClientOther(_) | Self::HttpStatusNotSuccess { .. } + ) + } +} + +impl From for JobError { + fn from(value: DbErr) -> Self { + Self::Db(value.to_string()) + } +} + +impl From for JobError { + fn from(value: ClientError) -> Self { + match value { + ClientError::HttpStatusNotSuccess { + method, + url, + status, + body, + } => Self::HttpStatusNotSuccess { + method, + url, + status, + body, + }, + other => Self::ClientOther(other.to_string()), + } + } +} + +#[derive(Debug)] +pub struct SharedJobState { + pub auth0_client: Auth0Client, + pub postmark_client: PostmarkClient, +} +impl From<&ApiConfig> for SharedJobState { + fn from(config: &ApiConfig) -> Self { + Self { + auth0_client: Auth0Client::new(config), + postmark_client: PostmarkClient::new(config), + } + } +} + +impl Job { + pub fn new_invitation_flow(membership: &Membership) -> Self { + Self::from(CreateUser { + membership_id: membership.id, + }) + } + + pub async fn perform( + &mut self, + job_state: &SharedJobState, + db: &impl ConnectionTrait, + ) -> Result, JobError> { + match self { + Job::V1(job) => job.perform(job_state, db).await, + } + } + + pub async fn insert( + self, + db: &impl ConnectionTrait, + ) -> Result { + crate::entity::queue::ActiveModel::from(self) + .insert(db) + .await + } +} diff --git a/src/queue/job/v1.rs b/src/queue/job/v1.rs new file mode 100644 index 00000000..bdef64fd --- /dev/null +++ b/src/queue/job/v1.rs @@ -0,0 +1,32 @@ +mod create_user; +mod reset_password; +mod send_invitation_email; + +use super::{Job, JobError, SharedJobState}; +use sea_orm::ConnectionTrait; +use serde::{Deserialize, Serialize}; + +pub use create_user::CreateUser; +pub use reset_password::ResetPassword; +pub use send_invitation_email::SendInvitationEmail; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub enum V1 { + SendInvitationEmail(SendInvitationEmail), + CreateUser(CreateUser), + ResetPassword(ResetPassword), +} + +impl V1 { + pub async fn perform( + &mut self, + job_state: &SharedJobState, + db: &impl ConnectionTrait, + ) -> Result, JobError> { + match self { + V1::SendInvitationEmail(job) => job.perform(job_state, db).await, + V1::CreateUser(job) => job.perform(job_state, db).await, + V1::ResetPassword(job) => job.perform(job_state, db).await, + } + } +} diff --git a/src/queue/job/v1/create_user.rs b/src/queue/job/v1/create_user.rs new file mode 100644 index 00000000..a4d9088e --- /dev/null +++ b/src/queue/job/v1/create_user.rs @@ -0,0 +1,56 @@ +use crate::{ + entity::*, + queue::job::{ + v1::{reset_password::ResetPassword, V1}, + Job, JobError, SharedJobState, + }, +}; +use sea_orm::{ConnectionTrait, EntityTrait}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Copy)] +pub struct CreateUser { + pub membership_id: Uuid, +} + +impl CreateUser { + pub async fn perform( + &mut self, + job_state: &SharedJobState, + db: &impl ConnectionTrait, + ) -> Result, JobError> { + let membership = Memberships::find_by_id(self.membership_id) + .one(db) + .await? + .ok_or_else(|| { + JobError::MissingRecord(String::from("membership"), self.membership_id.to_string()) + })?; + + let user_id = job_state + .auth0_client + .create_user(&membership.user_email) + .await?; + Ok(Some(Job::from(ResetPassword { + membership_id: self.membership_id, + user_id, + }))) + } +} + +impl From for Job { + fn from(value: CreateUser) -> Self { + Self::V1(V1::CreateUser(value)) + } +} + +impl PartialEq for CreateUser { + fn eq(&self, other: &Job) -> bool { + matches!(other, Job::V1(V1::CreateUser(c)) if c == self) + } +} +impl PartialEq for Job { + fn eq(&self, other: &CreateUser) -> bool { + matches!(self, Job::V1(V1::CreateUser(j)) if j == other) + } +} diff --git a/src/queue/job/v1/reset_password.rs b/src/queue/job/v1/reset_password.rs new file mode 100644 index 00000000..99703600 --- /dev/null +++ b/src/queue/job/v1/reset_password.rs @@ -0,0 +1,41 @@ +use crate::queue::{Job, JobError, SendInvitationEmail, SharedJobState, V1}; +use sea_orm::ConnectionTrait; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct ResetPassword { + pub membership_id: Uuid, + pub user_id: String, +} + +impl ResetPassword { + pub async fn perform( + &mut self, + job_state: &SharedJobState, + _db: &impl ConnectionTrait, + ) -> Result, JobError> { + let action_url = job_state.auth0_client.password_reset(&self.user_id).await?; + Ok(Some(Job::from(SendInvitationEmail { + membership_id: self.membership_id, + action_url, + }))) + } +} + +impl From for Job { + fn from(value: ResetPassword) -> Self { + Self::V1(V1::ResetPassword(value)) + } +} + +impl PartialEq for ResetPassword { + fn eq(&self, other: &Job) -> bool { + matches!(other, Job::V1(V1::ResetPassword(j)) if j == self) + } +} +impl PartialEq for Job { + fn eq(&self, other: &ResetPassword) -> bool { + matches!(self, Job::V1(V1::ResetPassword(j)) if j == other) + } +} diff --git a/src/queue/job/v1/send_invitation_email.rs b/src/queue/job/v1/send_invitation_email.rs new file mode 100644 index 00000000..b401f3bd --- /dev/null +++ b/src/queue/job/v1/send_invitation_email.rs @@ -0,0 +1,66 @@ +use crate::{ + entity::*, + queue::{Job, JobError, SharedJobState, V1}, +}; +use sea_orm::{ConnectionTrait, EntityTrait}; +use serde::{Deserialize, Serialize}; +use url::Url; +use uuid::Uuid; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct SendInvitationEmail { + pub membership_id: Uuid, + pub action_url: Url, +} + +impl SendInvitationEmail { + pub async fn perform( + &mut self, + job_state: &SharedJobState, + db: &impl ConnectionTrait, + ) -> Result, JobError> { + let (membership, account) = Memberships::find_by_id(self.membership_id) + .find_also_related(Accounts) + .one(db) + .await? + .ok_or_else(|| { + JobError::MissingRecord(String::from("membership"), self.membership_id.to_string()) + })?; + + let account = account.ok_or_else(|| { + JobError::MissingRecord(String::from("account"), membership.account_id.to_string()) + })?; + + job_state + .postmark_client + .send_email_template( + &membership.user_email, + "user-invitation", + &serde_json::json!({ + "email": membership.user_email, + "account_name": &account.name, + "action_url": self.action_url + }), + ) + .await?; + + Ok(None) + } +} + +impl From for Job { + fn from(value: SendInvitationEmail) -> Self { + Self::V1(V1::SendInvitationEmail(value)) + } +} +impl PartialEq for SendInvitationEmail { + fn eq(&self, other: &Job) -> bool { + matches!(other, Job::V1(V1::SendInvitationEmail(j)) if j == self) + } +} + +impl PartialEq for Job { + fn eq(&self, other: &SendInvitationEmail) -> bool { + matches!(self, Job::V1(V1::SendInvitationEmail(j)) if j == other) + } +} diff --git a/src/routes/memberships.rs b/src/routes/memberships.rs index a1da546c..d81e294a 100644 --- a/src/routes/memberships.rs +++ b/src/routes/memberships.rs @@ -1,11 +1,11 @@ use crate::{ - clients::Auth0Client, entity::{Account, Accounts, CreateMembership, MembershipColumn, Memberships}, handler::Error, + queue::Job, user::User, Db, }; -use sea_orm::{prelude::*, ActiveModelTrait, ModelTrait}; +use sea_orm::{prelude::*, ActiveModelTrait, ModelTrait, TransactionTrait}; use trillium::{Conn, Handler, Status}; use trillium_api::Json; use trillium_caching_headers::CachingHeadersExt; @@ -25,21 +25,25 @@ pub async fn index(conn: &mut Conn, (account, db): (Account, Db)) -> Result, Db, Auth0Client), + (account, Json(membership), db): (Account, Json, Db), ) -> Result { let membership = membership.build(&account)?; + let tx = db.begin().await?; + let first_membership_for_this_email = Memberships::find() .filter(MembershipColumn::UserEmail.eq(membership.user_email.as_ref())) - .one(&db) + .one(&tx) .await? .is_none(); - let membership = membership.insert(&db).await?; + let membership = membership.insert(&tx).await?; if first_membership_for_this_email && !cfg!(feature = "integration-testing") { - client.invite(&membership.user_email, &account.name).await?; + Job::new_invitation_flow(&membership).insert(&tx).await?; } + tx.commit().await?; + Ok((Json(membership), Status::Created)) } diff --git a/tests/harness/api_mocks.rs b/tests/harness/api_mocks.rs new file mode 100644 index 00000000..d14b83a4 --- /dev/null +++ b/tests/harness/api_mocks.rs @@ -0,0 +1,157 @@ +use super::{fixtures, AGGREGATOR_URL, AUTH0_URL, POSTMARK_URL}; +use divviup_api::{aggregator_api_mock::aggregator_api, clients::auth0_client::Token}; +use serde_json::{json, Value}; +use std::sync::Arc; +use trillium::{async_trait, Conn, Handler}; +use trillium_api::Json; +use trillium_http::{Body, Headers, Method, Status}; +use trillium_macros::Handler; +use trillium_router::router; +use url::Url; + +fn postmark_mock() -> impl Handler { + router().post("/email/withTemplate", Json(json!({}))) +} + +fn auth0_mock() -> impl Handler { + router() + .post( + "/oauth/token", + Json(Token { + access_token: "access token".into(), + expires_in: 60, + scope: "".into(), + token_type: "bearer".into(), + }), + ) + .post( + "/api/v2/users", + Json(json!({ "user_id": fixtures::random_name() })), + ) + .post( + "/api/v2/tickets/password-change", + Json(json!({ + "ticket": format!("{AUTH0_URL}/password_tickets/{}", fixtures::random_name()) + })), + ) +} + +#[derive(Debug, Clone)] +pub struct LoggedConn { + pub url: Url, + pub method: Method, + pub response_body: Option, + pub response_status: Status, + pub request_headers: Headers, + pub response_headers: Headers, +} + +impl LoggedConn { + pub fn response_json(&self) -> Value { + serde_json::from_str(&self.response_body.as_ref().unwrap()).unwrap() + } +} + +impl From<&Conn> for LoggedConn { + fn from(conn: &Conn) -> Self { + let url = Url::parse(&format!( + "{}://{}{}{}", + if conn.is_secure() { "https" } else { "http" }, + conn.inner().host().unwrap(), + conn.path(), + match conn.querystring() { + "" => "".into(), + q => format!("?{q}"), + } + )) + .unwrap(); + + Self { + url, + method: conn.method(), + response_body: conn + .inner() + .response_body() + .and_then(Body::static_bytes) + .map(|s| String::from_utf8_lossy(s).to_string()), + response_status: conn.status().unwrap_or(Status::NotFound), + request_headers: conn.request_headers().clone(), + response_headers: conn.response_headers().clone(), + } + } +} + +impl std::fmt::Display for LoggedConn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!( + "{} {}: {}", + self.method, self.url, self.response_status + )) + } +} + +#[derive(Debug, Default, Clone)] +pub struct ClientLogs { + logged_conns: Arc>>, +} + +impl ClientLogs { + pub fn logs(&self) -> Vec { + self.logged_conns.read().unwrap().clone() + } + + pub fn last(&self) -> LoggedConn { + self.logged_conns.read().unwrap().last().unwrap().clone() + } + + pub fn matching_url(&self, url: Url) -> Vec { + self.logged_conns + .read() + .unwrap() + .iter() + .filter(|lc| (**lc).url == url) + .cloned() + .collect() + } +} + +#[derive(Handler, Debug)] +pub struct ApiMocks { + #[handler] + handler: Box, + client_logs: ClientLogs, +} + +impl ApiMocks { + pub fn new() -> Self { + let client_logs = ClientLogs::default(); + + Self { + handler: Box::new(( + client_logs.clone(), + divviup_api::handler::origin_router() + .with_handler(POSTMARK_URL, postmark_mock()) + .with_handler(AGGREGATOR_URL, aggregator_api()) + .with_handler(AUTH0_URL, auth0_mock()), + )), + client_logs, + } + } + pub fn client_logs(&self) -> ClientLogs { + self.client_logs.clone() + } +} + +#[async_trait] +impl Handler for ClientLogs { + async fn run(&self, conn: Conn) -> Conn { + conn + } + async fn before_send(&self, conn: Conn) -> Conn { + self.logged_conns + .write() + .unwrap() + .push(LoggedConn::from(&conn)); + conn + } +} diff --git a/tests/harness/mod.rs b/tests/harness/mod.rs index b192202f..81542bc3 100644 --- a/tests/harness/mod.rs +++ b/tests/harness/mod.rs @@ -2,14 +2,13 @@ use divviup_api::{ aggregator_api_mock::{aggregator_api, random_hpke_config}, clients::auth0_client::Token, + entity::queue, ApiConfig, Db, }; use serde::{de::DeserializeOwned, Serialize}; use std::future::Future; use trillium::Handler; -use trillium_api::Json; use trillium_client::Client; -use trillium_router::router; use trillium_testing::TestConn; pub use divviup_api::{entity::*, DivviupApi, User}; @@ -25,6 +24,9 @@ pub use url::Url; pub type TestResult = Result<(), Box>; +mod api_mocks; +pub use api_mocks::{ApiMocks, ClientLogs, LoggedConn}; + const POSTMARK_URL: &str = "https://postmark.example"; const AGGREGATOR_API_URL: &str = "https://aggregator.example"; const AUTH0_URL: &str = "https://auth.example"; @@ -42,43 +44,10 @@ async fn set_up_schema(db: &Db) { set_up_schema_for(&schema, db, Accounts).await; set_up_schema_for(&schema, db, Memberships).await; set_up_schema_for(&schema, db, Tasks).await; + set_up_schema_for(&schema, db, queue::Entity).await; } -fn postmark_mock() -> impl Handler { - router().post("/email/withTemplate", Json(json!({}))) -} - -fn auth0_mock() -> impl Handler { - router() - .post( - "/oauth/token", - Json(Token { - access_token: "access token".into(), - expires_in: 60, - scope: "".into(), - token_type: "bearer".into(), - }), - ) - .post( - "/api/v2/users", - Json(json!({ "user_id": fixtures::random_name() })), - ) - .post( - "/api/v2/tickets/password-change", - Json(json!({ - "ticket": format!("{AUTH0_URL}/password_tickets/{}", fixtures::random_name()) - })), - ) -} - -fn api_mocks() -> impl Handler { - divviup_api::handler::origin_router() - .with_handler(POSTMARK_URL, postmark_mock()) - .with_handler(AGGREGATOR_API_URL, aggregator_api()) - .with_handler(AUTH0_URL, auth0_mock()) -} - -pub fn config() -> ApiConfig { +pub fn config(api_mocks: impl Handler) -> ApiConfig { ApiConfig { session_secret: "x".repeat(32), api_url: "https://api.example".parse().unwrap(), @@ -96,16 +65,18 @@ pub fn config() -> ApiConfig { postmark_token: "-".into(), email_address: "test@example.test".into(), postmark_url: POSTMARK_URL.parse().unwrap(), - client: Client::new(trillium_testing::connector(api_mocks())), + client: Client::new(trillium_testing::connector(api_mocks)), } } -pub async fn build_test_app() -> DivviupApi { - let mut app = DivviupApi::new(config()).await; +pub async fn build_test_app() -> (DivviupApi, ClientLogs) { + let api_mocks = ApiMocks::new(); + let client_logs = api_mocks.client_logs(); + let mut app = DivviupApi::new(config(api_mocks)).await; set_up_schema(app.db()).await; let mut info = "testing".into(); app.init(&mut info).await; - app + (app, client_logs) } pub fn set_up(f: F) @@ -114,11 +85,22 @@ where Fut: Future>> + Send + 'static, { block_on(async move { - let app = build_test_app().await; + let (app, _) = build_test_app().await; f(app).await.unwrap(); }); } +pub fn with_client_logs(f: F) +where + F: FnOnce(DivviupApi, ClientLogs) -> Fut, + Fut: Future>> + Send + 'static, +{ + block_on(async move { + let (app, client_logs) = build_test_app().await; + f(app, client_logs).await.unwrap(); + }); +} + pub mod fixtures { use divviup_api::{aggregator_api_mock, clients::aggregator_client::TaskCreate}; use validator::Validate; diff --git a/tests/jobs.rs b/tests/jobs.rs new file mode 100644 index 00000000..94d68346 --- /dev/null +++ b/tests/jobs.rs @@ -0,0 +1,126 @@ +mod harness; +use divviup_api::{ + entity::queue::Entity, + queue::{one, CreateUser, Job, JobStatus, ResetPassword, SendInvitationEmail}, +}; +use harness::{test, *}; + +#[test(harness = with_client_logs)] +async fn create_account(app: DivviupApi, client_logs: ClientLogs) -> TestResult { + let account = fixtures::account(&app).await; + let email = format!("test-{}@example.test", fixtures::random_name()); + let membership = Membership::build(email, &account)?.insert(app.db()).await?; + let membership_id = membership.id; + let mut job = CreateUser { membership_id }; + let next = job.perform(&app.config().into(), app.db()).await?.unwrap(); + let create_user_request = client_logs.last(); + assert_eq!( + create_user_request.url, + app.config().auth_url.join("/api/v2/users").unwrap() + ); + let user_id = create_user_request.response_json()["user_id"] + .as_str() + .unwrap() + .to_string(); + assert_eq!( + next, + ResetPassword { + membership_id, + user_id + } + ); + Ok(()) +} + +#[test(harness = with_client_logs)] +async fn reset_password(app: DivviupApi, client_logs: ClientLogs) -> TestResult { + let account = fixtures::account(&app).await; + let email = format!("test-{}@example.test", fixtures::random_name()); + let membership = Membership::build(email, &account)?.insert(app.db()).await?; + let membership_id = membership.id; + let mut job = ResetPassword { + membership_id, + user_id: fixtures::random_name(), + }; + + let next = job.perform(&app.config().into(), app.db()).await?.unwrap(); + + let reset_request = client_logs.last(); + assert_eq!( + reset_request.url, + app.config() + .auth_url + .join("/api/v2/tickets/password-change") + .unwrap() + ); + let action_url = reset_request.response_json()["ticket"] + .as_str() + .unwrap() + .parse() + .unwrap(); + assert_eq!( + next, + SendInvitationEmail { + membership_id, + action_url + } + ); + Ok(()) +} + +#[test(harness = with_client_logs)] +async fn send_email(app: DivviupApi, client_logs: ClientLogs) -> TestResult { + let account = fixtures::account(&app).await; + let email = format!("test-{}@example.test", fixtures::random_name()); + let membership = Membership::build(email, &account)?.insert(app.db()).await?; + let membership_id = membership.id; + let mut job = SendInvitationEmail { + membership_id, + action_url: Url::parse("http://any.url/for-now").unwrap(), + }; + + assert!(job.perform(&app.config().into(), app.db()).await?.is_none()); + + let reset_request = client_logs.logs().last().unwrap().clone(); + assert_eq!(reset_request.method, Method::Post); + assert_eq!( + reset_request.url, + app.config() + .postmark_url + .join("/email/withTemplate") + .unwrap() + ); + Ok(()) +} + +#[test(harness = with_client_logs)] +async fn all_together(app: DivviupApi, client_logs: ClientLogs) -> TestResult { + let account = fixtures::account(&app).await; + let email = format!("test-{}@example.test", fixtures::random_name()); + let membership = Membership::build(email, &account)?.insert(app.db()).await?; + + Job::new_invitation_flow(&membership) + .insert(app.db()) + .await?; + + let shared_job_state = app.config().into(); + + while let Some(_) = one(app.db(), &shared_job_state).await? {} + + let full_queue = Entity::find().all(app.db()).await?; + assert_eq!(full_queue.len(), 3); + assert!(full_queue.iter().all(|q| q.status == JobStatus::Success)); + let logs = client_logs.logs(); + assert_eq!( + logs.iter() + .map(ToString::to_string) + .collect::>(), + &[ + "POST https://auth.example/oauth/token: 200 OK", + "POST https://auth.example/api/v2/users: 200 OK", + "POST https://auth.example/api/v2/tickets/password-change: 200 OK", + "POST https://postmark.example/email/withTemplate: 200 OK" + ] + ); + Ok(()) +} diff --git a/tests/memberships.rs b/tests/memberships.rs index 9617cc9a..bc4b3730 100644 --- a/tests/memberships.rs +++ b/tests/memberships.rs @@ -2,6 +2,8 @@ mod harness; use harness::*; mod create { + use divviup_api::{entity::queue, queue::CreateUser}; + use super::{test, *}; #[test(harness = set_up)] @@ -19,12 +21,18 @@ mod create { let membership: Membership = conn.response_json().await; assert_eq!(membership.user_email, "someone.else@example.com"); assert_eq!(membership.account_id, account.id); + let membership_id = membership.id; - assert!(Memberships::find_by_id(membership.id) + assert!(Memberships::find_by_id(membership_id) .one(app.db()) .await? .is_some()); + let queue = queue::Entity::find().all(app.db()).await?; + assert_eq!(queue.len(), 1); + let queue_job = &queue[0]; + assert_eq!(queue_job.job, CreateUser { membership_id }); + // the rest of the invitation process is tested elsewhere Ok(()) } From 91d00aea96c77f62c72118300040a10d4d5ed76d Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Tue, 2 May 2023 13:59:34 -0700 Subject: [PATCH 03/20] just run two queue workers initially --- src/queue.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/queue.rs b/src/queue.rs index 0e7b31d7..29090d14 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -19,7 +19,7 @@ These configuration variables may eventually be useful to put on ApiConfig */ const MAX_RETRY: i32 = 5; const QUEUE_CHECK_INTERVAL: Range = 10..20; -const QUEUE_WORKER_COUNT: u8 = 5; +const QUEUE_WORKER_COUNT: u8 = 2; fn schedule_based_on_failure_count(failure_count: i32) -> Option { if failure_count >= MAX_RETRY { From b251984b888e2c8bd7b06357787d7402f19ec5e6 Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Tue, 2 May 2023 16:16:42 -0700 Subject: [PATCH 04/20] use tracing::error instead of eprintln --- Cargo.lock | 1 + Cargo.toml | 1 + src/queue.rs | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f000c27f..cf9e2401 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -922,6 +922,7 @@ dependencies = [ "thiserror", "time", "tokio", + "tracing", "trillium", "trillium-api", "trillium-caching-headers", diff --git a/Cargo.toml b/Cargo.toml index b3eee445..ef2b32fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ test-harness = "0.1.1" thiserror = "1.0.40" time = { version = "0.3.21", features = ["serde", "serde-well-known"] } tokio = { version = "1.28.1", features = ["full"] } +tracing = "0.1.37" trillium = "0.2.9" trillium-api = { version = "0.2.0-rc.3", default-features = false } trillium-caching-headers = "0.2.1" diff --git a/src/queue.rs b/src/queue.rs index 29090d14..d232eb09 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -93,7 +93,7 @@ fn spawn(join_set: &mut JoinSet<()>, db: &Db, job_state: &Arc) { loop { match one(&db, &job_state).await { Err(e) => { - eprintln!("job error {e}"); + tracing::error!("job error {e}"); } Ok(Some(_)) => {} @@ -114,7 +114,7 @@ pub async fn run(db: Db, config: ApiConfig) { } while join_set.join_next().await.is_some() { - eprintln!("Worker task shut down. Restarting."); + tracing::error!("Worker task shut down. Restarting."); spawn(&mut join_set, &db, &job_state); } } From 0e57e7377123fc6646ec15a95f2f625e9bc69466 Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Tue, 2 May 2023 16:33:18 -0700 Subject: [PATCH 05/20] rename queue::one to queue::dequeue_one --- src/queue.rs | 4 ++-- tests/jobs.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/queue.rs b/src/queue.rs index d232eb09..fc104b1f 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -34,7 +34,7 @@ fn schedule_based_on_failure_count(failure_count: i32) -> Option } } -pub async fn one(db: &Db, job_state: &SharedJobState) -> Result, DbErr> { +pub async fn dequeue_one(db: &Db, job_state: &SharedJobState) -> Result, DbErr> { let tx = db.begin().await?; let model = if let Some(model) = next(&tx).await? { let mut active_model = model.into_active_model(); @@ -91,7 +91,7 @@ fn spawn(join_set: &mut JoinSet<()>, db: &Db, job_state: &Arc) { let job_state = Arc::clone(job_state); join_set.spawn(async move { loop { - match one(&db, &job_state).await { + match dequeue_one(&db, &job_state).await { Err(e) => { tracing::error!("job error {e}"); } diff --git a/tests/jobs.rs b/tests/jobs.rs index 94d68346..f679ebb6 100644 --- a/tests/jobs.rs +++ b/tests/jobs.rs @@ -1,7 +1,7 @@ mod harness; use divviup_api::{ entity::queue::Entity, - queue::{one, CreateUser, Job, JobStatus, ResetPassword, SendInvitationEmail}, + queue::{dequeue_one, CreateUser, Job, JobStatus, ResetPassword, SendInvitationEmail}, }; use harness::{test, *}; @@ -105,7 +105,7 @@ async fn all_together(app: DivviupApi, client_logs: ClientLogs) -> TestResult { let shared_job_state = app.config().into(); - while let Some(_) = one(app.db(), &shared_job_state).await? {} + while dequeue_one(app.db(), &shared_job_state).await?.is_some() {} let full_queue = Entity::find().all(app.db()).await?; assert_eq!(full_queue.len(), 3); From b975023c7b02ebd35d7994d19e892532949af660 Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Tue, 2 May 2023 16:46:36 -0700 Subject: [PATCH 06/20] naming changes aspirationally intended to improve readability and clarity --- src/queue.rs | 54 ++++++++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/src/queue.rs b/src/queue.rs index fc104b1f..cc422d01 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -21,7 +21,7 @@ const MAX_RETRY: i32 = 5; const QUEUE_CHECK_INTERVAL: Range = 10..20; const QUEUE_WORKER_COUNT: u8 = 2; -fn schedule_based_on_failure_count(failure_count: i32) -> Option { +fn reschedule_based_on_failure_count(failure_count: i32) -> Option { if failure_count >= MAX_RETRY { None } else { @@ -36,49 +36,53 @@ fn schedule_based_on_failure_count(failure_count: i32) -> Option pub async fn dequeue_one(db: &Db, job_state: &SharedJobState) -> Result, DbErr> { let tx = db.begin().await?; - let model = if let Some(model) = next(&tx).await? { - let mut active_model = model.into_active_model(); - let mut job = active_model + let model = if let Some(queue_item) = next(&tx).await? { + let mut queue_item = queue_item.into_active_model(); + let mut job = queue_item .job .take() .expect("queue jobs should always have a Job"); let result = job.perform(job_state, &tx).await; - active_model.job = Set(job); - active_model.updated_at = Set(OffsetDateTime::now_utc()); + queue_item.job = Set(job); + match result { - Ok(Some(job)) => { - active_model.status = Set(JobStatus::Success); - active_model.scheduled_at = Set(None); - let mut next_job = ActiveModel::from(job); - next_job.parent_id = Set(Some(*active_model.id.as_ref())); + Ok(Some(next_job)) => { + queue_item.status = Set(JobStatus::Success); + queue_item.scheduled_at = Set(None); + + let mut next_job = ActiveModel::from(next_job); + next_job.parent_id = Set(Some(*queue_item.id.as_ref())); let next_job = next_job.insert(&tx).await?; - active_model.result = Set(Some(JobResult::Child(next_job.id))); + + queue_item.result = Set(Some(JobResult::Child(next_job.id))); } Ok(None) => { - active_model.scheduled_at = Set(None); - active_model.status = Set(JobStatus::Success); - active_model.result = Set(Some(JobResult::Complete)); + queue_item.scheduled_at = Set(None); + queue_item.status = Set(JobStatus::Success); + queue_item.result = Set(Some(JobResult::Complete)); } Err(e) if e.is_retryable() => { - active_model.failure_count = Set(active_model.failure_count.as_ref() + 1); + queue_item.failure_count = Set(queue_item.failure_count.as_ref() + 1); let reschedule = - schedule_based_on_failure_count(*active_model.failure_count.as_ref()); - active_model.status = + reschedule_based_on_failure_count(*queue_item.failure_count.as_ref()); + queue_item.status = Set(reschedule.map_or(JobStatus::Failed, |_| JobStatus::Pending)); - active_model.scheduled_at = Set(reschedule); - active_model.result = Set(Some(JobResult::Error(e))); + queue_item.scheduled_at = Set(reschedule); + queue_item.result = Set(Some(JobResult::Error(e))); } Err(e) => { - active_model.failure_count = Set(active_model.failure_count.as_ref() + 1); - active_model.scheduled_at = Set(None); - active_model.status = Set(JobStatus::Failed); - active_model.result = Set(Some(JobResult::Error(e))); + queue_item.failure_count = Set(queue_item.failure_count.as_ref() + 1); + queue_item.scheduled_at = Set(None); + queue_item.status = Set(JobStatus::Failed); + queue_item.result = Set(Some(JobResult::Error(e))); } } - Some(active_model.update(&tx).await?) + + queue_item.updated_at = Set(OffsetDateTime::now_utc()); + Some(queue_item.update(&tx).await?) } else { None }; From 80fcc4ac480513dac82b612634fed37f3cc1cdd9 Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Tue, 2 May 2023 17:49:45 -0700 Subject: [PATCH 07/20] use a stopper for partial graceful shutdown Trillium needs a change in order to expose CloneCounter for even more graceful shutdown --- src/bin.rs | 14 +++++++++----- src/queue.rs | 17 ++++++++++++----- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/bin.rs b/src/bin.rs index 967f527f..d10c9bf3 100644 --- a/src/bin.rs +++ b/src/bin.rs @@ -27,13 +27,17 @@ async fn main() { .run_async(divviup_api::aggregator_api_mock::aggregator_api()), ); } + let app = DivviupApi::new(config).await; - let db = app.db().clone(); - let config = app.config().clone(); - tokio::task::spawn(async move { - divviup_api::queue::run(db, config).await; - }); + { + let db = app.db().clone(); + let config = app.config().clone(); + let stopper = stopper.clone(); + tokio::task::spawn(async move { + divviup_api::queue::run(stopper, db, config).await; + }); + } trillium_tokio::config() .with_stopper(stopper) diff --git a/src/queue.rs b/src/queue.rs index cc422d01..c3783150 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -1,6 +1,7 @@ mod job; pub use crate::entity::queue::{JobResult, JobStatus}; pub use job::*; +use trillium_http::Stopper; use crate::{ entity::queue::{ActiveModel, Entity, Model}, @@ -90,11 +91,15 @@ pub async fn dequeue_one(db: &Db, job_state: &SharedJobState) -> Result, db: &Db, job_state: &Arc) { +fn spawn(stopper: Stopper, join_set: &mut JoinSet<()>, db: &Db, job_state: &Arc) { let db = db.clone(); let job_state = Arc::clone(job_state); join_set.spawn(async move { loop { + if stopper.is_stopped() { + break; + } + match dequeue_one(&db, &job_state).await { Err(e) => { tracing::error!("job error {e}"); @@ -110,16 +115,18 @@ fn spawn(join_set: &mut JoinSet<()>, db: &Db, job_state: &Arc) { }); } -pub async fn run(db: Db, config: ApiConfig) { +pub async fn run(stopper: Stopper, db: Db, config: ApiConfig) { let mut join_set = JoinSet::new(); let job_state = Arc::new(SharedJobState::from(&config)); for _ in 0..QUEUE_WORKER_COUNT { - spawn(&mut join_set, &db, &job_state); + spawn(stopper.clone(), &mut join_set, &db, &job_state); } while join_set.join_next().await.is_some() { - tracing::error!("Worker task shut down. Restarting."); - spawn(&mut join_set, &db, &job_state); + if !stopper.is_stopped() { + tracing::error!("Worker task shut down. Restarting."); + spawn(stopper.clone(), &mut join_set, &db, &job_state); + } } } From 5741a5bcd7ec9fad47df178239fa9948b30e7837 Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Tue, 2 May 2023 18:01:47 -0700 Subject: [PATCH 08/20] use rand for password generation --- src/clients/auth0_client.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/clients/auth0_client.rs b/src/clients/auth0_client.rs index 7d07bc32..065b8c3f 100644 --- a/src/clients/auth0_client.rs +++ b/src/clients/auth0_client.rs @@ -1,4 +1,5 @@ use async_lock::RwLock; +use rand::distributions::{Alphanumeric, DistString}; use serde::{de::DeserializeOwned, Serialize}; use serde_json::{json, Value}; use std::{ @@ -36,6 +37,10 @@ impl FromConn for Auth0Client { } } +fn generate_password() -> String { + Alphanumeric.sample_string(&mut rand::thread_rng(), 60) +} + impl Auth0Client { pub fn new(config: &ApiConfig) -> Self { Self { @@ -83,12 +88,17 @@ impl Auth0Client { } pub async fn create_user(&self, email: &str) -> Result { - let user: serde_json::Value = self.post("/api/v2/users", &json!({ - "connection": "Username-Password-Authentication", - "email": email, - "password": std::iter::repeat_with(fastrand::alphanumeric).take(60).collect::(), - "verify_email": false - })).await?; + let user: serde_json::Value = self + .post( + "/api/v2/users", + &json!({ + "connection": "Username-Password-Authentication", + "email": email, + "password": generate_password(), + "verify_email": false + }), + ) + .await?; user.get("user_id") .ok_or_else(|| ClientError::Other("expected user_id".into()))? From 60e932e40cc792b7c53964afa2ac6e73c9945074 Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Wed, 3 May 2023 11:12:23 -0700 Subject: [PATCH 09/20] Update src/queue.rs Co-authored-by: David Cook --- src/queue.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/queue.rs b/src/queue.rs index c3783150..539ddeaa 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -29,7 +29,7 @@ fn reschedule_based_on_failure_count(failure_count: i32) -> Option Date: Wed, 3 May 2023 11:41:38 -0700 Subject: [PATCH 10/20] improved graceful-shutdown and sea-orm native query --- src/bin.rs | 55 ++++++++++++++---------------- src/entity/queue.rs | 27 ++++++++++++++- src/queue.rs | 81 +++++++++++++++++++++++---------------------- src/telemetry.rs | 27 ++++----------- tests/jobs.rs | 5 ++- 5 files changed, 103 insertions(+), 92 deletions(-) diff --git a/src/bin.rs b/src/bin.rs index d10c9bf3..7cf44a3a 100644 --- a/src/bin.rs +++ b/src/bin.rs @@ -1,7 +1,7 @@ -use std::panic; +use divviup_api::{ApiConfig, DivviupApi}; -use divviup_api::{telemetry::install_metrics_exporter, ApiConfig, DivviupApi}; use trillium_http::Stopper; +use trillium_tokio::CloneCounterObserver; #[tokio::main] async fn main() { @@ -9,44 +9,37 @@ async fn main() { let config = ApiConfig::from_env().expect("Missing config"); let stopper = Stopper::new(); + let observer = CloneCounterObserver::default(); - let metrics_task_handle = install_metrics_exporter( - &config.prometheus_host, - config.prometheus_port, - stopper.clone(), - ) - .expect("Error setting up metrics"); + trillium_tokio::config() + .without_signals() + .with_port(config.prometheus_port) + .with_host(&config.prometheus_host) + .with_observer(observer.clone()) + .with_stopper(stopper.clone()) + .spawn(divviup_api::telemetry::metrics_exporter().unwrap()); #[cfg(all(debug_assertions, feature = "aggregator-api-mock"))] if let Some(port) = config.aggregator_api_url.port() { - tokio::task::spawn( - trillium_tokio::config() - .without_signals() - .with_port(port) - .with_stopper(stopper.clone()) - .run_async(divviup_api::aggregator_api_mock::aggregator_api()), - ); + trillium_tokio::config() + .without_signals() + .with_port(port) + .with_observer(observer.clone()) + .with_stopper(stopper.clone()) + .spawn(divviup_api::aggregator_api_mock::aggregator_api()); } let app = DivviupApi::new(config).await; - - { - let db = app.db().clone(); - let config = app.config().clone(); - let stopper = stopper.clone(); - tokio::task::spawn(async move { - divviup_api::queue::run(stopper, db, config).await; - }); - } + divviup_api::queue::spawn_workers( + observer.clone(), + stopper.clone(), + app.db().clone(), + app.config().clone(), + ); trillium_tokio::config() .with_stopper(stopper) - .run_async(app) + .with_observer(observer) + .spawn(app) .await; - - if let Err(e) = metrics_task_handle.await { - if let Ok(reason) = e.try_into_panic() { - panic::resume_unwind(reason); - } - } } diff --git a/src/entity/queue.rs b/src/entity/queue.rs index fbc616a2..ba335e3f 100644 --- a/src/entity/queue.rs +++ b/src/entity/queue.rs @@ -2,7 +2,11 @@ use crate::{ json_newtype, queue::{Job, JobError}, }; -use sea_orm::{entity::prelude::*, Set}; +use sea_orm::{ + entity::prelude::*, + sea_query::{self, all, any, LockBehavior, LockType}, + DatabaseTransaction, QueryOrder, QuerySelect, Set, +}; use serde::{Deserialize, Serialize}; use std::fmt::Debug; use time::OffsetDateTime; @@ -66,3 +70,24 @@ impl From for ActiveModel { } } } + +impl Entity { + pub async fn next(tx: &DatabaseTransaction) -> Result, DbErr> { + use Column::*; + let mut select = Entity::find() + .filter(all![ + Status.eq(JobStatus::Pending), + any![ + ScheduledAt.is_null(), + ScheduledAt.lt(OffsetDateTime::now_utc()) + ] + ]) + .order_by_asc(CreatedAt) + .limit(1); + + QuerySelect::query(&mut select) + .lock_with_behavior(LockType::Update, LockBehavior::SkipLocked); + + select.one(tx).await + } +} diff --git a/src/queue.rs b/src/queue.rs index 539ddeaa..d57033c1 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -1,19 +1,16 @@ mod job; pub use crate::entity::queue::{JobResult, JobStatus}; pub use job::*; -use trillium_http::Stopper; use crate::{ entity::queue::{ActiveModel, Entity, Model}, ApiConfig, Db, }; -use sea_orm::{ - ActiveModelTrait, ConnectionTrait, DatabaseBackend, DatabaseTransaction, DbErr, EntityTrait, - IntoActiveModel, Set, Statement, TransactionTrait, -}; +use sea_orm::{ActiveModelTrait, DbErr, IntoActiveModel, Set, TransactionTrait}; use std::{ops::Range, sync::Arc, time::Duration}; use time::OffsetDateTime; use tokio::{task::JoinSet, time::sleep}; +use trillium_tokio::{CloneCounterObserver, Stopper}; /* These configuration variables may eventually be useful to put on ApiConfig @@ -35,9 +32,14 @@ fn reschedule_based_on_failure_count(failure_count: i32) -> Option Result, DbErr> { +pub async fn dequeue_one( + db: &Db, + observer: &CloneCounterObserver, + job_state: &SharedJobState, +) -> Result, DbErr> { let tx = db.begin().await?; - let model = if let Some(queue_item) = next(&tx).await? { + let model = if let Some(queue_item) = Entity::next(&tx).await? { + let _counter = observer.counter(); let mut queue_item = queue_item.into_active_model(); let mut job = queue_item .job @@ -91,7 +93,13 @@ pub async fn dequeue_one(db: &Db, job_state: &SharedJobState) -> Result, db: &Db, job_state: &Arc) { +fn spawn( + observer: CloneCounterObserver, + stopper: Stopper, + join_set: &mut JoinSet<()>, + db: &Db, + job_state: &Arc, +) { let db = db.clone(); let job_state = Arc::clone(job_state); join_set.spawn(async move { @@ -100,7 +108,7 @@ fn spawn(stopper: Stopper, join_set: &mut JoinSet<()>, db: &Db, job_state: &Arc< break; } - match dequeue_one(&db, &job_state).await { + match dequeue_one(&db, &observer, &job_state).await { Err(e) => { tracing::error!("job error {e}"); } @@ -108,52 +116,47 @@ fn spawn(stopper: Stopper, join_set: &mut JoinSet<()>, db: &Db, job_state: &Arc< Ok(Some(_)) => {} Ok(None) => { - sleep(Duration::from_secs(fastrand::u64(QUEUE_CHECK_INTERVAL))).await; + let sleep_future = + sleep(Duration::from_secs(fastrand::u64(QUEUE_CHECK_INTERVAL))); + stopper.stop_future(sleep_future).await; } } } }); } -pub async fn run(stopper: Stopper, db: Db, config: ApiConfig) { +pub async fn run(observer: CloneCounterObserver, stopper: Stopper, db: Db, config: ApiConfig) { let mut join_set = JoinSet::new(); let job_state = Arc::new(SharedJobState::from(&config)); for _ in 0..QUEUE_WORKER_COUNT { - spawn(stopper.clone(), &mut join_set, &db, &job_state); + spawn( + observer.clone(), + stopper.clone(), + &mut join_set, + &db, + &job_state, + ); } while join_set.join_next().await.is_some() { if !stopper.is_stopped() { tracing::error!("Worker task shut down. Restarting."); - spawn(stopper.clone(), &mut join_set, &db, &job_state); + spawn( + observer.clone(), + stopper.clone(), + &mut join_set, + &db, + &job_state, + ); } } } -async fn next(tx: &DatabaseTransaction) -> Result, DbErr> { - let select = match tx.get_database_backend() { - backend @ DatabaseBackend::Postgres => Statement::from_sql_and_values( - backend, - r#"SELECT * FROM queue - WHERE status = $1 AND (scheduled_at IS NULL OR scheduled_at < $2) - ORDER BY updated_at ASC - FOR UPDATE - SKIP LOCKED - LIMIT 1"#, - [JobStatus::Pending.into(), OffsetDateTime::now_utc().into()], - ), - - backend @ DatabaseBackend::Sqlite => Statement::from_sql_and_values( - backend, - r#"SELECT * FROM queue - WHERE status = $1 AND (scheduled_at IS NULL OR scheduled_at < $2) - ORDER BY updated_at ASC - LIMIT 1"#, - [JobStatus::Pending.into(), OffsetDateTime::now_utc().into()], - ), - - _ => unimplemented!(), - }; - - Entity::find().from_raw_sql(select).one(tx).await +pub fn spawn_workers( + observer: CloneCounterObserver, + stopper: Stopper, + db: Db, + config: ApiConfig, +) -> tokio::task::JoinHandle<()> { + tokio::task::spawn(async move { run(observer, stopper, db, config).await }) } diff --git a/src/telemetry.rs b/src/telemetry.rs index 316cc253..f0e14f06 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -1,5 +1,3 @@ -use std::sync::Arc; - use git_version::git_version; use opentelemetry::{ global, @@ -10,16 +8,12 @@ use opentelemetry::{ }, Context, KeyValue, }; -use tokio::{spawn, task::JoinHandle}; -use trillium_http::Stopper; +use std::sync::Arc; -/// Install a Prometheus metrics provider and exporter. The OpenTelemetry global API can be used to -/// create and update instruments, and they will be sent through this exporter. -pub fn install_metrics_exporter( - host: &str, - port: u16, - stopper: Stopper, -) -> Result, MetricsError> { +/// Install a Prometheus metrics provider and exporter. The +/// OpenTelemetry global API can be used to create and update +/// instruments, and they will be sent through this exporter. +pub fn metrics_exporter() -> Result { let exporter = Arc::new( opentelemetry_prometheus::exporter( controllers::basic(processors::factory( @@ -55,14 +49,7 @@ pub fn install_metrics_exporter( ], ); - let router = trillium_prometheus::text_format_handler(exporter.registry().clone()); - - Ok(spawn( - trillium_tokio::config() - .with_host(host) - .with_port(port) - .without_signals() - .with_stopper(stopper) - .run_async(router), + Ok(trillium_prometheus::text_format_handler( + exporter.registry().clone(), )) } diff --git a/tests/jobs.rs b/tests/jobs.rs index f679ebb6..cd93e91c 100644 --- a/tests/jobs.rs +++ b/tests/jobs.rs @@ -105,7 +105,10 @@ async fn all_together(app: DivviupApi, client_logs: ClientLogs) -> TestResult { let shared_job_state = app.config().into(); - while dequeue_one(app.db(), &shared_job_state).await?.is_some() {} + while dequeue_one(app.db(), &Default::default(), &shared_job_state) + .await? + .is_some() + {} let full_queue = Entity::find().all(app.db()).await?; assert_eq!(full_queue.len(), 3); From 18c809d494654b3a6a3ee5c6cdb84105bd403834 Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Wed, 3 May 2023 12:25:16 -0700 Subject: [PATCH 11/20] wrap up queue implementation into a Queue struct --- src/bin.rs | 13 ++- src/lib.rs | 1 + src/queue.rs | 223 +++++++++++++++++++++++++------------------------- tests/jobs.rs | 10 +-- 4 files changed, 122 insertions(+), 125 deletions(-) diff --git a/src/bin.rs b/src/bin.rs index 7cf44a3a..ec02cb44 100644 --- a/src/bin.rs +++ b/src/bin.rs @@ -1,4 +1,4 @@ -use divviup_api::{ApiConfig, DivviupApi}; +use divviup_api::{ApiConfig, DivviupApi, Queue}; use trillium_http::Stopper; use trillium_tokio::CloneCounterObserver; @@ -30,12 +30,11 @@ async fn main() { } let app = DivviupApi::new(config).await; - divviup_api::queue::spawn_workers( - observer.clone(), - stopper.clone(), - app.db().clone(), - app.config().clone(), - ); + + Queue::new(app.db(), app.config()) + .with_observer(observer.clone()) + .with_stopper(stopper.clone()) + .spawn_workers(); trillium_tokio::config() .with_stopper(stopper) diff --git a/src/lib.rs b/src/lib.rs index f26b61e8..0ce6a541 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,5 +22,6 @@ mod user; pub use config::{ApiConfig, ApiConfigError}; pub use db::Db; pub use handler::DivviupApi; +pub use queue::Queue; pub use routes::routes; pub use user::{User, USER_SESSION_KEY}; diff --git a/src/queue.rs b/src/queue.rs index d57033c1..285fc342 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -9,9 +9,19 @@ use crate::{ use sea_orm::{ActiveModelTrait, DbErr, IntoActiveModel, Set, TransactionTrait}; use std::{ops::Range, sync::Arc, time::Duration}; use time::OffsetDateTime; -use tokio::{task::JoinSet, time::sleep}; +use tokio::{ + task::{JoinHandle, JoinSet}, + time::sleep, +}; use trillium_tokio::{CloneCounterObserver, Stopper}; +#[derive(Clone, Debug)] +pub struct Queue { + observer: CloneCounterObserver, + stopper: Stopper, + db: Db, + job_state: Arc, +} /* These configuration variables may eventually be useful to put on ApiConfig */ @@ -32,131 +42,122 @@ fn reschedule_based_on_failure_count(failure_count: i32) -> Option Result, DbErr> { - let tx = db.begin().await?; - let model = if let Some(queue_item) = Entity::next(&tx).await? { - let _counter = observer.counter(); - let mut queue_item = queue_item.into_active_model(); - let mut job = queue_item - .job - .take() - .expect("queue jobs should always have a Job"); - let result = job.perform(job_state, &tx).await; - queue_item.job = Set(job); - - match result { - Ok(Some(next_job)) => { - queue_item.status = Set(JobStatus::Success); - queue_item.scheduled_at = Set(None); - - let mut next_job = ActiveModel::from(next_job); - next_job.parent_id = Set(Some(*queue_item.id.as_ref())); - let next_job = next_job.insert(&tx).await?; - - queue_item.result = Set(Some(JobResult::Child(next_job.id))); - } +impl Queue { + pub fn new(db: &Db, config: &ApiConfig) -> Self { + Self { + observer: Default::default(), + db: db.clone(), + stopper: Default::default(), + job_state: Arc::new(config.into()), + } + } - Ok(None) => { - queue_item.scheduled_at = Set(None); - queue_item.status = Set(JobStatus::Success); - queue_item.result = Set(Some(JobResult::Complete)); - } + pub fn with_observer(mut self, observer: CloneCounterObserver) -> Self { + self.observer = observer; + self + } - Err(e) if e.is_retryable() => { - queue_item.failure_count = Set(queue_item.failure_count.as_ref() + 1); - let reschedule = - reschedule_based_on_failure_count(*queue_item.failure_count.as_ref()); - queue_item.status = - Set(reschedule.map_or(JobStatus::Failed, |_| JobStatus::Pending)); - queue_item.scheduled_at = Set(reschedule); - queue_item.result = Set(Some(JobResult::Error(e))); - } + pub fn with_stopper(mut self, stopper: Stopper) -> Self { + self.stopper = stopper; + self + } - Err(e) => { - queue_item.failure_count = Set(queue_item.failure_count.as_ref() + 1); - queue_item.scheduled_at = Set(None); - queue_item.status = Set(JobStatus::Failed); - queue_item.result = Set(Some(JobResult::Error(e))); - } - } + pub async fn perform_one_queue_job(&self) -> Result, DbErr> { + let tx = self.db.begin().await?; + let model = if let Some(queue_item) = Entity::next(&tx).await? { + let _counter = self.observer.counter(); + let mut queue_item = queue_item.into_active_model(); + let mut job = queue_item + .job + .take() + .expect("queue jobs should always have a Job"); + let result = job.perform(&self.job_state, &tx).await; + queue_item.job = Set(job); + + match result { + Ok(Some(next_job)) => { + queue_item.status = Set(JobStatus::Success); + queue_item.scheduled_at = Set(None); + + let mut next_job = ActiveModel::from(next_job); + next_job.parent_id = Set(Some(*queue_item.id.as_ref())); + let next_job = next_job.insert(&tx).await?; + + queue_item.result = Set(Some(JobResult::Child(next_job.id))); + } - queue_item.updated_at = Set(OffsetDateTime::now_utc()); - Some(queue_item.update(&tx).await?) - } else { - None - }; - tx.commit().await?; - Ok(model) -} + Ok(None) => { + queue_item.scheduled_at = Set(None); + queue_item.status = Set(JobStatus::Success); + queue_item.result = Set(Some(JobResult::Complete)); + } -fn spawn( - observer: CloneCounterObserver, - stopper: Stopper, - join_set: &mut JoinSet<()>, - db: &Db, - job_state: &Arc, -) { - let db = db.clone(); - let job_state = Arc::clone(job_state); - join_set.spawn(async move { - loop { - if stopper.is_stopped() { - break; - } + Err(e) if e.is_retryable() => { + queue_item.failure_count = Set(queue_item.failure_count.as_ref() + 1); + let reschedule = + reschedule_based_on_failure_count(*queue_item.failure_count.as_ref()); + queue_item.status = + Set(reschedule.map_or(JobStatus::Failed, |_| JobStatus::Pending)); + queue_item.scheduled_at = Set(reschedule); + queue_item.result = Set(Some(JobResult::Error(e))); + } - match dequeue_one(&db, &observer, &job_state).await { Err(e) => { - tracing::error!("job error {e}"); + queue_item.failure_count = Set(queue_item.failure_count.as_ref() + 1); + queue_item.scheduled_at = Set(None); + queue_item.status = Set(JobStatus::Failed); + queue_item.result = Set(Some(JobResult::Error(e))); } + } - Ok(Some(_)) => {} + queue_item.updated_at = Set(OffsetDateTime::now_utc()); + Some(queue_item.update(&tx).await?) + } else { + None + }; + tx.commit().await?; + Ok(model) + } - Ok(None) => { - let sleep_future = - sleep(Duration::from_secs(fastrand::u64(QUEUE_CHECK_INTERVAL))); - stopper.stop_future(sleep_future).await; + fn spawn_worker(self, join_set: &mut JoinSet<()>) { + join_set.spawn(async move { + loop { + if self.stopper.is_stopped() { + break; } - } - } - }); -} -pub async fn run(observer: CloneCounterObserver, stopper: Stopper, db: Db, config: ApiConfig) { - let mut join_set = JoinSet::new(); - let job_state = Arc::new(SharedJobState::from(&config)); - for _ in 0..QUEUE_WORKER_COUNT { - spawn( - observer.clone(), - stopper.clone(), - &mut join_set, - &db, - &job_state, - ); + match self.perform_one_queue_job().await { + Err(e) => { + tracing::error!("job error {e}"); + } + + Ok(Some(_)) => {} + + Ok(None) => { + let sleep_future = + sleep(Duration::from_secs(fastrand::u64(QUEUE_CHECK_INTERVAL))); + self.stopper.stop_future(sleep_future).await; + } + } + } + }); } - while join_set.join_next().await.is_some() { - if !stopper.is_stopped() { - tracing::error!("Worker task shut down. Restarting."); - spawn( - observer.clone(), - stopper.clone(), - &mut join_set, - &db, - &job_state, - ); + async fn supervise_workers(self) { + let mut join_set = JoinSet::new(); + for _ in 0..QUEUE_WORKER_COUNT { + self.clone().spawn_worker(&mut join_set); + } + + while join_set.join_next().await.is_some() { + if !self.stopper.is_stopped() { + tracing::error!("Worker task shut down. Restarting."); + self.clone().spawn_worker(&mut join_set); + } } } -} -pub fn spawn_workers( - observer: CloneCounterObserver, - stopper: Stopper, - db: Db, - config: ApiConfig, -) -> tokio::task::JoinHandle<()> { - tokio::task::spawn(async move { run(observer, stopper, db, config).await }) + pub fn spawn_workers(self) -> JoinHandle<()> { + tokio::task::spawn(self.supervise_workers()) + } } diff --git a/tests/jobs.rs b/tests/jobs.rs index cd93e91c..0c66bdc9 100644 --- a/tests/jobs.rs +++ b/tests/jobs.rs @@ -1,7 +1,7 @@ mod harness; use divviup_api::{ entity::queue::Entity, - queue::{dequeue_one, CreateUser, Job, JobStatus, ResetPassword, SendInvitationEmail}, + queue::{CreateUser, Job, JobStatus, Queue, ResetPassword, SendInvitationEmail}, }; use harness::{test, *}; @@ -103,12 +103,8 @@ async fn all_together(app: DivviupApi, client_logs: ClientLogs) -> TestResult { .insert(app.db()) .await?; - let shared_job_state = app.config().into(); - - while dequeue_one(app.db(), &Default::default(), &shared_job_state) - .await? - .is_some() - {} + let queue = Queue::new(app.db(), app.config()); + while queue.perform_one_queue_job().await?.is_some() {} let full_queue = Entity::find().all(app.db()).await?; assert_eq!(full_queue.len(), 3); From 41fbe28e69040d980c523adac170d6130c9a979c Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Wed, 3 May 2023 12:43:23 -0700 Subject: [PATCH 12/20] test cleanup --- tests/jobs.rs | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/tests/jobs.rs b/tests/jobs.rs index 0c66bdc9..07a30c0e 100644 --- a/tests/jobs.rs +++ b/tests/jobs.rs @@ -5,11 +5,15 @@ use divviup_api::{ }; use harness::{test, *}; -#[test(harness = with_client_logs)] -async fn create_account(app: DivviupApi, client_logs: ClientLogs) -> TestResult { +async fn build_membership(app: &DivviupApi) -> Result> { let account = fixtures::account(&app).await; let email = format!("test-{}@example.test", fixtures::random_name()); - let membership = Membership::build(email, &account)?.insert(app.db()).await?; + Ok(Membership::build(email, &account)?.insert(app.db()).await?) +} + +#[test(harness = with_client_logs)] +async fn create_account(app: DivviupApi, client_logs: ClientLogs) -> TestResult { + let membership = build_membership(&app).await?; let membership_id = membership.id; let mut job = CreateUser { membership_id }; let next = job.perform(&app.config().into(), app.db()).await?.unwrap(); @@ -34,9 +38,7 @@ async fn create_account(app: DivviupApi, client_logs: ClientLogs) -> TestResult #[test(harness = with_client_logs)] async fn reset_password(app: DivviupApi, client_logs: ClientLogs) -> TestResult { - let account = fixtures::account(&app).await; - let email = format!("test-{}@example.test", fixtures::random_name()); - let membership = Membership::build(email, &account)?.insert(app.db()).await?; + let membership = build_membership(&app).await?; let membership_id = membership.id; let mut job = ResetPassword { membership_id, @@ -70,9 +72,7 @@ async fn reset_password(app: DivviupApi, client_logs: ClientLogs) -> TestResult #[test(harness = with_client_logs)] async fn send_email(app: DivviupApi, client_logs: ClientLogs) -> TestResult { - let account = fixtures::account(&app).await; - let email = format!("test-{}@example.test", fixtures::random_name()); - let membership = Membership::build(email, &account)?.insert(app.db()).await?; + let membership = build_membership(&app).await?; let membership_id = membership.id; let mut job = SendInvitationEmail { membership_id, @@ -95,18 +95,20 @@ async fn send_email(app: DivviupApi, client_logs: ClientLogs) -> TestResult { #[test(harness = with_client_logs)] async fn all_together(app: DivviupApi, client_logs: ClientLogs) -> TestResult { - let account = fixtures::account(&app).await; - let email = format!("test-{}@example.test", fixtures::random_name()); - let membership = Membership::build(email, &account)?.insert(app.db()).await?; + let membership = build_membership(&app).await?; Job::new_invitation_flow(&membership) .insert(app.db()) .await?; + let mut completed_queue_jobs = vec![]; let queue = Queue::new(app.db(), app.config()); - while queue.perform_one_queue_job().await?.is_some() {} + while let Some(queue_job) = queue.perform_one_queue_job().await? { + completed_queue_jobs.push(queue_job); + } let full_queue = Entity::find().all(app.db()).await?; + assert_eq!(completed_queue_jobs, full_queue); assert_eq!(full_queue.len(), 3); assert!(full_queue.iter().all(|q| q.status == JobStatus::Success)); let logs = client_logs.logs(); From 31b820121fef05740a5df2225595e9f27d9f5110 Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Wed, 3 May 2023 14:44:12 -0700 Subject: [PATCH 13/20] add a message id to invitation emails --- src/clients/auth0_client.rs | 1 + src/clients/postmark_client.rs | 12 +++++++++++- src/queue/job/v1/send_invitation_email.rs | 14 ++++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/clients/auth0_client.rs b/src/clients/auth0_client.rs index 065b8c3f..9268f35d 100644 --- a/src/clients/auth0_client.rs +++ b/src/clients/auth0_client.rs @@ -69,6 +69,7 @@ impl Auth0Client { "account_name": account_name, "action_url": reset }), + None, ) .await?; diff --git a/src/clients/postmark_client.rs b/src/clients/postmark_client.rs index 90c5133b..6db2c9d0 100644 --- a/src/clients/postmark_client.rs +++ b/src/clients/postmark_client.rs @@ -48,14 +48,24 @@ impl PostmarkClient { to: &str, template_name: &str, model: &impl Serialize, + message_id: Option, ) -> Result { + let headers = match message_id { + Some(m) => json!([{ + "Name": "Message-ID", + "Value": format!("<{m}>") + }]), + None => json!([]), + }; + self.post( "/email/withTemplate", &json!({ "To": to, "From": self.email, "TemplateAlias": template_name, - "TemplateModel": model + "TemplateModel": model, + "Headers": headers }), ) .await diff --git a/src/queue/job/v1/send_invitation_email.rs b/src/queue/job/v1/send_invitation_email.rs index b401f3bd..e4bc0115 100644 --- a/src/queue/job/v1/send_invitation_email.rs +++ b/src/queue/job/v1/send_invitation_email.rs @@ -4,10 +4,15 @@ use crate::{ }; use sea_orm::{ConnectionTrait, EntityTrait}; use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, +}; use url::Url; use uuid::Uuid; -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] pub struct SendInvitationEmail { pub membership_id: Uuid, pub action_url: Url, @@ -31,16 +36,21 @@ impl SendInvitationEmail { JobError::MissingRecord(String::from("account"), membership.account_id.to_string()) })?; + let mut hasher = DefaultHasher::new(); + self.hash(&mut hasher); + let hash = hasher.finish(); + job_state .postmark_client .send_email_template( &membership.user_email, "user-invitation", - &serde_json::json!({ + &json!({ "email": membership.user_email, "account_name": &account.name, "action_url": self.action_url }), + Some(format!("{hash}@divviup.org")), ) .await?; From 6451d6043973431e2bb6e67247b2abd94136b46d Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Wed, 3 May 2023 16:22:20 -0700 Subject: [PATCH 14/20] don't unwrap --- src/queue.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/queue.rs b/src/queue.rs index 285fc342..0e461799 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -67,10 +67,14 @@ impl Queue { let model = if let Some(queue_item) = Entity::next(&tx).await? { let _counter = self.observer.counter(); let mut queue_item = queue_item.into_active_model(); - let mut job = queue_item - .job - .take() - .expect("queue jobs should always have a Job"); + + let mut job = queue_item.job.take().ok_or_else(|| { + DbErr::Custom(String::from( + r#"Queue item found without a job. + We believe this to be unreachable"#, + )) + })?; + let result = job.perform(&self.job_state, &tx).await; queue_item.job = Set(job); From 233bc9808a209e6270ccac19d1a1723b48c45867 Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Fri, 5 May 2023 11:34:41 -0700 Subject: [PATCH 15/20] use a uuid for message id instead of hashing the job --- src/queue/job/v1/reset_password.rs | 1 + src/queue/job/v1/send_invitation_email.rs | 13 +++---------- tests/jobs.rs | 15 +++++++-------- 3 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/queue/job/v1/reset_password.rs b/src/queue/job/v1/reset_password.rs index 99703600..6aeb982a 100644 --- a/src/queue/job/v1/reset_password.rs +++ b/src/queue/job/v1/reset_password.rs @@ -19,6 +19,7 @@ impl ResetPassword { Ok(Some(Job::from(SendInvitationEmail { membership_id: self.membership_id, action_url, + message_id: Uuid::new_v4(), }))) } } diff --git a/src/queue/job/v1/send_invitation_email.rs b/src/queue/job/v1/send_invitation_email.rs index e4bc0115..33f7a293 100644 --- a/src/queue/job/v1/send_invitation_email.rs +++ b/src/queue/job/v1/send_invitation_email.rs @@ -5,17 +5,14 @@ use crate::{ use sea_orm::{ConnectionTrait, EntityTrait}; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::{ - collections::hash_map::DefaultHasher, - hash::{Hash, Hasher}, -}; use url::Url; use uuid::Uuid; -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct SendInvitationEmail { pub membership_id: Uuid, pub action_url: Url, + pub message_id: Uuid, } impl SendInvitationEmail { @@ -36,10 +33,6 @@ impl SendInvitationEmail { JobError::MissingRecord(String::from("account"), membership.account_id.to_string()) })?; - let mut hasher = DefaultHasher::new(); - self.hash(&mut hasher); - let hash = hasher.finish(); - job_state .postmark_client .send_email_template( @@ -50,7 +43,7 @@ impl SendInvitationEmail { "account_name": &account.name, "action_url": self.action_url }), - Some(format!("{hash}@divviup.org")), + Some(format!("{}@divviup.org", self.message_id)), ) .await?; diff --git a/tests/jobs.rs b/tests/jobs.rs index 07a30c0e..ebb4bed0 100644 --- a/tests/jobs.rs +++ b/tests/jobs.rs @@ -1,9 +1,10 @@ mod harness; use divviup_api::{ entity::queue::Entity, - queue::{CreateUser, Job, JobStatus, Queue, ResetPassword, SendInvitationEmail}, + queue::{CreateUser, Job, JobStatus, Queue, ResetPassword, SendInvitationEmail, V1}, }; use harness::{test, *}; +use uuid::Uuid; async fn build_membership(app: &DivviupApi) -> Result> { let account = fixtures::account(&app).await; @@ -60,13 +61,10 @@ async fn reset_password(app: DivviupApi, client_logs: ClientLogs) -> TestResult .unwrap() .parse() .unwrap(); - assert_eq!( - next, - SendInvitationEmail { - membership_id, - action_url - } - ); + + let Job::V1(V1::SendInvitationEmail(next)) = next else { panic!() }; + assert_eq!(next.membership_id, membership_id); + assert_eq!(next.action_url, action_url); Ok(()) } @@ -77,6 +75,7 @@ async fn send_email(app: DivviupApi, client_logs: ClientLogs) -> TestResult { let mut job = SendInvitationEmail { membership_id, action_url: Url::parse("http://any.url/for-now").unwrap(), + message_id: Uuid::new_v4(), }; assert!(job.perform(&app.config().into(), app.db()).await?.is_none()); From 223b8f79507bae19a8af3fcff796343f67e57be1 Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Wed, 10 May 2023 14:23:10 -0700 Subject: [PATCH 16/20] add api endpoints and initial ui for queue --- app/package-lock.json | 102 +++++++++++++++++++++++++++++- app/package.json | 4 ++ app/src/ApiClient.ts | 24 ++++++- app/src/Header.tsx | 11 ++++ app/src/TaskForm.tsx | 4 +- app/src/admin/Queue.tsx | 117 +++++++++++++++++++++++++++++++++++ app/src/admin/QueueJob.tsx | 112 +++++++++++++++++++++++++++++++++ app/src/router.tsx | 29 +++++++++ src/handler.rs | 15 ++++- src/handler/assets.rs | 15 +++-- src/handler/misc.rs | 10 +++ src/queue.rs | 24 ++++--- src/queue/job.rs | 1 + src/queue/job/v1.rs | 1 + src/routes.rs | 4 +- src/routes/admin.rs | 82 ++++++++++++++++++++++++ tests/admin_queue.rs | 87 ++++++++++++++++++++++++++ tests/harness/api_mocks.rs | 86 +------------------------ tests/harness/client_logs.rs | 86 +++++++++++++++++++++++++ tests/harness/fixtures.rs | 92 +++++++++++++++++++++++++++ tests/harness/mod.rs | 109 +++++--------------------------- tests/jobs.rs | 61 +++++++++++++++--- 22 files changed, 869 insertions(+), 207 deletions(-) create mode 100644 app/src/admin/Queue.tsx create mode 100644 app/src/admin/QueueJob.tsx create mode 100644 src/routes/admin.rs create mode 100644 tests/admin_queue.rs create mode 100644 tests/harness/client_logs.rs create mode 100644 tests/harness/fixtures.rs diff --git a/app/package-lock.json b/app/package-lock.json index 47996615..8e3e7afe 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,6 +8,7 @@ "name": "divviup-app", "version": "0.1.0", "dependencies": { + "@github/relative-time-element": "^4.3.0", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", "axios": "^1.4.0", @@ -24,6 +25,7 @@ "react-router-dom": "^6.11.1", "react-scripts": "5.0.1", "typescript": "^4.9.5", + "use-interval": "^1.4.0", "web-vitals": "^3.3.1" }, "devDependencies": { @@ -37,7 +39,8 @@ "@types/react-dom": "^18.2.4", "@types/react-router": "^5.1.20", "@types/react-router-bootstrap": "^0.26.0", - "@types/react-router-dom": "^5.3.3" + "@types/react-router-dom": "^5.3.3", + "source-map-explorer": "^2.5.3" } }, "node_modules/@adobe/css-tools": { @@ -2311,6 +2314,11 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@github/relative-time-element": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@github/relative-time-element/-/relative-time-element-4.3.0.tgz", + "integrity": "sha512-+tFjX9//HRS1HnBa5cNgfEtE52arwiutYg1TOF+Trk40SPxst9Q8Rtc3BKD6aKsvfbtub68vfhipgchGjj9o7g==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.8", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", @@ -5182,6 +5190,18 @@ "node-int64": "^0.4.0" } }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "dev": true, + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -13826,6 +13846,49 @@ "node": ">= 8" } }, + "node_modules/source-map-explorer": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/source-map-explorer/-/source-map-explorer-2.5.3.tgz", + "integrity": "sha512-qfUGs7UHsOBE5p/lGfQdaAj/5U/GWYBw2imEpD6UQNkqElYonkow8t+HBL1qqIl3CuGZx7n8/CQo4x1HwSHhsg==", + "dev": true, + "dependencies": { + "btoa": "^1.2.1", + "chalk": "^4.1.0", + "convert-source-map": "^1.7.0", + "ejs": "^3.1.5", + "escape-html": "^1.0.3", + "glob": "^7.1.6", + "gzip-size": "^6.0.0", + "lodash": "^4.17.20", + "open": "^7.3.1", + "source-map": "^0.7.4", + "temp": "^0.9.4", + "yargs": "^16.2.0" + }, + "bin": { + "sme": "bin/cli.js", + "source-map-explorer": "bin/cli.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/source-map-explorer/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", @@ -14447,6 +14510,19 @@ "node": ">=6" } }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", @@ -14455,6 +14531,18 @@ "node": ">=8" } }, + "node_modules/temp/node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/tempy": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", @@ -14951,6 +15039,18 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-interval": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-interval/-/use-interval-1.4.0.tgz", + "integrity": "sha512-1betIJun2rXKLxa30AFOBZCeZhsBJoJ/3+gkCeYbJ63lAR//EnAb1NjNeFqzgqeM7zQfR76rrCUaA8DvfgoOpA==", + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "react": ">=16.8.0 || ^17" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/app/package.json b/app/package.json index 6028c75a..99967e2d 100644 --- a/app/package.json +++ b/app/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { + "@github/relative-time-element": "^4.3.0", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", "axios": "^1.4.0", @@ -19,9 +20,11 @@ "react-router-dom": "^6.11.1", "react-scripts": "5.0.1", "typescript": "^4.9.5", + "use-interval": "^1.4.0", "web-vitals": "^3.3.1" }, "scripts": { + "analyze": "source-map-explorer 'build/static/js/*.js'", "start": "npx react-scripts start", "build": "npx react-scripts build", "test": "npx react-scripts test", @@ -46,6 +49,7 @@ ] }, "devDependencies": { + "source-map-explorer": "^2.5.3", "@testing-library/jest-dom": "^5.16.5", "@types/humanize-duration": "^3.27.1", "@types/jest": "^29.5.1", diff --git a/app/src/ApiClient.ts b/app/src/ApiClient.ts index 3c17f8f0..df60789f 100644 --- a/app/src/ApiClient.ts +++ b/app/src/ApiClient.ts @@ -9,6 +9,7 @@ export interface User { picture: string; sub: string; updated_at: string; + admin: boolean; } export interface Account { @@ -28,6 +29,18 @@ export interface Membership { created_at: string; } +export interface QueueJob { + id: string; + created_at: string; + updated_at: string; + scheduled_at: string | null; + failure_count: number; + status: "Success" | "Pending" | "Failed"; + job: { type: string; version: string;[key: string]: unknown }; + result: unknown; + parent_id: string | null; +} + type VdafDefinition = | { type: "sum"; bits: number } | { type: "count" } @@ -128,7 +141,6 @@ export class ApiClient { } async getCurrentUser(): Promise { - console.log("GETTING CURRENT USER"); if (this.currentUser) { return this.currentUser; } @@ -240,6 +252,16 @@ export class ApiClient { throw res; } } + + async queue(searchParams: URLSearchParams): Promise { + const res = await this.get(`/api/admin/queue?${searchParams}`); + return res.data as QueueJob[]; + } + + async queueJob(id: string): Promise { + const res = await this.get(`/api/admin/queue/${id}`); + return res.data as QueueJob; + } } function errorToMessage({ message, code, params }: ValidationError) { diff --git a/app/src/Header.tsx b/app/src/Header.tsx index 9341a346..73306729 100644 --- a/app/src/Header.tsx +++ b/app/src/Header.tsx @@ -6,6 +6,8 @@ import { Suspense } from "react"; import NavDropdown from "react-bootstrap/NavDropdown"; import logo from "./logo/color/svg/cropped.svg"; import { LinkContainer } from "react-router-bootstrap"; +import { Nav } from "react-bootstrap"; + function HeaderPlaceholder() { return ( @@ -30,6 +32,15 @@ function LoggedInHeader() { DivviUp + + {user.admin ? ( + + ) : null} + Log Out diff --git a/app/src/TaskForm.tsx b/app/src/TaskForm.tsx index 4944b9a6..27920c78 100644 --- a/app/src/TaskForm.tsx +++ b/app/src/TaskForm.tsx @@ -528,13 +528,13 @@ function Expiration(props: FormikProps) { includeOffset: false, suppressSeconds: true, suppressMilliseconds: true, - }) + }) || undefined : "" } onChange={handleChange} onBlur={props.handleBlur} step={60} - min={min} + min={min || undefined} isInvalid={!!props.errors.expiration} /> ) : null} diff --git a/app/src/admin/Queue.tsx b/app/src/admin/Queue.tsx new file mode 100644 index 00000000..968ad749 --- /dev/null +++ b/app/src/admin/Queue.tsx @@ -0,0 +1,117 @@ +import { + Breadcrumb, + Col, + ListGroup, + ListGroupItem, + Nav, + Row, +} from "react-bootstrap"; +import { CheckSquare, Stopwatch, XCircle } from "react-bootstrap-icons"; +import { Outlet, useLoaderData, useRevalidator } from "react-router"; +import { QueueJob } from "../ApiClient"; +import useInterval from "use-interval"; +import { LinkContainer } from "react-router-bootstrap"; +import { useSearchParams } from "react-router-dom"; +import "@github/relative-time-element"; +import { DateTime } from "luxon"; + +export const Component = JobQueue; + +function TabLink({ search, text }: { search: string; text: string }) { + let [params] = useSearchParams(); + let active = params.toString() === search; + return ( + + + + {text} + + + + ); +} + +export function JobQueue() { + let revalidator = useRevalidator(); + + useInterval(() => { + if (revalidator.state === "idle") { + revalidator.revalidate(); + } + }, 1000); + + let queue = useLoaderData() as QueueJob[]; + + return ( + <> + + + + + Home + + + Queue + + + + + + + + + + + {queue.length === 0 ? ( + none + ) : null} + {queue.map((job) => ( + + ))} + + + + + + ); +} + +function JobStatus({ job }: { job: QueueJob }) { + switch (job.status) { + case "Success": + return ; + case "Failed": + return ; + case "Pending": + return ; + } +} + +function Job({ job }: { job: QueueJob }) { + return ( + + + <> + + {job.job.type} + + + + {DateTime.fromISO(job.updated_at) + .toLocal() + .toLocaleString(DateTime.DATETIME_SHORT)} + + + + + + ); +} diff --git a/app/src/admin/QueueJob.tsx b/app/src/admin/QueueJob.tsx new file mode 100644 index 00000000..2a513ace --- /dev/null +++ b/app/src/admin/QueueJob.tsx @@ -0,0 +1,112 @@ +import React from "react"; +import { Col, Table } from "react-bootstrap"; +import { CheckSquare, Stopwatch, XCircle } from "react-bootstrap-icons"; +import { useLoaderData } from "react-router"; +import { QueueJob } from "../ApiClient"; +import { DateTime } from "luxon"; +import { Link } from "react-router-dom"; + +export const Component = JobQueue; + +export function JobQueue() { + let job = useLoaderData() as QueueJob; + + return ( + + + + ); +} + +function JobStatus({ job }: { job: QueueJob }) { + switch (job.status) { + case "Success": + return ; + case "Failed": + return ; + case "Pending": + return ; + } +} + +function LabeledRow({ + label, + value, + children, +}: { + label: string; + value?: string; + children?: React.ReactNode; +}) { + return ( + + {label} + + <> + {value} + {children} + + + + ); +} + +function Job({ job }: { job: QueueJob }) { + return ( + <> +

+ {job.job.type} +

+ + + + + {DateTime.fromISO(job.created_at) + .toLocal() + .toLocaleString(DateTime.DATETIME_SHORT)} + + + + {DateTime.fromISO(job.updated_at) + .toLocal() + .toLocaleString(DateTime.DATETIME_SHORT)} + + + {job.scheduled_at ? ( + + {DateTime.fromISO(job.scheduled_at) + .toLocal() + .toLocaleString(DateTime.DATETIME_SHORT)} + + ) : null} + + {job.failure_count > 0 ? ( + {job.failure_count} + ) : null} + + +
+              {JSON.stringify(job.job, null, 2)}
+            
+
+ + {job.parent_id ? ( + + {job.parent_id} + + ) : null} + + {typeof job.result === "object" && + job.result !== null && + "Child" in job.result ? ( + + + <>{job.result.Child} + + + ) : null} + +
+ + ); +} diff --git a/app/src/router.tsx b/app/src/router.tsx index 7e0c42d0..8c73efca 100644 --- a/app/src/router.tsx +++ b/app/src/router.tsx @@ -50,6 +50,35 @@ function buildRouter(apiClient: ApiClient) { element: , index: true, }, + { + path: "admin", + children: [ + { + path: "queue", + async lazy() { + return import("./admin/Queue"); + }, + async loader({ request }) { + const params = new URL(request.url).searchParams; + return apiClient.queue(params); + }, + children: [ + { + path: ":job_id", + async lazy() { + return import("./admin/QueueJob"); + }, + + async loader({ params }) { + if ("job_id" in params && typeof params.job_id === "string") + return apiClient.queueJob(params.job_id); + }, + }, + ], + }, + ], + }, + { path: "login", element: , diff --git a/src/handler.rs b/src/handler.rs index 9e32d885..6bf0d078 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -16,7 +16,7 @@ use error::ErrorHandler; use logger::logger; use session_store::SessionStore; use std::sync::Arc; -use trillium::{state, Handler}; +use trillium::{state, Handler, Info}; use trillium_caching_headers::{ cache_control, caching_headers, CacheControlDirective::{MustRevalidate, Private}, @@ -36,13 +36,22 @@ pub use origin_router::origin_router; #[derive(Handler, Debug)] pub struct DivviupApi { - #[handler] + #[handler(except = init)] handler: Box, db: Db, config: Arc, } impl DivviupApi { + async fn init(&mut self, info: &mut Info) { + *info.server_description_mut() = format!("divviup-api {}", env!("CARGO_PKG_VERSION")); + *info.listener_description_mut() = format!( + "api url: {}\n app url: {}\n", + self.config.api_url, self.config.app_url, + ); + self.handler.init(info).await + } + pub async fn new(config: ApiConfig) -> Self { let config = Arc::new(config); let db = Db::connect(config.database_url.as_ref()).await; @@ -53,7 +62,7 @@ impl DivviupApi { Forwarding::trust_always(), caching_headers(), logger(), - origin_router().with_handler(config.app_url.as_ref(), static_assets(&config)), + static_assets(&config), api(&db, &config), ErrorHandler, )), diff --git a/src/handler/assets.rs b/src/handler/assets.rs index de8ffbd6..1741294e 100644 --- a/src/handler/assets.rs +++ b/src/handler/assets.rs @@ -1,4 +1,4 @@ -use crate::ApiConfig; +use crate::{handler::origin_router, ApiConfig}; use std::time::Duration; use trillium::{ Conn, Handler, @@ -12,9 +12,16 @@ use url::Url; const ONE_YEAR: Duration = Duration::from_secs(60 * 60 * 24 * 365); pub fn static_assets(config: &ApiConfig) -> impl Handler { - ReactApp { - handler: static_compiled!("$ASSET_DIR").with_index_file("index.html"), - api_url: config.api_url.clone(), + if std::env::var("SKIP_APP_COMPILATION").is_ok() { + None + } else { + Some(origin_router().with_handler( + config.app_url.as_ref(), + ReactApp { + handler: static_compiled!("$ASSET_DIR").with_index_file("index.html"), + api_url: config.api_url.clone(), + }, + )) } } diff --git a/src/handler/misc.rs b/src/handler/misc.rs index 81a634ab..dfbb7c63 100644 --- a/src/handler/misc.rs +++ b/src/handler/misc.rs @@ -42,3 +42,13 @@ pub async fn user_required(_: &mut Conn, user: Option) -> impl Handler { None } } + +pub async fn admin_required(_: &mut Conn, user: Option) -> impl Handler { + if matches!(user, Some(user) if user.is_admin()) { + None + } else { + // we return not found instead of forbidden so as to not + // reveal what admin endpoints exist + Some((Status::NotFound, Halt)) + } +} diff --git a/src/queue.rs b/src/queue.rs index 0e461799..444e534c 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -4,7 +4,7 @@ pub use job::*; use crate::{ entity::queue::{ActiveModel, Entity, Model}, - ApiConfig, Db, + ApiConfig, Db, DivviupApi, }; use sea_orm::{ActiveModelTrait, DbErr, IntoActiveModel, Set, TransactionTrait}; use std::{ops::Range, sync::Arc, time::Duration}; @@ -26,19 +26,25 @@ pub struct Queue { These configuration variables may eventually be useful to put on ApiConfig */ const MAX_RETRY: i32 = 5; -const QUEUE_CHECK_INTERVAL: Range = 10..20; +const QUEUE_CHECK_INTERVAL: Range = 60_000..120_000; +const SCHEDULE_RANDOMNESS: Range = 0..15_000; const QUEUE_WORKER_COUNT: u8 = 2; fn reschedule_based_on_failure_count(failure_count: i32) -> Option { if failure_count >= MAX_RETRY { None } else { - Some( - OffsetDateTime::now_utc() - + Duration::from_millis( - 1000 * 4_u64.pow(failure_count.try_into().unwrap()) + fastrand::u64(0..15000), - ), - ) + let duration = Duration::from_millis( + 1000 * 4_u64.pow(failure_count.try_into().unwrap()) + + fastrand::u64(SCHEDULE_RANDOMNESS), + ); + Some(OffsetDateTime::now_utc() + duration) + } +} + +impl From<&DivviupApi> for Queue { + fn from(app: &DivviupApi) -> Self { + Self::new(app.db(), app.config()) } } @@ -139,7 +145,7 @@ impl Queue { Ok(None) => { let sleep_future = - sleep(Duration::from_secs(fastrand::u64(QUEUE_CHECK_INTERVAL))); + sleep(Duration::from_millis(fastrand::u64(QUEUE_CHECK_INTERVAL))); self.stopper.stop_future(sleep_future).await; } } diff --git a/src/queue/job.rs b/src/queue/job.rs index 42d33f85..e114b487 100644 --- a/src/queue/job.rs +++ b/src/queue/job.rs @@ -13,6 +13,7 @@ mod v1; pub use v1::{CreateUser, ResetPassword, SendInvitationEmail, V1}; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(tag = "version")] pub enum Job { V1(V1), } diff --git a/src/queue/job/v1.rs b/src/queue/job/v1.rs index bdef64fd..7712954f 100644 --- a/src/queue/job/v1.rs +++ b/src/queue/job/v1.rs @@ -11,6 +11,7 @@ pub use reset_password::ResetPassword; pub use send_invitation_email::SendInvitationEmail; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(tag = "type")] pub enum V1 { SendInvitationEmail(SendInvitationEmail), CreateUser(CreateUser), diff --git a/src/routes.rs b/src/routes.rs index e8f02478..12fed1b7 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,4 +1,5 @@ mod accounts; +mod admin; mod health_check; mod memberships; mod tasks; @@ -66,7 +67,8 @@ fn api_routes(config: &ApiConfig) -> impl Handler { &[Patch, Get, Post], "/accounts/:account_id/*", accounts_routes(config), - ), + ) + .all("/admin/*", admin::routes()), ) } diff --git a/src/routes/admin.rs b/src/routes/admin.rs new file mode 100644 index 00000000..5a22bbac --- /dev/null +++ b/src/routes/admin.rs @@ -0,0 +1,82 @@ +use crate::{ + entity::queue::{self, Column, Entity, JobStatus, Model}, + handler::{admin_required, Error}, + Db, +}; +use querystrong::QueryStrong; +use sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryOrder, QuerySelect}; +use trillium::{async_trait, Conn, Handler, Status}; +use trillium_api::{api, FromConn, Json}; +use trillium_caching_headers::CachingHeadersExt; +use trillium_router::{router, RouterConnExt}; +use uuid::Uuid; + +pub fn routes() -> impl Handler { + ( + api(admin_required), + router() + .get("/queue", api(index)) + .get("/queue/:job_id", api(show)) + .delete("/queue/:job_id", api(delete)), + ) +} + +#[async_trait] +impl FromConn for queue::Model { + async fn from_conn(conn: &mut Conn) -> Option { + let db = Db::from_conn(conn).await?; + let id: Uuid = conn.param("job_id")?.parse().ok()?; + + match Entity::find_by_id(id).one(&db).await { + Ok(job) => job, + Err(error) => { + conn.set_state(Error::from(error)); + None + } + } + } +} + +async fn index(conn: &mut Conn, db: Db) -> Result>, Error> { + let params = QueryStrong::parse(conn.querystring()).unwrap_or_default(); + let mut find = Entity::find(); + let query = QuerySelect::query(&mut find); + match params.get_str("status") { + Some("pending") => { + query.cond_where(Column::Status.eq(JobStatus::Pending)); + } + + Some("success") => { + query.cond_where(Column::Status.eq(JobStatus::Success)); + } + + Some("failed") => { + query.cond_where(Column::Status.eq(JobStatus::Failed)); + } + + _ => {} + } + + let queue = find + .order_by_desc(Column::UpdatedAt) + .limit(100) + .all(&db) + .await?; + + if let Some(first) = queue.first() { + conn.set_last_modified(first.updated_at.into()); + } + + Ok(Json(queue)) +} + +async fn show(conn: &mut Conn, queue_job: Model) -> Json { + conn.set_last_modified(queue_job.updated_at.into()); + + Json(queue_job) +} + +async fn delete(_: &mut Conn, (queue_job, db): (Model, Db)) -> Result { + queue_job.delete(&db).await?; + Ok(Status::NoContent) +} diff --git a/tests/admin_queue.rs b/tests/admin_queue.rs new file mode 100644 index 00000000..45e75f1b --- /dev/null +++ b/tests/admin_queue.rs @@ -0,0 +1,87 @@ +mod harness; +use harness::{test, *}; + +mod index { + use super::{test, *}; + use divviup_api::entity::queue::{ + Column as QueueColumn, Entity as QueueItems, JobStatus, Model as QueueItem, + }; + + #[test(harness = set_up)] + async fn as_an_admin(app: DivviupApi) -> TestResult { + let (admin, ..) = fixtures::admin(&app).await; + let queue_item = Job::new_invitation_flow(&fixtures::build_membership(&app).await) + .insert(app.db()) + .await?; + let mut conn = get("/api/admin/queue") + .with_api_headers() + .with_state(admin) + .run_async(&app) + .await; + assert_ok!(conn); + assert_eq!( + conn.response_json::>().await, + vec![queue_item] + ); + Ok(()) + } + + #[test(harness = set_up)] + async fn as_a_non_admin(app: DivviupApi) -> TestResult { + let (user, ..) = fixtures::member(&app).await; + + Job::new_invitation_flow(&fixtures::build_membership(&app).await) + .insert(app.db()) + .await?; + + let conn = get("/api/admin/queue") + .with_api_headers() + .with_state(user) + .run_async(&app) + .await; + + assert_status!(conn, 404); + Ok(()) + } + + #[test(harness = set_up)] + async fn filtering(app: DivviupApi) -> TestResult { + let (admin, ..) = fixtures::admin(&app).await; + Job::new_invitation_flow(&fixtures::build_membership(&app).await) + .insert(app.db()) + .await?; + + Queue::from(&app).perform_one_queue_job().await?.unwrap(); + + let success: Vec = get("/api/admin/queue?status=success") + .with_api_headers() + .with_state(admin.clone()) + .run_async(&app) + .await + .response_json() + .await; + + let pending: Vec = get("/api/admin/queue?status=pending") + .with_api_headers() + .with_state(admin.clone()) + .run_async(&app) + .await + .response_json() + .await; + + let failed: Vec = get("/api/admin/queue?status=failed") + .with_api_headers() + .with_state(admin) + .run_async(&app) + .await + .response_json() + .await; + + assert!(success.iter().all(|item| item.status == JobStatus::Success)); + assert_eq!(success.len(), 1); + assert!(pending.iter().all(|item| item.status == JobStatus::Pending)); + assert_eq!(pending.len(), 1); + assert_eq!(failed.len(), 0); + Ok(()) + } +} diff --git a/tests/harness/api_mocks.rs b/tests/harness/api_mocks.rs index d14b83a4..20b7e941 100644 --- a/tests/harness/api_mocks.rs +++ b/tests/harness/api_mocks.rs @@ -1,13 +1,10 @@ -use super::{fixtures, AGGREGATOR_URL, AUTH0_URL, POSTMARK_URL}; +use super::{fixtures, ClientLogs, LoggedConn, AGGREGATOR_URL, AUTH0_URL, POSTMARK_URL}; use divviup_api::{aggregator_api_mock::aggregator_api, clients::auth0_client::Token}; -use serde_json::{json, Value}; -use std::sync::Arc; +use serde_json::json; use trillium::{async_trait, Conn, Handler}; use trillium_api::Json; -use trillium_http::{Body, Headers, Method, Status}; use trillium_macros::Handler; use trillium_router::router; -use url::Url; fn postmark_mock() -> impl Handler { router().post("/email/withTemplate", Json(json!({}))) @@ -36,85 +33,6 @@ fn auth0_mock() -> impl Handler { ) } -#[derive(Debug, Clone)] -pub struct LoggedConn { - pub url: Url, - pub method: Method, - pub response_body: Option, - pub response_status: Status, - pub request_headers: Headers, - pub response_headers: Headers, -} - -impl LoggedConn { - pub fn response_json(&self) -> Value { - serde_json::from_str(&self.response_body.as_ref().unwrap()).unwrap() - } -} - -impl From<&Conn> for LoggedConn { - fn from(conn: &Conn) -> Self { - let url = Url::parse(&format!( - "{}://{}{}{}", - if conn.is_secure() { "https" } else { "http" }, - conn.inner().host().unwrap(), - conn.path(), - match conn.querystring() { - "" => "".into(), - q => format!("?{q}"), - } - )) - .unwrap(); - - Self { - url, - method: conn.method(), - response_body: conn - .inner() - .response_body() - .and_then(Body::static_bytes) - .map(|s| String::from_utf8_lossy(s).to_string()), - response_status: conn.status().unwrap_or(Status::NotFound), - request_headers: conn.request_headers().clone(), - response_headers: conn.response_headers().clone(), - } - } -} - -impl std::fmt::Display for LoggedConn { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_fmt(format_args!( - "{} {}: {}", - self.method, self.url, self.response_status - )) - } -} - -#[derive(Debug, Default, Clone)] -pub struct ClientLogs { - logged_conns: Arc>>, -} - -impl ClientLogs { - pub fn logs(&self) -> Vec { - self.logged_conns.read().unwrap().clone() - } - - pub fn last(&self) -> LoggedConn { - self.logged_conns.read().unwrap().last().unwrap().clone() - } - - pub fn matching_url(&self, url: Url) -> Vec { - self.logged_conns - .read() - .unwrap() - .iter() - .filter(|lc| (**lc).url == url) - .cloned() - .collect() - } -} - #[derive(Handler, Debug)] pub struct ApiMocks { #[handler] diff --git a/tests/harness/client_logs.rs b/tests/harness/client_logs.rs new file mode 100644 index 00000000..eb34387a --- /dev/null +++ b/tests/harness/client_logs.rs @@ -0,0 +1,86 @@ +use serde_json::Value; +use std::{ + fmt::{Display, Formatter, Result}, + sync::{Arc, RwLock}, +}; +use trillium::{Body, Conn, Headers, Method, Status}; +use url::Url; + +#[derive(Debug, Clone)] +pub struct LoggedConn { + pub url: Url, + pub method: Method, + pub response_body: Option, + pub response_status: Status, + pub request_headers: Headers, + pub response_headers: Headers, +} + +impl LoggedConn { + pub fn response_json(&self) -> Value { + serde_json::from_str(&self.response_body.as_ref().unwrap()).unwrap() + } +} + +impl From<&Conn> for LoggedConn { + fn from(conn: &Conn) -> Self { + let url = Url::parse(&format!( + "{}://{}{}{}", + if conn.is_secure() { "https" } else { "http" }, + conn.inner().host().unwrap(), + conn.path(), + match conn.querystring() { + "" => "".into(), + q => format!("?{q}"), + } + )) + .unwrap(); + + Self { + url, + method: conn.method(), + response_body: conn + .inner() + .response_body() + .and_then(Body::static_bytes) + .map(|s| String::from_utf8_lossy(s).to_string()), + response_status: conn.status().unwrap_or(Status::NotFound), + request_headers: conn.request_headers().clone(), + response_headers: conn.response_headers().clone(), + } + } +} + +impl Display for LoggedConn { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + f.write_fmt(format_args!( + "{} {}: {}", + self.method, self.url, self.response_status + )) + } +} + +#[derive(Debug, Default, Clone)] +pub struct ClientLogs { + pub(super) logged_conns: Arc>>, +} + +impl ClientLogs { + pub fn logs(&self) -> Vec { + self.logged_conns.read().unwrap().clone() + } + + pub fn last(&self) -> LoggedConn { + self.logged_conns.read().unwrap().last().unwrap().clone() + } + + pub fn matching_url(&self, url: Url) -> Vec { + self.logged_conns + .read() + .unwrap() + .iter() + .filter(|lc| (**lc).url == url) + .cloned() + .collect() + } +} diff --git a/tests/harness/fixtures.rs b/tests/harness/fixtures.rs new file mode 100644 index 00000000..e5050334 --- /dev/null +++ b/tests/harness/fixtures.rs @@ -0,0 +1,92 @@ +use divviup_api::{aggregator_api_mock, clients::aggregator_client::TaskCreate}; +use validator::Validate; + +use super::*; + +pub fn user() -> User { + User { + email: format!("test-{}@example.example", random_name()), + email_verified: true, + name: "test user".into(), + nickname: "testy".into(), + picture: None, + sub: "".into(), + updated_at: time::OffsetDateTime::now_utc(), + admin: Some(false), + } +} + +pub fn random_name() -> String { + std::iter::repeat_with(fastrand::alphabetic) + .take(10) + .collect() +} + +pub async fn account(app: &DivviupApi) -> Account { + Account::build(random_name()) + .unwrap() + .insert(app.db()) + .await + .unwrap() +} + +pub async fn admin_account(app: &DivviupApi) -> Account { + let mut active_model = Account::build(random_name()).unwrap(); + active_model.admin = ActiveValue::Set(true); + active_model.insert(app.db()).await.unwrap() +} + +pub async fn membership(app: &DivviupApi, account: &Account, user: &User) -> Membership { + Membership::build(user.email.clone(), account) + .unwrap() + .insert(app.db()) + .await + .unwrap() +} + +pub async fn build_membership(app: &DivviupApi) -> Membership { + let account = account(&app).await; + let email = format!("test-{}@example.test", random_name()); + Membership::build(email, &account) + .unwrap() + .insert(app.db()) + .await + .unwrap() +} + +pub async fn admin(app: &DivviupApi) -> (User, Account, Membership) { + let user = user(); + let account = admin_account(app).await; + let membership = membership(app, &account, &user).await; + + (user, account, membership) +} + +pub async fn member(app: &DivviupApi) -> (User, Account, Membership) { + let user = user(); + let account = account(app).await; + let membership = membership(app, &account, &user).await; + + (user, account, membership) +} + +pub async fn task(app: &DivviupApi, account: &Account) -> Task { + let new_task = NewTask { + name: Some(random_name()), + partner_url: Some("https://dap.clodflair.test".into()), + vdaf: Some(task::Vdaf::Count), + min_batch_size: Some(500), + max_batch_size: Some(10000), + is_leader: Some(true), + expiration: None, + time_precision_seconds: Some(60 * 60), + hpke_config: Some(random_hpke_config().into()), + }; + new_task.validate().unwrap(); + let task_create = TaskCreate::build(new_task.clone(), app.config()).unwrap(); + let api_response = aggregator_api_mock::task_response(task_create); + task::build_task(new_task, api_response, account) + .insert(app.db()) + .await + .unwrap() +} diff --git a/tests/harness/mod.rs b/tests/harness/mod.rs index 81542bc3..351d63ff 100644 --- a/tests/harness/mod.rs +++ b/tests/harness/mod.rs @@ -6,15 +6,20 @@ use divviup_api::{ ApiConfig, Db, }; use serde::{de::DeserializeOwned, Serialize}; -use std::future::Future; +use std::{error::Error, future::Future}; use trillium::Handler; use trillium_client::Client; use trillium_testing::TestConn; -pub use divviup_api::{entity::*, DivviupApi, User}; +pub use divviup_api::{ + entity::{self, *}, + queue::{Job, Queue}, + DivviupApi, User, +}; pub use querystrong::QueryStrong; pub use sea_orm::{ - ActiveModelTrait, ActiveValue, ConnectionTrait, DbBackend, EntityTrait, PaginatorTrait, Schema, + ActiveModelTrait, ActiveValue, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, + PaginatorTrait, QueryFilter, Schema, }; pub use serde_json::{json, Value}; pub use test_harness::test; @@ -22,10 +27,15 @@ pub use trillium::{Conn, KnownHeaderName, Method, Status}; pub use trillium_testing::prelude::*; pub use url::Url; -pub type TestResult = Result<(), Box>; +pub type TestResult = Result<(), Box>; + +pub mod fixtures; + +mod client_logs; +pub use client_logs::{ClientLogs, LoggedConn}; mod api_mocks; -pub use api_mocks::{ApiMocks, ClientLogs, LoggedConn}; +pub use api_mocks::ApiMocks; const POSTMARK_URL: &str = "https://postmark.example"; const AGGREGATOR_API_URL: &str = "https://aggregator.example"; @@ -82,7 +92,7 @@ pub async fn build_test_app() -> (DivviupApi, ClientLogs) { pub fn set_up(f: F) where F: FnOnce(DivviupApi) -> Fut, - Fut: Future>> + Send + 'static, + Fut: Future>> + Send + 'static, { block_on(async move { let (app, _) = build_test_app().await; @@ -93,7 +103,7 @@ where pub fn with_client_logs(f: F) where F: FnOnce(DivviupApi, ClientLogs) -> Fut, - Fut: Future>> + Send + 'static, + Fut: Future>> + Send + 'static, { block_on(async move { let (app, client_logs) = build_test_app().await; @@ -101,91 +111,6 @@ where }); } -pub mod fixtures { - use divviup_api::{aggregator_api_mock, clients::aggregator_client::TaskCreate}; - use validator::Validate; - - use super::*; - - pub fn user() -> User { - User { - email: format!("test-{}@example.example", random_name()), - email_verified: true, - name: "test user".into(), - nickname: "testy".into(), - picture: None, - sub: "".into(), - updated_at: time::OffsetDateTime::now_utc(), - admin: Some(false), - } - } - - pub fn random_name() -> String { - std::iter::repeat_with(fastrand::alphabetic) - .take(10) - .collect() - } - - pub async fn account(app: &DivviupApi) -> Account { - Account::build(random_name()) - .unwrap() - .insert(app.db()) - .await - .unwrap() - } - - pub async fn admin_account(app: &DivviupApi) -> Account { - let mut active_model = Account::build(random_name()).unwrap(); - active_model.admin = ActiveValue::Set(true); - active_model.insert(app.db()).await.unwrap() - } - - pub async fn membership(app: &DivviupApi, account: &Account, user: &User) -> Membership { - Membership::build(user.email.clone(), account) - .unwrap() - .insert(app.db()) - .await - .unwrap() - } - - pub async fn admin(app: &DivviupApi) -> (User, Account, Membership) { - let user = user(); - let account = admin_account(app).await; - let membership = membership(app, &account, &user).await; - - (user, account, membership) - } - - pub async fn member(app: &DivviupApi) -> (User, Account, Membership) { - let user = user(); - let account = account(app).await; - let membership = membership(app, &account, &user).await; - - (user, account, membership) - } - - pub async fn task(app: &DivviupApi, account: &Account) -> Task { - let new_task = NewTask { - name: Some(random_name()), - partner_url: Some("https://dap.clodflair.test".into()), - vdaf: Some(task::Vdaf::Count), - min_batch_size: Some(500), - max_batch_size: Some(10000), - is_leader: Some(true), - expiration: None, - time_precision_seconds: Some(60 * 60), - hpke_config: Some(random_hpke_config().into()), - }; - new_task.validate().unwrap(); - let task_create = TaskCreate::build(new_task.clone(), app.config()).unwrap(); - let api_response = aggregator_api_mock::task_response(task_create); - task::build_task(new_task, api_response, account) - .insert(app.db()) - .await - .unwrap() - } -} - pub const APP_CONTENT_TYPE: &str = "application/vnd.divviup+json;version=0.1"; #[macro_export] diff --git a/tests/jobs.rs b/tests/jobs.rs index ebb4bed0..9e47c5c8 100644 --- a/tests/jobs.rs +++ b/tests/jobs.rs @@ -6,15 +6,9 @@ use divviup_api::{ use harness::{test, *}; use uuid::Uuid; -async fn build_membership(app: &DivviupApi) -> Result> { - let account = fixtures::account(&app).await; - let email = format!("test-{}@example.test", fixtures::random_name()); - Ok(Membership::build(email, &account)?.insert(app.db()).await?) -} - #[test(harness = with_client_logs)] async fn create_account(app: DivviupApi, client_logs: ClientLogs) -> TestResult { - let membership = build_membership(&app).await?; + let membership = fixtures::build_membership(&app).await; let membership_id = membership.id; let mut job = CreateUser { membership_id }; let next = job.perform(&app.config().into(), app.db()).await?.unwrap(); @@ -39,7 +33,7 @@ async fn create_account(app: DivviupApi, client_logs: ClientLogs) -> TestResult #[test(harness = with_client_logs)] async fn reset_password(app: DivviupApi, client_logs: ClientLogs) -> TestResult { - let membership = build_membership(&app).await?; + let membership = fixtures::build_membership(&app).await; let membership_id = membership.id; let mut job = ResetPassword { membership_id, @@ -70,7 +64,7 @@ async fn reset_password(app: DivviupApi, client_logs: ClientLogs) -> TestResult #[test(harness = with_client_logs)] async fn send_email(app: DivviupApi, client_logs: ClientLogs) -> TestResult { - let membership = build_membership(&app).await?; + let membership = fixtures::build_membership(&app).await; let membership_id = membership.id; let mut job = SendInvitationEmail { membership_id, @@ -94,7 +88,7 @@ async fn send_email(app: DivviupApi, client_logs: ClientLogs) -> TestResult { #[test(harness = with_client_logs)] async fn all_together(app: DivviupApi, client_logs: ClientLogs) -> TestResult { - let membership = build_membership(&app).await?; + let membership = fixtures::build_membership(&app).await; Job::new_invitation_flow(&membership) .insert(app.db()) @@ -124,3 +118,50 @@ async fn all_together(app: DivviupApi, client_logs: ClientLogs) -> TestResult { ); Ok(()) } + +#[test] +fn json_representations() { + let membership_id = Uuid::new_v4(); + assert_eq!( + serde_json::to_value(&Job::from(CreateUser { membership_id })).unwrap(), + json!({ + "version": "V1", + "type": "CreateUser", + "membership_id": membership_id + }) + ); + + let message_id = Uuid::new_v4(); + let action_url: Url = "https://action.url".parse().unwrap(); + + assert_eq!( + serde_json::to_value(&Job::from(SendInvitationEmail { + membership_id, + action_url: action_url.clone(), + message_id + })) + .unwrap(), + json!({ + "version": "V1", + "type": "SendInvitationEmail", + "membership_id": membership_id, + "action_url": action_url, + "message_id": message_id + }) + ); + + let user_id: String = "user-id".into(); + assert_eq!( + serde_json::to_value(&Job::from(ResetPassword { + membership_id, + user_id: user_id.clone() + })) + .unwrap(), + json!({ + "version": "V1", + "type": "ResetPassword", + "membership_id": membership_id, + "user_id": user_id + }) + ); +} From 7f23dbbcb04ac35fc20ff45937d06fbd497ef9fc Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Wed, 10 May 2023 15:13:01 -0700 Subject: [PATCH 17/20] change queue schema to have an error_message and child_id instead of a result --- app/src/ApiClient.ts | 13 +++++++++---- app/src/admin/Queue.tsx | 10 +++++++++- app/src/admin/QueueJob.tsx | 8 +++----- migration/src/m20230427_221953_create_queue.rs | 6 ++++-- src/entity/queue.rs | 15 +++++---------- src/queue.rs | 10 ++++------ tests/admin_queue.rs | 4 +--- 7 files changed, 35 insertions(+), 31 deletions(-) diff --git a/app/src/ApiClient.ts b/app/src/ApiClient.ts index df60789f..6a0de47e 100644 --- a/app/src/ApiClient.ts +++ b/app/src/ApiClient.ts @@ -36,8 +36,13 @@ export interface QueueJob { scheduled_at: string | null; failure_count: number; status: "Success" | "Pending" | "Failed"; - job: { type: string; version: string;[key: string]: unknown }; - result: unknown; + job: { + type: string; + version: string; + [key: string]: unknown; + }; + error_message: { [key: string]: unknown }; + child_id: string | null; parent_id: string | null; } @@ -316,8 +321,8 @@ export interface FormikLikeErrors { export type ValidationErrorsFor = { [K in keyof T]?: T[K] extends object - ? ValidationErrorsFor - : ValidationError[]; + ? ValidationErrorsFor + : ValidationError[]; }; export interface ValidationError { diff --git a/app/src/admin/Queue.tsx b/app/src/admin/Queue.tsx index 968ad749..efcba4b2 100644 --- a/app/src/admin/Queue.tsx +++ b/app/src/admin/Queue.tsx @@ -88,7 +88,15 @@ function JobStatus({ job }: { job: QueueJob }) { case "Failed": return ; case "Pending": - return ; + if (job.failure_count > 0) { + return ( + <> + {job.failure_count} + + ); + } else { + return ; + } } } diff --git a/app/src/admin/QueueJob.tsx b/app/src/admin/QueueJob.tsx index 2a513ace..56b599ae 100644 --- a/app/src/admin/QueueJob.tsx +++ b/app/src/admin/QueueJob.tsx @@ -96,12 +96,10 @@ function Job({ job }: { job: QueueJob }) { ) : null} - {typeof job.result === "object" && - job.result !== null && - "Child" in job.result ? ( + {job.child_id ? ( - - <>{job.result.Child} + + <>{job.child_id} ) : null} diff --git a/migration/src/m20230427_221953_create_queue.rs b/migration/src/m20230427_221953_create_queue.rs index 022bd787..94fb2bf7 100644 --- a/migration/src/m20230427_221953_create_queue.rs +++ b/migration/src/m20230427_221953_create_queue.rs @@ -40,8 +40,9 @@ impl MigrationTrait for Migration { .default(0), ) .col(ColumnDef::new(Queue::Job).json().not_null()) - .col(ColumnDef::new(Queue::Result).json().null()) + .col(ColumnDef::new(Queue::ErrorMessage).json().null()) .col(ColumnDef::new(Queue::ParentId).uuid().null()) + .col(ColumnDef::new(Queue::ChildId).uuid().null()) .to_owned(), ) .await?; @@ -77,6 +78,7 @@ enum Queue { FailureCount, Status, Job, - Result, + ErrorMessage, ParentId, + ChildId, } diff --git a/src/entity/queue.rs b/src/entity/queue.rs index ba335e3f..1ef741d3 100644 --- a/src/entity/queue.rs +++ b/src/entity/queue.rs @@ -11,14 +11,7 @@ use serde::{Deserialize, Serialize}; use std::fmt::Debug; use time::OffsetDateTime; -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -pub enum JobResult { - Complete, - Child(Uuid), - Error(JobError), -} - -json_newtype!(JobResult); +json_newtype!(JobError); json_newtype!(Job); #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] @@ -35,8 +28,9 @@ pub struct Model { pub failure_count: i32, pub status: JobStatus, pub job: Job, - pub result: Option, + pub error_message: Option, pub parent_id: Option, + pub child_id: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] @@ -65,8 +59,9 @@ impl From for ActiveModel { failure_count: Set(0), status: Set(JobStatus::Pending), job: Set(job), - result: Set(None), + error_message: Set(None), parent_id: Set(None), + child_id: Set(None), } } } diff --git a/src/queue.rs b/src/queue.rs index 444e534c..31d91315 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -1,5 +1,5 @@ mod job; -pub use crate::entity::queue::{JobResult, JobStatus}; +pub use crate::entity::queue::JobStatus; pub use job::*; use crate::{ @@ -92,14 +92,12 @@ impl Queue { let mut next_job = ActiveModel::from(next_job); next_job.parent_id = Set(Some(*queue_item.id.as_ref())); let next_job = next_job.insert(&tx).await?; - - queue_item.result = Set(Some(JobResult::Child(next_job.id))); + queue_item.child_id = Set(Some(next_job.id)); } Ok(None) => { queue_item.scheduled_at = Set(None); queue_item.status = Set(JobStatus::Success); - queue_item.result = Set(Some(JobResult::Complete)); } Err(e) if e.is_retryable() => { @@ -109,14 +107,14 @@ impl Queue { queue_item.status = Set(reschedule.map_or(JobStatus::Failed, |_| JobStatus::Pending)); queue_item.scheduled_at = Set(reschedule); - queue_item.result = Set(Some(JobResult::Error(e))); + queue_item.error_message = Set(Some(e)); } Err(e) => { queue_item.failure_count = Set(queue_item.failure_count.as_ref() + 1); queue_item.scheduled_at = Set(None); queue_item.status = Set(JobStatus::Failed); - queue_item.result = Set(Some(JobResult::Error(e))); + queue_item.error_message = Set(Some(e)); } } diff --git a/tests/admin_queue.rs b/tests/admin_queue.rs index 45e75f1b..d8f544f8 100644 --- a/tests/admin_queue.rs +++ b/tests/admin_queue.rs @@ -3,9 +3,7 @@ use harness::{test, *}; mod index { use super::{test, *}; - use divviup_api::entity::queue::{ - Column as QueueColumn, Entity as QueueItems, JobStatus, Model as QueueItem, - }; + use divviup_api::entity::queue::{JobStatus, Model as QueueItem}; #[test(harness = set_up)] async fn as_an_admin(app: DivviupApi) -> TestResult { From da44a27c5a2dfcedf8cadb5f07c4c1d2575a4fc2 Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Fri, 12 May 2023 12:40:52 -0700 Subject: [PATCH 18/20] fixes from rebase --- tests/harness/api_mocks.rs | 4 ++-- tests/harness/fixtures.rs | 5 ++++- tests/harness/mod.rs | 8 ++------ 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/tests/harness/api_mocks.rs b/tests/harness/api_mocks.rs index 20b7e941..703cc403 100644 --- a/tests/harness/api_mocks.rs +++ b/tests/harness/api_mocks.rs @@ -1,4 +1,4 @@ -use super::{fixtures, ClientLogs, LoggedConn, AGGREGATOR_URL, AUTH0_URL, POSTMARK_URL}; +use super::{fixtures, ClientLogs, LoggedConn, AGGREGATOR_API_URL, AUTH0_URL, POSTMARK_URL}; use divviup_api::{aggregator_api_mock::aggregator_api, clients::auth0_client::Token}; use serde_json::json; use trillium::{async_trait, Conn, Handler}; @@ -49,7 +49,7 @@ impl ApiMocks { client_logs.clone(), divviup_api::handler::origin_router() .with_handler(POSTMARK_URL, postmark_mock()) - .with_handler(AGGREGATOR_URL, aggregator_api()) + .with_handler(AGGREGATOR_API_URL, aggregator_api()) .with_handler(AUTH0_URL, auth0_mock()), )), client_logs, diff --git a/tests/harness/fixtures.rs b/tests/harness/fixtures.rs index e5050334..72d77b92 100644 --- a/tests/harness/fixtures.rs +++ b/tests/harness/fixtures.rs @@ -1,4 +1,7 @@ -use divviup_api::{aggregator_api_mock, clients::aggregator_client::TaskCreate}; +use divviup_api::{ + aggregator_api_mock::{self, random_hpke_config}, + clients::aggregator_client::TaskCreate, +}; use validator::Validate; use super::*; diff --git a/tests/harness/mod.rs b/tests/harness/mod.rs index 351d63ff..68f95d9c 100644 --- a/tests/harness/mod.rs +++ b/tests/harness/mod.rs @@ -1,10 +1,5 @@ #![allow(dead_code)] // because different tests use different parts of this -use divviup_api::{ - aggregator_api_mock::{aggregator_api, random_hpke_config}, - clients::auth0_client::Token, - entity::queue, - ApiConfig, Db, -}; +use divviup_api::{entity::queue, ApiConfig, Db}; use serde::{de::DeserializeOwned, Serialize}; use std::{error::Error, future::Future}; use trillium::Handler; @@ -94,6 +89,7 @@ where F: FnOnce(DivviupApi) -> Fut, Fut: Future>> + Send + 'static, { + std::env::remove_var("SKIP_APP_COMPILATION"); block_on(async move { let (app, _) = build_test_app().await; f(app).await.unwrap(); From 72159ae8d8a8d9136724d6a7d012cba9ec791767 Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Mon, 15 May 2023 16:35:47 -0700 Subject: [PATCH 19/20] use email domain from config --- Cargo.lock | 10 ++++++++++ Cargo.toml | 1 + src/clients/postmark_client.rs | 7 ++++--- src/config.rs | 5 +++-- src/queue/job/v1/send_invitation_email.rs | 2 +- tests/harness/mod.rs | 2 +- 6 files changed, 20 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cf9e2401..1ed448c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -902,6 +902,7 @@ dependencies = [ "async-lock", "async-session", "base64 0.21.0", + "email_address", "env_logger", "fastrand", "git-version", @@ -959,6 +960,15 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +[[package]] +name = "email_address" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.32" diff --git a/Cargo.toml b/Cargo.toml index ef2b32fe..fa83b3f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [".", "migration"] async-lock = "2.7.0" async-session = "3.0.0" base64 = "0.21.0" +email_address = "0.2.4" env_logger = "0.10.0" fastrand = "1.9.0" git-version = "0.3.5" diff --git a/src/clients/postmark_client.rs b/src/clients/postmark_client.rs index 6db2c9d0..3dbb928a 100644 --- a/src/clients/postmark_client.rs +++ b/src/clients/postmark_client.rs @@ -2,6 +2,7 @@ use crate::{ clients::{ClientConnExt, ClientError}, ApiConfig, }; +use email_address::EmailAddress; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::{json, Value}; use trillium::{async_trait, Conn, KnownHeaderName}; @@ -13,7 +14,7 @@ use url::Url; pub struct PostmarkClient { token: String, client: Client, - email: String, + email: EmailAddress, base_url: Url, } @@ -39,7 +40,7 @@ impl PostmarkClient { email .as_object_mut() .unwrap() - .insert("From".into(), self.email.clone().into()); + .insert("From".into(), self.email.to_string().into()); self.post("/email", &email).await } @@ -53,7 +54,7 @@ impl PostmarkClient { let headers = match message_id { Some(m) => json!([{ "Name": "Message-ID", - "Value": format!("<{m}>") + "Value": format!("<{m}@{}>", self.email.domain()) }]), None => json!([]), }; diff --git a/src/config.rs b/src/config.rs index d44fa709..9b29ab58 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,5 @@ use crate::handler::oauth2::Oauth2Config; +use email_address::EmailAddress; use std::{env::VarError, str::FromStr}; use thiserror::Error; use trillium_client::Client; @@ -22,7 +23,7 @@ pub struct ApiConfig { pub prometheus_host: String, pub prometheus_port: u16, pub postmark_token: String, - pub email_address: String, + pub email_address: EmailAddress, pub postmark_url: Url, pub client: Client, } @@ -91,7 +92,7 @@ impl ApiConfig { "16-bit number", )?, postmark_token: var("POSTMARK_TOKEN", "string")?, - email_address: var("EMAIL_ADDRESS", "string")?, + email_address: var("EMAIL_ADDRESS", "email")?, postmark_url: Url::parse("https://api.postmarkapp.com").unwrap(), client: Client::new(RustlsConfig::default().with_tcp_config(ClientConfig::default())) .with_default_pool(), diff --git a/src/queue/job/v1/send_invitation_email.rs b/src/queue/job/v1/send_invitation_email.rs index 33f7a293..38d9ff86 100644 --- a/src/queue/job/v1/send_invitation_email.rs +++ b/src/queue/job/v1/send_invitation_email.rs @@ -43,7 +43,7 @@ impl SendInvitationEmail { "account_name": &account.name, "action_url": self.action_url }), - Some(format!("{}@divviup.org", self.message_id)), + Some(self.message_id.to_string()), ) .await?; diff --git a/tests/harness/mod.rs b/tests/harness/mod.rs index 68f95d9c..b15f6949 100644 --- a/tests/harness/mod.rs +++ b/tests/harness/mod.rs @@ -68,7 +68,7 @@ pub fn config(api_mocks: impl Handler) -> ApiConfig { prometheus_host: "localhost".into(), prometheus_port: 9464, postmark_token: "-".into(), - email_address: "test@example.test".into(), + email_address: "test@example.test".parse().unwrap(), postmark_url: POSTMARK_URL.parse().unwrap(), client: Client::new(trillium_testing::connector(api_mocks)), } From 1467ee29d96ebcba9dc6461412dabda40cfef6ee Mon Sep 17 00:00:00 2001 From: Jacob Rothstein Date: Mon, 15 May 2023 16:56:44 -0700 Subject: [PATCH 20/20] simplify parsing logic and actually print the stringified error --- src/bin.rs | 5 ++++- src/config.rs | 39 ++++++++++++++++++--------------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/bin.rs b/src/bin.rs index ec02cb44..1d762d79 100644 --- a/src/bin.rs +++ b/src/bin.rs @@ -7,7 +7,10 @@ use trillium_tokio::CloneCounterObserver; async fn main() { env_logger::init(); - let config = ApiConfig::from_env().expect("Missing config"); + let config = match ApiConfig::from_env() { + Ok(config) => config, + Err(e) => panic!("{e}"), + }; let stopper = Stopper::new(); let observer = CloneCounterObserver::default(); diff --git a/src/config.rs b/src/config.rs index 9b29ab58..b32f978c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,7 +40,8 @@ pub enum ApiConfigError { InvalidUrl(#[from] url::ParseError), } -fn var(name: &'static str, format: &'static str) -> Result { +fn var(name: &'static str) -> Result { + let format = std::any::type_name::(); std::env::var(name) .map_err(|error| match error { VarError::NotPresent => ApiConfigError::MissingEnvVar(name), @@ -56,8 +57,8 @@ fn var(name: &'static str, format: &'static str) -> Result( name: &'static str, default: &'static str, - format: &'static str, ) -> Result { + let format = std::any::type_name::(); let input_res = std::env::var(name); let input = match &input_res { Ok(value) => value, @@ -74,25 +75,21 @@ fn var_optional( impl ApiConfig { pub fn from_env() -> Result { Ok(Self { - database_url: var("DATABASE_URL", "url")?, - session_secret: var("SESSION_SECRET", "string")?, - api_url: var("API_URL", "url")?, - auth_client_id: var("AUTH_CLIENT_ID", "string")?, - auth_client_secret: var("AUTH_CLIENT_SECRET", "string")?, - auth_audience: var("AUTH_AUDIENCE", "string")?, - app_url: var("APP_URL", "url")?, - auth_url: var("AUTH_URL", "url")?, - aggregator_dap_url: var("AGGREGATOR_DAP_URL", "url")?, - aggregator_api_url: var("AGGREGATOR_API_URL", "url")?, - aggregator_secret: var("AGGREGATOR_SECRET", "string")?, - prometheus_host: var_optional("OTEL_EXPORTER_PROMETHEUS_HOST", "localhost", "string")?, - prometheus_port: var_optional( - "OTEL_EXPORTER_PROMETHEUS_PORT", - "9464", - "16-bit number", - )?, - postmark_token: var("POSTMARK_TOKEN", "string")?, - email_address: var("EMAIL_ADDRESS", "email")?, + database_url: var("DATABASE_URL")?, + session_secret: var("SESSION_SECRET")?, + api_url: var("API_URL")?, + auth_client_id: var("AUTH_CLIENT_ID")?, + auth_client_secret: var("AUTH_CLIENT_SECRET")?, + auth_audience: var("AUTH_AUDIENCE")?, + app_url: var("APP_URL")?, + auth_url: var("AUTH_URL")?, + aggregator_dap_url: var("AGGREGATOR_DAP_URL")?, + aggregator_api_url: var("AGGREGATOR_API_URL")?, + aggregator_secret: var("AGGREGATOR_SECRET")?, + prometheus_host: var_optional("OTEL_EXPORTER_PROMETHEUS_HOST", "localhost")?, + prometheus_port: var_optional("OTEL_EXPORTER_PROMETHEUS_PORT", "9464")?, + postmark_token: var("POSTMARK_TOKEN")?, + email_address: var("EMAIL_ADDRESS")?, postmark_url: Url::parse("https://api.postmarkapp.com").unwrap(), client: Client::new(RustlsConfig::default().with_tcp_config(ClientConfig::default())) .with_default_pool(),