diff --git a/Cargo.lock b/Cargo.lock index 18e09cf5..efa9bd24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2704,13 +2704,8 @@ dependencies = [ name = "objectstore-options" version = "0.1.0" dependencies = [ - "arc-swap", - "objectstore-log", - "sentry-options", + "objectstore-typed-options", "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", ] [[package]] @@ -2803,6 +2798,29 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "objectstore-typed-options" +version = "0.1.0" +dependencies = [ + "arc-swap", + "objectstore-log", + "objectstore-typed-options-derive", + "sentry-options", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "objectstore-typed-options-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "objectstore-types" version = "0.1.5" diff --git a/Cargo.toml b/Cargo.toml index 3759cced..052f6631 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,8 @@ objectstore-log = { path = "objectstore-log" } objectstore-metrics = { path = "objectstore-metrics" } objectstore-options = { path = "objectstore-options" } objectstore-server = { path = "objectstore-server" } +objectstore-typed-options = { path = "objectstore-typed-options" } +objectstore-typed-options-derive = { path = "objectstore-typed-options-derive" } objectstore-service = { path = "objectstore-service" } objectstore-test = { path = "objectstore-test" } objectstore-types = { path = "objectstore-types", version = "0.1.5" } diff --git a/objectstore-options/Cargo.toml b/objectstore-options/Cargo.toml index 0dc7742d..68d93622 100644 --- a/objectstore-options/Cargo.toml +++ b/objectstore-options/Cargo.toml @@ -10,13 +10,8 @@ edition = "2024" publish = false [dependencies] -arc-swap = { workspace = true } -sentry-options = "1.0.5" +objectstore-typed-options = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -thiserror = { workspace = true } -objectstore-log = { workspace = true } -tokio = { workspace = true, features = ["time"] } [features] testing = [] diff --git a/objectstore-options/src/lib.rs b/objectstore-options/src/lib.rs index 2a91e5c7..99b14e1d 100644 --- a/objectstore-options/src/lib.rs +++ b/objectstore-options/src/lib.rs @@ -1,71 +1,28 @@ //! Runtime options for Objectstore, backed by [`sentry-options`]. //! +//! See the [`Options`] struct for details and usage instructions. +//! //! [`sentry-options`]: https://crates.io/crates/sentry-options use std::collections::BTreeMap; -use std::sync::{Arc, OnceLock}; -use std::time::Duration; -use arc_swap::ArcSwap; +use objectstore_typed_options::SentryOptions; use serde::{Deserialize, Serialize}; -const NAMESPACE: &str = "objectstore"; -const SCHEMA: &str = include_str!("../../sentry-options/schemas/objectstore/schema.json"); -const REFRESH_INTERVAL: Duration = Duration::from_secs(5); - -/// Global instance of the options, initialized by [`init`] and accessed via [`Options::get`]. -static OPTIONS: OnceLock> = OnceLock::new(); - -/// Errors returned by this crate. -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error(transparent)] - Options(#[from] sentry_options::OptionsError), - #[error("failed to deserialize option value")] - Deserialize(#[from] serde_json::Error), -} +pub use objectstore_typed_options::Error; /// Runtime options for Objectstore, loaded from sentry-options. /// /// Obtain a snapshot of the current options via [`Options::get`]. Before calling `get`, -/// the global instance must be initialized with [`init`]. -#[derive(Debug)] +/// the global instance must be initialized with [`Options::init`]. +#[derive(Debug, SentryOptions)] +#[sentry_options(namespace = "objectstore", path = "../../sentry-options")] pub struct Options { + /// Active killswitches that may disable access to specific object contexts. killswitches: Vec, } impl Options { - /// Returns a snapshot of the current options. - /// - /// The returned [`Arc`] holds the most recently loaded values. Callers may hold it across - /// await points without blocking updates — a new snapshot is swapped in atomically by the - /// background refresh task without invalidating existing references. - /// - /// # Panics - /// - /// Panics if [`init`] has not been called. - #[cfg(not(feature = "testing"))] - pub fn get() -> Arc { - OPTIONS.get().expect("options not initialized").load_full() - } - - /// Returns a snapshot of the current options, deserializing fresh from schema defaults. - /// - /// In test builds this bypasses the global instance and reads directly from the schema, so - /// [`init`] does not need to be called. Use [`override_options`] to test non-default values. - #[cfg(feature = "testing")] - pub fn get() -> Arc { - let inner = sentry_options::Options::from_schemas(&[(NAMESPACE, SCHEMA)]) - .expect("options schema should be valid"); - Arc::new(Self::deserialize(&inner).expect("failed to deserialize options")) - } - - fn deserialize(options: &sentry_options::Options) -> Result { - Ok(Self { - killswitches: Deserialize::deserialize(options.get(NAMESPACE, "killswitches")?)?, - }) - } - /// Returns the list of active killswitches. pub fn killswitches(&self) -> &[Killswitch] { &self.killswitches @@ -99,71 +56,6 @@ pub struct Killswitch { pub service: Option, } -/// Initializes the global options instance and spawns a background refresh task. -/// -/// The standard fallback chain is used: -/// -/// 1. `SENTRY_OPTIONS_DIR` environment variable -/// 2. `/etc/sentry-options` (if it exists) -/// 3. `sentry-options/` relative to the current working directory -/// 4. Schema defaults (if no values file is present) -/// -/// Idempotent: if already initialized, returns `Ok(())` without re-loading. -/// -/// Must be called from within a Tokio runtime. -pub fn init() -> Result<(), Error> { - if OPTIONS.get().is_none() { - // Load an initial snapshot and fail loudly if it can't be loaded. This ensures the - // application will not silently run with defaults or fail later when options are accessed. - let inner = sentry_options::Options::from_schemas(&[(NAMESPACE, SCHEMA)])?; - let initial = Options::deserialize(&inner)?; - - if OPTIONS.set(ArcSwap::from_pointee(initial)).is_ok() { - tokio::spawn(refresh(inner)); - } - } - - Ok(()) -} - -/// Periodically reloads options from disk and atomically swaps in the new snapshot. -async fn refresh(inner: sentry_options::Options) { - let Some(snapshot) = OPTIONS.get() else { - return; - }; - - let mut interval = tokio::time::interval(REFRESH_INTERVAL); - interval.tick().await; // consume the immediate first tick - - loop { - interval.tick().await; - - match Options::deserialize(&inner) { - Ok(new_snapshot) => snapshot.store(Arc::new(new_snapshot)), - Err(ref err) => { - objectstore_log::error!(!!err, "Failed to refresh objectstore options") - } - } - } -} - -/// Overrides the global options for testing purposes. -/// -/// This function is only available in test builds and allows temporarily overriding -/// specific options. The overrides are applied for the duration of the returned -/// `OverrideGuard`. -#[cfg(feature = "testing")] -pub fn override_options( - overrides: &[(&str, serde_json::Value)], -) -> sentry_options::testing::OverrideGuard { - let overrides = overrides - .iter() - .map(|(key, value)| (NAMESPACE, *key, value.clone())) - .collect::>(); - - sentry_options::testing::override_options(&overrides).unwrap() -} - #[cfg(test)] mod tests { use super::*; diff --git a/objectstore-server/src/cli.rs b/objectstore-server/src/cli.rs index 8e2dd6c8..132ef7d5 100644 --- a/objectstore-server/src/cli.rs +++ b/objectstore-server/src/cli.rs @@ -115,7 +115,7 @@ pub fn execute() -> Result<()> { objectstore_log::debug!(?config); objectstore_metrics::init(&config.metrics)?; - objectstore_options::init()?; + objectstore_options::Options::init()?; runtime.block_on(async move { match args.command { diff --git a/objectstore-typed-options-derive/Cargo.toml b/objectstore-typed-options-derive/Cargo.toml new file mode 100644 index 00000000..137b330f --- /dev/null +++ b/objectstore-typed-options-derive/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "objectstore-typed-options-derive" +authors = ["Sentry "] +description = "Derive macro for sentry-options backed runtime configuration" +homepage = "https://getsentry.github.io/objectstore/" +repository = "https://github.com/getsentry/objectstore" +license-file = "../LICENSE.md" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0.95" +quote = "1.0.40" +syn = { version = "2.0.101", features = ["full"] } diff --git a/objectstore-typed-options-derive/src/lib.rs b/objectstore-typed-options-derive/src/lib.rs new file mode 100644 index 00000000..41682ecd --- /dev/null +++ b/objectstore-typed-options-derive/src/lib.rs @@ -0,0 +1,232 @@ +//! Derive macro for `SentryOptions`. +//! +//! See the `objectstore-typed-options` crate for full documentation and usage examples. + +use proc_macro::TokenStream; +use quote::quote; +use syn::{DeriveInput, Fields, LitStr, parse_macro_input}; + +/// Derives the `SentryOptions` trait and generates runtime option machinery. +/// +/// # Container attributes +/// +/// - `namespace` — the sentry-options namespace (e.g. `"objectstore"`) +/// - `path` — relative path to the `sentry-options/` directory; the schema is resolved as +/// `{path}/schemas/{namespace}/schema.json` +/// +/// # Generated code +/// +/// For each struct field, `deserialize` calls `Deserialize::deserialize(options.get(NAMESPACE, +/// "")?)`. +/// +/// Additionally generates: +/// - `SentryOptions` trait impl with `NAMESPACE`, `SCHEMA`, and `deserialize` +/// - Inherent `get() -> Arc`, `init() -> Result<(), Error>`, and (under `testing` feature) +/// `override_with()` +/// - A `OnceLock>` static for the singleton instance, wrapped in `const _: () = { … }` +/// to avoid symbol conflicts when multiple structs derive `SentryOptions` in the same module +#[proc_macro_derive(SentryOptions, attributes(sentry_options))] +pub fn derive_sentry_options(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + match expand(input) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +struct Attrs { + namespace: LitStr, + path: LitStr, +} + +fn parse_attrs(input: &DeriveInput) -> syn::Result { + let mut namespace: Option = None; + let mut path: Option = None; + + for attr in &input.attrs { + if !attr.path().is_ident("sentry_options") { + continue; + } + + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("namespace") { + let value = meta.value()?; + namespace = Some(value.parse::()?); + Ok(()) + } else if meta.path.is_ident("path") { + let value = meta.value()?; + path = Some(value.parse::()?); + Ok(()) + } else { + Err(meta.error("unknown sentry_options attribute")) + } + })?; + } + + let namespace = namespace + .ok_or_else(|| syn::Error::new(input.ident.span(), "missing `namespace` attribute"))?; + let path = + path.ok_or_else(|| syn::Error::new(input.ident.span(), "missing `path` attribute"))?; + + Ok(Attrs { namespace, path }) +} + +fn expand(input: DeriveInput) -> syn::Result { + let attrs = parse_attrs(&input)?; + let name = &input.ident; + + let fields = match &input.data { + syn::Data::Struct(data) => match &data.fields { + Fields::Named(named) => &named.named, + _ => { + return Err(syn::Error::new( + name.span(), + "SentryOptions can only be derived on structs with named fields", + )); + } + }, + _ => { + return Err(syn::Error::new( + name.span(), + "SentryOptions can only be derived on structs", + )); + } + }; + + let namespace_str = &attrs.namespace; + let path_str = &attrs.path; + + // Build deserialize body: one line per field. + let field_deserializations: Vec<_> = fields + .iter() + .map(|f| { + let field_name = f.ident.as_ref().expect("named field"); + let field_key = field_name.to_string(); + quote! { + #field_name: ::objectstore_typed_options::serde::Deserialize::deserialize( + options.get(Self::NAMESPACE, #field_key)? + )? + } + }) + .collect(); + + Ok(quote! { + const _: () = { + static __OPTIONS: ::std::sync::OnceLock< + ::objectstore_typed_options::arc_swap::ArcSwap<#name> + > = ::std::sync::OnceLock::new(); + + impl ::objectstore_typed_options::SentryOptions for #name { + const NAMESPACE: &str = #namespace_str; + const SCHEMA: &str = include_str!( + concat!(#path_str, "/schemas/", #namespace_str, "/schema.json") + ); + + fn deserialize( + options: &::objectstore_typed_options::sentry_options::Options, + ) -> ::std::result::Result { + ::std::result::Result::Ok(Self { + #(#field_deserializations),* + }) + } + } + + impl #name { + /// Returns a snapshot of the current options. + /// + /// The returned [`Arc`] holds the most recently loaded values. Callers may hold + /// it across await points without blocking updates — a new snapshot is swapped in + /// atomically by the background refresh task without invalidating existing + /// references. + /// + /// # Panics + /// + /// Panics if [`init`](Self::init) has not been called. + #[cfg(not(feature = "testing"))] + pub fn get() -> ::std::sync::Arc { + __OPTIONS + .get() + .expect("options not initialized") + .load_full() + } + + /// Returns a snapshot of the current options, deserializing fresh from schema + /// defaults. + /// + /// In test builds this bypasses the global instance and reads directly from the + /// schema, so [`init`](Self::init) does not need to be called. Use + /// [`override_with`](Self::override_with) to test non-default values. + #[cfg(feature = "testing")] + pub fn get() -> ::std::sync::Arc { + use ::objectstore_typed_options::SentryOptions as _; + + let inner = ::objectstore_typed_options::sentry_options::Options::from_schemas( + &[(Self::NAMESPACE, Self::SCHEMA)], + ) + .expect("options schema should be valid"); + + ::std::sync::Arc::new( + Self::deserialize(&inner).expect("failed to deserialize options"), + ) + } + + /// Initializes the global options instance and spawns a background refresh task. + /// + /// The standard fallback chain is used: + /// + /// 1. `SENTRY_OPTIONS_DIR` environment variable + /// 2. `/etc/sentry-options` (if it exists) + /// 3. `sentry-options/` relative to the current working directory + /// 4. Schema defaults (if no values file is present) + /// + /// Idempotent: if already initialized, returns `Ok(())` without re-loading. + /// + /// Must be called from within a Tokio runtime. + pub fn init() -> ::std::result::Result<(), ::objectstore_typed_options::Error> { + use ::objectstore_typed_options::SentryOptions as _; + + if __OPTIONS.get().is_none() { + let inner = + ::objectstore_typed_options::sentry_options::Options::from_schemas( + &[(Self::NAMESPACE, Self::SCHEMA)], + )?; + let initial = Self::deserialize(&inner)?; + + if __OPTIONS + .set(::objectstore_typed_options::arc_swap::ArcSwap::from_pointee( + initial, + )) + .is_ok() + { + ::objectstore_typed_options::tokio::spawn( + ::objectstore_typed_options::refresh::(&__OPTIONS, inner), + ); + } + } + + ::std::result::Result::Ok(()) + } + + /// Overrides the global options for testing purposes. + /// + /// This function is only available in test builds and allows temporarily + /// overriding specific options. The overrides are applied for the duration of + /// the returned [`OverrideGuard`](objectstore_typed_options::sentry_options::testing::OverrideGuard). + #[cfg(feature = "testing")] + pub fn override_with( + overrides: &[(&str, ::objectstore_typed_options::serde_json::Value)], + ) -> ::objectstore_typed_options::sentry_options::testing::OverrideGuard { + use ::objectstore_typed_options::SentryOptions as _; + + let overrides = overrides + .iter() + .map(|(key, value)| (Self::NAMESPACE, *key, value.clone())) + .collect::<::std::vec::Vec<_>>(); + + ::objectstore_typed_options::sentry_options::testing::override_options(&overrides) + .expect("failed to override options") + } + } + }; // end const _: () = { ... } + }) +} diff --git a/objectstore-typed-options/Cargo.toml b/objectstore-typed-options/Cargo.toml new file mode 100644 index 00000000..22e7d857 --- /dev/null +++ b/objectstore-typed-options/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "objectstore-typed-options" +authors = ["Sentry "] +description = "Runtime typed options trait and support machinery, backed by sentry-options" +homepage = "https://getsentry.github.io/objectstore/" +repository = "https://github.com/getsentry/objectstore" +license-file = "../LICENSE.md" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +arc-swap = { workspace = true } +objectstore-log = { workspace = true } +objectstore-typed-options-derive = { workspace = true, optional = true } +sentry-options = "1.0.5" +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["time"] } + +[features] +derive = ["dep:objectstore-typed-options-derive"] diff --git a/objectstore-typed-options/src/lib.rs b/objectstore-typed-options/src/lib.rs new file mode 100644 index 00000000..9e69f81b --- /dev/null +++ b/objectstore-typed-options/src/lib.rs @@ -0,0 +1,150 @@ +//! Typed, live-reloading runtime options backed by sentry-options. +//! +//! Annotate a plain Rust struct with `#[derive(SentryOptions)]` and it gains a global +//! singleton, schema validation at startup, atomic background refresh every few seconds, +//! and a test-friendly override mechanism — without any hand-written boilerplate. +//! +//! # Usage +//! +//! Annotate a named struct with `#[derive(SentryOptions)]` and the two required container +//! attributes: +//! +//! - `namespace` — the sentry-options namespace string +//! - `path` — relative path (from the source file) to the `sentry-options/` directory; +//! the schema is resolved as `{path}/schemas/{namespace}/schema.json` +//! +//! Each struct field must implement [`serde::Deserialize`] and corresponds to a key of the +//! same name within the namespace. +//! +//! # Generated API +//! +//! - `T::get() -> Arc` — Returns an atomic snapshot of the current values. Panics if +//! `init` was not called (non-test builds). +//! - `T::init() -> Result<(), Error>` — Loads initial values, sets the global singleton, and +//! spawns a background refresh task. Idempotent. Must be called inside a Tokio runtime. +//! - `T::override_with(…)` — *(testing feature only)* Temporarily overrides option values; +//! reverts when the returned guard is dropped. +//! +//! The background refresh task reloads values from disk every 5 seconds and atomically swaps +//! in the new snapshot without blocking readers. +//! +//! # Testing +//! +//! Compile with the `testing` feature to enable a test-friendly variant of `get()` that +//! deserializes fresh from schema defaults on every call, bypassing the global singleton. +//! This means [`init`](SentryOptions) does not need to be called in tests. +//! +//! A minimal schema validity test — which every options struct should have — looks like: +//! +//! ```rust,no_run +//! # use objectstore_typed_options::SentryOptions; +//! # #[derive(Debug, SentryOptions)] +//! # #[sentry_options(namespace = "objectstore", path = "../../sentry-options")] +//! # struct Options {} +//! #[cfg(test)] +//! mod tests { +//! use super::*; +//! +//! #[test] +//! fn schema_is_valid() { +//! let _ = Options::get(); +//! } +//! } +//! ``` +//! +//! # Example +//! +//! ```rust,no_run +//! use objectstore_typed_options::SentryOptions; +//! +//! // `path` points to the sentry-options directory; the schema is resolved as +//! // `{path}/schemas/{namespace}/schema.json` and embedded at compile time. +//! #[derive(Debug, SentryOptions)] +//! #[sentry_options( +//! namespace = "objectstore", +//! path = "../../sentry-options" +//! )] +//! pub struct Options { +//! max_retries: u32, +//! allowed_orgs: Vec, +//! } +//! +//! impl Options { +//! pub fn max_retries(&self) -> u32 { +//! self.max_retries +//! } +//! +//! pub fn allowed_orgs(&self) -> &[u32] { +//! &self.allowed_orgs +//! } +//! } +//! +//! // At startup (inside a Tokio runtime): +//! Options::init().expect("failed to load options"); +//! +//! // At call sites: +//! println!("max_retries = {}", Options::get().max_retries()); +//! ``` + +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use arc_swap::ArcSwap; + +// Re-exported for use by generated code from `#[derive(SentryOptions)]`. Not public API. +#[doc(hidden)] +pub use {arc_swap, sentry_options, serde, serde_json, tokio}; + +#[cfg(feature = "derive")] +pub use objectstore_typed_options_derive::SentryOptions; + +const REFRESH_INTERVAL: Duration = Duration::from_secs(5); + +/// Errors returned by sentry-options operations. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Options(#[from] sentry_options::OptionsError), + #[error("failed to deserialize option value")] + Deserialize(#[from] serde_json::Error), +} + +/// Trait implemented by option structs that are backed by sentry-options. +/// +/// Typically derived via `#[derive(SentryOptions)]` rather than implemented manually. +pub trait SentryOptions: Sized + Send + Sync + 'static { + /// The sentry-options namespace for this type. + const NAMESPACE: &str; + + /// The raw JSON schema string (embedded at compile time via `include_str!`). + const SCHEMA: &str; + + /// Deserializes an instance from the loaded sentry-options values. + fn deserialize(options: &sentry_options::Options) -> Result; +} + +/// Periodically reloads options from disk and atomically swaps in the new snapshot. +/// +/// Called by the generated [`init`](SentryOptions) implementation. Not intended for direct use. +pub async fn refresh( + lock: &'static OnceLock>, + inner: sentry_options::Options, +) { + let Some(snapshot) = lock.get() else { + return; + }; + + let mut interval = tokio::time::interval(REFRESH_INTERVAL); + interval.tick().await; // consume the immediate first tick + + loop { + interval.tick().await; + + match T::deserialize(&inner) { + Ok(new_snapshot) => snapshot.store(Arc::new(new_snapshot)), + Err(ref err) => { + objectstore_log::error!(!!err, "Failed to refresh objectstore options") + } + } + } +}