Class: Philiprehberger::ExpiringMap::Map

Inherits:
Object
  • Object
show all
Includes:
Enumerable
Defined in:
lib/philiprehberger/expiring_map/map.rb

Overview

Thread-safe hash with per-key TTL and automatic expiration

Instance Method Summary collapse

Constructor Details

#initialize(default_ttl: 60, max_size: nil) ⇒ Map

Returns a new instance of Map.

Parameters:

  • default_ttl (Numeric) (defaults to: 60)

    default TTL in seconds for new entries

  • max_size (Integer, nil) (defaults to: nil)

    maximum number of entries, nil for unlimited



13
14
15
16
17
18
19
20
21
22
23
# File 'lib/philiprehberger/expiring_map/map.rb', line 13

def initialize(default_ttl: 60, max_size: nil)
  @default_ttl = default_ttl
  @max_size = max_size
  @store = {}
  @mutex = Mutex.new
  @on_expire_callback = nil
  @hits = 0
  @misses = 0
  @expirations = 0
  @evictions = 0
end

Instance Method Details

#clearvoid

This method returns an undefined value.

Remove all entries



175
176
177
178
179
# File 'lib/philiprehberger/expiring_map/map.rb', line 175

def clear
  @mutex.synchronize do
    @store.clear
  end
end

#delete(key) ⇒ Object?

Delete a key

Parameters:

  • key (Object)

    the key

Returns:

  • (Object, nil)

    the deleted value or nil



117
118
119
120
121
122
# File 'lib/philiprehberger/expiring_map/map.rb', line 117

def delete(key)
  @mutex.synchronize do
    entry = @store.delete(key)
    entry&.value
  end
end

#delete_if {|key, value| ... } ⇒ Integer

Remove entries where the block returns true

Yields:

  • (key, value)

    each non-expired entry

Returns:

  • (Integer)

    count of deleted entries

Raises:

  • (ArgumentError)


233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
# File 'lib/philiprehberger/expiring_map/map.rb', line 233

def delete_if(&block)
  raise ArgumentError, 'block required' unless block

  @mutex.synchronize do
    sweep_expired
    count = 0
    @store.delete_if do |key, entry|
      if block.call(key, entry.value)
        count += 1
        true
      else
        false
      end
    end
    count
  end
end

#each {|key, value| ... } ⇒ Enumerator

Iterate over non-expired entries

Yields:

  • (key, value)

    each non-expired entry

Returns:

  • (Enumerator)

    if no block given



185
186
187
188
189
190
191
192
193
194
# File 'lib/philiprehberger/expiring_map/map.rb', line 185

def each(&block)
  pairs = @mutex.synchronize do
    sweep_expired
    @store.map { |k, e| [k, e.value] }
  end

  return pairs.each unless block

  pairs.each(&block)
end

#fetch(key, ttl: nil) ⇒ Object

Retrieve a value by key, or atomically compute and store it on miss

If the key is present and not expired, returns the stored value. Otherwise evaluates the block, stores the result under key with the default TTL (or ttl: override), and returns it. The get/compute/set path runs under the same mutex used by #get and #set so there is no race with concurrent writers.

Parameters:

  • key (Object)

    the key

  • ttl (Numeric, nil) (defaults to: nil)

    TTL in seconds for the inserted value on miss, uses default if nil

Yield Returns:

  • (Object)

    the value to memoize under key on miss

Returns:

  • (Object)

    the stored or freshly computed value

Raises:

  • (KeyError)

    if the key is missing/expired and no block is given



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/philiprehberger/expiring_map/map.rb', line 82

def fetch(key, ttl: nil)
  @mutex.synchronize do
    entry = @store[key]

    if entry && !entry.expired?
      @hits += 1
      return entry.value
    end

    if entry&.expired?
      @expirations += 1
      fire_expire(key, entry.value)
      @store.delete(key)
    end

    @misses += 1

    raise KeyError, "key not found: #{key.inspect}" unless block_given?

    value = yield
    effective_ttl = ttl || @default_ttl
    expires_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + effective_ttl

    sweep_expired
    evict_oldest if @max_size && @store.size >= @max_size && !@store.key?(key)
    @store[key] = Entry.new(value, expires_at)

    value
  end
end

#get(key) ⇒ Object?

Retrieve a value by key

Parameters:

  • key (Object)

    the key

Returns:

  • (Object, nil)

    the value or nil if expired/missing



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/philiprehberger/expiring_map/map.rb', line 48

