Skip to content

Commit 2f788f1

Browse files
committed
Handle errors when refreshing the cache async
1 parent f55a4e0 commit 2f788f1

File tree

4 files changed

+138
-14
lines changed

4 files changed

+138
-14
lines changed

lib/langfuse/client.rb

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,17 +156,22 @@ def create_cache
156156
max_size: config.cache_max_size
157157
)
158158
when :rails
159-
RailsCacheAdapter.new(
160-
ttl: config.cache_ttl,
161-
lock_timeout: config.cache_lock_timeout,
162-
stale_ttl: config.cache_stale_while_revalidate ? config.cache_stale_ttl : nil,
163-
refresh_threads: config.cache_refresh_threads
164-
)
159+
create_rails_cache_adapter
165160
else
166161
raise ConfigurationError, "Unknown cache backend: #{config.cache_backend}"
167162
end
168163
end
169164

165+
def create_rails_cache_adapter
166+
RailsCacheAdapter.new(
167+
ttl: config.cache_ttl,
168+
lock_timeout: config.cache_lock_timeout,
169+
stale_ttl: config.cache_stale_while_revalidate ? config.cache_stale_ttl : nil,
170+
refresh_threads: config.cache_refresh_threads,
171+
logger: config.logger
172+
)
173+
end
174+
170175
# Build the appropriate prompt client based on prompt type
171176
#
172177
# @param prompt_data [Hash] The prompt data from API

lib/langfuse/rails_cache_adapter.rb

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require "concurrent"
44
require "json"
5+
require "logger"
56

67
module Langfuse
78
# rubocop:disable Metrics/ClassLength
@@ -16,7 +17,7 @@ module Langfuse
1617
# adapter.get("greeting:1") # => prompt_data
1718
#
1819
class RailsCacheAdapter
19-
attr_reader :ttl, :namespace, :lock_timeout, :stale_ttl, :thread_pool
20+
attr_reader :ttl, :namespace, :lock_timeout, :stale_ttl, :thread_pool, :logger
2021

2122
# Initialize a new Rails.cache adapter
2223
#
@@ -25,14 +26,16 @@ class RailsCacheAdapter
2526
# @param lock_timeout [Integer] Lock timeout in seconds for stampede protection (default: 10)
2627
# @param stale_ttl [Integer, nil] Stale TTL for SWR (default: nil, disabled)
2728
# @param refresh_threads [Integer] Number of background refresh threads (default: 5)
29+
# @param logger [Logger, nil] Logger instance for error reporting (default: nil, creates new logger)
2830
# @raise [ConfigurationError] if Rails.cache is not available
29-
def initialize(ttl: 60, namespace: "langfuse", lock_timeout: 10, stale_ttl: nil, refresh_threads: 5)
31+
def initialize(ttl: 60, namespace: "langfuse", lock_timeout: 10, stale_ttl: nil, refresh_threads: 5, logger: nil)
3032
validate_rails_cache!
3133

3234
@ttl = ttl
3335
@namespace = namespace
3436
@lock_timeout = lock_timeout
3537
@stale_ttl = stale_ttl
38+
@logger = logger || default_logger
3639
@thread_pool = initialize_thread_pool(refresh_threads) if stale_ttl
3740
end
3841

@@ -207,6 +210,8 @@ def initialize_thread_pool(refresh_threads)
207210
# Prevents duplicate refreshes by using a refresh lock. If another process
208211
# is already refreshing this key, this method returns immediately.
209212
#
213+
# Errors during refresh are caught and logged to prevent thread crashes.
214+
#
210215
# @param key [String] Cache key
211216
# @yield Block to execute to fetch fresh data
212217
# @return [void]
@@ -218,6 +223,8 @@ def schedule_refresh(key)
218223
thread_pool.post do
219224
value = yield
220225
set_with_metadata(key, value)
226+
rescue StandardError => e
227+
logger.error("Langfuse cache refresh failed for key '#{key}': #{e.class} - #{e.message}")
221228
ensure
222229
release_lock(refresh_lock_key)
223230
end
@@ -346,6 +353,17 @@ def validate_rails_cache!
346353
raise ConfigurationError,
347354
"Rails.cache is not available. Rails cache backend requires Rails with a configured cache store."
348355
end
356+
357+
# Create a default logger
358+
#
359+
# @return [Logger]
360+
def default_logger
361+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
362+
Rails.logger
363+
else
364+
Logger.new($stdout, level: Logger::WARN)
365+
end
366+
end
349367
end
350368
# rubocop:enable Metrics/ClassLength
351369
end

