diff --git a/key-type-flow.md b/key-type-flow.md new file mode 100644 index 00000000..4afaac08 --- /dev/null +++ b/key-type-flow.md @@ -0,0 +1,250 @@ +# FDv2 Key Type Flow + +## Data Pipeline + +```mermaid +graph TD + subgraph "Streaming Protocol Layer" + A["SSE Stream\n(raw JSON)"] -->|"JSON.parse(symbolize_names: true)"| B["Parsed Hash\nhash keys = SYMBOL\nhash values = STRING\n{key: 'my-flag', version: 1}"] + B -->|"PutObject.from_h\nkey.to_sym"| C["PutObject / DeleteObject\n@key = SYMBOL\n:my_flag"] + end + + subgraph "ChangeSet Layer" + C -->|"add_put / add_delete"| D["Change\n@key = SYMBOL\n:my_flag"] + D --> E["ChangeSet\nchanges = [Change...]"] + end + + subgraph "Store Layer" + E -->|"store.apply(change_set)"| F["changes_to_store_data\nHash keys = SYMBOL\nDELETE value key = .to_s ✅\n{FEATURES => {:my_flag => obj}}"] + + F -->|"memory_store.set_basis /\napply_delta"| G["InMemoryFeatureStoreV2\n@items keys = SYMBOL\n:my_flag"] + + F -->|"decode_collection →\nModel.deserialize"| H["FeatureFlag\n@key = STRING\n'my-flag'\n(extracted from data[:key])"] + end + + subgraph "Dependency Tracker" + F -->|"key.to_s ✅"| I["DependencyTracker\nfrom_what = {kind:, key: STRING}\n'my-flag'"] + H -->|"prereq.key / clause.values"| J["Dependencies\n{kind:, key: STRING}\n'prereq-flag'"] + I --- J + I -->|"add_affected_items\nlookup @to hash"| K["affected_items\n{kind:, key: STRING}\n'my-flag', 'prereq-flag'"] + end + + subgraph "Flag Change Notification" + K -->|"FlagChange.new(item[:key])"| L["FlagChange\n@key = STRING ✅\n'my-flag'"] + L -->|"broadcaster"| M["FlagValueChangeAdapter\n.to_s comparison ✅\n.to_s on eval_fn call ✅\n.to_s on emitted key ✅"] + end + + subgraph "Client API (reads)" + N["LDClient.variation('my-flag', ...)\nkey = STRING"] -->|"store.get(FEATURES, key)"| O["InMemoryFeatureStoreV2.get\nkey.to_sym for lookup"] + O --> G + end + + style I fill:#90EE90 + style K fill:#90EE90 + style J fill:#90EE90 + style L fill:#90EE90 + style M fill:#90EE90 + style F fill:#90EE90 +``` + +## Sequence Diagrams + +These show the key type at each handoff point, with `.to_s` conversions marked. + +**Legend:** Yellow background = key is a **Symbol**, Blue background = key is a **String**. + +### PUT flow (flag received from stream) + +```mermaid +sequenceDiagram + participant SSE as SSE Stream + participant Parse as JSON Parser + participant PO as PutObject + participant CS as ChangeSet + participant Store as Store + participant Mem as InMemoryStore + participant DT as DependencyTracker + participant BC as Broadcaster + participant Adapter as FlagValueChangeAdapter + + SSE->>Parse: raw JSON {"key":"my-flag", ...} + rect rgb(255, 248, 200) + Note over Parse: JSON.parse(symbolize_names: true)
hash = {key: "my-flag", ...}
hash keys = Symbol, values = String + end + + Parse->>PO: PutObject.from_h(hash) + rect rgb(255, 248, 200) + Note over PO: @key = hash[:key].to_sym
→ :my_flag (SYMBOL) + end + + PO->>CS: add_put(:my_flag, 1, obj) + rect rgb(255, 248, 200) + Note over CS: Change @key = :my_flag (SYMBOL) + end + + CS->>Store: apply(change_set) + rect rgb(255, 248, 200) + Note over Store: changes_to_store_data
hash key = :my_flag (SYMBOL)
value[:key] = "my-flag" (STRING, from obj) + end + + Store->>Mem: set_basis / apply_delta + rect rgb(255, 248, 200) + Note over Mem: @items[:my_flag] = obj
index key = SYMBOL + end + + Store->>DT: update_dependencies_from(kind, key.to_s, item) + rect rgb(200, 220, 255) + Note over DT: from_key = "my-flag" (STRING)
prereq.key = "my-flag" (STRING)
types match for hash lookups + end + + DT->>Store: affected_items {kind:, key: "my-flag"} + rect rgb(200, 220, 255) + Note over Store: item[:key] is already STRING + end + + Store->>BC: FlagChange.new("my-flag") + rect rgb(200, 220, 255) + Note over BC: @key = "my-flag" (STRING) + end + + BC->>Adapter: update(flag_change) + rect rgb(200, 220, 255) + Note over Adapter: flag_change.key.to_s == @flag_key.to_s
tolerates Symbol or String + end +``` + +### DELETE flow (flag deleted from stream) + +```mermaid +sequenceDiagram + participant SSE as SSE Stream + participant Parse as JSON Parser + participant DO as DeleteObject + participant CS as ChangeSet + participant Store as Store + participant Mem as InMemoryStore + participant DT as DependencyTracker + participant BC as Broadcaster + + SSE->>Parse: raw JSON {"key":"my-flag", "version":2} + rect rgb(255, 248, 200) + Note over Parse: hash = {key: "my-flag", version: 2} + end + + Parse->>DO: DeleteObject.from_h(hash) + rect rgb(255, 248, 200) + Note over DO: @key = :my_flag (SYMBOL) + end + + DO->>CS: add_delete(:my_flag, 2) + rect rgb(255, 248, 200) + Note over CS: Change @key = :my_flag (SYMBOL) + end + + CS->>Store: apply(change_set) + rect rgb(255, 248, 200) + Note over Store: changes_to_store_data
hash key = :my_flag (SYMBOL) + end + rect rgb(200, 220, 255) + Note over Store: value = {key: change.key.to_s, ...}
→ {key: "my-flag", ...} (.to_s conversion) + end + + Store->>Mem: apply_delta + rect rgb(255, 248, 200) + Note over Mem: @items[:my_flag] = deleted tombstone + end + + Store->>DT: update_dependencies_from(kind, "my-flag", item) + rect rgb(200, 220, 255) + Note over DT: deleted item → clears deps
key = "my-flag" (STRING) + end + + DT->>Store: affected_items {kind:, key: "my-flag"} + rect rgb(200, 220, 255) + Note over Store: key = "my-flag" (STRING) + end + + Store->>BC: FlagChange.new("my-flag") + rect rgb(200, 220, 255) + Note over BC: @key = "my-flag" (STRING) + end +``` + +### Dependency propagation (prerequisite change) + +```mermaid +sequenceDiagram + participant Store as Store + participant DT as DependencyTracker + participant BC as Broadcaster + participant User as User Listener + + rect rgb(200, 220, 255) + Note over Store: Initial state:
flag_b has prerequisite on flag_a
DT tracks: "flag_a" → {"flag_b"} (all STRING) + end + + Store->>DT: update_dependencies_from(FLAGS, "flag_a", new_flag_a) + rect rgb(200, 220, 255) + Note over DT: Refresh flag_a's own deps
key = "flag_a" (STRING) + end + + Store->>DT: add_affected_items(set, {kind: FLAGS, key: "flag_a"}) + rect rgb(200, 220, 255) + Note over DT: Walk graph:
1. "flag_a" directly changed
2. "flag_b" depends on "flag_a"
All keys are STRING + end + + DT->>Store: affected = {"flag_a", "flag_b"} + + Store->>BC: FlagChange.new("flag_a") + Store->>BC: FlagChange.new("flag_b") + + BC->>User: update(FlagChange) for each + rect rgb(200, 220, 255) + Note over User: .key is STRING + end +``` + +### Client read path (variation call) + +```mermaid +sequenceDiagram + participant App as Application + participant Client as LDClient + participant Store as Store + participant Mem as InMemoryStore + + App->>Client: variation("my-flag", context, default) + rect rgb(200, 220, 255) + Note over Client: key = "my-flag" (STRING) + end + + Client->>Store: get(FEATURES, "my-flag") + Store->>Mem: get(FEATURES, "my-flag") + rect rgb(255, 248, 200) + Note over Mem: lookup: key.to_sym → :my_flag
@items[:my_flag] → FeatureFlag + end + + Mem->>Store: FeatureFlag (@key = "my-flag") + rect rgb(200, 220, 255) + Note over Store: FeatureFlag @key = "my-flag" (STRING) + end + Store->>Client: FeatureFlag + Client->>App: evaluated value +``` + +## Two Distinct Key Concepts + +| Concept | Type | Example | Where | +|---|---|---|---| +| **Hash key** in collections (how items are indexed in the store) | **Symbol** | `:my_flag` | `changes_to_store_data`, `InMemoryFeatureStoreV2.@items` | +| **Flag key** as a value (the flag's identifier string) | **String** | `"my-flag"` | `FeatureFlag#key`, `Prerequisite#key`, `LDClient.variation`, `FlagChange#key`, `FlagValueChange#key` | + +## The Fix + +1. **DELETE path** — `changes_to_store_data` now uses `.to_s` on the `:key` value field of fabricated delete hashes, matching PUT objects which carry String keys from JSON. + +2. **Dependency tracker boundary** — keys are converted with `.to_s` when passed from the store to the dependency tracker, ensuring hash lookups match between items indexed by `change.key` (Symbol) and dependencies extracted from model objects (String). + +3. **FlagValueChangeAdapter** — uses `.to_s` on both sides of comparisons and on the key passed to `eval_fn` and emitted in `FlagValueChange`. Users can pass either Symbol or String to `add_flag_value_change_listener`. + +4. **Interface docs** — `FlagChange`, `FlagValueChange`, and `add_flag_value_change_listener` document `[String]` keys. Internal `Change` class documents that its Symbol key is converted to String at user-facing boundaries. diff --git a/lib/ldclient-rb/impl/data_store/store.rb b/lib/ldclient-rb/impl/data_store/store.rb index 5fa7fcf4..32f14a03 100644 --- a/lib/ldclient-rb/impl/data_store/store.rb +++ b/lib/ldclient-rb/impl/data_store/store.rb @@ -248,9 +248,10 @@ def get_data_store_status_provider collections.each do |kind, collection| collection.each do |key, item| - @dependency_tracker.update_dependencies_from(kind, key, item) + string_key = key.to_s + @dependency_tracker.update_dependencies_from(kind, string_key, item) if has_listeners - @dependency_tracker.add_affected_items(affected_items, { kind: kind, key: key }) + @dependency_tracker.add_affected_items(affected_items, { kind: kind, key: string_key }) end end end @@ -297,7 +298,7 @@ def get_data_store_status_provider if change.action == LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT && !change.object.nil? all_data[kind][change.key] = change.object elsif change.action == LaunchDarkly::Interfaces::DataSystem::ChangeType::DELETE - all_data[kind][change.key] = { key: change.key, deleted: true, version: change.version } + all_data[kind][change.key] = { key: change.key.to_s, deleted: true, version: change.version } end end @@ -314,7 +315,7 @@ def get_data_store_status_provider @dependency_tracker.reset all_data.each do |kind, items| items.each do |key, item| - @dependency_tracker.update_dependencies_from(kind, key, item) + @dependency_tracker.update_dependencies_from(kind, key.to_s, item) end end end @@ -356,9 +357,9 @@ def get_data_store_status_provider # If either is missing or versions differ, it's a change if old_item.nil? || new_item.nil? - @dependency_tracker.add_affected_items(affected_items, { kind: kind, key: key }) + @dependency_tracker.add_affected_items(affected_items, { kind: kind, key: key.to_s }) elsif old_item[:version] != new_item[:version] - @dependency_tracker.add_affected_items(affected_items, { kind: kind, key: key }) + @dependency_tracker.add_affected_items(affected_items, { kind: kind, key: key.to_s }) end end end diff --git a/lib/ldclient-rb/impl/flag_tracker.rb b/lib/ldclient-rb/impl/flag_tracker.rb index 83341118..c7775fd0 100644 --- a/lib/ldclient-rb/impl/flag_tracker.rb +++ b/lib/ldclient-rb/impl/flag_tracker.rb @@ -26,7 +26,7 @@ def add_flag_value_change_listener(key, context, listener) # An adapter which turns a normal flag change listener into a flag value change listener. # class FlagValueChangeAdapter - # @param [Symbol] flag_key + # @param [String] flag_key # @param [LaunchDarkly::LDContext] context # @param [#update] listener # @param [#call] eval_fn @@ -35,22 +35,22 @@ def initialize(flag_key, context, listener, eval_fn) @context = context @listener = listener @eval_fn = eval_fn - @value = Concurrent::AtomicReference.new(@eval_fn.call(@flag_key, @context)) + @value = Concurrent::AtomicReference.new(@eval_fn.call(@flag_key.to_s, @context)) end # # @param [LaunchDarkly::Interfaces::FlagChange] flag_change # def update(flag_change) - return unless flag_change.key == @flag_key + return unless flag_change.key.to_s == @flag_key.to_s - new_eval = @eval_fn.call(@flag_key, @context) + new_eval = @eval_fn.call(@flag_key.to_s, @context) old_eval = @value.get_and_set(new_eval) return if new_eval == old_eval @listener.update( - LaunchDarkly::Interfaces::FlagValueChange.new(@flag_key, old_eval, new_eval)) + LaunchDarkly::Interfaces::FlagValueChange.new(@flag_key.to_s, old_eval, new_eval)) end end end diff --git a/lib/ldclient-rb/interfaces/data_system.rb b/lib/ldclient-rb/interfaces/data_system.rb index 92479b75..65057475 100644 --- a/lib/ldclient-rb/interfaces/data_system.rb +++ b/lib/ldclient-rb/interfaces/data_system.rb @@ -206,7 +206,7 @@ class Change # @return [String] The kind ({ObjectKind}) attr_reader :kind - # @return [Symbol] The key + # @return [Symbol] The key (Symbol for internal store indexing; converted to String at user-facing boundaries) attr_reader :key # @return [Integer] The version @@ -218,7 +218,7 @@ class Change # # @param action [String] The action type ({ChangeType}) # @param kind [String] The object kind ({ObjectKind}) - # @param key [Symbol] The key + # @param key [Symbol] The key (Symbol for internal store indexing; converted to String at user-facing boundaries) # @param version [Integer] The version # @param object [Hash, nil] The object data # @@ -510,7 +510,7 @@ def finish(selector) # Adds a new object to the changeset. # # @param kind [String] The object kind ({ObjectKind}) - # @param key [Symbol] The key + # @param key [Symbol] The key (Symbol for internal store indexing; converted to String at user-facing boundaries) # @param version [Integer] The version # @param obj [Hash] The object data # @return [void] @@ -529,7 +529,7 @@ def add_put(kind, key, version, obj) # Adds a deletion to the changeset. # # @param kind [String] The object kind ({ObjectKind}) - # @param key [Symbol] The key + # @param key [Symbol] The key (Symbol for internal store indexing; converted to String at user-facing boundaries) # @param version [Integer] The version # @return [void] # diff --git a/lib/ldclient-rb/interfaces/flag_tracker.rb b/lib/ldclient-rb/interfaces/flag_tracker.rb index 3c8a8f8c..f32a5f78 100644 --- a/lib/ldclient-rb/interfaces/flag_tracker.rb +++ b/lib/ldclient-rb/interfaces/flag_tracker.rb @@ -66,7 +66,7 @@ def remove_listener(listener) end # The returned listener represents the subscription that was created by this method # call; to unsubscribe, pass that object (not your listener) to {#remove_listener}. # - # @param key [Symbol] + # @param key [String] # @param context [LaunchDarkly::LDContext] # @param listener [#update] # @@ -79,7 +79,7 @@ def add_flag_value_change_listener(key, context, listener) end class FlagChange attr_accessor :key - # @param [Symbol] key + # @param [String] key def initialize(key) @key = key end @@ -93,7 +93,7 @@ class FlagValueChange attr_accessor :old_value attr_accessor :new_value - # @param [Symbol] key + # @param [String] key # @param [Object] old_value # @param [Object] new_value def initialize(key, old_value, new_value) diff --git a/spec/impl/data_store/store_spec.rb b/spec/impl/data_store/store_spec.rb index b3260ecb..816e54bf 100644 --- a/spec/impl/data_store/store_spec.rb +++ b/spec/impl/data_store/store_spec.rb @@ -250,6 +250,200 @@ def reset_tracking end end + describe "flag change events" do + let(:flag_change_broadcaster) { LaunchDarkly::Impl::Broadcaster.new(SynchronousExecutor.new, logger) } + let(:change_set_broadcaster) { LaunchDarkly::Impl::Broadcaster.new(SynchronousExecutor.new, logger) } + subject { Store.new(flag_change_broadcaster, change_set_broadcaster, logger) } + + it "TRANSFER_FULL fires FlagChange with String keys" do + listener = ListenerSpy.new + flag_change_broadcaster.add_listener(listener) + + change = LaunchDarkly::Interfaces::DataSystem::Change.new( + action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT, + kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, + key: :flag_a, + version: 1, + object: { key: "flag_a", version: 1, on: true, variations: [true, false], fallthrough: { variation: 0 } } + ) + + change_set = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new( + intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL, + changes: [change], + selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector + ) + + subject.apply(change_set, false) + + expect(listener.statuses.length).to eq(1) + expect(listener.statuses[0]).to be_a(LaunchDarkly::Interfaces::FlagChange) + expect(listener.statuses[0].key).to be_a(String) + expect(listener.statuses[0].key).to eq("flag_a") + end + + it "TRANSFER_CHANGES fires FlagChange with String keys" do + # Initialize first (no listeners yet) + init_change = LaunchDarkly::Interfaces::DataSystem::Change.new( + action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT, + kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, + key: :flag_a, + version: 1, + object: { key: "flag_a", version: 1, on: true, variations: [true, false], fallthrough: { variation: 0 } } + ) + + init_cs = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new( + intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL, + changes: [init_change], + selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector + ) + + subject.apply(init_cs, false) + + # Now add listener and apply delta + listener = ListenerSpy.new + flag_change_broadcaster.add_listener(listener) + + delta_change = LaunchDarkly::Interfaces::DataSystem::Change.new( + action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT, + kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, + key: :flag_a, + version: 2, + object: { key: "flag_a", version: 2, on: false, variations: [true, false], fallthrough: { variation: 0 } } + ) + + delta_cs = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new( + intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_CHANGES, + changes: [delta_change], + selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector + ) + + subject.apply(delta_cs, false) + + expect(listener.statuses.length).to eq(1) + expect(listener.statuses[0].key).to be_a(String) + expect(listener.statuses[0].key).to eq("flag_a") + end + + it "prerequisite dependency propagation" do + # flag_b has prerequisite on flag_a + flag_a_data = { key: "flag_a", version: 1, on: true, variations: [true, false], fallthrough: { variation: 0 }, prerequisites: [], rules: [] } + flag_b_data = { key: "flag_b", version: 1, on: true, variations: [true, false], fallthrough: { variation: 0 }, prerequisites: [{ key: "flag_a", variation: 0 }], rules: [] } + + init_cs = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new( + intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL, + changes: [ + LaunchDarkly::Interfaces::DataSystem::Change.new(action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT, kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, key: :flag_a, version: 1, object: flag_a_data), + LaunchDarkly::Interfaces::DataSystem::Change.new(action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT, kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, key: :flag_b, version: 1, object: flag_b_data), + ], + selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector + ) + + subject.apply(init_cs, false) + + # Now listen and change flag_a + listener = ListenerSpy.new + flag_change_broadcaster.add_listener(listener) + + delta_cs = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new( + intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_CHANGES, + changes: [ + LaunchDarkly::Interfaces::DataSystem::Change.new(action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT, kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, key: :flag_a, version: 2, object: flag_a_data.merge(version: 2)), + ], + selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector + ) + + subject.apply(delta_cs, false) + + changed_keys = listener.statuses.map(&:key) + expect(changed_keys).to include("flag_a") + expect(changed_keys).to include("flag_b") + changed_keys.each { |k| expect(k).to be_a(String) } + end + + it "segment dependency propagation" do + # flag uses segment via segmentMatch rule + segment_data = { key: "seg1", version: 1, included: [], excluded: [], rules: [] } + flag_data = { + key: "flag_a", version: 1, on: true, variations: [true, false], fallthrough: { variation: 0 }, + prerequisites: [], + rules: [{ id: "rule1", variation: 0, clauses: [{ attribute: "", op: "segmentMatch", values: ["seg1"], negate: false }] }], + } + + init_cs = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new( + intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL, + changes: [ + LaunchDarkly::Interfaces::DataSystem::Change.new(action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT, kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, key: :flag_a, version: 1, object: flag_data), + LaunchDarkly::Interfaces::DataSystem::Change.new(action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT, kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT, key: :seg1, version: 1, object: segment_data), + ], + selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector + ) + + subject.apply(init_cs, false) + + # Now listen and change the segment + listener = ListenerSpy.new + flag_change_broadcaster.add_listener(listener) + + delta_cs = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new( + intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_CHANGES, + changes: [ + LaunchDarkly::Interfaces::DataSystem::Change.new(action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT, kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT, key: :seg1, version: 2, object: segment_data.merge(version: 2)), + ], + selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector + ) + + subject.apply(delta_cs, false) + + changed_keys = listener.statuses.map(&:key) + # Segment change should propagate to the flag + expect(changed_keys).to include("flag_a") + changed_keys.each { |k| expect(k).to be_a(String) } + end + + it "DELETE fires FlagChange with String keys" do + # Initialize with a flag + init_cs = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new( + intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL, + changes: [ + LaunchDarkly::Interfaces::DataSystem::Change.new( + action: LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT, + kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, + key: :flag_a, + version: 1, + object: { key: "flag_a", version: 1, on: true, variations: [true, false], fallthrough: { variation: 0 } } + ), + ], + selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector + ) + + subject.apply(init_cs, false) + + # Now listen and delete the flag + listener = ListenerSpy.new + flag_change_broadcaster.add_listener(listener) + + delete_cs = LaunchDarkly::Interfaces::DataSystem::ChangeSet.new( + intent_code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_CHANGES, + changes: [ + LaunchDarkly::Interfaces::DataSystem::Change.new( + action: LaunchDarkly::Interfaces::DataSystem::ChangeType::DELETE, + kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, + key: :flag_a, + version: 2, + object: nil + ), + ], + selector: LaunchDarkly::Interfaces::DataSystem::Selector.no_selector + ) + + subject.apply(delete_cs, false) + + expect(listener.statuses.length).to eq(1) + expect(listener.statuses[0].key).to be_a(String) + expect(listener.statuses[0].key).to eq("flag_a") + end + end + describe "#commit" do context "without persistent store" do it "returns nil and does nothing" do diff --git a/spec/impl/data_system/fdv2_datasystem_spec.rb b/spec/impl/data_system/fdv2_datasystem_spec.rb index 44c85548..6aabfc3c 100644 --- a/spec/impl/data_system/fdv2_datasystem_spec.rb +++ b/spec/impl/data_system/fdv2_datasystem_spec.rb @@ -65,9 +65,9 @@ def build(_sdk_key, _config) expect(modified.wait(1)).to be true expect(changes.length).to eq(3) - expect(changes[0].key).to eq(:flagkey) - expect(changes[1].key).to eq(:flagkey) - expect(changes[2].key).to eq(:flagkey) + expect(changes[0].key).to eq("flagkey") + expect(changes[1].key).to eq("flagkey") + expect(changes[2].key).to eq("flagkey") fdv2.stop end @@ -166,8 +166,8 @@ def build(_sdk_key, _config) expect(changed.wait(2)).to be true expect(changes.length).to eq(2) - expect(changes[0].key).to eq(:flagkey) - expect(changes[1].key).to eq(:flagkey) + expect(changes[0].key).to eq("flagkey") + expect(changes[1].key).to eq("flagkey") fdv2.stop end @@ -263,7 +263,7 @@ def build(_sdk_key, _config) # Verify we got flag changes from FDv1 expect(changes.length).to be > 0 - expect(changes.any? { |change| change.key == :fdv1flag }).to be true + expect(changes.any? { |change| change.key == "fdv1flag" }).to be true fdv2.stop end @@ -318,7 +318,7 @@ def build(_sdk_key, _config) # Verify FDv1 is active and we got both changes expect(changes.length).to eq(2) - expect(changes.all? { |change| change.key == :fdv1fallbackflag }).to be true + expect(changes.all? { |change| change.key == "fdv1fallbackflag" }).to be true fdv2.stop end @@ -369,8 +369,8 @@ def build(_sdk_key, _config) # Verify we got changes for both flags flag_keys = changes.map { |change| change.key } - expect(flag_keys).to include(:initialflag) - expect(flag_keys).to include(:fdv1replacementflag) + expect(flag_keys).to include("initialflag") + expect(flag_keys).to include("fdv1replacementflag") fdv2.stop end diff --git a/spec/impl/data_system/fdv2_persistence_spec.rb b/spec/impl/data_system/fdv2_persistence_spec.rb index 8eee67cc..d629cd5b 100644 --- a/spec/impl/data_system/fdv2_persistence_spec.rb +++ b/spec/impl/data_system/fdv2_persistence_spec.rb @@ -147,7 +147,7 @@ def get_data_snapshot listener = Object.new listener.define_singleton_method(:update) do |flag_change| changes << flag_change - flag_changed.set if flag_change.key == :newflag + flag_changed.set if flag_change.key == "newflag" end fdv2.flag_change_broadcaster.add_listener(listener) @@ -326,7 +326,7 @@ def get_data_snapshot flag_changed = Concurrent::Event.new listener = Object.new listener.define_singleton_method(:update) do |flag_change| - flag_changed.set if flag_change.key == :flagkey + flag_changed.set if flag_change.key == "flagkey" end fdv2.flag_change_broadcaster.add_listener(listener) @@ -412,7 +412,7 @@ def get_data_snapshot listener = Object.new listener.define_singleton_method(:update) do |flag_change| - sync_flag_arrived.set if flag_change.key == :"sync-flag" + sync_flag_arrived.set if flag_change.key == "sync-flag" end fdv2.flag_change_broadcaster.add_listener(listener) diff --git a/spec/impl/flag_tracker_spec.rb b/spec/impl/flag_tracker_spec.rb index 5d830a67..b9b462c3 100644 --- a/spec/impl/flag_tracker_spec.rb +++ b/spec/impl/flag_tracker_spec.rb @@ -26,6 +26,71 @@ module Impl expect(listener.statuses[1].key).to eq(:flag2) end + describe "cross-type key handling" do + it "String FlagChange + String listener key" do + responses = [:initial, :second] + eval_fn = Proc.new { |key, _ctx| responses.shift } + tracker = subject.new(broadcaster, eval_fn) + + listener = ListenerSpy.new + tracker.add_flag_value_change_listener("flag1", nil, listener) + + broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new("flag1")) + expect(listener.statuses.count).to eq(1) + expect(listener.statuses[0].key).to eq("flag1") + expect(listener.statuses[0].key).to be_a(String) + end + + it "String FlagChange + Symbol listener key" do + responses = [:initial, :second] + eval_fn = Proc.new { |key, _ctx| responses.shift } + tracker = subject.new(broadcaster, eval_fn) + + listener = ListenerSpy.new + tracker.add_flag_value_change_listener(:flag1, nil, listener) + + broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new("flag1")) + expect(listener.statuses.count).to eq(1) + expect(listener.statuses[0].key).to eq("flag1") + expect(listener.statuses[0].key).to be_a(String) + end + + it "Symbol FlagChange + String listener key" do + responses = [:initial, :second] + eval_fn = Proc.new { |key, _ctx| responses.shift } + tracker = subject.new(broadcaster, eval_fn) + + listener = ListenerSpy.new + tracker.add_flag_value_change_listener("flag1", nil, listener) + + broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) + expect(listener.statuses.count).to eq(1) + expect(listener.statuses[0].key).to eq("flag1") + expect(listener.statuses[0].key).to be_a(String) + end + + it "eval_fn always receives String key" do + received_keys = [] + eval_fn = Proc.new { |key, _ctx| received_keys << key; :value } + tracker = subject.new(broadcaster, eval_fn) + + listener = ListenerSpy.new + tracker.add_flag_value_change_listener(:flag1, nil, listener) + + # The initial eval in the constructor should have passed a String + expect(received_keys.length).to eq(1) + expect(received_keys[0]).to be_a(String) + expect(received_keys[0]).to eq("flag1") + + # Trigger an update + broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new("flag1")) + + expect(received_keys.length).to eq(2) + expect(received_keys[1]).to be_a(String) + expect(received_keys[1]).to eq("flag1") + end + end + describe "flag change listener" do it "listener is notified when value changes" do responses = [:initial, :second, :second, :final] @@ -46,11 +111,11 @@ module Impl broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) expect(listener.statuses.count).to eq(2) - expect(listener.statuses[0].key).to eq(:flag1) + expect(listener.statuses[0].key).to eq("flag1") expect(listener.statuses[0].old_value).to eq(:initial) expect(listener.statuses[0].new_value).to eq(:second) - expect(listener.statuses[1].key).to eq(:flag1) + expect(listener.statuses[1].key).to eq("flag1") expect(listener.statuses[1].old_value).to eq(:second) expect(listener.statuses[1].new_value).to eq(:final) end