diff --git a/.gitignore b/.gitignore index 2cee92b..bbb7d41 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,12 @@ .rspec_status .byebug_history -.gemspec \ No newline at end of file +.gemspec +.claude/ +.DS_Store +AUDIT_REPORT.md +FIXES_SUMMARY.md +SILENT_FAILURE_AUDIT.md +vendor/ +*.bak +*.backup diff --git a/CHANGELOG.md b/CHANGELOG.md index 118e3cb..25768d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,28 @@ -## [Unreleased] +## [2.0.0] - Unreleased + +Behavior now matches the JavaScript SDK (the reference implementation). + +### Breaking +- Read methods (`treatment`, `peek_treatment`, `variable_value`, `peek_variable_value`, `variable_keys`, `custom_field_keys`, `custom_field_value`, `custom_field_value_type`) raise `IllegalStateException` "ABsmartly Context is not yet ready." before the context is ready and "ABsmartly Context is finalized." after `close`, instead of returning 0 / the default. `experiments` raises only when not ready. `publish` and `refresh` raise when not ready. +- The finalize event is named `"finalize"` (`ContextEventLogger::EVENT_TYPE::FINALIZE`); `EVENT_TYPE::CLOSE` is kept as a deprecated alias with the new value. +- `close` after a failed final publish returns the failure and leaves the context open with its events queued, so it can be retried. It used to seal the context and orphan the events. +- A failed `refresh` no longer marks the context failed: it logs the error, returns it and keeps the current data. `refresh` on a failed context is a no-op. +- `ABSmartly.new` is a class-level overload (config object or `endpoint, api_key:, application:, environment:, ...`); subclasses that override `initialize` must adapt. +- `Client#headers` is no longer a public accessor. +- `Array.wrap`, `String#underscore` and `String#compare_to` core extensions removed. +- `match` no longer caps pattern/text length (the JavaScript SDK has no cap); an invalid pattern still evaluates to null. + +### Fixed +- Units are hashed from the UTF-8 encoding of their Unicode text: non-UTF-8 strings (e.g. UTF-16) are transcoded, surrogate pairs written as CESU-8 are joined into 4-byte sequences, and lone surrogates or invalid bytes become U+FFFD. +- HTTP: network errors and 5xx responses are retried for both the context fetch and publish; a connection error fails the context instead of raising out of `create_context`. +- A raising event logger no longer breaks the operation that logs (initialization, treatment, publish). +- `in` with an object haystack finds symbol keys (audience attributes are parsed with symbolized names). +- An indeterminate audience result (no filter) keeps a cached assignment instead of re-assigning. +- An exposure or goal can no longer be queued after `close` sealed the context. ### Added - Configurable connection pool settings: `pool_size` and `pool_idle_timeout` +- Backwards-compatible class aliases: `SDK` (alias for `ABSmartly`) and `SDKConfig` (alias for `ABSmartlyConfig`) ## [0.1.0] - 2022-08-03 diff --git a/Gemfile.lock b/Gemfile.lock index 22334b1..0fbad35 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - absmartly-sdk (1.3.0) + absmartly-sdk (2.0.0) base64 (~> 0.2) faraday (~> 2.0) faraday-net_http_persistent (~> 2.0) diff --git a/README.md b/README.md index d83f029..99ab9fb 100644 --- a/README.md +++ b/README.md @@ -1,161 +1,229 @@ -# A/B Smartly SDK +# ABsmartly Ruby SDK -A/B Smartly Ruby SDK +Ruby SDK for [ABsmartly](https://www.absmartly.com/) A/B testing platform. ## Compatibility -The A/B Smartly Ruby SDK is compatible with Ruby versions 2.7 and later. For the best performance and code readability, Ruby 3 or later is recommended. This SDK is being constantly tested with the nightly builds of Ruby, to ensure it is compatible with the latest Ruby version. +The ABsmartly Ruby SDK is compatible with Ruby versions 2.7 and later. For the best performance and code readability, Ruby 3 or later is recommended. This SDK is being constantly tested with the nightly builds of Ruby, to ensure it is compatible with the latest Ruby version. +## Installation + +Install the gem and add to the application's Gemfile by executing: + +```bash +bundle add absmartly-sdk +``` + +If bundler is not being used to manage dependencies, install the gem by executing: + +```bash +gem install absmartly-sdk +``` ## Getting Started -### Install the SDK +Please follow the [installation](#installation) instructions before trying the following code. -Install the gem and add to the application's Gemfile by executing: +### Initialization - $ bundle add absmartly-sdk +This example assumes an API Key, an Application, and an Environment have been created in the ABsmartly web console. -If bundler is not being used to manage dependencies, install the gem by executing: +#### Recommended: Named Parameters (Ruby Keyword Arguments) + +The simplest and most idiomatic way to initialize the SDK in Ruby: - $ gem install absmartly-sdk +```ruby +require 'absmartly' -## Import and Initialize the SDK +sdk = ABSmartly.new( + "https://your-company.absmartly.io/v1", + api_key: "YOUR-API-KEY", + application: "website", + environment: "development" +) +``` -Once the SDK is installed, it can be initialized in your project. +#### With Optional Parameters ```ruby +sdk = ABSmartly.new( + "https://your-company.absmartly.io/v1", + api_key: "YOUR-API-KEY", + application: "website", + environment: "development", + timeout: 5000, # Connection timeout in milliseconds (default: 3000) + retries: 3 # Max retry attempts (default: 5) +) +``` + +#### Alternative: Global Configuration + +For applications that need a single SDK instance shared globally: + +```ruby +require 'absmartly' + Absmartly.configure_client do |config| config.endpoint = "https://your-company.absmartly.io/v1" config.api_key = "YOUR-API-KEY" config.application = "website" config.environment = "development" - config.connect_timeout = 3.0 - config.connection_request_timeout = 3.0 - config.retry_interval = 0.5 - config.max_retries = 5 - config.pool_size = 20 - config.pool_idle_timeout = 5 end ``` +#### Alternative: Full Configuration + +For use cases where you need custom providers, serializers, or other low-level configurations: + +```ruby +require 'absmartly' + +client_config = ClientConfig.create +client_config.endpoint = "https://your-company.absmartly.io/v1" +client_config.api_key = "YOUR-API-KEY" +client_config.application = "website" +client_config.environment = "development" +client_config.connect_timeout = 3.0 +client_config.connection_request_timeout = 3.0 +client_config.retry_interval = 0.5 +client_config.max_retries = 5 + +sdk_config = ABSmartlyConfig.create +sdk_config.client = Client.create(client_config) + +sdk = ABSmartly.create(sdk_config) +``` + **SDK Options** -| Config | Type | Required? | Default | Description | -| :---------- | :----------------------------------- | :-------: | :-------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| endpoint | `string` | ✅ | `undefined` | The URL to your API endpoint. Most commonly `"your-company.absmartly.io"` | -| api_key | `string` | ✅ | `undefined` | Your API key which can be found on the Web Console. | -| environment | `"production"` or `"development"` | ✅ | `undefined` | The environment of the platform where the SDK is installed. Environments are created on the Web Console and should match the available environments in your infrastructure. | -| application | `string` | ✅ | `undefined` | The name of the application where the SDK is installed. Applications are created on the Web Console and should match the applications where your experiments will be running. | -| connect_timeout | `number` | ❌ | `3.0` | The socket connection timeout in seconds. | -| connection_request_timeout | `number` | ❌ | `3.0` | The request timeout in seconds. | -| retry_interval | `number` | ❌ | `0.5` | The initial retry interval in seconds (uses exponential backoff). | -| max_retries | `number` | ❌ | `5` | The maximum number of retries before giving up. | -| pool_size | `number` | ❌ | `20` | The number of connections in the HTTP connection pool. | -| pool_idle_timeout | `number` | ❌ | `5` | The time in seconds before idle connections are closed. | -| event_logger | `ContextEventLogger` | ❌ | See "Using a Custom Event Logger" below | A `ContextEventLogger` instance implementing `handle_event(event, data)` to receive SDK events. | - -### Using a Custom Event Logger - -The A/B Smartly SDK can be instantiated with an event logger used for all -contexts. In addition, an event logger can be specified when creating a -particular context in the context config. +| Parameter | Type | Required? | Default | Description | +| :------------------------- | :-------------------------------- | :-------: | :-----: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| endpoint | `String` | ✅ | `nil` | The URL to your API endpoint. Most commonly `"https://your-company.absmartly.io/v1"` (first positional parameter) | +| api_key | `String` | ✅ | `nil` | Your API key which can be found on the Web Console. | +| application | `String` | ✅ | `nil` | The name of the application where the SDK is installed. Applications are created on the Web Console and should match the applications where your experiments will be running. | +| environment | `String` | ✅ | `nil` | The environment of the platform where the SDK is installed. Environments are created on the Web Console and should match the available environments in your infrastructure. | +| timeout | `Integer` | ❌ | `3000` | The connection and request timeout in milliseconds. Converted to seconds internally. | +| retries | `Integer` | ❌ | `5` | The maximum number of retries before giving up. | +| context_event_logger | `ContextEventLogger` | ❌ | `nil` | A `ContextEventLogger` instance implementing `handle_event(event, data)` to receive SDK events. See "Custom Event Logger" below. | + +## Creating a New Context + +### Basic Context Creation ```ruby -class MyEventLogger < ContextEventLogger - def handle_event(event, data) - case event - when EVENT_TYPE::EXPOSURE - puts "Exposure: #{data}" - when EVENT_TYPE::GOAL - puts "Goal: #{data}" - when EVENT_TYPE::ERROR - puts "Error: #{data}" - when EVENT_TYPE::PUBLISH - puts "Publish: #{data}" - when EVENT_TYPE::READY - puts "Ready: #{data}" - when EVENT_TYPE::REFRESH - puts "Refresh: #{data}" - when EVENT_TYPE::CLOSE - puts "Close" - end - end -end +sdk = ABSmartly.new( + "https://your-company.absmartly.io/v1", + api_key: "YOUR-API-KEY", + application: "website", + environment: "development" +) + +context_config = ContextConfig.create +context_config.set_unit('session_id', '5ebf06d8cb5d8137290c4abb64155584fbdb64d8') + +context = sdk.create_context(context_config) +``` + +Or using the global configuration approach: +```ruby Absmartly.configure_client do |config| config.endpoint = "https://your-company.absmartly.io/v1" config.api_key = "YOUR-API-KEY" config.application = "website" config.environment = "development" - config.event_logger = MyEventLogger.new end + +context_config = Absmartly.create_context_config +context_config.set_unit('session_id', '5ebf06d8cb5d8137290c4abb64155584fbdb64d8') + +context = Absmartly.create_context(context_config) ``` -The data parameter depends on the type of event. Currently, the SDK logs the -following events: +### Async Context Creation -| eventName | when | data | -| ------------ | ------------------------------------------------------- | -------------------------------------------- | -| `"error"` | `Context` receives an error | error object thrown | -| `"ready"` | `Context` turns ready | data used to initialize the context | -| `"refresh"` | `Context.refresh()` method succeeds | data used to refresh the context | -| `"publish"` | `Context.publish()` method succeeds | data sent to the A/B Smartly event collector | -| `"exposure"` | `Context.treatment()` method succeeds on first exposure | exposure data enqueued for publishing | -| `"goal"` | `Context.track()` method succeeds | goal data enqueued for publishing | -| `"close"` | `Context.close()` method succeeds the first time | nil | +When you want to avoid blocking the current thread while fetching experiment data, use `create_context_async`. It returns a `Context` immediately and fetches data in a background thread. + +```ruby +sdk = ABSmartly.new( + "https://your-company.absmartly.io/v1", + api_key: "YOUR-API-KEY", + application: "website", + environment: "development" +) + +context_config = ContextConfig.create +context_config.set_unit('session_id', '5ebf06d8cb5d8137290c4abb64155584fbdb64d8') + +context = sdk.create_context_async(context_config) + +# Do other work while data is being fetched... + +# Block until the context is ready (or use a timeout) +context.wait_until_ready +# context.wait_until_ready(5) # with a 5-second timeout + +if context.ready? + treatment = context.treatment('exp_test_experiment') +end +``` -## Create a New Context Request +Read methods (`treatment`, `peek_treatment`, `variable_value`, `peek_variable_value`, `variable_keys`, `experiments`, `custom_field_keys`, `custom_field_value`) raise `IllegalStateException` ("ABsmartly Context is not yet ready.") until the context is ready, and ("ABsmartly Context is finalized.") after `close`. Check `ready?` first. A context whose data fetch failed is ready and failed: reads return control (0) and the caller's defaults. +Or using the global configuration approach: ```ruby -context_config = Absmartly.create_context_config +context = Absmartly.create_context_async(context_config) +context.wait_until_ready ``` -**With Prefetched Data** +### With Pre-fetched Data + +When doing full-stack experimentation with ABsmartly, we recommend creating a context only once on the server-side. Creating a context involves a round-trip to the ABsmartly event collector. We can avoid repeating the round-trip on the client-side by sending the server-side data embedded in the first document. ```ruby -client_config = ClientConfig.new( - endpoint: 'https://your-company.absmartly.io/v1', - api_key: 'YOUR-API-KEY', - application: 'website', - environment: 'development') +sdk = ABSmartly.new( + "https://your-company.absmartly.io/v1", + api_key: "YOUR-API-KEY", + application: "website", + environment: "development" +) -sdk_config = ABSmartlyConfig.create -sdk_config.client = Client.create(client_config) +context_config = ContextConfig.create +context_config.set_unit('session_id', '5ebf06d8cb5d8137290c4abb64155584fbdb64d8') + +server_context = sdk.create_context(context_config) -sdk = Absmartly.create(sdk_config) +client_context_config = ContextConfig.create +client_context_config.set_unit('session_id', '5ebf06d8cb5d8137290c4abb64155584fbdb64d8') + +client_context = sdk.create_context_with(client_context_config, server_context.data) ``` -**Refreshing the Context with Fresh Experiment Data** +### Refreshing the Context with Fresh Experiment Data -For long-running contexts, the context is usually created once when the -application is first started. However, any experiments being tracked in your -production code, but started after the context was created, will not be -triggered. +For long-running contexts, the context is usually created once when the application is first started. However, any experiments being tracked in your production code, but started after the context was created, will not be triggered. -Alternatively, the `refresh` method can be called manually. The -`refresh` method pulls updated experiment data from the A/B -Smartly collector and will trigger recently started experiments when -`treatment` is called again. +The `refresh` method can be called manually. The `refresh` method pulls updated experiment data from the ABsmartly collector and will trigger recently started experiments when `treatment` is called again. -**Setting Extra Units** +```ruby +context.refresh +``` -You can add additional units to a context by calling the `set_unit()` or -`set_units()` methods. These methods may be used, for example, when a user -logs in to your application and you want to use the new unit type in the -context. +### Setting Extra Units -Please note, you cannot override an already set unit type as that would be -a change of identity and would throw an exception. In this case, you must -create a new context instead. The `set_unit()` and -`set_units()` methods can be called before the context is ready. +You can add additional units to a context by calling the `set_unit` or `set_units` methods. These methods may be used, for example, when a user logs in to your application and you want to use the new unit type in the context. + +> **Note:** You cannot override an already set unit type as that would be a change of identity and would throw an exception. In this case, you must create a new context instead. The `set_unit` and `set_units` methods can be called before the context is ready. ```ruby context_config.set_unit('session_id', 'bf06d8cb5d8137290c4abb64155584fbdb64d8') context_config.set_unit('user_id', '123456') context = Absmartly.create_context(context_config) ``` -or + +or + ```ruby context_config.set_units( session_id: 'bf06d8cb5d8137290c4abb64155584fbdb64d8', @@ -166,7 +234,7 @@ context = Absmartly.create_context(context_config) ## Basic Usage -### Selecting A Treatment +### Selecting a Treatment ```ruby treatment = context.treatment('exp_test_experiment') @@ -180,40 +248,37 @@ end ### Treatment Variables -```ruby -default_button_color_value = 'red' +Variables allow you to configure experiment variants dynamically: -context.variable_value('experiment_name', default_button_color_value) +```ruby +default_button_color = 'red' +button_color = context.variable_value('button.color', default_button_color) ``` ### Peek at Treatment Variants -Although generally not recommended, it is sometimes necessary to peek at -a treatment or variable without triggering an exposure. The A/B Smartly -SDK provides a `peek_treatment()` method for that. +Although generally not recommended, it is sometimes necessary to peek at a treatment or variable without triggering an exposure. The ABsmartly SDK provides `peek_treatment` and `peek_variable_value` methods for that. ```ruby treatment = context.peek_treatment('exp_test_experiment') ``` -#### Peeking at variables +#### Peeking at Variables ```ruby -treatment = context.peek_variable_value('exp_test_experiment') +button_color = context.peek_variable_value('button.color', 'red') ``` ### Overriding Treatment Variants -During development, for example, it is useful to force a treatment for an -experiment. This can be achieved with the `set_override()` and/or `set_overrides()` -methods. These methods can be called before the context is ready. +During development, for example, it is useful to force a treatment for an experiment. This can be achieved with the `set_override` and/or `set_overrides` methods. These methods can be called before the context is ready. ```ruby -context.set_override("exp_test_experiment", 1) # force variant 1 of treatment +context.set_override('exp_test_experiment', 1) # force variant 1 of treatment context.set_overrides( - 'exp_test_experiment' => 1, - 'exp_another_experiment' => 0, + 'exp_test_experiment' => 1, + 'exp_another_experiment' => 0 ) ``` @@ -221,70 +286,275 @@ context.set_overrides( ### Context Attributes -Attributes are used to pass meta-data about the user and/or the request. -They can be used later in the Web Console to create segments or audiences. -They can be set using the `set_attribute()` or `set_attributes()` -methods, before or after the context is ready. +Attributes are used to pass meta-data about the user and/or the request. They can be used later in the Web Console to create segments or audiences. They can be set using the `set_attribute` or `set_attributes` methods, before or after the context is ready. ```ruby -context.set_attribute('session_id', session_id) +context.set_attribute('user_agent', request.user_agent) + context.set_attributes( - 'customer_age' => 'new_customer' + customer_age: 'new_customer', + account_type: 'premium' ) ``` ### Custom Assignments -Sometimes it may be necessary to override the automatic selection of a -variant. For example, if you wish to have your variant chosen based on -data from an API call. This can be accomplished using the -`set_custom_assignment()` method. +Sometimes it may be necessary to override the automatic selection of a variant. For example, if you wish to have your variant chosen based on data from an API call. This can be accomplished using the `set_custom_assignment` method. ```ruby chosen_variant = 1 context.set_custom_assignment('experiment_name', chosen_variant) ``` -If you are running multiple experiments and need to choose different -custom assignments for each one, you can do so using the -`set_custom_assignments()` method. +If you are running multiple experiments and need to choose different custom assignments for each one, you can do so using the `set_custom_assignments` method. ```ruby -assignments = [ - 'experiment_name' => 1, - 'another_experiment_name' => 0, - 'a_third_experiment_name' => 2 -] +assignments = { + 'experiment_name' => 1, + 'another_experiment_name' => 0, + 'a_third_experiment_name' => 2 +} -context.set_custom_assignments(assignments) +context.set_custom_assignments(assignments) ``` -### Publish +### Tracking Goals -Sometimes it is necessary to ensure all events have been published to the -A/B Smartly collector, before proceeding. You can explicitly call the -`publish()` methods. +Goals are created in the ABsmartly web console. +```ruby +context.track('payment', { + item_count: 1, + total_amount: 1999.99 +}) ``` + +### Publishing Pending Data + +Sometimes it is necessary to ensure all events have been published to the ABsmartly collector, before proceeding. You can explicitly call the `publish` method. + +```ruby context.publish ``` -### Finalize +### Finalizing -The `close()` method will ensure all events have been -published to the A/B Smartly collector, like `publish()`, and will also -"seal" the context, throwing an error if any method that could generate -an event is called. +The `close` method will ensure all events have been published to the ABsmartly collector, like `publish`, and will also "seal" the context, throwing an error if any method that could generate an event is called. -``` +If the final publish fails, `close` returns the failure, the context stays open and the events stay queued, so `close` can be called again to retry. + +```ruby context.close ``` -### Tracking Goals +### Custom Event Logger + +The ABsmartly SDK can be instantiated with an event logger used for all contexts. In addition, an event logger can be specified when creating a particular context in the context config. ```ruby -context.track( - 'payment', - { item_count: 1, total_amount: 1999.99 } +class CustomEventLogger < ContextEventLogger + def handle_event(event, data) + case event + when EVENT_TYPE::EXPOSURE + puts "Exposed to experiment: #{data.name}" + when EVENT_TYPE::GOAL + puts "Goal tracked: #{data.name}" + when EVENT_TYPE::ERROR + puts "Error: #{data}" + when EVENT_TYPE::PUBLISH + puts "Events published: #{data.length} events" + when EVENT_TYPE::READY + puts "Context ready with #{data.experiments.length} experiments" + when EVENT_TYPE::REFRESH + puts "Context refreshed with #{data.experiments.length} experiments" + when EVENT_TYPE::FINALIZE + puts "Context finalized" + end + end +end + +sdk = ABSmartly.new( + "https://your-company.absmartly.io/v1", + api_key: "YOUR-API-KEY", + application: "website", + environment: "development", + context_event_logger: CustomEventLogger.new ) ``` + +Or using the global configuration approach: + +```ruby +Absmartly.configure_client do |config| + config.endpoint = "https://your-company.absmartly.io/v1" + config.api_key = "YOUR-API-KEY" + config.application = "website" + config.environment = "development" + config.event_logger = CustomEventLogger.new +end +``` + +The data parameter depends on the type of event. Currently, the SDK logs the following events: + +**Event Types** + +| Event | When | Data | +| ----------- | ------------------------------------------------------- | -------------------------------------------- | +| `Error` | `Context` receives an error | Error object thrown | +| `Ready` | `Context` turns ready | ContextData used to initialize the context | +| `Refresh` | `Context.refresh()` method succeeds | ContextData used to refresh the context | +| `Publish` | `Context.publish()` method succeeds | PublishEvent sent to the collector | +| `Exposure` | `Context.treatment()` method succeeds on first exposure | Exposure data enqueued for publishing | +| `Goal` | `Context.track()` method succeeds | GoalAchievement enqueued for publishing | +| `Finalize` | `Context.close()` method succeeds the first time | `nil` | + +## Platform-Specific Examples + +### Using with Ruby on Rails + +```ruby +# config/initializers/absmartly.rb +require 'absmartly' + +Absmartly.configure_client do |config| + config.endpoint = ENV['ABSMARTLY_ENDPOINT'] + config.api_key = ENV['ABSMARTLY_API_KEY'] + config.application = "website" + config.environment = Rails.env +end + +# app/controllers/application_controller.rb +class ApplicationController < ActionController::Base + before_action :setup_absmartly_context + after_action :close_absmartly_context + + private + + def setup_absmartly_context + context_config = Absmartly.create_context_config + context_config.set_unit('session_id', session.id.to_s) + context_config.set_unit('user_id', current_user&.id&.to_s) if current_user + + @absmartly_context = Absmartly.create_context(context_config) + rescue => e + Rails.logger.error "ABsmartly context creation failed: #{e.message}" + @absmartly_context = nil + end + + def close_absmartly_context + @absmartly_context&.close + end +end + +# app/controllers/products_controller.rb +class ProductsController < ApplicationController + def show + treatment = @absmartly_context&.treatment('exp_product_layout') || 0 + + if treatment == 0 + render 'show_control' + else + render 'show_treatment' + end + end +end +``` + +### Using with Sinatra + +```ruby +require 'sinatra' +require 'absmartly' + +configure do + Absmartly.configure_client do |config| + config.endpoint = ENV['ABSMARTLY_ENDPOINT'] + config.api_key = ENV['ABSMARTLY_API_KEY'] + config.application = "website" + config.environment = ENV['RACK_ENV'] + end +end + +use Rack::Session::Cookie, secret: ENV['SESSION_SECRET'] + +before do + context_config = Absmartly.create_context_config + context_config.set_unit('session_id', session[:session_id] ||= SecureRandom.uuid) + + @absmartly_context = Absmartly.create_context(context_config) +end + +after do + @absmartly_context&.close +end + +get '/' do + treatment = @absmartly_context.treatment('exp_test_experiment') + + if treatment == 0 + erb :control + else + erb :treatment + end +end +``` + +### Using with Rack Middleware + +```ruby +# config.ru +require 'absmartly' + +class ABsmartlyMiddleware + def initialize(app) + @app = app + + Absmartly.configure_client do |config| + config.endpoint = ENV['ABSMARTLY_ENDPOINT'] + config.api_key = ENV['ABSMARTLY_API_KEY'] + config.application = "website" + config.environment = ENV['RACK_ENV'] + end + end + + def call(env) + request = Rack::Request.new(env) + + context_config = Absmartly.create_context_config + context_config.set_unit('session_id', request.session['session_id']) + + context = Absmartly.create_context(context_config) + + env['absmartly.context'] = context + + begin + @app.call(env) + ensure + context.close + end + end +end + +use ABsmartlyMiddleware +run MyApp +``` + +## About A/B Smartly + +**A/B Smartly** is the leading provider of state-of-the-art, on-premises, full-stack experimentation platforms for engineering and product teams that want to confidently deploy features as fast as they can develop them. +A/B Smartly's real-time analytics helps engineering and product teams ensure that new features will improve the customer experience without breaking or degrading performance and/or business metrics. + +### Have a look at our growing list of clients and SDKs: +- [JavaScript SDK](https://www.github.com/absmartly/javascript-sdk) +- [Java SDK](https://www.github.com/absmartly/java-sdk) +- [PHP SDK](https://www.github.com/absmartly/php-sdk) +- [Swift SDK](https://www.github.com/absmartly/swift-sdk) +- [Vue2 SDK](https://www.github.com/absmartly/vue2-sdk) +- [Vue3 SDK](https://www.github.com/absmartly/vue3-sdk) +- [React SDK](https://www.github.com/absmartly/react-sdk) +- [Python3 SDK](https://www.github.com/absmartly/python3-sdk) +- [Go SDK](https://www.github.com/absmartly/go-sdk) +- [Ruby SDK](https://www.github.com/absmartly/ruby-sdk) (this package) +- [.NET SDK](https://www.github.com/absmartly/dotnet-sdk) +- [Dart SDK](https://www.github.com/absmartly/dart-sdk) +- [Flutter SDK](https://www.github.com/absmartly/flutter-sdk) diff --git a/absmartly.gemspec b/absmartly.gemspec index 44af53d..26af526 100644 --- a/absmartly.gemspec +++ b/absmartly.gemspec @@ -25,7 +25,7 @@ Gem::Specification.new do |spec| # The `git ls-files -z` loads the files in the RubyGem that have been added into git. spec.files = Dir.chdir(__dir__) do `git ls-files -z`.split("\x0").reject do |f| - (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) + (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features|example)/|\.(?:git|travis|circleci)|appveyor)}) end end spec.bindir = "exe" diff --git a/example/example.rb b/example/example.rb index bad1150..255fe11 100644 --- a/example/example.rb +++ b/example/example.rb @@ -4,10 +4,10 @@ # config file Absmartly.configure_client do |config| - config.endpoint = "https://demo.absmartly.io/v1" - config.api_key = "x3ZyxmeKmb6n3VilTGs5I6-tBdaS9gYyr3i4YQXmUZcpPhH8nd8ev44NoEL_3yvA" - config.application = "www" - config.environment = "prod" + config.endpoint = ENV.fetch("ABSMARTLY_ENDPOINT", "https://demo.absmartly.io/v1") + config.api_key = ENV.fetch("ABSMARTLY_API_KEY") { raise "Set ABSMARTLY_API_KEY environment variable" } + config.application = ENV.fetch("ABSMARTLY_APPLICATION", "www") + config.environment = ENV.fetch("ABSMARTLY_ENVIRONMENT", "prod") end # define a new context request diff --git a/lib/a_b_smartly.rb b/lib/a_b_smartly.rb index 2ac57c8..cc23c04 100644 --- a/lib/a_b_smartly.rb +++ b/lib/a_b_smartly.rb @@ -1,13 +1,17 @@ # frozen_string_literal: true require "time" +require "ostruct" require_relative "context" require_relative "audience_matcher" require_relative "default_context_data_provider" +require_relative "default_context_publisher" require_relative "default_context_event_handler" require_relative "default_variable_parser" require_relative "default_audience_deserializer" require_relative "scheduled_thread_pool_executor" +require_relative "client_config" +require_relative "client" class ABSmartly attr_accessor :context_data_provider, :context_event_handler, @@ -18,7 +22,33 @@ def self.create(config) ABSmartly.new(config) end - def initialize(config) + def self.new(config_or_endpoint = nil, + api_key: nil, + application: nil, + environment: nil, + timeout: nil, + retries: nil, + context_event_logger: nil) + if config_or_endpoint.is_a?(ABSmartlyConfig) + allocate.tap { |instance| instance.send(:initialize_from_config, config_or_endpoint) } + else + allocate.tap { |instance| + instance.send(:initialize_from_params, + config_or_endpoint, + api_key, + application, + environment, + timeout, + retries, + context_event_logger + ) + } + end + end + + private + + def initialize_from_config(config) @context_data_provider = config.context_data_provider @context_event_handler = config.context_event_handler @context_event_logger = config.context_event_logger @@ -35,7 +65,7 @@ def initialize(config) end if @context_event_handler.nil? - @context_event_handler = DefaultContextEventHandler.new(@client) + @context_event_handler = DefaultContextPublisher.new(@client) end end @@ -51,6 +81,38 @@ def initialize(config) end end + def initialize_from_params(endpoint, api_key, application, environment, timeout, retries, event_logger) + raise ArgumentError.new("Missing required parameter: endpoint") if endpoint.nil? || endpoint.to_s.strip.empty? + raise ArgumentError.new("Missing required parameter: api_key") if api_key.nil? || api_key.to_s.strip.empty? + raise ArgumentError.new("Missing required parameter: application") if application.nil? || application.to_s.strip.empty? + raise ArgumentError.new("Missing required parameter: environment") if environment.nil? || environment.to_s.strip.empty? + + timeout ||= 3000 + retries ||= 5 + + raise ArgumentError.new("timeout must be a positive number") if Integer(timeout) <= 0 + raise ArgumentError.new("retries must be a non-negative number") if Integer(retries) < 0 + + client_config = ClientConfig.create + client_config.endpoint = endpoint + client_config.api_key = api_key + client_config.application = application + client_config.environment = environment + client_config.connect_timeout = timeout.to_f / 1000.0 + client_config.connection_request_timeout = timeout.to_f / 1000.0 + client_config.max_retries = retries + + @client = Client.create(client_config) + @context_data_provider = DefaultContextDataProvider.new(@client) + @context_event_handler = DefaultContextPublisher.new(@client) + @context_event_logger = event_logger + @variable_parser = DefaultVariableParser.new + @audience_deserializer = DefaultAudienceDeserializer.new + @scheduler = ScheduledThreadPoolExecutor.new(1) + end + + public + def create_context(config) validate_params(config) Context.create(get_utc_format, config, @context_data_provider.context_data, @@ -58,6 +120,23 @@ def create_context(config) AudienceMatcher.new(@audience_deserializer)) end + def create_context_async(config) + validate_params(config) + context = Context.create_async(get_utc_format, config, @context_data_provider, + @context_event_handler, @context_event_logger, @variable_parser, + AudienceMatcher.new(@audience_deserializer)) + data_provider = @context_data_provider + Thread.new do + begin + data_future = data_provider.context_data + context.set_data(data_future) + rescue => e + context.set_data(OpenStruct.new(success?: false, exception: e)) + end + end + context + end + def create_context_with(config, data) validate_params(config) Context.create(get_utc_format, config, data, @@ -86,3 +165,5 @@ def validate_params(params) end end end + +SDK = ABSmartly diff --git a/lib/a_b_smartly_config.rb b/lib/a_b_smartly_config.rb index 9e04760..096cb8d 100644 --- a/lib/a_b_smartly_config.rb +++ b/lib/a_b_smartly_config.rb @@ -43,3 +43,5 @@ def client=(client) self end end + +SDKConfig = ABSmartlyConfig diff --git a/lib/absmartly.rb b/lib/absmartly.rb index 2b37374..f176268 100644 --- a/lib/absmartly.rb +++ b/lib/absmartly.rb @@ -32,6 +32,10 @@ def create_context(context_config) sdk.create_context(context_config) end + def create_context_async(context_config) + sdk.create_context_async(context_config) + end + def create_context_with(context_config, data) sdk.create_context_with(context_config, data) end diff --git a/lib/absmartly/version.rb b/lib/absmartly/version.rb index bdfd7b2..0156af4 100644 --- a/lib/absmartly/version.rb +++ b/lib/absmartly/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Absmartly - VERSION = "1.3.0" + VERSION = "2.0.0" end diff --git a/lib/audience_matcher.rb b/lib/audience_matcher.rb index 0cf0d40..7916ac8 100644 --- a/lib/audience_matcher.rb +++ b/lib/audience_matcher.rb @@ -32,7 +32,11 @@ def evaluate(audience, attributes) Result.new(@json_expr.evaluate_boolean_expr(filter, attributes)) end end - rescue + rescue JSON::ParserError => e + warn("Failed to parse audience JSON: #{e.message}") + nil + rescue StandardError => e + warn("Audience evaluation failed: #{e.class} - #{e.message}") nil end end diff --git a/lib/client.rb b/lib/client.rb index 7c13c60..7b8524f 100644 --- a/lib/client.rb +++ b/lib/client.rb @@ -5,7 +5,7 @@ require_relative "default_context_event_serializer" class Client - attr_accessor :url, :query, :headers, :http_client, :executor, :deserializer, :serializer + attr_accessor :url, :query, :http_client, :executor, :deserializer, :serializer attr_reader :data_future, :promise, :exception def self.create(config, http_client = nil) @@ -50,9 +50,21 @@ def initialize(config, http_client = nil) end def context_data - @promise = @http_client.get(@url, @query, @headers) + @exception = nil + @data_future = nil + begin + @promise = @http_client.get(@url, @query, @headers) + rescue StandardError => e + # A network failure fails the context instead of escaping create_context. + @promise = nil + @exception = e + warn("Failed to fetch context data: #{e.class} - #{e.message}") + return self + end + unless @promise.success? @exception = Exception.new(@promise.body) + warn("Failed to fetch context data (status=#{@promise.respond_to?(:status) ? @promise.status : 'unknown'})") return self end @@ -64,7 +76,12 @@ def context_data def publish(event) content = @serializer.serialize(event) response = @http_client.put(@url, nil, @headers, content) - return Exception.new(response.body) unless response.success? + + unless response.success? + error = Exception.new(response.body) + warn("Publish failed (status=#{response.respond_to?(:status) ? response.status : 'unknown'})") + return error + end response end @@ -76,4 +93,9 @@ def close def success? @promise&.success? || false end + + def inspect + "#" + end + end diff --git a/lib/context.rb b/lib/context.rb index db7bd69..3be20a0 100644 --- a/lib/context.rb +++ b/lib/context.rb @@ -10,7 +10,11 @@ require_relative "json/goal_achievement" class Context - attr_reader :data, :pending_count + # Immutable-by-convention snapshot swapped in with a single assignment so + # readers see either the complete old or the complete new state. + State = Struct.new(:data, :index, :index_variables, :custom_fields) + + attr_reader :pending_count def self.create(clock, config, data_future, data_provider, event_handler, event_logger, variable_parser, audience_matcher) @@ -18,37 +22,19 @@ def self.create(clock, config, data_future, data_provider, event_handler, event_logger, variable_parser, audience_matcher) end + def self.create_async(clock, config, data_provider, + event_handler, event_logger, variable_parser, audience_matcher) + context = Context.allocate + context.send(:initialize_async, clock, config, data_provider, + event_handler, event_logger, variable_parser, audience_matcher) + context + end + def initialize(clock, config, data_future, data_provider, event_handler, event_logger, variable_parser, audience_matcher) - @index = [] - @context_custom_fields = {} - @achievements = [] - @assignment_cache = {} - @assignments = {} - @clock = clock.is_a?(String) ? Time.iso8601(clock) : clock - @publish_delay = config.publish_delay - @refresh_interval = config.refresh_interval - @event_handler = event_handler - @event_logger = !config.event_logger.nil? ? config.event_logger : event_logger - @data_provider = data_provider - @variable_parser = variable_parser - @audience_matcher = audience_matcher - @closed = false - - @units = {} - @attributes = [] - @overrides = {} - @cassignments = {} - @assigners = {} - @hashed_units = {} - @pending_count = 0 - @exposures ||= [] - @attrs_seq = 0 + init_common(clock, config, data_provider, event_handler, event_logger, + variable_parser, audience_matcher) - set_units(config.units) if config.units - set_attributes(config.attributes) if config.attributes - set_overrides(config.overrides) if config.overrides - set_custom_assignments(config.custom_assignments) if config.custom_assignments if data_future.success? assign_data(data_future.data_future) log_event(ContextEventLogger::EVENT_TYPE::READY, data_future.data_future) @@ -58,8 +44,49 @@ def initialize(clock, config, data_future, data_provider, end end + def set_data(data_future) + ready_data = nil + failure = nil + @ready_mutex.synchronize do + if data_future.success? + assign_data(data_future.data_future) + ready_data = data_future.data_future + else + set_data_failed(data_future.exception) + failure = data_future.exception + end + @ready_condvar.broadcast + end + + # User callbacks run outside the readiness lock so they may call + # wait_until_ready. + if failure.nil? + log_event(ContextEventLogger::EVENT_TYPE::READY, ready_data) + else + log_error(failure) + end + end + + def wait_until_ready(timeout = nil) + @ready_mutex.synchronize do + unless ready? || failed? + if timeout + deadline = Time.now + timeout + until ready? || failed? + remaining = deadline - Time.now + break if remaining <= 0 + @ready_condvar.wait(@ready_mutex, remaining) + end + else + @ready_condvar.wait(@ready_mutex) until ready? || failed? + end + end + end + self + end + def ready? - !@data.nil? + !@state.data.nil? end def failed? @@ -70,21 +97,53 @@ def closed? @closed end + def finalized? + closed? + end + + def finalizing? + !@closed && @closing + end + + def ready_error + @ready_error + end + + def get_unit(unit_type) + @units[unit_type.to_sym] + end + + def get_units + @units.dup + end + + def get_attribute(name) + result = nil + @attributes.each do |attr| + result = attr.value if attr.name == name + end + result + end + + def get_attributes + result = {} + @attributes.each do |attr| + result[attr.name] = attr.value + end + result + end + def experiments - check_ready?(true) + check_ready?(false) - @data.experiments.map(&:name) + @state.data.experiments.map(&:name) end def set_override(experiment_name, variant) - check_not_closed? - @overrides[experiment_name.to_s.to_sym] = variant end def set_overrides(overrides) - check_not_closed? - @overrides.merge!(overrides.transform_keys(&:to_sym)) end @@ -115,17 +174,17 @@ def custom_assignment(experiment_name) def set_unit(unit_type, uid) check_not_closed? - previous = @units[unit_type.to_sym] - if !previous.nil? && previous != uid - raise IllegalStateException.new("Unit '#{unit_type}' already set.") - end - - trimmed = uid.to_s.strip + trimmed = Hashing.to_utf8(uid).strip if trimmed.empty? raise IllegalStateException.new("Unit '#{unit_type}' UID must not be blank.") end - @units[unit_type] = trimmed + previous = @units[unit_type.to_sym] + if !previous.nil? && previous != trimmed + raise IllegalStateException.new("Unit '#{unit_type}' UID already set.") + end + + @units[unit_type.to_sym] = trimmed end def set_units(units) @@ -149,35 +208,41 @@ def set_attributes(attributes) def treatment(experiment_name) check_ready?(true) - assignment = assignment(experiment_name) - unless assignment.exposed - queue_exposure(assignment) - end + assignment = assignment(experiment_name) + queue_exposure(assignment) assignment.variant end def queue_exposure(assignment) - unless assignment.exposed - assignment.exposed = true - - exposure = Exposure.new - exposure.id = assignment.id || 0 - exposure.name = assignment.name - exposure.unit = assignment.unit_type - exposure.variant = assignment.variant - exposure.exposed_at = @clock.to_i - exposure.assigned = assignment.assigned - exposure.eligible = assignment.eligible - exposure.overridden = assignment.overridden - exposure.full_on = assignment.full_on - exposure.custom = assignment.custom - exposure.audience_mismatch = assignment.audience_mismatch - - @pending_count += 1 - @exposures.push(exposure) - log_event(ContextEventLogger::EVENT_TYPE::EXPOSURE, exposure) + exposure = nil + @queue_mutex.synchronize do + # Checked under the queue lock so an exposure cannot slip in after close + # sealed the queue and be stranded. + check_not_closed? + + unless assignment.exposed + assignment.exposed = true + + exposure = Exposure.new + exposure.id = assignment.id || 0 + exposure.name = assignment.name + exposure.unit = assignment.unit_type + exposure.variant = assignment.variant + exposure.exposed_at = @clock.to_i + exposure.assigned = assignment.assigned + exposure.eligible = assignment.eligible + exposure.overridden = assignment.overridden + exposure.full_on = assignment.full_on + exposure.custom = assignment.custom + exposure.audience_mismatch = assignment.audience_mismatch + + @pending_count += 1 + @exposures.push(exposure) + end end + # User callbacks run outside the lock so they can call publish/track. + log_event(ContextEventLogger::EVENT_TYPE::EXPOSURE, exposure) unless exposure.nil? end def peek_treatment(experiment_name) @@ -192,7 +257,9 @@ def variable_keys check_ready?(true) hsh = {} - @index_variables.each { |key, value| hsh[key] = value.data.name } + @state.index_variables.each do |key, values| + hsh[key] = values.map { |v| v.data.name } + end hsh end @@ -201,7 +268,7 @@ def variable_value(key, default_value) assignment = variable_assignment(key) unless assignment.nil? || assignment.variables.nil? - queue_exposure(assignment) unless assignment.exposed + queue_exposure(assignment) return assignment.variables[key.to_s.to_sym] if assignment.variables.key?(key.to_s.to_sym) end @@ -210,9 +277,10 @@ def variable_value(key, default_value) def custom_field_keys check_ready?(true) + keys = [] - @data.experiments.each do |experiment| + @state.data.experiments.each do |experiment| custom_field_values = experiment.custom_field_values if custom_field_values != nil custom_field_values.each do |custom_field| @@ -225,33 +293,15 @@ def custom_field_keys end def custom_field_value(experimentName, key) - check_ready?(true) - - experiment_custom_fields = @context_custom_fields[experimentName] - - if experiment_custom_fields != nil - field = experiment_custom_fields[key] - if field != nil - return field.value - end - end + custom_field(experimentName, key)&.value + end - return nil + def custom_field_value_type(experimentName, key) + custom_field(experimentName, key)&.type end def custom_field_type(experimentName, key) - check_ready?(true) - - experiment_custom_fields = @context_custom_fields[experimentName] - - if experiment_custom_fields != nil - field = experiment_custom_fields[key] - if field != nil - return field.type - end - end - - return nil + custom_field_value_type(experimentName, key) end def peek_variable_value(key, default_value) @@ -273,104 +323,186 @@ def track(goal_name, properties) achievement.name = goal_name achievement.properties = properties - @pending_count += 1 - @achievements.push(achievement) + @queue_mutex.synchronize do + check_not_closed? + + @pending_count += 1 + @achievements.push(achievement) + end log_event(ContextEventLogger::EVENT_TYPE::GOAL, achievement) end def publish - check_not_closed? + check_ready?(true) - flush + result = flush + result end + # A failed context is never recovered: refresh is a no-op. A failed fetch + # keeps the current data, logs the error and returns it. def refresh - check_not_closed? + check_ready?(true) + return nil if @failed - unless @failed - data_future = @data_provider.context_data - if data_future.success? - assign_data(data_future.data_future) - log_event(ContextEventLogger::EVENT_TYPE::REFRESH, data_future.data_future) - else - set_data_failed(data_future.exception) - log_error(data_future.exception) - end + data_future = @data_provider.context_data + if data_future.success? + assign_data(data_future.data_future) + log_event(ContextEventLogger::EVENT_TYPE::REFRESH, data_future.data_future) + nil + else + log_error(data_future.exception) + data_future.exception end end + # Publishes pending events, then seals the context. If the final publish + # fails the context stays open with the events queued, so close can be + # retried; the failure is returned (or raised, if the publisher raised). def close - unless @closed - if @pending_count > 0 - flush + return nil if @closed + + # Called from a logger callback while a publish is in flight: defer so the + # events queued by the callback are still sent before finalizing. + if @publish_mutex.owned? + @close_pending = true + return nil + end + + @closing = true + begin + loop do + result = flush + # A failed context discards its events; only a real publish failure + # keeps the context open. + return result if !@failed && publish_failed?(result) + + sealed = @queue_mutex.synchronize do + next :done if @closed + # Events queued while the final publish was in flight go out too. + next :retry unless @exposures.empty? && @achievements.empty? + + @closed = true + :sealed + end + return nil if sealed == :done + next if sealed == :retry + + log_event(ContextEventLogger::EVENT_TYPE::FINALIZE, nil) + return nil end - @closed = true - log_event(ContextEventLogger::EVENT_TYPE::CLOSE, nil) + ensure + @closing = false end end + def finalize + close + end + def data check_ready?(true) - @data + @state.data + end + + def inspect + "#" end private def flush - if !@failed - if @pending_count > 0 - exposures = nil - achievements = nil - event_count = @pending_count - - if event_count > 0 - unless @exposures.empty? - exposures = @exposures - @exposures = [] - end + if @failed + @queue_mutex.synchronize do + @exposures = [] + @achievements = [] + @pending_count = 0 + end + return @data_failed + end - unless @achievements.empty? - achievements = @achievements - @achievements = [] - end + # Serialize publishes so a queued event is part of at most one in-flight + # request; on failure the events stay queued for the next attempt. + # A logger callback that calls publish from inside a publish must not + # deadlock or re-send the in-flight snapshot; the outer call owns it. + return nil if @publish_mutex.owned? + + result = @publish_mutex.synchronize do + exposures = achievements = nil + event_count = 0 + @queue_mutex.synchronize do + exposures = @exposures.dup + achievements = @achievements.dup + event_count = exposures.size + achievements.size + end - @pending_count = 0 + next nil unless event_count > 0 - event = PublishEvent.new - event.hashed = true - event.published_at = @clock.to_i - event.units = @units.map do |key, value| - Unit.new(key.to_s, unit_hash(key, value)) - end - event.exposures = exposures - event.attributes = @attributes unless @attributes.empty? - event.goals = achievements unless achievements.nil? - log_event(ContextEventLogger::EVENT_TYPE::PUBLISH, event) - @event_handler.publish(self, event) + event = PublishEvent.new + event.hashed = true + event.published_at = @clock.to_i + event.units = @units.map do |key, value| + Unit.new(key.to_s, unit_hash(key, value)) + end + event.exposures = exposures unless exposures.empty? + event.attributes = @attributes unless @attributes.empty? + event.goals = achievements unless achievements.empty? + log_event(ContextEventLogger::EVENT_TYPE::PUBLISH, event) + + response = @event_handler.publish(self, event) + + if response.is_a?(Exception) + warn("Publish failed: #{response.message}") + response + elsif response.respond_to?(:exception) && response.exception + response + elsif response.respond_to?(:success?) && !response.success? + warn("Publish failed (status=#{response.respond_to?(:status) ? response.status : 'unknown'})") + response + else + # Remove only the events that were published; others may have been + # queued concurrently while the request was in flight. + @queue_mutex.synchronize do + @exposures.shift(exposures.size) + @achievements.shift(achievements.size) + @pending_count = [@pending_count - event_count, 0].max end + response end - else - @exposures = [] - @achievements = [] - @pending_count = 0 - @data_failed end + + if @close_pending + @close_pending = false + close unless @closed + end + result + end + + def publish_failed?(result) + result.is_a?(Exception) || + (result.respond_to?(:exception) && result.exception) || + (result.respond_to?(:success?) && !result.success?) end def check_not_closed? if @closed - raise IllegalStateException.new("ABSmartly Context is closed") + raise IllegalStateException.new("ABsmartly Context is finalized.") end end def check_ready?(expect_not_closed) if !ready? - raise IllegalStateException.new("ABSmartly Context is not yet ready") + raise IllegalStateException.new("ABsmartly Context is not yet ready.") elsif expect_not_closed check_not_closed? end end + def custom_field(experiment_name, key) + check_ready?(true) + @state.custom_fields.dig(experiment_name, key) + end + def experiment_matches(experiment, assignment) experiment.id == assignment.id && experiment.unit_type == assignment.unit_type && @@ -387,7 +519,8 @@ def audience_matches(experiment, assignment) hash end match = @audience_matcher.evaluate(experiment.audience, attrs) - new_audience_mismatch = match && !match.result + # An indeterminate result (no filter, bad JSON) keeps the cached flag. + new_audience_mismatch = match.nil? ? assignment.audience_mismatch : !match.result if new_audience_mismatch != assignment.audience_mismatch return false @@ -399,13 +532,14 @@ def audience_matches(experiment, assignment) true end - def assignment(experiment_name) + def assignment(experiment_name, state = @state) assignment = @assignment_cache[experiment_name.to_s] + exp_key = experiment_name.to_s.to_sym if !assignment.nil? - custom = @cassignments.transform_keys(&:to_sym)[experiment_name.to_s.to_sym] - override = @overrides.transform_keys(&:to_sym)[experiment_name.to_s.to_sym] - experiment = experiment(experiment_name.to_s) + custom = @cassignments[exp_key] + override = @overrides[exp_key] + experiment = experiment(experiment_name.to_s, state) if !override.nil? if assignment.overridden && assignment.variant == override return assignment @@ -421,9 +555,9 @@ def assignment(experiment_name) end end - custom = @cassignments.transform_keys(&:to_sym)[experiment_name.to_s.to_sym] - override = @overrides.transform_keys(&:to_sym)[experiment_name.to_s.to_sym] - experiment = experiment(experiment_name.to_s) + custom = @cassignments[exp_key] + override = @overrides[exp_key] + experiment = experiment(experiment_name.to_s, state) assignment = Assignment.new assignment.name = experiment_name @@ -455,7 +589,7 @@ def assignment(experiment_name) if experiment.data.audience_strict && assignment.audience_mismatch assignment.variant = 0 elsif experiment.data.full_on_variant == 0 - uid = @units.transform_keys(&:to_sym)[experiment.data.unit_type.to_sym] + uid = @units[experiment.data.unit_type.to_sym] unless uid.nil? assigner = VariantAssigner.new(uid) eligible = assigner.assign(experiment.data.traffic_split, @@ -500,17 +634,19 @@ def assignment(experiment_name) end def variable_assignment(key) - experiment = variable_experiment(key) + state = @state + experiment = variable_experiment(key, state) - assignment(experiment.data.name) unless experiment.nil? + assignment(experiment.data.name, state) unless experiment.nil? end - def experiment(experiment) - @index.transform_keys(&:to_sym)[experiment.to_s.to_sym] + def experiment(experiment, state = @state) + state.index[experiment.to_s.to_sym] end - def variable_experiment(key) - @index_variables.transform_keys(&:to_sym)[key.to_s.to_sym] + def variable_experiment(key, state = @state) + experiments = state.index_variables[key.to_s.to_sym] + experiments&.first end def unit_hash(unit_type, unit_uid) @@ -522,13 +658,13 @@ def variant_assigner(unit_type, unit_hash) end def assign_data(data) - @data = data - @index = {} - @index_variables = {} + index = {} + index_variables = {} + custom_fields = {} if data && !data.experiments.nil? && !data.experiments.empty? data.experiments.each do |experiment| - @experimentCustomFieldValues = {} + experiment_custom_field_values = {} experiment_variables = ExperimentVariables.new experiment_variables.data = experiment @@ -537,7 +673,10 @@ def assign_data(data) if !variant.config.nil? && !variant.config.empty? variables = @variable_parser.parse(self, experiment.name, variant.name, variant.config) - variables.keys.each { |key| @index_variables[key] = experiment_variables } + variables.keys.each do |key| + index_variables[key] ||= [] + index_variables[key] << experiment_variables unless index_variables[key].include?(experiment_variables) + end experiment_variables.variables.push(variables) else experiment_variables.variables.push({}) @@ -565,37 +704,90 @@ def assign_data(data) value.value = custom_field_value.value end - @experimentCustomFieldValues[custom_field_value.name] = value + experiment_custom_field_values[custom_field_value.name] = value end end end - @index[experiment.name] = experiment_variables - @context_custom_fields[experiment.name] = @experimentCustomFieldValues + index[experiment.name.to_sym] = experiment_variables + custom_fields[experiment.name] = experiment_custom_field_values end end + + # Publish the fully built snapshot; the maps are never mutated after this. + @state = State.new(data, index, index_variables, custom_fields) end def set_data_failed(exception) @data_failed = exception - @index = {} - @index_variables = {} - @data = ContextData.new + @ready_error = exception + @state = State.new(ContextData.new, {}, {}, {}) @failed = true end + # A raising logger must not break the operation that is logging. def log_event(event, data) unless @event_logger.nil? @event_logger.handle_event(event, data) end + rescue StandardError => e + warn("Context event logger raised: #{e.class} - #{e.message}") end def log_error(error) unless @event_logger.nil? @event_logger.handle_event(ContextEventLogger::EVENT_TYPE::ERROR, error.message) end + rescue StandardError => e + warn("Context event logger raised: #{e.class} - #{e.message}") + end + + def init_common(clock, config, data_provider, event_handler, event_logger, + variable_parser, audience_matcher) + @state = State.new(nil, {}, {}, {}) + @achievements = [] + @assignment_cache = {} + @assignments = {} + @clock = clock.is_a?(String) ? Time.iso8601(clock) : clock + @publish_delay = config.publish_delay + @refresh_interval = config.refresh_interval + @event_handler = event_handler + @event_logger = !config.event_logger.nil? ? config.event_logger : event_logger + @data_provider = data_provider + @variable_parser = variable_parser + @audience_matcher = audience_matcher + @closed = false + @closing = false + @failed = false + @ready_error = nil + + @units = {} + @attributes = [] + @overrides = {} + @cassignments = {} + @assigners = {} + @hashed_units = {} + @pending_count = 0 + @exposures ||= [] + @attrs_seq = 0 + @ready_mutex = Mutex.new + @ready_condvar = ConditionVariable.new + @queue_mutex = Mutex.new + @publish_mutex = Mutex.new + @close_pending = false + + set_units(config.units) if config.units + set_attributes(config.attributes) if config.attributes + set_overrides(config.overrides) if config.overrides + set_custom_assignments(config.custom_assignments) if config.custom_assignments + end + + def initialize_async(clock, config, data_provider, event_handler, event_logger, + variable_parser, audience_matcher) + init_common(clock, config, data_provider, event_handler, event_logger, + variable_parser, audience_matcher) end attr_accessor :clock, diff --git a/lib/context_event_handler.rb b/lib/context_event_handler.rb index 8757196..5608cee 100644 --- a/lib/context_event_handler.rb +++ b/lib/context_event_handler.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -class ContextEventHandler - # @interface method - def publish(context, event) - raise NotImplementedError.new("You must implement publish method.") - end -end +require_relative "context_publisher" + +# @deprecated Use ContextPublisher instead. +ContextEventHandler = ContextPublisher diff --git a/lib/context_event_logger.rb b/lib/context_event_logger.rb index 4aab7b1..f334e85 100644 --- a/lib/context_event_logger.rb +++ b/lib/context_event_logger.rb @@ -8,7 +8,9 @@ module EVENT_TYPE PUBLISH = "publish" EXPOSURE = "exposure" GOAL = "goal" - CLOSE = "close" + FINALIZE = "finalize" + # Deprecated: the event is named "finalize" in every ABsmartly SDK. + CLOSE = FINALIZE end def handle_event(event, data) diff --git a/lib/context_event_logger_callback.rb b/lib/context_event_logger_callback.rb index b6b76b9..013c3f0 100644 --- a/lib/context_event_logger_callback.rb +++ b/lib/context_event_logger_callback.rb @@ -8,6 +8,6 @@ def initialize(callable) end def handle_event(event, data) - @callable.call(event, data) if @callable.present? + @callable.call(event, data) if @callable end end diff --git a/lib/context_publisher.rb b/lib/context_publisher.rb new file mode 100644 index 0000000..c328bda --- /dev/null +++ b/lib/context_publisher.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ContextPublisher + # @interface method + def publish(context, event) + raise NotImplementedError.new("You must implement publish method.") + end +end diff --git a/lib/default_audience_deserializer.rb b/lib/default_audience_deserializer.rb index 0d4ac12..07fe36d 100644 --- a/lib/default_audience_deserializer.rb +++ b/lib/default_audience_deserializer.rb @@ -6,8 +6,12 @@ class DefaultAudienceDeserializer < AudienceDeserializer attr_accessor :log, :reader def deserialize(bytes, offset, length) - JSON.parse(bytes[offset..length], symbolize_names: true) - rescue JSON::ParserError + JSON.parse(bytes[offset, length], symbolize_names: true) + rescue JSON::ParserError => e + warn("Failed to deserialize audience data: #{e.message}") + nil + rescue StandardError => e + warn("Unexpected error deserializing audience data: #{e.class} - #{e.message}") nil end end diff --git a/lib/default_context_data_deserializer.rb b/lib/default_context_data_deserializer.rb index 7a5b4a8..bcea224 100644 --- a/lib/default_context_data_deserializer.rb +++ b/lib/default_context_data_deserializer.rb @@ -8,9 +8,13 @@ class DefaultContextDataDeserializer < ContextDataDeserializer attr_accessor :log, :reader def deserialize(bytes, offset, length) - parse = JSON.parse(bytes[offset..length], symbolize_names: true) + parse = JSON.parse(bytes[offset, length], symbolize_names: true) @reader = ContextData.new(parse[:experiments]) - rescue JSON::ParserError + rescue JSON::ParserError => e + warn("Failed to deserialize context data: #{e.message}") + nil + rescue StandardError => e + warn("Unexpected error deserializing context data: #{e.class} - #{e.message}") nil end end diff --git a/lib/default_context_event_handler.rb b/lib/default_context_event_handler.rb index caecf68..a7ed58c 100644 --- a/lib/default_context_event_handler.rb +++ b/lib/default_context_event_handler.rb @@ -1,15 +1,11 @@ # frozen_string_literal: true -require_relative "context_event_handler" - -class DefaultContextEventHandler < ContextEventHandler - attr_accessor :client +require_relative "default_context_publisher" +# @deprecated Use DefaultContextPublisher instead. +class DefaultContextEventHandler < DefaultContextPublisher def initialize(client) - @client = client - end - - def publish(context, event) - @client.publish(event) + warn "[DEPRECATION] DefaultContextEventHandler is deprecated. Use DefaultContextPublisher instead." + super(client) end end diff --git a/lib/default_context_publisher.rb b/lib/default_context_publisher.rb new file mode 100644 index 0000000..c666906 --- /dev/null +++ b/lib/default_context_publisher.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative "context_publisher" + +class DefaultContextPublisher < ContextPublisher + attr_accessor :client + + def initialize(client) + @client = client + end + + def publish(context, event) + @client.publish(event) + end +end diff --git a/lib/default_http_client.rb b/lib/default_http_client.rb index 8381cdb..21fca72 100644 --- a/lib/default_http_client.rb +++ b/lib/default_http_client.rb @@ -16,11 +16,15 @@ def self.create(config) def initialize(config) @config = config @session = Faraday.new("") do |f| + # Like the JavaScript SDK, retry network failures and 5xx responses on + # both the context fetch (GET) and publish (PUT); 4xx is final. f.request :retry, max: config.max_retries, interval: config.retry_interval, interval_randomness: 0.5, - backoff_factor: 2 + backoff_factor: 2, + exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [Faraday::ConnectionFailed], + retry_statuses: (500..599).to_a f.options.timeout = config.connect_timeout f.options.open_timeout = config.connection_request_timeout f.adapter :net_http_persistent, pool_size: config.pool_size do |http| diff --git a/lib/default_variable_parser.rb b/lib/default_variable_parser.rb index ee3b89f..af987ea 100644 --- a/lib/default_variable_parser.rb +++ b/lib/default_variable_parser.rb @@ -7,7 +7,11 @@ class DefaultVariableParser < VariableParser def parse(context, experiment_name, variant_name, config) JSON.parse(config, symbolize_names: true) - rescue JSON::ParserError - nil + rescue JSON::ParserError => e + warn("Failed to parse variant config for experiment '#{experiment_name}', variant '#{variant_name}': #{e.message}") + {} + rescue StandardError => e + warn("Unexpected error parsing variant config for experiment '#{experiment_name}': #{e.class} - #{e.message}") + {} end end diff --git a/lib/hashing.rb b/lib/hashing.rb index 81de948..70e8b47 100644 --- a/lib/hashing.rb +++ b/lib/hashing.rb @@ -3,7 +3,36 @@ require "digest" class Hashing + REPLACEMENT = "�" + # A UTF-16 surrogate pair written as two 3-byte sequences (CESU-8 / WTF-8). + ENCODED_SURROGATE_PAIR = /\xED([\xA0-\xAF])([\x80-\xBF])\xED([\xB0-\xBF])([\x80-\xBF])/n.freeze + # A lone surrogate written as a 3-byte sequence. + ENCODED_SURROGATE = /\xED[\xA0-\xBF][\x80-\xBF]/n.freeze + def self.hash_unit(str) - Digest::MD5.base64digest(str.to_s).gsub("==", "").gsub("+", "-").gsub("/", "_") + Digest::MD5.base64digest(to_utf8(str).b).gsub("==", "").gsub("+", "-").gsub("/", "_") + end + + # Returns the UTF-8 form of the unit's Unicode text. Characters outside the + # BMP become the standard 4-byte sequence and each lone surrogate becomes + # U+FFFD, matching the JavaScript SDK's TextEncoder-based encoding. Strings in + # another encoding (e.g. UTF-16) are transcoded; binary strings are read as + # UTF-8 bytes. + def self.to_utf8(value) + str = value.to_s + if str.encoding == Encoding::UTF_8 || str.encoding == Encoding::BINARY || str.encoding == Encoding::US_ASCII + return str if str.encoding == Encoding::UTF_8 && str.valid_encoding? + + bytes = str.b + bytes = bytes.gsub(ENCODED_SURROGATE_PAIR) do + high = 0xD800 | ((Regexp.last_match(1).ord & 0x0F) << 6) | (Regexp.last_match(2).ord & 0x3F) + low = 0xDC00 | ((Regexp.last_match(3).ord & 0x0F) << 6) | (Regexp.last_match(4).ord & 0x3F) + [0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00)].pack("U").b + end + bytes = bytes.gsub(ENCODED_SURROGATE, REPLACEMENT.b) + bytes.force_encoding(Encoding::UTF_8).scrub(REPLACEMENT) + else + str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: REPLACEMENT) + end end end diff --git a/lib/json/experiment.rb b/lib/json/experiment.rb index 42e70e3..cbb03b2 100644 --- a/lib/json/experiment.rb +++ b/lib/json/experiment.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "../string" +require_relative "../type_utils" require_relative "experiment_application" require_relative "experiment_variant" require_relative "custom_field_value" @@ -10,8 +10,23 @@ class Experiment :traffic_seed_hi, :traffic_seed_lo, :traffic_split, :full_on_variant, :applications, :variants, :audience_strict, :audience, :custom_field_values + ALLOWED_KEYS = %i[ + id name unitType unit_type iteration seedHi seed_hi seedLo seed_lo split + trafficSeedHi traffic_seed_hi trafficSeedLo traffic_seed_lo trafficSplit traffic_split + fullOnVariant full_on_variant applications variants audienceStrict audience_strict + audience customFieldValues custom_field_values + ].freeze + def initialize(args = {}) args.each do |name, value| + key_sym = name.to_sym + key_str = TypeUtils.underscore(name.to_s) + + unless ALLOWED_KEYS.include?(key_sym) || ALLOWED_KEYS.include?(key_str.to_sym) + warn("Ignoring unexpected experiment field: #{name}") + next + end + if name == :applications @applications = assign_to_klass(ExperimentApplication, value) elsif name == :variants @@ -21,7 +36,7 @@ def initialize(args = {}) @custom_field_values = assign_to_klass(CustomFieldValue, value) end else - self.instance_variable_set("@#{name.to_s.underscore}", value) + self.instance_variable_set("@#{key_str}", value) end end @audience_strict ||= false @@ -30,10 +45,11 @@ def initialize(args = {}) def assign_to_klass(klass, arr) arr.map do |item| - return item if item.is_a?(klass) + next if item.nil? + next item if item.is_a?(klass) klass.new(*item.values) - end + end.compact end def ==(o) diff --git a/lib/json_expr/expr_evaluator.rb b/lib/json_expr/expr_evaluator.rb index 8e73197..7151317 100644 --- a/lib/json_expr/expr_evaluator.rb +++ b/lib/json_expr/expr_evaluator.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "../string" +require_relative "../type_utils" require_relative "./evaluator" EMPTY_MAP = {} EMPTY_LIST = [] @@ -94,10 +94,11 @@ def compare(lhs, rhs) if lhs.is_a?(Numeric) rvalue = number_convert(rhs) - return lhs.to_f.to_s.casecmp(rvalue.to_s) unless rvalue.nil? + return nil if rvalue.nil? + return lhs.to_f <=> rvalue.to_f elsif lhs.is_a?(String) rvalue = string_convert(rhs) - return lhs.compare_to(rvalue) unless rvalue.nil? + return TypeUtils.compare_strings(lhs, rvalue) unless rvalue.nil? elsif lhs.is_a?(TrueClass) || lhs.is_a?(FalseClass) rvalue = boolean_convert(rhs) return lhs.to_s.casecmp(rvalue.to_s) unless rvalue.nil? @@ -107,15 +108,3 @@ def compare(lhs, rhs) nil end end - -class Array - def self.wrap(object) - if object.nil? - [] - elsif object.respond_to?(:to_ary) - object.to_ary || [object] - else - [object] - end - end -end diff --git a/lib/json_expr/operators/and_combinator.rb b/lib/json_expr/operators/and_combinator.rb index f286ec2..0902223 100644 --- a/lib/json_expr/operators/and_combinator.rb +++ b/lib/json_expr/operators/and_combinator.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true require_relative "boolean_combinator" +require_relative "../../type_utils" class AndCombinator include BooleanCombinator def combine(evaluator, exprs) - Array.wrap(exprs).each do |expr| + TypeUtils.wrap_array(exprs).each do |expr| return false unless evaluator.boolean_convert(evaluator.evaluate(expr)) end true diff --git a/lib/json_expr/operators/in_operator.rb b/lib/json_expr/operators/in_operator.rb index 9a3ab4a..1bfb889 100644 --- a/lib/json_expr/operators/in_operator.rb +++ b/lib/json_expr/operators/in_operator.rb @@ -16,7 +16,9 @@ def binary(evaluator, haystack, needle) return !needle_string.nil? && haystack.include?(needle_string) elsif haystack.is_a?(Hash) needle_string = evaluator.string_convert(needle) - return !needle_string.nil? && haystack.key?(needle_string) + # Keys may be strings or, for audience JSON parsed with + # symbolize_names, symbols. + return !needle_string.nil? && (haystack.key?(needle_string) || haystack.key?(needle_string.to_sym)) end nil end diff --git a/lib/json_expr/operators/match_operator.rb b/lib/json_expr/operators/match_operator.rb index 3d450fc..901d928 100644 --- a/lib/json_expr/operators/match_operator.rb +++ b/lib/json_expr/operators/match_operator.rb @@ -7,11 +7,15 @@ class MatchOperator def binary(evaluator, lhs, rhs) text = evaluator.string_convert(lhs) - unless text.nil? - pattern = evaluator.string_convert(rhs) - unless pattern.nil? - text.match(pattern) - end + return nil if text.nil? + + pattern = evaluator.string_convert(rhs) + return nil if pattern.nil? + + begin + Regexp.new(pattern).match?(text) + rescue RegexpError + nil end end end diff --git a/lib/json_expr/operators/or_combinator.rb b/lib/json_expr/operators/or_combinator.rb index a7102fb..db959f1 100644 --- a/lib/json_expr/operators/or_combinator.rb +++ b/lib/json_expr/operators/or_combinator.rb @@ -1,14 +1,16 @@ # frozen_string_literal: true require_relative "boolean_combinator" +require_relative "../../type_utils" class OrCombinator include BooleanCombinator def combine(evaluator, exprs) - Array.wrap(exprs).each do |expr| + wrapped = TypeUtils.wrap_array(exprs) + wrapped.each do |expr| return true if evaluator.boolean_convert(evaluator.evaluate(expr)) end - Array.wrap(exprs).empty? + wrapped.empty? end end diff --git a/lib/scheduled_thread_pool_executor.rb b/lib/scheduled_thread_pool_executor.rb index 52a3578..761313b 100644 --- a/lib/scheduled_thread_pool_executor.rb +++ b/lib/scheduled_thread_pool_executor.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true -require_relative "audience_deserializer" - -class ScheduledThreadPoolExecutor < AudienceDeserializer - attr_accessor :log, :reader - +class ScheduledThreadPoolExecutor def initialize(timer = 1) + @timer = timer + end + + def execute(&block) + block.call if block end - def deserialize(bytes, offset, length) - @reader.read_value(bytes, offset, length) + def shutdown end end diff --git a/lib/type_utils.rb b/lib/type_utils.rb new file mode 100644 index 0000000..b3ef22a --- /dev/null +++ b/lib/type_utils.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module TypeUtils + def self.boolean?(value) + case value + when String then %w[true false].include?(value) + when Array then false + else false + end + end + + def self.compare_strings(str1, str2) + value = str1.bytes + other = str2.bytes + len1 = value.size + len2 = other.size + lim = [len1, len2].min + + 0.upto(lim - 1) do |k| + if value[k] != other[k] + return get_char(value, k) - get_char(other, k) + end + end + len1 - len2 + end + + def self.get_char(val, index) + val[index] & 0xff + end + + def self.underscore(str) + str.gsub(/::/, "/") + .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .tr("-", "_") + .downcase + end + + def self.wrap_array(object) + if object.nil? + [] + elsif object.respond_to?(:to_ary) + object.to_ary || [object] + else + [object] + end + end +end diff --git a/spec/a_b_smartly_named_params_spec.rb b/spec/a_b_smartly_named_params_spec.rb new file mode 100644 index 0000000..9d16179 --- /dev/null +++ b/spec/a_b_smartly_named_params_spec.rb @@ -0,0 +1,285 @@ +# frozen_string_literal: true + +require "a_b_smartly" +require "a_b_smartly_config" +require "client" +require "context_config" + +RSpec.describe ABSmartly do + describe ".new with named parameters" do + let(:valid_params) do + { + endpoint: "https://test.absmartly.io/v1", + api_key: "test-api-key", + application: "website", + environment: "development" + } + end + + context "with all required parameters" do + it "creates an instance successfully" do + sdk = ABSmartly.new( + valid_params[:endpoint], + api_key: valid_params[:api_key], + application: valid_params[:application], + environment: valid_params[:environment] + ) + + expect(sdk).not_to be_nil + expect(sdk.client).not_to be_nil + expect(sdk.context_data_provider).not_to be_nil + expect(sdk.context_event_handler).not_to be_nil + expect(sdk.variable_parser).not_to be_nil + expect(sdk.audience_deserializer).not_to be_nil + expect(sdk.scheduler).not_to be_nil + end + + it "uses default timeout of 3000ms" do + sdk = ABSmartly.new( + valid_params[:endpoint], + api_key: valid_params[:api_key], + application: valid_params[:application], + environment: valid_params[:environment] + ) + + expect(sdk).not_to be_nil + end + + it "uses default retries of 5" do + sdk = ABSmartly.new( + valid_params[:endpoint], + api_key: valid_params[:api_key], + application: valid_params[:application], + environment: valid_params[:environment] + ) + + expect(sdk).not_to be_nil + end + end + + context "with optional timeout parameter" do + it "creates an instance with custom timeout" do + sdk = ABSmartly.new( + valid_params[:endpoint], + api_key: valid_params[:api_key], + application: valid_params[:application], + environment: valid_params[:environment], + timeout: 5000 + ) + + expect(sdk).not_to be_nil + end + + it "rejects negative timeout" do + expect { + ABSmartly.new( + valid_params[:endpoint], + api_key: valid_params[:api_key], + application: valid_params[:application], + environment: valid_params[:environment], + timeout: -1000 + ) + }.to raise_error(ArgumentError, "timeout must be a positive number") + end + + it "rejects zero timeout" do + expect { + ABSmartly.new( + valid_params[:endpoint], + api_key: valid_params[:api_key], + application: valid_params[:application], + environment: valid_params[:environment], + timeout: 0 + ) + }.to raise_error(ArgumentError, "timeout must be a positive number") + end + end + + context "with optional retries parameter" do + it "creates an instance with custom retries" do + sdk = ABSmartly.new( + valid_params[:endpoint], + api_key: valid_params[:api_key], + application: valid_params[:application], + environment: valid_params[:environment], + retries: 3 + ) + + expect(sdk).not_to be_nil + end + + it "accepts zero retries" do + sdk = ABSmartly.new( + valid_params[:endpoint], + api_key: valid_params[:api_key], + application: valid_params[:application], + environment: valid_params[:environment], + retries: 0 + ) + + expect(sdk).not_to be_nil + end + + it "rejects negative retries" do + expect { + ABSmartly.new( + valid_params[:endpoint], + api_key: valid_params[:api_key], + application: valid_params[:application], + environment: valid_params[:environment], + retries: -1 + ) + }.to raise_error(ArgumentError, "retries must be a non-negative number") + end + end + + context "with context_event_logger parameter" do + let(:mock_logger) { double("event_logger") } + + it "sets the event logger" do + sdk = ABSmartly.new( + valid_params[:endpoint], + api_key: valid_params[:api_key], + application: valid_params[:application], + environment: valid_params[:environment], + context_event_logger: mock_logger + ) + + expect(sdk.context_event_logger).to eq(mock_logger) + end + end + + context "with all optional parameters" do + let(:mock_logger) { double("event_logger") } + + it "creates an instance successfully" do + sdk = ABSmartly.new( + valid_params[:endpoint], + api_key: valid_params[:api_key], + application: valid_params[:application], + environment: valid_params[:environment], + timeout: 5000, + retries: 3, + context_event_logger: mock_logger + ) + + expect(sdk).not_to be_nil + expect(sdk.context_event_logger).to eq(mock_logger) + end + end + + context "with missing required parameters" do + it "raises error when endpoint is missing" do + expect { + ABSmartly.new( + nil, + api_key: valid_params[:api_key], + application: valid_params[:application], + environment: valid_params[:environment] + ) + }.to raise_error(ArgumentError, "Missing required parameter: endpoint") + end + + it "raises error when endpoint is empty" do + expect { + ABSmartly.new( + " ", + api_key: valid_params[:api_key], + application: valid_params[:application], + environment: valid_params[:environment] + ) + }.to raise_error(ArgumentError, "Missing required parameter: endpoint") + end + + it "raises error when api_key is missing" do + expect { + ABSmartly.new( + valid_params[:endpoint], + api_key: nil, + application: valid_params[:application], + environment: valid_params[:environment] + ) + }.to raise_error(ArgumentError, "Missing required parameter: api_key") + end + + it "raises error when api_key is empty" do + expect { + ABSmartly.new( + valid_params[:endpoint], + api_key: "", + application: valid_params[:application], + environment: valid_params[:environment] + ) + }.to raise_error(ArgumentError, "Missing required parameter: api_key") + end + + it "raises error when application is missing" do + expect { + ABSmartly.new( + valid_params[:endpoint], + api_key: valid_params[:api_key], + application: nil, + environment: valid_params[:environment] + ) + }.to raise_error(ArgumentError, "Missing required parameter: application") + end + + it "raises error when application is empty" do + expect { + ABSmartly.new( + valid_params[:endpoint], + api_key: valid_params[:api_key], + application: "", + environment: valid_params[:environment] + ) + }.to raise_error(ArgumentError, "Missing required parameter: application") + end + + it "raises error when environment is missing" do + expect { + ABSmartly.new( + valid_params[:endpoint], + api_key: valid_params[:api_key], + application: valid_params[:application], + environment: nil + ) + }.to raise_error(ArgumentError, "Missing required parameter: environment") + end + + it "raises error when environment is empty" do + expect { + ABSmartly.new( + valid_params[:endpoint], + api_key: valid_params[:api_key], + application: valid_params[:application], + environment: "" + ) + }.to raise_error(ArgumentError, "Missing required parameter: environment") + end + end + + context "backwards compatibility with ABSmartlyConfig" do + let(:client) { instance_double(Client) } + + it "still works with ABSmartlyConfig.create approach" do + config = ABSmartlyConfig.create + config.client = client + + sdk = ABSmartly.new(config) + + expect(sdk).not_to be_nil + expect(sdk.client).to eq(client) + end + + it "works with ABSmartly.create(config)" do + config = ABSmartlyConfig.create + config.client = client + + sdk = ABSmartly.create(config) + + expect(sdk).not_to be_nil + expect(sdk.client).to eq(client) + end + end + end +end diff --git a/spec/absmartly_spec.rb b/spec/absmartly_spec.rb index 9f71df9..93d25fe 100644 --- a/spec/absmartly_spec.rb +++ b/spec/absmartly_spec.rb @@ -246,7 +246,7 @@ def create_ready_context .with(ContextEventLogger::EVENT_TYPE::PUBLISH, instance_of(PublishEvent)).once end - it "receives CLOSE event when close() is called" do + it "receives FINALIZE event when close() is called" do mock_logger.clear context = create_ready_context mock_logger.clear @@ -254,7 +254,7 @@ def create_ready_context context.close expect(mock_logger).to have_received(:handle_event) - .with(ContextEventLogger::EVENT_TYPE::CLOSE, nil).once + .with(ContextEventLogger::EVENT_TYPE::FINALIZE, nil).once end end end diff --git a/spec/backwards_compatibility_spec.rb b/spec/backwards_compatibility_spec.rb new file mode 100644 index 0000000..e3baa3d --- /dev/null +++ b/spec/backwards_compatibility_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "a_b_smartly" +require "a_b_smartly_config" + +RSpec.describe "Backwards Compatibility" do + it "SDK is an alias for ABSmartly" do + expect(SDK).to eq(ABSmartly) + end + + it "SDKConfig is an alias for ABSmartlyConfig" do + expect(SDKConfig).to eq(ABSmartlyConfig) + end + + it "can create SDK instance using the alias" do + config = SDKConfig.create + config.client = instance_double(Client) + sdk = SDK.create(config) + expect(sdk).to be_instance_of(ABSmartly) + end +end diff --git a/spec/concurrency_spec.rb b/spec/concurrency_spec.rb new file mode 100644 index 0000000..a4def9a --- /dev/null +++ b/spec/concurrency_spec.rb @@ -0,0 +1,351 @@ +# frozen_string_literal: true + +require "ostruct" +require "timeout" +require "context" +require "context_config" +require "default_context_data_deserializer" +require "default_variable_parser" +require "default_audience_deserializer" +require "context_data_provider" +require "default_context_data_provider" +require "context_event_handler" +require "context_event_logger" +require "audience_matcher" +require "json/unit" +require "logger" + +RSpec.describe "Concurrent Operations" do + let(:units) { + { + session_id: "e791e240fcd3df7d238cfc285f475e8152fcc0ec", + user_id: "123456789", + email: "bleh@absmartly.com" + } + } + let(:clock) { Time.at(1620000000000 / 1000) } + + let(:descr) { DefaultContextDataDeserializer.new } + let(:json) { resource("context.json") } + let(:data) { descr.deserialize(json, 0, json.length) } + + let(:publish_future) { OpenStruct.new(success?: true) } + let(:event_handler) do + ev = instance_double(ContextEventHandler) + allow(ev).to receive(:publish).and_return(publish_future) + ev + end + let(:event_logger) { nil } + let(:variable_parser) { DefaultVariableParser.new } + let(:audience_matcher) { AudienceMatcher.new(DefaultAudienceDeserializer.new) } + + def client_mock(data_future = nil) + client = instance_double(Client) + allow(client).to receive(:context_data).and_return(OpenStruct.new(data_future: data_future || data, success?: true)) + client + end + + let(:data_provider) { DefaultContextDataProvider.new(client_mock) } + let(:data_future_ready) { data_provider.context_data } + + def create_context + config = ContextConfig.create + config.set_units(units) + + Context.create(clock, config, data_future_ready, data_provider, + event_handler, event_logger, variable_parser, audience_matcher) + end + + describe "thread-safe treatment access" do + it "handles concurrent getTreatment calls without errors" do + context = create_context + errors = [] + results = [] + mutex = Mutex.new + + threads = 10.times.map do + Thread.new do + 20.times do + begin + result = context.treatment("exp_test_ab") + mutex.synchronize { results << result } + rescue StandardError => e + mutex.synchronize { errors << e } + end + end + end + end + + threads.each(&:join) + + expect(errors).to be_empty + expect(results.uniq.length).to eq(1) + expect(results.first).to be_a(Integer) + end + + it "returns consistent treatment values across threads" do + context = create_context + treatments = [] + mutex = Mutex.new + + threads = 5.times.map do + Thread.new do + 10.times do + treatment = context.treatment("exp_test_ab") + mutex.synchronize { treatments << treatment } + end + end + end + + threads.each(&:join) + + expect(treatments.uniq.length).to eq(1) + end + end + + describe "thread-safe goal tracking" do + it "handles concurrent track calls without errors" do + context = create_context + errors = [] + mutex = Mutex.new + + threads = 10.times.map do |i| + Thread.new do + 10.times do |j| + begin + context.track("goal_#{i}_#{j}", { value: i * j }) + rescue StandardError => e + mutex.synchronize { errors << e } + end + end + end + end + + threads.each(&:join) + + expect(errors).to be_empty + expect(context.pending_count).to eq(100) + end + + it "tracks all goals from concurrent threads" do + context = create_context + tracked_count = 0 + mutex = Mutex.new + + threads = 5.times.map do + Thread.new do + 10.times do + context.track("concurrent_goal", { amount: 1 }) + mutex.synchronize { tracked_count += 1 } + end + end + end + + threads.each(&:join) + + expect(tracked_count).to eq(50) + expect(context.pending_count).to eq(50) + end + end + + describe "thread-safe publishing" do + it "handles concurrent publish requests safely" do + context = create_context + + 50.times { context.track("goal", nil) } + + publish_results = [] + mutex = Mutex.new + errors = [] + + threads = 5.times.map do + Thread.new do + begin + result = context.publish + mutex.synchronize { publish_results << result } + rescue StandardError => e + mutex.synchronize { errors << e } + end + end + end + + threads.each(&:join) + + expect(errors).to be_empty + end + + it "includes each queued event in at most one publish" do + sent = [] + sent_mutex = Mutex.new + handler = instance_double(ContextEventHandler) + allow(handler).to receive(:publish) do |_ctx, event| + sleep 0.02 + sent_mutex.synchronize { sent.concat((event.goals || []).map(&:name)) } + publish_future + end + config = ContextConfig.create + config.set_units(units) + context = Context.create(clock, config, data_future_ready, data_provider, + handler, event_logger, variable_parser, audience_matcher) + + 20.times { |i| context.track("goal_#{i}", nil) } + 5.times.map { Thread.new { context.publish } }.each(&:join) + + expect(sent.sort).to eq(sent.uniq.sort) + expect(sent.size).to eq(20) + end + + it "allows event logger callbacks to call track and publish" do + logger = Class.new do + attr_accessor :context + def handle_event(event, _data) + return unless event == ContextEventLogger::EVENT_TYPE::EXPOSURE + context.track("from_callback", nil) + context.publish + end + end.new + config = ContextConfig.create + config.set_units(units) + context = Context.create(clock, config, data_future_ready, data_provider, + event_handler, logger, variable_parser, audience_matcher) + logger.context = context + + expect { context.treatment("exp_test_ab") }.not_to raise_error + end + + it "allows a PUBLISH callback to call publish without deadlocking" do + logger = Class.new do + attr_accessor :context + def handle_event(event, _data) + context.publish if event == ContextEventLogger::EVENT_TYPE::PUBLISH + end + end.new + config = ContextConfig.create + config.set_units(units) + context = Context.create(clock, config, data_future_ready, data_provider, + event_handler, logger, variable_parser, audience_matcher) + logger.context = context + context.track("goal", nil) + + expect { Timeout.timeout(5) { context.publish } }.not_to raise_error + expect(event_handler).to have_received(:publish).once + end + + it "does not strand events when a PUBLISH callback queues an event and closes" do + sent = [] + handler = instance_double(ContextEventHandler) + allow(handler).to receive(:publish) do |_ctx, event| + sent.concat((event.goals || []).map(&:name)) + publish_future + end + logger = Class.new do + attr_accessor :context + def initialize; @done = false; end + def handle_event(event, _data) + return unless event == ContextEventLogger::EVENT_TYPE::PUBLISH && !@done + @done = true + context.track("late", nil) + context.close + end + end.new + config = ContextConfig.create + config.set_units(units) + context = Context.create(clock, config, data_future_ready, data_provider, + handler, logger, variable_parser, audience_matcher) + logger.context = context + context.track("first", nil) + + Timeout.timeout(5) { context.publish } + + expect(sent).to contain_exactly("first", "late") + expect(context.closed?).to be(true) + end + + it "allows a READY callback to call wait_until_ready" do + logger = Class.new do + attr_accessor :context + def handle_event(event, _data) + context.wait_until_ready(1) if event == ContextEventLogger::EVENT_TYPE::READY + end + end.new + config = ContextConfig.create + config.set_units(units) + context = Context.allocate + logger.context = context + context.send(:initialize_async, clock, config, data_provider, event_handler, + logger, variable_parser, audience_matcher) + + expect { Timeout.timeout(5) { context.set_data(data_future_ready) } }.not_to raise_error + end + + it "does not lose events during concurrent operations" do + context = create_context + + threads = 5.times.map do |i| + Thread.new do + 5.times do |j| + context.track("goal_#{i}_#{j}", nil) + context.treatment("exp_test_ab") if j.even? + end + end + end + + threads.each(&:join) + + expect(context.pending_count).to be >= 25 + end + end + + describe "thread-safe attribute setting" do + it "handles concurrent set_attribute calls" do + context = create_context + errors = [] + mutex = Mutex.new + + threads = 10.times.map do |i| + Thread.new do + 10.times do |j| + begin + context.set_attribute("attr_#{i}_#{j}", "value_#{i}_#{j}") + rescue StandardError => e + mutex.synchronize { errors << e } + end + end + end + end + + threads.each(&:join) + + expect(errors).to be_empty + attrs = context.instance_variable_get(:@attributes) + expect(attrs.length).to eq(100) + end + end + + describe "thread-safe context creation" do + it "creates multiple contexts concurrently without errors" do + contexts = [] + errors = [] + mutex = Mutex.new + + threads = 10.times.map do + Thread.new do + begin + ctx = create_context + mutex.synchronize { contexts << ctx } + rescue StandardError => e + mutex.synchronize { errors << e } + end + end + end + + threads.each(&:join) + + expect(errors).to be_empty + expect(contexts.length).to eq(10) + contexts.each do |ctx| + expect(ctx.ready?).to be_truthy + end + end + end +end diff --git a/spec/context_spec.rb b/spec/context_spec.rb index 39ee8f8..40c671b 100644 --- a/spec/context_spec.rb +++ b/spec/context_spec.rb @@ -50,13 +50,13 @@ } let(:variable_experiments) { { - "banner.border": "exp_test_ab", - "banner.size": "exp_test_ab", - "button.color": "exp_test_abc", - "card.width": "exp_test_not_eligible", - "submit.color": "exp_test_fullon", - "submit.shape": "exp_test_fullon", - "show-modal": "exp_test_new" + "banner.border": ["exp_test_ab"], + "banner.size": ["exp_test_ab"], + "button.color": ["exp_test_abc"], + "card.width": ["exp_test_not_eligible"], + "submit.color": ["exp_test_fullon"], + "submit.shape": ["exp_test_fullon"], + "show-modal": ["exp_test_new"] } } let(:publish_units) { @@ -221,34 +221,22 @@ def faraday_response(content) expect(context.ready?).to be_falsey expect(context.failed?).to be_falsey - not_ready_message = "ABSmartly Context is not yet ready" - expect { - context.peek_treatment("exp_test_ab") - }.to raise_error(IllegalStateException, not_ready_message) + not_ready_message = "ABsmartly Context is not yet ready." - expect { - context.treatment("exp_test_ab") - }.to raise_error(IllegalStateException, not_ready_message) + expect { context.peek_treatment("exp_test_ab") }.to raise_error(IllegalStateException, not_ready_message) + expect { context.treatment("exp_test_ab") }.to raise_error(IllegalStateException, not_ready_message) + expect { context.experiments }.to raise_error(IllegalStateException, not_ready_message) + expect { context.variable_value("banner.border", 17) }.to raise_error(IllegalStateException, not_ready_message) + expect { context.peek_variable_value("banner.border", 17) }.to raise_error(IllegalStateException, not_ready_message) + expect { context.variable_keys }.to raise_error(IllegalStateException, not_ready_message) + expect { context.custom_field_keys }.to raise_error(IllegalStateException, not_ready_message) + expect { context.custom_field_value("exp_test_ab", "country") }.to raise_error(IllegalStateException, not_ready_message) + expect { context.publish }.to raise_error(IllegalStateException, not_ready_message) + expect { context.refresh }.to raise_error(IllegalStateException, not_ready_message) expect { context.data }.to raise_error(IllegalStateException, not_ready_message) - - expect { - context.experiments - }.to raise_error(IllegalStateException, not_ready_message) - - expect { - context.variable_value("banner.border", 17) - }.to raise_error(IllegalStateException, not_ready_message) - - expect { - context.peek_variable_value("banner.border", 17) - }.to raise_error(IllegalStateException, not_ready_message) - - expect { - context.variable_keys - }.to raise_error(IllegalStateException, not_ready_message) end it "throws when closed" do @@ -262,7 +250,7 @@ def faraday_response(content) expect(context.closed?).to be_truthy - closed_message = "ABSmartly Context is closed" + closed_message = "ABsmartly Context is finalized." expect { context.set_attribute("attr1", "value1") }.to raise_error(IllegalStateException, closed_message) @@ -271,14 +259,6 @@ def faraday_response(content) context.set_attributes("attr1": "value1") }.to raise_error(IllegalStateException, closed_message) - expect { - context.set_override("exp_test_ab", 2) - }.to raise_error(IllegalStateException, closed_message) - - expect { - context.set_overrides("exp_test_ab": 2) - }.to raise_error(IllegalStateException, closed_message) - expect { context.set_unit("test", "test") }.to raise_error(IllegalStateException, closed_message) @@ -291,14 +271,6 @@ def faraday_response(content) context.set_custom_assignments("exp_test_ab": 2) }.to raise_error(IllegalStateException, closed_message) - expect { - context.peek_treatment("exp_test_ab") - }.to raise_error(IllegalStateException, closed_message) - - expect { - context.treatment("exp_test_ab") - }.to raise_error(IllegalStateException, closed_message) - expect { context.track("goal1", nil) }.to raise_error(IllegalStateException, closed_message) @@ -311,21 +283,15 @@ def faraday_response(content) context.data }.to raise_error(IllegalStateException, closed_message) - expect { - context.experiments - }.to raise_error(IllegalStateException, closed_message) - - expect { - context.variable_value("banner.border", 17) - }.to raise_error(IllegalStateException, closed_message) - - expect { - context.peek_variable_value("banner.border", 17) - }.to raise_error(IllegalStateException, closed_message) - - expect { - context.variable_keys - }.to raise_error(IllegalStateException, closed_message) + expect { context.peek_treatment("exp_test_ab") }.to raise_error(IllegalStateException, closed_message) + expect { context.treatment("exp_test_ab") }.to raise_error(IllegalStateException, closed_message) + expect { context.variable_value("banner.border", 17) }.to raise_error(IllegalStateException, closed_message) + expect { context.peek_variable_value("banner.border", 17) }.to raise_error(IllegalStateException, closed_message) + expect { context.variable_keys }.to raise_error(IllegalStateException, closed_message) + expect { context.custom_field_keys }.to raise_error(IllegalStateException, closed_message) + expect { context.refresh }.to raise_error(IllegalStateException, closed_message) + # experiments only requires readiness, like the JavaScript SDK. + expect(context.experiments).to eq(data.experiments.map(&:name)) end it "experiments" do @@ -350,7 +316,7 @@ def faraday_response(content) expect { context.set_unit("session_id", "new_uid") }.to raise_error(IllegalStateException, - "Unit 'session_id' already set.") + "Unit 'session_id' UID already set.") end it "set override" do @@ -567,8 +533,9 @@ def faraday_response(content) experiments = data.experiments.map(&:name) - variable_experiments.each do |variable, experiment_name| + variable_experiments.each do |variable, experiment_names| actual = context.peek_variable_value(variable, 17) + experiment_name = experiment_names.first eligible = experiment_name != "exp_test_not_eligible" if eligible && experiments.include?(experiment_name) @@ -598,8 +565,9 @@ def faraday_response(content) experiments = data.experiments.map(&:name) - variable_experiments.each do |variable, experiment_name| + variable_experiments.each do |variable, experiment_names| actual = context.variable_value(variable, 17) + experiment_name = experiment_names.first eligible = experiment_name != "exp_test_not_eligible" if eligible && experiments.include?(experiment_name) @@ -1202,7 +1170,7 @@ def faraday_response(content) actual = context.publish expect(actual).to eq(failure) - expect(event_logger).to have_received(:handle_event).with(ContextEventLogger::EVENT_TYPE::ERROR, "FAILED").once + expect(event_logger).to have_received(:handle_event).with(ContextEventLogger::EVENT_TYPE::ERROR, "FAILED").at_least(:once) end it "publish Does Not Call event handler When Failed" do @@ -1261,6 +1229,961 @@ def faraday_response(content) experiments = refresh_data.experiments.map { |x| x.name } expect(context.experiments).to eq(experiments) end + + describe "publish behavior" do + it "publishes pending events on manual publish" do + context = create_ready_context + context.track("goal1", { amount: 100 }) + context.treatment("exp_test_ab") + + expect(context.pending_count).to eq(2) + + context.publish + + expect(context.pending_count).to eq(0) + expect(event_handler).to have_received(:publish).once + end + + it "does not publish when no pending events exist" do + context = create_ready_context + expect(context.pending_count).to eq(0) + + context.publish + + expect(event_handler).not_to have_received(:publish) + end + + it "clears pending count after successful publish" do + context = create_ready_context + context.track("goal1", nil) + context.track("goal2", nil) + context.track("goal3", nil) + + expect(context.pending_count).to eq(3) + + context.publish + + expect(context.pending_count).to eq(0) + end + + it "publishes on close when pending events exist" do + context = create_ready_context + context.track("goal1", { amount: 50 }) + + expect(context.pending_count).to eq(1) + + context.close + + expect(event_handler).to have_received(:publish).once + expect(context.closed?).to be_truthy + end + + it "does not publish on close when no pending events" do + context = create_ready_context + expect(context.pending_count).to eq(0) + + context.close + + expect(event_handler).not_to have_received(:publish) + expect(context.closed?).to be_truthy + end + end + + describe "refresh error handling" do + it "handles provider error during refresh" do + context = create_context(data_future_ready, dt_provider: data_provider) + expect(context.ready?).to be_truthy + expect(context.failed?).to be_falsey + + allow(data_provider).to receive(:context_data).and_return(failure_future) + + expect(context.refresh).to eq(failure) + + # A failed refresh keeps the current data; only the initial fetch fails a context. + expect(context.failed?).to be_falsey + expect(context.treatment("exp_test_ab")).to eq(expected_variants[:exp_test_ab]) + expect(event_logger).to have_received(:handle_event).with(ContextEventLogger::EVENT_TYPE::ERROR, "FAILED") + end + + it "preserves pending events during failed refresh" do + context = create_context(data_future_ready, dt_provider: data_provider) + context.track("goal1", { amount: 100 }) + context.treatment("exp_test_ab") + + expect(context.pending_count).to eq(2) + + allow(data_provider).to receive(:context_data).and_return(failure_future) + context.refresh + + expect(context.pending_count).to eq(2) + expect(context.failed?).to be_falsey + end + + it "does not refresh when context already failed" do + context = create_failed_context + expect(context.failed?).to be_truthy + + initial_data = context.data + expect(context.refresh).to be_nil + + expect(context.data).to eq(initial_data) + expect(context.failed?).to be_truthy + end + + it "updates data on successful refresh" do + context = create_context(data_future_ready, dt_provider: refresh_data_provider) + original_experiments = context.experiments.dup + + context.refresh + + new_experiments = context.experiments + expect(new_experiments).not_to be_empty + end + + it "logs refresh event on success" do + event_logger.clear + context = create_context(data_future_ready, dt_provider: refresh_data_provider) + expect(event_logger).to have_received(:handle_event).with(ContextEventLogger::EVENT_TYPE::READY, anything).once + + context.refresh + + expect(event_logger).to have_received(:handle_event).with(ContextEventLogger::EVENT_TYPE::REFRESH, anything).once + end + + it "throws when refreshing closed context" do + context = create_ready_context + context.close + + expect { + context.refresh + }.to raise_error(IllegalStateException, "ABsmartly Context is finalized.") + end + end + + describe "error recovery and resilience" do + it "context remains usable after failed publish" do + ev = instance_double(ContextEventHandler) + allow(ev).to receive(:publish).and_return(failure_future) + + context = create_ready_context(evt_handler: ev) + context.track("goal1", nil) + + result = context.publish + expect(result.exception).to eq(failure) + + expect(context.ready?).to be_truthy + expect(context.closed?).to be_falsey + expect(context.treatment("exp_test_ab")).to eq(expected_variants[:exp_test_ab]) + end + + it "queues new events after failed publish" do + ev = instance_double(ContextEventHandler) + allow(ev).to receive(:publish).and_return(failure_future) + + context = create_ready_context(evt_handler: ev) + context.track("goal1", nil) + context.publish + + context.track("goal2", nil) + expect(context.pending_count).to eq(2) + end + + it "calls error callback on publish failure" do + context = create_context(data_future_failed) + context.track("goal1", nil) + + allow(event_handler).to receive(:publish).and_return(failure_future) + context.publish + + expect(event_logger).to have_received(:handle_event).with(ContextEventLogger::EVENT_TYPE::ERROR, "FAILED") + end + + it "failed context clears pending events on publish" do + context = create_failed_context + context.track("goal1", nil) + context.track("goal2", nil) + + expect(context.pending_count).to eq(2) + + context.publish + + expect(context.pending_count).to eq(0) + end + + it "failed context returns control variant" do + context = create_failed_context + expect(context.failed?).to be_truthy + + expect(context.treatment("any_experiment")).to eq(0) + expect(context.peek_treatment("any_experiment")).to eq(0) + end + end + + describe "attribute timestamp validation" do + it "stores correct timestamp on attributes" do + context = create_ready_context + + context.set_attribute("attr1", "value1") + context.set_attribute("attr2", "value2") + + attrs = context.instance_variable_get(:@attributes) + expect(attrs.length).to eq(2) + expect(attrs[0].set_at).to eq(clock_in_millis) + expect(attrs[1].set_at).to eq(clock_in_millis) + end + + it "includes attributes with timestamps in publish event" do + context = create_ready_context(evt_handler: event_handler) + + context.set_attribute("test_attr", "test_value") + context.track("goal1", nil) + + expected = PublishEvent.new + expected.hashed = true + expected.published_at = clock_in_millis + expected.units = publish_units + expected.attributes = [ + Attribute.new("test_attr", "test_value", clock_in_millis) + ] + expected.goals = [ + GoalAchievement.new("goal1", clock_in_millis, nil) + ] + + context.publish + + expect(event_handler).to have_received(:publish).with(context, expected).once + end + + it "preserves all attributes across multiple set operations" do + context = create_ready_context + + context.set_attribute("attr1", "value1") + context.set_attribute("attr2", "value2") + context.set_attributes({ attr3: "value3", attr4: "value4" }) + + attrs = context.instance_variable_get(:@attributes) + expect(attrs.length).to eq(4) + + names = attrs.map(&:name) + expect(names).to include("attr1") + expect(names).to include("attr2") + expect(names).to include(:attr3) + expect(names).to include(:attr4) + end + end + + describe "variable override precedence" do + it "override takes precedence over computed assignment" do + context = create_ready_context + + computed = context.treatment("exp_test_ab") + expect(computed).to eq(expected_variants[:exp_test_ab]) + + context.set_override("exp_test_ab", 99) + + overridden = context.treatment("exp_test_ab") + expect(overridden).to eq(99) + end + + it "override takes precedence over custom assignment" do + context = create_ready_context + + context.set_custom_assignment("exp_test_ab", 5) + custom = context.treatment("exp_test_ab") + expect(custom).to eq(5) + + context.set_override("exp_test_ab", 10) + overridden = context.treatment("exp_test_ab") + expect(overridden).to eq(10) + end + + it "custom assignment takes precedence over computed for eligible experiments" do + context = create_ready_context + + computed = context.peek_treatment("exp_test_ab") + expect(computed).to eq(expected_variants[:exp_test_ab]) + + context.set_custom_assignment("exp_test_ab", 99) + + custom = context.treatment("exp_test_ab") + expect(custom).to eq(99) + end + + it "complex precedence scenario with override custom and computed" do + context = create_ready_context + + computed = context.peek_treatment("exp_test_ab") + expect(computed).to eq(expected_variants[:exp_test_ab]) + + context.set_custom_assignment("exp_test_ab", 5) + expect(context.treatment("exp_test_ab")).to eq(5) + + context.set_override("exp_test_ab", 10) + expect(context.treatment("exp_test_ab")).to eq(10) + + context.set_override("exp_test_ab", 15) + expect(context.treatment("exp_test_ab")).to eq(15) + end + + it "peek_treatment respects override precedence" do + context = create_ready_context + + context.set_override("exp_test_ab", 42) + + expect(context.peek_treatment("exp_test_ab")).to eq(42) + expect(context.pending_count).to eq(0) + end + end + + describe "cache invalidation on refresh" do + def modified_data(experiment_name, &block) + json_copy = JSON.parse(resource("context.json")) + json_copy["experiments"].each do |exp| + block.call(exp) if exp["name"] == experiment_name + end + descr.deserialize(JSON.generate(json_copy), 0, JSON.generate(json_copy).length) + end + + def create_refreshable_context(modified) + modified_client = instance_double(Client) + allow(modified_client).to receive(:context_data).and_return( + OpenStruct.new(data_future: modified, success?: true) + ) + modified_provider = DefaultContextDataProvider.new(modified_client) + create_context(data_future_ready, dt_provider: modified_provider) + end + + it "should pick up changes in experiment stopped" do + modified = modified_data("exp_test_ab") { |exp| exp["fullOnVariant"] = 2 } + context = create_refreshable_context(modified) + + expect(context.treatment("exp_test_ab")).to eq(expected_variants[:exp_test_ab]) + expect(context.pending_count).to eq(1) + + context.refresh + + expect(context.treatment("exp_test_ab")).to eq(2) + expect(context.pending_count).to eq(2) + end + + it "should pick up changes in experiment started" do + started_json = JSON.parse(resource("context.json")) + started_json["experiments"].each do |exp| + exp["fullOnVariant"] = 2 if exp["name"] == "exp_test_ab" + end + started_data = descr.deserialize(JSON.generate(started_json), 0, JSON.generate(started_json).length) + + restarted_json = JSON.parse(resource("context.json")) + restarted_json["experiments"].each do |exp| + exp["fullOnVariant"] = 0 if exp["name"] == "exp_test_ab" + end + restarted_data = descr.deserialize(JSON.generate(restarted_json), 0, JSON.generate(restarted_json).length) + + restarted_client = instance_double(Client) + allow(restarted_client).to receive(:context_data).and_return( + OpenStruct.new(data_future: restarted_data, success?: true) + ) + restarted_provider = DefaultContextDataProvider.new(restarted_client) + + started_client = instance_double(Client) + allow(started_client).to receive(:context_data).and_return( + OpenStruct.new(data_future: started_data, success?: true) + ) + started_provider = DefaultContextDataProvider.new(started_client) + + context = create_context(started_provider.context_data, dt_provider: restarted_provider) + + expect(context.treatment("exp_test_ab")).to eq(2) + expect(context.pending_count).to eq(1) + + context.refresh + + expect(context.treatment("exp_test_ab")).to eq(expected_variants[:exp_test_ab]) + expect(context.pending_count).to eq(2) + end + + it "should pick up changes in experiment fullon" do + modified = modified_data("exp_test_abc") { |exp| exp["fullOnVariant"] = 1 } + context = create_refreshable_context(modified) + + expect(context.treatment("exp_test_abc")).to eq(expected_variants[:exp_test_abc]) + expect(context.pending_count).to eq(1) + + context.refresh + + expect(context.treatment("exp_test_abc")).to eq(1) + expect(context.pending_count).to eq(2) + end + + it "should pick up changes in experiment traffic split" do + modified = modified_data("exp_test_not_eligible") do |exp| + exp["trafficSplit"] = [0.0, 1.0] + end + context = create_refreshable_context(modified) + + expect(context.treatment("exp_test_not_eligible")).to eq(expected_variants[:exp_test_not_eligible]) + expect(context.pending_count).to eq(1) + + context.refresh + + expect(context.treatment("exp_test_not_eligible")).to eq(2) + expect(context.pending_count).to eq(2) + end + + it "should pick up changes in experiment iteration" do + modified = modified_data("exp_test_abc") do |exp| + exp["iteration"] = 2 + exp["trafficSeedHi"] = 398724581 + exp["seedHi"] = 34737352 + end + context = create_refreshable_context(modified) + + expect(context.treatment("exp_test_abc")).to eq(expected_variants[:exp_test_abc]) + expect(context.pending_count).to eq(1) + + context.refresh + + expect(context.treatment("exp_test_abc")).to eq(1) + expect(context.pending_count).to eq(2) + end + + it "should pick up changes in experiment id" do + modified = modified_data("exp_test_abc") do |exp| + exp["id"] = 11 + exp["trafficSeedHi"] = 398724581 + exp["seedHi"] = 34737352 + end + context = create_refreshable_context(modified) + + expect(context.treatment("exp_test_abc")).to eq(expected_variants[:exp_test_abc]) + expect(context.pending_count).to eq(1) + + context.refresh + + expect(context.treatment("exp_test_abc")).to eq(1) + expect(context.pending_count).to eq(2) + end + + it "should not re-queue exposures after refresh when not changed" do + context = create_context(data_future_ready, dt_provider: data_provider) + + expect(context.treatment("exp_test_ab")).to eq(expected_variants[:exp_test_ab]) + expect(context.pending_count).to eq(1) + + context.refresh + + expect(context.treatment("exp_test_ab")).to eq(expected_variants[:exp_test_ab]) + expect(context.pending_count).to eq(1) + end + + it "should not re-queue when not changed with override" do + context = create_context(data_future_ready, dt_provider: data_provider) + + context.set_override("exp_test_ab", 3) + expect(context.treatment("exp_test_ab")).to eq(3) + expect(context.pending_count).to eq(1) + + context.refresh + + expect(context.treatment("exp_test_ab")).to eq(3) + expect(context.pending_count).to eq(1) + end + + it "should keep overrides after refresh" do + modified = modified_data("exp_test_ab") { |exp| exp["fullOnVariant"] = 2 } + context = create_refreshable_context(modified) + + context.set_override("exp_test_ab", 99) + expect(context.treatment("exp_test_ab")).to eq(99) + expect(context.pending_count).to eq(1) + + context.refresh + + expect(context.treatment("exp_test_ab")).to eq(99) + expect(context.pending_count).to eq(1) + end + + it "should keep custom assignments after refresh" do + modified = modified_data("exp_test_ab") { |exp| exp["fullOnVariant"] = 2 } + context = create_refreshable_context(modified) + + context.set_custom_assignment("exp_test_ab", 2) + expect(context.treatment("exp_test_ab")).to eq(2) + expect(context.pending_count).to eq(1) + + context.refresh + + expect(context.treatment("exp_test_ab")).to eq(2) + expect(context.pending_count).to eq(2) + end + + it "should not re-queue when not changed on audience mismatch" do + aud_context = create_context(audience_data_future_ready, dt_provider: audience_data_provider) + + expect(aud_context.treatment("exp_test_ab")).to eq(1) + expect(aud_context.pending_count).to eq(1) + + aud_context.refresh + + expect(aud_context.treatment("exp_test_ab")).to eq(1) + expect(aud_context.pending_count).to eq(1) + end + end + + describe "treatment queues exposure after peek" do + it "should queue exposure after peek" do + context = create_ready_context + + data.experiments.each do |experiment| + expect(context.peek_treatment(experiment.name)).to eq(expected_variants[experiment.name.to_sym]) + end + expect(context.pending_count).to eq(0) + + data.experiments.each do |experiment| + expect(context.treatment(experiment.name)).to eq(expected_variants[experiment.name.to_sym]) + end + expect(context.pending_count).to eq(data.experiments.size) + end + end + + describe "treatment with custom assignment variant" do + it "should queue exposure with custom assignment variant" do + context = create_ready_context(evt_handler: event_handler) + + context.set_custom_assignment("exp_test_ab", 2) + expect(context.treatment("exp_test_ab")).to eq(2) + expect(context.pending_count).to eq(1) + + context.publish + + expected = PublishEvent.new + expected.hashed = true + expected.published_at = clock_in_millis + expected.units = publish_units + + expected.exposures = [ + Exposure.new(1, "exp_test_ab", "session_id", 2, clock_in_millis, + true, true, false, false, true, false), + ] + + expect(event_handler).to have_received(:publish).once + expect(event_handler).to have_received(:publish).with(context, expected).once + end + end + + describe "track validation" do + it "should not throw when goal property values are numbers" do + context = create_ready_context + + expect { + context.track("goal1", { amount: 125, hours: 245 }) + }.not_to raise_error + expect(context.pending_count).to eq(1) + end + + it "should be callable before ready" do + context = create_context(data_future) + expect(context.ready?).to be_falsey + + expect { + context.track("goal1", { amount: 125 }) + }.not_to raise_error + expect(context.pending_count).to eq(1) + end + + it "should track goals with nil properties" do + context = create_ready_context + + expect { + context.track("goal1", nil) + }.not_to raise_error + expect(context.pending_count).to eq(1) + end + end + + describe "publish includes data" do + it "should include exposure data" do + context = create_ready_context(evt_handler: event_handler) + + context.treatment("exp_test_ab") + + expected = PublishEvent.new + expected.hashed = true + expected.published_at = clock_in_millis + expected.units = publish_units + expected.exposures = [ + Exposure.new(1, "exp_test_ab", "session_id", 1, clock_in_millis, + true, true, false, false, false, false), + ] + + context.publish + + expect(event_handler).to have_received(:publish).once + expect(event_handler).to have_received(:publish).with(context, expected).once + end + + it "should include goal data" do + context = create_ready_context(evt_handler: event_handler) + + context.track("goal1", { amount: 125, hours: 245 }) + + expected = PublishEvent.new + expected.hashed = true + expected.published_at = clock_in_millis + expected.units = publish_units + expected.goals = [ + GoalAchievement.new("goal1", clock_in_millis, { amount: 125, hours: 245 }) + ] + + context.publish + + expect(event_handler).to have_received(:publish).once + expect(event_handler).to have_received(:publish).with(context, expected).once + end + + it "should include attribute data" do + context = create_ready_context(evt_handler: event_handler) + + context.set_attribute("attr1", "value1") + context.track("goal1", nil) + + expected = PublishEvent.new + expected.hashed = true + expected.published_at = clock_in_millis + expected.units = publish_units + expected.attributes = [ + Attribute.new("attr1", "value1", clock_in_millis) + ] + expected.goals = [ + GoalAchievement.new("goal1", clock_in_millis, nil) + ] + + context.publish + + expect(event_handler).to have_received(:publish).once + expect(event_handler).to have_received(:publish).with(context, expected).once + end + + it "should not clear queue on failure" do + ev = instance_double(ContextEventHandler) + allow(ev).to receive(:publish).and_return(failure_future) + + context = create_ready_context(evt_handler: ev) + context.track("goal1", nil) + expect(context.pending_count).to eq(1) + + context.publish + + context.track("goal2", nil) + expect(context.pending_count).to eq(2) + end + end + + describe "finalize operations" do + it "should not call client publish when queue is empty" do + context = create_ready_context + expect(context.pending_count).to eq(0) + + context.close + + expect(event_handler).not_to have_received(:publish) + expect(context.closed?).to be_truthy + end + + it "should call client publish" do + context = create_ready_context(evt_handler: event_handler) + + context.treatment("exp_test_ab") + context.track("goal1", nil) + + context.close + + expect(event_handler).to have_received(:publish).once + expect(context.closed?).to be_truthy + end + + it "should stop accepting events after close" do + context = create_ready_context + context.close + + expect { context.treatment("exp_test_ab") }.to raise_error(IllegalStateException) + expect { context.track("goal1", nil) }.to raise_error(IllegalStateException) + expect { context.publish }.to raise_error(IllegalStateException) + end + end + + describe "unit management" do + it "should set a unit" do + context = create_ready_context + + context.set_unit("db_user_id", "new_uid") + + units_ivar = context.instance_variable_get(:@units) + expect(units_ivar[:db_user_id]).to eq("new_uid") + end + + it "should be callable before ready" do + context = create_context(data_future) + expect(context.ready?).to be_falsey + + expect { context.set_unit("db_user_id", "some_uid") }.not_to raise_error + end + end + + describe "refresh event logging" do + it "should call event logger when refresh failed" do + context = create_context(data_future_ready, dt_provider: data_provider) + expect(context.ready?).to be_truthy + + allow(data_provider).to receive(:context_data).and_return(failure_future) + + event_logger.clear + context.refresh + + expect(event_logger).to have_received(:handle_event).with(ContextEventLogger::EVENT_TYPE::ERROR, "FAILED").once + end + + it "should call event logger on refresh success" do + context = create_context(data_future_ready, dt_provider: refresh_data_provider) + expect(context.ready?).to be_truthy + + event_logger.clear + context.refresh + + expect(event_logger).to have_received(:handle_event).with(ContextEventLogger::EVENT_TYPE::REFRESH, anything).once + end + end + + describe "variableValue queues exposures" do + it "should queue exposures after peekVariable" do + context = create_ready_context + + expect(context.peek_variable_value("banner.border", 17)).to eq(1) + expect(context.pending_count).to eq(0) + + expect(context.variable_value("banner.border", 17)).to eq(1) + expect(context.pending_count).to eq(1) + end + + it "should queue exposures only once" do + context = create_ready_context + + context.variable_value("banner.border", 17) + context.variable_value("banner.size", 17) + expect(context.pending_count).to eq(1) + + context.variable_value("banner.border", 17) + context.variable_value("banner.size", 17) + expect(context.pending_count).to eq(1) + end + + it "should return default value on unknown variable" do + context = create_ready_context + + expect(context.variable_value("unknown_variable", 42)).to eq(42) + end + end + + describe "peekVariableValue edge cases" do + it "should return default value on unknown override variant" do + context = create_ready_context + context.set_override("exp_test_ab", 99) + + expect(context.peek_variable_value("banner.border", 42)).to eq(42) + end + end + + describe "track event logger" do + it "should call event logger" do + event_logger.clear + context = create_ready_context + event_logger.clear + + context.track("goal1", { amount: 125, hours: 245 }) + + expect(event_logger).to have_received(:handle_event).with( + ContextEventLogger::EVENT_TYPE::GOAL, + satisfy { |goal| goal.name == "goal1" && goal.properties == { amount: 125, hours: 245 } } + ).once + end + end + + describe "publish event logger on success" do + it "should call event logger on publish success" do + event_logger.clear + context = create_ready_context(evt_handler: event_handler) + event_logger.clear + + context.track("goal1", nil) + context.publish + + expect(event_logger).to have_received(:handle_event).with( + ContextEventLogger::EVENT_TYPE::PUBLISH, instance_of(PublishEvent) + ).once + end + end + + describe "finalize event logging" do + it "should call event logger on close success" do + event_logger.clear + context = create_ready_context(evt_handler: event_handler) + event_logger.clear + + context.track("goal1", nil) + context.close + + expect(event_logger).to have_received(:handle_event).with( + ContextEventLogger::EVENT_TYPE::PUBLISH, instance_of(PublishEvent) + ).once + expect(event_logger).to have_received(:handle_event).with( + ContextEventLogger::EVENT_TYPE::FINALIZE, nil + ).once + end + + it "should call event logger on close error" do + ev = instance_double(ContextEventHandler) + allow(ev).to receive(:publish).and_return(failure_future) + + event_logger.clear + context = create_context(data_future_ready, evt_handler: ev) + event_logger.clear + + context.track("goal1", nil) + context.close + + expect(event_logger).to have_received(:handle_event).with( + ContextEventLogger::EVENT_TYPE::PUBLISH, instance_of(PublishEvent) + ).once + end + end + + describe "custom field value type" do + it "should return custom field value type" do + context = create_context(data_future_ready) + + expect(context.custom_field_type("exp_test_ab", "country")).to eq("string") + expect(context.custom_field_type("exp_test_ab", "overrides")).to eq("json") + end + end + + describe "get_unit" do + it "returns the uid for a given unit type" do + context = create_ready_context + expect(context.get_unit(:session_id)).to eq("e791e240fcd3df7d238cfc285f475e8152fcc0ec") + expect(context.get_unit(:user_id)).to eq("123456789") + expect(context.get_unit(:email)).to eq("bleh@absmartly.com") + end + + it "returns nil for unknown unit type" do + context = create_ready_context + expect(context.get_unit(:nonexistent)).to be_nil + end + end + + describe "get_units" do + it "returns a copy of all units" do + context = create_ready_context + result = context.get_units() + expect(result[:session_id]).to eq("e791e240fcd3df7d238cfc285f475e8152fcc0ec") + expect(result[:user_id]).to eq("123456789") + expect(result[:email]).to eq("bleh@absmartly.com") + end + end + + describe "get_attribute" do + it "returns the last set value for a given attribute name" do + context = create_ready_context + context.set_attribute("country", "US") + context.set_attribute("language", "en") + context.set_attribute("country", "DE") + + expect(context.get_attribute("country")).to eq("DE") + expect(context.get_attribute("language")).to eq("en") + end + + it "returns nil for unknown attribute" do + context = create_ready_context + expect(context.get_attribute("nonexistent")).to be_nil + end + end + + describe "get_attributes" do + it "returns a hash of all attributes with last value for each key" do + context = create_ready_context + context.set_attribute("country", "US") + context.set_attribute("language", "en") + context.set_attribute("country", "DE") + + result = context.get_attributes() + expect(result["country"]).to eq("DE") + expect(result["language"]).to eq("en") + end + end + + describe "ready_error" do + it "returns nil when context is ready without error" do + context = create_ready_context + expect(context.ready_error).to be_nil + end + + it "returns the exception when context failed" do + context = create_failed_context + expect(context.ready_error).not_to be_nil + expect(context.ready_error.message).to eq("FAILED") + end + end + + describe "finalizing?" do + it "returns false when context is not closing" do + context = create_ready_context + expect(context.finalizing?).to be_falsey + end + + it "returns false when context is fully closed" do + context = create_ready_context + context.close + expect(context.finalizing?).to be_falsey + expect(context.closed?).to be_truthy + end + end + + describe "read methods raise when not ready" do + let(:not_ready_message) { "ABsmartly Context is not yet ready." } + + it "raises for treatment, peek and variable reads" do + context = create_context(data_future) + expect(context.ready?).to be_falsey + expect { context.treatment("exp_test_ab") }.to raise_error(IllegalStateException, not_ready_message) + expect { context.peek_treatment("exp_test_ab") }.to raise_error(IllegalStateException, not_ready_message) + expect { context.variable_value("banner.border", 17) }.to raise_error(IllegalStateException, not_ready_message) + expect { context.peek_variable_value("banner.border", 17) }.to raise_error(IllegalStateException, not_ready_message) + expect { context.experiments }.to raise_error(IllegalStateException, not_ready_message) + expect { context.variable_keys }.to raise_error(IllegalStateException, not_ready_message) + expect { context.custom_field_keys }.to raise_error(IllegalStateException, not_ready_message) + expect(context.pending_count).to eq(0) + end + + it "still queues goals before ready" do + context = create_context(data_future) + context.track("goal1", nil) + expect(context.pending_count).to eq(1) + end + end + + describe "read methods raise when closed" do + it "raises for treatment, peek and variable reads" do + context = create_ready_context + context.close + closed_message = "ABsmartly Context is finalized." + expect { context.treatment("exp_test_ab") }.to raise_error(IllegalStateException, closed_message) + expect { context.peek_treatment("exp_test_ab") }.to raise_error(IllegalStateException, closed_message) + expect { context.variable_value("banner.border", 17) }.to raise_error(IllegalStateException, closed_message) + expect { context.peek_variable_value("banner.border", 17) }.to raise_error(IllegalStateException, closed_message) + expect { context.variable_keys }.to raise_error(IllegalStateException, closed_message) + expect { context.custom_field_keys }.to raise_error(IllegalStateException, closed_message) + expect { context.custom_field_value("exp_test_ab", "country") }.to raise_error(IllegalStateException, closed_message) + end + end end diff --git a/spec/create_context_async_spec.rb b/spec/create_context_async_spec.rb new file mode 100644 index 0000000..db12eaa --- /dev/null +++ b/spec/create_context_async_spec.rb @@ -0,0 +1,294 @@ +# frozen_string_literal: true + +require "a_b_smartly" +require "a_b_smartly_config" +require "context" +require "context_config" +require "default_context_data_deserializer" +require "default_variable_parser" +require "default_audience_deserializer" +require "context_data_provider" +require "default_context_data_provider" +require "context_event_handler" +require "context_event_logger" +require "audience_matcher" +require "json/unit" +require "logger" + +RSpec.describe "create_context_async" do + let(:units) { + { + session_id: "e791e240fcd3df7d238cfc285f475e8152fcc0ec", + user_id: "123456789", + email: "bleh@absmartly.com" + } + } + let(:clock) { Time.at(1620000000000 / 1000) } + + let(:descr) { DefaultContextDataDeserializer.new } + let(:json) { resource("context.json") } + let(:data) { descr.deserialize(json, 0, json.length) } + + let(:publish_future) { OpenStruct.new(success?: true) } + let(:event_handler) do + ev = instance_double(ContextEventHandler) + allow(ev).to receive(:publish).and_return(publish_future) + ev + end + let(:event_logger) { nil } + let(:variable_parser) { DefaultVariableParser.new } + let(:audience_matcher) { AudienceMatcher.new(DefaultAudienceDeserializer.new) } + let(:failure) { Exception.new("FAILED") } + + def slow_client_mock(delay: 0.1) + client = instance_double(Client) + allow(client).to receive(:context_data) do + sleep(delay) + OpenStruct.new(data_future: data, success?: true) + end + client + end + + def fast_client_mock + client = instance_double(Client) + allow(client).to receive(:context_data).and_return(OpenStruct.new(data_future: data, success?: true)) + client + end + + def failed_client_mock + client = instance_double(Client) + allow(client).to receive(:context_data).and_return( + OpenStruct.new(exception: failure, success?: false, data_future: nil) + ) + client + end + + describe "ABSmartly#create_context_async" do + it "returns a context immediately before data is fetched" do + config = ABSmartlyConfig.create + config.client = slow_client_mock(delay: 0.5) + sdk = ABSmartly.create(config) + + context_config = ContextConfig.create + context_config.set_units(units) + + context = sdk.create_context_async(context_config) + + expect(context).to be_a(Context) + expect(context.ready?).to be false + expect(context.failed?).to be_falsey + + context.wait_until_ready + expect(context.ready?).to be true + end + + it "becomes ready once data is fetched" do + config = ABSmartlyConfig.create + config.client = fast_client_mock + sdk = ABSmartly.create(config) + + context_config = ContextConfig.create + context_config.set_units(units) + + context = sdk.create_context_async(context_config) + context.wait_until_ready + + expect(context.ready?).to be true + expect(context.failed?).to be_falsey + expect(context.data).to eq(data) + end + + it "can get treatment after becoming ready" do + config = ABSmartlyConfig.create + config.client = fast_client_mock + sdk = ABSmartly.create(config) + + context_config = ContextConfig.create + context_config.set_units(units) + + context = sdk.create_context_async(context_config) + context.wait_until_ready + + treatment = context.treatment("exp_test_ab") + expect(treatment).to be_a(Integer) + end + + it "marks context as failed when fetch fails" do + config = ABSmartlyConfig.create + config.client = failed_client_mock + sdk = ABSmartly.create(config) + + context_config = ContextConfig.create + context_config.set_units(units) + + context = sdk.create_context_async(context_config) + context.wait_until_ready + + expect(context.failed?).to be true + end + + it "raises for treatment before ready" do + config = ABSmartlyConfig.create + config.client = slow_client_mock(delay: 5) + sdk = ABSmartly.create(config) + + context_config = ContextConfig.create + context_config.set_units(units) + + context = sdk.create_context_async(context_config) + + expect { context.treatment("exp_test_ab") } + .to raise_error(IllegalStateException, "ABsmartly Context is not yet ready.") + end + + it "validates params just like create_context" do + config = ABSmartlyConfig.create + config.client = fast_client_mock + sdk = ABSmartly.create(config) + + context_config = ContextConfig.create + context_config.set_unit(:user_id, "") + + expect { sdk.create_context_async(context_config) }.to raise_error(ArgumentError) + end + end + + describe "Context.create_async" do + let(:data_provider) { DefaultContextDataProvider.new(fast_client_mock) } + + it "creates an unready context" do + context = Context.create_async(clock, ContextConfig.create, data_provider, + event_handler, event_logger, variable_parser, audience_matcher) + + expect(context).to be_a(Context) + expect(context.ready?).to be false + end + + it "becomes ready after set_data with success" do + context_config = ContextConfig.create + context_config.set_units(units) + + context = Context.create_async(clock, context_config, data_provider, + event_handler, event_logger, variable_parser, audience_matcher) + + expect(context.ready?).to be false + + context.set_data(OpenStruct.new(data_future: data, success?: true)) + + expect(context.ready?).to be true + expect(context.data).to eq(data) + end + + it "becomes failed after set_data with failure" do + context_config = ContextConfig.create + context_config.set_units(units) + + context = Context.create_async(clock, context_config, data_provider, + event_handler, event_logger, variable_parser, audience_matcher) + + context.set_data(OpenStruct.new(exception: failure, success?: false, data_future: nil)) + + expect(context.failed?).to be true + end + end + + describe "Context#wait_until_ready" do + it "returns immediately when already ready" do + data_provider = DefaultContextDataProvider.new(fast_client_mock) + data_future = data_provider.context_data + + context_config = ContextConfig.create + context_config.set_units(units) + + context = Context.create(clock, context_config, data_future, data_provider, + event_handler, event_logger, variable_parser, audience_matcher) + + start = Time.now + context.wait_until_ready + elapsed = Time.now - start + + expect(context.ready?).to be true + expect(elapsed).to be < 0.1 + end + + it "blocks until data arrives from background thread" do + context_config = ContextConfig.create + context_config.set_units(units) + + data_provider = DefaultContextDataProvider.new(fast_client_mock) + context = Context.create_async(clock, context_config, data_provider, + event_handler, event_logger, variable_parser, audience_matcher) + + Thread.new do + sleep(0.05) + context.set_data(OpenStruct.new(data_future: data, success?: true)) + end + + context.wait_until_ready + expect(context.ready?).to be true + end + + it "respects timeout and returns even if not ready" do + context_config = ContextConfig.create + context_config.set_units(units) + + data_provider = DefaultContextDataProvider.new(fast_client_mock) + context = Context.create_async(clock, context_config, data_provider, + event_handler, event_logger, variable_parser, audience_matcher) + + start = Time.now + context.wait_until_ready(0.1) + elapsed = Time.now - start + + expect(context.ready?).to be false + expect(elapsed).to be >= 0.09 + expect(elapsed).to be < 0.5 + end + + it "returns self for chaining" do + data_provider = DefaultContextDataProvider.new(fast_client_mock) + data_future = data_provider.context_data + + context_config = ContextConfig.create + context_config.set_units(units) + + context = Context.create(clock, context_config, data_future, data_provider, + event_handler, event_logger, variable_parser, audience_matcher) + + result = context.wait_until_ready + expect(result).to eq(context) + end + end + + describe "Absmartly.create_context_async" do + after do + Absmartly.endpoint = nil + Absmartly.api_key = nil + Absmartly.application = nil + Absmartly.environment = nil + Absmartly.event_logger = nil + Absmartly.instance_variable_set(:@sdk, nil) + Absmartly.instance_variable_set(:@sdk_config, nil) + end + + it "creates an async context via the module interface" do + allow(Client).to receive(:create).and_return(fast_client_mock) + + Absmartly.configure_client do |config| + config.endpoint = "https://test.absmartly.io/v1" + config.api_key = "test-key" + config.application = "test-app" + config.environment = "test" + end + + context_config = ContextConfig.create + context_config.set_units(units) + + context = Absmartly.create_context_async(context_config) + expect(context).to be_a(Context) + + context.wait_until_ready + expect(context.ready?).to be true + end + end +end diff --git a/spec/default_variable_parser_spec.rb b/spec/default_variable_parser_spec.rb index 0b99445..1c3e9ea 100644 --- a/spec/default_variable_parser_spec.rb +++ b/spec/default_variable_parser_spec.rb @@ -33,6 +33,6 @@ variable_parser = described_class.new - expect(variable_parser.parse(context, "test_exp", "B", config_value)).to be_nil + expect(variable_parser.parse(context, "test_exp", "B", config_value)).to eq({}) end end diff --git a/spec/fix_plan_spec.rb b/spec/fix_plan_spec.rb new file mode 100644 index 0000000..00cf5e9 --- /dev/null +++ b/spec/fix_plan_spec.rb @@ -0,0 +1,434 @@ +# frozen_string_literal: true + +require "ostruct" +require "context" +require "context_config" +require "default_context_data_deserializer" +require "default_variable_parser" +require "default_audience_deserializer" +require "context_data_provider" +require "default_context_data_provider" +require "context_event_handler" +require "context_event_logger" +require "audience_matcher" +require "json/unit" +require "a_b_smartly" +require "a_b_smartly_config" +require "client" +require "client_config" +require "context_event_logger_callback" +require "json_expr/operators/match_operator" + +RSpec.describe "Fix Plan Validations" do + let(:units) { + { + session_id: "e791e240fcd3df7d238cfc285f475e8152fcc0ec", + user_id: "123456789", + email: "bleh@absmartly.com" + } + } + let(:clock) { Time.at(1620000000000 / 1000) } + let(:descr) { DefaultContextDataDeserializer.new } + let(:json) { resource("context.json") } + let(:data) { descr.deserialize(json, 0, json.length) } + let(:publish_future) { OpenStruct.new(success?: true) } + let(:event_handler) do + ev = instance_double(ContextEventHandler) + allow(ev).to receive(:publish).and_return(publish_future) + ev + end + let(:event_logger) { nil } + let(:variable_parser) { DefaultVariableParser.new } + let(:audience_matcher) { AudienceMatcher.new(DefaultAudienceDeserializer.new) } + + def client_mock(data_future = nil) + client = instance_double(Client) + allow(client).to receive(:context_data).and_return(OpenStruct.new(data_future: data_future || data, success?: true)) + client + end + + let(:data_provider) { DefaultContextDataProvider.new(client_mock) } + let(:data_future_ready) { data_provider.context_data } + + def create_ready_context(evt_handler: nil) + config = ContextConfig.create + config.set_units(units) + Context.create(clock, config, data_future_ready, data_provider, + evt_handler || event_handler, event_logger, variable_parser, audience_matcher) + end + + describe "Fix #1: Thread safety with Mutex" do + it "handles concurrent track calls without losing events" do + context = create_ready_context + errors = [] + mutex = Mutex.new + + threads = 10.times.map do + Thread.new do + 10.times do + begin + context.track("goal", { value: 1 }) + rescue => e + mutex.synchronize { errors << e } + end + end + end + end + + threads.each(&:join) + + expect(errors).to be_empty + expect(context.pending_count).to eq(100) + end + + it "handles concurrent treatment calls and exposure queueing" do + context = create_ready_context + errors = [] + mutex = Mutex.new + + threads = 10.times.map do + Thread.new do + begin + context.treatment("exp_test_ab") + rescue => e + mutex.synchronize { errors << e } + end + end + end + + threads.each(&:join) + + expect(errors).to be_empty + exposures = context.instance_variable_get(:@exposures) + expect(exposures.length).to be <= 10 + end + + it "flush atomically clears events before publishing" do + ev = instance_double(ContextEventHandler) + allow(ev).to receive(:publish).and_return(publish_future) + + context = create_ready_context(evt_handler: ev) + context.track("goal1", nil) + context.track("goal2", nil) + expect(context.pending_count).to eq(2) + + context.publish + + expect(context.pending_count).to eq(0) + end + end + + describe "Fix #2/#13/#21: MatchOperator" do + let(:operator) { MatchOperator.new } + let(:evaluator) do + ev = double("evaluator") + allow(ev).to receive(:evaluate) { |arg| arg } + allow(ev).to receive(:string_convert) { |arg| arg.is_a?(String) ? arg : nil } + ev + end + + it "returns true/false (boolean) instead of MatchData" do + result = operator.binary(evaluator, "abcdef", "abc") + expect(result).to be(true) + + result = operator.binary(evaluator, "abcdef", "xyz") + expect(result).to be(false) + end + + it "has no length caps on pattern or text (matches the JavaScript SDK)" do + expect(operator.binary(evaluator, "test", "(?:t|x)" + ("e?" * 600) + "st")).to be(true) + expect(operator.binary(evaluator, ("b" * 10_001) + "a", "a$")).to be(true) + end + + it "returns nil for invalid regex patterns" do + result = operator.binary(evaluator, "test", "[invalid") + expect(result).to be_nil + end + + it "does not use Timeout.timeout" do + source = File.read(File.join("lib", "json_expr", "operators", "match_operator.rb")) + expect(source).not_to include("Timeout.timeout") + end + end + + describe "Fix #4: create_context_async error handling" do + it "handles exception in data provider thread" do + failing_provider = instance_double(ContextDataProvider) + allow(failing_provider).to receive(:context_data).and_raise(RuntimeError, "connection failed") + + config = ABSmartlyConfig.create + config.client = instance_double(Client) + config.context_data_provider = failing_provider + config.context_event_handler = event_handler + + absmartly = ABSmartly.create(config) + + ctx_config = ContextConfig.create + ctx_config.set_unit(:session_id, "test123") + + context = absmartly.create_context_async(ctx_config) + context.wait_until_ready(2) + + expect(context.failed?).to be(true) + end + end + + describe "Fix #5: Redundant nil check in ContextEventLoggerCallback" do + it "calls callable when present" do + called = false + callback = ContextEventLoggerCallback.new(->(_event, _data) { called = true }) + callback.handle_event("test", nil) + expect(called).to be(true) + end + + it "does not call when callable is nil" do + callback = ContextEventLoggerCallback.new(nil) + expect { callback.handle_event("test", nil) }.not_to raise_error + end + end + + describe "Fix #6: @index initialization as Hash" do + it "initializes @index as a Hash" do + context = create_ready_context + index = context.instance_variable_get(:@state).index + expect(index).to be_a(Hash) + end + end + + describe "Fix #7: @exposures initialization without ||=" do + it "initializes @exposures as empty array" do + context = create_ready_context + exposures = context.instance_variable_get(:@exposures) + expect(exposures).to eq([]) + end + end + + describe "Fix #10: Simplified publish method" do + it "returns flush result directly" do + context = create_ready_context + context.track("goal", nil) + result = context.publish + expect(result).to eq(publish_future) + end + end + + describe "Fix #12: Backtrace leak in AudienceMatcher" do + it "does not include backtrace in error output" do + matcher = AudienceMatcher.new(DefaultAudienceDeserializer.new) + warnings = [] + allow(matcher).to receive(:warn) { |msg| warnings << msg } + + matcher.evaluate("not json {{", {}) + + expect(warnings.first).not_to include("\n") + end + end + + describe "Fix #14: Client headers not publicly accessible" do + it "does not expose headers as a public method" do + config = ClientConfig.create + config.endpoint = "https://localhost/v1" + config.api_key = "test-key" + config.application = "test" + config.environment = "dev" + + http_client = instance_double(DefaultHttpClient) + allow(DefaultHttpClient).to receive(:create).and_return(http_client) + + client = Client.create(config) + expect { client.headers }.to raise_error(NoMethodError) + end + end + + describe "Fix #15: DefaultAudienceDeserializer offset/length" do + it "uses offset+length slicing (not range)" do + deser = DefaultAudienceDeserializer.new + audience = "{\"filter\":[{\"gte\":[{\"var\":\"age\"},{\"value\":20}]}]}" + expected = { filter: [{ gte: [{ var: "age" }, { value: 20 }] }] } + + result = deser.deserialize(audience, 0, audience.length) + expect(result).to eq(expected) + end + + it "handles non-zero offset correctly" do + deser = DefaultAudienceDeserializer.new + prefix = "XXXX" + json_str = '{"key":"val"}' + bytes = prefix + json_str + + result = deser.deserialize(bytes, prefix.length, json_str.length) + expect(result).to eq({ key: "val" }) + end + end + + describe "Fix #16: transform_keys hot path removed" do + it "looks up experiments without transform_keys on every call" do + context = create_ready_context + source = File.read(File.join("lib", "context.rb")) + experiment_method = source[/def experiment\(experiment.*?end/m] + expect(experiment_method).not_to include("transform_keys") + end + + it "looks up variable_experiment without transform_keys on every call" do + context = create_ready_context + source = File.read(File.join("lib", "context.rb")) + variable_method = source[/def variable_experiment\(key.*?end/m] + expect(variable_method).not_to include("transform_keys") + end + end + + describe "Fix #17: set_unit stores keys as symbols" do + it "stores unit with symbol key when called with string" do + context = create_ready_context + context.set_unit("db_user_id", "test_uid") + + units_ivar = context.instance_variable_get(:@units) + expect(units_ivar[:db_user_id]).to eq("test_uid") + expect(units_ivar["db_user_id"]).to be_nil + end + + it "stores unit with symbol key when called with symbol" do + context = create_ready_context + context.set_unit(:db_user_id, "test_uid") + + units_ivar = context.instance_variable_get(:@units) + expect(units_ivar[:db_user_id]).to eq("test_uid") + end + + it "detects duplicate units regardless of key type" do + context = create_ready_context + context.set_unit("db_user_id", "test_uid") + + expect { + context.set_unit(:db_user_id, "different_uid") + }.to raise_error(IllegalStateException) + end + end + + describe "Fix #20: camelCase instance variable renamed" do + it "uses snake_case @experiment_custom_field_values" do + context = create_ready_context + source = File.read(File.join("lib", "context.rb")) + expect(source).not_to include("@experimentCustomFieldValues") + expect(source).to include("experiment_custom_field_values") + end + end + + describe "Fix #19: require 'ostruct' in specs" do + it "OpenStruct is available" do + expect(defined?(OpenStruct)).to eq("constant") + end + end + + describe "Fix 1.3: publish error handling preserves events on failure" do + it "preserves pending_count after publish failure" do + ev = instance_double(ContextEventHandler) + failure = Exception.new("FAILED") + failure_future = OpenStruct.new(exception: failure, success?: false, data_future: nil) + allow(ev).to receive(:publish).and_return(failure_future) + + config = ContextConfig.create + config.set_units({ session_id: "e791e240fcd3df7d238cfc285f475e8152fcc0ec" }) + + context = Context.create(clock, config, data_future_ready, data_provider, + ev, nil, DefaultVariableParser.new, + AudienceMatcher.new(DefaultAudienceDeserializer.new)) + + context.track("goal1", nil) + expect(context.pending_count).to eq(1) + + context.publish + + expect(context.pending_count).to eq(1) + end + + it "clears pending_count after successful publish" do + config = ContextConfig.create + config.set_units({ session_id: "e791e240fcd3df7d238cfc285f475e8152fcc0ec" }) + + context = Context.create(clock, config, data_future_ready, data_provider, + event_handler, nil, DefaultVariableParser.new, + AudienceMatcher.new(DefaultAudienceDeserializer.new)) + + context.track("goal1", nil) + expect(context.pending_count).to eq(1) + + context.publish + + expect(context.pending_count).to eq(0) + end + end + + describe "Fix 4.1: set_override works after context is closed" do + it "allows set_override after close without raising" do + context = create_ready_context + context.close + + expect { context.set_override("exp_test_ab", 2) }.not_to raise_error + end + + it "allows set_overrides after close without raising" do + context = create_ready_context + context.close + + expect { context.set_overrides("exp_test_ab": 2) }.not_to raise_error + end + end + + describe "Fix 1.1: variableKeys returns array of experiment names" do + it "maps each variable key to an array of experiment names" do + context = create_ready_context + keys = context.variable_keys + keys.each do |_key, names| + expect(names).to be_a(Array) + expect(names).not_to be_empty + end + end + + it "includes both experiment names when two experiments share a variable key" do + descr = DefaultContextDataDeserializer.new + + exp_a_variant_b = { "name" => "B", "config" => '{"shared.key":"from_exp_a"}' } + exp_b_variant_b = { "name" => "B", "config" => '{"shared.key":"from_exp_b"}' } + + exp_a = { + "id" => 1, "name" => "exp_a", "unitType" => "session_id", + "iteration" => 1, "seedHi" => 0, "seedLo" => 0, + "split" => [0.5, 0.5], "trafficSeedHi" => 0, "trafficSeedLo" => 0, + "trafficSplit" => [0.0, 1.0], "fullOnVariant" => 0, + "variants" => [{ "name" => "A", "config" => nil }, exp_a_variant_b], + "audienceStrict" => false, "audience" => "" + } + exp_b = { + "id" => 2, "name" => "exp_b", "unitType" => "session_id", + "iteration" => 1, "seedHi" => 0, "seedLo" => 0, + "split" => [0.5, 0.5], "trafficSeedHi" => 0, "trafficSeedLo" => 0, + "trafficSplit" => [0.0, 1.0], "fullOnVariant" => 0, + "variants" => [{ "name" => "A", "config" => nil }, exp_b_variant_b], + "audienceStrict" => false, "audience" => "" + } + + json = JSON.generate({ "experiments" => [exp_a, exp_b] }) + shared_data = descr.deserialize(json, 0, json.length) + + shared_client = instance_double(Client) + allow(shared_client).to receive(:context_data).and_return( + OpenStruct.new(data_future: shared_data, success?: true) + ) + shared_provider = DefaultContextDataProvider.new(shared_client) + shared_future = shared_provider.context_data + + config = ContextConfig.create + config.set_unit(:session_id, "test-unit") + + context = Context.create(clock, config, shared_future, shared_provider, + event_handler, nil, DefaultVariableParser.new, + AudienceMatcher.new(DefaultAudienceDeserializer.new)) + + keys = context.variable_keys + expect(keys[:"shared.key"]).to be_a(Array) + expect(keys[:"shared.key"]).to include("exp_a") + expect(keys[:"shared.key"]).to include("exp_b") + end + end +end diff --git a/spec/hashing_spec.rb b/spec/hashing_spec.rb index 7acf003..014ca41 100644 --- a/spec/hashing_spec.rb +++ b/spec/hashing_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "hashing" +require "murmurhash3" RSpec.describe Hashing do describe ".hash_unit" do @@ -16,8 +17,14 @@ ["testy12", "YqRAtOz000gIu61ErEH18A"], ["testy123", "pfV2H07L6WvdqlY0zHuYIw"], ["special characters açb↓c", "4PIrO7lKtTxOcj2eMYlG7A"], + # Characters outside the BMP are stored as UTF-16 surrogate pairs and must + # encode to 4-byte UTF-8; these canonical hashes are shared across all SDKs. + ["😀", "KgLqw51xanDs83V5GFkntg"], + ["😀😁", "ZJuDalvUWRJnVtkspj-2bQ"], + ["世界你好", "v2CJG7YcjjWncKOSCzF2GA"], + ["user_世界_123", "SCgk4OzXlFMvo1UMsP88fA"], ["The quick brown fox jumps over the lazy dog", "nhB9nTcrtoJr2B01QqQZ1g"], - ["The quick brown fox jumps over the lazy dog and eats a pie", "iM-8ECRrLUQzixl436y96A"], + ["The quick brown fox jumps over the lazy dog and eats a pie", "iM-8ECRrLUQzixl436y96A"], ["Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", "24m7XOq4f5wPzCqzbBicLA"] ] @@ -30,3 +37,53 @@ end end end + +RSpec.describe "Murmur3_32" do + describe "should match known hashes" do + murmur3_tests = [ + ["", 0x00000000, 0], + [" ", 0x00000000, 2129959832], + ["t", 0x00000000, 3397902157], + ["te", 0x00000000, 3988319771], + ["tes", 0x00000000, 196677210], + ["test", 0x00000000, 3127628307], + ["testy", 0x00000000, 1152353090], + ["testy1", 0x00000000, 2316969018], + ["testy12", 0x00000000, 2220122553], + ["testy123", 0x00000000, 1197640388], + ["special characters açb↓c", 0x00000000, 3196301632], + ["The quick brown fox jumps over the lazy dog", 0x00000000, 776992547], + ["", 0xdeadbeef, 233162409], + [" ", 0xdeadbeef, 632081987], + ["t", 0xdeadbeef, 991288568], + ["te", 0xdeadbeef, 2895647538], + ["tes", 0xdeadbeef, 3251080666], + ["test", 0xdeadbeef, 2854409242], + ["testy", 0xdeadbeef, 2230711843], + ["testy1", 0xdeadbeef, 166537449], + ["testy12", 0xdeadbeef, 575043637], + ["testy123", 0xdeadbeef, 3593668109], + ["special characters açb↓c", 0xdeadbeef, 4160608418], + ["The quick brown fox jumps over the lazy dog", 0xdeadbeef, 981155661], + ["", 0x00000001, 1364076727], + [" ", 0x00000001, 1326412082], + ["t", 0x00000001, 1571914526], + ["te", 0x00000001, 3527981870], + ["tes", 0x00000001, 3560106868], + ["test", 0x00000001, 2579507938], + ["testy", 0x00000001, 3316833310], + ["testy1", 0x00000001, 865230059], + ["testy12", 0x00000001, 3643580195], + ["testy123", 0x00000001, 1002533165], + ["special characters açb↓c", 0x00000001, 691218357], + ["The quick brown fox jumps over the lazy dog", 0x00000001, 2028379687] + ] + + murmur3_tests.each do |input, seed, expected_hash| + it "hashes '#{input}' with seed 0x#{seed.to_s(16).rjust(8, '0')} to #{expected_hash}" do + result = MurmurHash3::V32.str_hash(input, seed) + expect(result).to eq(expected_hash) + end + end + end +end diff --git a/spec/http_retry_spec.rb b/spec/http_retry_spec.rb new file mode 100644 index 0000000..5a99b57 --- /dev/null +++ b/spec/http_retry_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require "default_http_client" +require "default_http_client_config" + +RSpec.describe "HTTP Retry Logic" do + describe "configuration" do + it "configures max_retries correctly" do + config = DefaultHttpClientConfig.create + config.max_retries = 3 + + client = DefaultHttpClient.create(config) + + expect(config.max_retries).to eq(3) + end + + it "configures retry_interval correctly" do + config = DefaultHttpClientConfig.create + config.retry_interval = 0.2 + + expect(config.retry_interval).to eq(0.2) + end + + it "uses default max_retries of 5" do + config = DefaultHttpClientConfig.create + + expect(config.max_retries).to eq(5) + end + + it "uses default retry_interval of 0.5" do + config = DefaultHttpClientConfig.create + + expect(config.retry_interval).to eq(0.5) + end + + it "uses default connect_timeout of 3.0" do + config = DefaultHttpClientConfig.create + + expect(config.connect_timeout).to eq(3.0) + end + + it "uses default connection_request_timeout of 3.0" do + config = DefaultHttpClientConfig.create + + expect(config.connection_request_timeout).to eq(3.0) + end + + it "uses default pool_size of 20" do + config = DefaultHttpClientConfig.create + + expect(config.pool_size).to eq(20) + end + + it "uses default pool_idle_timeout of 5" do + config = DefaultHttpClientConfig.create + + expect(config.pool_idle_timeout).to eq(5) + end + end + + describe "Faraday retry middleware configuration" do + it "configures retry middleware with max_retries" do + config = DefaultHttpClientConfig.create + config.max_retries = 3 + config.retry_interval = 0.1 + + client = DefaultHttpClient.create(config) + + handlers = client.session.builder.handlers + retry_handler = handlers.find { |h| h.name.include?("Retry") } + + expect(retry_handler).not_to be_nil + end + + it "creates client with custom pool size" do + config = DefaultHttpClientConfig.create + config.pool_size = 50 + + expect_any_instance_of(Faraday::Connection).to receive(:adapter) + .with(:net_http_persistent, pool_size: 50) + .and_call_original + + DefaultHttpClient.create(config) + end + + it "creates client with custom timeout settings" do + config = DefaultHttpClientConfig.create + config.connect_timeout = 10.0 + config.connection_request_timeout = 15.0 + + client = DefaultHttpClient.create(config) + + expect(client.session.options.timeout).to eq(10.0) + expect(client.session.options.open_timeout).to eq(15.0) + end + end + + describe "HTTP client methods" do + let(:config) { DefaultHttpClientConfig.create } + let(:client) { DefaultHttpClient.create(config) } + + it "responds to get method" do + expect(client).to respond_to(:get) + end + + it "responds to put method" do + expect(client).to respond_to(:put) + end + + it "responds to post method" do + expect(client).to respond_to(:post) + end + + it "responds to close method" do + expect(client).to respond_to(:close) + end + end + + describe "default_response factory" do + it "creates response with correct status code" do + response = DefaultHttpClient.default_response(200, "OK", "application/json", '{"data": "test"}') + + expect(response.status).to eq(200) + end + + it "creates response with correct body" do + response = DefaultHttpClient.default_response(200, "OK", "application/json", '{"data": "test"}') + + expect(response.body).to eq('{"data": "test"}') + end + + it "creates error response with status message as body when content is nil" do + response = DefaultHttpClient.default_response(500, "Internal Server Error", nil, nil) + + expect(response.status).to eq(500) + expect(response.body).to eq("Internal Server Error") + end + + it "creates response with content type header" do + response = DefaultHttpClient.default_response(200, "OK", "application/json", '{}') + + expect(response.headers["Content-Type"]).to eq("application/json") + end + + it "handles 4xx client errors" do + response = DefaultHttpClient.default_response(400, "Bad Request", "application/json", '{"error": "bad"}') + + expect(response.status).to eq(400) + expect(response.body).to eq('{"error": "bad"}') + end + + it "handles 5xx server errors" do + response = DefaultHttpClient.default_response(503, "Service Unavailable", nil, nil) + + expect(response.status).to eq(503) + end + end + + describe "retry behavior expectations" do + it "faraday-retry gem is available" do + expect(defined?(Faraday::Retry)).not_to be_nil + end + + it "retry middleware supports exponential backoff" do + config = DefaultHttpClientConfig.create + client = DefaultHttpClient.create(config) + + handlers = client.session.builder.handlers + retry_handler = handlers.find { |h| h.name.include?("Retry") } + + expect(retry_handler).not_to be_nil + end + end +end diff --git a/spec/local_server_integration_spec.rb b/spec/local_server_integration_spec.rb new file mode 100644 index 0000000..ad84a8a --- /dev/null +++ b/spec/local_server_integration_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "a_b_smartly" +require "context_config" +require "socket" +require "json" +require "uri" + +# Hermetic integration test: spins up a real local HTTP server on an ephemeral +# port (stdlib TCPServer, no WEBrick dependency), points the SDK's client +# endpoint at it, and drives the PUBLIC SDK API so the real Faraday HTTP client +# performs a GET /context (create_context -> wait_until_ready) and a +# PUT /context (track -> publish). Asserts the wire contract. +class LocalTestServer + attr_reader :port, :requests + + def initialize + @server = TCPServer.new("127.0.0.1", 0) + @port = @server.addr[1] + @requests = [] + @mutex = Mutex.new + @thread = Thread.new { serve } + end + + def serve + loop do + client = @server.accept + handle(client) + rescue IOError, Errno::EBADF + break + end + end + + def handle(client) + request_line = client.gets + return if request_line.nil? + + method, path, = request_line.split(" ") + headers = {} + while (line = client.gets) && line != "\r\n" + key, value = line.split(":", 2) + headers[key.strip.downcase] = value.strip if value + end + + body = nil + if (len = headers["content-length"]) && len.to_i.positive? + body = client.read(len.to_i) + end + + @mutex.synchronize do + @requests << { method: method, path: path, headers: headers, body: body } + end + + response_body = method == "GET" ? '{"experiments":[]}' : "{}" + client.write("HTTP/1.1 200 OK\r\n") + client.write("Content-Type: application/json\r\n") + client.write("Content-Length: #{response_body.bytesize}\r\n") + client.write("Connection: close\r\n") + client.write("\r\n") + client.write(response_body) + ensure + client.close rescue nil + end + + def base_url + "http://127.0.0.1:#{port}" + end + + def stop + @server.close rescue nil + @thread.kill + end +end + +RSpec.describe "Local server integration (real HTTP)" do + let(:server) { LocalTestServer.new } + + after { server.stop } + + it "performs a real GET /context and PUT /context against a local server" do + sdk = ABSmartly.new( + server.base_url, + api_key: "test-api-key", + application: "website", + environment: "dev" + ) + + context_config = ContextConfig.create + context_config.set_unit("user_id", "123456789") + + context = sdk.create_context(context_config) + context.wait_until_ready + + # --- assert the real GET /context --- + get_req = server.requests.find { |r| r[:method] == "GET" } + expect(get_req).not_to be_nil + uri = URI.parse(get_req[:path]) + expect(uri.path).to eq("/context") + query = URI.decode_www_form(uri.query || "").to_h + expect(query["application"]).to eq("website") + expect(query["environment"]).to eq("dev") + + # --- queue an event then publish --- + context.track("payment", { value: 99 }) + context.publish + + put_req = server.requests.find { |r| r[:method] == "PUT" } + expect(put_req).not_to be_nil + expect(URI.parse(put_req[:path]).path).to eq("/context") + + # --- headers --- + h = put_req[:headers] + expect(h["x-api-key"]).to eq("test-api-key") + expect(h["x-application"]).to eq("website") + expect(h["x-environment"]).to eq("dev") + expect(h["x-application-version"]).to eq("0") + expect(h["x-agent"]).not_to be_nil + expect(h["x-agent"]).not_to be_empty + expect(h["content-type"]).to include("application/json") + + # --- body --- + body = JSON.parse(put_req[:body]) + expect(body).to have_key("hashed") + expect(body["units"]).to be_an(Array) + expect(body["units"]).not_to be_empty + expect(body["units"].first).to have_key("type") + expect(body["units"].first).to have_key("uid") + expect(body).to have_key("publishedAt") + expect(body["publishedAt"]).to be_a(Integer) + expect(body["goals"]).to be_an(Array) + expect(body["goals"]).not_to be_empty + expect(body["goals"].first["name"]).to eq("payment") + end +end diff --git a/spec/parity_spec.rb b/spec/parity_spec.rb new file mode 100644 index 0000000..5bdb101 --- /dev/null +++ b/spec/parity_spec.rb @@ -0,0 +1,299 @@ +# frozen_string_literal: true + +require "context" +require "context_config" +require "default_context_data_deserializer" +require "default_variable_parser" +require "default_audience_deserializer" +require "default_context_data_provider" +require "context_event_handler" +require "context_event_logger" +require "audience_matcher" +require "hashing" +require "variant_assigner" +require "json_expr/json_expr" +require "client" +require "client_config" +require "socket" + +# Behaviors aligned with the JavaScript SDK (the reference implementation). +RSpec.describe "JavaScript SDK parity" do + describe "UTF-8 unit encoding" do + # Shared cross-SDK vectors, validated against the JavaScript SDK's + # stringToUint8Array / hashUnit. + vectors = { + "user\u{1F600}id" => [[117, 115, 101, 114, 240, 159, 152, 128, 105, 100], "nnPMxN6wY36LGRnuJ6jZrw"], + "\u{1F600}" => [[240, 159, 152, 128], "KgLqw51xanDs83V5GFkntg"], + "\u{2000B}" => [[240, 160, 128, 139], "8mWvgvNGn-DZqb6Ft0HVjA"], + "a€\u{1D11E}z" => [[97, 226, 130, 172, 240, 157, 132, 158, 122], "AGGq8R9K1XjO77q33izhLg"], + "\u{1F1F5}\u{1F1F9}" => [[240, 159, 135, 181, 240, 159, 135, 185], "1RRpVphazpblARsm5DaSkg"] + } + lone_surrogate_bytes = [97, 98, 239, 191, 189, 99, 100] + lone_surrogate_hash = "jvDxBUzS9Tdsml3QQStWSg" + + vectors.each do |value, (bytes, hash)| + it "encodes #{value.inspect} as standard 4-byte UTF-8 and hashes it" do + expect(Hashing.to_utf8(value).bytes).to eq(bytes) + expect(Hashing.hash_unit(value)).to eq(hash) + end + + it "hashes #{value.inspect} the same from UTF-16 and from CESU-8 bytes" do + expect(Hashing.hash_unit(value.encode("UTF-16LE"))).to eq(hash) + cesu = value.encode("UTF-16BE").unpack("n*").map { |u| [u].pack("U") }.join.b + expect(Hashing.hash_unit(cesu.force_encoding("UTF-8"))).to eq(hash) + end + end + + it "replaces a lone surrogate in a UTF-16 string with U+FFFD" do + utf16 = "ab".encode("UTF-16LE") + [0xD83D].pack("v").force_encoding("UTF-16LE") + "cd".encode("UTF-16LE") + expect(Hashing.to_utf8(utf16).bytes).to eq(lone_surrogate_bytes) + expect(Hashing.hash_unit(utf16)).to eq(lone_surrogate_hash) + end + + it "replaces a lone surrogate encoded as UTF-8 bytes with a single U+FFFD" do + wtf8 = "ab\xED\xA0\xBDcd".dup.force_encoding("UTF-8") + expect(Hashing.to_utf8(wtf8).bytes).to eq(lone_surrogate_bytes) + expect(Hashing.hash_unit(wtf8)).to eq(lone_surrogate_hash) + end + + it "replaces other invalid bytes with U+FFFD" do + expect(Hashing.to_utf8("a\xFFb".dup.force_encoding("UTF-8")).bytes).to eq([97, 239, 191, 189, 98]) + end + + it "assigns non-BMP units like the JavaScript VariantAssigner" do + { + "\u{1F600}" => 1, + "\u{1F1F5}\u{1F1F9}" => 0, + "a€\u{1D11E}z" => 1, + "user\u{1F600}id" => 0 + }.each do |unit, variant| + expect(VariantAssigner.new(unit).assign([0.5, 0.5], 3603583, 233373918)).to eq(variant), unit + end + end + end + + describe "operators" do + let(:expr) { JsonExpr.new } + + def value(x) + { value: x } + end + + it "eq(null, null) is null" do + expect(expr.evaluate_expr({ eq: [value(nil), value(nil)] }, {})).to be_nil + end + + it "in looks up keys of an object haystack, including symbolized audience attributes" do + vars = JSON.parse('{"h":{"a":1}}', symbolize_names: true) + expect(expr.evaluate_expr({ in: [{ var: "h" }, value("a")] }, vars)).to be(true) + expect(expr.evaluate_expr({ in: [{ var: "h" }, value("b")] }, vars)).to be(false) + end + + it "in is haystack-first and converts a non-string needle for a string haystack" do + expect(expr.evaluate_expr({ in: [value("abc1"), value(1)] }, {})).to be(true) + expect(expr.evaluate_expr({ in: [value("abc"), value([1])] }, {})).to be(false) + expect(expr.evaluate_expr({ in: [value(5), value(1)] }, {})).to be_nil + end + + it "match returns null for an invalid pattern" do + expect(expr.evaluate_expr({ match: [value("abc"), value("(")] }, {})).to be_nil + end + end + + describe "context lifecycle" do + let(:descr) { DefaultContextDataDeserializer.new } + let(:json) { resource("context.json") } + let(:data) { descr.deserialize(json, 0, json.length) } + let(:clock) { Time.at(1620000000) } + let(:ready_future) { OpenStruct.new(data_future: data, success?: true) } + let(:failure) { Exception.new("FAILED") } + let(:failure_future) { OpenStruct.new(exception: failure, success?: false, data_future: nil) } + let(:ok_future) { OpenStruct.new(success?: true) } + let(:data_provider) { instance_double(ContextDataProvider, context_data: ready_future) } + let(:handler) { instance_double(ContextEventHandler) } + let(:events) { [] } + let(:logger) do + log = events + Class.new(ContextEventLogger) do + define_method(:handle_event) { |event, data| log << [event, data] } + end.new + end + + def make_context(future = ready_future, event_logger: logger) + config = ContextConfig.create + config.set_units(session_id: "e791e240fcd3df7d238cfc285f475e8152fcc0ec") + Context.create(clock, config, future, data_provider, handler, event_logger, + DefaultVariableParser.new, AudienceMatcher.new(DefaultAudienceDeserializer.new)) + end + + it "keeps the context open and the events queued when the final publish fails" do + allow(handler).to receive(:publish).and_return(failure_future) + context = make_context + context.track("goal1", nil) + + result = context.close + + expect(result.exception).to eq(failure) + expect(context.closed?).to be(false) + expect(context.finalizing?).to be(false) + expect(context.pending_count).to eq(1) + expect(events.map(&:first)).not_to include(ContextEventLogger::EVENT_TYPE::FINALIZE) + + allow(handler).to receive(:publish).and_return(ok_future) + expect(context.close).to be_nil + expect(context.closed?).to be(true) + expect(context.pending_count).to eq(0) + expect(handler).to have_received(:publish).twice + expect(events.map(&:first)).to include(ContextEventLogger::EVENT_TYPE::FINALIZE) + end + + it "emits the finalize event under the name \"finalize\"" do + allow(handler).to receive(:publish).and_return(ok_future) + context = make_context + context.close + expect(events.last).to eq(["finalize", nil]) + end + + it "closes a failed context, discarding its events" do + context = make_context(failure_future) + context.track("goal1", nil) + context.close + expect(context.closed?).to be(true) + expect(context.pending_count).to eq(0) + end + + it "treats refresh of a failed context as a successful no-op" do + context = make_context(failure_future) + expect(context.refresh).to be_nil + expect(data_provider).not_to have_received(:context_data) + expect(context.failed?).to be(true) + end + + it "keeps a ready context usable after a failed refresh" do + context = make_context + allow(data_provider).to receive(:context_data).and_return(failure_future) + expect(context.refresh).to eq(failure) + expect(context.failed?).to be(false) + expect(context.experiments).to eq(data.experiments.map(&:name)) + end + + it "does not let a raising event logger break treatment or publish" do + raising = Class.new(ContextEventLogger) do + def handle_event(_event, _data) + raise "logger boom" + end + end.new + allow(handler).to receive(:publish).and_return(ok_future) + context = nil + expect { context = make_context(event_logger: raising) }.not_to raise_error + expect { context.treatment("exp_test_ab") }.not_to raise_error + expect(context.publish).to eq(ok_future) + expect(context.pending_count).to eq(0) + end + + it "keeps a cached assignment when a re-evaluated audience is indeterminate" do + exp = data.experiments.find { |e| e.name == "exp_test_ab" } + exp.audience_strict = true + exp.audience = '{"filter":[{"value":false}]}' + allow(handler).to receive(:publish).and_return(ok_future) + context = make_context + expect(context.treatment("exp_test_ab")).to eq(0) + exp.audience = '{"no_filter":true}' + context.set_attribute("age", 25) + expect(context.treatment("exp_test_ab")).to eq(0) + expect(context.pending_count).to eq(1) + end + + it "normalizes a unit to UTF-8 before comparing and hashing it" do + config = ContextConfig.create + context = Context.create(clock, config, ready_future, data_provider, handler, logger, + DefaultVariableParser.new, AudienceMatcher.new(DefaultAudienceDeserializer.new)) + context.set_unit(:user_id, "user\u{1F600}id".encode("UTF-16LE")) + expect(context.get_unit(:user_id).encoding).to eq(Encoding::UTF_8) + expect { context.set_unit(:user_id, "user\u{1F600}id") }.not_to raise_error + end + end + + describe "transport" do + # Answers each request with the next status from the list. + def with_server(statuses) + server = TCPServer.new("127.0.0.1", 0) + seen = [] + thread = Thread.new do + statuses.each do |status| + client = server.accept + request = client.gets + length = 0 + while (line = client.gets) && line != "\r\n" + length = line.split(":", 2)[1].to_i if line.downcase.start_with?("content-length") + end + client.read(length) if length.positive? + seen << request.split(" ").first + body = '{"experiments":[]}' + client.write("HTTP/1.1 #{status} X\r\nContent-Type: application/json\r\n" \ + "Content-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}") + client.close + end + end + yield "http://127.0.0.1:#{server.addr[1]}", seen + ensure + thread&.kill + server&.close + end + + def client_for(endpoint) + config = ClientConfig.create + config.endpoint = endpoint + config.api_key = "k" + config.application = "a" + config.environment = "e" + config.retry_interval = 0.01 + Client.create(config) + end + + it "retries a 5xx publish and reports success" do + with_server([503, 200]) do |endpoint, seen| + event = PublishEvent.new + event.hashed = true + event.units = [] + response = client_for(endpoint).publish(event) + expect(response).not_to be_a(Exception) + expect(seen).to eq(%w[PUT PUT]) + end + end + + it "retries a 5xx context fetch" do + with_server([500, 200]) do |endpoint, seen| + client = client_for(endpoint).context_data + expect(client.exception).to be_nil + expect(seen).to eq(%w[GET GET]) + end + end + + it "does not retry a 4xx publish" do + with_server([400, 200]) do |endpoint, seen| + event = PublishEvent.new + event.hashed = true + event.units = [] + expect(client_for(endpoint).publish(event)).to be_a(Exception) + expect(seen).to eq(%w[PUT]) + end + end + + it "fails the context fetch instead of raising on a connection error" do + server = TCPServer.new("127.0.0.1", 0) + port = server.addr[1] + server.close + config = ClientConfig.create + config.endpoint = "http://127.0.0.1:#{port}" + config.api_key = "k" + config.application = "a" + config.environment = "e" + config.max_retries = 0 + client = nil + expect { client = Client.create(config).context_data }.not_to raise_error + expect(client.success?).to be(false) + expect(client.exception).not_to be_nil + end + end +end