philiprehberger-expiring_map 0.2.0 → 0.4.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: aed5fe4178a5814d949531b8352284b152a8ac520963541944584e602ebe0c17
4
- data.tar.gz: 319d4b05843b15e90cb91c4487d6edc670dc3ffda54d26c9a77e09c035b4a843
3
+ metadata.gz: c9113519082c9f4bc5b232176d347f5bf07ffd282a63f27be15a68443a6aee90
4
+ data.tar.gz: 7b90b98bab68612c39f4723c4d7673a1149155c50531972754eb7ae0daea841c
5
5
  SHA512:
6
- metadata.gz: a3a545614963d2b2a4db845a909ad9f2bafb0fea91b543ec9456eebf0a7ddf3a7af2029c8080f357aea6bf0294a9744b672015fbbcfc9b74942b3f524b3ab8d6
7
- data.tar.gz: a7052a46245d890e339ed5e40b3bd78e21a1d29fb45441c86629ed0afaaf2fede1c69e7d77e9d401e1670d66eef55282bab0b63f4d3e7523e37473408a142ffb
6
+ metadata.gz: f55f4d5d64f2b81cd57519784e8fdce758360f72c9cd85c6789f29bea0950de80eaf3a649b1d0c070c890179db3a126893e9933aa39f9e41817fcccc765c3e34
7
+ data.tar.gz: 40c275b4ef7abd9dfc531bcf6ef19e44d812e25b1eb5158c4d4b2cdea07f6e0952dff5e6543a6fad9cb67ea4d2a6389dfbda2c6c2ce4dee867173d86f374fb44
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-04-27
11
+
12
+ ### Added
13
+ - `Map#purge_expired!` — actively sweep expired entries and return the count removed; fires `on_expire` callbacks. Useful for explicit cleanup in long-running processes that read sparingly.
14
+ - `Map#expired?(key)` — predicate for whether an entry is present-but-expired without deleting it or firing `on_expire`. Distinct from `get(key).nil?`, which can't tell "missing" from "expired".
15
+
16
+ ## [0.3.0] - 2026-04-17
17
+
18
+ ### Added
19
+ - `Map#fetch(key, ttl: nil) { block }` atomically memoizes the block result on miss
20
+
10
21
  ## [0.2.0] - 2026-04-03
11
22
 
12
23
  ### Added
data/README.md CHANGED
@@ -42,6 +42,19 @@ cache.set(:config, data, ttl: 3600) # expires in 1 hour
42
42
  cache.ttl(:token) # => remaining seconds
43
43
  ```
44
44
 
45
+ ### Fetch-or-compute
46
+
47
+ ```ruby
48
+ cache = Philiprehberger::ExpiringMap.new(default_ttl: 300)
49
+
50
+ # On miss, the block is evaluated, the result stored, and returned.
51
+ # On hit, the stored value is returned and the block is not called.
52
+ user = cache.fetch(:user_42) { User.find(42) }
53
+
54
+ # Override the TTL for this insert only:
55
+ token = cache.fetch(:token, ttl: 60) { Auth.issue_token }
56
+ ```
57
+
45
58
  ### Max Size with Eviction
46
59
 
47
60
  ```ruby
@@ -108,6 +121,33 @@ cache.each { |key, value| puts "#{key}: #{value}" }
108
121
  cache.select { |_k, v| v > 10 }
109
122
  ```
110
123
 
124
+ ### Manual Sweep
125
+
126
+ Most read methods (`get`, `size`, `each`, `keys`, `values`) sweep expired
127
+ entries lazily on access. Long-running processes that read sparingly can
128
+ accumulate stale entries between reads — call `purge_expired!` to reclaim
129
+ memory and fire `on_expire` callbacks for everything that has expired:
130
+
131
+ ```ruby
132
+ cache.set(:a, 1, ttl: 0.01)
133
+ cache.set(:b, 2, ttl: 10)
134
+ sleep 0.02
135
+
136
+ cache.purge_expired! # => 1 (number of entries removed)
137
+ cache.keys # => [:b]
138
+ ```
139
+
140
+ `expired?(key)` returns whether a present key has elapsed its TTL — without
141
+ deleting the entry or firing `on_expire`. Distinct from `get(key).nil?`,
142
+ which can't tell "missing" from "expired":
143
+
144
+ ```ruby
145
+ cache.set(:dead, 'gone', ttl: 0.01)
146
+ sleep 0.02
147
+ cache.expired?(:dead) # => true
148
+ cache.expired?(:nope) # => false (missing, not expired)
149
+ ```
150
+
111
151
  ## API
112
152
 
113
153
  | Method | Description |
@@ -115,6 +155,7 @@ cache.select { |_k, v| v > 10 }
115
155
  | `.new(default_ttl:, max_size:)` | Create a new expiring map |
116
156
  | `#set(key, value, ttl:)` | Store a value with optional per-key TTL |
