Skip to content
Closed
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
250 changes: 250 additions & 0 deletions key-type-flow.md
Original file line number Diff line number Diff line change
@@ -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)<br/>hash = {key: "my-flag", ...}<br/>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<br/>→ :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<br/>hash key = :my_flag (SYMBOL)<br/>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<br/>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)<br/>prereq.key = "my-flag" (STRING)<br/>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<br/>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<br/>hash key = :my_flag (SYMBOL)
end
rect rgb(200, 220, 255)
Note over Store: value = {key: change.key.to_s, ...}<br/>→ {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<br/>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:<br/>flag_b has prerequisite on flag_a<br/>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<br/>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:<br/>1. "flag_a" directly changed<br/>2. "flag_b" depends on "flag_a"<br/>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<br/>@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.
13 changes: 7 additions & 6 deletions lib/ldclient-rb/impl/data_store/store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions lib/ldclient-rb/impl/flag_tracker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions lib/ldclient-rb/interfaces/data_system.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
#
Expand Down Expand Up @@ -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]
Expand All @@ -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]
#
Expand Down
6 changes: 3 additions & 3 deletions lib/ldclient-rb/interfaces/flag_tracker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
#
Expand All @@ -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
Expand All @@ -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)
Expand Down
Loading
Loading