Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions lib/schematic/rules_engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def initialize(logger: nil)
@alloc_fn = nil
@dealloc_fn = nil
@check_flag_fn = nil
@set_time_fn = nil
@get_result_json_fn = nil
@get_result_json_length_fn = nil
@get_version_key_fn = nil
Expand Down Expand Up @@ -47,6 +48,11 @@ def initialize!
@alloc_fn = export_func("alloc")
@dealloc_fn = export_func("dealloc")
@check_flag_fn = export_func("checkFlagCombined")
# Optional: feed the wasm the current wall-clock time before each check.
# The raw wasm32-unknown-unknown build has no clock under wasmtime
# (SCHY-471); without this, metric-period reset timestamps are omitted.
# Absent on older wasm — hence the safe-navigation call below.
@set_time_fn = @instance.export("setCurrentTimeMillis")&.to_func
@get_result_json_fn = export_func("getResultJson")
@get_result_json_length_fn = export_func("getResultJsonLength")
@get_version_key_fn = export_func("get_version_key_wasm")
Expand Down Expand Up @@ -80,6 +86,11 @@ def check_flag(flag, company = nil, user = nil)
ptr = @alloc_fn.call(json_bytes.bytesize)
begin
@memory.write(ptr, json_bytes)

# Supply the host's current time so the engine can compute
# metric-period reset timestamps (SCHY-471). No-op on older wasm.
@set_time_fn&.call((Time.now.to_f * 1000).to_i)

result_len = @check_flag_fn.call(ptr, json_bytes.bytesize)

raise "WASM checkFlagCombined returned error" if result_len.negative?
Expand Down
75 changes: 75 additions & 0 deletions test/rules_engine_clock_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# frozen_string_literal: true

require_relative "test_helper"
require "minitest/autorun"

# Regression for SCHY-471.
#
# A company-override entitlement rule whose metric condition uses a
# calendar/billing metric period drives the engine into the metric-period-reset
# code path, which needs a wall clock. On the raw wasm32-unknown-unknown build
# that path used to trap (+wasm unreachable+); the flag then wrongly fell back to
# its default value for a company that is legitimately entitled. The host now
# injects the current time via +setCurrentTimeMillis+ so this evaluates cleanly.
class RulesEngineClockTest < Minitest::Test
def setup
@engine = Schematic::RulesEngine.new
@engine.initialize!
# The wasm binary is fetched via scripts/download-wasm.sh and may be absent
# (or the wasmtime gem missing) in some environments — skip rather than fail.
skip "WASM rules engine unavailable (wasm binary or wasmtime gem missing)" unless @engine.initialized?
end

def flag
{
"id" => "flag1", "key" => "mcp-access", "account_id" => "acc_1",
"environment_id" => "env_1", "default_value" => false, "rules" => []
}
end

# Usage 40 < allocation 100, so the override grants the feature.
def entitled_company
{
"id" => "co_entitled", "account_id" => "acc_1", "environment_id" => "env_1",
"keys" => { "id" => "co_entitled" },
"metrics" => [{
"account_id" => "acc_1", "environment_id" => "env_1", "company_id" => "co_entitled",
"event_subtype" => "api-calls", "period" => "current_month",
"month_reset" => "billing_cycle", "value" => 40, "created_at" => "2023-01-01T00:00:00Z"
}],
"rules" => [{
"id" => "rule_override", "flag_id" => "flag1", "account_id" => "acc_1",
"environment_id" => "env_1", "name" => "Company Override",
"rule_type" => "company_override", "priority" => 0, "value" => true,
"condition_groups" => [],
"conditions" => [
{
"id" => "cond_company", "account_id" => "acc_1", "environment_id" => "env_1",
"condition_type" => "company", "operator" => "eq",
"resource_ids" => ["co_entitled"], "trait_value" => ""
},
{
"id" => "cond_metric", "account_id" => "acc_1", "environment_id" => "env_1",
"condition_type" => "metric", "operator" => "lt", "event_subtype" => "api-calls",
"metric_value" => 100, "metric_period" => "current_month",
"metric_period_month_reset" => "billing_cycle", "trait_value" => "100"
}
]
}]
}
end

def test_billing_metric_override_evaluates_without_trapping
result = @engine.check_flag(flag, entitled_company)

assert result[:value], "company override should grant the flag, not fall back to default false"
assert_match(/override/i, result[:reason].to_s)
end

def test_billing_metric_override_populates_reset_at
result = @engine.check_flag(flag, entitled_company)

refute_nil result[:feature_usage_reset_at],
"reset-at should be computed from the injected host time"
end
end