Skip to content

Commit 1ff10c9

Browse files
committed
feat(config): Allow configuring SWR caches that never expire
1 parent f85c00a commit 1ff10c9

File tree

8 files changed

+78
-30
lines changed

8 files changed

+78
-30
lines changed

lib/langfuse/config.rb

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ class Config
7575
DEFAULT_CACHE_BACKEND = :memory
7676
DEFAULT_CACHE_LOCK_TIMEOUT = 10
7777
DEFAULT_CACHE_STALE_WHILE_REVALIDATE = false
78-
DEFAULT_CACHE_STALE_TTL = 300
7978
DEFAULT_CACHE_REFRESH_THREADS = 5
8079
DEFAULT_TRACING_ASYNC = true
8180
DEFAULT_BATCH_SIZE = 50
@@ -96,7 +95,7 @@ def initialize
9695
@cache_backend = DEFAULT_CACHE_BACKEND
9796
@cache_lock_timeout = DEFAULT_CACHE_LOCK_TIMEOUT
9897
@cache_stale_while_revalidate = DEFAULT_CACHE_STALE_WHILE_REVALIDATE
99-
@cache_stale_ttl = DEFAULT_CACHE_STALE_TTL
98+
@cache_stale_ttl = @cache_ttl # Default to same as cache_ttl (SWR disabled, entries expire not go stale)
10099
@cache_refresh_threads = DEFAULT_CACHE_REFRESH_THREADS
101100
@tracing_async = DEFAULT_TRACING_ASYNC
102101
@batch_size = DEFAULT_BATCH_SIZE
@@ -117,6 +116,7 @@ def validate!
117116
raise ConfigurationError, "secret_key is required" if secret_key.nil? || secret_key.empty?
118117
raise ConfigurationError, "base_url cannot be empty" if base_url.nil? || base_url.empty?
119118
raise ConfigurationError, "timeout must be positive" if timeout.nil? || timeout <= 0
119+
raise ConfigurationError, "cache_ttl cannot be Float::INFINITY" if cache_ttl == Float::INFINITY
120120
raise ConfigurationError, "cache_ttl must be non-negative" if cache_ttl.nil? || cache_ttl.negative?
121121
raise ConfigurationError, "cache_max_size must be positive" if cache_max_size.nil? || cache_max_size <= 0
122122

@@ -150,9 +150,7 @@ def validate_cache_backend!
150150
end
151151

152152
def validate_swr_config!
153-
if cache_stale_ttl.nil? || cache_stale_ttl.negative?
154-
raise ConfigurationError, "cache_stale_ttl must be non-negative"
155-
end
153+
raise ConfigurationError, "cache_stale_ttl must be non-negative" if cache_stale_ttl.negative?
156154

157155
return unless cache_refresh_threads.nil? || cache_refresh_threads <= 0
158156

lib/langfuse/prompt_cache.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,18 @@ def expired?
5959
#
6060
# @param ttl [Integer] Time-to-live in seconds (default: 60)
6161
# @param max_size [Integer] Maximum cache size (default: 1000)
62-
# @param stale_ttl [Integer, nil] Stale TTL for SWR (default: nil, disabled)
62+
# @param stale_ttl [Integer, Float::INFINITY, nil] Stale TTL for SWR (default: same as ttl, SWR disabled). Use Float::INFINITY for 1000 years, e.g. non-expiring cache.
6363
# @param refresh_threads [Integer] Number of background refresh threads (default: 5)
6464
# @param logger [Logger, nil] Logger instance for error reporting (default: nil, creates new logger)
6565
def initialize(ttl: 60, max_size: 1000, stale_ttl: nil, refresh_threads: 5, logger: default_logger)
6666
@ttl = ttl
6767
@max_size = max_size
68-
@stale_ttl = stale_ttl
68+
@stale_ttl = StaleWhileRevalidate.normalize_stale_ttl(stale_ttl || ttl)
6969
@logger = logger
7070
@cache = {}
7171
@monitor = Monitor.new
7272
@locks = {} # Track locks for in-memory locking
73-
initialize_swr(refresh_threads: refresh_threads) if stale_ttl
73+
initialize_swr(refresh_threads: refresh_threads) if @stale_ttl > @ttl
7474
end
7575

7676
# Get a value from the cache

lib/langfuse/rails_cache_adapter.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class RailsCacheAdapter
2424
# @param ttl [Integer] Time-to-live in seconds (default: 60)
2525
# @param namespace [String] Cache key namespace (default: "langfuse")
2626
# @param lock_timeout [Integer] Lock timeout in seconds for stampede protection (default: 10)
27-
# @param stale_ttl [Integer, nil] Stale TTL for SWR (default: nil, disabled)
27+
# @param stale_ttl [Integer, Float::INFINITY, nil] Stale TTL for SWR (default: same as ttl, SWR disabled). Use Float::INFINITY for 1000 years, e.g. non-expiring cache.
2828
# @param refresh_threads [Integer] Number of background refresh threads (default: 5)
2929
# @param logger [Logger, nil] Logger instance for error reporting (default: nil, creates new logger)
3030
# @raise [ConfigurationError] if Rails.cache is not available
@@ -35,9 +35,9 @@ def initialize(ttl: 60, namespace: "langfuse", lock_timeout: 10, stale_ttl: nil,
3535
@ttl = ttl
3636
@namespace = namespace
3737
@lock_timeout = lock_timeout
38-
@stale_ttl = stale_ttl
38+
@stale_ttl = StaleWhileRevalidate.normalize_stale_ttl(stale_ttl || ttl)
3939
@logger = logger
40-
initialize_swr(refresh_threads: refresh_threads) if stale_ttl
40+
initialize_swr(refresh_threads: refresh_threads) if @stale_ttl > @ttl
4141
end
4242

4343
# Get a value from the cache

lib/langfuse/stale_while_revalidate.rb

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ module Langfuse
1818
# class MyCache
1919
# include Langfuse::StaleWhileRevalidate
2020
#
21-
# def initialize(ttl: 60, stale_ttl: 30)
21+
# def initialize(ttl: 60, stale_ttl: nil)
2222
# @ttl = ttl
23-
# @stale_ttl = stale_ttl
23+
# @stale_ttl = StaleWhileRevalidate.normalize_stale_ttl(stale_ttl || ttl)
2424
# @logger = Logger.new($stdout)
25-
# initialize_swr if stale_ttl
25+
# initialize_swr
2626
# end
2727
#
2828
# def cache_get(key)
@@ -47,6 +47,26 @@ module StaleWhileRevalidate
4747
# Refresh locks are short-lived to prevent duplicate background refreshes
4848
REFRESH_LOCK_TIMEOUT = 60
4949

50+
# Number of seconds in 1000 years (accounting for leap years)
51+
THOUSAND_YEARS_IN_SECONDS = (1000 * 365.25 * 24 * 60 * 60).to_i
52+
53+
# Normalize stale_ttl value
54+
#
55+
# Converts Float::INFINITY to 1000 years in seconds for practical "never expire"
56+
# behavior while keeping the value finite for calculations.
57+
#
58+
# @param stale_ttl [Integer, Float::INFINITY] Stale TTL value (required, no nil allowed)
59+
# @return [Integer] Normalized stale TTL in seconds
60+
#
61+
# @example
62+
# StaleWhileRevalidate.normalize_stale_ttl(300) # => 300
63+
# StaleWhileRevalidate.normalize_stale_ttl(Float::INFINITY) # => 31557600000
64+
def self.normalize_stale_ttl(stale_ttl)
65+
return THOUSAND_YEARS_IN_SECONDS if stale_ttl == Float::INFINITY
66+
67+
stale_ttl
68+
end
69+
5070
# Initialize SWR infrastructure
5171
#
5272
# Must be called by including class after setting @stale_ttl, @ttl, and @logger.
@@ -145,9 +165,12 @@ def fetch_with_lock(key)
145165

146166
# Check if SWR is enabled
147167
#
148-
# @return [Boolean] true if stale_ttl is configured
168+
# SWR is enabled when stale_ttl > ttl, meaning there's a grace period
169+
# where stale data can be served while revalidating in the background.
170+
#
171+
# @return [Boolean] true if stale_ttl is greater than ttl
149172
def swr_enabled?
150-
!stale_ttl.nil? && stale_ttl.positive?
173+
stale_ttl > ttl
151174
end
152175

153176
# Shutdown the cache refresh thread pool gracefully
@@ -358,7 +381,7 @@ def ttl
358381

359382
# Get stale TTL value
360383
#
361-
# @return [Integer, nil] Stale TTL in seconds, or nil if disabled
384+
# @return [Integer] Stale TTL in seconds
362385
# @raise [NotImplementedError] if not implemented by including class
363386
def stale_ttl
364387
@stale_ttl || raise(NotImplementedError, "#{self.class} must provide @stale_ttl")

spec/langfuse/client_spec.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,9 @@ class << self
148148
client = described_class.new(config_with_rails_cache)
149149
adapter = client.api_client.cache
150150

151-
expect(adapter.stale_ttl).to be_nil
152-
expect(adapter.thread_pool).to be_nil
151+
# When SWR disabled, stale_ttl defaults to ttl (no stale period, immediate expiration)
152+
expect(adapter.stale_ttl).to eq(120) # Same as cache_ttl
153+
expect(adapter.thread_pool).to be_nil # Thread pool not initialized when stale_ttl == ttl
153154
end
154155
end
155156

spec/langfuse/config_spec.rb

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
expect(config.cache_max_size).to eq(1000)
1212
expect(config.cache_backend).to eq(:memory)
1313
expect(config.cache_stale_while_revalidate).to be false
14-
expect(config.cache_stale_ttl).to eq(300)
14+
expect(config.cache_stale_ttl).to eq(60) # Defaults to cache_ttl
1515
expect(config.cache_refresh_threads).to eq(5)
1616
end
1717

@@ -158,10 +158,23 @@
158158
)
159159
end
160160

