Skip to content

Commit 2c4aa24

Browse files
committed
feat(options): Add SentryOptions derive macro and typed-options crate
Introduces two new crates that replace the hand-written boilerplate in objectstore-options: - `objectstore-typed-options` — defines the `SentryOptions` trait, the shared `Error` type, and the generic background `refresh` loop. All dependencies consumed by generated code (arc-swap, serde, serde_json, sentry-options, tokio) are re-exported as hidden items so consumers do not need to declare them directly. Exposes a `derive` feature that re-exports the proc macro. - `objectstore-typed-options-derive` — provides `#[derive(SentryOptions)]`, which generates the global OnceLock singleton, trait impl, `get()`, `init()`, and (under the `testing` feature) `override_with()`. All generated paths are fully qualified through `objectstore_typed_options`, so the derive can be used from any crate. `objectstore-options` is reduced to the concrete struct definitions: - Its dependency list shrinks to `objectstore-typed-options` (with the `derive` feature) and `serde`. - The `init()` free function is gone; callers use `Options::init()`.
1 parent fd488ac commit 2c4aa24

9 files changed

Lines changed: 455 additions & 129 deletions

File tree

Cargo.lock

Lines changed: 24 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ objectstore-log = { path = "objectstore-log" }
2626
objectstore-metrics = { path = "objectstore-metrics" }
2727
objectstore-options = { path = "objectstore-options" }
2828
objectstore-server = { path = "objectstore-server" }
29+
objectstore-typed-options = { path = "objectstore-typed-options" }
30+
objectstore-typed-options-derive = { path = "objectstore-typed-options-derive" }
2931
objectstore-service = { path = "objectstore-service" }
3032
objectstore-test = { path = "objectstore-test" }
3133
objectstore-types = { path = "objectstore-types", version = "0.1.5" }

objectstore-options/Cargo.toml

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,8 @@ edition = "2024"
1010
publish = false
1111

1212
[dependencies]
13-
arc-swap = { workspace = true }
14-
sentry-options = "1.0.5"
13+
objectstore-typed-options = { workspace = true, features = ["derive"] }
1514
serde = { workspace = true, features = ["derive"] }
16-
serde_json = { workspace = true }
17-
thiserror = { workspace = true }
18-
objectstore-log = { workspace = true }
19-
tokio = { workspace = true, features = ["time"] }
2015

2116
[features]
2217
testing = []

objectstore-options/src/lib.rs

Lines changed: 8 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,28 @@
11
//! Runtime options for Objectstore, backed by [`sentry-options`].
22
//!
3+
//! See the [`Options`] struct for details and usage instructions.
4+
//!
35
//! [`sentry-options`]: https://crates.io/crates/sentry-options
46
57
use std::collections::BTreeMap;
6-
use std::sync::{Arc, OnceLock};
7-
use std::time::Duration;
88

9-
use arc_swap::ArcSwap;
9+
use objectstore_typed_options::SentryOptions;
1010
use serde::{Deserialize, Serialize};
1111