117
157
  | `#get(key)` | Retrieve a value, nil if expired or missing |
158
+ | `#fetch(key, ttl:) { block }` | Return stored value or atomically memoize block result on miss |
118
159
  | `#set_many(hash, ttl:)` | Bulk insert from a hash |
119
160
  | `#get_many(*keys)` | Bulk get returning hash of key => value |
120
161
  | `#delete(key)` | Remove and return a value |
@@ -128,6 +169,8 @@ cache.select { |_k, v| v > 10 }
128
169
  | `#on_expire { \|k, v\| }` | Register expiration callback |
129
170
  | `#clear` | Remove all entries |
130
171
  | `#each { \|k, v\| }` | Iterate over non-expired entries |
172
+ | `#purge_expired!` | Actively sweep expired entries; fires `on_expire`; returns the count removed |
173
+ | `#expired?(key)` | Whether the entry at `key` is present-but-expired (does not delete or fire `on_expire`) |
131
174
 
132
175
  ## Development
133
176
 
@@ -66,6 +66,50 @@ module Philiprehberger
66
66
  end
67
67
  end
68
68
 
69
+ # Retrieve a value by key, or atomically compute and store it on miss
70
+ #
71
+ # If the key is present and not expired, returns the stored value.
72
+ # Otherwise evaluates the block, stores the result under +key+ with the
73
+ # default TTL (or +ttl:+ override), and returns it. The get/compute/set
74
+ # path runs under the same mutex used by +#get+ and +#set+ so there is
75
+ # no race with concurrent writers.
76
+ #
77
+ # @param key [Object] the key
78
+ # @param ttl [Numeric, nil] TTL in seconds for the inserted value on miss, uses default if nil
79
+ # @yieldreturn [Object] the value to memoize under +key+ on miss
80
+ # @raise [KeyError] if the key is missing/expired and no block is given
81
+ # @return [Object] the stored or freshly computed value
82
+ def fetch(key, ttl: nil)
83
+ @mutex.synchronize do
84
+ entry = @store[key]
85
+
86
+ if entry && !entry.expired?
87
+ @hits += 1
88
+ return entry.value
89
+ end
90
+
91
+ if entry&.expired?
92
+ @expirations += 1
93
+ fire_expire(key, entry.value)
94
+ @store.delete(key)
95
+ end
96
+
97
+ @misses += 1
98
+
99
+ raise KeyError, "key not found: #{key.inspect}" unless block_given?
100
+
101
+ value = yield
102
+ effective_ttl = ttl || @default_ttl
103
+ expires_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + effective_ttl
104
+
105
+ sweep_expired
106
+ evict_oldest if @max_size && @store.size >= @max_size && !@store.key?(key)
107
+ @store[key] = Entry.new(value, expires_at)
108
+
109
+ value
110
+ end
111
+ end
112
+
69
113
  # Delete a key
70
114
  #
71
115
  # @param key [Object] the key
@@ -224,6 +268,39 @@ module Philiprehberger
224
268
  end
225
269
  end
226
270
 
271
+ # Actively sweep expired entries and return how many were removed.
272
+ #
273
+ # Most read methods (`get`, `size`, `each`, `keys`, `values`) sweep lazily,
274
+ # so long-running processes that read sparingly may accumulate stale
275
+ # entries before the next read triggers cleanup. Call this to reclaim
276
+ # memory and fire `on_expire` callbacks for everything that has expired.
277
+ #
278
+ # @return [Integer] number of entries removed
279
+ def purge_expired!
280
+ @mutex.synchronize do
281
+ before = @store.size
282
+ sweep_expired
283
+ before - @store.size
284
+ end
285
+ end
286
+
287
+ # Whether the entry at +key+ has expired.
288
+ #
289
+ # Returns +false+ when the key is missing or still valid; returns +true+
290
+ # when the entry exists but its TTL has elapsed. Unlike `get`, this does
291
+ # not delete the entry or fire `on_expire`.
292
+ #
293
+ # @param key [Object] the key
294
+ # @return [Boolean]
295
+ def expired?(key)
296
+ @mutex.synchronize do
297
+ entry = @store[key]
298
+ return false unless entry
299
+
300
+ entry.expired?
301
+ end
302
+ end
303
+
227
304
  private
228
305
 
229
306
  # Remove all expired entries, firing callbacks
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module ExpiringMap
5
- VERSION = '0.2.0'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-expiring_map
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-03 00:00:00.000000000 Z
11
+ date: 2026-04-28 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A thread-safe hash map where each key has its own TTL, with automatic
14
14
  expiration, max size eviction, expiration callbacks, and Enumerable support.