spec/langfuse/client_spec.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,36 @@ class << self
121121
described_class.new(config_with_rails_cache)
122122
end.not_to raise_error
123123
end
124+
125+
it "passes logger from config to RailsCacheAdapter" do
126+
custom_logger = Logger.new($stdout)
127+
config_with_rails_cache.logger = custom_logger
128+
client = described_class.new(config_with_rails_cache)
129+
expect(client.api_client.cache.logger).to eq(custom_logger)
130+
end
131+
132+
it "configures RailsCacheAdapter with stale-while-revalidate settings" do
133+
config_with_rails_cache.cache_stale_while_revalidate = true
134+
config_with_rails_cache.cache_stale_ttl = 300
135+
config_with_rails_cache.cache_refresh_threads = 3
136+
config_with_rails_cache.cache_lock_timeout = 15
137+
138+
client = described_class.new(config_with_rails_cache)
139+
adapter = client.api_client.cache
140+
141+
expect(adapter.stale_ttl).to eq(300)
142+
expect(adapter.lock_timeout).to eq(15)
143+
expect(adapter.thread_pool).to be_a(Concurrent::CachedThreadPool)
144+
end
145+
146+
it "configures RailsCacheAdapter without SWR when disabled" do
147+
config_with_rails_cache.cache_stale_while_revalidate = false
148+
client = described_class.new(config_with_rails_cache)
149+
adapter = client.api_client.cache
150+
151+
expect(adapter.stale_ttl).to be_nil
152+
expect(adapter.thread_pool).to be_nil
153+
end
124154
end
125155

126156
context "with invalid cache backend" do

spec/langfuse/rails_cache_adapter_spec.rb

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,36 @@ class << self
9393
expect(adapter.thread_pool).to be_nil
9494
end
9595
end
96+
97+
context "with logger parameter" do
98+
it "uses provided logger" do
99+
custom_logger = Logger.new($stdout)
100+
adapter = described_class.new(logger: custom_logger)
101+
expect(adapter.logger).to eq(custom_logger)
102+
end
103+
104+
it "creates default stdout logger when no logger provided and Rails.logger not available" do
105+
allow(Rails).to receive(:respond_to?).and_return(true)
106+
allow(Rails).to receive(:respond_to?).with(:logger).and_return(false)
107+
adapter = described_class.new
108+
expect(adapter.logger).to be_a(Logger)
109+
end
110+
111+
it "uses Rails.logger as default when Rails is available" do
112+
rails_logger = Logger.new($stdout)
113+
allow(Rails).to receive(:respond_to?).and_return(true)
114+
allow(Rails).to receive(:logger).and_return(rails_logger)
115+
adapter = described_class.new
116+
expect(adapter.logger).to eq(rails_logger)
117+
end
118+
119+
it "creates stdout logger when Rails.logger returns nil" do
120+
allow(Rails).to receive(:respond_to?).and_return(true)
121+
allow(Rails).to receive(:logger).and_return(nil)
122+
adapter = described_class.new
123+
expect(adapter.logger).to be_a(Logger)
124+
end
125+
end
96126
end
97127

98128
context "when Rails.cache is not available" do
@@ -698,21 +728,62 @@ class << self
698728
adapter_with_swr.send(:schedule_refresh, cache_key) { "refreshed_value" }
699729
end
700730

701-
it "releases the refresh lock even if block raises" do
731+
it "logs error and releases lock when refresh block raises error" do
702732
cache_key = "test_key"
703733
refresh_lock_key = "langfuse:#{cache_key}:refreshing"
734+
mock_logger = instance_double(Logger)
704735

705-
allow(adapter_with_swr).to receive(:acquire_refresh_lock)
736+
adapter_with_logger = described_class.new(
737+
ttl: ttl,
738+
stale_ttl: stale_ttl,
739+
refresh_threads: refresh_threads,
740+
logger: mock_logger
741+
)
742+
743+
allow(adapter_with_logger).to receive(:acquire_refresh_lock)
706744
.with(refresh_lock_key)
707745
.and_return(true)
708-
allow(adapter_with_swr.thread_pool).to receive(:post).and_yield
746+
allow(adapter_with_logger.thread_pool).to receive(:post).and_yield
709747

710-
expect(adapter_with_swr).to receive(:release_lock)
748+
expect(mock_logger).to receive(:error)
749+
.with(/Langfuse cache refresh failed for key 'test_key': RuntimeError - test error/)
750+
751+
expect(adapter_with_logger).to receive(:release_lock)
711752
.with(refresh_lock_key)
712753

754+
# Error should be caught and logged, not raised
713755
expect do
714-
adapter_with_swr.send(:schedule_refresh, cache_key) { raise "test error" }
715-
end.to raise_error("test error")
756+
adapter_with_logger.send(:schedule_refresh, cache_key) { raise "test error" }
757+
end.not_to raise_error
758+
end
759+
760+
it "logs error with correct exception class and message" do
761+
cache_key = "greeting:1"
762+
refresh_lock_key = "langfuse:#{cache_key}:refreshing"
763+
mock_logger = instance_double(Logger)
764+
765+
adapter_with_logger = described_class.new(
766+
ttl: ttl,
767+
stale_ttl: stale_ttl,
768+
refresh_threads: refresh_threads,
769+
logger: mock_logger
770+
)
771+
772+
allow(adapter_with_logger).to receive(:acquire_refresh_lock)
773+
.with(refresh_lock_key)
774+
.and_return(true)
775+
allow(adapter_with_logger.thread_pool).to receive(:post).and_yield
776+
777+
expect(mock_logger).to receive(:error)
778+
.with("Langfuse cache refresh failed for key 'greeting:1': ArgumentError - Invalid prompt data")
779+
780+
expect(adapter_with_logger).to receive(:release_lock)
781+
.with(refresh_lock_key)
782+
783+
# Custom exception type
784+
adapter_with_logger.send(:schedule_refresh, cache_key) do
785+
raise ArgumentError, "Invalid prompt data"
786+
end
716787
end
717788
end
718789

0 commit comments

Comments
 (0)