Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ruby): add offline initialization and Configuration API #39

Merged
merged 2 commits into from
Oct 11, 2024
Merged
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
7 changes: 4 additions & 3 deletions ruby-sdk/Cargo.lock

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

2 changes: 1 addition & 1 deletion ruby-sdk/Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
eppo-server-sdk (3.1.1)
eppo-server-sdk (3.2.0)

GEM
remote: https://rubygems.org/
Expand Down
5 changes: 3 additions & 2 deletions ruby-sdk/ext/eppo_client/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "eppo_client"
# TODO: this version and lib/eppo_client/version.rb should be in sync
version = "3.1.2"
version = "3.2.0"
edition = "2021"
license = "MIT"
publish = false
Expand All @@ -14,7 +14,8 @@ crate-type = ["cdylib"]
env_logger = { version = "0.11.3", features = ["unstable-kv"] }
eppo_core = { version = "4.0.0" }
log = { version = "0.4.21", features = ["kv_serde"] }
magnus = { version = "0.6.2" }
magnus = { version = "0.6.4" }
serde = { version = "1.0.203", features = ["derive"] }
serde_magnus = "0.8.1"
rb-sys = "0.9"
serde_json = "1.0.128"
70 changes: 50 additions & 20 deletions ruby-sdk/ext/eppo_client/src/client.rs
Original file line number Diff line number Diff line change
@@ -1,33 +1,46 @@
use std::{cell::RefCell, sync::Arc};
use std::{cell::RefCell, sync::Arc, time::Duration};

use eppo_core::{
configuration_fetcher::{ConfigurationFetcher, ConfigurationFetcherConfig},
configuration_store::ConfigurationStore,
eval::{Evaluator, EvaluatorConfig},
poller_thread::PollerThread,
poller_thread::{PollerThread, PollerThreadConfig},
ufc::VariationType,
Attributes, ContextAttributes, SdkMetadata,
Attributes, ContextAttributes,
};
use magnus::{error::Result, exception, prelude::*, Error, TryConvert, Value};

use crate::{configuration::Configuration, SDK_METADATA};

#[derive(Debug)]
#[magnus::wrap(class = "EppoClient::Core::Config", size, free_immediately)]
pub struct Config {
api_key: String,
base_url: String,
poll_interval: Option<Duration>,
poll_jitter: Duration,
}

impl TryConvert for Config {
// `val` is expected to be of type EppoClient::Config.
fn try_convert(val: magnus::Value) -> Result<Self> {
let api_key = String::try_convert(val.funcall("api_key", ())?)?;
let base_url = String::try_convert(val.funcall("base_url", ())?)?;
Ok(Config { api_key, base_url })
let poll_interval_seconds =
Option::<u64>::try_convert(val.funcall("poll_interval_seconds", ())?)?;
let poll_jitter_seconds = u64::try_convert(val.funcall("poll_jitter_seconds", ())?)?;
Ok(Config {
api_key,
base_url,
poll_interval: poll_interval_seconds.map(Duration::from_secs),
poll_jitter: Duration::from_secs(poll_jitter_seconds),
})
}
}