def get(key)
  @mutex.synchronize do
    entry = @store[key]
    unless entry
      @misses += 1
      return nil
    end

    if entry.expired?
      @expirations += 1
      @misses += 1
      fire_expire(key, entry.value)
      @store.delete(key)
      return nil
    end

    @hits += 1
    entry.value
  end
end

#get_many(*keys) ⇒ Hash

Bulk retrieve values by keys

Parameters:

  • keys (Array<Object>)

    keys to retrieve

Returns:

  • (Hash)

    key => value (nil for misses)



225
226
227
# File 'lib/philiprehberger/expiring_map/map.rb', line 225

def get_many(*keys)
  keys.flatten.to_h { |k| [k, get(k)] }
end

#keysArray<Object>

Return all non-expired keys

Returns:

  • (Array<Object>)

    array of keys



254
255
256
257
258
259
# File 'lib/philiprehberger/expiring_map/map.rb', line 254

def keys
  @mutex.synchronize do
    sweep_expired
    @store.keys
  end
end

#on_expire {|key, value| ... } ⇒ Object

Register a callback for expired entries

Yields:

  • (key, value)

    called when an entry expires



166
167
168
169
170
# File 'lib/philiprehberger/expiring_map/map.rb', line 166

def on_expire(&block)
  @mutex.synchronize do
    @on_expire_callback = block
  end
end

#set(key, value, ttl: nil) ⇒ Object

Store a value with an optional per-key TTL

Parameters:

  • key (Object)

    the key

  • value (Object)

    the value

  • ttl (Numeric, nil) (defaults to: nil)

    TTL in seconds, uses default if nil

Returns:

  • (Object)

    the stored value



31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/philiprehberger/expiring_map/map.rb', line 31

def set(key, value, ttl: nil)
  ttl ||= @default_ttl
  expires_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl

  @mutex.synchronize do
    sweep_expired
    evict_oldest if @max_size && @store.size >= @max_size && !@store.key?(key)
    @store[key] = Entry.new(value, expires_at)
  end

  value
end

#set_many(hash, ttl: nil) ⇒ void

This method returns an undefined value.

Bulk insert from a hash

Parameters:

  • hash (Hash)

    key-value pairs to insert

  • ttl (Numeric, nil) (defaults to: nil)

    TTL for all entries, uses default if nil



217
218
219
# File 'lib/philiprehberger/expiring_map/map.rb', line 217

def set_many(hash, ttl: nil)
  hash.each { |k, v| set(k, v, ttl: ttl) }
end

#sizeInteger

Return the number of non-expired entries

Returns:

  • (Integer)

    the count



156
157
158
159
160
161
# File 'lib/philiprehberger/expiring_map/map.rb', line 156

def size
  @mutex.synchronize do
    sweep_expired
    @store.size
  end
end

#statsHash

Return statistics about the map

Returns:

  • (Hash)

    stats with hits, misses, expirations, evictions, size



199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/philiprehberger/expiring_map/map.rb', line 199

def stats
  @mutex.synchronize do
    sweep_expired
    {
      hits: @hits,
      misses: @misses,
      expirations: @expirations,
      evictions: @evictions,
      size: @store.size
    }
  end
end

#touch(key) ⇒ Boolean

Reset the TTL for a key to the default

Parameters:

  • key (Object)

    the key

Returns:

  • (Boolean)

    true if the key exists and was touched



142
143
144
145
146
147
148
149
150
151
# File 'lib/philiprehberger/expiring_map/map.rb', line 142

def touch(key)
  @mutex.synchronize do
    entry = @store[key]
    return false unless entry
    return false if entry.expired?

    entry.expires_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @default_ttl
    true
  end
end

#ttl(key) ⇒ Float?

Return remaining TTL for a key

Parameters:

  • key (Object)

    the key

Returns:

  • (Float, nil)

    remaining TTL in seconds or nil if missing



128
129
130
131
132
133
134
135
136
# File 'lib/philiprehberger/expiring_map/map.rb', line 128

def ttl(key)
  @mutex.synchronize do
    entry = @store[key]
    return nil unless entry
    return nil if entry.expired?

    entry.ttl
  end
end

#valuesArray<Object>

Return all non-expired values

Returns:

  • (Array<Object>)

    array of values



264
265
266
267
268
269
# File 'lib/philiprehberger/expiring_map/map.rb', line 264

def values
  @mutex.synchronize do
    sweep_expired
    @store.values.map(&:value)
  end
end