Skip to content

Commit d7703e2

Browse files
authored
[elixir-sdk] Add wait-for-initialization (#249)
1 parent 1b09d62 commit d7703e2

File tree

9 files changed

+126
-10
lines changed

9 files changed

+126
-10
lines changed

.changeset/purple-tools-smoke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"elixir-sdk": minor
3+
---
4+
5+
Add `wait_for_initialization()` method.

elixir-sdk/Cargo.lock

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

elixir-sdk/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ config = %EppoSdk.Client.Config{api_key: "your-api-key", assignment_logger: Your
9595
{:ok, _pid} = EppoSdk.Server.start_link(config)
9696

9797
# When testing locally, wait for the client to initialize
98-
Process.sleep(1000)
98+
client = EppoSdk.Server.get_instance()
99+
EppoSdk.Client.wait_for_initialization(client)
99100

100101
# Then use as normal
101102
client = EppoSdk.Server.get_instance()
@@ -106,7 +107,7 @@ Or you can use the client directly:
106107
{:ok, client} = EppoSdk.Client.new(config)
107108

108109
# When testing locally, wait for the client to initialize
109-
Process.sleep(1000)
110+
EppoSdk.Client.wait_for_initialization(client)
110111
```
111112

112113
## Development

elixir-sdk/lib/client.ex

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,26 @@ defmodule EppoSdk.Client do
115115
{:error, Exception.message(e)}
116116
end
117117
end
118+
119+
@doc """
120+
Waits for client to fetch configuration and get ready to serve
121+
assignments.
122+
123+
This method blocks the current thread until configuration is
124+
successfully fetched or `timeout_seconds` passes.
125+
126+
## Parameters
127+
- timeout_seconds: Timeout in seconds (default: 1.0)
128+
129+
## Usage
130+
```elixir
131+
EppoSdk.Client.wait_for_initialization(client, 2.0)
132+
```
133+
"""
134+
def wait_for_initialization(%__MODULE__{} = client, timeout_seconds \\ 1.0) do
135+
EppoSdk.Core.wait_for_initialization(client.client_ref, timeout_seconds)
136+
:ok
137+
end
118138

119139
@doc """
120140
Assigns a string variant based on the provided flag configuration.

elixir-sdk/lib/core.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ defmodule EppoSdk.Core do
3131
_expected_type
3232
),
3333
do: error()
34+
35+
def wait_for_initialization(_client, _timeout_secs \\ 1.0), do: error()
3436

3537
# Helper function for NIF not loaded errors
3638
defp error, do: :erlang.nif_error(:nif_not_loaded)

elixir-sdk/native/sdk_core/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ crate-type = ["cdylib"]
1212
rustler = { version = "0.36.1", features = ["serde"] }
1313
eppo_core = { version = "=9.1.1", path = "../../../eppo_core", features = ["rustler"] }
1414
serde_json = "1.0.138"
15+
tokio = { version = "1.44.1", default-features = false, features = ["time"] }
16+
log = "0.4.21"

elixir-sdk/native/sdk_core/src/lib.rs

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@ use crate::conversion::{convert_attributes, convert_value_term, convert_event_te
77
use crate::assignment::{get_assignment_inner, get_assignment_details_inner};
88
use eppo_core::{
99
configuration_fetcher::{ConfigurationFetcher, ConfigurationFetcherConfig},
10-
configuration_poller::{start_configuration_poller, ConfigurationPollerConfig},
10+
configuration_poller::{start_configuration_poller, ConfigurationPollerConfig, ConfigurationPoller},
1111
configuration_store::ConfigurationStore,
1212
eval::{Evaluator, EvaluatorConfig},
1313
ufc::VariationType,
1414
SdkMetadata,
1515
background::BackgroundThread,
1616
};
1717
use std::panic::{RefUnwindSafe, UnwindSafe};
18+
use std::time::Duration;
1819

1920
use rustler::{Encoder, Env, NifResult, ResourceArc, Term};
2021
use rustler::types::atom;
@@ -27,8 +28,9 @@ const SDK_METADATA: SdkMetadata = SdkMetadata {
2728
};
2829

2930
pub struct EppoClient {
30-
pub evaluator: Evaluator,
31-
pub background_thread: BackgroundThread,
31+
evaluator: Evaluator,
32+
background_thread: BackgroundThread,
33+
configuration_poller: ConfigurationPoller,
3234
}
3335

3436
#[rustler::resource_impl]
@@ -62,7 +64,7 @@ fn init(config: Config) -> NifResult<ResourceArc<EppoClient>> {
6264
))
6365
.with_jitter(std::time::Duration::from_secs(config.poll_jitter_seconds));
6466

65-
let _poller = start_configuration_poller(
67+
let poller = start_configuration_poller(
6668
background_thread.runtime(),
6769
fetcher,
6870
store.clone(),
@@ -78,6 +80,7 @@ fn init(config: Config) -> NifResult<ResourceArc<EppoClient>> {
7880
let client = ResourceArc::new(EppoClient {
7981
evaluator,
8082
background_thread,
83+
configuration_poller: poller,
8184
});
8285

8386
Ok(client)
@@ -151,4 +154,29 @@ fn get_assignment_details<'a>(
151154
}
152155
}
153156

157+
#[rustler::nif]
158+
fn wait_for_initialization(
159+
client: ResourceArc<EppoClient>,
160+
timeout_secs: f64,
161+
) -> NifResult<()> {
162+
log::info!(target: "eppo", "waiting for initialization");
163+
164+
let _ = client
165+
.background_thread
166+
.runtime()
167+
.async_runtime
168+
.block_on(async {
169+
tokio::time::timeout(
170+
Duration::from_secs_f64(timeout_secs),
171+
client.configuration_poller.wait_for_configuration(),
172+
)
173+
.await
174+
})
175+
.inspect_err(|err| {
176+
log::warn!(target: "eppo", "failed to wait for initialization: {:?}", err);
177+
});
178+
179+
Ok(())
180+
}
181+
154182
rustler::init!("Elixir.EppoSdk.Core");
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
defmodule EppoSdk.ClientWaitTest do
2+
use ExUnit.Case
3+
doctest EppoSdk.Client
4+
5+
setup do
6+
# Reset client singleton state before each test
7+
:ok
8+
end
9+
10+
test "wait_for_initialization succeeds with default timeout" do
11+
config = %EppoSdk.Client.Config{
12+
api_key: "test-api-key",
13+
base_url: "http://127.0.0.1:8378/ufc/api"
14+
}
15+
16+
{:ok, client} = EppoSdk.Client.new(config)
17+
result = EppoSdk.Client.wait_for_initialization(client)
18+
19+
assert result == :ok
20+
# We just verify that the function completed successfully
21+
# The actual configuration fetching result depends on the mock server
22+
# and we've already asserted that wait_for_initialization returned :ok
23+
end
24+
25+
test "wait_for_initialization works with custom timeout" do
26+
config = %EppoSdk.Client.Config{
27+
api_key: "test-api-key",
28+
base_url: "http://127.0.0.1:8378/ufc/api"
29+
}
30+
31+
{:ok, client} = EppoSdk.Client.new(config)
32+
result = EppoSdk.Client.wait_for_initialization(client, 0.5)
33+
34+
assert result == :ok
35+
end
36+
37+
test "wait_for_initialization handles timeouts" do
38+
config = %EppoSdk.Client.Config{
39+
api_key: "test-api-key",
40+
# Bad URL
41+
base_url: "http://127.0.0.1:8378/undefined/api"
42+
}
43+
44+
{:ok, client} = EppoSdk.Client.new(config)
45+
start_time = System.monotonic_time(:millisecond)
46+
result = EppoSdk.Client.wait_for_initialization(client, 0.01)
47+
end_time = System.monotonic_time(:millisecond)
48+
49+
# Still returns :ok on timeout
50+
assert result == :ok
51+
# Verify timeout occurred by checking elapsed time
52+
# at least 200ms elapsed
53+
assert end_time - start_time >= 10
54+
end
55+
end

elixir-sdk/test/test_helper.exs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ defmodule TestHelper do
1717

1818
start_supervised({EppoSdk.Server, config})
1919

20-
# Sleep to allow client to fetch config
20+
# Wait for initialization to complete
2121
unless test_name == "offline" do
22-
:timer.sleep(100)
22+
client = EppoSdk.Server.get_instance()
23+
EppoSdk.Client.wait_for_initialization(client)
2324
end
2425
end
2526
end

0 commit comments

Comments
 (0)