Skip to content

Ruby SDK: All tests passing (412/412 unit, 137/138 cross-SDK) - #39

Open
joalves wants to merge 36 commits into
mainfrom
feat/ruby-3.3-compatibility
Open

joalves wants to merge 36 commits into
mainfrom
feat/ruby-3.3-compatibility

Conversation

@joalves

@joalves joalves commented Feb 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Upgrade Ruby compatibility to 3.3.10 with frozen string literal fixes
  • Fix cross-SDK test compatibility issues (audience matcher, match operator, exposure deduplication, variable parser)
  • Add comprehensive test coverage improvements (340 -> 412 unit tests)
  • Add peek alias for peekTreatment consistency with other SDKs
  • Add Rails and Sinatra integration examples

Test Results

  • Unit Tests: 412/412 passed, exit code 0
  • Cross-SDK Tests: 137/138 passed (1 skipped: scenario 67 requires asyncContext), exit code 0

Key Fixes

  • Fix frozen string literal issues across codebase for Ruby 3.3+
  • Add explicit require 'ostruct' for Ruby 3.3+ compatibility
  • Fix audience matcher null handling edge cases
  • Fix match operator regex evaluation
  • Fix context event exposure deduplication logic
  • Add type_utils module for consistent type handling
  • Update experiment model with custom field support
  • Fix variable parser edge cases

Test plan

  • All 412 unit tests pass with bundle exec rspec
  • All 137/138 cross-SDK scenarios pass via Docker orchestrator
  • Ruby 3.3.10 compatibility verified

Summary by CodeRabbit

  • New Features

    • Asynchronous context creation, SDK/SDKConfig aliases, named-parameter initialization and global client configuration with timeout/retry options.
  • Documentation

    • Major README overhaul: installation, getting-started, async contexts, event-logging and examples updated.
  • Bug Fixes

    • Improved error handling, safer parsing and regex guards, normalized comparisons and strengthened thread-safety.
  • Tests

    • Extensive new and updated suites for concurrency, async flows, HTTP retry behaviour and many edge cases.
  • Chores

    • Ruby version bump, updated ignore patterns, packaging exclusions and example switched to environment-driven configuration.

- Add custom_field_value method for accessing custom field values
- Fix type coercion for custom field values (json, number, boolean types)
- Upgrade Ruby version from 3.0.6 to 3.3.10
- Update gem dependencies for compatibility
- Add peek alias to peek_treatment method
- Add object key containment test for in_operator
- Add peek_treatment audience re-evaluation tests
- Add treatment audience re-evaluation tests for strict/non-strict modes
- Add tests for audience cache invalidation behavior
- Add publish behavior tests
- Add refresh error handling tests
- Add HTTP retry logic tests
- Add error recovery and resilience tests
- Add concurrent operations tests
- Add attribute timestamp validation tests
- Add variable override precedence tests

Total: 55 new tests added, all 316 tests pass
Add 36 Murmur3 parameterized tests and ~36 context tests
covering cache invalidation, treatment/exposure queuing,
publish data, finalize, unit management, refresh logging,
variable value edge cases, track validation, and event logging.
- Fix frozen string literal issues across codebase
- Add explicit ostruct require for Ruby 3.3+ compatibility
- Fix audience matcher null handling
- Fix match operator regex evaluation
- Fix context event exposure deduplication
- Add type_utils module for consistent type handling
- Update experiment model with custom field support
- Fix variable parser edge cases
@coderabbitai

coderabbitai Bot commented Feb 21, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This diff updates project metadata (.ruby-version, .gitignore), expands README and CHANGELOG entries, and adds environment-driven example config. It introduces SDK and SDKConfig aliases, a named-parameter ABSmartly factory, and public create_context_async. Context gains async creation, readiness signalling, attrs_seq tracking and many concurrency-safe helpers. Adds TypeUtils and refactors json_expr operators to use it. Improves error handling across parsers/deserializers, adjusts Client and scheduler internals, introduces ContextPublisher/DefaultContextPublisher, and adds/updates many specs.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • calthejuggler
  • cwmpinho
🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.02% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title references test results (412/412 unit, 137/138 cross-SDK) but the PR contains substantial feature additions including Ruby 3.3 compatibility, new type utilities, API enhancements (async context creation, aliases), and extensive documentation updates that go well beyond test execution outcomes. Revise the title to reflect primary deliverables such as 'Ruby 3.3 compatibility, async context API, and type utilities' rather than focusing solely on test execution metrics.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ruby-3.3-compatibility

🐰 I hopped through code with eager paws,
Named params, threads and tiny laws.
Contexts wake, async and bright,
Aliases twinkle in the night.
Tests hum softly — carrot delight.

Comment @coderabbitai help to get the list of available commands.

Fix EqualsOperator for numeric string comparisons and InOperator for
string containment. Add named params and backwards compatibility specs.
Add create_context_async for non-blocking context creation, plus
updated README with async examples.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/json/experiment.rb (1)

30-39: ⚠️ Potential issue | 🟠 Major

Use the normalised key for conversion branches.

ALLOWED_KEYS accepts multiple key forms, but Line 30/Line 32/Line 34 branch on raw name. Keys like "applications" or :custom_field_values can bypass class conversion and leave raw hashes in instance state.

💡 Proposed fix
-      if name == :applications
+      if key_str == "applications"
         `@applications` = assign_to_klass(ExperimentApplication, value)
-      elsif name == :variants
+      elsif key_str == "variants"
         `@variants` = assign_to_klass(ExperimentVariant, value)
-      elsif name == :customFieldValues
-        if value != nil
-          `@custom_field_values` = assign_to_klass(CustomFieldValue, value)
-        end
+      elsif key_str == "custom_field_values"
+        `@custom_field_values` = assign_to_klass(CustomFieldValue, value) unless value.nil?
       else
         self.instance_variable_set("@#{key_str}", value)
       end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/json/experiment.rb` around lines 30 - 39, The conversion branches are
using the raw name variable so keys like "applications" or :custom_field_values
bypass class conversion; modify the branch conditions to check the normalized
key used by ALLOWED_KEYS (e.g., use key_str or the normalized symbol instead of
name) so that assign_to_klass(ExperimentApplication, value),
assign_to_klass(ExperimentVariant, value) and assign_to_klass(CustomFieldValue,
value) run for all equivalent key forms; update the if/elsif/elsif to compare
the normalized key (the same normalization ALLOWED_KEYS expects) before setting
`@applications`, `@variants`, or `@custom_field_values` and keep the fallback
instance_variable_set as-is.
🧹 Nitpick comments (9)
lib/context_event_logger_callback.rb (1)

11-11: Redundant nil check.

The condition @callable && !@callable.nil? is redundant — if @callable is truthy, it is inherently not nil. This can be simplified.

✨ Suggested simplification
-    `@callable.call`(event, data) if `@callable` && !@callable.nil?
+    `@callable.call`(event, data) if `@callable`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/context_event_logger_callback.rb` at line 11, Replace the redundant
nil-check in the call site: instead of "@callable.call(event, data) if `@callable`
&& !@callable.nil?" simplify to either "if `@callable`" or use the safe navigation
operator "@callable&.call(event, data)". Update the line where `@callable` is
invoked so only one truthiness check is performed (reference: the
"@callable.call(event, data)" invocation and the "@callable" instance variable).
lib/json_expr/operators/match_operator.rb (1)

23-33: Prefer regexp-level timeout when available, with fallback for older Ruby.

Using Timeout.timeout around regex matching raises asynchronous exceptions unpredictably. Regexp-native timeout, available in Ruby 3.2+, is safer. Since this repository supports Ruby 2.7, the fallback to Timeout.timeout remains necessary.

The proposed refactor below uses runtime detection for compatibility, though caching the check result would avoid repeated inspection:

♻️ Proposed compatibility-safe refactor
-    begin
-      Timeout.timeout(MATCH_TIMEOUT) do
-        Regexp.new(pattern).match(text)
-      end
+    begin
+      supports_regexp_timeout = Regexp.method(:new).parameters.any? { |_, name| name == :timeout }
+
+      if supports_regexp_timeout
+        Regexp.new(pattern, timeout: MATCH_TIMEOUT).match(text)
+      else
+        Timeout.timeout(MATCH_TIMEOUT) { Regexp.new(pattern).match(text) }
+      end
     rescue Timeout::Error
       warn("Regex match timeout: pattern=#{pattern[0..50].inspect}...")
       nil
lib/context.rb (1)

632-632: Minor: unnecessary ||= in initialisation.

@exposures ||= [] uses conditional assignment, but since this is fresh initialisation, a simple assignment @exposures = [] would be clearer and consistent with the other initialisations in this method.

Proposed fix
-    `@exposures` ||= []
+    `@exposures` = []
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/context.rb` at line 632, Replace the conditional assignment for the
instance variable `@exposures` with a direct assignment to match the other
initialisations in the same method: change the initialization from using ||= to
a simple = assignment so `@exposures` is explicitly set to an empty array during
context setup (look for the line setting `@exposures` and the surrounding
initialisation block in the same method).
spec/http_retry_spec.rb (1)

8-15: Remove the unused client setup in this example.

Line 12 currently adds setup noise without contributing to the assertion.

💡 Proposed fix
     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
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/http_retry_spec.rb` around lines 8 - 15, The test creates an unused
client variable which adds noise; remove the unnecessary call to
DefaultHttpClient.create(config) (the line assigning to client) from the example
so the spec only sets config via DefaultHttpClientConfig.create and asserts
config.max_retries equals 3; references: DefaultHttpClient.create,
DefaultHttpClientConfig.create, and the local variable client.
spec/concurrency_spec.rb (1)

67-72: Trim redundant begin wrappers in thread blocks.

