Skip to content

Commit

Permalink
feat(ruby): add offline initialization and Configuration API (#39)
Browse files Browse the repository at this point in the history
* feat(ruby): add offline initialization and Configuration API

* chore(ruby): bump version
  • Loading branch information
rasendubi authored Oct 11, 2024
1 parent ac70134 commit 427fed8
Show file tree
Hide file tree
Showing 13 changed files with 352 additions and 30 deletions.
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

0 comments on commit 427fed8

Please sign in to comment.