Skip to content

Mitigate stale cache across processes (related to #735) - #746

Open
snaka wants to merge 2 commits into
splitrb:mainfrom
snaka:add-cache-invalidation-ttl
Open

Mitigate stale cache across processes (related to #735)#746
snaka wants to merge 2 commits into
splitrb:mainfrom
snaka:add-cache-invalidation-ttl

Conversation

@snaka

@snaka snaka commented May 5, 2026

Copy link
Copy Markdown
Contributor

Related to #735.

Background

When config.cache = true is enabled in a multi-process setup (e.g. Puma cluster mode), each process maintains its own in-memory cache via class instance variables. Mutations made in one process — for example setting a winner from the Dashboard — are not propagated to other processes. As a result, reloads can flip between the new and old state until every worker is restarted.

This PR does not eliminate the underlying single-process design, but it bounds the staleness window so the inconsistency naturally resolves within a few seconds without a process restart.

Solution

A single Redis key (split:cache:global_ts) acts as a shared invalidation signal across processes. Split::Cache.clear_key — already called from Experiment#reset and Experiment#reset_winner, and now also from Experiment#winner= — bumps this timestamp; other processes detect the change on their next throttled read and drop their snapshot.

When enabled, this means cross-process propagation works for the Dashboard "Use as winner", "Reopen", and "Reset" actions.

The mechanism is opt-in (controlled by cache_global_ts_check_interval), so existing applications see no change in caching behavior.

Object relationships

flowchart LR
    U(("User"))

    subgraph Rails["Rails"]
        Ctrl["Controller"]
    end

    subgraph SplitGem["Split"]
        direction TB
        IMC["In-memory cache<br/>(@cache)"]
        CI["CacheInvalidator<br/>(@global_cache_ts,<br/>@last_global_ts_check)"]
    end

    subgraph RedisDB["Redis"]
        direction TB
        RC["experiment_winner<br/>(authoritative data)"]
        GT["global_ts<br/>(invalidation signal)"]
    end

    U -- "HTTP" --> Ctrl
    Ctrl -- "ab_test / winner=" --> IMC
    IMC -- "check before serve" --> CI
    IMC -- "HGET on miss" --> RC
    IMC -- "SET on clear_key" --> GT
    CI -- "GET (throttled)" --> GT
Loading
  • In-memory cache lives per process (Puma worker). Serves cached values; falls back to Redis on miss.
  • CacheInvalidator holds the last-seen global_ts and is consulted before each cache read. When enabled, it re-reads from Redis at most once per cache_global_ts_check_interval seconds, which also bounds the cross-process staleness window.

Configuration

Split.configure do |config|
  config.cache = true

  # Optional. Default: nil (mechanism disabled).
  # When set, processes re-read split:cache:global_ts at most once per N seconds
  # and drop their in-memory cache if it has been bumped by another process.
  # The value is the upper bound on cross-process staleness (in seconds).
  config.cache_global_ts_check_interval = 10
end

Backwards compatibility

No breaking changes. The new mechanism is opt-in; config.cache = true alone keeps the previous behavior. config.cache = false continues to bypass the cache entirely.

Note

The straightforward workaround for the staleness issue — setting config.cache = false — eliminates the inconsistency but also gives up the performance benefit caching provides on hot paths. For high-traffic applications this is a costly trade-off, and flipping it off in production carries its own operational risk. This PR aims to provide an opt-in mitigation that keeps the cache enabled while bounding cross-process staleness, so users can address the symptom of #735 without disabling caching wholesale.

- Add CacheInvalidator for cross-process cache invalidation via a Redis-backed global timestamp.
- Add cache_global_ts_check_interval configuration option (opt-in; mechanism is disabled when unset).
- Clear experiment cache when winner is set.
- Add tests for cross-process invalidation and the opt-in default.
Comment thread lib/split/cache.rb Outdated

value = @cache[namespace][key]
return value if value
unless value

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Building on top of "broken" Split::Cache behavior: if yield returns a falsy value, fetch never caches it.

Namespace When block is falsy
:experiment_winner nil when Redis has no winner (hget miss) — explicit else nil
:experiment_start_times nil when no start time in Redis
:experiments Missing experiment uses return unless exists? inside the block → non-local return from find, so fetch never stores anything.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — all three cases behave as you describe (the non-local return in Experiment.find included). This is pre-existing behavior on main that this PR does not change; I have reverted the cosmetic rewrite of fetch's tail (144b194) so the diff no longer touches those lines. Fixing falsy caching interacts with the invalidation design — today the nil winner is effectively uncached, so "no winner → winner set" is never stale cross-process; caching nil would widen the staleness window this PR bounds — so I would like to address it in a follow-up PR. I can open an issue to track it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened #762 to track this.

Comment thread lib/split/cache.rb
@@ -4,21 +4,32 @@ module Split
class Cache
def self.clear
@cache = nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Building on top of "broken" Split::Cache behavior: clear is not thread-safe, can cause undefined method '[]' for nil (NoMethodError) to be raised from fetch executing in a different thread (e.g. if clear sets @cache = nil in thread A after fetch performed @cache ||= {} but before @cache[namespace][key] in thread B).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 144b194. fetch and clear_key now take a local snapshot of @cache up front, so a concurrent clear can no longer cause the NoMethodError — at worst a racing thread writes into a detached hash, which is just one extra cache miss. Applied the same pattern to the ivars read twice in CacheInvalidator's predicates. Added a deterministic regression spec (clear landing mid-fetch), which raises NoMethodError on the previous code.

Address review feedback on thread safety: `fetch` and `clear_key`
dereferenced @cache multiple times, so a concurrent `clear` setting
@cache = nil between those reads raised NoMethodError. This becomes a
real production path with this PR, since global invalidation now calls
`clear` from within `fetch` on request threads.

Take a local snapshot of @cache (and the namespace hash) up front; a
racing `clear` now at worst writes into a detached hash, which is just
an extra cache miss. Apply the same snapshot pattern to the ivars read
twice in CacheInvalidator's predicates.

Also restore `fetch`'s original tail (`return value if value`) — the
previous rewrite was semantically identical and only added diff noise;
the falsy-value caching behavior is unchanged and tracked separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants