semantic-cache 0.1.0 → 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: 0a16f649e20d3989d12d16c4e52b809c7f91fc81383d83cdd9e4b86306ceb8d4
4
- data.tar.gz: 8e1a0cf3bfdec291aa24141ffdd4c32f31f4d33802e10b4bc08e36b5576daec1
3
+ metadata.gz: fe7443a701b34378500d134735a7d00eea7ab078cfaf036d7b11406495eed1b9
4
+ data.tar.gz: 4f079d89a8e0f977ac89af9ba09841e9064c7121b649ea91f9fb430475f0a628
5
5
  SHA512:
6
- metadata.gz: 339b424cff6654e37aa888b22ef9a9ab8f06d69fcda0d3ed42f62ded02d852d29ac18a3a1ee570539231e8cbdd83922ef909cdc24eb11e301859f73906ca774c
7
- data.tar.gz: 857a0e0013fb7d93c006c87418c9adad1fbd0cfb79faa92cadcbf8a35ed9c690e3c26f02df2247d62e6a48254959e29bffc8457a6bef55e3c6807f624d54502c
6
+ metadata.gz: 8b79b8b26a38343eed5db1e008be0ca4417277c33fb3ab0beda3dc8b6440d79a3d8d5e39301714ba12cf1a268be3dbe3307f639c8bc0dea83dd34e9186d4c566
7
+ data.tar.gz: bd19ca0b045887bffa9c2510977d6e005ea1fad50a4e44012e1e88a6a051100286fe09a847848ffa0ddd78b20212510b51ed3175fc4ed5d9a77edf2ea45a0929
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.2.0] - 2025-01-29
6
+
7
+ ### Added
8
+
9
+ - **Input validation** — `cache.fetch(nil)` and `cache.fetch("")` now raise `ArgumentError` early instead of failing at the API level
10
+ - **API timeout** — embedding generation is wrapped in `Timeout.timeout` (default: 30s, configurable via `config.embedding_timeout`)
11
+ - **Max cache size** — both Memory and Redis stores support `max_size` with LRU eviction (oldest entry by `created_at` is evicted when full)
12
+ - **Redis store tests** — full test coverage for write, entries, delete, tags, clear, size, max_size, and namespace
13
+ - **Rails integration tests** — tests for `with_cache`, `current`, `Cacheable` concern, and exception safety
14
+ - **Embedding batch validation** — `generate_batch` validates each element in the array
15
+
16
+ ### Improved
17
+
18
+ - Test coverage increased from 85.6% to 97.67% (113 tests, 0 failures)
19
+ - Configuration now includes `embedding_timeout` and `max_cache_size` attributes
20
+
5
21
  ## [0.1.0] - 2025-01-29
6
22
 
7
23
  ### Added
@@ -13,7 +13,8 @@ module SemanticCache
13
13
  store_options: {},
14
14
  default_ttl: nil,
15
15
  namespace: nil,
16
- track_costs: nil
16
+ track_costs: nil,
17
+ max_size: nil
17
18
  )
18
19
  config = SemanticCache.configuration
19
20
 
@@ -21,6 +22,7 @@ module SemanticCache
21
22
  @default_ttl = default_ttl || config.default_ttl
22
23
  @track_costs = track_costs.nil? ? config.track_costs : track_costs
23
24
  @namespace = namespace || config.namespace
25
+ @max_size = max_size || config.max_cache_size
24
26
 