These begin ... rescue wrappers are unnecessary in block scope and add noise; you can keep the same rescue behaviour with less nesting.

Also applies to: 113-117, 161-170, 206-210, 231-236

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/concurrency_spec.rb` around lines 67 - 72, Remove the redundant
begin...end wrappers inside the thread blocks that call
context.treatment("exp_test_ab"); instead write the rescue directly on the block
(thread do ... rescue StandardError => e ... end) so behaviour is identical but
nesting is reduced—keep mutex.synchronize { results << result } and
mutex.synchronize { errors << e } unchanged; apply the same change for the other
thread blocks that use context.treatment, mutex, results and errors in this
spec.
spec/context_spec.rb (1)

1364-1364: Remove unused local original_experiments.

Line 1364 assigns original_experiments but never uses it.

♻️ Tiny cleanup
-      original_experiments = context.experiments.dup
-
       context.refresh
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/context_spec.rb` at line 1364, Remove the unused local variable
assignment by deleting the line that sets original_experiments =
context.experiments.dup; locate the assignment by the symbol
original_experiments and the call to context.experiments.dup in
spec/context_spec.rb and remove it to clean up the unused local without changing
any other behavior.
README.md (1)

14-14: Consider removing shell prompts in command blocks.

Line 14 and Line 20 use $ prefixes, which triggers MD014 and is usually avoided in copy/paste snippets.

Also applies to: 20-20

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` at line 14, Remove the leading shell prompt from the command
examples so they are plain copy/paste-friendly; replace occurrences of "$ bundle
add absmartly-sdk" and the other "$ ..." command in the README with the command
text without the "$" prefix (e.g., "bundle add absmartly-sdk") in the code
blocks to resolve MD014 and improve usability.
spec/a_b_smartly_named_params_spec.rb (1)

37-57: Default timeout/retries tests do not assert the defaults they name.

Line 37 and Line 48 currently pass even if defaults regress, because they only check sdk is non-nil.

💡 Suggested test tightening
   it "uses default timeout of 3000ms" do
+    captured_config = nil
+    allow(Client).to receive(:create) do |cfg, *_|
+      captured_config = cfg
+      instance_double(Client)
+    end
+
     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(captured_config.connect_timeout).to eq(3.0)
+    expect(captured_config.connection_request_timeout).to eq(3.0)
   end
 
   it "uses default retries of 5" do
+    captured_config = nil
+    allow(Client).to receive(:create) do |cfg, *_|
+      captured_config = cfg
+      instance_double(Client)
+    end
+
     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(captured_config.max_retries).to eq(5)
   end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/a_b_smartly_named_params_spec.rb` around lines 37 - 57, The tests
currently only assert sdk is non-nil but should verify the actual default
values; update the two examples that call ABSmartly.new to replace the
expect(sdk).not_to be_nil with assertions that the instance uses the expected
defaults (e.g. expect(sdk.options[:timeout]).to eq(3000) for the timeout test
and expect(sdk.options[:retries]).to eq(5) for the retries test), locating the
instance created via ABSmartly.new (variable sdk) and adjusting the accessor
(options, config or accessor methods like timeout/retries) to match your SDK
internals.
spec/create_context_async_spec.rb (1)

130-143: The pre-ready test leaves a long-running worker thread behind.

Line 132 uses a 5-second delay and the example exits without draining that async fetch. This can slow or destabilise the suite.

💡 Suggested adjustment
-      config.client = slow_client_mock(delay: 5)
+      config.client = slow_client_mock(delay: 0.2)
@@
       expect { context.treatment("exp_test_ab") }.to raise_error(
         IllegalStateException, "ABSmartly Context is not yet ready"
       )
+
+      # Drain async worker for test hygiene
+      context.wait_until_ready(1)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/create_context_async_spec.rb` around lines 130 - 143, The test leaves a
long-running worker from the slow_client_mock( delay: 5 ) behind; change the
mock to use a tiny delay (e.g. delay: 0.01) or replace with a
non-blocking/faster stub so create_context_async (via ABSmartly.create and
sdk.create_context_async) doesn't spawn a long-lived fetch, and/or ensure the
background worker is drained by calling the SDK shutdown/cleanup (e.g.
sdk.shutdown or equivalent) after the expectation so the test process doesn't
retain the async thread.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/a_b_smartly.rb`:
- Around line 89-93: Replace the permissive .to_i/.to_f coercions with strict
Integer()/Float() conversions for parameters so invalid strings raise
immediately: validate timeout using Float(timeout) and ensure it is > 0 (raise
ArgumentError with a clear message on failure), validate retries using
Integer(retries) and ensure it is >= 0 (raise ArgumentError on failure), and
when assigning to client_config.max_retries use Integer(retries) (and similarly
use Float(timeout) where the numeric timeout is forwarded) so only real numeric
values are accepted and propagated to Faraday.
- Around line 127-130: The background thread that calls
data_provider.context_data and then context.set_data must rescue exceptions so
wait_until_ready() isn't left blocked; wrap the Thread.new body in a
begin/rescue, catch any exception from data_provider.context_data,
construct/passthrough a failure object (or the exception) to context.set_data so
that context.set_data_failed runs and `@ready_condvar` is broadcast, and re-use
the existing symbols: Thread.new, data_provider.context_data, context.set_data,
set_data_failed, wait_until_ready, and `@ready_condvar`.