#[magnus::wrap(class = "EppoClient::Core::Client")]
pub struct Client {
configuration_store: Arc<ConfigurationStore>,
evaluator: Evaluator,
// Magnus only allows sharing aliased references (&T) through the API, so we need to use RefCell
// to get interior mutability.
Expand All @@ -41,29 +54,35 @@ impl Client {
pub fn new(config: Config) -> Client {
let configuration_store = Arc::new(ConfigurationStore::new());

let sdk_metadata = SdkMetadata {
name: "ruby",
version: env!("CARGO_PKG_VERSION"),
let poller_thread = if let Some(poll_interval) = config.poll_interval {
Some(
PollerThread::start_with_config(
ConfigurationFetcher::new(ConfigurationFetcherConfig {
base_url: config.base_url,
api_key: config.api_key,
sdk_metadata: SDK_METADATA,
}),
configuration_store.clone(),
PollerThreadConfig {
interval: poll_interval,
jitter: config.poll_jitter,
},
)
.expect("should be able to start poller thread"),
)
} else {
None
};

let poller_thread = PollerThread::start(
ConfigurationFetcher::new(ConfigurationFetcherConfig {
base_url: config.base_url,
api_key: config.api_key,
sdk_metadata: sdk_metadata.clone(),
}),
configuration_store.clone(),
)
.expect("should be able to start poller thread");

let evaluator = Evaluator::new(EvaluatorConfig {
configuration_store,
sdk_metadata,
configuration_store: configuration_store.clone(),
sdk_metadata: SDK_METADATA,
});

Client {
configuration_store,
evaluator,
poller_thread: RefCell::new(Some(poller_thread)),
poller_thread: RefCell::new(poller_thread),
}
}

Expand Down Expand Up @@ -171,6 +190,17 @@ impl Client {
serde_magnus::serialize(&result)
}

pub fn get_configuration(&self) -> Option<Configuration> {
self.configuration_store
.get_configuration()
.map(|it| it.into())
}

pub fn set_configuration(&self, configuration: &Configuration) {
self.configuration_store
.set_configuration(configuration.clone().into())
}

pub fn shutdown(&self) {
if let Some(t) = self.poller_thread.take() {
let _ = t.shutdown();
Expand Down
113 changes: 113 additions & 0 deletions ruby-sdk/ext/eppo_client/src/configuration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
use std::sync::Arc;

use magnus::{function, method, prelude::*, scan_args::get_kwargs, Error, RHash, RString, Ruby};

use eppo_core::{ufc::UniversalFlagConfig, Configuration as CoreConfiguration};

use crate::{gc_lock::GcLock, SDK_METADATA};

pub(crate) fn init(ruby: &Ruby) -> Result<(), Error> {
let eppo_client = ruby.define_module("EppoClient")?;

let configuration = eppo_client.define_class("Configuration", magnus::class::object())?;
configuration.define_singleton_method("new", function!(Configuration::new, 1))?;
configuration.define_method(
"flags_configuration",
method!(Configuration::flags_configuration, 0),
)?;
configuration.define_method(
"bandits_configuration",
method!(Configuration::bandits_configuration, 0),
)?;

Ok(())
}

#[derive(Debug, Clone)]
#[magnus::wrap(class = "EppoClient::Configuration", free_immediately)]
pub struct Configuration {
inner: Arc<CoreConfiguration>,
}

impl Configuration {
fn new(ruby: &Ruby, kw: RHash) -> Result<Configuration, Error> {
let args = get_kwargs(kw, &["flags_configuration"], &["bandits_configuration"])?;
let (flags_configuration,): (RString,) = args.required;
let (bandits_configuration,): (Option<Option<RString>>,) = args.optional;
let rest: RHash = args.splat;
if !rest.is_empty() {
return Err(Error::new(
ruby.exception_arg_error(),
format!("unexpected keyword arguments: {:?}", rest),
));
}

let inner = {
let _gc_lock = GcLock::new(ruby);

Arc::new(CoreConfiguration::from_server_response(
UniversalFlagConfig::from_json(
SDK_METADATA,
unsafe {
// SAFETY: we have disabled GC, so the memory can't be modified concurrently.
flags_configuration.as_slice()
}
.to_vec(),
)
.map_err(|err| {
Error::new(
ruby.exception_arg_error(),
format!("failed to parse flags_configuration: {err:?}"),
)
})?,
bandits_configuration
.flatten()
.map(|bandits| {
serde_json::from_slice(unsafe {
// SAFETY: we have disabled GC, so the memory can't be modified concurrently.
bandits.as_slice()
})
})
.transpose()
.map_err(|err| {
Error::new(
ruby.exception_arg_error(),
format!("failed to parse bandits_configuration: {err:?}"),
)
})?,
))
};

Ok(Configuration { inner })
}

fn flags_configuration(ruby: &Ruby, rb_self: &Self) -> Result<RString, Error> {
Ok(ruby.str_from_slice(rb_self.inner.flags.to_json()))
}

fn bandits_configuration(ruby: &Ruby, rb_self: &Self) -> Result<Option<RString>, Error> {
let Some(bandits) = &rb_self.inner.bandits else {
return Ok(None)
};
let vec = serde_json::to_vec(bandits).map_err(|err| {
// this should never happen
Error::new(
ruby.exception_runtime_error(),
format!("failed to serialize bandits configuration: {err:?}"),
)
})?;
Ok(Some(ruby.str_from_slice(&vec)))
}
}

impl From<Arc<CoreConfiguration>> for Configuration {
fn from(inner: Arc<CoreConfiguration>) -> Configuration {
Configuration { inner }
}
}

impl From<Configuration> for Arc<CoreConfiguration> {
fn from(value: Configuration) -> Arc<CoreConfiguration> {
value.inner
}
}
25 changes: 25 additions & 0 deletions ruby-sdk/ext/eppo_client/src/gc_lock.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
use magnus::Ruby;

pub struct GcLock<'a> {
ruby: &'a Ruby,
/// Holds `true` if GC was already disabled before acquiring the lock (so it doesn't need to be
/// re-enabled).
gc_was_disabled: bool,
}

impl<'a> GcLock<'a> {
pub fn new(ruby: &'a Ruby) -> GcLock<'a> {
GcLock {
ruby,
gc_was_disabled: ruby.gc_disable(),
}
}
}

impl<'a> Drop for GcLock<'a> {
fn drop(&mut self) {
if !self.gc_was_disabled {
self.ruby.gc_enable();
}
}
}
22 changes: 21 additions & 1 deletion ruby-sdk/ext/eppo_client/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
mod client;
mod configuration;
mod gc_lock;

use eppo_core::SdkMetadata;
use magnus::{function, method, prelude::*, Error, Object, Ruby};

use crate::client::Client;

pub(crate) const SDK_METADATA: SdkMetadata = SdkMetadata {
name: "ruby",
version: env!("CARGO_PKG_VERSION"),
};

#[magnus::init]
fn init(ruby: &Ruby) -> Result<(), Error> {
env_logger::Builder::from_env(env_logger::Env::new().default_filter_or("eppo")).init();
env_logger::Builder::from_env(env_logger::Env::new().default_filter_or("eppo=debug")).init();

let eppo_client = ruby.define_module("EppoClient")?;
let core = eppo_client.define_module("Core")?;
Expand All @@ -23,12 +31,24 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
"get_bandit_action_details",
method!(Client::get_bandit_action_details, 5),
)?;
core_client.define_method("configuration", method!(Client::get_configuration, 0))?;
core_client.define_method("configuration=", method!(Client::set_configuration, 1))?;
core_client.define_method("shutdown", method!(Client::shutdown, 0))?;

core.const_set(
"DEFAULT_BASE_URL",
eppo_core::configuration_fetcher::DEFAULT_BASE_URL,
)?;
core.const_set(
"DEFAULT_POLL_INTERVAL_SECONDS",
eppo_core::poller_thread::PollerThreadConfig::DEFAULT_POLL_INTERVAL.as_secs(),
)?;
core.const_set(
"DEFAULT_POLL_JITTER_SECONDS",
eppo_core::poller_thread::PollerThreadConfig::DEFAULT_POLL_JITTER.as_secs(),
)?;

configuration::init(ruby)?;

Ok(())
}
8 changes: 8 additions & 0 deletions ruby-sdk/lib/eppo_client/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ def init(config)
@core = EppoClient::Core::Client.new(config)
end

def configuration
@core.configuration
end

def configuration=(configuration)
@core.configuration = configuration
end

def shutdown
@core.shutdown
end
Expand Down
6 changes: 4 additions & 2 deletions ruby-sdk/lib/eppo_client/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
module EppoClient
# The class for configuring the Eppo client singleton
class Config
attr_reader :api_key, :assignment_logger, :base_url
attr_reader :api_key, :assignment_logger, :base_url, :poll_interval_seconds, :poll_jitter_seconds

def initialize(api_key, assignment_logger: AssignmentLogger.new, base_url: EppoClient::Core::DEFAULT_BASE_URL)
def initialize(api_key, assignment_logger: AssignmentLogger.new, base_url: EppoClient::Core::DEFAULT_BASE_URL, poll_interval_seconds: EppoClient::Core::DEFAULT_POLL_INTERVAL_SECONDS, poll_jitter_seconds: EppoClient::Core::DEFAULT_POLL_JITTER_SECONDS, initial_configuration: nil)
@api_key = api_key
@assignment_logger = assignment_logger
@base_url = base_url
@poll_interval_seconds = poll_interval_seconds
@poll_jitter_seconds = poll_jitter_seconds
end

def validate
Expand Down
Loading
Loading