togglecraft 1.0.0
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 +7 -0
- data/CHANGELOG.md +108 -0
- data/LICENSE +18 -0
- data/README.md +347 -0
- data/lib/togglecraft/cache.rb +151 -0
- data/lib/togglecraft/cache_adapters/memory_adapter.rb +54 -0
- data/lib/togglecraft/client.rb +568 -0
- data/lib/togglecraft/connection_pool.rb +134 -0
- data/lib/togglecraft/evaluator.rb +309 -0
- data/lib/togglecraft/shared_sse_connection.rb +266 -0
- data/lib/togglecraft/sse_connection.rb +296 -0
- data/lib/togglecraft/utils.rb +179 -0
- data/lib/togglecraft/version.rb +5 -0
- data/lib/togglecraft.rb +19 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/togglecraft/cache/memory_adapter_spec.rb +128 -0
- data/spec/togglecraft/cache_spec.rb +274 -0
- data/spec/togglecraft/client_spec.rb +728 -0
- data/spec/togglecraft/connection_pool_spec.rb +178 -0
- data/spec/togglecraft/evaluator_spec.rb +443 -0
- data/spec/togglecraft/shared_sse_connection_spec.rb +585 -0
- data/spec/togglecraft/sse_connection_spec.rb +691 -0
- data/spec/togglecraft/utils_spec.rb +506 -0
- metadata +151 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6e77c8218c124ce83fbdc25f2f2975f50ca69fde64b3109116e639580ac5be42
|
|
4
|
+
data.tar.gz: 872451734b10596e4b11337275454a998605a7966ff7e9f5d575c4764ff2a89e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3353a6384c9c2ab564c5244f0c015a723c403287213e6fa6542c0b8cbfbb49bf804d303f3c1d51510c1a07858884b2626a0128a05744d2d0a7e394ad6f1f43b0
|
|
7
|
+
data.tar.gz: 6a31210c7fb15839f8b5bbb2b85b37c13129f00ea26a57796d885331a5cf981e6c4eefaf7ad69fdd882a05a29acb2b9175321784892a750d087ef9deba4c4e96
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.0.0] - 2025-10-26
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
**Core Features:**
|
|
13
|
+
- Real-time feature flag evaluation with Server-Sent Events (SSE)
|
|
14
|
+
- Three flag types: boolean, multivariate, and percentage rollout
|
|
15
|
+
- Advanced targeting rules with 14 operators (equals, contains, regex, semver, etc.)
|
|
16
|
+
- Scheduled rollout stages with automatic percentage transitions
|
|
17
|
+
- Local flag evaluation for zero-latency performance
|
|
18
|
+
- Smart caching with TTL support (memory adapter included)
|
|
19
|
+
- Thread-safe connection pooling for multi-client efficiency
|
|
20
|
+
- Event system for real-time updates (ready, flags_updated, disconnected, error, reconnecting, rollout_stage_changed)
|
|
21
|
+
|
|
22
|
+
**Client Features:**
|
|
23
|
+
- `Client#enabled?` - Boolean flag evaluation
|
|
24
|
+
- `Client#variant` - Multivariate flag evaluation with weighted distribution
|
|
25
|
+
- `Client#in_percentage?` - Percentage rollout evaluation with consistent hashing
|
|
26
|
+
- `Client#wait_for_ready` - Synchronous initialization support
|
|
27
|
+
- `Client#on`/`once`/`off` - Event listener management
|
|
28
|
+
- Automatic reconnection with exponential backoff and hybrid strategy
|
|
29
|
+
- Graceful degradation with default values
|
|
30
|
+
|
|
31
|
+
**Connection Management:**
|
|
32
|
+
- Shared SSE connection pooling (default)
|
|
33
|
+
- Dedicated SSE connections (opt-in)
|
|
34
|
+
- Heartbeat mechanism with jitter (0-30s) to prevent thundering herd
|
|
35
|
+
- Zombie connection prevention (critical for billing accuracy)
|
|
36
|
+
- Automatic reconnection with exponential backoff
|
|
37
|
+
- Hybrid reconnection strategy (fast mode → slow mode)
|
|
38
|
+
|
|
39
|
+
**Developer Experience:**
|
|
40
|
+
- Comprehensive RSpec test suite (375 tests, 87.36% coverage)
|
|
41
|
+
- Full YARD documentation
|
|
42
|
+
- Thread-safe for Puma, Sidekiq, and multi-threaded environments
|
|
43
|
+
- Rails-friendly with initializer examples
|
|
44
|
+
- Zero-build philosophy compatibility
|
|
45
|
+
- Debug logging support
|
|
46
|
+
|
|
47
|
+
**Documentation:**
|
|
48
|
+
- Complete README with quick start guide
|
|
49
|
+
- API reference documentation
|
|
50
|
+
- Advanced configuration guide
|
|
51
|
+
- Framework integration examples (Rails, Sidekiq)
|
|
52
|
+
- Troubleshooting guide
|
|
53
|
+
- Security policy (SECURITY.md)
|
|
54
|
+
- Publishing guide (PUBLISHING.md)
|
|
55
|
+
|
|
56
|
+
### Technical Highlights
|
|
57
|
+
|
|
58
|
+
- **Zombie Connection Prevention:** Heartbeat only sent when SSE connection is actually open, preventing billing for dead connections
|
|
59
|
+
- **Consistent Hashing:** Same user always gets same variant/percentage result across all SDKs
|
|
60
|
+
- **Jitter Support:** Version update fetching includes configurable jitter (default 1500ms) to prevent thundering herd
|
|
61
|
+
- **Thread Safety:** Uses `Concurrent::Map`, `Concurrent::AtomicBoolean`, and proper mutex locking throughout
|
|
62
|
+
- **Connection Pooling:** Multiple clients can share a single SSE connection for efficiency
|
|
63
|
+
- **Rollout Stage Polling:** Automatic detection of scheduled percentage increases (every 60s, configurable)
|
|
64
|
+
|
|
65
|
+
### Dependencies
|
|
66
|
+
|
|
67
|
+
**Runtime:**
|
|
68
|
+
- `concurrent-ruby` ~> 1.2 (thread-safe data structures)
|
|
69
|
+
- `http` ~> 5.0 (HTTP client for SSE and heartbeat)
|
|
70
|
+
|
|
71
|
+
**Development:**
|
|
72
|
+
- `rspec` ~> 3.12 (testing framework)
|
|
73
|
+
- `rubocop` ~> 1.59 (linting)
|
|
74
|
+
- `simplecov` ~> 0.22 (code coverage)
|
|
75
|
+
- `webmock` ~> 3.19 (HTTP mocking)
|
|
76
|
+
|
|
77
|
+
### Requirements
|
|
78
|
+
|
|
79
|
+
- Ruby 3.0 or higher
|
|
80
|
+
- Compatible with Rails 6.0+, Sinatra, Hanami, and standalone Ruby applications
|
|
81
|
+
|
|
82
|
+
### Performance
|
|
83
|
+
|
|
84
|
+
- **Zero-latency local evaluation:** All flag evaluation happens locally after initial SSE connection
|
|
85
|
+
- **Minimal bandwidth:** Only flag changes trigger SSE messages
|
|
86
|
+
- **Efficient connection pooling:** Share one SSE connection across multiple client instances
|
|
87
|
+
- **Smart caching:** Reduces server load with configurable TTL
|
|
88
|
+
|
|
89
|
+
### Security
|
|
90
|
+
|
|
91
|
+
- MFA required for RubyGems publishing (`rubygems_mfa_required: true`)
|
|
92
|
+
- No credentials in code (SDK key passed at initialization)
|
|
93
|
+
- HTTPS-only connections (enforced)
|
|
94
|
+
- Security policy documented in SECURITY.md
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## [Unreleased]
|
|
99
|
+
|
|
100
|
+
### Planned Features
|
|
101
|
+
- Redis cache adapter
|
|
102
|
+
- Custom cache adapter support
|
|
103
|
+
- Additional targeting operators
|
|
104
|
+
- Metrics and analytics integration
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
[1.0.0]: https://github.com/togglecraft/ruby-sdk/releases/tag/v1.0.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 ToggleCraft
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
6
|
+
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
|
7
|
+
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
12
|
+
portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
15
|
+
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
16
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
18
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# ToggleCraft Ruby SDK
|
|
2
|
+
|
|
3
|
+
A lightweight, real-time feature flag SDK for Ruby applications. Thread-safe, Rails-friendly, and built for production.
|
|
4
|
+
|
|
5
|
+
## Why ToggleCraft?
|
|
6
|
+
|
|
7
|
+
- 🚀 **Real-time Updates** - Instant flag changes via Server-Sent Events
|
|
8
|
+
- 🧵 **Thread-Safe** - Built for Puma, Sidekiq, and multi-threaded environments
|
|
9
|
+
- 💾 **Smart Caching** - Works offline with memory or Redis support
|
|
10
|
+
- 🔒 **Production Ready** - 214 passing tests with comprehensive coverage
|
|
11
|
+
- 🌐 **Universal** - Works with Rails, Sinatra, Hanami, and standalone Ruby
|
|
12
|
+
- ♻️ **Auto-reconnection** - Resilient connection handling
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Add to your Gemfile:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem 'togglecraft'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or install directly:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
gem install togglecraft
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
### Ruby
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
require 'togglecraft'
|
|
34
|
+
|
|
35
|
+
# Initialize client
|
|
36
|
+
client = ToggleCraft::Client.new(sdk_key: 'your-sdk-key')
|
|
37
|
+
|
|
38
|
+
# Connect and wait for flags
|
|
39
|
+
client.connect
|
|
40
|
+
client.wait_for_ready
|
|
41
|
+
|
|
42
|
+
# Use your flags
|
|
43
|
+
if client.enabled?('new-feature', user: { id: current_user.id })
|
|
44
|
+
# Feature is enabled
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Rails
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# config/initializers/togglecraft.rb
|
|
52
|
+
Rails.application.config.togglecraft = ToggleCraft::Client.new(
|
|
53
|
+
sdk_key: ENV['TOGGLECRAFT_SDK_KEY'],
|
|
54
|
+
cache_adapter: :memory,
|
|
55
|
+
logger: Rails.logger,
|
|
56
|
+
debug: Rails.env.development?
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
Rails.application.config.togglecraft.connect
|
|
60
|
+
|
|
61
|
+
at_exit do
|
|
62
|
+
Rails.application.config.togglecraft.destroy
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Then use in your controllers:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
class DashboardController < ApplicationController
|
|
70
|
+
def index
|
|
71
|
+
if togglecraft.enabled?('premium-dashboard', user: { id: current_user.id })
|
|
72
|
+
render :premium
|
|
73
|
+
else
|
|
74
|
+
render :standard
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def togglecraft
|
|
81
|
+
Rails.application.config.togglecraft
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Basic Usage
|
|
87
|
+
|
|
88
|
+
### Boolean Flags
|
|
89
|
+
|
|
90
|
+
Simple on/off feature toggles:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
if client.enabled?('dark-mode', user: { id: '123' })
|
|
94
|
+
enable_dark_mode
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Multivariate Flags
|
|
99
|
+
|
|
100
|
+
A/B/n testing with multiple variants:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
variant = client.variant('checkout-flow', user: { id: '123' })
|
|
104
|
+
|
|
105
|
+
case variant
|
|
106
|
+
when 'one-page'
|
|
107
|
+
render_one_page_checkout
|
|
108
|
+
when 'multi-step'
|
|
109
|
+
render_multi_step_checkout
|
|
110
|
+
else
|
|
111
|
+
render_default_checkout
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Percentage Rollouts
|
|
116
|
+
|
|
117
|
+
Gradual feature rollouts:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
if client.in_percentage?('new-algorithm', user: { id: '123' })
|
|
121
|
+
use_new_algorithm
|
|
122
|
+
else
|
|
123
|
+
use_legacy_algorithm
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Configuration
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
client = ToggleCraft::Client.new(
|
|
131
|
+
# Required
|
|
132
|
+
sdk_key: 'your-sdk-key', # Get from ToggleCraft dashboard
|
|
133
|
+
|
|
134
|
+
# Optional - Common settings
|
|
135
|
+
enable_cache: true, # Enable caching (default: true)
|
|
136
|
+
cache_adapter: :memory, # :memory or :redis (default: :memory)
|
|
137
|
+
cache_ttl: 300, # Cache TTL in seconds (default: 5 minutes)
|
|
138
|
+
debug: false # Enable debug logging (default: false)
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Need more control?** See [Advanced Configuration →](docs/ADVANCED.md)
|
|
143
|
+
|
|
144
|
+
## Evaluation Context
|
|
145
|
+
|
|
146
|
+
The context object provides data for targeting rules:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
context = {
|
|
150
|
+
user: {
|
|
151
|
+
id: 'user-123', # Required for consistent evaluation
|
|
152
|
+
email: 'user@example.com',
|
|
153
|
+
plan: 'premium',
|
|
154
|
+
# Add any custom attributes you need
|
|
155
|
+
role: 'admin',
|
|
156
|
+
company_id: 'acme-corp'
|
|
157
|
+
},
|
|
158
|
+
request: {
|
|
159
|
+
ip: '192.168.1.1',
|
|
160
|
+
country: 'US'
|
|
161
|
+
},
|
|
162
|
+
device: {
|
|
163
|
+
type: 'mobile',
|
|
164
|
+
os: 'iOS'
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
client.enabled?('premium-feature', context)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
You can add any custom properties - the SDK evaluates all attributes using dot notation (e.g., `user.role`, `request.country`).
|
|
172
|
+
|
|
173
|
+
## Framework Integration
|
|
174
|
+
|
|
175
|
+
### Rails
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
# config/initializers/togglecraft.rb
|
|
179
|
+
Rails.application.config.togglecraft = ToggleCraft::Client.new(
|
|
180
|
+
sdk_key: ENV['TOGGLECRAFT_SDK_KEY'],
|
|
181
|
+
cache_adapter: :memory,
|
|
182
|
+
logger: Rails.logger
|
|
183
|
+
)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
[Full Rails Integration Guide →](docs/FRAMEWORKS.md#rails)
|
|
187
|
+
|
|
188
|
+
### Sidekiq
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
class FeatureWorker
|
|
192
|
+
include Sidekiq::Worker
|
|
193
|
+
|
|
194
|
+
def togglecraft
|
|
195
|
+
@togglecraft ||= ToggleCraft::Client.new(
|
|
196
|
+
sdk_key: ENV['TOGGLECRAFT_SDK_KEY'],
|
|
197
|
+
share_connection: true # Share connection with other workers
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def perform(user_id)
|
|
202
|
+
togglecraft.connect unless togglecraft.connected?
|
|
203
|
+
|
|
204
|
+
if togglecraft.enabled?('batch-processing', user: { id: user_id })
|
|
205
|
+
# Use new batch processing
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
[Full Sidekiq Integration Guide →](docs/FRAMEWORKS.md#sidekiq)
|
|
212
|
+
|
|
213
|
+
## Event Handling
|
|
214
|
+
|
|
215
|
+
Listen for real-time updates:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
# Flags are ready
|
|
219
|
+
client.on(:ready) do
|
|
220
|
+
puts 'Client ready!'
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Flags updated in real-time
|
|
224
|
+
client.on(:flags_updated) do |flags|
|
|
225
|
+
puts "Flags updated: #{flags.keys.join(', ')}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Connection lost
|
|
229
|
+
client.on(:disconnected) do
|
|
230
|
+
puts 'Disconnected - using cached flags'
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Error occurred
|
|
234
|
+
client.on(:error) do |error|
|
|
235
|
+
logger.error "ToggleCraft error: #{error}"
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Error Handling
|
|
240
|
+
|
|
241
|
+
Always provide default values and handle errors gracefully:
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
# Safe evaluation with defaults
|
|
245
|
+
is_enabled = client.enabled?('feature', context, default: false)
|
|
246
|
+
# Returns false if flag doesn't exist or on error
|
|
247
|
+
|
|
248
|
+
# Handle connection errors
|
|
249
|
+
begin
|
|
250
|
+
client.connect
|
|
251
|
+
rescue StandardError => e
|
|
252
|
+
logger.error "Failed to connect: #{e}"
|
|
253
|
+
# App still works with cached values or defaults
|
|
254
|
+
end
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Best Practices
|
|
258
|
+
|
|
259
|
+
1. **Initialize Once** - Create a single client instance and reuse it
|
|
260
|
+
2. **Always Provide Context** - Include at least `user.id` for consistent evaluation
|
|
261
|
+
3. **Use Default Values** - Handle missing flags gracefully
|
|
262
|
+
4. **Clean Up on Shutdown** - Call `client.destroy` when closing
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
# On application shutdown
|
|
266
|
+
at_exit do
|
|
267
|
+
client.destroy
|
|
268
|
+
end
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## API Reference
|
|
272
|
+
|
|
273
|
+
**Core Methods:**
|
|
274
|
+
- `enabled?(flag_key, context = {}, default: false)` - Check boolean flags
|
|
275
|
+
- `variant(flag_key, context = {}, default: nil)` - Get multivariate variant
|
|
276
|
+
- `in_percentage?(flag_key, context = {}, default: false)` - Check percentage rollout
|
|
277
|
+
|
|
278
|
+
**Connection:**
|
|
279
|
+
- `connect` - Connect to SSE server
|
|
280
|
+
- `disconnect` - Disconnect from server
|
|
281
|
+
- `ready?` - Check if client has flags loaded
|
|
282
|
+
- `wait_for_ready(timeout: 5)` - Wait for client to be ready
|
|
283
|
+
|
|
284
|
+
[Full API Documentation →](docs/API.md)
|
|
285
|
+
|
|
286
|
+
## Advanced Features
|
|
287
|
+
|
|
288
|
+
Power users can customize:
|
|
289
|
+
- Connection pooling and reconnection strategies
|
|
290
|
+
- Scheduled rollout stages with automatic transitions
|
|
291
|
+
- Redis cache adapter
|
|
292
|
+
- Hybrid reconnection with exponential backoff
|
|
293
|
+
|
|
294
|
+
[Advanced Features Guide →](docs/ADVANCED.md)
|
|
295
|
+
|
|
296
|
+
## Troubleshooting
|
|
297
|
+
|
|
298
|
+
**Client not connecting?**
|
|
299
|
+
- Verify your SDK key is correct
|
|
300
|
+
- Check that you called `connect` before using the client
|
|
301
|
+
- Ensure your firewall allows connections to `sse.togglecraft.io`
|
|
302
|
+
|
|
303
|
+
**Flags not updating?**
|
|
304
|
+
- Verify the SSE connection is established (`client.connected?`)
|
|
305
|
+
- Check the logs for error messages
|
|
306
|
+
- Enable debug mode: `debug: true`
|
|
307
|
+
|
|
308
|
+
[More Troubleshooting →](docs/TROUBLESHOOTING.md)
|
|
309
|
+
|
|
310
|
+
## Requirements
|
|
311
|
+
|
|
312
|
+
- **Ruby 3.0+**
|
|
313
|
+
- **Dependencies:**
|
|
314
|
+
- `concurrent-ruby` (~> 1.2) - Thread-safe data structures
|
|
315
|
+
- `http` (~> 5.0) - HTTP client for API requests
|
|
316
|
+
- `semantic` (~> 1.6) - Semantic version comparison
|
|
317
|
+
|
|
318
|
+
## Thread Safety
|
|
319
|
+
|
|
320
|
+
This SDK is fully thread-safe and production-ready for:
|
|
321
|
+
- **Puma** - Multi-threaded Rails server
|
|
322
|
+
- **Sidekiq** - Background job processing
|
|
323
|
+
- **Any multi-threaded Ruby environment**
|
|
324
|
+
|
|
325
|
+
All critical sections use `Concurrent::Map`, `Mutex`, and `Concurrent::AtomicBoolean` for thread safety.
|
|
326
|
+
|
|
327
|
+
## Documentation
|
|
328
|
+
|
|
329
|
+
- [API Reference](docs/API.md) - Complete API documentation
|
|
330
|
+
- [Advanced Features](docs/ADVANCED.md) - Connection pooling, rollout stages, custom configuration
|
|
331
|
+
- [Framework Integration](docs/FRAMEWORKS.md) - Rails, Sidekiq, and other framework guides
|
|
332
|
+
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
|
|
333
|
+
- [Security](SECURITY.md) - Security best practices and policies
|
|
334
|
+
|
|
335
|
+
## License
|
|
336
|
+
|
|
337
|
+
MIT
|
|
338
|
+
|
|
339
|
+
## Contributing
|
|
340
|
+
|
|
341
|
+
Contributions are welcome! Please open an issue or submit a pull request on GitHub.
|
|
342
|
+
|
|
343
|
+
## Support
|
|
344
|
+
|
|
345
|
+
- **Documentation:** [GitHub Repository](https://github.com/togglecraft/ruby-sdk)
|
|
346
|
+
- **Issues:** [GitHub Issues](https://github.com/togglecraft/ruby-sdk/issues)
|
|
347
|
+
- **Security:** See [SECURITY.md](SECURITY.md) for reporting vulnerabilities
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'cache_adapters/memory_adapter'
|
|
4
|
+
|
|
5
|
+
module ToggleCraft
|
|
6
|
+
# Cache manager with TTL support and multiple adapters
|
|
7
|
+
class Cache
|
|
8
|
+
attr_reader :adapter, :default_ttl
|
|
9
|
+
|
|
10
|
+
# Initialize the cache
|
|
11
|
+
# @param adapter [Symbol, Object] The cache adapter (:memory or custom adapter instance)
|
|
12
|
+
# @param ttl [Integer] Default TTL in seconds (default: 300 = 5 minutes)
|
|
13
|
+
def initialize(adapter: :memory, ttl: 300)
|
|
14
|
+
@default_ttl = ttl
|
|
15
|
+
@adapter = case adapter
|
|
16
|
+
when :memory
|
|
17
|
+
CacheAdapters::MemoryAdapter.new
|
|
18
|
+
else
|
|
19
|
+
adapter
|
|
20
|
+
end
|
|
21
|
+
@cleanup_thread = nil
|
|
22
|
+
start_cleanup
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Get a value from cache
|
|
26
|
+
# Returns nil if key doesn't exist or is expired
|
|
27
|
+
# @param key [String] The cache key
|
|
28
|
+
# @return [Object, nil] The cached value or nil
|
|
29
|
+
def get(key)
|
|
30
|
+
entry = @adapter.get(key)
|
|
31
|
+
return nil unless entry
|
|
32
|
+
return nil if expired?(entry)
|
|
33
|
+
|
|
34
|
+
entry[:value]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Set a value in cache with optional TTL override
|
|
38
|
+
# @param key [String] The cache key
|
|
39
|
+
# @param value [Object] The value to cache
|
|
40
|
+
# @param ttl [Integer, nil] Optional TTL override in seconds
|
|
41
|
+
def set(key, value, ttl: nil)
|
|
42
|
+
entry = {
|
|
43
|
+
value: value,
|
|
44
|
+
timestamp: Time.now.to_f,
|
|
45
|
+
ttl: ttl || @default_ttl
|
|
46
|
+
}
|
|
47
|
+
@adapter.set(key, entry)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Delete a value from cache
|
|
51
|
+
# @param key [String] The cache key
|
|
52
|
+
# @return [Boolean] true if deleted, false otherwise
|
|
53
|
+
def delete(key)
|
|
54
|
+
@adapter.delete(key)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Clear all cache entries
|
|
58
|
+
def clear
|
|
59
|
+
@adapter.clear
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if a key exists in cache (and is not expired)
|
|
63
|
+
# @param key [String] The cache key
|
|
64
|
+
# @return [Boolean]
|
|
65
|
+
def has?(key)
|
|
66
|
+
entry = @adapter.get(key)
|
|
67
|
+
return false unless entry
|
|
68
|
+
return false if expired?(entry)
|
|
69
|
+
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Delete all keys matching a prefix
|
|
74
|
+
# @param prefix [String] The prefix to match
|
|
75
|
+
def clear_by_prefix(prefix)
|
|
76
|
+
keys_to_delete = @adapter.keys.select { |key| key.start_with?(prefix) }
|
|
77
|
+
keys_to_delete.each { |key| delete(key) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Get all cached flags (non-expired)
|
|
81
|
+
# @return [Hash] Hash of flag_key => flag_data
|
|
82
|
+
def all_flags
|
|
83
|
+
flags = {}
|
|
84
|
+
@adapter.keys.each do |key|
|
|
85
|
+
next unless key.start_with?('flag:')
|
|
86
|
+
|
|
87
|
+
flag_key = key.sub('flag:', '')
|
|
88
|
+
value = get(key)
|
|
89
|
+
flags[flag_key] = value if value
|
|
90
|
+
end
|
|
91
|
+
flags
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Store all flags at once
|
|
95
|
+
# @param flags [Hash] Hash of flag_key => flag_data
|
|
96
|
+
# @param ttl [Integer, nil] Optional TTL override
|
|
97
|
+
def set_all_flags(flags, ttl: nil)
|
|
98
|
+
flags.each do |flag_key, flag_data|
|
|
99
|
+
set("flag:#{flag_key}", flag_data, ttl: ttl)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Clean up resources and stop background cleanup
|
|
104
|
+
def destroy
|
|
105
|
+
stop_cleanup
|
|
106
|
+
clear
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# Check if an entry is expired
|
|
112
|
+
# @param entry [Hash] The cache entry
|
|
113
|
+
# @return [Boolean]
|
|
114
|
+
def expired?(entry)
|
|
115
|
+
return false unless entry[:timestamp] && entry[:ttl]
|
|
116
|
+
|
|
117
|
+
Time.now.to_f - entry[:timestamp] > entry[:ttl]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Start periodic cleanup of expired entries
|
|
121
|
+
def start_cleanup
|
|
122
|
+
return if @cleanup_thread
|
|
123
|
+
|
|
124
|
+
@cleanup_thread = Thread.new do
|
|
125
|
+
loop do
|
|
126
|
+
sleep 60 # Run cleanup every minute
|
|
127
|
+
cleanup_expired
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
warn "[ToggleCraft Cache] Cleanup thread error: #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
@cleanup_thread.abort_on_exception = false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Clean up expired entries
|
|
136
|
+
def cleanup_expired
|
|
137
|
+
@adapter.keys.each do |key|
|
|
138
|
+
entry = @adapter.get(key)
|
|
139
|
+
delete(key) if entry && expired?(entry)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Stop the cleanup thread
|
|
144
|
+
def stop_cleanup
|
|
145
|
+
return unless @cleanup_thread
|
|
146
|
+
|
|
147
|
+
@cleanup_thread.kill
|
|
148
|
+
@cleanup_thread = nil
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent'
|
|
4
|
+
|
|
5
|
+
module ToggleCraft
|
|
6
|
+
module CacheAdapters
|
|
7
|
+
# In-memory cache adapter using thread-safe Concurrent::Map
|
|
8
|
+
# Suitable for single-process applications
|
|
9
|
+
class MemoryAdapter
|
|
10
|
+
def initialize
|
|
11
|
+
@store = Concurrent::Map.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Get a value from the store
|
|
15
|
+
# @param key [String] The cache key
|
|
16
|
+
# @return [Object, nil] The stored value or nil
|
|
17
|
+
def get(key)
|
|
18
|
+
@store[key]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Set a value in the store
|
|
22
|
+
# @param key [String] The cache key
|
|
23
|
+
# @param value [Object] The value to store
|
|
24
|
+
def set(key, value)
|
|
25
|
+
@store[key] = value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Delete a value from the store
|
|
29
|
+
# @param key [String] The cache key
|
|
30
|
+
# @return [Boolean] true if deleted, false otherwise
|
|
31
|
+
def delete(key)
|
|
32
|
+
!@store.delete(key).nil?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Clear all values from the store
|
|
36
|
+
def clear
|
|
37
|
+
@store.clear
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Check if a key exists in the store
|
|
41
|
+
# @param key [String] The cache key
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def has?(key)
|
|
44
|
+
@store.key?(key)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get all keys in the store
|
|
48
|
+
# @return [Array<String>]
|
|
49
|
+
def keys
|
|
50
|
+
@store.keys
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|