Skip to main content

mz_adapter/
webhook.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use std::collections::BTreeMap;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicBool, Ordering};
13
14use anyhow::Context;
15use chrono::{DateTime, Utc};
16use derivative::Derivative;
17use mz_expr::Eval;
18use mz_ore::cast::CastFrom;
19use mz_repr::{Datum, Diff, Row, RowArena};
20use mz_secrets::SecretsReader;
21use mz_secrets::cache::CachingSecretsReader;
22use mz_sql::plan::{WebhookBodyFormat, WebhookHeaders, WebhookValidation, WebhookValidationSecret};
23use mz_storage_client::controller::MonotonicAppender;
24use mz_storage_client::statistics::WebhookStatistics;
25use mz_storage_types::controller::StorageError;
26use tokio::sync::Semaphore;
27
28use crate::optimize::dataflows::{ExprPrep, ExprPrepWebhookValidation};
29
30/// Errors returns when attempting to append to a webhook.
31#[derive(thiserror::Error, Debug)]
32pub enum AppendWebhookError {
33    // A secret that we need for validation has gone missing.
34    #[error("could not read a required secret")]
35    MissingSecret,
36    #[error("the provided request body is not UTF-8: {msg}")]
37    InvalidUtf8Body { msg: String },
38    #[error("the provided request body is not valid JSON: {msg}")]
39    InvalidJsonBody { msg: String },
40    #[error("webhook source '{database}.{schema}.{name}' does not exist")]
41    UnknownWebhook {
42        database: String,
43        schema: String,
44        name: String,
45    },
46    #[error("failed to validate the request")]
47    ValidationFailed,
48    // Note: we should _NEVER_ add more detail to this error, including the actual error we got
49    // when running validation. This is because the error messages might contain info about the
50    // arguments provided to the validation expression, we could contains user SECRETs. So by
51    // including any more detail we might accidentally expose SECRETs.
52    #[error("validation error")]
53    ValidationError,
54    #[error("internal channel closed")]
55    ChannelClosed,
56    #[error("internal error: {0:?}")]
57    InternalError(#[from] anyhow::Error),
58    #[error("internal storage failure! {0:?}")]
59    StorageError(#[from] StorageError),
60}
61
62/// Contains all of the components necessary for running webhook validation.
63///
64/// To actually validate a webhook request call [`AppendWebhookValidator::eval`].
65#[derive(Clone)]
66pub struct AppendWebhookValidator {
67    validation: WebhookValidation,
68    secrets_reader: CachingSecretsReader,
69}
70
71impl AppendWebhookValidator {
72    pub fn new(validation: WebhookValidation, secrets_reader: CachingSecretsReader) -> Self {
73        AppendWebhookValidator {
74            validation,
75            secrets_reader,
76        }
77    }
78
79    /// Runs the validation expression against one request.
80    ///
81    /// `memory_budget` caps the temporary storage the expression may allocate. The expression is
82    /// user-authored and runs in `environmentd`, so without a cap proportionate to the request one
83    /// `CHECK` can turn a bounded body into an unbounded amount of heap on a shared process.
84    pub async fn eval(
85        self,
86        body: bytes::Bytes,
87        headers: Arc<BTreeMap<String, String>>,
88        received_at: DateTime<Utc>,
89        memory_budget: usize,
90    ) -> Result<bool, AppendWebhookError> {
91        let AppendWebhookValidator {
92            validation,
93            secrets_reader,
94        } = self;
95
96        let WebhookValidation {
97            mut expression,
98            relation_desc: _,
99            secrets,
100            bodies: body_columns,
101            headers: header_columns,
102        } = validation;
103
104        // Use the secrets reader to get any secrets.
105        let mut secret_contents = BTreeMap::new();
106        for WebhookValidationSecret {
107            id,
108            column_idx,
109            use_bytes,
110        } in secrets
111        {
112            let secret = secrets_reader
113                .read(id)
114                .await
115                .map_err(|_| AppendWebhookError::MissingSecret)?;
116            secret_contents.insert(column_idx, (secret, use_bytes));
117        }
118
119        // Transform any calls to `now()` into a constant representing of the current time.
120        //
121        // Note: we do this outside the closure, because otherwise there are some odd catch unwind
122        // boundary errors, and this shouldn't be too computationally expensive.
123        ExprPrepWebhookValidation { now: received_at }
124            .prep_scalar_expr(&mut expression)
125            .map_err(|err| {
126                tracing::error!(?err, "failed to evaluate current time");
127                AppendWebhookError::ValidationError
128            })?;
129
130        // Create a closure to run our validation, this allows lifetimes and unwind boundaries to
131        // work.
132        let validate = move || {
133            // Gather our Datums for evaluation
134            let temp_storage = RowArena::with_budget(memory_budget);
135            let mut datums = Vec::with_capacity(
136                body_columns.len() + header_columns.len() + secret_contents.len(),
137            );
138
139            // Append all of our body columns.
140            for (column_idx, use_bytes) in body_columns {
141                assert_eq!(column_idx, datums.len(), "body index and datums mismatch!");
142
143                let datum = if use_bytes {
144                    Datum::Bytes(&body[..])
145                } else {
146                    let s = std::str::from_utf8(&body[..])
147                        .map_err(|m| AppendWebhookError::InvalidUtf8Body { msg: m.to_string() })?;
148                    Datum::String(s)
149                };
150                datums.push(datum);
151            }
152
153            // Append all of our header columns, re-using Row packings.
154            //
155            let headers_byte = std::cell::OnceCell::new();
156            let headers_text = std::cell::OnceCell::new();
157            for (column_idx, use_bytes) in header_columns {
158                assert_eq!(column_idx, datums.len(), "index and datums mismatch!");
159
160                let row = if use_bytes {
161                    headers_byte.get_or_init(|| {
162                        let mut row = Row::with_capacity(1);
163                        let mut packer = row.packer();
164                        packer.push_dict(
165                            headers
166                                .iter()
167                                .map(|(name, val)| (name.as_str(), Datum::Bytes(val.as_bytes()))),
168                        );
169                        row
170                    })
171                } else {
172                    headers_text.get_or_init(|| {
173                        let mut row = Row::with_capacity(1);
174                        let mut packer = row.packer();
175                        packer.push_dict(
176                            headers
177                                .iter()
178                                .map(|(name, val)| (name.as_str(), Datum::String(val))),
179                        );
180                        row
181                    })
182                };
183                datums.push(row.unpack_first());
184            }
185
186            // Append all of our secrets to our datums, in the correct column order.
187            for column_idx in datums.len()..datums.len() + secret_contents.len() {
188                // Get the secret that corresponds with what is the next "column";
189                let (secret, use_bytes) = secret_contents
190                    .get(&column_idx)
191                    .expect("more secrets to provide, but none for the next column");
192
193                if *use_bytes {
194                    datums.push(Datum::Bytes(secret));
195                } else {
196                    let secret_str = std::str::from_utf8(&secret[..]).expect("valid UTF-8");
197                    datums.push(Datum::String(secret_str));
198                }
199            }
200
201            // Run our validation
202            let valid = expression
203                .eval(&datums[..], &temp_storage)
204                .map_err(|_| AppendWebhookError::ValidationError)?;
205            match valid {
206                Datum::True => Ok::<_, AppendWebhookError>(true),
207                Datum::False | Datum::Null => Ok(false),
208                _ => unreachable!("Creating a webhook source asserts we return a boolean"),
209            }
210        };
211
212        // Then run the validation itself.
213        let valid = mz_ore::task::spawn_blocking(
214            || "webhook-validator-expr",
215            move || {
216                // Since the validation expression is technically a user defined function, we want to
217                // be extra careful and guard against issues taking down the entire process.
218                mz_ore::panic::catch_unwind(validate).map_err(|_| {
219                    tracing::error!("panic while validating webhook request!");
220                    AppendWebhookError::ValidationError
221                })
222            },
223        )
224        .await
225        .context("joining on validation")
226        .map_err(|e| {
227            tracing::error!("Failed to run validation for webhook, {e}");
228            AppendWebhookError::ValidationError
229        })?;
230
231        valid
232    }
233}
234
235#[derive(Derivative, Clone)]
236#[derivative(Debug)]
237pub struct AppendWebhookResponse {
238    /// Channel to monotonically append rows to a webhook source.
239    pub tx: WebhookAppender,
240    /// Column type for the `body` column.
241    pub body_format: WebhookBodyFormat,
242    /// Types of the columns for the headers of a request.
243    pub header_tys: WebhookHeaders,
244    /// Expression used to validate a webhook request.
245    #[derivative(Debug = "ignore")]
246    pub validator: Option<AppendWebhookValidator>,
247}
248
249/// A wrapper around [`MonotonicAppender`] that can get closed by the `Coordinator` if the webhook
250/// gets modified.
251#[derive(Clone, Debug)]
252pub struct WebhookAppender {
253    tx: MonotonicAppender,
254    guard: WebhookAppenderGuard,
255    // Shared statistics related to this webhook.
256    stats: Arc<WebhookStatistics>,
257}
258
259impl WebhookAppender {
260    /// Checks if the [`WebhookAppender`] has closed.
261    pub fn is_closed(&self) -> bool {
262        self.guard.is_closed()
263    }
264
265    /// Appends updates to the linked webhook source.
266    pub async fn append(&self, updates: Vec<(Row, Diff)>) -> Result<(), AppendWebhookError> {
267        if self.is_closed() {
268            return Err(AppendWebhookError::ChannelClosed);
269        }
270
271        let count = u64::cast_from(updates.len());
272        self.stats
273            .updates_staged
274            .fetch_add(count, Ordering::Relaxed);
275        let updates = updates.into_iter().map(|update| update.into()).collect();
276        self.tx.append(updates).await?;
277        self.stats
278            .updates_committed
279            .fetch_add(count, Ordering::Relaxed);
280        Ok(())
281    }
282
283    /// Increment the `messages_received` user-facing statistics. This
284    /// should be incremented even if the request is invalid.
285    pub fn increment_messages_received(&self, msgs: u64) {
286        self.stats
287            .messages_received
288            .fetch_add(msgs, Ordering::Relaxed);
289    }
290
291    /// Increment the `bytes_received` user-facing statistics. This
292    /// should be incremented even if the request is invalid.
293    pub fn increment_bytes_received(&self, bytes: u64) {
294        self.stats
295            .bytes_received
296            .fetch_add(bytes, Ordering::Relaxed);
297    }
298
299    pub(crate) fn new(
300        tx: MonotonicAppender,
301        guard: WebhookAppenderGuard,
302        stats: Arc<WebhookStatistics>,
303    ) -> Self {
304        WebhookAppender { tx, guard, stats }
305    }
306}
307
308/// When a webhook, or it's containing schema and database, get modified we need to invalidate any
309/// outstanding [`WebhookAppender`]s. This is because `Adapter`s will cache [`WebhookAppender`]s to
310/// increase performance, and the (database, schema, name) tuple they cached an appender for is now
311/// incorrect.
312#[derive(Clone, Debug)]
313pub struct WebhookAppenderGuard {
314    is_closed: Arc<AtomicBool>,
315}
316
317impl WebhookAppenderGuard {
318    pub fn is_closed(&self) -> bool {
319        self.is_closed.load(Ordering::SeqCst)
320    }
321}
322
323/// A handle to invalidate [`WebhookAppender`]s. See the comment on [`WebhookAppenderGuard`] for
324/// more detail.
325///
326/// Note: to invalidate the associated [`WebhookAppender`]s, you must drop the corresponding
327/// [`WebhookAppenderInvalidator`].
328#[derive(Debug)]
329pub struct WebhookAppenderInvalidator {
330    is_closed: Arc<AtomicBool>,
331}
332// We want to enforce unique ownership over the ability to invalidate a `WebhookAppender`.
333static_assertions::assert_not_impl_all!(WebhookAppenderInvalidator: Clone);
334
335impl WebhookAppenderInvalidator {
336    pub(crate) fn new() -> WebhookAppenderInvalidator {
337        let is_closed = Arc::new(AtomicBool::new(false));
338        WebhookAppenderInvalidator { is_closed }
339    }
340
341    pub fn guard(&self) -> WebhookAppenderGuard {
342        WebhookAppenderGuard {
343            is_closed: Arc::clone(&self.is_closed),
344        }
345    }
346}
347
348impl Drop for WebhookAppenderInvalidator {
349    fn drop(&mut self) {
350        self.is_closed.store(true, Ordering::SeqCst);
351    }
352}
353
354pub type WebhookAppenderName = (String, String, String);
355
356/// A cache of [`WebhookAppender`]s and other metadata required for appending to a wbhook source.
357///
358/// Entries in the cache get invalidated when a [`WebhookAppender`] closes, at which point the
359/// entry should be dropped from the cache and a request made to the `Coordinator` for a new one.
360#[derive(Debug, Clone)]
361pub struct WebhookAppenderCache {
362    pub entries: Arc<tokio::sync::Mutex<BTreeMap<WebhookAppenderName, AppendWebhookResponse>>>,
363}
364
365impl WebhookAppenderCache {
366    pub fn new() -> Self {
367        WebhookAppenderCache {
368            entries: Arc::new(tokio::sync::Mutex::new(BTreeMap::new())),
369        }
370    }
371}
372
373/// Manages how many concurrent webhook requests we allow at once.
374#[derive(Debug, Clone)]
375pub struct WebhookConcurrencyLimiter {
376    semaphore: Arc<Semaphore>,
377    prev_limit: usize,
378}
379
380impl WebhookConcurrencyLimiter {
381    pub fn new(limit: usize) -> Self {
382        let semaphore = Arc::new(Semaphore::new(limit));
383
384        WebhookConcurrencyLimiter {
385            semaphore,
386            prev_limit: limit,
387        }
388    }
389
390    /// Returns the underlying [`Semaphore`] used for limiting.
391    pub fn semaphore(&self) -> Arc<Semaphore> {
392        Arc::clone(&self.semaphore)
393    }
394
395    /// Updates the limit of how many concurrent requests can be run at once.
396    pub fn set_limit(&mut self, new_limit: usize) {
397        if new_limit > self.prev_limit {
398            // Add permits.
399            let diff = new_limit.saturating_sub(self.prev_limit);
400            tracing::debug!("Adding {diff} permits");
401
402            self.semaphore.add_permits(diff);
403        } else if new_limit < self.prev_limit {
404            // Remove permits.
405            let diff = self.prev_limit.saturating_sub(new_limit);
406            let diff = u32::try_from(diff).unwrap_or(u32::MAX);
407            tracing::debug!("Removing {diff} permits");
408
409            let semaphore = self.semaphore();
410
411            // Kind of janky, but the recommended way to reduce the amount of permits is to spawn
412            // a task the acquires and then forgets old permits.
413            mz_ore::task::spawn(|| "webhook-concurrency-limiter-drop-permits", async move {
414                if let Ok(permit) = Semaphore::acquire_many_owned(semaphore, diff).await {
415                    permit.forget()
416                }
417            });
418        }
419
420        // Store our new limit.
421        self.prev_limit = new_limit;
422        tracing::debug!("New limit, {} permits", self.prev_limit);
423    }
424}
425
426impl Default for WebhookConcurrencyLimiter {
427    fn default() -> Self {
428        WebhookConcurrencyLimiter::new(mz_sql::WEBHOOK_CONCURRENCY_LIMIT)
429    }
430}
431
432#[cfg(test)]
433mod test {
434    use mz_ore::assert_err;
435
436    use super::WebhookConcurrencyLimiter;
437
438    #[mz_ore::test(tokio::test)]
439    #[cfg_attr(miri, ignore)] // unsupported operation: returning ready events from epoll_wait is not yet implemented
440    async fn smoke_test_concurrency_limiter() {
441        let mut limiter = WebhookConcurrencyLimiter::new(10);
442
443        let semaphore_a = limiter.semaphore();
444        let _permit_a = semaphore_a.try_acquire_many(10).expect("acquire");
445
446        let semaphore_b = limiter.semaphore();
447        assert_err!(semaphore_b.try_acquire());
448
449        // Increase our limit.
450        limiter.set_limit(15);
451
452        // This should now succeed!
453        let _permit_b = semaphore_b.try_acquire().expect("acquire");
454
455        // Decrease our limit.
456        limiter.set_limit(5);
457
458        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
459
460        // This should fail again.
461        assert_err!(semaphore_b.try_acquire());
462    }
463}