From 326b3f7de57b1c142e7d1abdcbe46821e59fe668 Mon Sep 17 00:00:00 2001 From: Eliza Weisman Date: Fri, 3 Jan 2025 16:38:08 -0800 Subject: [PATCH] turn event class globs into `SIMILAR TO` patterns needs testing, but i'd like to do that after finishing the DB queries that use it... --- nexus/db-model/src/schema.rs | 1 + nexus/db-model/src/webhook_rx.rs | 84 ++++++++++++++++++- nexus/db-queries/src/db/datastore/mod.rs | 1 + .../src/db/datastore/webhook_event.rs | 59 +++++++++++++ schema/crdb/add-webhooks/up04.sql | 11 ++- schema/crdb/dbinit.sql | 11 ++- 6 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 nexus/db-queries/src/db/datastore/webhook_event.rs diff --git a/nexus/db-model/src/schema.rs b/nexus/db-model/src/schema.rs index bdeb6da21f..152ddeb0c1 100644 --- a/nexus/db-model/src/schema.rs +++ b/nexus/db-model/src/schema.rs @@ -2139,6 +2139,7 @@ table! { webhook_rx_subscription (rx_id, event_class) { rx_id -> Uuid, event_class -> Text, + similar_to -> Text, time_created -> Timestamptz, } } diff --git a/nexus/db-model/src/webhook_rx.rs b/nexus/db-model/src/webhook_rx.rs index a5a459845d..081ae909ce 100644 --- a/nexus/db-model/src/webhook_rx.rs +++ b/nexus/db-model/src/webhook_rx.rs @@ -6,7 +6,7 @@ use crate::schema::{webhook_rx, webhook_rx_secret, webhook_rx_subscription}; use crate::typed_uuid::DbTypedUuid; use chrono::{DateTime, Utc}; use db_macros::Resource; -use omicron_uuid_kinds::WebhookReceiverKind; +use omicron_uuid_kinds::{WebhookReceiverKind, WebhookReceiverUuid}; use serde::{Deserialize, Serialize}; /// A webhook receiver configuration. @@ -47,5 +47,87 @@ pub struct WebhookRxSecret { pub struct WebhookRxSubscription { pub rx_id: DbTypedUuid, pub event_class: String, + pub similar_to: String, pub time_created: DateTime, } + +impl WebhookRxSubscription { + pub fn new(rx_id: WebhookReceiverUuid, event_class: String) -> Self { + fn seg2regex(segment: &str, similar_to: &mut String) { + match segment { + // Match one segment (i.e. any number of segment characters) + "*" => similar_to.push_str("[a-zA-Z0-9\\_\\-]+"), + // Match any number of segments + "**" => similar_to.push('%'), + // Match the literal segment. + // Because `_` his a metacharacter in Postgres' SIMILAR TO + // regexes, we've gotta go through and escape them. + s => { + for s in s.split_inclusive('_') { + // Handle the fact that there might not be a `_` in the + // string at all + if let Some(s) = s.strip_suffix('_') { + similar_to.push_str(s); + similar_to.push_str("\\_"); + } else { + similar_to.push_str(s); + } + } + } + } + } + + // The subscription's regex will always be at least as long as the event class. + let mut similar_to = String::with_capacity(event_class.len()); + let mut segments = event_class.split('.'); + if let Some(segment) = segments.next() { + seg2regex(segment, &mut similar_to); + for segment in segments { + similar_to.push('.'); // segment separator + seg2regex(segment, &mut similar_to); + } + } else { + // TODO(eliza): we should probably validate that the event class has + // at least one segment... + }; + + // `_` is a metacharacter in Postgres' SIMILAR TO regexes, so escape + // them. + + Self { + rx_id: DbTypedUuid(rx_id), + event_class, + similar_to, + time_created: Utc::now(), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_event_class_glob_to_regex() { + const CASES: &[(&str, &str)] = &[ + ("foo.bar", "foo.bar"), + ("foo.*.bar", "foo.[a-zA-Z0-9\\_\\-]+.bar"), + ("foo.*", "foo.[a-zA-Z0-9\\_\\-]+"), + ("*.foo", "[a-zA-Z0-9\\_\\-]+.foo"), + ("foo.**.bar", "foo.%.bar"), + ("foo.**", "foo.%"), + ("foo_bar.baz", "foo\\_bar.baz"), + ("foo_bar.*.baz", "foo\\_bar.[a-zA-Z0-9\\_\\-]+.baz"), + ]; + let rx_id = WebhookReceiverUuid::new_v4(); + for (class, regex) in CASES { + let subscription = + WebhookRxSubscription::new(rx_id, dbg!(class).to_string()); + assert_eq!( + dbg!(regex), + dbg!(&subscription.similar_to), + "event class {class:?} should produce the regex {regex:?}" + ); + } + } +} diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index 25f1f282f7..f3070ec4d0 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -106,6 +106,7 @@ mod vmm; mod volume; mod volume_repair; mod vpc; +mod webhook_event; mod zpool; pub use address_lot::AddressLotCreateResult; diff --git a/nexus/db-queries/src/db/datastore/webhook_event.rs b/nexus/db-queries/src/db/datastore/webhook_event.rs new file mode 100644 index 0000000000..e29469d327 --- /dev/null +++ b/nexus/db-queries/src/db/datastore/webhook_event.rs @@ -0,0 +1,59 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! [`DataStore`] methods for webhook events and event delivery dispatching. + +use super::DataStore; +use crate::db::pool::DbConnection; +use async_bb8_diesel::AsyncRunQueryDsl; +use diesel::prelude::*; +use diesel::result::OptionalExtension; +use omicron_uuid_kinds::{GenericUuid, WebhookEventUuid}; + +use crate::db::model::WebhookEvent; +use crate::db::schema::webhook_event::dsl as event_dsl; + +impl DataStore { + /// Select the next webhook event in need of dispatching. + /// + /// This performs a `SELECT ... FOR UPDATE SKIP LOCKED` on the + /// `webhook_event` table, returning the oldest webhook event which has not + /// yet been dispatched to receivers and which is not actively being + /// dispatched in another transaction. + // NOTE: it would be kinda nice if this query could also select the + // webhook receivers subscribed to this event, but I am not totally sure + // what the CRDB semantics of joining on another table in a `SELECT ... FOR + // UPDATE SKIP LOCKED` query are. We don't want to inadvertantly also lock + // the webhook receivers... + pub async fn webhook_event_select_for_dispatch( + &self, + conn: &async_bb8_diesel::Connection, + ) -> Result, diesel::result::Error> { + event_dsl::webhook_event + .filter(event_dsl::time_dispatched.is_null()) + .order_by(event_dsl::time_created.asc()) + .limit(1) + .for_update() + .skip_locked() + .select(WebhookEvent::as_select()) + .get_result_async(conn) + .await + .optional() + } + + /// Mark the webhook event with the provided UUID as dispatched. + pub async fn webhook_event_set_dispatched( + &self, + event_id: &WebhookEventUuid, + conn: &async_bb8_diesel::Connection, + ) -> Result<(), diesel::result::Error> { + diesel::update(event_dsl::webhook_event) + .filter(event_dsl::id.eq(event_id.into_untyped_uuid())) + .filter(event_dsl::time_dispatched.is_null()) + .set(event_dsl::time_dispatched.eq(diesel::dsl::now)) + .execute_async(conn) + .await + .map(|_| ()) // this should always be 1... + } +} diff --git a/schema/crdb/add-webhooks/up04.sql b/schema/crdb/add-webhooks/up04.sql index 7911418a78..e5c6b433ca 100644 --- a/schema/crdb/add-webhooks/up04.sql +++ b/schema/crdb/add-webhooks/up04.sql @@ -2,8 +2,17 @@ CREATE TABLE IF NOT EXISTS omicron.public.webhook_rx_subscription ( -- UUID of the webhook receiver (foreign key into -- `omicron.public.webhook_rx`) rx_id UUID NOT NULL, - -- An event class to which this receiver is subscribed. + -- An event class (or event class glob) to which this receiver is subscribed. event_class STRING(512) NOT NULL, + -- The event class or event classs glob transformed into a patteern for use + -- in SQL `SIMILAR TO` clauses. + -- + -- This is a bit interesting: users specify event class globs as sequences + -- of dot-separated segments which may be `*` to match any one segment or + -- `**` to match any number of segments. In order to match webhook events to + -- subscriptions within the database, we transform these into patterns that + -- can be used with a `SIMILAR TO` clause. + similar_to STRING(512) NOT NULL, time_created TIMESTAMPTZ NOT NULL, PRIMARY KEY (rx_id, event_class) diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index c0646112cc..4b7ffe4cff 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -4731,8 +4731,17 @@ CREATE TABLE IF NOT EXISTS omicron.public.webhook_rx_subscription ( -- UUID of the webhook receiver (foreign key into -- `omicron.public.webhook_rx`) rx_id UUID NOT NULL, - -- An event class to which this receiver is subscribed. + -- An event class (or event class glob) to which this receiver is subscribed. event_class STRING(512) NOT NULL, + -- The event class or event classs glob transformed into a patteern for use + -- in SQL `SIMILAR TO` clauses. + -- + -- This is a bit interesting: users specify event class globs as sequences + -- of dot-separated segments which may be `*` to match any one segment or + -- `**` to match any number of segments. In order to match webhook events to + -- subscriptions within the database, we transform these into patterns that + -- can be used with a `SIMILAR TO` clause. + similar_to STRING(512) NOT NULL, time_created TIMESTAMPTZ NOT NULL, PRIMARY KEY (rx_id, event_class)