fino 1.3.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +125 -6
- data/lib/fino/ab_testing/experiment.rb +64 -0
- data/lib/fino/ab_testing/variant.rb +29 -0
- data/lib/fino/ab_testing/variant_picker.rb +29 -0
- data/lib/fino/adapter.rb +13 -1
- data/lib/fino/cache/memory.rb +18 -10
- data/lib/fino/definition/section.rb +9 -0
- data/lib/fino/definition/setting.rb +24 -14
- data/lib/fino/expirator.rb +1 -1
- data/lib/fino/library.rb +25 -24
- data/lib/fino/pipe/cache.rb +3 -3
- data/lib/fino/pipe/storage.rb +2 -7
- data/lib/fino/pipe.rb +1 -1
- data/lib/fino/pretty_inspectable.rb +32 -0
- data/lib/fino/registry.rb +16 -8
- data/lib/fino/setting.rb +36 -74
- data/lib/fino/setting_builder.rb +51 -0
- data/lib/fino/settings/boolean.rb +2 -0
- data/lib/fino/settings/float.rb +2 -0
- data/lib/fino/settings/integer.rb +2 -0
- data/lib/fino/settings/string.rb +2 -0
- data/lib/fino/version.rb +1 -1
- data/lib/fino.rb +22 -16
- metadata +6 -3
- data/lib/fino/variant.rb +0 -5
- data/lib/fino/variant_picker.rb +0 -27
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c47c47b3ab49c1eac2959185f67a6cc62e952936c7693f1e1d3d95843afaae88
|
4
|
+
data.tar.gz: da1f3fea78b8d9adbf58a3209c87e6c8acd717371dafe7618f9a2522e744fc0d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bbadb8e1f7f954e7c1f0d9d1a0d7f6455236e67c6dfdaad87a02b9659b3a130f4c7be93cad53f9c486fad6cecd335a308e696ee82b91b1404f2a7fa31048c15c
|
7
|
+
data.tar.gz: '0797cdb045a602b6f9b79ce8f772f7085c857d9addb3d664d74f3a3790676afe046b4c5d81ad4d6b2865b4c244b6debc7bc2ac6ed87cb255adf66fea4a57d9be'
|
data/README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# Fino
|
2
2
|
|
3
|
-
⚠️ Fino
|
3
|
+
⚠️ Fino in active development phase at wasn't properly battle tested in production just yet. Give us a star and stay tuned for Production test results and new features
|
4
4
|
|
5
5
|
Fino is a dynamic settings engine for Ruby and Rails
|
6
6
|
|
@@ -23,10 +23,15 @@ Fino.configure do
|
|
23
23
|
settings do
|
24
24
|
setting :maintenance_mode, :boolean, default: false
|
25
25
|
|
26
|
+
setting :api_rate_limit,
|
27
|
+
:integer,
|
28
|
+
default: 1000,
|
29
|
+
description: "Maximum API requests per minute per user to prevent abuse"
|
30
|
+
|
26
31
|
section :openai, label: "OpenAI" do
|
27
32
|
setting :model,
|
28
33
|
:string,
|
29
|
-
default: "gpt-
|
34
|
+
default: "gpt-5",
|
30
35
|
description: "OpenAI model"
|
31
36
|
|
32
37
|
setting :temperature,
|
@@ -51,7 +56,7 @@ end
|
|
51
56
|
### Work with settings
|
52
57
|
|
53
58
|
```ruby
|
54
|
-
Fino.value(:model, at: :openai) #=> "gpt-
|
59
|
+
Fino.value(:model, at: :openai) #=> "gpt-5"
|
55
60
|
Fino.value(:temperature, at: :openai) #=> 0.7
|
56
61
|
|
57
62
|
Fino.values(:model, :temperature, at: :openai) #=> ["gpt-4", 0.7]
|
@@ -60,12 +65,46 @@ Fino.set(model: "gpt-5", at: :openai)
|
|
60
65
|
Fino.value(:model, at: :openai) #=> "gpt-5"
|
61
66
|
```
|
62
67
|
|
63
|
-
###
|
68
|
+
### Overrides
|
64
69
|
|
65
70
|
```ruby
|
66
|
-
|
71
|
+
Fino.value(:model, at: :openai) #=> "gpt-5"
|
72
|
+
|
73
|
+
Fino.set(model: "gpt-5", at: :openai, overrides: { "qa" => "our_local_model_not_to_pay_to_sam_altman" })
|
74
|
+
|
75
|
+
Fino.value(:model, at: :openai) #=> "gpt-5"
|
76
|
+
Fino.value(:model, at: :openai, for: "qa") #=> "our_local_model_not_to_pay_to_sam_altman"
|
77
|
+
```
|
78
|
+
|
79
|
+
### A/B testing
|
80
|
+
|
81
|
+
```ruby
|
82
|
+
Fino.value(:model, at: :openai) #=> "gpt-5"
|
83
|
+
|
84
|
+
# "gpt-5" becomes the control variant value and a 20.0% variant is created with value "gpt-6"
|
85
|
+
Fino.set(model: "gpt-5", at: :openai, variants: { 20.0 => "gpt-6" })
|
86
|
+
|
87
|
+
Fino.setting(:model, at: :openai).experiment.variant(for: "user_1") #=> #<Fino::AbTesting::Variant percentage: 20.0, value: "gpt-6">
|
88
|
+
|
89
|
+
# Picked variant is sticked to the user
|
90
|
+
Fino.value(:model, at: :openai, for: "user_1") #=> "gpt-6"
|
91
|
+
Fino.value(:model, at: :openai, for: "user_1") #=> "gpt-6"
|
92
|
+
|
93
|
+
Fino.value(:model, at: :openai, for: "user_2") #=> "gpt-5"
|
67
94
|
```
|
68
95
|
|
96
|
+
## Rails integration
|
97
|
+
|
98
|
+
Fino easily integrates with Rails. Just add the gem to your Gemfile:
|
99
|
+
|
100
|
+
```
|
101
|
+
gem "fino-rails", require: false
|
102
|
+
```
|
103
|
+
|
104
|
+
to get built-in UI engine for your settings!
|
105
|
+
|
106
|
+
### UI engine
|
107
|
+
|
69
108
|
Mount Fino Rails engine in your `config/routes.rb`:
|
70
109
|
|
71
110
|
```ruby
|
@@ -74,4 +113,84 @@ Rails.application.routes.draw do
|
|
74
113
|
end
|
75
114
|
```
|
76
115
|
|
77
|
-
|
116
|
+
### Configuration
|
117
|
+
|
118
|
+
```ruby
|
119
|
+
Rails.application.configure do
|
120
|
+
config.fino.instrument = true
|
121
|
+
config.fino.log = true
|
122
|
+
config.fino.cache_within_request = false
|
123
|
+
config.fino.preload_before_request = true
|
124
|
+
end
|
125
|
+
```
|
126
|
+
|
127
|
+
<img width="1493" height="676" alt="Screenshot 2025-09-19 at 13 09 06" src="https://github.com/user-attachments/assets/19b6147a-e18c-41cf-aac7-99111efcc9d5" />
|
128
|
+
|
129
|
+
<img width="1775" height="845" alt="Screenshot 2025-09-19 at 13 09 33" src="https://github.com/user-attachments/assets/c0010abd-285d-43d0-ae5d-ce0edb781309" />
|
130
|
+
|
131
|
+
## Performance tweaks
|
132
|
+
|
133
|
+
1. In Memory cache
|
134
|
+
|
135
|
+
Fino provides in-memory settings caching functionality which will store settings received from adaper in memory for
|
136
|
+
a very quick access. As this kind of cache is not distributed between machines, but belongs to each process
|
137
|
+
separately, it's impossible to invalidate all at once, so be aware that setting update application time will depend
|
138
|
+
on cache TTL you configure
|
139
|
+
|
140
|
+
```ruby
|
141
|
+
Fino.configure do
|
142
|
+
# ...
|
143
|
+
cache { Fino::Cache::Memory.new(expires_in: 3.seconds) }
|
144
|
+
# ...
|
145
|
+
end
|
146
|
+
```
|
147
|
+
|
148
|
+
2. Request scoped cache
|
149
|
+
|
150
|
+
When using Fino in Rails context it's possible to cache settings within request, in current thread storage. This is
|
151
|
+
safe way to cache settings as it's lifetime is limited, thus it is enabled by default
|
152
|
+
|
153
|
+
```ruby
|
154
|
+
Rails.application.configure do
|
155
|
+
config.fino.cache_within_request = true
|
156
|
+
end
|
157
|
+
```
|
158
|
+
|
159
|
+
3. Preloading
|
160
|
+
|
161
|
+
In Rails context it is possible to tell Fino to preload multiple settings before processing request in a single
|
162
|
+
adapter call. Preloading is recommended for requests that use multiple different settings in their logic
|
163
|
+
|
164
|
+
```ruby
|
165
|
+
# Preload all settings
|
166
|
+
Rails.application.configure do
|
167
|
+
config.fino.preload_before_request = true
|
168
|
+
end
|
169
|
+
|
170
|
+
# Preload specific subset of settings depending on request
|
171
|
+
Rails.application.configure do
|
172
|
+
config.fino.preload_before_request = ->(request) {
|
173
|
+
case request.path
|
174
|
+
when "request/using/all/settings"
|
175
|
+
true
|
176
|
+
when "request/not/using/settings"
|
177
|
+
false
|
178
|
+
when "request/using/specific/settings"
|
179
|
+
[
|
180
|
+
:api_rate_limit,
|
181
|
+
openai: [:model, :temperature]
|
182
|
+
]
|
183
|
+
end
|
184
|
+
}
|
185
|
+
end
|
186
|
+
```
|
187
|
+
|
188
|
+
## Releasing
|
189
|
+
|
190
|
+
`rake release`
|
191
|
+
|
192
|
+
## Contributing
|
193
|
+
|
194
|
+
1. Fork it
|
195
|
+
2. Do contribution
|
196
|
+
6. Create Pull Request into this repo
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Fino::AbTesting::Experiment
|
4
|
+
include Fino::PrettyInspectable
|
5
|
+
|
6
|
+
TOTAL_PERCENTAGE = 100.0
|
7
|
+
|
8
|
+
attr_reader :setting_definition
|
9
|
+
|
10
|
+
def initialize(setting_definition)
|
11
|
+
@setting_definition = setting_definition
|
12
|
+
@user_variants = []
|
13
|
+
end
|
14
|
+
|
15
|
+
def <<(variant)
|
16
|
+
@user_variants << variant
|
17
|
+
|
18
|
+
@variants = nil
|
19
|
+
@value_by_variant_id = nil
|
20
|
+
end
|
21
|
+
|
22
|
+
def variants
|
23
|
+
return @variants if @variants
|
24
|
+
return @variants = [] if @user_variants.empty?
|
25
|
+
|
26
|
+
@variants = [
|
27
|
+
Fino::AbTesting::Variant.new(
|
28
|
+
percentage: TOTAL_PERCENTAGE - @user_variants.sum(&:percentage),
|
29
|
+
value: Fino::AbTesting::Variant::CONTROL_VALUE
|
30
|
+
),
|
31
|
+
*@user_variants
|
32
|
+
]
|
33
|
+
end
|
34
|
+
|
35
|
+
def value(for:)
|
36
|
+
variant = variant(for: binding.local_variable_get(:for))
|
37
|
+
|
38
|
+
value_by_variant_id.fetch(
|
39
|
+
variant.id,
|
40
|
+
Fino::AbTesting::Variant::CONTROL_VALUE
|
41
|
+
)
|
42
|
+
end
|
43
|
+
|
44
|
+
def variant(for:)
|
45
|
+
Fino::AbTesting::VariantPicker.new(setting_definition).call(
|
46
|
+
variants,
|
47
|
+
binding.local_variable_get(:for)
|
48
|
+
)
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def value_by_variant_id
|
54
|
+
@value_by_variant_id ||= variants.each_with_object({}) do |variant, memo|
|
55
|
+
memo[variant.id] = variant.value
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def inspectable_attributes
|
60
|
+
{
|
61
|
+
variants: variants
|
62
|
+
}
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Fino::AbTesting::Variant
|
4
|
+
CONTROL_VALUE = Class.new do
|
5
|
+
def inspect
|
6
|
+
"Fino::AbTesting::Variant::CONTROL_VALUE"
|
7
|
+
end
|
8
|
+
end.new
|
9
|
+
|
10
|
+
include Fino::PrettyInspectable
|
11
|
+
|
12
|
+
attr_reader :id, :percentage, :value
|
13
|
+
|
14
|
+
def initialize(percentage:, value:)
|
15
|
+
@id = SecureRandom.uuid
|
16
|
+
|
17
|
+
@percentage = percentage.to_f
|
18
|
+
@value = value
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def inspectable_attributes
|
24
|
+
{
|
25
|
+
percentage: percentage,
|
26
|
+
value: value
|
27
|
+
}
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "zlib"
|
4
|
+
|
5
|
+
class Fino::AbTesting::VariantPicker
|
6
|
+
SCALING_FACTOR = 1_000
|
7
|
+
|
8
|
+
attr_reader :setting_definition
|
9
|
+
|
10
|
+
def initialize(setting_definition)
|
11
|
+
@setting_definition = setting_definition
|
12
|
+
end
|
13
|
+
|
14
|
+
def call(variants, scope)
|
15
|
+
return nil if variants.empty?
|
16
|
+
|
17
|
+
random = Zlib.crc32("#{setting_definition.key}#{scope}") % (100 * SCALING_FACTOR)
|
18
|
+
cumulative = 0
|
19
|
+
|
20
|
+
picked_variant = variants.sort_by(&:percentage).find do |variant|
|
21
|
+
cumulative += variant.percentage * SCALING_FACTOR
|
22
|
+
random <= cumulative
|
23
|
+
end
|
24
|
+
|
25
|
+
Fino.logger.debug { "Variant picked: #{picked_variant}" }
|
26
|
+
|
27
|
+
picked_variant
|
28
|
+
end
|
29
|
+
end
|
data/lib/fino/adapter.rb
CHANGED
@@ -9,7 +9,19 @@ module Fino::Adapter
|
|
9
9
|
raise NotImplementedError
|
10
10
|
end
|
11
11
|
|
12
|
-
def write(setting_definition, value)
|
12
|
+
def write(setting_definition, value, overrides, variants)
|
13
|
+
raise NotImplementedError
|
14
|
+
end
|
15
|
+
|
16
|
+
def fetch_value_from(raw_adapter_data)
|
17
|
+
raise NotImplementedError
|
18
|
+
end
|
19
|
+
|
20
|
+
def fetch_raw_overrides_from(raw_adapter_data)
|
21
|
+
raise NotImplementedError
|
22
|
+
end
|
23
|
+
|
24
|
+
def fetch_raw_variants_from(raw_adapter_data)
|
13
25
|
raise NotImplementedError
|
14
26
|
end
|
15
27
|
end
|
data/lib/fino/cache/memory.rb
CHANGED
@@ -9,15 +9,15 @@ class Fino::Cache::Memory
|
|
9
9
|
end
|
10
10
|
|
11
11
|
def exist?(key)
|
12
|
+
expire_if_ready
|
13
|
+
|
12
14
|
hash.key?(key)
|
13
|
-
ensure
|
14
|
-
expire_if_needed
|
15
15
|
end
|
16
16
|
|
17
17
|
def read(key)
|
18
|
+
expire_if_ready
|
19
|
+
|
18
20
|
hash[key]
|
19
|
-
ensure
|
20
|
-
expire_if_needed
|
21
21
|
end
|
22
22
|
|
23
23
|
def write(key, value)
|
@@ -25,14 +25,20 @@ class Fino::Cache::Memory
|
|
25
25
|
end
|
26
26
|
|
27
27
|
def fetch(key, &block)
|
28
|
+
raise ArgumentError, "no block provided to #{self.class.name}#fetch" unless block
|
29
|
+
|
30
|
+
expire_if_ready
|
31
|
+
|
28
32
|
hash.fetch(key) do
|
29
33
|
write(key, block.call)
|
30
34
|
end
|
31
|
-
ensure
|
32
|
-
expire_if_needed
|
33
35
|
end
|
34
36
|
|
35
|
-
def fetch_multi(keys, &block)
|
37
|
+
def fetch_multi(*keys, &block)
|
38
|
+
raise ArgumentError, "no block provided to #{self.class.name}#fetch_multi" unless block
|
39
|
+
|
40
|
+
expire_if_ready
|
41
|
+
|
36
42
|
missing_keys = keys - hash.keys
|
37
43
|
|
38
44
|
if missing_keys.any?
|
@@ -44,19 +50,21 @@ class Fino::Cache::Memory
|
|
44
50
|
end
|
45
51
|
|
46
52
|
hash.values_at(*keys)
|
47
|
-
ensure
|
48
|
-
expire_if_needed
|
49
53
|
end
|
50
54
|
|
51
55
|
def delete(key)
|
52
56
|
hash.delete(key)
|
53
57
|
end
|
54
58
|
|
59
|
+
def clear
|
60
|
+
hash.clear
|
61
|
+
end
|
62
|
+
|
55
63
|
private
|
56
64
|
|
57
65
|
attr_reader :hash, :expirator
|
58
66
|
|
59
|
-
def
|
67
|
+
def expire_if_ready
|
60
68
|
expirator&.when_ready do
|
61
69
|
Fino.logger.debug { "Expiring all cache entries" }
|
62
70
|
hash.clear
|
@@ -1,6 +1,17 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
class Fino::Definition::Setting
|
4
|
+
TYPE_CLASSES = [
|
5
|
+
Fino::Settings::String,
|
6
|
+
Fino::Settings::Integer,
|
7
|
+
Fino::Settings::Float,
|
8
|
+
Fino::Settings::Boolean
|
9
|
+
].freeze
|
10
|
+
|
11
|
+
SETTING_TYPE_TO_TYPE_CLASS_MAPPING = TYPE_CLASSES.each_with_object({}) do |klass, hash|
|
12
|
+
hash[klass.type_identifier] = klass
|
13
|
+
end.freeze
|
14
|
+
|
4
15
|
attr_reader :setting_name, :section_definition, :type, :options
|
5
16
|
|
6
17
|
def initialize(type:, setting_name:, section_definition: nil, **options)
|
@@ -10,20 +21,10 @@ class Fino::Definition::Setting
|
|
10
21
|
@options = options
|
11
22
|
end
|
12
23
|
|
13
|
-
def type_class
|
14
|
-
@type_class ||=
|
15
|
-
|
16
|
-
|
17
|
-
Fino::Settings::String
|
18
|
-
when :integer
|
19
|
-
Fino::Settings::Integer
|
20
|
-
when :float
|
21
|
-
Fino::Settings::Float
|
22
|
-
when :boolean
|
23
|
-
Fino::Settings::Boolean
|
24
|
-
else
|
25
|
-
raise "Unknown type #{type}"
|
26
|
-
end
|
24
|
+
def type_class
|
25
|
+
@type_class ||= SETTING_TYPE_TO_TYPE_CLASS_MAPPING.fetch(type) do
|
26
|
+
raise ArgumentError, "Unknown setting type #{type}"
|
27
|
+
end
|
27
28
|
end
|
28
29
|
|
29
30
|
def default
|
@@ -43,4 +44,13 @@ class Fino::Definition::Setting
|
|
43
44
|
def key
|
44
45
|
@key ||= path.reverse.join("/")
|
45
46
|
end
|
47
|
+
|
48
|
+
def eql?(other)
|
49
|
+
self.class.eql?(other.class) && key == other.key
|
50
|
+
end
|
51
|
+
alias == eql?
|
52
|
+
|
53
|
+
def hash
|
54
|
+
key.hash
|
55
|
+
end
|
46
56
|
end
|
data/lib/fino/expirator.rb
CHANGED
data/lib/fino/library.rb
CHANGED
@@ -34,12 +34,6 @@ class Fino::Library
|
|
34
34
|
pipeline.read_multi(setting_definitions)
|
35
35
|
end
|
36
36
|
|
37
|
-
def variant(setting_name, at: nil, for:)
|
38
|
-
setting(setting_name, at: at).variant(
|
39
|
-
for: binding.local_variable_get(:for)
|
40
|
-
)
|
41
|
-
end
|
42
|
-
|
43
37
|
def set(**data)
|
44
38
|
at = data.delete(:at)
|
45
39
|
raw_overrides = data.delete(:overrides) || {}
|
@@ -49,43 +43,50 @@ class Fino::Library
|
|
49
43
|
setting_definition = build_setting_definition(setting_name, at: at)
|
50
44
|
value = setting_definition.type_class.deserialize(raw_value)
|
51
45
|
|
52
|
-
|
53
|
-
Fino::Variant.new(percentage, setting_definition.type_class.deserialize(value))
|
54
|
-
end
|
46
|
+
overrides = raw_overrides.transform_values { |v| setting_definition.type_class.deserialize(v) }
|
55
47
|
|
56
|
-
|
57
|
-
|
58
|
-
|
48
|
+
experiment = Fino::AbTesting::Experiment.new(setting_definition)
|
49
|
+
|
50
|
+
raw_variants.map do |percentage, value|
|
51
|
+
experiment << Fino::AbTesting::Variant.new(
|
52
|
+
percentage: percentage,
|
53
|
+
value: setting_definition.type_class.deserialize(value)
|
54
|
+
)
|
55
|
+
end
|
59
56
|
|
60
57
|
pipeline.write(
|
61
58
|
setting_definition,
|
62
59
|
value,
|
63
|
-
|
64
|
-
variants
|
60
|
+
overrides,
|
61
|
+
experiment.variants
|
65
62
|
)
|
66
63
|
end
|
67
64
|
|
68
|
-
def slice(
|
69
|
-
setting_definitions =
|
70
|
-
|
71
|
-
|
65
|
+
def slice(*settings)
|
66
|
+
setting_definitions = settings.each_with_object([]) do |symbol_or_hash, memo|
|
67
|
+
case symbol_or_hash
|
68
|
+
when Symbol
|
69
|
+
memo << build_setting_definition(symbol_or_hash)
|
70
|
+
when Hash
|
71
|
+
symbol_or_hash.each do |section_name, setting_names|
|
72
|
+
Array(setting_names).each do |setting_name|
|
73
|
+
memo << build_setting_definition(setting_name, at: section_name)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
else
|
77
|
+
raise ArgumentError, "Settings to preload should be either symbols or hashes"
|
72
78
|
end
|
73
79
|
end
|
74
80
|
|
75
81
|
pipeline.read_multi(setting_definitions)
|
76
82
|
end
|
77
83
|
|
78
|
-
def set_variants(setting_name, at: nil, variants:)
|
79
|
-
setting_definition = build_setting_definition(setting_name, at: at)
|
80
|
-
pipeline.write_variants(setting_definition, variants)
|
81
|
-
end
|
82
|
-
|
83
84
|
private
|
84
85
|
|
85
86
|
attr_reader :configuration
|
86
87
|
|
87
88
|
def build_setting_definition(setting_name, at: nil)
|
88
|
-
configuration.registry.setting_definition(setting_name.to_s, at&.to_s)
|
89
|
+
configuration.registry.setting_definition!(setting_name.to_s, at&.to_s)
|
89
90
|
end
|
90
91
|
|
91
92
|
def pipeline
|
data/lib/fino/pipe/cache.rb
CHANGED
@@ -15,15 +15,15 @@ class Fino::Pipe::Cache
|
|
15
15
|
end
|
16
16
|
|
17
17
|
def read_multi(setting_definitions)
|
18
|
-
cache.fetch_multi(setting_definitions.map(&:key)) do |missing_keys|
|
18
|
+
cache.fetch_multi(*setting_definitions.map(&:key)) do |missing_keys|
|
19
19
|
uncached_setting_definitions = setting_definitions.filter { |sd| missing_keys.include?(sd.key) }
|
20
20
|
|
21
21
|
missing_keys.zip(pipe.read_multi(uncached_setting_definitions))
|
22
22
|
end
|
23
23
|
end
|
24
24
|
|
25
|
-
def write(setting_definition, value,
|
26
|
-
pipe.write(setting_definition, value,
|
25
|
+
def write(setting_definition, value, overrides, variants)
|
26
|
+
pipe.write(setting_definition, value, overrides, variants)
|
27
27
|
|
28
28
|
cache.delete(setting_definition.key)
|
29
29
|
end
|
data/lib/fino/pipe/storage.rb
CHANGED
@@ -27,14 +27,9 @@ class Fino::Pipe::Storage
|
|
27
27
|
|
28
28
|
def to_setting(setting_definition, raw_adapter_data)
|
29
29
|
raw_value = adapter.fetch_value_from(raw_adapter_data)
|
30
|
-
|
30
|
+
raw_overrides = adapter.fetch_raw_overrides_from(raw_adapter_data)
|
31
31
|
raw_variants = adapter.fetch_raw_variants_from(raw_adapter_data)
|
32
32
|
|
33
|
-
setting_definition.
|
34
|
-
setting_definition,
|
35
|
-
raw_value,
|
36
|
-
scoped_raw_values,
|
37
|
-
raw_variants
|
38
|
-
)
|
33
|
+
Fino::SettingBuilder.new(setting_definition).call(raw_value, raw_overrides, raw_variants)
|
39
34
|
end
|
40
35
|
end
|
data/lib/fino/pipe.rb
CHANGED
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Fino::PrettyInspectable
|
4
|
+
def inspect
|
5
|
+
attributes = inspectable_attributes.map do |key, value|
|
6
|
+
"#{key}=#{value.inspect}"
|
7
|
+
end
|
8
|
+
|
9
|
+
"#<#{self.class.name} #{attributes.join(', ')}>"
|
10
|
+
end
|
11
|
+
|
12
|
+
def pretty_print(pp) # rubocop:disable Metrics/MethodLength
|
13
|
+
pp.object_group(self) do
|
14
|
+
pp.nest(1) do
|
15
|
+
pp.breakable
|
16
|
+
pp.seplist(inspectable_attributes, nil, :each) do |key, value|
|
17
|
+
pp.group do
|
18
|
+
pp.text key.to_s
|
19
|
+
pp.text ": "
|
20
|
+
pp.pp value
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def inspectable_attributes
|
30
|
+
raise NotImplementedError
|
31
|
+
end
|
32
|
+
end
|
data/lib/fino/registry.rb
CHANGED
@@ -1,6 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
class Fino::Registry
|
4
|
+
DuplicateSetting = Class.new(Fino::Error)
|
5
|
+
UnknownSetting = Class.new(Fino::Error)
|
6
|
+
|
4
7
|
class DSL
|
5
8
|
class SectionDSL
|
6
9
|
def initialize(section_definition, registry)
|
@@ -46,22 +49,24 @@ class Fino::Registry
|
|
46
49
|
end
|
47
50
|
end
|
48
51
|
|
49
|
-
UnknownSetting = Class.new(Fino::Error)
|
50
|
-
|
51
52
|
using Fino::Ext::Hash
|
52
53
|
|
53
54
|
attr_reader :setting_definitions, :section_definitions
|
54
55
|
|
55
56
|
def initialize
|
56
|
-
@setting_definitions =
|
57
|
+
@setting_definitions = Set.new
|
57
58
|
@setting_definitions_by_path = {}
|
58
59
|
|
59
|
-
@section_definitions =
|
60
|
+
@section_definitions = Set.new
|
60
61
|
@section_definitions_by_name = {}
|
61
62
|
end
|
62
63
|
|
63
64
|
def setting_definition(*path)
|
64
|
-
@setting_definitions_by_path.dig(*path.compact.reverse)
|
65
|
+
@setting_definitions_by_path.dig(*path.compact.reverse.map(&:to_s))
|
66
|
+
end
|
67
|
+
|
68
|
+
def setting_definition!(*path)
|
69
|
+
setting_definition(*path).tap do |definition|
|
65
70
|
raise UnknownSetting, "Unknown setting: #{path.compact.join('.')}" unless definition
|
66
71
|
end
|
67
72
|
end
|
@@ -82,13 +87,16 @@ class Fino::Registry
|
|
82
87
|
end
|
83
88
|
|
84
89
|
def register(setting_definition)
|
85
|
-
@setting_definitions
|
90
|
+
unless @setting_definitions.add?(setting_definition)
|
91
|
+
raise DuplicateSetting, "#{setting_definition.setting_name} is already registered at #{setting_definition.key}"
|
92
|
+
end
|
86
93
|
|
87
94
|
@setting_definitions_by_path.deep_set(setting_definition, *setting_definition.path.map(&:to_s))
|
88
95
|
end
|
89
96
|
|
90
97
|
def register_section(section_definition)
|
91
|
-
@section_definitions
|
92
|
-
|
98
|
+
return unless @section_definitions.add?(section_definition)
|
99
|
+
|
100
|
+
@section_definitions_by_name[section_definition.name.to_s] = section_definition
|
93
101
|
end
|
94
102
|
end
|
data/lib/fino/setting.rb
CHANGED
@@ -1,11 +1,21 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Fino::Setting
|
4
|
+
include Fino::PrettyInspectable
|
5
|
+
|
4
6
|
def self.included(base)
|
5
7
|
base.extend(ClassMethods)
|
6
8
|
end
|
7
9
|
|
8
10
|
module ClassMethods
|
11
|
+
def type_identifier=(identifier)
|
12
|
+
@type_identifier = identifier
|
13
|
+
end
|
14
|
+
|
15
|
+
def type_identifier
|
16
|
+
@type_identifier
|
17
|
+
end
|
18
|
+
|
9
19
|
def serialize(value)
|
10
20
|
raise NotImplementedError
|
11
21
|
end
|
@@ -13,81 +23,36 @@ module Fino::Setting
|
|
13
23
|
def deserialize(raw_value)
|
14
24
|
raise NotImplementedError
|
15
25
|
end
|
16
|
-
|
17
|
-
def build(setting_definition, raw_value, scoped_raw_values, raw_variants)
|
18
|
-
value = raw_value.equal?(Fino::EMPTINESS) ? setting_definition.options[:default] : deserialize(raw_value)
|
19
|
-
scoped_values = scoped_raw_values.transform_values { |v| deserialize(v) }
|
20
|
-
|
21
|
-
variants = raw_variants.map do |raw_variant|
|
22
|
-
Fino::Variant.new(
|
23
|
-
raw_variant.fetch(:percentage),
|
24
|
-
deserialize(raw_variant.fetch(:value))
|
25
|
-
)
|
26
|
-
end
|
27
|
-
|
28
|
-
variants.prepend(
|
29
|
-
Fino::Variant.new(percentage: 100.0 - variants.sum(&:percentage), value: Fino::Variant::CONTROL)
|
30
|
-
)
|
31
|
-
|
32
|
-
new(
|
33
|
-
setting_definition,
|
34
|
-
value,
|
35
|
-
scoped_values,
|
36
|
-
variants
|
37
|
-
)
|
38
|
-
end
|
39
26
|
end
|
40
27
|
|
41
|
-
attr_reader :definition, :
|
28
|
+
attr_reader :definition, :global_value, :overrides, :experiment
|
42
29
|
|
43
|
-
def initialize(definition,
|
30
|
+
def initialize(definition, global_value, overrides = {}, experiment = nil)
|
44
31
|
@definition = definition
|
45
|
-
@
|
46
|
-
@
|
47
|
-
@
|
48
|
-
end
|
49
|
-
|
50
|
-
def name
|
51
|
-
definition.setting_name
|
52
|
-
end
|
53
|
-
|
54
|
-
def key
|
55
|
-
definition.key
|
32
|
+
@global_value = global_value
|
33
|
+
@overrides = overrides
|
34
|
+
@experiment = experiment
|
56
35
|
end
|
57
36
|
|
58
37
|
def value(**context)
|
59
|
-
return
|
38
|
+
return global_value unless (scope = context[:for])
|
60
39
|
|
61
|
-
|
62
|
-
return
|
40
|
+
overrides.fetch(scope.to_s) do
|
41
|
+
return global_value unless experiment
|
63
42
|
|
64
|
-
|
65
|
-
|
43
|
+
value = experiment.value(for: scope)
|
44
|
+
return global_value if value == Fino::AbTesting::Variant::CONTROL_VALUE
|
66
45
|
|
67
|
-
|
68
|
-
|
69
|
-
result
|
46
|
+
value
|
70
47
|
end
|
71
48
|
end
|
72
49
|
|
73
|
-
def
|
74
|
-
|
75
|
-
binding.local_variable_get(:for)
|
76
|
-
)
|
77
|
-
end
|
78
|
-
|
79
|
-
def variant_id_to_value
|
80
|
-
@variant_id_to_value ||= variants.each_with_object({}) do |variant, memo|
|
81
|
-
memo[variant.id] = variant.value
|
82
|
-
end
|
83
|
-
end
|
84
|
-
|
85
|
-
def overriden_scopes
|
86
|
-
scoped_values.keys
|
50
|
+
def name
|
51
|
+
definition.setting_name
|
87
52
|
end
|
88
53
|
|
89
|
-
def
|
90
|
-
|
54
|
+
def key
|
55
|
+
definition.key
|
91
56
|
end
|
92
57
|
|
93
58
|
def type
|
@@ -114,20 +79,17 @@ module Fino::Setting
|
|
114
79
|
definition.description
|
115
80
|
end
|
116
81
|
|
117
|
-
def inspect
|
118
|
-
attributes = [
|
119
|
-
"key=#{key.inspect}",
|
120
|
-
"type=#{type_class.inspect}",
|
121
|
-
"value=#{value.inspect}",
|
122
|
-
"overrides=#{@scoped_values.inspect}",
|
123
|
-
"variants=#{variants.inspect}",
|
124
|
-
"default=#{default.inspect}"
|
125
|
-
]
|
126
|
-
|
127
|
-
"#<#{self.class.name} #{attributes.join(', ')}>"
|
128
|
-
end
|
129
|
-
|
130
82
|
private
|
131
83
|
|
132
|
-
|
84
|
+
def inspectable_attributes
|
85
|
+
{
|
86
|
+
key: key,
|
87
|
+
type: type_class,
|
88
|
+
default: default,
|
89
|
+
global_value: global_value
|
90
|
+
}.tap do |attributes|
|
91
|
+
attributes[:overrides] = overrides if overrides.present?
|
92
|
+
attributes[:experiment] = experiment if experiment
|
93
|
+
end
|
94
|
+
end
|
133
95
|
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Fino::SettingBuilder
|
4
|
+
attr_reader :setting_definition
|
5
|
+
|
6
|
+
def initialize(setting_definition)
|
7
|
+
@setting_definition = setting_definition
|
8
|
+
end
|
9
|
+
|
10
|
+
def call(raw_value, raw_overrides, raw_variants)
|
11
|
+
global_value = deserialize_global_value(raw_value)
|
12
|
+
overrides = deserialize_overrides(raw_overrides)
|
13
|
+
experiment = deserialize_experiment(raw_variants)
|
14
|
+
|
15
|
+
setting_definition.type_class.new(
|
16
|
+
setting_definition,
|
17
|
+
global_value,
|
18
|
+
overrides,
|
19
|
+
experiment
|
20
|
+
)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def deserialize_global_value(raw_value)
|
26
|
+
return setting_definition.options[:default] if raw_value.equal?(Fino::EMPTINESS)
|
27
|
+
|
28
|
+
deserialize(raw_value)
|
29
|
+
end
|
30
|
+
|
31
|
+
def deserialize_overrides(raw_overrides)
|
32
|
+
raw_overrides.transform_values { |v| deserialize(v) }
|
33
|
+
end
|
34
|
+
|
35
|
+
def deserialize_experiment(raw_variants)
|
36
|
+
return if raw_variants.empty?
|
37
|
+
|
38
|
+
Fino::AbTesting::Experiment.new(setting_definition).tap do |experiment|
|
39
|
+
raw_variants.each do |raw_variant|
|
40
|
+
experiment << Fino::AbTesting::Variant.new(
|
41
|
+
percentage: raw_variant.fetch(:percentage),
|
42
|
+
value: deserialize(raw_variant.fetch(:value))
|
43
|
+
)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def deserialize(value)
|
49
|
+
setting_definition.type_class.deserialize(value)
|
50
|
+
end
|
51
|
+
end
|
data/lib/fino/settings/float.rb
CHANGED
data/lib/fino/settings/string.rb
CHANGED
data/lib/fino/version.rb
CHANGED
data/lib/fino.rb
CHANGED
@@ -4,12 +4,30 @@ require "forwardable"
|
|
4
4
|
require "zeitwerk"
|
5
5
|
|
6
6
|
module Fino
|
7
|
-
module
|
7
|
+
module Stateful
|
8
8
|
def configure(&block)
|
9
9
|
configuration.instance_eval(&block)
|
10
10
|
end
|
11
11
|
|
12
|
-
|
12
|
+
def reset!
|
13
|
+
Thread.current[:fino_library] = nil
|
14
|
+
|
15
|
+
@registry = nil
|
16
|
+
@configuration = nil
|
17
|
+
end
|
18
|
+
|
19
|
+
def reconfigure(&block)
|
20
|
+
reset!
|
21
|
+
configure(&block)
|
22
|
+
end
|
23
|
+
|
24
|
+
def library
|
25
|
+
Thread.current[:fino_library] ||= Fino::Library.new(configuration)
|
26
|
+
end
|
27
|
+
|
28
|
+
def registry
|
29
|
+
@registry ||= Fino::Registry.new
|
30
|
+
end
|
13
31
|
|
14
32
|
def configuration
|
15
33
|
@configuration ||= Fino::Configuration.new(registry)
|
@@ -25,32 +43,20 @@ module Fino
|
|
25
43
|
:setting,
|
26
44
|
:settings,
|
27
45
|
:slice,
|
28
|
-
:set
|
29
|
-
:define_variants,
|
30
|
-
:variant
|
31
|
-
|
32
|
-
module_function
|
46
|
+
:set
|
33
47
|
|
34
48
|
def library
|
35
49
|
raise NotImplementedError
|
36
50
|
end
|
37
51
|
end
|
38
52
|
|
39
|
-
extend Configurable
|
40
53
|
extend SettingsAccessible
|
54
|
+
extend Stateful
|
41
55
|
|
42
56
|
EMPTINESS = Object.new.freeze
|
43
57
|
|
44
58
|
module_function
|
45
59
|
|
46
|
-
def library
|
47
|
-
Thread.current[:fino_library] ||= Fino::Library.new(configuration)
|
48
|
-
end
|
49
|
-
|
50
|
-
def registry
|
51
|
-
@registry ||= Fino::Registry.new
|
52
|
-
end
|
53
|
-
|
54
60
|
def logger
|
55
61
|
@logger ||= begin
|
56
62
|
require "logger"
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: fino
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.3.
|
4
|
+
version: 1.3.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Egor Iskrenkov
|
@@ -32,6 +32,9 @@ files:
|
|
32
32
|
- LICENSE
|
33
33
|
- README.md
|
34
34
|
- lib/fino.rb
|
35
|
+
- lib/fino/ab_testing/experiment.rb
|
36
|
+
- lib/fino/ab_testing/variant.rb
|
37
|
+
- lib/fino/ab_testing/variant_picker.rb
|
35
38
|
- lib/fino/adapter.rb
|
36
39
|
- lib/fino/cache.rb
|
37
40
|
- lib/fino/cache/memory.rb
|
@@ -47,15 +50,15 @@ files:
|
|
47
50
|
- lib/fino/pipe/cache.rb
|
48
51
|
- lib/fino/pipe/storage.rb
|
49
52
|
- lib/fino/pipeline.rb
|
53
|
+
- lib/fino/pretty_inspectable.rb
|
50
54
|
- lib/fino/registry.rb
|
51
55
|
- lib/fino/setting.rb
|
56
|
+
- lib/fino/setting_builder.rb
|
52
57
|
- lib/fino/settings/boolean.rb
|
53
58
|
- lib/fino/settings/float.rb
|
54
59
|
- lib/fino/settings/integer.rb
|
55
60
|
- lib/fino/settings/section.rb
|
56
61
|
- lib/fino/settings/string.rb
|
57
|
-
- lib/fino/variant.rb
|
58
|
-
- lib/fino/variant_picker.rb
|
59
62
|
- lib/fino/version.rb
|
60
63
|
homepage: https://github.com/eiskrenkov/fino
|
61
64
|
licenses:
|
data/lib/fino/variant.rb
DELETED
data/lib/fino/variant_picker.rb
DELETED
@@ -1,27 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
class Fino::VariantPicker
|
4
|
-
SCALING_FACTOR = 1_000
|
5
|
-
|
6
|
-
attr_reader :setting
|
7
|
-
|
8
|
-
def initialize(setting)
|
9
|
-
@setting = setting
|
10
|
-
end
|
11
|
-
|
12
|
-
def call(scope)
|
13
|
-
return nil if setting.variants.empty?
|
14
|
-
|
15
|
-
random = Zlib.crc32("#{setting.key}#{scope}") % (100 * SCALING_FACTOR)
|
16
|
-
cumulative = 0
|
17
|
-
|
18
|
-
picked_variant = setting.variants.sort_by(&:percentage).find do |variant|
|
19
|
-
cumulative += variant.percentage * SCALING_FACTOR
|
20
|
-
random <= cumulative
|
21
|
-
end
|
22
|
-
|
23
|
-
Fino.logger.debug { "Variant picked: #{picked_variant}" }
|
24
|
-
|
25
|
-
picked_variant
|
26
|
-
end
|
27
|
-
end
|