Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
7 changes: 1 addition & 6 deletions objectstore-options/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
124 changes: 8 additions & 116 deletions objectstore-options/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<ArcSwap<Options>> = 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<Killswitch>,
}

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> {
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<Options> {
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<Self, Error> {
Ok(Self {
killswitches: Deserialize::deserialize(options.get(NAMESPACE, "killswitches")?)?,
})
}

/// Returns the list of active killswitches.
pub fn killswitches(&self) -> &[Killswitch] {
&self.killswitches
Expand Down Expand Up @@ -99,71 +56,6 @@ pub struct Killswitch {
pub service: Option<String>,
}

/// 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::<Vec<_>>();

sentry_options::testing::override_options(&overrides).unwrap()
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
2 changes: 1 addition & 1 deletion objectstore-server/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions objectstore-typed-options-derive/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "objectstore-typed-options-derive"
authors = ["Sentry <oss@sentry.io>"]
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"] }
Loading
Loading