161+
it "raises ConfigurationError when Float::INFINITY" do
162+
config.cache_ttl = Float::INFINITY
163+
expect { config.validate! }.to raise_error(
164+
Langfuse::ConfigurationError,
165+
"cache_ttl cannot be Float::INFINITY"
166+
)
167+
end
168+
161169
it "allows zero (disabled cache)" do
162170
config.cache_ttl = 0
163171
expect { config.validate! }.not_to raise_error
164172
end
173+
174+
it "allows positive values" do
175+
config.cache_ttl = 300
176+
expect { config.validate! }.not_to raise_error
177+
end
165178
end
166179

167180
context "when cache_max_size is invalid" do
@@ -211,14 +224,6 @@
211224
end
212225

213226
context "when cache_stale_ttl is invalid" do
214-
it "raises ConfigurationError when nil" do
215-
config.cache_stale_ttl = nil
216-
expect { config.validate! }.to raise_error(
217-
Langfuse::ConfigurationError,
218-
"cache_stale_ttl must be non-negative"
219-
)
220-
end
221-
222227
it "raises ConfigurationError when negative" do
223228
config.cache_stale_ttl = -1
224229
expect { config.validate! }.to raise_error(
@@ -389,15 +394,14 @@
389394
expect { config.validate! }.not_to raise_error
390395

391396
expect(config.cache_stale_while_revalidate).to be false
392-
expect(config.cache_stale_ttl).to eq(300) # Default
397+
expect(config.cache_stale_ttl).to eq(60) # Defaults to cache_ttl
393398
expect(config.cache_refresh_threads).to eq(5) # Default
394399
end
395400
end
396401

397402
describe "constants" do
398403
it "defines correct SWR default values" do
399404
expect(Langfuse::Config::DEFAULT_CACHE_STALE_WHILE_REVALIDATE).to be false
400-
expect(Langfuse::Config::DEFAULT_CACHE_STALE_TTL).to eq(300)
401405
expect(Langfuse::Config::DEFAULT_CACHE_REFRESH_THREADS).to eq(5)
402406
end
403407
end

spec/langfuse/prompt_cache_spec.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,23 @@
7272
cache = described_class.new(max_size: 500)
7373
expect(cache.max_size).to eq(500)
7474
end
75+
76+
context "with stale_ttl" do
77+
it "sets custom stale_ttl" do
78+
cache = described_class.new(stale_ttl: 300)
79+
expect(cache.stale_ttl).to eq(300)
80+
end
81+
82+
it "converts Float::INFINITY to 1000 years in seconds" do
83+
cache = described_class.new(stale_ttl: Float::INFINITY)
84+
expect(cache.stale_ttl).to eq(Langfuse::StaleWhileRevalidate::THOUSAND_YEARS_IN_SECONDS)
85+
end
86+
87+
it "defaults nil to ttl value" do
88+
cache = described_class.new(ttl: 60, stale_ttl: nil)
89+
expect(cache.stale_ttl).to eq(60)
90+
end
91+
end
7592
end
7693

7794
describe "#get and #set" do

spec/langfuse/rails_cache_adapter_spec.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ class << self
4646
expect(adapter.stale_ttl).to eq(300)
4747
end
4848

49+
it "converts Float::INFINITY to 1000 years in seconds" do
50+
adapter = described_class.new(stale_ttl: Float::INFINITY)
51+
expect(adapter.stale_ttl).to eq(Langfuse::StaleWhileRevalidate::THOUSAND_YEARS_IN_SECONDS)
52+
end
53+
4954
it "initializes thread pool with default refresh_threads (5)" do
5055
expect(Concurrent::CachedThreadPool).to receive(:new)
5156
.with(

0 commit comments

Comments
 (0)