12-
const NAMESPACE: &str = "objectstore";
13-
const SCHEMA: &str = include_str!("../../sentry-options/schemas/objectstore/schema.json");
14-
const REFRESH_INTERVAL: Duration = Duration::from_secs(5);
15-
16-
/// Global instance of the options, initialized by [`init`] and accessed via [`Options::get`].
17-
static OPTIONS: OnceLock<ArcSwap<Options>> = OnceLock::new();
18-
19-
/// Errors returned by this crate.
20-
#[derive(Debug, thiserror::Error)]
21-
pub enum Error {
22-
#[error(transparent)]
23-
Options(#[from] sentry_options::OptionsError),
24-
#[error("failed to deserialize option value")]
25-
Deserialize(#[from] serde_json::Error),
26-
}
12+
pub use objectstore_typed_options::Error;
2713

2814
/// Runtime options for Objectstore, loaded from sentry-options.
2915
///
3016
/// Obtain a snapshot of the current options via [`Options::get`]. Before calling `get`,
31-
/// the global instance must be initialized with [`init`].
32-
#[derive(Debug)]
17+
/// the global instance must be initialized with [`Options::init`].
18+
#[derive(Debug, SentryOptions)]
19+
#[sentry_options(namespace = "objectstore", path = "../../sentry-options")]
3320
pub struct Options {
21+
/// Active killswitches that may disable access to specific object contexts.
3422
killswitches: Vec<Killswitch>,
3523
}
3624

3725
impl Options {
38-
/// Returns a snapshot of the current options.
39-
///
40-
/// The returned [`Arc`] holds the most recently loaded values. Callers may hold it across
41-
/// await points without blocking updates — a new snapshot is swapped in atomically by the
42-
/// background refresh task without invalidating existing references.
43-
///
44-
/// # Panics
45-
///
46-
/// Panics if [`init`] has not been called.
47-
#[cfg(not(feature = "testing"))]
48-
pub fn get() -> Arc<Options> {
49-
OPTIONS.get().expect("options not initialized").load_full()
50-
}
51-
52-
/// Returns a snapshot of the current options, deserializing fresh from schema defaults.
53-
///
54-
/// In test builds this bypasses the global instance and reads directly from the schema, so
55-
/// [`init`] does not need to be called. Use [`override_options`] to test non-default values.
56-
#[cfg(feature = "testing")]
57-
pub fn get() -> Arc<Options> {
58-
let inner = sentry_options::Options::from_schemas(&[(NAMESPACE, SCHEMA)])
59-
.expect("options schema should be valid");
60-
Arc::new(Self::deserialize(&inner).expect("failed to deserialize options"))
61-
}
62-
63-
fn deserialize(options: &sentry_options::Options) -> Result<Self, Error> {
64-
Ok(Self {
65-
killswitches: Deserialize::deserialize(options.get(NAMESPACE, "killswitches")?)?,
66-
})
67-
}
68-
6926
/// Returns the list of active killswitches.
7027
pub fn killswitches(&self) -> &[Killswitch] {
7128
&self.killswitches
@@ -99,71 +56,6 @@ pub struct Killswitch {
9956
pub service: Option<String>,
10057
}
10158

102-
/// Initializes the global options instance and spawns a background refresh task.
103-
///
104-
/// The standard fallback chain is used:
105-
///
106-
/// 1. `SENTRY_OPTIONS_DIR` environment variable
107-
/// 2. `/etc/sentry-options` (if it exists)
108-
/// 3. `sentry-options/` relative to the current working directory
109-
/// 4. Schema defaults (if no values file is present)
110-
///
111-
/// Idempotent: if already initialized, returns `Ok(())` without re-loading.
112-
///
113-
/// Must be called from within a Tokio runtime.
114-
pub fn init() -> Result<(), Error> {
115-
if OPTIONS.get().is_none() {
116-
// Load an initial snapshot and fail loudly if it can't be loaded. This ensures the
117-
// application will not silently run with defaults or fail later when options are accessed.
118-
let inner = sentry_options::Options::from_schemas(&[(NAMESPACE, SCHEMA)])?;
119-
let initial = Options::deserialize(&inner)?;
120-
121-
if OPTIONS.set(ArcSwap::from_pointee(initial)).is_ok() {
122-
tokio::spawn(refresh(inner));
123-
}
124-
}
125-
126-
Ok(())
127-
}
128-
129-
/// Periodically reloads options from disk and atomically swaps in the new snapshot.
130-
async fn refresh(inner: sentry_options::Options) {
131-
let Some(snapshot) = OPTIONS.get() else {
132-
return;
133-
};
134-
135-
let mut interval = tokio::time::interval(REFRESH_INTERVAL);
136-
interval.tick().await; // consume the immediate first tick
137-
138-
loop {
139-
interval.tick().await;
140-
141-
match Options::deserialize(&inner) {
142-
Ok(new_snapshot) => snapshot.store(Arc::new(new_snapshot)),
143-
Err(ref err) => {
144-
objectstore_log::error!(!!err, "Failed to refresh objectstore options")
145-
}
146-
}
147-
}
148-
}
149-
150-
/// Overrides the global options for testing purposes.
151-
///
152-
/// This function is only available in test builds and allows temporarily overriding
153-
/// specific options. The overrides are applied for the duration of the returned
154-
/// `OverrideGuard`.
155-
#[cfg(feature = "testing")]
156-
pub fn override_options(
157-
overrides: &[(&str, serde_json::Value)],
158-
) -> sentry_options::testing::OverrideGuard {
159-
let overrides = overrides
160-
.iter()
161-
.map(|(key, value)| (NAMESPACE, *key, value.clone()))
162-
.collect::<Vec<_>>();
163-
164-
sentry_options::testing::override_options(&overrides).unwrap()
165-
}
166-
16759
#[cfg(test)]
16860
mod tests {
16961
use super::*;

objectstore-server/src/cli.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ pub fn execute() -> Result<()> {
115115
objectstore_log::debug!(?config);
116116

117117
objectstore_metrics::init(&config.metrics)?;
118-
objectstore_options::init()?;
118+
objectstore_options::Options::init()?;
119119

120120
runtime.block_on(async move {
121121
match args.command {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "objectstore-typed-options-derive"
3+
authors = ["Sentry <oss@sentry.io>"]
4+
description = "Derive macro for sentry-options backed runtime configuration"
5+
homepage = "https://getsentry.github.io/objectstore/"
6+
repository = "https://github.com/getsentry/objectstore"
7+
license-file = "../LICENSE.md"
8+
version = "0.1.0"
9+
edition = "2024"
10+
publish = false
11+
12+
[lib]
13+
proc-macro = true
14+
15+
[dependencies]
16+
proc-macro2 = "1.0.95"
17+
quote = "1.0.40"
18+
syn = { version = "2.0.101", features = ["full"] }

0 commit comments

Comments
 (0)