langfuse-rb 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +12 -4
- data/README.md +8 -1
- data/lib/langfuse/api_client.rb +58 -21
- data/lib/langfuse/client.rb +28 -8
- data/lib/langfuse/config.rb +69 -0
- data/lib/langfuse/prompt_cache.rb +119 -8
- data/lib/langfuse/propagation.rb +5 -16
- data/lib/langfuse/rails_cache_adapter.rb +115 -55
- data/lib/langfuse/stale_while_revalidate.rb +262 -0
- data/lib/langfuse/version.rb +1 -1
- metadata +37 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 60270020fc35460c5e29381351bbca35f1cdfe8b7dfe05eca43a0fb20dc06b7b
|
|
4
|
+
data.tar.gz: 4951c9b1546de4c9d00bb3b3be4c5325c8edf4d5bf6f5ecab7d9520ab052451b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 84fa1fc6ea91bda9ddcaa32dd43cb457439e0e4cdee06e3f3df88aae9c54c5b10abd16cb120f234da28733ba6497c466d0ece7888410d7b2711815e13f3956ef
|
|
7
|
+
data.tar.gz: 785bd5801a8c6b0ecd7c94f43083bdd6f12463bb918fa5f4a6faee32924a87a5058cf9700b20984fa9e8964cb3070975235771ddfddabca115cb33538020b065
|
data/CHANGELOG.md
CHANGED
|
@@ -7,15 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-01-23
|
|
11
|
+
|
|
10
12
|
### Added
|
|
11
|
-
-
|
|
13
|
+
- Stale-while-revalidate (SWR) cache strategy for improved performance (#35)
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- OpenTelemetry Baggage API method signatures for context propagation (#39)
|
|
12
17
|
|
|
18
|
+
### Changed
|
|
19
|
+
- Relaxed Faraday version constraint for better compatibility with older projects (#37)
|
|
13
20
|
|
|
14
21
|
## [0.2.0] - 2025-12-19
|
|
15
22
|
|
|
16
23
|
### Added
|
|
17
|
-
- Prompt creation and update methods (`create_prompt`, `update_prompt`)
|
|
18
|
-
- Extended prompt management documentation with create/update examples
|
|
24
|
+
- Prompt creation and update methods (`create_prompt`, `update_prompt`) (#36)
|
|
19
25
|
|
|
20
26
|
## [0.1.0] - 2025-12-01
|
|
21
27
|
|
|
@@ -34,5 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
34
40
|
- Migrated from legacy ingestion API to OTLP endpoint
|
|
35
41
|
- Removed `tracing_enabled` configuration flag (#2)
|
|
36
42
|
|
|
37
|
-
[Unreleased]: https://github.com/simplepractice/langfuse-rb/compare/v0.
|
|
43
|
+
[Unreleased]: https://github.com/simplepractice/langfuse-rb/compare/v0.3.0...HEAD
|
|
44
|
+
[0.3.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.2.0...v0.3.0
|
|
45
|
+
[0.2.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.1.0...v0.2.0
|
|
38
46
|
[0.1.0]: https://github.com/simplepractice/langfuse-rb/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
- 🎯 **Prompt Management** - Centralized prompt versioning with Mustache templating
|
|
16
16
|
- 📊 **LLM Tracing** - Zero-boilerplate observability built on OpenTelemetry
|
|
17
|
-
- ⚡ **Performance** - In-memory or Redis-backed caching with stampede protection
|
|
17
|
+
- ⚡ **Performance** - In-memory or Redis-backed caching with stampede protection, both supporting stale-while-revalidate cache strategy
|
|
18
18
|
- 💬 **Chat & Text Prompts** - First-class support for both formats
|
|
19
19
|
- 🔄 **Automatic Retries** - Built-in exponential backoff for resilient API calls
|
|
20
20
|
- 🛡️ **Fallback Support** - Graceful degradation when API unavailable
|
|
@@ -43,6 +43,10 @@ Langfuse.configure do |config|
|
|
|
43
43
|
config.secret_key = ENV['LANGFUSE_SECRET_KEY']
|
|
44
44
|
# Optional: for self-hosted instances
|
|
45
45
|
config.base_url = ENV.fetch('LANGFUSE_BASE_URL', 'https://cloud.langfuse.com')
|
|
46
|
+
|
|
47
|
+
# Optional: Enable stale-while-revalidate for best performance
|
|
48
|
+
config.cache_backend = :rails # or :memory
|
|
49
|
+
config.cache_stale_while_revalidate = true
|
|
46
50
|
end
|
|
47
51
|
```
|
|
48
52
|
|
|
@@ -110,3 +114,6 @@ We welcome contributions! Please:
|
|
|
110
114
|
- **[Langfuse Documentation](https://langfuse.com/docs)** - Platform documentation
|
|
111
115
|
- **[API Reference](https://api.reference.langfuse.com)** - REST API reference
|
|
112
116
|
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
[MIT](LICENSE)
|
data/lib/langfuse/api_client.rb
CHANGED
|
@@ -21,8 +21,7 @@ module Langfuse
|
|
|
21
21
|
# logger: Logger.new($stdout)
|
|
22
22
|
# )
|
|
23
23
|
#
|
|
24
|
-
# rubocop:disable Metrics/ClassLength
|
|
25
|
-
class ApiClient
|
|
24
|
+
class ApiClient # rubocop:disable Metrics/ClassLength
|
|
26
25
|
attr_reader :public_key, :secret_key, :base_url, :timeout, :logger, :cache
|
|
27
26
|
|
|
28
27
|
# Initialize a new API client
|
|
@@ -107,26 +106,10 @@ module Langfuse
|
|
|
107
106
|
# @raise [ApiError] for other API errors
|
|
108
107
|
def get_prompt(name, version: nil, label: nil)
|
|
109
108
|
raise ArgumentError, "Cannot specify both version and label" if version && label
|
|
109
|
+
return fetch_prompt_from_api(name, version: version, label: label) if cache.nil?
|
|
110
110
|
|
|
111
111
|
cache_key = PromptCache.build_key(name, version: version, label: label)
|
|
112
|
-
|
|
113
|
-
# Use distributed lock if cache supports it (Rails.cache backend)
|
|
114
|
-
if cache.respond_to?(:fetch_with_lock)
|
|
115
|
-
cache.fetch_with_lock(cache_key) do
|
|
116
|
-
fetch_prompt_from_api(name, version: version, label: label)
|
|
117
|
-
end
|
|
118
|
-
elsif cache
|
|
119
|
-
# In-memory cache - use simple get/set pattern
|
|
120
|
-
cached_data = cache.get(cache_key)
|
|
121
|
-
return cached_data if cached_data
|
|
122
|
-
|
|
123
|
-
prompt_data = fetch_prompt_from_api(name, version: version, label: label)
|
|
124
|
-
cache.set(cache_key, prompt_data)
|
|
125
|
-
prompt_data
|
|
126
|
-
else
|
|
127
|
-
# No cache - fetch directly
|
|
128
|
-
fetch_prompt_from_api(name, version: version, label: label)
|
|
129
|
-
end
|
|
112
|
+
fetch_with_appropriate_caching_strategy(cache_key, name, version, label)
|
|
130
113
|
end
|
|
131
114
|
|
|
132
115
|
# Create a new prompt (or new version if prompt with same name exists)
|
|
@@ -246,8 +229,63 @@ module Langfuse
|
|
|
246
229
|
raise ApiError, "Batch send failed: #{e.message}"
|
|
247
230
|
end
|
|
248
231
|
|
|
232
|
+
def shutdown
|
|
233
|
+
cache.shutdown if cache.respond_to?(:shutdown)
|
|
234
|
+
end
|
|
235
|
+
|
|
249
236
|
private
|
|
250
237
|
|
|
238
|
+
# Fetch prompt using the most appropriate caching strategy available
|
|
239
|
+
#
|
|
240
|
+
# @param cache_key [String] The cache key for this prompt
|
|
241
|
+
# @param name [String] The name of the prompt
|
|
242
|
+
# @param version [Integer, nil] Optional specific version number
|
|
243
|
+
# @param label [String, nil] Optional label
|
|
244
|
+
# @return [Hash] The prompt data
|
|
245
|
+
def fetch_with_appropriate_caching_strategy(cache_key, name, version, label)
|
|
246
|
+
if swr_cache_available?
|
|
247
|
+
fetch_with_swr_cache(cache_key, name, version, label)
|
|
248
|
+
elsif distributed_cache_available?
|
|
249
|
+
fetch_with_distributed_cache(cache_key, name, version, label)
|
|
250
|
+
else
|
|
251
|
+
fetch_with_simple_cache(cache_key, name, version, label)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Check if SWR cache is available
|
|
256
|
+
def swr_cache_available?
|
|
257
|
+
cache.respond_to?(:swr_enabled?) && cache.swr_enabled?
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Check if distributed cache is available
|
|
261
|
+
def distributed_cache_available?
|
|
262
|
+
cache.respond_to?(:fetch_with_lock)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Fetch with SWR cache
|
|
266
|
+
def fetch_with_swr_cache(cache_key, name, version, label)
|
|
267
|
+
cache.fetch_with_stale_while_revalidate(cache_key) do
|
|
268
|
+
fetch_prompt_from_api(name, version: version, label: label)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Fetch with distributed cache (Rails.cache with stampede protection)
|
|
273
|
+
def fetch_with_distributed_cache(cache_key, name, version, label)
|
|
274
|
+
cache.fetch_with_lock(cache_key) do
|
|
275
|
+
fetch_prompt_from_api(name, version: version, label: label)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Fetch with simple cache (in-memory cache)
|
|
280
|
+
def fetch_with_simple_cache(cache_key, name, version, label)
|
|
281
|
+
cached_data = cache.get(cache_key)
|
|
282
|
+
return cached_data if cached_data
|
|
283
|
+
|
|
284
|
+
prompt_data = fetch_prompt_from_api(name, version: version, label: label)
|
|
285
|
+
cache.set(cache_key, prompt_data)
|
|
286
|
+
prompt_data
|
|
287
|
+
end
|
|
288
|
+
|
|
251
289
|
# Fetch a prompt from the API (without caching)
|
|
252
290
|
#
|
|
253
291
|
# @param name [String] The name of the prompt
|
|
@@ -408,4 +446,3 @@ module Langfuse
|
|
|
408
446
|
end
|
|
409
447
|
end
|
|
410
448
|
end
|
|
411
|
-
# rubocop:enable Metrics/ClassLength
|
data/lib/langfuse/client.rb
CHANGED
|
@@ -340,9 +340,12 @@ module Langfuse
|
|
|
340
340
|
|
|
341
341
|
# Shutdown the client and flush any pending scores
|
|
342
342
|
#
|
|
343
|
+
# Also shuts down the cache if it supports shutdown (e.g., SWR thread pool).
|
|
344
|
+
#
|
|
343
345
|
# @return [void]
|
|
344
346
|
def shutdown
|
|
345
347
|
@score_client.shutdown
|
|
348
|
+
@api_client.shutdown
|
|
346
349
|
end
|
|
347
350
|
|
|
348
351
|
private
|
|
@@ -362,20 +365,37 @@ module Langfuse
|
|
|
362
365
|
def create_cache
|
|
363
366
|
case config.cache_backend
|
|
364
367
|
when :memory
|
|
365
|
-
|
|
366
|
-
ttl: config.cache_ttl,
|
|
367
|
-
max_size: config.cache_max_size
|
|
368
|
-
)
|
|
368
|
+
create_memory_cache
|
|
369
369
|
when :rails
|
|
370
|
-
|
|
371
|
-
ttl: config.cache_ttl,
|
|
372
|
-
lock_timeout: config.cache_lock_timeout
|
|
373
|
-
)
|
|
370
|
+
create_rails_cache_adapter
|
|
374
371
|
else
|
|
375
372
|
raise ConfigurationError, "Unknown cache backend: #{config.cache_backend}"
|
|
376
373
|
end
|
|
377
374
|
end
|
|
378
375
|
|
|
376
|
+
# Create in-memory cache with SWR support if enabled
|
|
377
|
+
#
|
|
378
|
+
# @return [PromptCache]
|
|
379
|
+
def create_memory_cache
|
|
380
|
+
PromptCache.new(
|
|
381
|
+
ttl: config.cache_ttl,
|
|
382
|
+
max_size: config.cache_max_size,
|
|
383
|
+
stale_ttl: config.normalized_stale_ttl,
|
|
384
|
+
refresh_threads: config.cache_refresh_threads,
|
|
385
|
+
logger: config.logger
|
|
386
|
+
)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def create_rails_cache_adapter
|
|
390
|
+
RailsCacheAdapter.new(
|
|
391
|
+
ttl: config.cache_ttl,
|
|
392
|
+
lock_timeout: config.cache_lock_timeout,
|
|
393
|
+
stale_ttl: config.normalized_stale_ttl,
|
|
394
|
+
refresh_threads: config.cache_refresh_threads,
|
|
395
|
+
logger: config.logger
|
|
396
|
+
)
|
|
397
|
+
end
|
|
398
|
+
|
|
379
399
|
# Build the appropriate prompt client based on prompt type
|
|
380
400
|
#
|
|
381
401
|
# @param prompt_data [Hash] The prompt data from API
|
data/lib/langfuse/config.rb
CHANGED
|
@@ -46,6 +46,16 @@ module Langfuse
|
|
|
46
46
|
# @return [Integer] Lock timeout in seconds for distributed cache stampede protection
|
|
47
47
|
attr_accessor :cache_lock_timeout
|
|
48
48
|
|
|
49
|
+
# @return [Boolean] Enable stale-while-revalidate caching (when true, sets cache_stale_ttl to cache_ttl if not customized)
|
|
50
|
+
attr_accessor :cache_stale_while_revalidate
|
|
51
|
+
|
|
52
|
+
# @return [Integer, Symbol] Stale TTL in seconds (grace period for serving stale data, default: 0 when SWR disabled, cache_ttl when SWR enabled)
|
|
53
|
+
# Accepts :indefinite which is automatically normalized to 1000 years (31,536,000,000 seconds) for practical "never expire" behavior.
|
|
54
|
+
attr_accessor :cache_stale_ttl
|
|
55
|
+
|
|
56
|
+
# @return [Integer] Number of background threads for cache refresh
|
|
57
|
+
attr_accessor :cache_refresh_threads
|
|
58
|
+
|
|
49
59
|
# @return [Boolean] Use async processing for traces (requires ActiveJob)
|
|
50
60
|
attr_accessor :tracing_async
|
|
51
61
|
|
|
@@ -65,11 +75,16 @@ module Langfuse
|
|
|
65
75
|
DEFAULT_CACHE_MAX_SIZE = 1000
|
|
66
76
|
DEFAULT_CACHE_BACKEND = :memory
|
|
67
77
|
DEFAULT_CACHE_LOCK_TIMEOUT = 10
|
|
78
|
+
DEFAULT_CACHE_STALE_WHILE_REVALIDATE = false
|
|
79
|
+
DEFAULT_CACHE_REFRESH_THREADS = 5
|
|
68
80
|
DEFAULT_TRACING_ASYNC = true
|
|
69
81
|
DEFAULT_BATCH_SIZE = 50
|
|
70
82
|
DEFAULT_FLUSH_INTERVAL = 10
|
|
71
83
|
DEFAULT_JOB_QUEUE = :default
|
|
72
84
|
|
|
85
|
+
# Number of seconds representing indefinite cache duration (~1000 years)
|
|
86
|
+
INDEFINITE_SECONDS = 1000 * 365 * 24 * 60 * 60
|
|
87
|
+
|
|
73
88
|
# Initialize a new Config object
|
|
74
89
|
#
|
|
75
90
|
# @yield [config] Optional block for configuration
|
|
@@ -83,6 +98,9 @@ module Langfuse
|
|
|
83
98
|
@cache_max_size = DEFAULT_CACHE_MAX_SIZE
|
|
84
99
|
@cache_backend = DEFAULT_CACHE_BACKEND
|
|
85
100
|
@cache_lock_timeout = DEFAULT_CACHE_LOCK_TIMEOUT
|
|
101
|
+
@cache_stale_while_revalidate = DEFAULT_CACHE_STALE_WHILE_REVALIDATE
|
|
102
|
+
@cache_stale_ttl = 0 # Default to 0 (SWR disabled, entries expire immediately after TTL)
|
|
103
|
+
@cache_refresh_threads = DEFAULT_CACHE_REFRESH_THREADS
|
|
86
104
|
@tracing_async = DEFAULT_TRACING_ASYNC
|
|
87
105
|
@batch_size = DEFAULT_BATCH_SIZE
|
|
88
106
|
@flush_interval = DEFAULT_FLUSH_INTERVAL
|
|
@@ -110,10 +128,29 @@ module Langfuse
|
|
|
110
128
|
"cache_lock_timeout must be positive"
|
|
111
129
|
end
|
|
112
130
|
|
|
131
|
+
validate_swr_config!
|
|
132
|
+
|
|
113
133
|
validate_cache_backend!
|
|
114
134
|
end
|
|
115
135
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
116
136
|
|
|
137
|
+
# Normalize stale_ttl value
|
|
138
|
+
#
|
|
139
|
+
# Converts :indefinite to 1000 years in seconds for practical "never expire"
|
|
140
|
+
# behavior while keeping the value finite for calculations.
|
|
141
|
+
#
|
|
142
|
+
# @return [Integer] Normalized stale TTL in seconds
|
|
143
|
+
#
|
|
144
|
+
# @example
|
|
145
|
+
# config.cache_stale_ttl = 300
|
|
146
|
+
# config.normalized_stale_ttl # => 300
|
|
147
|
+
#
|
|
148
|
+
# config.cache_stale_ttl = :indefinite
|
|
149
|
+
# config.normalized_stale_ttl # => 31536000000
|
|
150
|
+
def normalized_stale_ttl
|
|
151
|
+
cache_stale_ttl == :indefinite ? INDEFINITE_SECONDS : cache_stale_ttl
|
|
152
|
+
end
|
|
153
|
+
|
|
117
154
|
private
|
|
118
155
|
|
|
119
156
|
def default_logger
|
|
@@ -131,5 +168,37 @@ module Langfuse
|
|
|
131
168
|
raise ConfigurationError,
|
|
132
169
|
"cache_backend must be one of #{valid_backends.inspect}, got #{cache_backend.inspect}"
|
|
133
170
|
end
|
|
171
|
+
|
|
172
|
+
def validate_swr_config!
|
|
173
|
+
validate_swr_stale_ttl!
|
|
174
|
+
validate_refresh_threads!
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def validate_swr_stale_ttl!
|
|
178
|
+
# Check if SWR is enabled but stale_ttl is nil
|
|
179
|
+
if cache_stale_while_revalidate && cache_stale_ttl.nil?
|
|
180
|
+
raise ConfigurationError,
|
|
181
|
+
"cache_stale_ttl cannot be nil when cache_stale_while_revalidate is enabled. " \
|
|
182
|
+
"Set it to cache_ttl for a logical default, or use :indefinite for never-expiring cache."
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Validate that cache_stale_ttl is not nil (unless already caught by SWR check)
|
|
186
|
+
if cache_stale_ttl.nil?
|
|
187
|
+
raise ConfigurationError,
|
|
188
|
+
"cache_stale_ttl must be non-negative or :indefinite"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Validate numeric values are non-negative
|
|
192
|
+
return unless cache_stale_ttl.is_a?(Integer) && cache_stale_ttl.negative?
|
|
193
|
+
|
|
194
|
+
raise ConfigurationError,
|
|
195
|
+
"cache_stale_ttl must be non-negative or :indefinite"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def validate_refresh_threads!
|
|
199
|
+
return unless cache_refresh_threads.nil? || cache_refresh_threads <= 0
|
|
200
|
+
|
|
201
|
+
raise ConfigurationError, "cache_refresh_threads must be positive"
|
|
202
|
+
end
|
|
134
203
|
end
|
|
135
204
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "monitor"
|
|
4
|
+
require_relative "stale_while_revalidate"
|
|
4
5
|
|
|
5
6
|
module Langfuse
|
|
6
7
|
# Simple in-memory cache for prompt data with TTL
|
|
@@ -14,24 +15,63 @@ module Langfuse
|
|
|
14
15
|
# cache.get("greeting:1") # => prompt_data
|
|
15
16
|
#
|
|
16
17
|
class PromptCache
|
|
18
|
+
include StaleWhileRevalidate
|
|
19
|
+
|
|
17
20
|
# Cache entry with data and expiration time
|
|
18
|
-
|
|
21
|
+
#
|
|
22
|
+
# Supports stale-while-revalidate pattern:
|
|
23
|
+
# - fresh_until: Time until entry is considered fresh (can be served immediately)
|
|
24
|
+
# - stale_until: Time until entry is considered stale (serve while revalidating in background)
|
|
25
|
+
# - After stale_until: Entry is expired (must revalidate synchronously)
|
|
26
|
+
CacheEntry = Struct.new(:data, :fresh_until, :stale_until) do
|
|
27
|
+
# Check if the cache entry is still fresh
|
|
28
|
+
#
|
|
29
|
+
# @return [Boolean] true if current time is before fresh_until
|
|
30
|
+
def fresh?
|
|
31
|
+
Time.now < fresh_until
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if the cache entry is stale but not expired
|
|
35
|
+
#
|
|
36
|
+
# Stale entries can be served immediately while a background
|
|
37
|
+
# revalidation occurs (stale-while-revalidate pattern)
|
|
38
|
+
#
|
|
39
|
+
# @return [Boolean] true if current time is between fresh_until and stale_until
|
|
40
|
+
def stale?
|
|
41
|
+
now = Time.now
|
|
42
|
+
now >= fresh_until && now < stale_until
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check if the cache entry has expired
|
|
46
|
+
#
|
|
47
|
+
# Expired entries should not be served and must be revalidated
|
|
48
|
+
# synchronously before use.
|
|
49
|
+
#
|
|
50
|
+
# @return [Boolean] true if current time is at or after stale_until
|
|
19
51
|
def expired?
|
|
20
|
-
Time.now
|
|
52
|
+
Time.now >= stale_until
|
|
21
53
|
end
|
|
22
54
|
end
|
|
23
55
|
|
|
24
|
-
attr_reader :ttl, :max_size
|
|
56
|
+
attr_reader :ttl, :max_size, :stale_ttl, :logger
|
|
25
57
|
|
|
26
58
|
# Initialize a new cache
|
|
27
59
|
#
|
|
28
60
|
# @param ttl [Integer] Time-to-live in seconds (default: 60)
|
|
29
61
|
# @param max_size [Integer] Maximum cache size (default: 1000)
|
|
30
|
-
|
|
62
|
+
# @param stale_ttl [Integer] Stale TTL for SWR in seconds (default: 0, SWR disabled).
|
|
63
|
+
# Note: :indefinite is normalized to 1000 years by Config before being passed here.
|
|
64
|
+
# @param refresh_threads [Integer] Number of background refresh threads (default: 5)
|
|
65
|
+
# @param logger [Logger, nil] Logger instance for error reporting (default: nil, creates new logger)
|
|
66
|
+
def initialize(ttl: 60, max_size: 1000, stale_ttl: 0, refresh_threads: 5, logger: default_logger)
|
|
31
67
|
@ttl = ttl
|
|
32
68
|
@max_size = max_size
|
|
69
|
+
@stale_ttl = stale_ttl
|
|
70
|
+
@logger = logger
|
|
33
71
|
@cache = {}
|
|
34
72
|
@monitor = Monitor.new
|
|
73
|
+
@locks = {} # Track locks for in-memory locking
|
|
74
|
+
initialize_swr(refresh_threads: refresh_threads) if swr_enabled?
|
|
35
75
|
end
|
|
36
76
|
|
|
37
77
|
# Get a value from the cache
|
|
@@ -58,8 +98,10 @@ module Langfuse
|
|
|
58
98
|
# Evict oldest entry if at max size
|
|
59
99
|
evict_oldest if @cache.size >= max_size
|
|
60
100
|
|
|
61
|
-
|
|
62
|
-
|
|
101
|
+
now = Time.now
|
|
102
|
+
fresh_until = now + ttl
|
|
103
|
+
stale_until = fresh_until + stale_ttl
|
|
104
|
+
@cache[key] = CacheEntry.new(value, fresh_until, stale_until)
|
|
63
105
|
value
|
|
64
106
|
end
|
|
65
107
|
end
|
|
@@ -117,15 +159,84 @@ module Langfuse
|
|
|
117
159
|
|
|
118
160
|
private
|
|
119
161
|
|
|
162
|
+
# Implementation of StaleWhileRevalidate abstract methods
|
|
163
|
+
|
|
164
|
+
# Get value from cache (SWR interface)
|
|
165
|
+
#
|
|
166
|
+
# @param key [String] Cache key
|
|
167
|
+
# @return [PromptCache::CacheEntry, nil] Cached value
|
|
168
|
+
def cache_get(key)
|
|
169
|
+
@monitor.synchronize do
|
|
170
|
+
@cache[key]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Set value in cache (SWR interface)
|
|
175
|
+
#
|
|
176
|
+
# @param key [String] Cache key
|
|
177
|
+
# @param value [PromptCache::CacheEntry] Value to cache
|
|
178
|
+
# @return [PromptCache::CacheEntry] The cached value
|
|
179
|
+
def cache_set(key, value)
|
|
180
|
+
@monitor.synchronize do
|
|
181
|
+
# Evict oldest entry if at max size
|
|
182
|
+
evict_oldest if @cache.size >= max_size
|
|
183
|
+
|
|
184
|
+
@cache[key] = value
|
|
185
|
+
value
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Acquire a lock using in-memory locking
|
|
190
|
+
#
|
|
191
|
+
# Prevents duplicate background refreshes from different threads within
|
|
192
|
+
# the same process. This is NOT distributed locking - it only works
|
|
193
|
+
# within a single process. For distributed locking, use RailsCacheAdapter.
|
|
194
|
+
#
|
|
195
|
+
# **MEMORY LEAK WARNING**: Locks are stored in a hash and only deleted on
|
|
196
|
+
# release_lock. If a refresh thread crashes or is killed externally (e.g., Thread#kill)
|
|
197
|
+
# between acquire_lock and release_lock, the lock persists forever. Unlike Redis locks
|
|
198
|
+
# which have TTL expiration, in-memory locks have no timeout. For production use with
|
|
199
|
+
# SWR, prefer RailsCacheAdapter to avoid lock accumulation and potential memory exhaustion.
|
|
200
|
+
#
|
|
201
|
+
# @param lock_key [String] Lock key
|
|
202
|
+
# @return [Boolean] true if lock was acquired, false if already held
|
|
203
|
+
def acquire_lock(lock_key)
|
|
204
|
+
@monitor.synchronize do
|
|
205
|
+
return false if @locks[lock_key]
|
|
206
|
+
|
|
207
|
+
@locks[lock_key] = true
|
|
208
|
+
true
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Release a lock
|
|
213
|
+
#
|
|
214
|
+
# @param lock_key [String] Lock key
|
|
215
|
+
# @return [void]
|
|
216
|
+
def release_lock(lock_key)
|
|
217
|
+
@monitor.synchronize do
|
|
218
|
+
@locks.delete(lock_key)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# In-memory cache helper methods
|
|
223
|
+
|
|
120
224
|
# Evict the oldest entry from cache
|
|
121
225
|
#
|
|
122
226
|
# @return [void]
|
|
123
227
|
def evict_oldest
|
|
124
228
|
return if @cache.empty?
|
|
125
229
|
|
|
126
|
-
# Find entry with earliest expiration
|
|
127
|
-
oldest_key = @cache.min_by { |_key, entry| entry.
|
|
230
|
+
# Find entry with earliest expiration (using stale_until as expiration time)
|
|
231
|
+
oldest_key = @cache.min_by { |_key, entry| entry.stale_until }&.first
|
|
128
232
|
@cache.delete(oldest_key) if oldest_key
|
|
129
233
|
end
|
|
234
|
+
|
|
235
|
+
# Create a default logger
|
|
236
|
+
#
|
|
237
|
+
# @return [Logger]
|
|
238
|
+
def default_logger
|
|
239
|
+
Logger.new($stdout, level: Logger::WARN)
|
|
240
|
+
end
|
|
130
241
|
end
|
|
131
242
|
end
|
data/lib/langfuse/propagation.rb
CHANGED
|
@@ -364,7 +364,6 @@ module Langfuse
|
|
|
364
364
|
def self._get_span_key_from_baggage_key(baggage_key)
|
|
365
365
|
return nil unless baggage_key.start_with?(BAGGAGE_PREFIX)
|
|
366
366
|
|
|
367
|
-
# Remove prefix
|
|
368
367
|
suffix = baggage_key[BAGGAGE_PREFIX.length..]
|
|
369
368
|
|
|
370
369
|
# Handle metadata keys (format: langfuse_metadata_{key_name})
|
|
@@ -373,17 +372,7 @@ module Langfuse
|
|
|
373
372
|
return "#{OtelAttributes::TRACE_METADATA}.#{metadata_key}"
|
|
374
373
|
end
|
|
375
374
|
|
|
376
|
-
|
|
377
|
-
case suffix
|
|
378
|
-
when "user_id"
|
|
379
|
-
_get_propagated_span_key("user_id")
|
|
380
|
-
when "session_id"
|
|
381
|
-
_get_propagated_span_key("session_id")
|
|
382
|
-
when "version"
|
|
383
|
-
_get_propagated_span_key("version")
|
|
384
|
-
when "tags"
|
|
385
|
-
_get_propagated_span_key("tags")
|
|
386
|
-
end
|
|
375
|
+
SPAN_KEY_MAP[suffix]
|
|
387
376
|
end
|
|
388
377
|
|
|
389
378
|
# Check if baggage API is available
|
|
@@ -404,7 +393,7 @@ module Langfuse
|
|
|
404
393
|
def self._extract_baggage_attributes(context)
|
|
405
394
|
return {} unless baggage_available?
|
|
406
395
|
|
|
407
|
-
baggage = OpenTelemetry::Baggage.
|
|
396
|
+
baggage = OpenTelemetry::Baggage.values(context: context)
|
|
408
397
|
return {} unless baggage.is_a?(Hash)
|
|
409
398
|
|
|
410
399
|
attributes = {}
|
|
@@ -453,12 +442,12 @@ module Langfuse
|
|
|
453
442
|
if key == "metadata" && value.is_a?(Hash)
|
|
454
443
|
value.each do |k, v|
|
|
455
444
|
entry_key = "#{baggage_key}_#{k}"
|
|
456
|
-
context = OpenTelemetry::Baggage.set_value(
|
|
445
|
+
context = OpenTelemetry::Baggage.set_value(entry_key, v.to_s, context: context)
|
|
457
446
|
end
|
|
458
447
|
elsif key == "tags" && value.is_a?(Array)
|
|
459
|
-
context = OpenTelemetry::Baggage.set_value(
|
|
448
|
+
context = OpenTelemetry::Baggage.set_value(baggage_key, value.join(","), context: context)
|
|
460
449
|
else
|
|
461
|
-
context = OpenTelemetry::Baggage.set_value(
|
|
450
|
+
context = OpenTelemetry::Baggage.set_value(baggage_key, value.to_s, context: context)
|
|
462
451
|
end
|
|
463
452
|
context
|
|
464
453
|
rescue StandardError => e
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "prompt_cache"
|
|
4
|
+
require_relative "stale_while_revalidate"
|
|
5
|
+
|
|
3
6
|
module Langfuse
|
|
4
7
|
# Rails.cache adapter for distributed caching with Redis
|
|
5
8
|
#
|
|
@@ -12,20 +15,30 @@ module Langfuse
|
|
|
12
15
|
# adapter.get("greeting:1") # => prompt_data
|
|
13
16
|
#
|
|
14
17
|
class RailsCacheAdapter
|
|
15
|
-
|
|
18
|
+
include StaleWhileRevalidate
|
|
19
|
+
|
|
20
|
+
attr_reader :ttl, :namespace, :lock_timeout, :stale_ttl, :thread_pool, :logger
|
|
16
21
|
|
|
17
22
|
# Initialize a new Rails.cache adapter
|
|
18
23
|
#
|
|
19
24
|
# @param ttl [Integer] Time-to-live in seconds (default: 60)
|
|
20
25
|
# @param namespace [String] Cache key namespace (default: "langfuse")
|
|
21
26
|
# @param lock_timeout [Integer] Lock timeout in seconds for stampede protection (default: 10)
|
|
27
|
+
# @param stale_ttl [Integer] Stale TTL for SWR in seconds (default: 0, SWR disabled).
|
|
28
|
+
# Note: :indefinite is normalized to 1000 years by Config before being passed here.
|
|
29
|
+
# @param refresh_threads [Integer] Number of background refresh threads (default: 5)
|
|
30
|
+
# @param logger [Logger, nil] Logger instance for error reporting (default: nil, creates new logger)
|
|
22
31
|
# @raise [ConfigurationError] if Rails.cache is not available
|
|
23
|
-
def initialize(ttl: 60, namespace: "langfuse", lock_timeout: 10
|
|
32
|
+
def initialize(ttl: 60, namespace: "langfuse", lock_timeout: 10, stale_ttl: 0, refresh_threads: 5,
|
|
33
|
+
logger: default_logger)
|
|
24
34
|
validate_rails_cache!
|
|
25
35
|
|
|
26
36
|
@ttl = ttl
|
|
27
37
|
@namespace = namespace
|
|
28
38
|
@lock_timeout = lock_timeout
|
|
39
|
+
@stale_ttl = stale_ttl
|
|
40
|
+
@logger = logger
|
|
41
|
+
initialize_swr(refresh_threads: refresh_threads) if swr_enabled?
|
|
29
42
|
end
|
|
30
43
|
|
|
31
44
|
# Get a value from the cache
|
|
@@ -42,14 +55,57 @@ module Langfuse
|
|
|
42
55
|
# @param value [Object] Value to cache
|
|
43
56
|
# @return [Object] The cached value
|
|
44
57
|
def set(key, value)
|
|
45
|
-
|
|
58
|
+
# Calculate expiration: use total_ttl if SWR enabled, otherwise just ttl
|
|
59
|
+
expires_in = swr_enabled? ? total_ttl : ttl
|
|
60
|
+
Rails.cache.write(namespaced_key(key), value, expires_in:)
|
|
46
61
|
value
|
|
47
62
|
end
|
|
48
63
|
|
|
49
|
-
#
|
|
64
|
+
# Clear the entire Langfuse cache namespace
|
|
65
|
+
#
|
|
66
|
+
# Note: This uses delete_matched which may not be available on all cache stores.
|
|
67
|
+
# Works with Redis, Memcached, and memory stores. File store support varies.
|
|
68
|
+
#
|
|
69
|
+
# @return [void]
|
|
70
|
+
def clear
|
|
71
|
+
# Delete all keys matching the namespace pattern
|
|
72
|
+
Rails.cache.delete_matched("#{namespace}:*")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Get current cache size
|
|
76
|
+
#
|
|
77
|
+
# Note: Rails.cache doesn't provide a size method, so we return nil
|
|
78
|
+
# to indicate this operation is not supported.
|
|
79
|
+
#
|
|
80
|
+
# @return [nil]
|
|
81
|
+
def size
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Check if cache is empty
|
|
86
|
+
#
|
|
87
|
+
# Note: Rails.cache doesn't provide an efficient way to check if empty,
|
|
88
|
+
# so we return false to indicate this operation is not supported.
|
|
89
|
+
#
|
|
90
|
+
# @return [Boolean] Always returns false (unsupported operation)
|
|
91
|
+
def empty?
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Build a cache key from prompt name and options
|
|
96
|
+
#
|
|
97
|
+
# @param name [String] Prompt name
|
|
98
|
+
# @param version [Integer, nil] Optional version
|
|
99
|
+
# @param label [String, nil] Optional label
|
|
100
|
+
# @return [String] Cache key
|
|
101
|
+
def self.build_key(name, version: nil, label: nil)
|
|
102
|
+
PromptCache.build_key(name, version: version, label: label)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Fetch a value from cache with lock for stampede protection
|
|
50
106
|
#
|
|
51
107
|
# This method prevents cache stampedes (thundering herd) by ensuring only one
|
|
52
|
-
# process fetches from the source when the cache is empty.
|
|
108
|
+
# process/thread fetches from the source when the cache is empty. Others wait
|
|
53
109
|
# for the first one to populate the cache.
|
|
54
110
|
#
|
|
55
111
|
# Uses exponential backoff: 50ms, 100ms, 200ms (3 retries max, ~350ms total).
|
|
@@ -60,7 +116,7 @@ module Langfuse
|
|
|
60
116
|
# @return [Object] Cached or freshly fetched value
|
|
61
117
|
#
|
|
62
118
|
# @example
|
|
63
|
-
#
|
|
119
|
+
# cache.fetch_with_lock("greeting:v1") do
|
|
64
120
|
# api_client.get_prompt("greeting")
|
|
65
121
|
# end
|
|
66
122
|
def fetch_with_lock(key)
|
|
@@ -68,8 +124,8 @@ module Langfuse
|
|
|
68
124
|
cached = get(key)
|
|
69
125
|
return cached if cached
|
|
70
126
|
|
|
71
|
-
# 2. Cache miss - try to acquire
|
|
72
|
-
lock_key =
|
|
127
|
+
# 2. Cache miss - try to acquire lock
|
|
128
|
+
lock_key = build_lock_key(key)
|
|
73
129
|
|
|
74
130
|
if acquire_lock(lock_key)
|
|
75
131
|
begin
|
|
@@ -92,74 +148,57 @@ module Langfuse
|
|
|
92
148
|
end
|
|
93
149
|
end
|
|
94
150
|
|
|
95
|
-
|
|
96
|
-
#
|
|
97
|
-
# Note: This uses delete_matched which may not be available on all cache stores.
|
|
98
|
-
# Works with Redis, Memcached, and memory stores. File store support varies.
|
|
99
|
-
#
|
|
100
|
-
# @return [void]
|
|
101
|
-
def clear
|
|
102
|
-
# Delete all keys matching the namespace pattern
|
|
103
|
-
Rails.cache.delete_matched("#{namespace}:*")
|
|
104
|
-
end
|
|
151
|
+
private
|
|
105
152
|
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
# Note: Rails.cache doesn't provide a size method, so we return nil
|
|
109
|
-
# to indicate this operation is not supported.
|
|
110
|
-
#
|
|
111
|
-
# @return [nil]
|
|
112
|
-
def size
|
|
113
|
-
nil
|
|
114
|
-
end
|
|
153
|
+
# Implementation of StaleWhileRevalidate abstract methods
|
|
115
154
|
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
# Note: Rails.cache doesn't provide an efficient way to check if empty,
|
|
119
|
-
# so we return false to indicate this operation is not supported.
|
|
155
|
+
# Get value from cache (SWR interface)
|
|
120
156
|
#
|
|
121
|
-
# @
|
|
122
|
-
|
|
123
|
-
|
|
157
|
+
# @param key [String] Cache key
|
|
158
|
+
# @return [Object, nil] Cached value
|
|
159
|
+
def cache_get(key)
|
|
160
|
+
get(key)
|
|
124
161
|
end
|
|
125
162
|
|
|
126
|
-
#
|
|
163
|
+
# Set value in cache (SWR interface)
|
|
127
164
|
#
|
|
128
|
-
# @param
|
|
129
|
-
# @param
|
|
130
|
-
# @
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
PromptCache.build_key(name, version: version, label: label)
|
|
165
|
+
# @param key [String] Cache key
|
|
166
|
+
# @param value [Object] Value to cache (expects CacheEntry)
|
|
167
|
+
# @return [Object] The cached value
|
|
168
|
+
def cache_set(key, value)
|
|
169
|
+
set(key, value)
|
|
134
170
|
end
|
|
135
171
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
# Add namespace prefix to cache key
|
|
172
|
+
# Build lock key with namespace
|
|
139
173
|
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
|
|
143
|
-
|
|
174
|
+
# Used for both fetch operations (stampede protection) and refresh operations
|
|
175
|
+
# (preventing duplicate background refreshes).
|
|
176
|
+
#
|
|
177
|
+
# @param key [String] Cache key
|
|
178
|
+
# @return [String] Namespaced lock key
|
|
179
|
+
def build_lock_key(key)
|
|
180
|
+
"#{namespaced_key(key)}:lock"
|
|
144
181
|
end
|
|
145
182
|
|
|
146
|
-
# Acquire a
|
|
183
|
+
# Acquire a lock using Rails.cache
|
|
147
184
|
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
185
|
+
# Used for both fetch operations and refresh operations.
|
|
186
|
+
# Uses the configured lock_timeout for all locking scenarios.
|
|
150
187
|
#
|
|
151
188
|
# @param lock_key [String] Full lock key (already namespaced)
|
|
152
|
-
# @return [Boolean] true if lock was acquired, false if already held
|
|
189
|
+
# @return [Boolean] true if lock was acquired, false if already held
|
|
153
190
|
def acquire_lock(lock_key)
|
|
154
191
|
Rails.cache.write(
|
|
155
192
|
lock_key,
|
|
156
193
|
true,
|
|
157
194
|
unless_exist: true, # Atomic: only write if key doesn't exist
|
|
158
|
-
expires_in: lock_timeout #
|
|
195
|
+
expires_in: lock_timeout # Use configured lock timeout
|
|
159
196
|
)
|
|
160
197
|
end
|
|
161
198
|
|
|
162
|
-
# Release a
|
|
199
|
+
# Release a lock
|
|
200
|
+
#
|
|
201
|
+
# Used for both fetch and refresh operations.
|
|
163
202
|
#
|
|
164
203
|
# @param lock_key [String] Full lock key (already namespaced)
|
|
165
204
|
# @return [void]
|
|
@@ -172,7 +211,7 @@ module Langfuse
|
|
|
172
211
|
# Uses exponential backoff: 50ms, 100ms, 200ms (3 retries, ~350ms total).
|
|
173
212
|
# This gives the lock holder time to fetch and populate the cache.
|
|
174
213
|
#
|
|
175
|
-
# @param key [String] Cache key
|
|
214
|
+
# @param key [String] Cache key
|
|
176
215
|
# @return [Object, nil] Cached value if found, nil if still empty after waiting
|
|
177
216
|
def wait_for_cache(key)
|
|
178
217
|
intervals = [0.05, 0.1, 0.2] # 50ms, 100ms, 200ms (exponential backoff)
|
|
@@ -186,6 +225,16 @@ module Langfuse
|
|
|
186
225
|
nil # Cache still empty after all retries
|
|
187
226
|
end
|
|
188
227
|
|
|
228
|
+
# Rails.cache-specific helper methods
|
|
229
|
+
|
|
230
|
+
# Add namespace prefix to cache key
|
|
231
|
+
#
|
|
232
|
+
# @param key [String] Original cache key
|
|
233
|
+
# @return [String] Namespaced cache key
|
|
234
|
+
def namespaced_key(key)
|
|
235
|
+
"#{namespace}:#{key}"
|
|
236
|
+
end
|
|
237
|
+
|
|
189
238
|
# Validate that Rails.cache is available
|
|
190
239
|
#
|
|
191
240
|
# @raise [ConfigurationError] if Rails.cache is not available
|
|
@@ -196,5 +245,16 @@ module Langfuse
|
|
|
196
245
|
raise ConfigurationError,
|
|
197
246
|
"Rails.cache is not available. Rails cache backend requires Rails with a configured cache store."
|
|
198
247
|
end
|
|
248
|
+
|
|
249
|
+
# Create a default logger
|
|
250
|
+
#
|
|
251
|
+
# @return [Logger]
|
|
252
|
+
def default_logger
|
|
253
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
254
|
+
Rails.logger
|
|
255
|
+
else
|
|
256
|
+
Logger.new($stdout, level: Logger::WARN)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
199
259
|
end
|
|
200
260
|
end
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Langfuse
|
|
6
|
+
# Stale-While-Revalidate caching pattern module
|
|
7
|
+
#
|
|
8
|
+
# Provides SWR functionality for cache implementations. When included,
|
|
9
|
+
# allows serving stale data immediately while refreshing in the background.
|
|
10
|
+
#
|
|
11
|
+
# Including classes must implement:
|
|
12
|
+
# - cache_get(key) - Read from cache
|
|
13
|
+
# - cache_set(key, value) - Write to cache
|
|
14
|
+
# - acquire_lock(lock_key) - Acquire lock for background refresh
|
|
15
|
+
# - release_lock(lock_key) - Release refresh lock
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# class MyCache
|
|
19
|
+
# include Langfuse::StaleWhileRevalidate
|
|
20
|
+
#
|
|
21
|
+
# def initialize(ttl: 60, stale_ttl: 0)
|
|
22
|
+
# @ttl = ttl
|
|
23
|
+
# @stale_ttl = stale_ttl
|
|
24
|
+
# @logger = Logger.new($stdout)
|
|
25
|
+
# initialize_swr if stale_ttl.positive?
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# def cache_get(key)
|
|
29
|
+
# @storage[key]
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# def cache_set(key, value)
|
|
33
|
+
# @storage[key] = value
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# def acquire_lock(lock_key)
|
|
37
|
+
# # Implementation-specific lock acquisition
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
# def release_lock(lock_key)
|
|
41
|
+
# # Implementation-specific lock release
|
|
42
|
+
# end
|
|
43
|
+
# end
|
|
44
|
+
module StaleWhileRevalidate
|
|
45
|
+
# Initialize SWR infrastructure
|
|
46
|
+
#
|
|
47
|
+
# Must be called by including class after setting @stale_ttl, @ttl, and @logger.
|
|
48
|
+
# Typically called in the class's initialize method when stale_ttl is provided.
|
|
49
|
+
#
|
|
50
|
+
# @param refresh_threads [Integer] Number of background refresh threads (default: 5)
|
|
51
|
+
# @return [void]
|
|
52
|
+
def initialize_swr(refresh_threads: 5)
|
|
53
|
+
@thread_pool = initialize_thread_pool(refresh_threads)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Fetch a value from cache with Stale-While-Revalidate support
|
|
57
|
+
#
|
|
58
|
+
# This method implements SWR caching: serves stale data immediately while
|
|
59
|
+
# refreshing in the background. Requires SWR to be enabled (stale_ttl must be positive).
|
|
60
|
+
#
|
|
61
|
+
# Three cache states:
|
|
62
|
+
# - FRESH: Return immediately, no action needed
|
|
63
|
+
# - STALE: Return stale data + trigger background refresh
|
|
64
|
+
# - EXPIRED: Must fetch fresh data synchronously
|
|
65
|
+
#
|
|
66
|
+
# @param key [String] Cache key
|
|
67
|
+
# @yield Block to execute to fetch fresh data
|
|
68
|
+
# @return [Object] Cached, stale, or freshly fetched value
|
|
69
|
+
# @raise [ConfigurationError] if SWR is not enabled (stale_ttl is not positive)
|
|
70
|
+
#
|
|
71
|
+
# @example
|
|
72
|
+
# cache.fetch_with_stale_while_revalidate("greeting:v1") do
|
|
73
|
+
# api_client.get_prompt("greeting")
|
|
74
|
+
# end
|
|
75
|
+
def fetch_with_stale_while_revalidate(key, &)
|
|
76
|
+
raise ConfigurationError, "fetch_with_stale_while_revalidate requires a positive stale_ttl" unless swr_enabled?
|
|
77
|
+
|
|
78
|
+
entry = cache_get(key)
|
|
79
|
+
|
|
80
|
+
if entry&.fresh?
|
|
81
|
+
# FRESH - return immediately
|
|
82
|
+
logger.debug("CACHE HIT!")
|
|
83
|
+
entry.data
|
|
84
|
+
elsif entry&.stale?
|
|
85
|
+
# REVALIDATE - return stale + refresh in background
|
|
86
|
+
logger.debug("CACHE STALE!")
|
|
87
|
+
schedule_refresh(key, &)
|
|
88
|
+
entry.data # Instant response!
|
|
89
|
+
else
|
|
90
|
+
# MISS - must fetch synchronously
|
|
91
|
+
logger.debug("CACHE MISS!")
|
|
92
|
+
fetch_and_cache(key, &)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Check if SWR is enabled
|
|
97
|
+
#
|
|
98
|
+
# SWR is enabled when stale_ttl is positive, meaning there's a grace period
|
|
99
|
+
# where stale data can be served while revalidating in the background.
|
|
100
|
+
#
|
|
101
|
+
# @return [Boolean] true if stale_ttl is positive
|
|
102
|
+
def swr_enabled?
|
|
103
|
+
stale_ttl.positive?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Shutdown the cache refresh thread pool gracefully
|
|
107
|
+
#
|
|
108
|
+
# @return [void]
|
|
109
|
+
def shutdown
|
|
110
|
+
return unless @thread_pool
|
|
111
|
+
|
|
112
|
+
@thread_pool.shutdown
|
|
113
|
+
@thread_pool.wait_for_termination(5) # Wait up to 5 seconds
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# Initialize thread pool for background refresh operations
|
|
119
|
+
#
|
|
120
|
+
# @param refresh_threads [Integer] Maximum number of refresh threads
|
|
121
|
+
# @return [Concurrent::CachedThreadPool]
|
|
122
|
+
def initialize_thread_pool(refresh_threads)
|
|
123
|
+
Concurrent::CachedThreadPool.new(
|
|
124
|
+
max_threads: refresh_threads,
|
|
125
|
+
min_threads: 0,
|
|
126
|
+
max_queue: 50,
|
|
127
|
+
fallback_policy: :discard
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Schedule a background refresh for a cache key
|
|
132
|
+
#
|
|
133
|
+
# Prevents duplicate refreshes by using a fetch lock. If another process/thread
|
|
134
|
+
# is already refreshing this key, this method returns immediately.
|
|
135
|
+
#
|
|
136
|
+
# Errors during refresh are caught and logged to prevent thread crashes.
|
|
137
|
+
#
|
|
138
|
+
# @param key [String] Cache key
|
|
139
|
+
# @yield Block to execute to fetch fresh data
|
|
140
|
+
# @return [void]
|
|
141
|
+
def schedule_refresh(key, &block)
|
|
142
|
+
# Prevent duplicate refreshes
|
|
143
|
+
lock_key = build_lock_key(key)
|
|
144
|
+
return unless acquire_lock(lock_key)
|
|
145
|
+
|
|
146
|
+
@thread_pool.post do
|
|
147
|
+
value = yield block
|
|
148
|
+
set_cache_entry(key, value)
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
logger.error("Langfuse cache refresh failed for key '#{key}': #{e.class} - #{e.message}")
|
|
151
|
+
ensure
|
|
152
|
+
release_lock(lock_key)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Fetch data and cache it with SWR metadata
|
|
157
|
+
#
|
|
158
|
+
# @param key [String] Cache key
|
|
159
|
+
# @yield Block to execute to fetch fresh data
|
|
160
|
+
# @return [Object] Freshly fetched value
|
|
161
|
+
def fetch_and_cache(key, &block)
|
|
162
|
+
value = yield block
|
|
163
|
+
set_cache_entry(key, value)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Set value in cache with SWR metadata (CacheEntry)
|
|
167
|
+
#
|
|
168
|
+
# @param key [String] Cache key
|
|
169
|
+
# @param value [Object] Value to cache
|
|
170
|
+
# @return [Object] The cached value
|
|
171
|
+
def set_cache_entry(key, value)
|
|
172
|
+
now = Time.now
|
|
173
|
+
fresh_until = now + ttl
|
|
174
|
+
stale_until = fresh_until + stale_ttl
|
|
175
|
+
entry = PromptCache::CacheEntry.new(value, fresh_until, stale_until)
|
|
176
|
+
|
|
177
|
+
cache_set(key, entry)
|
|
178
|
+
|
|
179
|
+
value
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Build a lock key for fetch operations
|
|
183
|
+
#
|
|
184
|
+
# Can be overridden by including class if custom key format is needed.
|
|
185
|
+
#
|
|
186
|
+
# @param key [String] Cache key
|
|
187
|
+
# @return [String] Lock key
|
|
188
|
+
def build_lock_key(key)
|
|
189
|
+
"#{key}:lock"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Calculate total TTL (fresh + stale)
|
|
193
|
+
#
|
|
194
|
+
# @return [Integer] Total TTL in seconds
|
|
195
|
+
def total_ttl
|
|
196
|
+
ttl + stale_ttl
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Abstract methods that must be implemented by including class
|
|
200
|
+
|
|
201
|
+
# Get a value from cache
|
|
202
|
+
#
|
|
203
|
+
# @param key [String] Cache key
|
|
204
|
+
# @return [Object, nil] Cached value or nil
|
|
205
|
+
# @raise [NotImplementedError] if not implemented by including class
|
|
206
|
+
def cache_get(_key)
|
|
207
|
+
raise NotImplementedError, "#{self.class} must implement #cache_get"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Set a value in cache
|
|
211
|
+
#
|
|
212
|
+
# @param key [String] Cache key
|
|
213
|
+
# @param value [Object] Value to cache
|
|
214
|
+
# @return [Object] The cached value
|
|
215
|
+
# @raise [NotImplementedError] if not implemented by including class
|
|
216
|
+
def cache_set(_key, _value)
|
|
217
|
+
raise NotImplementedError, "#{self.class} must implement #cache_set"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Acquire a lock
|
|
221
|
+
#
|
|
222
|
+
# @param lock_key [String] Lock key
|
|
223
|
+
# @return [Boolean] true if lock was acquired
|
|
224
|
+
# @raise [NotImplementedError] if not implemented by including class
|
|
225
|
+
def acquire_lock(_lock_key)
|
|
226
|
+
raise NotImplementedError, "#{self.class} must implement #acquire_lock"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Release a lock
|
|
230
|
+
#
|
|
231
|
+
# @param lock_key [String] Lock key
|
|
232
|
+
# @return [void]
|
|
233
|
+
# @raise [NotImplementedError] if not implemented by including class
|
|
234
|
+
def release_lock(_lock_key)
|
|
235
|
+
raise NotImplementedError, "#{self.class} must implement #release_lock"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Get TTL value
|
|
239
|
+
#
|
|
240
|
+
# @return [Integer] TTL in seconds
|
|
241
|
+
# @raise [NotImplementedError] if not implemented by including class
|
|
242
|
+
def ttl
|
|
243
|
+
@ttl || raise(NotImplementedError, "#{self.class} must provide @ttl")
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Get stale TTL value
|
|
247
|
+
#
|
|
248
|
+
# @return [Integer] Stale TTL in seconds
|
|
249
|
+
# @raise [NotImplementedError] if not implemented by including class
|
|
250
|
+
def stale_ttl
|
|
251
|
+
@stale_ttl || raise(NotImplementedError, "#{self.class} must provide @stale_ttl")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Get logger instance
|
|
255
|
+
#
|
|
256
|
+
# @return [Logger] Logger instance
|
|
257
|
+
# @raise [NotImplementedError] if not implemented by including class
|
|
258
|
+
def logger
|
|
259
|
+
@logger || raise(NotImplementedError, "#{self.class} must provide @logger")
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
data/lib/langfuse/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: langfuse-rb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- SimplePractice
|
|
@@ -13,30 +13,42 @@ dependencies:
|
|
|
13
13
|
name: faraday
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
|
-
- - "
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.0'
|
|
19
|
+
- - "<"
|
|
17
20
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '
|
|
21
|
+
version: '3'
|
|
19
22
|
type: :runtime
|
|
20
23
|
prerelease: false
|
|
21
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
25
|
requirements:
|
|
23
|
-
- - "
|
|
26
|
+
- - ">="
|
|
24
27
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '
|
|
28
|
+
version: '1.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '3'
|
|
26
32
|
- !ruby/object:Gem::Dependency
|
|
27
33
|
name: faraday-retry
|
|
28
34
|
requirement: !ruby/object:Gem::Requirement
|
|
29
35
|
requirements:
|
|
30
|
-
- - "
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '1.0'
|
|
39
|
+
- - "<"
|
|
31
40
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '
|
|
41
|
+
version: '3.0'
|
|
33
42
|
type: :runtime
|
|
34
43
|
prerelease: false
|
|
35
44
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
45
|
requirements:
|
|
37
|
-
- - "
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '1.0'
|
|
49
|
+
- - "<"
|
|
38
50
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '
|
|
51
|
+
version: '3.0'
|
|
40
52
|
- !ruby/object:Gem::Dependency
|
|
41
53
|
name: mustache
|
|
42
54
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -51,6 +63,20 @@ dependencies:
|
|
|
51
63
|
- - "~>"
|
|
52
64
|
- !ruby/object:Gem::Version
|
|
53
65
|
version: '1.1'
|
|
66
|
+
- !ruby/object:Gem::Dependency
|
|
67
|
+
name: concurrent-ruby
|
|
68
|
+
requirement: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - "~>"
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '1.2'
|
|
73
|
+
type: :runtime
|
|
74
|
+
prerelease: false
|
|
75
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - "~>"
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '1.2'
|
|
54
80
|
- !ruby/object:Gem::Dependency
|
|
55
81
|
name: opentelemetry-api
|
|
56
82
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -146,6 +172,7 @@ files:
|
|
|
146
172
|
- lib/langfuse/rails_cache_adapter.rb
|
|
147
173
|
- lib/langfuse/score_client.rb
|
|
148
174
|
- lib/langfuse/span_processor.rb
|
|
175
|
+
- lib/langfuse/stale_while_revalidate.rb
|
|
149
176
|
- lib/langfuse/text_prompt_client.rb
|
|
150
177
|
- lib/langfuse/types.rb
|
|
151
178
|
- lib/langfuse/version.rb
|
|
@@ -171,7 +198,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
171
198
|
- !ruby/object:Gem::Version
|
|
172
199
|
version: '0'
|
|
173
200
|
requirements: []
|
|
174
|
-
rubygems_version: 4.0.
|
|
201
|
+
rubygems_version: 4.0.3
|
|
175
202
|
specification_version: 4
|
|
176
203
|
summary: Ruby SDK for Langfuse - LLM observability and prompt management
|
|
177
204
|
test_files: []
|