cru-flags 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4eab9de685d2ede692ce24cf2b1db8d939b9f316e3ede26c6d4ef4796bee6ffe
4
- data.tar.gz: 2db33c91a5571087296900d9edf2ec012c397aa6bab028d731bfefde372d2c81
3
+ metadata.gz: f7095f1356c7bbd71e37d86dbcd3b2bd4b3fdfd8eab1596f72f4b3d0e7aced11
4
+ data.tar.gz: 0b7ad1b83c3346b6cf4e4ab1e6c471e7ee7c2ae19e2c1baa7fc752b08e40a3b4
5
5
  SHA512:
6
- metadata.gz: 131419c9830fafc66f0a169cf756f2ea0017e635dd877a3b0d50f2227973aa6f6e4462af2dae4bec516b45bee9e5399800a8f72daa331daf439122cca3fea41a
7
- data.tar.gz: 3fa93d5e3b293f540e8f354b0bf091737e76b19ebdace9ad3a610d7444f41203661f2bf4d384c5162cf084b7149ed519b45a16082eaedb5280b45c93fc86ec3c
6
+ metadata.gz: 57b7ca9b139f2880951c8365c86dcfe613c52cc4f287ba3e4960da4057df4cb159789506ff48726e2c3bbab70e2aeca1be0d2df9e59b94996ddd43c017b088bd
7
+ data.tar.gz: 0d657b212c65ade4858620199c18ca91bae2e810d9382cedf67ef1ba00798aaf3933dd31f2ffaef6907a51263a11fba8bfc8f3c17662ac42761025c979a3a52f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0](https://github.com/CruGlobal/cru-flags-ruby/compare/cru-flags/v0.1.2...cru-flags/v0.2.0) (2026-10-29)
4
+
5
+
6
+ ### Features
7
+
8
+ * keep a fallback copy of the flag document in Rails.cache ([#12](https://github.com/CruGlobal/cru-flags-ruby/issues/12)) ([cf03f02](https://github.com/CruGlobal/cru-flags-ruby/commit/cf03f02adf998774ae1b258af5685ddb668140d5))
9
+
10
+ ## [0.1.2](https://github.com/CruGlobal/cru-flags-ruby/compare/cru-flags/v0.1.1...cru-flags/v0.1.2) (2026-08-24)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * gem "cru-flags" with no require: now actually loads the gem ([#9](https://github.com/CruGlobal/cru-flags-ruby/issues/9)) ([05ce44c](https://github.com/CruGlobal/cru-flags-ruby/commit/05ce44c1b68673487d07d669e96f191c701658d7))
16
+
3
17
  ## [0.1.1](https://github.com/CruGlobal/cru-flags-ruby/compare/cru-flags/v0.1.0...cru-flags/v0.1.1) (2026-08-24)
4
18
 
5
19
 
data/README.md CHANGED
@@ -32,4 +32,10 @@ until the first successful fetch; after that, an outage means flags stop
32
32
  changing — never that they turn off. Reads never raise and never do I/O.
33
33
  Health transitions (and only transitions) are logged.
34
34
 
35
+ In Rails, the client also keeps a copy of the last good document in
36
+ `Rails.cache` (no expiry). A process that boots while the flag service is down
37
+ serves that copy instead of every flag off. To opt out, set
38
+ `CruFlags.fallback_store = nil` anywhere in your app's config (application,
39
+ environment file, or initializer).
40
+
35
41
  CRuby >= 3.2 only. See [docs/design.md](docs/design.md) for everything else.
data/docs/design.md CHANGED
@@ -44,6 +44,9 @@ underneath it. What it replaces, and why, is §4.
44
44
  background mode), and never changes answer because the network broke.
45
45
  - A read-only `CruFlags::FlipperAdapter` over the snapshot, plus a Railtie
46
46
  that wires it into Flipper with zero app code.
47
+ - An optional cross-process **fallback copy** of the last good document
48
+ (`Rails.cache` by default in Rails), read only by a process that boots
49
+ while the service is down (§6.1).
47
50
  - Stdlib-only client core (`net/http`, `json`, `thread`); Ruby >= 3.2.
48
51
 
49
52
  ### Out of scope
@@ -66,9 +69,11 @@ underneath it. What it replaces, and why, is §4.
66
69
  - We do **not** raise on misconfiguration. An application missing
67
70
  `CRU_FLAGS_URL` must still boot and serve traffic, with every flag off —
68
71
  exactly ararat's current Memory-adapter fallback posture.
69
- - We do **not** cache in `Rails.cache`. The snapshot is process-local memory;
70
- there is no TTL, no Marshal round-trip, and therefore no TTL cliff and no
71
- default_proc trap (§4).
72
+ - We do **not** read flags through `Rails.cache`. The snapshot is
73
+ process-local memory; there is no TTL, no Marshal round-trip, and therefore
74
+ no TTL cliff and no default_proc trap (§4). The §6.1 fallback copy is not a
75
+ read-through cache: it is written with no expiry, stored as a JSON string,
76
+ and read only by a process that has no snapshot at all.
72
77
 
73
78
  ---
74
79
 
@@ -134,8 +139,12 @@ CruFlags::Client.new(
134
139
  poll_seconds: 30.0, # refresh interval, ±20% jitter
135
140
  fetch_timeout: 2.0, # per-request open AND read timeout
136
141
  on_error: nil, # nil -> log to the "cru_flags" logger (§3.6)
137
- refresh_mode: nil # nil -> read CRU_FLAGS_REFRESH_MODE, else "background"
142
+ refresh_mode: nil, # nil -> read CRU_FLAGS_REFRESH_MODE, else "background"
143
+ fallback_store: nil # nil -> no fallback copy; a store, or a Proc returning one (§6.1)
138
144
  )
145
+
146
+ CruFlags.fallback_store = nil # opt out; set anywhere in app config
147
+ CruFlags.fallback_store = -> { Rails.cache } # a store, or a Proc returning one
139
148
  ```
140
149
 
141
150
  ### 3.1 `CruFlags` module methods
@@ -145,6 +154,15 @@ The module methods delegate to a lazily-constructed process-wide
145
154
  module-level `flags`. Requiring the gem does **nothing**: no environment
146
155
  read, no socket, no thread (§7). The singleton is constructed on first use,
147
156
  so tests and dotenv can set `CRU_FLAGS_URL` any time before the first lookup.
157
+ The singleton resolves `CruFlags.fallback_store` on every use, not once at
158
+ construction (§6.1): an explicit assignment (nil included) if there was one,
159
+ else `CruFlags.default_fallback_store`, the Railtie's `-> { Rails.cache }`. A
160
+ Proc is called on each use. That matters in `config/application.rb` and
161
+ environment files, where `Rails.cache` is still nil (Bootstrap has not built
162
+ it yet): a bare `Rails.cache` there assigns nil and turns the fallback off,
163
+ while `-> { Rails.cache }` works anywhere. `default_fallback_store=` belongs
164
+ to the Railtie, which overwrites it unconditionally; apps use
165
+ `fallback_store=`.
148
166
 
149
167
  ### 3.2 `enabled?(name) -> bool`
150
168
 
@@ -156,24 +174,26 @@ Symbol; Symbols are converted with `to_s` (the document's keys are strings;
156
174
  Flipper call sites pass symbols).
157
175
 
158
176
  Unknown flag, unset flag, no document yet, no `CRU_FLAGS_URL`, service down
159
- before the first success — all `false`.
177
+ before the first success with no fallback copy (§6.1) — all `false`.
160
178
 
161
179
  ### 3.3 `ready(timeout: nil) -> bool`
162
180
 
163
181
  Blocks until the **first fetch attempt completes** — success *or* failure —
164
182
  and returns whether it completed within `timeout`. Returns `true` after a
165
- failed first attempt (the attempt did complete, and "all off" is the final
166
- answer until the next poll). Returns `false` immediately, without blocking,
183
+ failed first attempt (the attempt did complete, and its answer stands until
184
+ the next poll: all off, or the §6.1 fallback copy). Returns `false` immediately, without blocking,
167
185
  when the client is inert (no URL) — there is no attempt to wait for and never
168
186
  will be. In on-demand mode `ready` *performs* the first attempt like any
169
187
  other read, so `timeout:` is unused there — the call is bounded by the fetch
170
188
  timeouts (up to ~2s by default) regardless of a smaller `timeout:` value,
171
- exactly as the python client documents. Never raises.
189
+ exactly as the python client documents. With a `fallback_store`, the attempt
190
+ also includes at most one store call (§6.1), bounded by the store's own timeouts.
191
+ Never raises.
172
192
 
173
193
  ### 3.4 `snapshot -> Hash`
174
194
 
175
195
  A plain, deep-copied, JSON-serializable Hash of the last document received
176
- (`{}` before the first success). `JSON.generate(CruFlags.snapshot)`
196
+ (`{}` before the first success, unless a §6.1 fallback copy was restored). `JSON.generate(CruFlags.snapshot)`
177
197
  round-trips to exactly what the service sent. The copy exists so a caller
178
198
  cannot mutate library state; the internally stored snapshot is genuinely
179
199
  frozen (§6).
@@ -183,7 +203,8 @@ frozen (§6).
183
203
  Fetches on the *calling* thread and returns whether the snapshot is fresh (an
184
204
  attempt has completed and the most recent one succeeded). Without `force` it
185
205
  is a no-op while the last attempt is younger than `poll_seconds`. Blocks for
186
- at most the fetch timeouts. Never raises. This is the refresh primitive in
206
+ at most the fetch timeouts, plus one fallback-store call when one is
207
+ configured (§6.1). Never raises. This is the refresh primitive in
187
208
  on-demand mode, and an out-of-band poke in background mode — where, per the
188
209
  Node contract, concurrent `refresh` calls and a concurrent poller tick all
189
210
  **share one in-flight request** rather than stacking simultaneous fetches.
@@ -226,7 +247,10 @@ fatal.**
226
247
  post-fork poller, still lock-free and network-free — the guard only takes
227
248
  a lock on the (rare) path where it actually needs to respawn.
228
249
  2. **All flags are `false` until the first successful fetch.** A flag guards
229
- *new* behaviour; the safe answer while ignorant is the old behaviour.
250
+ *new* behaviour; the safe answer while ignorant is the old behaviour. The
251
+ one exception is §6.1: a process whose first fetch fails serves the
252
+ fleet's last good copy if one exists, because a flag that is already on
253
+ for every other process is no longer "new" to the users of this one.
230
254
  3. **Last-known-good persists indefinitely. There is no TTL.** This is the
231
255
  failure-mode semantic §6 of the fleet's original flags design doc (the
232
256
  Google-Doc-era spec, snapshotted as cru-flags-design-doc.md) promised and
@@ -329,6 +353,13 @@ that, in an initializer:
329
353
  projects legitimately have no flag document, and `Flipper.add` is never
330
354
  called by app code (flags are born in the service), so dev-mode `:warn`
331
355
  would fire on every check.
356
+ - in a second initializer, sets `CruFlags.default_fallback_store =
357
+ -> { Rails.cache }` (§6.1). It is a default, not an assignment: a
358
+ `CruFlags.fallback_store =` anywhere in the app (`config/application.rb`
359
+ and environment files run *before* Railtie initializers,
360
+ `config/initializers` after) wins whatever the order, nil included. Both
361
+ are resolved on each use, so a flag read during boot cannot lock in the
362
+ default, and a `Rails.cache` swapped in later is the one used.
332
363
 
333
364
  With `CRU_FLAGS_URL` unset the client is inert, the snapshot is empty, and
334
365
  every flag reads `false` — byte-for-byte ararat's current Memory-adapter
@@ -364,6 +395,68 @@ thawed deep copy so the public API stays ordinary Ruby.
364
395
  Consistency guarantee: a reader sees either the whole old document or the
365
396
  whole new one, never a half-applied update.
366
397
 
398
+ ### 6.1 Fallback copy
399
+
400
+ The snapshot lives in process memory, so a process that boots while the flag
401
+ service is down has nothing: every flag reads `false` until the service comes
402
+ back. For a flag that is off everywhere that changes nothing. For a flag that
403
+ is **on** in production (launched, not yet retired), the new process serves
404
+ the old behaviour while every older process serves the new one, and users see
405
+ the feature come and go between requests. Deploys start new processes, but
406
+ so do autoscaling and crash or health-check restarts, which happen with or
407
+ without a deploy.
408
+
409
+ A client built with `fallback_store:` keeps a second copy of the last good
410
+ document in a store shared across processes (in Rails, `Rails.cache`, so
411
+ Redis in most of the fleet):
412
+
413
+ - **Write**: after every successful tick (`200`, `304`, `404`), write the
414
+ current document as a JSON string under `cru_flags/v1/<url>`, with
415
+ `expires_in: nil`. The URL carries project and environment, so stage and
416
+ production never share a key even on a shared Redis. Rewriting on `304`
417
+ too means an evicted or flushed key comes back within one interval, and
418
+ the fleet converges on the newest document. A `404`'s empty document is
419
+ written like any other: "this project has no flags" is data (§4).
420
+ - **Read**: only on a **failed** tick, and only while the process has no
421
+ document at all. A warm process keeps its own document even if another
422
+ process has since written a newer copy: the shared copy exists to cover a
423
+ process with nothing, and the warm one picks up any change on its next
424
+ successful tick. A found copy is
425
+ parsed with `Document.parse`, so it is validated and deep-frozen exactly
426
+ like a fetched one; anything else (a miss, a non-String, bad JSON, a
427
+ wrong-typed `Flags`) is ignored and the process stays at all-`false`.
428
+ - The restored copy sets **no ETag**, so the first successful fetch after
429
+ recovery is a full `200` that replaces it. Health stays `failing`: the
430
+ fetch did fail, and §3.6 reports it as usual.
431
+ - Both sides run inside `attempt_fetch`, under the fetch mutex, and both
432
+ swallow every exception; the health transition is reported before the
433
+ read, so a broken store can never silence an outage. In background mode
434
+ that is the poller thread, so a slow or dead store costs the poller time,
435
+ never a request. In on-demand mode (§7.1) the fetch already runs on the
436
+ reading thread, so once per interval a request also waits on one store
437
+ call, bounded by the store's own timeouts.
438
+ - Stored as a JSON string, not a Hash, so no cache store ever Marshals a
439
+ frozen tree (the §4 default_proc trap cannot apply).
440
+
441
+ The copy is only as durable as the store. `expires_in: nil` stops a TTL, not
442
+ eviction: a full or LRU-evicting Redis can drop the key, and SolidCache
443
+ removes rows by age (`max_age`, two weeks by default) and evicts oldest
444
+ first, and its upsert does not refresh a row's age. While the service is up,
445
+ the every-tick rewrite puts the copy back within one interval; during an
446
+ outage nothing can. A per-container store (`FileStore`, `MemoryStore`) only
447
+ helps a restart inside the same container. This assumes `CRU_FLAGS_URL` is
448
+ unset during image builds (the fleet injects it at runtime), which leaves the
449
+ client inert; a build that sets it and reads a flag would bake a copy into
450
+ the image's `tmp/cache` under `FileStore`.
451
+
452
+ Trade-off accepted: the copy is only as fresh as the last process that
453
+ reached the service. That is the same promise a warm process makes during an
454
+ outage (§4, point 3), extended to processes that start during one.
455
+
456
+ The siblings have the same cold-start gap. Their runtimes (Lambda, Cloud
457
+ Run) rarely have a shared cache next to them, so this ships Ruby-first, like
458
+ §8's redirect and body-cap hardening.
459
+
367
460
  ---
368
461
 
369
462
  ## 7. Threading model
@@ -464,6 +557,9 @@ One tick:
464
557
  the client would pin itself to the bad response via endless 304s.
465
558
 
466
559
  4. Report the health transition (§3.6), if any.
560
+ 5. With a `fallback_store` (§6.1): on an `ok` outcome, write the current
561
+ document to it; on a `failing` outcome while there is no snapshot yet,
562
+ publish the stored copy if it parses.
467
563
 
468
564
  Parsing is lenient exactly like the siblings, with the Node client's §6.1
469
565
  rules as the reference: missing/`null` `Flags` becomes `{}`, but `Flags`
@@ -500,6 +596,16 @@ Plus, ported from the siblings and ararat:
500
596
  returns.
501
597
  - On-demand mode: its own section (coalescing, one-fetch-per-interval while
502
598
  down, no thread).
599
+ - Fallback copy (§6.1): saved with no expiry on every `ok` tick, keyed by
600
+ URL; restored only by a process with no document; garbage and a raising
601
+ store ignored; an `ActiveSupport::Cache::MemoryStore` with a default TTL
602
+ keeps the copy past it; a raising store read still reports the outage;
603
+ on-demand mode restores too; an assignment beats the default in either
604
+ order, even after the singleton exists; a Proc works through both
605
+ `Client.new` and the module setter; and four real Rails boots: zero-config
606
+ uses `Rails.cache`, `-> { Rails.cache }` set in app config resolves to it,
607
+ and a `nil` set in app config (before the Railtie) or in an initializer
608
+ (after it) wins.
503
609
  - `test/verify_live.rb`, opt-in and networked, against the real public
504
610
  endpoint (document parses; second conditional fetch is 304). Not in CI.
505
611
 
data/lib/cru-flags.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler's autorequire derives the require path from the GEM NAME
4
+ # ("cru-flags" -> "cru-flags", then "cru/flags"), never from the real entry
5
+ # point ("cru_flags") — so `gem "cru-flags"` (no explicit `require:`) loads
6
+ # THIS file. Do not delete: without it, autorequire silently no-ops (the
7
+ # LoadError is swallowed) and the Railtie never loads.
8
+ require "cru_flags"
@@ -22,12 +22,13 @@ module CruFlags
22
22
  end
23
23
 
24
24
  def initialize(url: nil, poll_seconds: 30.0, fetch_timeout: 2.0,
25
- on_error: nil, refresh_mode: nil)
25
+ on_error: nil, refresh_mode: nil, fallback_store: nil)
26
26
  @poll_seconds = positive(poll_seconds, 30.0)
27
27
  @fetch_timeout = positive(fetch_timeout, 2.0)
28
28
  @on_error = on_error || default_on_error
29
29
  @refresh_mode = resolve_mode(refresh_mode)
30
30
  @url = url || self.class.url_from_env
31
+ @fallback_store = fallback_store
31
32
  @document = nil
32
33
  @etag = nil
33
34
  @healthy = true
@@ -268,10 +269,38 @@ module CruFlags
268
269
  became(healthy: true)
269
270
  when :failed
270
271
  became(healthy: false, error: outcome.error)
272
+ restore_fallback if @document.nil?
271
273
  end
272
274
  @last_attempt_ok = outcome.kind != :failed
275
+ save_fallback if @last_attempt_ok
273
276
  end
274
277
 
278
+ # Design doc §6.1. Runs inside attempt_fetch, never on enabled?'s
279
+ # background-mode read path, and never raises.
280
+ def save_fallback
281
+ store = fallback_store or return
282
+ # expires_in: nil overrides any store-wide default TTL: a copy that
283
+ # expires is the TTL cliff §4 rules out.
284
+ store.write(fallback_key, JSON.generate(@document), expires_in: nil)
285
+ rescue
286
+ nil
287
+ end
288
+
289
+ # Only for a process that has never held a document.
290
+ def restore_fallback
291
+ store = fallback_store or return
292
+ body = store.read(fallback_key)
293
+ @document = Document.parse(body) if body.is_a?(String)
294
+ rescue
295
+ nil
296
+ end
297
+
298
+ def fallback_store
299
+ @fallback_store.is_a?(Proc) ? @fallback_store.call : @fallback_store
300
+ end
301
+
302
+ def fallback_key = "cru_flags/v1/#{@url}"
303
+
275
304
  def became(healthy:, error: nil)
276
305
  return if healthy == @healthy
277
306
  @healthy = healthy
@@ -41,5 +41,12 @@ module CruFlags
41
41
  app.config.flipper.strict = false
42
42
  end
43
43
  end
44
+
45
+ # A default, not an assignment: CruFlags.fallback_store set anywhere
46
+ # (application.rb, an environment file, an initializer), before or after
47
+ # this runs, wins. Rails.cache is looked up on each use.
48
+ initializer "cru_flags.fallback_store" do
49
+ CruFlags.default_fallback_store = -> { Rails.cache }
50
+ end
44
51
  end
45
52
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CruFlags
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/cru_flags.rb CHANGED
@@ -15,13 +15,32 @@ module CruFlags
15
15
  @client = nil
16
16
  @flipper_adapter_mutex = Mutex.new
17
17
  @flipper_adapter = nil
18
+ UNSET = Object.new.freeze
19
+ private_constant :UNSET
20
+ @fallback_store = UNSET
21
+ @default_fallback_store = nil
18
22
 
19
23
  class << self
24
+ # The singleton's cross-process fallback store (design doc §6.1): a
25
+ # store, or a Proc returning one. Any assignment, nil included, beats
26
+ # default_fallback_store whenever it happens: the singleton resolves the
27
+ # store on every use, not once at construction.
28
+ attr_writer :fallback_store
29
+
30
+ # :nodoc: Owned by the Railtie, which overwrites it unconditionally.
31
+ attr_writer :default_fallback_store
32
+
33
+ def fallback_store
34
+ store = @fallback_store
35
+ store = @default_fallback_store if store.equal?(UNSET)
36
+ store.is_a?(Proc) ? store.call : store
37
+ end
38
+
20
39
  # The env read happens inside Client (Client.url_from_env), so the
21
40
  # singleton and a hand-rolled `Client.new` share one normalization
22
41
  # rather than two copies that can drift.
23
42
  def client
24
- @client || @client_mutex.synchronize { @client ||= Client.new }
43
+ @client || @client_mutex.synchronize { @client ||= Client.new(fallback_store: -> { fallback_store }) }
25
44
  end
26
45
 
27
46
  def enabled?(name) = client.enabled?(name)
@@ -51,12 +70,14 @@ module CruFlags
51
70
  end
52
71
 
53
72
  # Test hook, not public API: closes and discards the singleton so the
54
- # next call re-reads the environment.
73
+ # next call re-reads the environment, and forgets any fallback_store
74
+ # assignment, so the Railtie's default applies again.
55
75
  def reset!
56
76
  @client_mutex.synchronize do
57
77
  @client&.close
58
78
  @client = nil
59
79
  end
80
+ @fallback_store = UNSET
60
81
  @flipper_adapter_mutex.synchronize { @flipper_adapter = nil }
61
82
  end
62
83
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cru-flags
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cru
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-24 00:00:00.000000000 Z
11
+ date: 2026-10-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: flipper
@@ -51,6 +51,7 @@ files:
51
51
  - LICENSE
52
52
  - README.md
53
53
  - docs/design.md
54
+ - lib/cru-flags.rb
54
55
  - lib/cru_flags.rb
55
56
  - lib/cru_flags/client.rb
56
57
  - lib/cru_flags/document.rb