25
27
  @embedding = Embedding.new(
26
28
  model: embedding_model || config.embedding_model,
@@ -43,6 +45,7 @@ module SemanticCache
43
45
  # model: - Model name for cost tracking
44
46
  def fetch(query, ttl: nil, tags: [], model: nil, metadata: {}, &block)
45
47
  raise ArgumentError, "A block is required" unless block_given?
48
+ validate_query!(query)
46
49
 
47
50
  start_time = Time.now
48
51
 
@@ -133,6 +136,13 @@ module SemanticCache
133
136
 
134
137
  private
135
138
 
139
+ def validate_query!(query)
140
+ raise ArgumentError, "query cannot be nil" if query.nil?
141
+
142
+ query_str = query.to_s.strip
143
+ raise ArgumentError, "query cannot be blank" if query_str.empty?
144
+ end
145
+
136
146
  def find_similar(query_embedding)
137
147
  entries = @store.entries
138
148
  return nil if entries.empty?
@@ -168,11 +178,13 @@ module SemanticCache
168
178
  end
169
179
 
170
180
  def build_store(type, options)
181
+ store_max_size = @max_size
182
+
171
183
  case type
172
184
  when :memory, "memory"
173
- Stores::Memory.new(**options)
185
+ Stores::Memory.new(max_size: store_max_size, **options)
174
186
  when :redis, "redis"
175
- Stores::Redis.new(**options)
187
+ Stores::Redis.new(max_size: store_max_size, **options)
176
188
  when Stores::Memory, Stores::Redis
177
189
  type # Already instantiated
178
190
  else
@@ -10,7 +10,9 @@ module SemanticCache
10
10
  :store_options,
11
11
  :track_costs,
12
12
  :model_costs,
13
- :namespace
13
+ :namespace,
14
+ :embedding_timeout,
15
+ :max_cache_size
14
16
 
15
17
  # Cost per 1K tokens (USD)
16
18
  DEFAULT_MODEL_COSTS = {
@@ -41,6 +43,8 @@ module SemanticCache
41
43
  @track_costs = true
42
44
  @model_costs = DEFAULT_MODEL_COSTS.dup
43
45
  @namespace = "semantic_cache"
46
+ @embedding_timeout = 30 # seconds
47
+ @max_cache_size = nil # nil = unlimited
44
48
  end
45
49
 
46
50
  def cost_for(model)
@@ -1,24 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "openai"
4
+ require "timeout"
4
5
 
5
6
  module SemanticCache
6
7
  class Embedding
7
8
  def initialize(model: nil, api_key: nil)
8
9
  config = SemanticCache.configuration
9
10
  @model = model || config.embedding_model
11
+ @timeout = config.embedding_timeout
10
12
  @client = OpenAI::Client.new(access_token: api_key || config.openai_api_key)
11
13
  end
12
14
 
13
15
  # Generate an embedding vector for the given text.
14
16
  # Returns an Array of Floats.
17
+ #
18
+ # Raises ArgumentError if text is nil or empty.
19
+ # Raises SemanticCache::Error on API failure or timeout.
15
20
  def generate(text)
16
- response = @client.embeddings(
17
- parameters: {
18
- model: @model,
19
- input: text
20
- }
21
- )
21
+ validate_input!(text)
22
+
23
+ response = with_timeout do
24
+ @client.embeddings(
25
+ parameters: {
26
+ model: @model,
27
+ input: text
28
+ }
29
+ )
30
+ end
22
31
 
23
32
  data = response.dig("data", 0, "embedding")
24
33
  raise Error, "Failed to generate embedding: #{response}" if data.nil?
@@ -28,18 +37,46 @@ module SemanticCache
28
37
 
29
38
  # Generate embeddings for multiple texts in a single API call.
30
39
  # Returns an Array of Arrays of Floats.
40
+ #
41
+ # Raises ArgumentError if texts is empty or contains nil/blank entries.
42
+ # Raises SemanticCache::Error on API failure or timeout.
31
43
  def generate_batch(texts)
32
- response = @client.embeddings(
33
- parameters: {
34
- model: @model,
35
- input: texts
36
- }
37
- )
44
+ raise ArgumentError, "texts must be a non-empty Array" if !texts.is_a?(Array) || texts.empty?
45
+
46
+ texts.each_with_index do |t, i|
47
+ validate_input!(t, label: "texts[#{i}]")
48
+ end
49
+
50
+ response = with_timeout do
51
+ @client.embeddings(
52
+ parameters: {
53
+ model: @model,
54
+ input: texts
55
+ }
56
+ )
57
+ end
38
58
 
39
59
  data = response["data"]
40
60
  raise Error, "Failed to generate embeddings: #{response}" if data.nil?
41
61
 
42
62
  data.sort_by { |d| d["index"] }.map { |d| d["embedding"] }
43
63
  end
64
+
65
+ private
66
+
67
+ def validate_input!(text, label: "query")
68
+ raise ArgumentError, "#{label} cannot be nil" if text.nil?
69
+
70
+ text_str = text.to_s.strip
71
+ raise ArgumentError, "#{label} cannot be blank" if text_str.empty?
72
+ end
73
+
74
+ def with_timeout(&block)
75
+ if @timeout && @timeout > 0
76
+ Timeout.timeout(@timeout, Error, "Embedding API request timed out after #{@timeout}s", &block)
77
+ else
78
+ block.call
79
+ end
80
+ end
44
81
  end
45
82
  end
@@ -6,18 +6,24 @@ module SemanticCache
6
6
  module Stores
7
7
  # Thread-safe in-memory cache store.
8
8
  # Good for development, testing, and single-process apps.
9
+ #
10
+ # Options:
11
+ # max_size: Maximum number of entries to keep. When exceeded, the oldest
12
+ # entry (by created_at) is evicted. nil = unlimited.
9
13
  class Memory
10
14
  include MonitorMixin
11
15
 
12
- def initialize(**_options)
16
+ def initialize(max_size: nil, **_options)
13
17
  super()
14
18
  @data = {}
15
19
  @tags_index = Hash.new { |h, k| h[k] = Set.new }
20
+ @max_size = max_size
16
21
  end
17
22
 
18
- # Store a cache entry.
23
+ # Store a cache entry. Evicts the oldest entry if max_size is reached.
19
24
  def write(key, entry)
20
25
  synchronize do
26
+ evict_oldest! if @max_size && @data.size >= @max_size && !@data.key?(key)
21
27
  @data[key] = entry
22
28
  entry.tags.each { |tag| @tags_index[tag].add(key) }
23
29
  end
@@ -73,6 +79,12 @@ module SemanticCache
73
79
  expired_keys = @data.select { |_k, v| v.expired? }.keys
74
80
  expired_keys.each { |key| delete(key) }
75
81
  end
82
+
83
+ # Evict the oldest entry (by created_at) to make room for a new one.
84
+ def evict_oldest!
85
+ oldest_key = @data.min_by { |_k, v| v.created_at }&.first
86
+ delete(oldest_key) if oldest_key
87
+ end
76
88
  end
77
89
  end
78
90
  end
@@ -8,14 +8,21 @@ module SemanticCache
8
8
  # Suitable for production, multi-process, and distributed apps.
9
9
  #
10
10
  # Requires the `redis` gem: gem install redis
11
+ #
12
+ # Options:
13
+ # max_size: Maximum number of entries to keep. When exceeded, the oldest
14
+ # entry (by created_at) is evicted. nil = unlimited.
11
15
  class Redis
12
- def initialize(redis: nil, namespace: nil, **options)
16
+ def initialize(redis: nil, namespace: nil, max_size: nil, **options)
13
17
  @namespace = namespace || SemanticCache.configuration.namespace
14
18
  @redis = redis || connect(options)
19
+ @max_size = max_size
15
20
  end
16
21
 
17
- # Store a cache entry.
22
+ # Store a cache entry. Evicts the oldest entry if max_size is reached.
18
23
  def write(key, entry)
24
+ evict_oldest! if @max_size && size >= @max_size
25
+
19
26
  full_key = namespaced_key(key)
20
27
  data = entry.to_json
21
28
 
@@ -122,6 +129,21 @@ module SemanticCache
122
129
  @redis.del(full_key)
123
130
  @redis.srem(keys_set_key, full_key)
124
131
  end
132
+
133
+ # Evict the oldest entry (by created_at) to make room for a new one.
134
+ def evict_oldest!
135
+ all_entries = entries
136
+ return if all_entries.empty?
137
+
138
+ oldest = all_entries.min_by(&:created_at)
139
+ key = @redis.smembers(keys_set_key).find do |k|
140
+ data = @redis.get(k)
141
+ next false unless data
142
+
143
+ Entry.from_json(data).query == oldest.query
144
+ end
145
+ delete_raw(key) if key
146
+ end
125
147
  end
126
148
  end
127
149
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SemanticCache
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: semantic-cache
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - stokry