In `@lib/audience_matcher.rb`:
- Around line 38-39: The rescue handler in audience_matcher.rb can raise if
e.backtrace is nil; update the warn call that logs the exception (the rescue
block using "rescue StandardError => e" and the warn("Audience evaluation
failed: #{e.class} - #{e.message}\n#{e.backtrace.first(5).join("\n")}")
invocation) to normalize the backtrace with Array(e.backtrace) before calling
first(5).join so the log is nil-safe and won't raise a NoMethodError.

In `@lib/client.rb`:
- Around line 55-57: Replace raw response-body usage in the error path: instead
of creating Exception.new(`@promise.body`) and logging warn("Failed to fetch
context data: #{`@promise.body`}"), build the exception and log using
non-sensitive metadata (e.g. `@promise.status`, `@promise.code`, or `@promise.reason`)
and a sanitized/truncated body snippet if required (e.g. first N chars with
sensitive patterns redacted). Update the two locations that set `@exception` and
call warn (the block using `@promise` shown and the similar block around lines
69-72) to use Exception.new with a safe message and warn with status/code plus a
short, redacted/truncated body preview rather than the full `@promise.body`.

In `@lib/context.rb`:
- Line 610: The `@index` instance variable is initialized as an Array but used as
a Hash elsewhere (e.g., assign_data and accesses like `@index`[experiment.name]);
change the initialization from [] to {} so `@index` is consistently a Hash,
ensuring code paths guarded by check_ready? continue to work with the correct
type (update the declaration of `@index` to use {}).

In `@lib/default_audience_deserializer.rb`:
- Line 9: Replace the inclusive-range slice used in the JSON.parse call
(currently JSON.parse(bytes[offset..length], ...)) with the start/length form to
match the other deserializer; i.e., change the slice to bytes[offset, length] so
both deserializers use the same semantics when parsing the JSON payload.

In `@lib/json/experiment.rb`:
- Around line 47-52: The method assign_to_klass uses a method-level `return`
inside arr.map which prematurely exits the method; replace the `return item if
item.is_a?(klass)` with a non-return expression (e.g., yield the item back to
the map by using `item` or `next item`) so the block always produces values for
mapping, keep the `klass.new(*item.values)` branch as-is, and ensure the final
`.compact` call still returns an array of instances; reference arr.map,
item.is_a?(klass), klass.new(*item.values), and assign_to_klass when making the
change.

In `@README.md`:
- Around line 351-366: The example CustomEventLogger#handle_event assumes event
payloads are Hashes (using data[:...]) but the SDK provides typed objects (e.g.,
Exposure, GoalAchievement, ContextData), which will raise NoMethodError; update
the example to access attributes on the objects (e.g., use data.name or
data.experiments instead of data[:name]/data[:experiments], and call length on
the returned array where needed) for the cases handling EVENT_TYPE::EXPOSURE,
EVENT_TYPE::GOAL, EVENT_TYPE::PUBLISH, EVENT_TYPE::READY, and
EVENT_TYPE::REFRESH so the code uses object attribute access consistent with the
payload types.

---

Outside diff comments:
In `@lib/json/experiment.rb`:
- Around line 30-39: The conversion branches are using the raw name variable so
keys like "applications" or :custom_field_values bypass class conversion; modify
the branch conditions to check the normalized key used by ALLOWED_KEYS (e.g.,
use key_str or the normalized symbol instead of name) so that
assign_to_klass(ExperimentApplication, value),
assign_to_klass(ExperimentVariant, value) and assign_to_klass(CustomFieldValue,
value) run for all equivalent key forms; update the if/elsif/elsif to compare
the normalized key (the same normalization ALLOWED_KEYS expects) before setting
`@applications`, `@variants`, or `@custom_field_values` and keep the fallback
instance_variable_set as-is.

---

Nitpick comments:
In `@lib/context_event_logger_callback.rb`:
- Line 11: Replace the redundant nil-check in the call site: instead of
"@callable.call(event, data) if `@callable` && !@callable.nil?" simplify to either
"if `@callable`" or use the safe navigation operator "@callable&.call(event,
data)". Update the line where `@callable` is invoked so only one truthiness check
is performed (reference: the "@callable.call(event, data)" invocation and the
"@callable" instance variable).

In `@lib/context.rb`:
- Line 632: Replace the conditional assignment for the instance variable
`@exposures` with a direct assignment to match the other initialisations in the
same method: change the initialization from using ||= to a simple = assignment
so `@exposures` is explicitly set to an empty array during context setup (look for
the line setting `@exposures` and the surrounding initialisation block in the same
method).

In `@README.md`:
- Line 14: Remove the leading shell prompt from the command examples so they are
plain copy/paste-friendly; replace occurrences of "$ bundle add absmartly-sdk"
and the other "$ ..." command in the README with the command text without the
"$" prefix (e.g., "bundle add absmartly-sdk") in the code blocks to resolve
MD014 and improve usability.

In `@spec/a_b_smartly_named_params_spec.rb`:
- Around line 37-57: The tests currently only assert sdk is non-nil but should
verify the actual default values; update the two examples that call
ABSmartly.new to replace the expect(sdk).not_to be_nil with assertions that the
instance uses the expected defaults (e.g. expect(sdk.options[:timeout]).to
eq(3000) for the timeout test and expect(sdk.options[:retries]).to eq(5) for the
retries test), locating the instance created via ABSmartly.new (variable sdk)
and adjusting the accessor (options, config or accessor methods like
timeout/retries) to match your SDK internals.

In `@spec/concurrency_spec.rb`:
- Around line 67-72: Remove the redundant begin...end wrappers inside the thread
blocks that call context.treatment("exp_test_ab"); instead write the rescue
directly on the block (thread do ... rescue StandardError => e ... end) so
behaviour is identical but nesting is reduced—keep mutex.synchronize { results
<< result } and mutex.synchronize { errors << e } unchanged; apply the same
change for the other thread blocks that use context.treatment, mutex, results
and errors in this spec.

In `@spec/context_spec.rb`:
- Line 1364: Remove the unused local variable assignment by deleting the line
that sets original_experiments = context.experiments.dup; locate the assignment
by the symbol original_experiments and the call to context.experiments.dup in
spec/context_spec.rb and remove it to clean up the unused local without changing
any other behavior.

In `@spec/create_context_async_spec.rb`:
- Around line 130-143: The test leaves a long-running worker from the
slow_client_mock( delay: 5 ) behind; change the mock to use a tiny delay (e.g.
delay: 0.01) or replace with a non-blocking/faster stub so create_context_async
(via ABSmartly.create and sdk.create_context_async) doesn't spawn a long-lived
fetch, and/or ensure the background worker is drained by calling the SDK
shutdown/cleanup (e.g. sdk.shutdown or equivalent) after the expectation so the
test process doesn't retain the async thread.

In `@spec/http_retry_spec.rb`:
- Around line 8-15: The test creates an unused client variable which adds noise;
remove the unnecessary call to DefaultHttpClient.create(config) (the line
assigning to client) from the example so the spec only sets config via
DefaultHttpClientConfig.create and asserts config.max_retries equals 3;
references: DefaultHttpClient.create, DefaultHttpClientConfig.create, and the
local variable client.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0fef1d1 and f7c057b.

⛔ Files ignored due to path filters (1)
  • Gemfile.lock is excluded by !**/*.lock
📒 Files selected for processing (35)
  • .gitignore
  • .ruby-version
  • CHANGELOG.md
  • README.md
  • absmartly.gemspec
  • example/example.rb
  • lib/a_b_smartly.rb
  • lib/a_b_smartly_config.rb
  • lib/absmartly.rb
  • lib/audience_matcher.rb
  • lib/client.rb
  • lib/context.rb
  • lib/context_event_logger_callback.rb
  • lib/default_audience_deserializer.rb
  • lib/default_context_data_deserializer.rb
  • lib/default_variable_parser.rb
  • lib/json/experiment.rb
  • lib/json_expr/expr_evaluator.rb
  • lib/json_expr/operators/and_combinator.rb
  • lib/json_expr/operators/equals_operator.rb
  • lib/json_expr/operators/in_operator.rb
  • lib/json_expr/operators/match_operator.rb
  • lib/json_expr/operators/or_combinator.rb
  • lib/scheduled_thread_pool_executor.rb
  • lib/type_utils.rb
  • spec/a_b_smartly_named_params_spec.rb
  • spec/backwards_compatibility_spec.rb
  • spec/concurrency_spec.rb
  • spec/context_spec.rb
  • spec/create_context_async_spec.rb
  • spec/default_variable_parser_spec.rb
  • spec/hashing_spec.rb
  • spec/http_retry_spec.rb
  • spec/json_expr/operators/equals_operator_spec.rb
  • spec/json_expr/operators/in_operator_spec.rb

Comment thread lib/a_b_smartly.rb
Comment thread lib/a_b_smartly.rb
Comment thread lib/audience_matcher.rb Outdated
Comment thread lib/client.rb
Comment thread lib/context.rb Outdated
Comment thread lib/default_audience_deserializer.rb Outdated
Comment thread lib/json/experiment.rb
Comment thread README.md

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
README.md (1)

351-365: ⚠️ Potential issue | 🟡 Minor

Use object accessors in CustomEventLogger.

data is documented here as typed event payloads, so data[:name] and data[:experiments] will raise NoMethodError in this example. Use attribute accessors instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 351 - 365, The example CustomEventLogger#handle_event
uses hash-style access (data[:name], data[:experiments], data.length) which will
raise NoMethodError because event payloads expose attributes, not hash keys;
update handle_event in CustomEventLogger to use attribute accessors on the
payload (e.g., data.name instead of data[:name], data.experiments instead of
data[:experiments], use data.events.size or data.experiments.size as
appropriate, and for errors use data.message or data.to_s) for the
EVENT_TYPE::EXPOSURE, ::GOAL, ::ERROR, ::PUBLISH, ::READY and ::REFRESH branches
so you access the typed payload properties correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@README.md`:
- Around line 13-20: Remove the leading shell prompt from the two install
command snippets in the README: replace "`$ bundle add absmartly-sdk`" and "`$
gem install absmartly-sdk`" code blocks with the plain commands "bundle add
absmartly-sdk" and "gem install absmartly-sdk" so they are copy/paste-friendly
and satisfy markdownlint; update the two fenced code blocks containing those
commands accordingly.
- Around line 516-530: The middleware example's call(env) method may skip
context.close if `@app.call`(env) raises; wrap the call and close in an ensure
block so that context.close is always executed: create context via
Absmartly.create_context(context_config), assign it to env['absmartly.context'],
invoke `@app.call`(env) inside begin/rescue/ensure (or ensure only) and call
context.close in the ensure section to guarantee cleanup and final publish even
on exceptions.

---

Duplicate comments:
In `@README.md`:
- Around line 351-365: The example CustomEventLogger#handle_event uses
hash-style access (data[:name], data[:experiments], data.length) which will
raise NoMethodError because event payloads expose attributes, not hash keys;
update handle_event in CustomEventLogger to use attribute accessors on the
payload (e.g., data.name instead of data[:name], data.experiments instead of
data[:experiments], use data.events.size or data.experiments.size as
appropriate, and for errors use data.message or data.to_s) for the
EVENT_TYPE::EXPOSURE, ::GOAL, ::ERROR, ::PUBLISH, ::READY and ::REFRESH branches
so you access the typed payload properties correctly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 03ad1922-6b80-474c-8a99-1837a3e91ef6

📥 Commits

Reviewing files that changed from the base of the PR and between f7c057b and 1b97de5.

📒 Files selected for processing (1)
  • README.md

Comment thread README.md Outdated
Comment thread README.md Outdated
joalves added 11 commits March 15, 2026 16:23
Matches JS SDK behavior where override() and overrides() do not check
if the context is finalized. Allows setting overrides after close.
Matches JS SDK behavior where _indexVariables maps each variable key
to an array of Experiment objects. Each key in variable_keys now
returns [experiment_name, ...] instead of a single string, supporting
cases where multiple experiments share a variable key.
Events are only cleared from the queue after a successful publish.
On failure, the queue is left intact so events can be retried.
Matches JS SDK behavior where pending/exposures/goals are cleared
in the .then() success handler, not before the publish call.
…_field_type

- Add finalize, finalized?, verify finalizing? works
- Rename custom_field_type to custom_field_value_type, keep old as alias
- Standardize error messages: use 'ABsmartly', add trailing periods, include unit type in unit errors
…ady or closed

Read methods (treatment, peek_treatment, peek, variable_value, peek_variable_value,
variable_keys, experiments, custom_field_keys, custom_field_value, custom_field_value_type)
now return safe defaults (0, default_value, {}, [], nil) and log via event logger
instead of raising exceptions when context is not ready or closed.

Write methods (set_unit, set_attribute, track, publish) continue to raise.
…melCase ivar

- Fix #16: Store @index and @index_variables keys as symbols at assignment time, removing per-call transform_keys
- Fix #17: Store @Units keys as symbols, enabling consistent lookup regardless of string/symbol input
- Fix #20: Rename @experimentCustomFieldValues to @experiment_custom_field_values
- Update context_spec to expect symbol key in @Units after set_unit

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
lib/context.rb (2)

301-311: ⚠️ Potential issue | 🟠 Major

Unprotected queue state mutations create race condition between track and flush.

The track method mutates @pending_count and @achievements without synchronisation, whilst flush reads and resets these same fields without synchronisation. Although @queue_mutex exists and is used in queue_exposure, the track and flush methods bypass it entirely, allowing concurrent calls to lose increments, lose events, or clear freshly queued items.

Wrap the queue state mutations in both methods with @queue_mutex.synchronize:

Changes required

In track (lines 306–308):

-    `@pending_count` += 1
-    `@achievements.push`(achievement)
-    log_event(ContextEventLogger::EVENT_TYPE::GOAL, achievement)
+    `@queue_mutex.synchronize` do
+      `@pending_count` += 1
+      `@achievements.push`(achievement)
+      log_event(ContextEventLogger::EVENT_TYPE::GOAL, achievement)
+    end

In flush (lines 362–365 and 368–399):

   def flush
     if `@failed`
-      `@exposures` = []
-      `@achievements` = []
-      `@pending_count` = 0
+      `@queue_mutex.synchronize` do
+        `@exposures` = []
+        `@achievements` = []
+        `@pending_count` = 0
+      end
       return `@data_failed`
     end

-    if `@pending_count` > 0
-      exposures = `@exposures.dup`
-      achievements = `@achievements.dup`
-      event_count = `@pending_count`
+    `@queue_mutex.synchronize` do
+      return nil unless `@pending_count` > 0
+      exposures = `@exposures.dup`
+      achievements = `@achievements.dup`
+      event_count = `@pending_count`
       # ... rest of publishing logic ...
+      `@exposures` = []
+      `@achievements` = []
+      `@pending_count` = 0
+    end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/context.rb` around lines 301 - 311, The track and flush methods mutate
and read shared queue state (`@pending_count` and `@achievements`) without locking,
creating a race; wrap the mutations and reads in `@queue_mutex.synchronize` to
protect them: in method track (the block that increments `@pending_count`, pushes
to `@achievements` and calls log_event) enclose those operations in
`@queue_mutex.synchronize`, and in method flush (the code that reads/reset
`@pending_count` and swaps or clears `@achievements` and any code that constructs
the batch for sending) also enclose the read-reset/swap logic in
`@queue_mutex.synchronize`; keep existing queue_exposure usage as-is and ensure
the synchronized blocks are minimal and only cover the shared-state access.

456-555: ⚠️ Potential issue | 🔴 Critical

@assignment_cache lacks thread-safe access; concurrent treatment calls create duplicate exposures.

The assignment method (lines 453–552) reads @assignment_cache at line 454 and writes at line 550 without synchronisation. When multiple threads call treatment for the same experiment concurrently, both can read a nil cache entry, create separate Assignment instances, and store them separately—resulting in duplicate exposures.

Add @assignment_mutex to synchronise cache access:

Proposed fix

Initialize the mutex in init_common (around line 690):

      `@ready_mutex` = Mutex.new
      `@ready_condvar` = ConditionVariable.new
      `@queue_mutex` = Mutex.new
+     `@assignment_mutex` = Mutex.new

Wrap cache access in the assignment method:

     def assignment(experiment_name)
-      assignment = `@assignment_cache`[experiment_name.to_s]
+      `@assignment_mutex.synchronize` do
+        assignment = `@assignment_cache`[experiment_name.to_s]
       exp_key = experiment_name.to_s.to_sym

       if !assignment.nil?
         # ... existing logic ...
       end

       # ... assignment creation logic ...

-      `@assignment_cache`[experiment_name.to_s] = assignment
-      assignment
+        `@assignment_cache`[experiment_name.to_s] = assignment
+        assignment
+      end
     end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/context.rb` around lines 456 - 555, The assignment method reads and
writes `@assignment_cache` without synchronization causing duplicate Assignment
instances under concurrent treatment calls; initialize a Mutex (e.g.
`@assignment_mutex`) in init_common and use it to protect cache access in
assignment: synchronize to read the cached entry (exp_key), if present return
it; if absent compute the Assignment (the logic that builds Assignment.new, sets
variant/eligible/etc.), then synchronize again to check the cache a second time
and store/return the cached assignment (double-checked locking). Reference the
assignment method, `@assignment_cache`, `@overrides`, `@cassignments`, and the
Assignment.new creation/assignment code when applying the changes.
♻️ Duplicate comments (1)
lib/a_b_smartly.rb (1)

89-103: ⚠️ Potential issue | 🟠 Major

Use strict numeric coercion for timeout/retries before validation.

At Line 92 and Line 93, .to_i allows invalid values like "abc" to coerce to 0, which can pass/warp validation intent and then be propagated (Line 102).

💡 Proposed fix
-    timeout ||= 3000
-    retries ||= 5
-
-    raise ArgumentError.new("timeout must be a positive number") if timeout.to_i <= 0
-    raise ArgumentError.new("retries must be a non-negative number") if retries.to_i < 0
+    timeout = 3000 if timeout.nil?
+    retries = 5 if retries.nil?
+
+    begin
+      timeout = Float(timeout)
+    rescue ArgumentError, TypeError
+      raise ArgumentError.new("timeout must be a positive number")
+    end
+
+    begin
+      retries = Integer(retries)
+    rescue ArgumentError, TypeError
+      raise ArgumentError.new("retries must be a non-negative number")
+    end
+
+    raise ArgumentError.new("timeout must be a positive number") if timeout <= 0
+    raise ArgumentError.new("retries must be a non-negative number") if retries < 0
@@
-    client_config.connect_timeout = timeout.to_f / 1000.0
-    client_config.connection_request_timeout = timeout.to_f / 1000.0
+    client_config.connect_timeout = timeout / 1000.0
+    client_config.connection_request_timeout = timeout / 1000.0
     client_config.max_retries = retries
#!/bin/bash
# Verify permissive coercion is still used in constructor validation.
cat -n lib/a_b_smartly.rb | sed -n '83,105p'
rg -n --type=rb 'timeout\.to_i|retries\.to_i|max_retries\s*=' lib/a_b_smartly.rb
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/a_b_smartly.rb` around lines 89 - 103, The validation currently uses
timeout.to_i and retries.to_i which silently coerce invalid strings to 0; change
to strict numeric coercion by parsing timeout and retries with Integer/Float
(e.g., parse timeout into a float_ms = Float(timeout) rescue raise ArgumentError
and parse retries with Integer(retries) rescue raise ArgumentError), validate
the parsed values (timeout_ms > 0, retries >= 0), and then assign the parsed
values to client_config.connect_timeout / connection_request_timeout (use
parsed_timeout_ms / 1000.0) and client_config.max_retries (use parsed_retries)
so only valid numeric inputs reach ClientConfig.create and its attributes.
🧹 Nitpick comments (1)
spec/context_spec.rb (1)

1324-1326: Remove the unused original_experiments local.

Line 1325 assigns original_experiments but never reads it; dropping it will keep the spec intent clearer.

✂️ Suggested cleanup
-      original_experiments = context.experiments.dup
-
       context.refresh
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/context_spec.rb` around lines 1324 - 1326, The test assigns an unused
local original_experiments after creating the context; remove the entire
assignment (the variable original_experiments) so the spec reads only the
relevant setup (leave the create_context call and any uses of
context.experiments intact), ensuring no other assertions rely on
original_experiments.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@lib/context.rb`:
- Around line 301-311: The track and flush methods mutate and read shared queue
state (`@pending_count` and `@achievements`) without locking, creating a race; wrap
the mutations and reads in `@queue_mutex.synchronize` to protect them: in method
track (the block that increments `@pending_count`, pushes to `@achievements` and
calls log_event) enclose those operations in `@queue_mutex.synchronize`, and in
method flush (the code that reads/reset `@pending_count` and swaps or clears
`@achievements` and any code that constructs the batch for sending) also enclose
the read-reset/swap logic in `@queue_mutex.synchronize`; keep existing
queue_exposure usage as-is and ensure the synchronized blocks are minimal and
only cover the shared-state access.
- Around line 456-555: The assignment method reads and writes `@assignment_cache`
without synchronization causing duplicate Assignment instances under concurrent
treatment calls; initialize a Mutex (e.g. `@assignment_mutex`) in init_common and
use it to protect cache access in assignment: synchronize to read the cached
entry (exp_key), if present return it; if absent compute the Assignment (the
logic that builds Assignment.new, sets variant/eligible/etc.), then synchronize
again to check the cache a second time and store/return the cached assignment
(double-checked locking). Reference the assignment method, `@assignment_cache`,
`@overrides`, `@cassignments`, and the Assignment.new creation/assignment code when
applying the changes.

---

Duplicate comments:
In `@lib/a_b_smartly.rb`:
- Around line 89-103: The validation currently uses timeout.to_i and
retries.to_i which silently coerce invalid strings to 0; change to strict
numeric coercion by parsing timeout and retries with Integer/Float (e.g., parse
timeout into a float_ms = Float(timeout) rescue raise ArgumentError and parse
retries with Integer(retries) rescue raise ArgumentError), validate the parsed
values (timeout_ms > 0, retries >= 0), and then assign the parsed values to
client_config.connect_timeout / connection_request_timeout (use
parsed_timeout_ms / 1000.0) and client_config.max_retries (use parsed_retries)
so only valid numeric inputs reach ClientConfig.create and its attributes.

---

Nitpick comments:
In `@spec/context_spec.rb`:
- Around line 1324-1326: The test assigns an unused local original_experiments
after creating the context; remove the entire assignment (the variable
original_experiments) so the spec reads only the relevant setup (leave the
create_context call and any uses of context.experiments intact), ensuring no
other assertions rely on original_experiments.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d8626971-3f28-47c5-8c93-865a85d5c08e

📥 Commits

Reviewing files that changed from the base of the PR and between 1b97de5 and 573ec6f.

📒 Files selected for processing (11)
  • lib/a_b_smartly.rb
  • lib/audience_matcher.rb
  • lib/client.rb
  • lib/context.rb
  • lib/context_event_logger_callback.rb
  • lib/default_audience_deserializer.rb
  • lib/json_expr/operators/match_operator.rb
  • spec/concurrency_spec.rb
  • spec/context_spec.rb
  • spec/create_context_async_spec.rb
  • spec/fix_plan_spec.rb
🚧 Files skipped from review as they are similar to previous changes (5)
  • spec/create_context_async_spec.rb
  • lib/audience_matcher.rb
  • lib/client.rb
  • lib/context_event_logger_callback.rb
  • lib/default_audience_deserializer.rb

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/context.rb (1)

577-633: ⚠️ Potential issue | 🔴 Critical

Prevent partially initialised context data from becoming visible to readers.

assign_data mutates shared lookup tables in place. With async creation/refresh, readers can observe mixed old/new state during rebuild. Build new maps locally, then swap all state atomically.

Suggested fix
 def assign_data(data)
-  `@data` = data
-  `@index` = {}
-  `@index_variables` = {}
+  new_index = {}
+  new_index_variables = {}
+  new_context_custom_fields = {}

   if data && !data.experiments.nil? && !data.experiments.empty?
     data.experiments.each do |experiment|
       `@experiment_custom_field_values` = {}
@@
-              variables.keys.each do |key|
-                `@index_variables`[key] ||= []
-                `@index_variables`[key] << experiment_variables unless `@index_variables`[key].include?(experiment_variables)
+              variables.keys.each do |key|
+                new_index_variables[key] ||= []
+                new_index_variables[key] << experiment_variables unless new_index_variables[key].include?(experiment_variables)
               end
@@
-          `@index`[experiment.name.to_sym] = experiment_variables
-          `@context_custom_fields`[experiment.name] = `@experiment_custom_field_values`
+          new_index[experiment.name.to_sym] = experiment_variables
+          new_context_custom_fields[experiment.name] = `@experiment_custom_field_values`
         end
       end
     end
+
+  `@index` = new_index
+  `@index_variables` = new_index_variables
+  `@context_custom_fields` = new_context_custom_fields
+  `@data` = data
 end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/context.rb` around lines 577 - 633, assign_data currently mutates shared
instance maps (`@index`, `@index_variables`, `@context_custom_fields`,
`@experiment_custom_field_values`) in place which can expose partially-built
state; change assign_data to build local hashes (e.g., local_index,
local_index_variables, local_context_custom_fields) and local experiment-level
structures while iterating experiments and only assign these locals to the
instance variables (`@index` = local_index; `@index_variables` =
local_index_variables; `@context_custom_fields` = local_context_custom_fields)
once the entire new state is fully constructed to ensure an atomic swap and
avoid readers seeing mixed old/new state; keep per-experiment
ExperimentVariables creation and parsing logic but push results into the locals
instead of the shared instance fields during build.
♻️ Duplicate comments (1)
lib/context.rb (1)

658-661: ⚠️ Potential issue | 🟡 Minor

Initialise @index as a Hash for consistent type semantics.

experiment uses hash-style lookups, but init_common sets @index = []. This creates an inconsistent type before first data assignment.

Suggested fix
-  `@index` = []
+  `@index` = {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/context.rb` around lines 658 - 661, The `@index` instance variable is
initialized as an Array in init_common which conflicts with later hash-style
lookups (see experiment method); change the initialization in init_common
(method name: init_common) from an array to a Hash (initialize `@index` as an
empty hash) so `@index` has consistent hash semantics before any data is assigned.
🧹 Nitpick comments (1)
spec/fix_plan_spec.rb (1)

101-104: Strengthen the exposure assertion to catch false negatives.

<= 10 can still pass when no exposure is queued. Add a lower bound so the test validates that queueing actually happened.

Suggested fix
-  expect(exposures.length).to be <= 10
+  expect(exposures.length).to be_between(1, 10).inclusive
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/fix_plan_spec.rb` around lines 101 - 104, The test currently only
asserts an upper bound on exposures (expect(exposures.length).to be <= 10) which
can pass when nothing was queued; update the assertion to require a positive
lower bound as well. In the spec/fix_plan_spec.rb example block where exposures
is retrieved from context.instance_variable_get(:`@exposures`), replace the single
upper-bound assertion with a range check such as expect(exposures.length).to
be_between(1, 10).inclusive (or two assertions expect(...).to be >= 1 and
expect(...).to be <= 10) so the test verifies that at least one exposure was
queued and no more than 10.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/context.rb`:
- Around line 360-399: The flush method races with producers
(track/queue_exposure) because it snapshots and clears `@exposures`, `@achievements`
and `@pending_count` without synchronization; introduce a shared mutex (e.g.
`@mutex` = Mutex.new) and wrap the critical sections in flush where
exposures/achievements/ pending_count are read/cleared and similarly wrap the
code paths in track and queue_exposure that mutate those instance variables so
producers and flush acquire the same lock to avoid lost events; ensure you only
hold the lock for the minimal snapshot/clear operations (release before calling
external handlers like `@event_handler.publish`) and update references to
methods/vars: flush, track, queue_exposure, `@exposures`, `@achievements`,
`@pending_count`, `@event_handler.publish`.

In `@spec/fix_plan_spec.rb`:
- Around line 155-160: The test "does not use Timeout.timeout" currently
short-circuits when Timeout is undefined; change it so it always inspects the
source of the MatchOperator implementation instead of relying on
defined?(Timeout). Update the example in the spec (the it block named "does not
use Timeout.timeout") to unconditionally read the contents of the MatchOperator
source (match_operator.rb) and assert that it does not include the string
"Timeout.timeout" (e.g., expect(source).not_to include("Timeout.timeout")),
removing the defined?(Timeout) branch so the file content is always checked.

---

Outside diff comments:
In `@lib/context.rb`:
- Around line 577-633: assign_data currently mutates shared instance maps
(`@index`, `@index_variables`, `@context_custom_fields`,
`@experiment_custom_field_values`) in place which can expose partially-built
state; change assign_data to build local hashes (e.g., local_index,
local_index_variables, local_context_custom_fields) and local experiment-level
structures while iterating experiments and only assign these locals to the
instance variables (`@index` = local_index; `@index_variables` =
local_index_variables; `@context_custom_fields` = local_context_custom_fields)
once the entire new state is fully constructed to ensure an atomic swap and
avoid readers seeing mixed old/new state; keep per-experiment
ExperimentVariables creation and parsing logic but push results into the locals
instead of the shared instance fields during build.

---

Duplicate comments:
In `@lib/context.rb`:
- Around line 658-661: The `@index` instance variable is initialized as an Array
in init_common which conflicts with later hash-style lookups (see experiment
method); change the initialization in init_common (method name: init_common)
from an array to a Hash (initialize `@index` as an empty hash) so `@index` has
consistent hash semantics before any data is assigned.

---

Nitpick comments:
In `@spec/fix_plan_spec.rb`:
- Around line 101-104: The test currently only asserts an upper bound on
exposures (expect(exposures.length).to be <= 10) which can pass when nothing was
queued; update the assertion to require a positive lower bound as well. In the
spec/fix_plan_spec.rb example block where exposures is retrieved from
context.instance_variable_get(:`@exposures`), replace the single upper-bound
assertion with a range check such as expect(exposures.length).to be_between(1,
10).inclusive (or two assertions expect(...).to be >= 1 and expect(...).to be <=
10) so the test verifies that at least one exposure was queued and no more than
10.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 88140906-475c-4d24-b315-20a1aeec2800

📥 Commits

Reviewing files that changed from the base of the PR and between 573ec6f and e3ca7f2.

📒 Files selected for processing (2)
  • lib/context.rb
  • spec/fix_plan_spec.rb

Comment thread lib/context.rb Outdated
Comment thread spec/fix_plan_spec.rb

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
lib/a_b_smartly.rb (1)

93-103: ⚠️ Potential issue | 🟠 Major

Use Integer() and Float() for strict parameter coercion.

The validation on lines 93-94 uses .to_i, which allows non-numeric strings like "abc" to pass because "abc".to_i returns 0. The raw value is then forwarded to client_config.max_retries on line 103.

This was previously flagged and remains unaddressed.
,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/a_b_smartly.rb` around lines 93 - 103, The timeout and retries validation
uses .to_i/.to_f which silently accepts non-numeric strings; replace those
coercions with strict Integer(timeout) and Integer(retries) (rescuing
ArgumentError to raise a clear ArgumentError if coercion fails) and use
Float(timeout) for connect_timeout/connection_request_timeout; then assign the
validated integer to client_config.max_retries and the validated float (divided
by 1000.0) to client_config.connect_timeout and
client_config.connection_request_timeout so invalid inputs like "abc" will
trigger an ArgumentError instead of being treated as 0.
🧹 Nitpick comments (3)
lib/context_event_handler.rb (1)

3-6: Consider adding a runtime deprecation warning for consistency.

Unlike DefaultContextEventHandler, this alias doesn't emit a runtime deprecation warning. Users subclassing ContextEventHandler won't be notified of the deprecation unless they read the source code.

If runtime warnings are desired, you could use Ruby's deprecate_constant:

ContextEventHandler = ContextPublisher
deprecate_constant :ContextEventHandler

This is optional since the alias preserves full compatibility.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/context_event_handler.rb` around lines 3 - 6, The file creates an alias
ContextEventHandler = ContextPublisher but doesn't emit a runtime deprecation
warning; add a runtime deprecation for the legacy constant by invoking Ruby's
deprecate_constant on ContextEventHandler after the alias so users get a runtime
warning when referencing ContextEventHandler while preserving compatibility with
ContextPublisher.
lib/default_context_publisher.rb (1)

5-14: Consider using attr_reader instead of attr_accessor for client.

The client is assigned once during initialisation. Using attr_reader would prevent unintended reassignment and better express the immutable intent.

💡 Suggested change
 class DefaultContextPublisher < ContextPublisher
-  attr_accessor :client
+  attr_reader :client
 
   def initialize(client)
     `@client` = client
   end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/default_context_publisher.rb` around lines 5 - 14, Replace the mutable
accessor with a read-only accessor: change attr_accessor :client to attr_reader
:client in the DefaultContextPublisher class so the client is only set in
initialize and cannot be reassigned; verify no external code relies on a writer
for DefaultContextPublisher#client and, if any does, refactor those call sites
to avoid mutating the client or provide an explicit method to replace it with
clear intent.
lib/a_b_smartly.rb (1)

49-51: Minor style: Remove blank line after private and fix indentation.

RuboCop flags style issues with the access modifier layout.

💡 Suggested change
   private
-
-  def initialize_from_config(config)
+  def initialize_from_config(config)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/a_b_smartly.rb` around lines 49 - 51, Remove the blank line after the
access modifier `private` and adjust indentation so `def
initialize_from_config(config)` is indented to the same level as `private` (no
extra leading spaces), ensuring the `private` block and the
`initialize_from_config` method follow RuboCop's access modifier layout style.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@lib/a_b_smartly.rb`:
- Around line 93-103: The timeout and retries validation uses .to_i/.to_f which
silently accepts non-numeric strings; replace those coercions with strict
Integer(timeout) and Integer(retries) (rescuing ArgumentError to raise a clear
ArgumentError if coercion fails) and use Float(timeout) for
connect_timeout/connection_request_timeout; then assign the validated integer to
client_config.max_retries and the validated float (divided by 1000.0) to
client_config.connect_timeout and client_config.connection_request_timeout so
invalid inputs like "abc" will trigger an ArgumentError instead of being treated
as 0.

---

Nitpick comments:
In `@lib/a_b_smartly.rb`:
- Around line 49-51: Remove the blank line after the access modifier `private`
and adjust indentation so `def initialize_from_config(config)` is indented to
the same level as `private` (no extra leading spaces), ensuring the `private`
block and the `initialize_from_config` method follow RuboCop's access modifier
layout style.

In `@lib/context_event_handler.rb`:
- Around line 3-6: The file creates an alias ContextEventHandler =
ContextPublisher but doesn't emit a runtime deprecation warning; add a runtime
deprecation for the legacy constant by invoking Ruby's deprecate_constant on
ContextEventHandler after the alias so users get a runtime warning when
referencing ContextEventHandler while preserving compatibility with
ContextPublisher.

In `@lib/default_context_publisher.rb`:
- Around line 5-14: Replace the mutable accessor with a read-only accessor:
change attr_accessor :client to attr_reader :client in the
DefaultContextPublisher class so the client is only set in initialize and cannot
be reassigned; verify no external code relies on a writer for
DefaultContextPublisher#client and, if any does, refactor those call sites to
avoid mutating the client or provide an explicit method to replace it with clear
intent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: aaee8212-6a3b-43ef-9521-82fa4b41c6d3

📥 Commits

Reviewing files that changed from the base of the PR and between e3ca7f2 and 02edc75.

📒 Files selected for processing (5)
  • lib/a_b_smartly.rb
  • lib/context_event_handler.rb
  • lib/context_publisher.rb
  • lib/default_context_event_handler.rb
  • lib/default_context_publisher.rb

joalves added 4 commits March 18, 2026 13:29
- Use Integer()/Float() for strict parameter coercion instead of .to_i/.to_f to prevent non-numeric strings from passing validation
- Initialize @index as Hash to match its actual usage pattern
- Replace return with next inside map block to prevent early method exit
The IN operator is a CONTAINS check with haystack-first operand order
([haystack, needle]), matching the collector and production behavior. A
prior change on this branch had swapped the operands to needle-first,
diverging from the collector. Revert the operator and its tests to the
canonical haystack-first order.
…ndling)

A null operand short-circuits binary operators to null — eq(null, null) is
null, not true. This matches origin/main of every SDK and the collector. The
branch had introduced an eq override (or removed the base null-skip) that made
eq(null,null) true, diverging from the canonical behavior. Revert to the
null-skip behavior and align the operator tests.

@Pedro-Revez-Silva Pedro-Revez-Silva left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved per request. Good functional coverage and multi-Ruby CI are present, but audit gaps remain: HTTP tests mostly assert retry/config plumbing and response factories, not collector wire shape; RuboCop/static config exists but is not run in CI; and the 137/138 cross-SDK claim is only in PR metadata, not backed by harness evidence in this PR.

Local server + public SDK exercises real GET /context and PUT /context, asserting
the wire contract.
@joalves

joalves commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

@Pedro-Revez-Silva Addressed the HTTP wire-shape gap.

Real HTTP integration test: added `spec/local_server_integration_spec.rb` (commit `b2ccac2`), a stdlib `TCPServer` on an ephemeral port (WEBrick isn't bundled in Ruby 3.3). It drives the public SDK (`ABSmartly.new(endpoint, api_key:, ...) → create_context → wait_until_ready → track → publish`) so the real Faraday client makes an actual GET /context and PUT /context, and asserts the concrete collector wire shape (method/path/query and the publish headers + body), not just retry/config plumbing. CI already runs on `pull_request`.

@jervasion-absmartly

jervasion-absmartly commented Oct 20, 2026 •

Copy link
Copy Markdown

Review submitted

The automated review has been submitted. See the review for the verdict and any findings.

Head: 042711a20ff7 | Updated: 2026-10-20 22:47 UTC

@jervasion-absmartly jervasion-absmartly left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGES REQUESTED - concurrent publish and refresh can expose or duplicate event/data state

Reviewed head: 78f49e4031064dd53e4cb493e740b65e82572f24
Merge-base: 2b09db3d1ab5e7d735d523d93ca99533e97f0634 (the supplied base 0fef1d1e542b44163c6be2113763381bdb715c6e is an ancestor; review covers the complete supplied base..head range)

Intent and impact: this PR adds asynchronous context creation/readiness signalling, context refresh changes, and queue synchronization intended to preserve events under concurrent operations. The changed surfaces are public Context APIs and the collector publish path.

Findings

  • [P2] Make a publish snapshot single-owner before sending it — Context#publish is public and the new flush snapshots the queues under @queue_mutex, but releases that mutex before @event_handler.publish and does not mark the snapshot in flight. Two concurrent callers can therefore both copy the same @exposures/@achievements, both send identical PUT payloads, and both later shift the same prefix. This is a reachable chain: Context#publish -> two application threads -> flush lines 374-394 -> shared event handler -> duplicate collector events. The existing spec/concurrency_spec.rb exercises concurrent publish calls but only asserts that they do not raise, so it does not catch the duplicate payload. Preserve the invariant that each queued event is included in at most one successful publish (and remains queued after a failed publish), using the smallest ownership/serialization mechanism that satisfies that invariant.

  • [P2] Publish a fully built refresh state atomically — assign_data now resets and repopulates @index, @index_variables, and @context_custom_fields in place. refresh calls it without the @ready_mutex, while public readers such as treatment, variable_keys, and custom-field access can run concurrently. During a refresh, a reader can observe the new @data with only part of the new indexes, or the new index with stale/partially rebuilt companion maps, and compute a wrong treatment/variable/custom-field result. The reachable chain is Context#refresh -> assign_data lines 590-646 -> concurrent public reader -> mixed lookup state. Build the replacement maps off to the side and publish the coherent set only after construction, or otherwise ensure readers cannot observe the intermediate state; the acceptance criterion is that every reader sees either the complete old snapshot or the complete new snapshot.

Validation

  • Exact-head GitHub Actions CI: all six RSpec matrix jobs (Ruby 2.7, 3.0, 3.1, 3.2, 3.3, 3.4) completed successfully in run 39392977757.
  • Local syntax checks passed: ruby -c lib/context.rb, ruby -c lib/a_b_smartly.rb, ruby -c lib/client.rb, and ruby -c lib/json_expr/operators/match_operator.rb.
  • git diff --check passed.
  • Full local Bundler/RSpec execution was not available because the environment could not write to the system gem cache and the provided /tmp/opencode directory was root-owned; CI was used for the full suite.
  • No cloud queries, browser authentication, deployments, or PR source changes were used.

Passes (non-blocking): run: core runtime/API/concurrency/security; tests, CI, packaging, and end-to-end harnesses; cross-cutting simplicity; behaviour-bearing data and policy; comment hygiene and diff composition | not run: deployment, operations, and observability (no deployment manifests, release playbooks, service definitions, schema migrations, alerts, dashboards, runbooks, capacity constants, or new metrics/log/trace emission changed), shared-component blast radius and interaction complexity (no frontend source changed)

Comment hygiene (non-blocking): 26 comment / 2714 code lines, ratio ~1:104, 0 blocks flagged
threshold T=28; restatements counted: no

  • none flagged

Diff composition (non-blocking): every file is required for the change; none flagged

Simplifications (non-blocking): 1 candidate

  • nested duplicate if event_count > 0 guard in lib/context.rb, smaller alternative: remove the inner redundant guard while preserving the surrounding condition, LOC delta: -2
    Run a simplification pass locally before pushing: these candidates cost less to address now than as a further review round.

Comment thread lib/context.rb Outdated
Comment thread lib/context.rb
@jervasion-absmartly
jervasion-absmartly dismissed their stale review October 20, 2026 16:11

Round 2: publish ownership fixed and validated; async parsing readiness fixed. The earlier broader refresh-atomicity requirement was pre-existing and over-scoped. No prior code blocker remains; separate merge conflict is documented in the updated review.

@jervasion-absmartly jervasion-absmartly left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

COMMENT - prior code blockers cleared; merge conflicts prevent exact-head merge-readiness approval.

Round 2 assessment

Reviewed head a847fa78215227801e21bec469458bc4c155576e against base 0fef1d1e542b44163c6be2113763381bdb715c6e; merge-base / responsibility baseline 2b09db3d1ab5e7d735d523d93ca99533e97f0634. Correction to my previous metadata: the supplied base is not an ancestor of the head.

The follow-up serializes publish ownership and builds replacement context indexes locally. The cumulative PR changes Ruby compatibility, JSON expressions, public SDK convenience/async APIs, tests and documentation. No rollout flag is involved. Historical cross-SDK totals are author-reported, not independently rerun here.

Prior findings:

  • Concurrent publishing: fixed. The publish mutex covers snapshot creation, the request, and successful removal. An overlapping-publish harness sent all 20 goals exactly once across five callers; failed-publish retention and retry also passed.
  • Partial initialization: the new async path remains unready throughout parsing, with data published after the completed maps. A parser-blocking harness verified safe pre-ready reads and no premature exposure. Scope correction: concurrent refresh already rebuilt state in place at the merge-base. Requiring a globally atomic multi-map refresh/read transaction was not established as a PR-introduced obligation. Sequential instance-variable assignments are not an atomic transaction; I am not certifying that stronger property or retaining it as a blocker.

No verified code blocker survives. The supplied prior change-request review is being dismissed as addressed, with the scope correction above.

Remaining merge limitation

GitHub reports mergeable: false / CONFLICTING. Read-only git merge-tree --write-tree 0fef1d1e542b44163c6be2113763381bdb715c6e a847fa78215227801e21bec469458bc4c155576e independently reports a content conflict in lib/context.rb. Resolve it and validate the resulting head before treating the PR as ready to merge. This is not a CI deferral: exact-head CI has finished successfully.

Validation

  • Installed locked dependencies with BUNDLE_PATH=/workspace/review-gems BUNDLE_FROZEN=true bundle install --jobs 4, without changing the lockfile.
  • BUNDLE_PATH=/workspace/review-gems BUNDLE_FROZEN=true bundle exec rspec: 487 examples, 0 failures, including real local-HTTP fetch/publish, async creation, failures and concurrency.
  • Independent runtime/tests pass: 54 focused examples and 487 full-suite examples passed; the new publish uniqueness test fails when the previous flush implementation is restored in memory.
  • Primary-agent in-memory harness: async parsing/readiness, overlapping publish uniqueness, failed-publish retention and retry all passed.
  • Exact-head CI run 39403028894: all six Ruby 2.7–3.4 RSpec jobs completed successfully. CodeRabbit status is success with review paused, not evidence of a new code review.
  • git diff --check passed. RuboCop on lib/context.rb, spec/concurrency_spec.rb, and spec/fix_plan_spec.rb reported 13 style/unused-local offenses, not a clean lint result. None is introduced by this follow-up; these do not establish a functional blocker, and CI does not enforce RuboCop.

Passes (non-blocking)

Run: core runtime/API/security/data/error paths (SDK executable changes; independent remediation pass); tests/CI/packaging/end-to-end harnesses (new concurrency test and cumulative packaging/HTTP coverage); deployment/operations/observability (changed warning emissions in the cumulative diff); cross-cutting simplicity (mutex/local state and redundant-guard removal); comment hygiene and diff composition (cumulative diff and introduced-blob enumeration).
Not run: shared-component blast radius (no frontend source); behaviour-bearing data/policy (no runtime configuration, policy, schema or behavioural fixture change).

Comment hygiene (non-blocking)

29 comment / 2739 code lines, ratio ~1:94.
threshold T=28; restatements counted: no (10 × C < E).
Flagged restatement blocks: lib/context.rb:372–373, 407–408, 649; lib/context_publisher.rb:4; spec/local_server_integration_spec.rb:9–13, 94, 103, 111, 121. These describe adjacent implementation/test steps. Non-blocking census only; no promotion threshold is reached. Protocol/encoding constraints and Ruby magic comments are preserved.

Diff composition (non-blocking)

Every file is required for the change; none flagged. Introduced content writes were enumerated using the pinned policy helper, including intermediate history.

Simplifications (non-blocking)

None remaining from the prior review: the nested duplicate event-count guard was removed. No further concrete construct-removal candidate demonstrated.

No PR source changes, cloud queries, browser authentication or deployments were performed. Temporary review checkout and validation artifacts will be removed after publication.

@jervasion-absmartly jervasion-absmartly left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGES REQUESTED - round 3 still has two verified concurrency defects

Reviewed head: 293849d71f08fd8a70dc9bd7e474ad498fcd39de
Merge-base: 0fef1d1e542b44163c6be2113763381bdb715c6e

Intent and impact

The PR adds asynchronous context creation/readiness APIs, refresh/index rebuilding, event-queue synchronization, public SDK conveniences, JSON-expression fixes, and broad compatibility/documentation/test coverage. The round-2 publish-ownership remediation is present at this head; the PR is otherwise cleanly mergeable according to GitHub.

Findings

Two P2 findings are posted inline. Both are reachable from public Context APIs and need to be fixed before merge:

  1. Refresh still does not publish one coherent reader-visible state. assign_data constructs replacement maps locally, but assigns @index, @index_variables, @context_custom_fields, and @data as four separate instance-variable writes. Readers do not take a common snapshot/read lock. During refresh, a reader can combine the new experiment index with the old variable index (or vice versa), returning a default or queuing an exposure for a state that never existed as a complete context snapshot. This is a remediation follow-up to the prior refresh-state finding; the stable acceptance criterion is that every reader observes either the complete old state or the complete new state.

  2. Exposure logging runs while @queue_mutex is held. A configured event logger is user code; if its exposure callback calls public publish or track, that callback recursively acquires the non-reentrant queue mutex and raises ThreadError: deadlock; recursive locking. The base invoked this callback without the queue mutex, so this is a remediation regression from the queue synchronization fix. The acceptance criterion is that event callbacks cannot deadlock or fail solely because queue bookkeeping is synchronized.

Validation

  • Exact-head Actions: CI run 39455428270 completed successfully; both the pull-request and push runs for this exact SHA are successful. The readable commit status is CodeRabbit success (review paused), not treated as code validation.
  • git diff --check: passed.
  • Ruby syntax: ruby -c lib/context.rb and ruby -c lib/a_b_smartly.rb: passed.
  • Independent adversarial pass reproduced the callback deadlock and reproduced mixed refresh state with a controlled refresh/read interleaving.
  • Full local Bundler/RSpec execution was unavailable: bundle exec rspec reports the rspec executable is not installed, and the environment lacks the locked dependencies.
  • Introduced-content audit completed with the pinned helper; no credential, scratch artifact, or other unnecessary introduced content was found.
  • No PR source changes, deployments, cloud queries, or browser authentication were used.

Passes (non-blocking)

Run: core runtime/API/security/data/error paths (executable SDK and concurrency changes); tests/CI/packaging/end-to-end harnesses (large test additions and exact-head CI); deployment/operations/observability (changed warning/logging behavior); cross-cutting simplicity (mutexes and snapshot publication); comment hygiene and diff composition (cumulative diff and introduced-content audit) | not run: shared-component blast radius (no frontend source changed), behaviour-bearing data and policy (no runtime configuration, policy, schema, or behaviour fixture changed)

Comment hygiene (non-blocking): 29 comment / 2739 code lines, ratio ~1:94, 9 blocks flagged

threshold T=28; restatements counted: no (10 × C < E)

  • lib/context.rb:372-373, 407-408, 649: restates the synchronization/snapshot code
  • lib/context_publisher.rb:4: restates the alias
  • spec/local_server_integration_spec.rb:9-13, 94, 103, 111, 121: restate adjacent test setup/assertion steps
    None warrants promotion; the threshold is not reached.

Diff composition (non-blocking): every file is required for the change; none flagged

Simplifications (non-blocking): none

No prior review was dismissed: the supplied dismissible review ID list did not include an active CHANGES_REQUESTED review ID, so no dismissal was attempted.

Comment thread lib/context.rb Outdated
Comment thread lib/context.rb Outdated

@jervasion-absmartly jervasion-absmartly left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGES REQUESTED - round 4 leaves two verified reentrant/concurrency defects

Reviewed head: 69122e6842bd45315f9ebdd64b9e322a274bbf5d
Merge-base: 0fef1d1e542b44163c6be2113763381bdb715c6e

Intent and impact

The latest commit remediates the prior round's two blockers by introducing a State snapshot and moving exposure logger callbacks outside @queue_mutex. The cumulative PR changes public Context readiness, refresh, assignment, publishing, SDK configuration, JSON-expression behavior, and compatibility APIs. No rollout flag is involved; the changed Context APIs and collector path are public SDK surfaces.

Findings

Two P2 findings survive, both reachable from public Context APIs:

  1. Remediation regression: @data is still published separately from @state. The new State swap is atomic for derived maps, but assign_data assigns @state and then @data separately. ready?, experiments, custom_field_keys, and data read @data directly, while assignment and variable readers read @state. A refresh interleaving can therefore return new derived indexes with old data, or old derived indexes with new data; failure publication has the inverse ordering. The stable acceptance criterion from the prior round remains open: every reader must observe one complete old or complete new context state.

  2. Remediation regression: PUBLISH callbacks can deadlock on @publish_mutex. flush holds @publish_mutex while calling log_event(PUBLISH, event). The logger callback is user code, and a callback calling public publish attempts to acquire the same non-reentrant mutex again and deadlocks. The latest test covers only an EXPOSURE callback after @queue_mutex release, not reentrant PUBLISH callbacks. The acceptance criterion is that event callbacks cannot deadlock or fail solely because queue bookkeeping/publish serialization is synchronized.

The other prior blocker, exposure callbacks under @queue_mutex, is satisfied by the latest code and test. I did not dismiss review PRR_kwDOHzET7s8AAAABTStoDA because the prompt did not supply it as an exact dismissible ID for an active review.

Validation

  • Exact-head Actions runs 39475569363 (pull request) and 39475563233 (push), each completed successfully with all six Ruby 2.7–3.4 jobs passing.
  • Commit status for the exact head is CodeRabbit success / review paused; it is not treated as code validation.
  • git diff --check passed.
  • ruby -c lib/context.rb, ruby -c lib/a_b_smartly.rb, and ruby -c lib/json_expr/operators/match_operator.rb passed.
  • Independent adversarial analysis verified the remaining @state/@data split and the reentrant PUBLISH mutex path.
  • Full local RSpec could not be run because the checkout environment lacks the RSpec executable and locked dependencies; exact-head CI supplied the full-suite evidence.
  • Introduced-content audit completed with the pinned helper; no credential, scratch artifact, build output, or unnecessary file was found.
  • No PR source changes, deployments, cloud queries, or browser authentication were used.

Passes (non-blocking)

Run: core runtime/API/security/data/error paths (public SDK and concurrency changes); tests/CI/packaging/end-to-end harnesses (new reentrancy tests and exact-head CI); deployment/operations/observability (changed event/log callback behavior); cross-cutting simplicity (State snapshot and mutex lifecycle); comment hygiene and diff composition (cumulative diff and introduced-content audit) | not run: shared-component blast radius (no frontend source changed), behaviour-bearing data and policy (no runtime configuration, policy, schema, or behavior fixture changed)

Comment hygiene (non-blocking): 32 comment / 2779 code lines, ratio ~1:87, 11 blocks flagged

threshold T=28; restatements counted: no (10 × C < E)

  • lib/context.rb:13-14, 230, 378-379, 413-414, 656-656: restates adjacent synchronization/snapshot code
  • lib/context_publisher.rb:4: restates the alias
  • spec/local_server_integration_spec.rb:9-13, 94, 103, 111, 121: restate adjacent test setup/assertion steps
    None warrants promotion; the threshold is not reached.

Diff composition (non-blocking): every file is required for the change; none flagged

Simplifications (non-blocking): none

Convergence summary — review round 4

  • Unresolved original blockers: 0
  • Remediation regressions: 2
  • Independent PR-change findings: 0
  • Acceptance criteria still open: coherent State/data publication; reentrant event callbacks must not deadlock
  • Human adjudication requested: no — both defects are verified and actionable

Comment thread lib/context.rb
Comment thread lib/context.rb Outdated

@jervasion-absmartly jervasion-absmartly left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGES REQUESTED - round 5 still exposes reentrant lifecycle deadlocks and event loss

Reviewed head: 51703118b7ff748d9cd17f01f4e4ffe474ba115a
Merge-base: 0fef1d1e542b44163c6be2113763381bdb715c6e

Intent and impact

The latest commit moves all data-derived readers to State and adds an owned-mutex fast path intended to prevent nested PUBLISH callbacks from deadlocking. The cumulative PR changes public asynchronous readiness, refresh, assignment, publishing, event logging, SDK configuration, JSON-expression behavior, and compatibility APIs. The changed Context and event-logger APIs are public SDK surfaces; no rollout flag is involved.

Findings

Two P2 remediation regressions survive:

  1. READY callbacks can deadlock async readiness. set_data holds @ready_mutex while invoking the configured event logger for READY. A logger callback that calls the public wait_until_ready method re-enters the same non-reentrant mutex on the same thread and deadlocks, leaving async context creation stuck. The added callback coverage exercises exposure and PUBLISH events, but not READY callbacks. The acceptance criterion is that a user event callback cannot deadlock readiness or leave the context unable to become ready solely because readiness bookkeeping is synchronized.

  2. A nested close from a PUBLISH callback can strand events in a closed context. The new return nil if @publish_mutex.owned? avoids recursive publish deadlock, but close treats that no-op flush as success and sets @closed = true. If the PUBLISH callback queues an event and calls close, the outer publish removes only its original snapshot; the newly queued event remains pending, while subsequent publish calls reject the finalized context. This violates the documented close behavior that pending data is published. The acceptance criterion is that reentrant lifecycle calls cannot close a context while newly queued events remain permanently unsendable.

These are remediation regressions from the readiness/publish synchronization fixes. The prior @data/@state and direct PUBLISH deadlock findings are satisfied at this head. I did not dismiss any review: no exact dismissible CHANGES_REQUESTED review ID was supplied.

Validation

  • Exact-head Actions runs 39491876585 (pull request) and 39491869063 (push) completed successfully; all six Ruby 2.7–3.4 jobs passed.
  • Exact-head commit status is CodeRabbit success / review paused; it is not treated as code validation.
  • git diff --check passed.
  • ruby -c lib/context.rb, ruby -c lib/a_b_smartly.rb, and ruby -c lib/json_expr/operators/match_operator.rb passed.
  • Independent adversarial analysis verified the READY mutex re-entry path and the nested-close event-retention path from the current source.
  • Full local RSpec was unavailable because the checkout lacks the RSpec executable and locked dependencies; exact-head CI supplied the full-suite evidence.
  • Introduced-content audit completed with the pinned helper; no credential, scratch artifact, build output, or unnecessary file was found.
  • No PR source changes, deployments, cloud queries, or browser authentication were used.

Passes (non-blocking)

Run: core runtime/API/security/data/error paths (public Context lifecycle and event callbacks); tests/CI/packaging/end-to-end harnesses (new callback tests and exact-head CI); deployment/operations/observability (changed event/log callback behavior); cross-cutting simplicity (State snapshot and mutex ownership); comment hygiene and diff composition (cumulative diff and introduced-content audit) | not run: shared-component blast radius (no frontend source changed), behaviour-bearing data and policy (no runtime configuration, policy, schema, or behavior fixture changed)

Comment hygiene (non-blocking): 36 comment / 2822 code lines, ratio ~1:78, 10 blocks flagged

threshold T=29; restatements counted: no (10 × C < E)

  • lib/context.rb:13-14, 230, 378-382, 413-414, 656-656: restates adjacent synchronization/snapshot code
  • lib/context_publisher.rb:4: restates the alias
  • spec/local_server_integration_spec.rb:9-13, 94, 103, 111, 121: restate adjacent test setup/assertion steps
    None warrants promotion; the threshold is not reached.

Diff composition (non-blocking): every file is required for the change; none flagged

Simplifications (non-blocking): none

Convergence summary — review round 5

  • Unresolved original blockers: 0
  • Remediation regressions: 2
  • Independent PR-change findings: 0
  • Acceptance criteria still open: reentrant READY callbacks must not deadlock; reentrant close must not strand queued events
  • Human adjudication requested: yes — rounds 4 and 5 each replaced fixed blockers with new remediation regressions; please choose among fixing these lifecycle invariants, explicitly accepting the specific risks, or changing the callback/close contract.

Comment thread lib/context.rb Outdated
Comment thread lib/context.rb

@jervasion-absmartly jervasion-absmartly left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVED - Exact head is ready to merge; no verified blocker or material limitation remains.

Reviewed head: 042711a20ff7864cc992c1d7f95f0f96bc33a602
Merge-base: 0fef1d1e542b44163c6be2113763381bdb715c6e

The PR intends to bring the Ruby SDK to Ruby 3.3 compatibility and cross-SDK behavior, while adding async context creation and public API conveniences. The affected runtime surfaces are context readiness, immutable context-data publication, exposure/goal queueing and serialized publishing, callback reentrancy/close behavior, HTTP retry/publish handling, JSON-expression conversion, and the public SDK constructors and aliases. The PR reports 412/412 unit tests and 137/138 cross-SDK scenarios, with one documented async scenario skipped.

I re-read the cumulative merge-base-to-head changes, all available PR discussion and prior review threads, the current implementation, and exact-head CI. The final concurrency fixes preserve atomic state publication, invoke READY and EXPOSURE callbacks outside their respective mutexes, avoid nested publish deadlock, and defer close until an outer publish has completed and callback-queued events have been flushed. The adversarial pass found no verified surviving finding; a forced assignment-cache race was confirmed pre-existing at the base and is not attributed to this PR's changed behavior.

Validation:

  • Exact-head GitHub Actions CI: the latest CI run for this head completed successfully for Ruby 2.7, 3.0, 3.1, 3.2, 3.3, and 3.4; each job's RSpec step passed.
  • ruby -c lib/context.rb, ruby -c lib/a_b_smartly.rb, and ruby -c lib/client.rb: passed.
  • git diff --check: passed.
  • Introduced-content audit over every PR commit: completed successfully; no credential, scratch artifact, build output, or unnecessary introduced content was found.
  • Local Bundler/RSpec execution was attempted but could not install locked dependencies because the review environment's system gem cache is not writable. This is covered by the passing exact-head CI evidence above.

Passes (non-blocking): run: core runtime/API/security/data/error paths; tests, CI, packaging, and end-to-end harnesses; cross-cutting simplicity; comment hygiene and diff composition | not run: deployment, operations, and observability (no deployment manifests, release playbooks, service definitions, migrations, alerts, dashboards, runbooks, capacity constants, or metric/log/trace emission changed); shared-component blast radius and interaction complexity (no frontend source changed); behaviour-bearing data and policy (no runtime configuration, policy, generated contract/schema, or behavior-bearing fixture changed)

Comment hygiene (non-blocking): 38 comment / 2702 code lines, ratio ~1:71, 0 blocks flagged

  • none flagged
    threshold T=28; restatements counted: no (10 × C < E)

Diff composition (non-blocking): every file is required for the change; none flagged

Simplifications (non-blocking): none

Convergence summary — review round 6

  • Unresolved original blockers: 0
  • Remediation regressions: 0
  • Independent PR-change findings: 0
  • Acceptance criteria still open: none
  • Human adjudication requested: no

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants