Class: Mudis
- Inherits:
-
Object
- Object
- Mudis
- Defined in:
- lib/mudis.rb
Overview
Mudis is a thread-safe, in-memory, sharded, LRU cache with optional compression and expiry. It is designed for high concurrency and performance within a Ruby application.
Defined Under Namespace
Classes: LRUNode
Class Attribute Summary collapse
-
.compress ⇒ Object
Returns the value of attribute compress.
-
.default_ttl ⇒ Object
Returns the value of attribute default_ttl.
-
.hard_memory_limit ⇒ Object
Returns the value of attribute hard_memory_limit.
-
.max_bytes ⇒ Object
Returns the value of attribute max_bytes.
-
.max_ttl ⇒ Object
Returns the value of attribute max_ttl.
-
.max_value_bytes ⇒ Object
Returns the value of attribute max_value_bytes.
-
.serializer ⇒ Object
Returns the value of attribute serializer.
Class Method Summary collapse
-
.all_keys ⇒ Object
Returns an array of all cache keys.
-
.apply_config! ⇒ Object
Applies the current configuration to Mudis.
-
.bucket_index(key) ⇒ Object
Computes which bucket a key belongs to.
-
.buckets ⇒ Object
Number of cache buckets (shards).
-
.cleanup_expired! ⇒ Object
Removes expired keys across all buckets.
-
.clear(key, namespace: nil) ⇒ Object
Clears a specific key from the cache, a semantic synonym for delete This method is provided for clarity in usage It behaves the same as delete.
-
.clear_namespace(namespace:) ⇒ Object
Clears all keys in a specific namespace.
-
.config ⇒ Object
Returns the current configuration object.
-
.configure {|config| ... } ⇒ Object
Configures Mudis with a block, allowing customization of settings.
-
.current_memory_bytes ⇒ Object
Returns total memory used across all buckets.
-
.delete(*a, **k) ⇒ Object
rubocop:disable Naming/MethodParameterName,Style/GlobalVars.
-
.exists?(key, namespace: nil) ⇒ Boolean
Checks if a key exists and is not expired.
-
.fetch(*a, **k, &b) ⇒ Object
rubocop:disable Naming/MethodParameterName,Style/GlobalVars.
-
.inspect(key, namespace: nil) ⇒ Object
Inspects a key and returns all meta data for it.
-
.keys(namespace:) ⇒ Object
Returns all keys in a specific namespace.
-
.least_touched(n = 10) ⇒ Object
Returns the least-touched keys across all buckets.
-
.max_memory_bytes ⇒ Object
Returns configured maximum memory allowed.
-
.metrics ⇒ Object
rubocop:disable Style/GlobalVars.
-
.read(*a, **k) ⇒ Object
rubocop:disable Naming/MethodParameterName,Style/GlobalVars.
-
.replace(key, value, expires_in: nil, namespace: nil) ⇒ Object
Replaces the value for a key if it exists, otherwise does nothing This is useful for updating values without needing to check existence first It will write the new value and update the expiration if provided If the key does not exist, it will not create a new entry.
-
.reset! ⇒ Object
rubocop:disable Style/GlobalVars.
-
.reset_metrics! ⇒ Object
rubocop:disable Style/GlobalVars.
-
.start_expiry_thread(interval: 60) ⇒ Object
Starts a thread that periodically removes expired entries.
-
.stop_expiry_thread ⇒ Object
Signals and joins the expiry thread.
-
.update(key, namespace: nil) ⇒ Object
Atomically updates the value for a key using a block.
-
.validate_config! ⇒ Object
Validates the current configuration, raising errors for invalid settings.
-
.with_namespace(namespace) ⇒ Object
Executes a block with a specific namespace, restoring the old namespace afterwards.
-
.write(*a, **k) ⇒ Object
rubocop:disable Naming/MethodParameterName,Style/GlobalVars.
Class Attribute Details
.compress ⇒ Object
Returns the value of attribute compress.
24 25 26 |
# File 'lib/mudis.rb', line 24 def compress @compress end |
.default_ttl ⇒ Object
Returns the value of attribute default_ttl.
24 25 26 |
# File 'lib/mudis.rb', line 24 def default_ttl @default_ttl end |
.hard_memory_limit ⇒ Object
Returns the value of attribute hard_memory_limit.
24 25 26 |
# File 'lib/mudis.rb', line 24 def hard_memory_limit @hard_memory_limit end |
.max_bytes ⇒ Object
Returns the value of attribute max_bytes.
25 26 27 |
# File 'lib/mudis.rb', line 25 def max_bytes @max_bytes end |
.max_ttl ⇒ Object
Returns the value of attribute max_ttl.
24 25 26 |
# File 'lib/mudis.rb', line 24 def max_ttl @max_ttl end |
.max_value_bytes ⇒ Object
Returns the value of attribute max_value_bytes.
25 26 27 |
# File 'lib/mudis.rb', line 25 def max_value_bytes @max_value_bytes end |
.serializer ⇒ Object
Returns the value of attribute serializer.
24 25 26 |
# File 'lib/mudis.rb', line 24 def serializer @serializer end |
Class Method Details
.all_keys ⇒ Object
Returns an array of all cache keys
376 377 378 379 380 381 382 383 384 |
# File 'lib/mudis.rb', line 376 def all_keys keys = [] buckets.times do |idx| mutex = @mutexes[idx] store = @stores[idx] mutex.synchronize { keys.concat(store.keys) } end keys end |
.apply_config! ⇒ Object
Applies the current configuration to Mudis
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
# File 'lib/mudis.rb', line 39 def apply_config! # rubocop:disable Metrics/AbcSize,Metrics/MethodLength validate_config! self.serializer = config.serializer self.compress = config.compress self.max_value_bytes = config.max_value_bytes self.hard_memory_limit = config.hard_memory_limit self.max_bytes = config.max_bytes self.max_ttl = config.max_ttl self.default_ttl = config.default_ttl if config.buckets # rubocop:disable Style/GuardClause @buckets = config.buckets reset! end end |
.bucket_index(key) ⇒ Object
Computes which bucket a key belongs to
190 191 192 |
# File 'lib/mudis.rb', line 190 def bucket_index(key) key.hash % buckets end |
.buckets ⇒ Object
Number of cache buckets (shards). Default: 32
144 145 146 147 148 149 150 151 |
# File 'lib/mudis.rb', line 144 def self.buckets return @buckets if @buckets val = config.buckets || ENV["MUDIS_BUCKETS"]&.to_i || 32 raise ArgumentError, "bucket count must be > 0" if val <= 0 @buckets = val end |
.cleanup_expired! ⇒ Object
Removes expired keys across all buckets
362 363 364 365 366 367 368 369 370 371 372 373 |
# File 'lib/mudis.rb', line 362 def cleanup_expired! now = Time.now buckets.times do |idx| mutex = @mutexes[idx] store = @stores[idx] mutex.synchronize do store.keys.each do |key| # rubocop:disable Style/HashEachMethods evict_key(idx, key) if store[key][:expires_at] && now > store[key][:expires_at] end end end end |
.clear(key, namespace: nil) ⇒ Object
Clears a specific key from the cache, a semantic synonym for delete This method is provided for clarity in usage It behaves the same as delete
325 326 327 |
# File 'lib/mudis.rb', line 325 def clear(key, namespace: nil) delete(key, namespace: namespace) end |
.clear_namespace(namespace:) ⇒ Object
Clears all keys in a specific namespace
395 396 397 398 399 400 401 402 403 404 405 406 407 408 |
# File 'lib/mudis.rb', line 395 def clear_namespace(namespace:) raise ArgumentError, "namespace is required" unless namespace prefix = "#{namespace}:" buckets.times do |idx| mutex = @mutexes[idx] store = @stores[idx] mutex.synchronize do keys_to_delete = store.keys.select { |key| key.start_with?(prefix) } keys_to_delete.each { |key| evict_key(idx, key) } end end end |
.config ⇒ Object
Returns the current configuration object
34 35 36 |
# File 'lib/mudis.rb', line 34 def config @config ||= MudisConfig.new end |
.configure {|config| ... } ⇒ Object
Configures Mudis with a block, allowing customization of settings
28 29 30 31 |
# File 'lib/mudis.rb', line 28 def configure yield(config) apply_config! end |
.current_memory_bytes ⇒ Object
Returns total memory used across all buckets
429 430 431 |
# File 'lib/mudis.rb', line 429 def current_memory_bytes @current_bytes.sum end |
.delete(*a, **k) ⇒ Object
rubocop:disable Naming/MethodParameterName,Style/GlobalVars
296 297 298 299 300 301 302 303 304 |
# File 'lib/mudis.rb', line 296 def delete(key, namespace: nil) key = namespaced_key(key, namespace) idx = bucket_index(key) mutex = @mutexes[idx] mutex.synchronize do evict_key(idx, key) end end |
.exists?(key, namespace: nil) ⇒ Boolean
Checks if a key exists and is not expired
195 196 197 198 |
# File 'lib/mudis.rb', line 195 def exists?(key, namespace: nil) key = namespaced_key(key, namespace) !!read(key) end |
.fetch(*a, **k, &b) ⇒ Object
rubocop:disable Naming/MethodParameterName,Style/GlobalVars
310 311 312 313 314 315 316 317 318 319 320 |
# File 'lib/mudis.rb', line 310 def fetch(key, expires_in: nil, force: false, namespace: nil) key = namespaced_key(key, namespace) unless force cached = read(key) return cached if cached end value = yield write(key, value, expires_in: expires_in) value end |
.inspect(key, namespace: nil) ⇒ Object
Inspects a key and returns all meta data for it
340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 |
# File 'lib/mudis.rb', line 340 def inspect(key, namespace: nil) # rubocop:disable Metrics/MethodLength key = namespaced_key(key, namespace) idx = bucket_index(key) store = @stores[idx] mutex = @mutexes[idx] mutex.synchronize do entry = store[key] return nil unless entry { key: key, bucket: idx, expires_at: entry[:expires_at], created_at: entry[:created_at], size_bytes: key.bytesize + entry[:value].bytesize, compressed: compress } end end |
.keys(namespace:) ⇒ Object
Returns all keys in a specific namespace
387 388 389 390 391 392 |
# File 'lib/mudis.rb', line 387 def keys(namespace:) raise ArgumentError, "namespace is required" unless namespace prefix = "#{namespace}:" all_keys.select { |key| key.start_with?(prefix) }.map { |key| key.delete_prefix(prefix) } end |
.least_touched(n = 10) ⇒ Object
Returns the least-touched keys across all buckets
411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 |
# File 'lib/mudis.rb', line 411 def least_touched(n = 10) # rubocop:disable Metrics/MethodLength,Naming/MethodParameterName keys_with_touches = [] buckets.times do |idx| mutex = @mutexes[idx] store = @stores[idx] mutex.synchronize do store.each do |key, entry| keys_with_touches << [key, entry[:touches] || 0] end end end keys_with_touches.sort_by { |_, count| count }.first(n) end |
.max_memory_bytes ⇒ Object
Returns configured maximum memory allowed
434 435 436 |
# File 'lib/mudis.rb', line 434 def max_memory_bytes @max_bytes end |
.metrics ⇒ Object
rubocop:disable Style/GlobalVars
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/mudis.rb', line 71 def metrics # rubocop:disable Metrics/MethodLength @metrics_mutex.synchronize do { hits: @metrics[:hits], misses: @metrics[:misses], evictions: @metrics[:evictions], rejected: @metrics[:rejected], total_memory: current_memory_bytes, least_touched: least_touched(10), buckets: buckets.times.map do |idx| { index: idx, keys: @stores[idx].size, memory_bytes: @current_bytes[idx], lru_size: @lru_nodes[idx].size } end } end end |
.read(*a, **k) ⇒ Object
rubocop:disable Naming/MethodParameterName,Style/GlobalVars
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 |
# File 'lib/mudis.rb', line 201 def read(key, namespace: nil) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity key = namespaced_key(key, namespace) raw_entry = nil idx = bucket_index(key) mutex = @mutexes[idx] store = @stores[idx] mutex.synchronize do raw_entry = @stores[idx][key] if raw_entry && raw_entry[:expires_at] && Time.now > raw_entry[:expires_at] evict_key(idx, key) raw_entry = nil end store[key][:touches] = (store[key][:touches] || 0) + 1 if store[key] metric(:hits) if raw_entry metric(:misses) unless raw_entry end return nil unless raw_entry value = decompress_and_deserialize(raw_entry[:value]) promote_lru(idx, key) value end |
.replace(key, value, expires_in: nil, namespace: nil) ⇒ Object
Replaces the value for a key if it exists, otherwise does nothing This is useful for updating values without needing to check existence first It will write the new value and update the expiration if provided If the key does not exist, it will not create a new entry
333 334 335 336 337 |
# File 'lib/mudis.rb', line 333 def replace(key, value, expires_in: nil, namespace: nil) return unless exists?(key, namespace: namespace) write(key, value, expires_in: expires_in, namespace: namespace) end |
.reset! ⇒ Object
rubocop:disable Style/GlobalVars
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 |
# File 'lib/mudis.rb', line 100 def reset! stop_expiry_thread @buckets = nil b = buckets @stores = Array.new(b) { {} } @mutexes = Array.new(b) { Mutex.new } @lru_heads = Array.new(b) { nil } @lru_tails = Array.new(b) { nil } @lru_nodes = Array.new(b) { {} } @current_bytes = Array.new(b, 0) reset_metrics! end |
.reset_metrics! ⇒ Object
rubocop:disable Style/GlobalVars
93 94 95 96 97 |
# File 'lib/mudis.rb', line 93 def reset_metrics! @metrics_mutex.synchronize do @metrics = { hits: 0, misses: 0, evictions: 0, rejected: 0 } end end |
.start_expiry_thread(interval: 60) ⇒ Object
Starts a thread that periodically removes expired entries
168 169 170 171 172 173 174 175 176 177 178 179 180 |
# File 'lib/mudis.rb', line 168 def start_expiry_thread(interval: 60) return if @expiry_thread&.alive? @stop_expiry = false @expiry_thread = Thread.new do loop do break if @stop_expiry sleep interval cleanup_expired! end end end |
.stop_expiry_thread ⇒ Object
Signals and joins the expiry thread
183 184 185 186 187 |
# File 'lib/mudis.rb', line 183 def stop_expiry_thread @stop_expiry = true @expiry_thread&.join @expiry_thread = nil end |
.update(key, namespace: nil) ⇒ Object
Atomically updates the value for a key using a block
269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 |
# File 'lib/mudis.rb', line 269 def update(key, namespace: nil) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength key = namespaced_key(key, namespace) idx = bucket_index(key) mutex = @mutexes[idx] store = @stores[idx] raw_entry = nil mutex.synchronize do raw_entry = store[key] return nil unless raw_entry end value = decompress_and_deserialize(raw_entry[:value]) new_value = yield(value) new_raw = serializer.dump(new_value) new_raw = Zlib::Deflate.deflate(new_raw) if compress mutex.synchronize do old_size = key.bytesize + raw_entry[:value].bytesize new_size = key.bytesize + new_raw.bytesize store[key][:value] = new_raw @current_bytes[idx] += (new_size - old_size) promote_lru(idx, key) end end |
.validate_config! ⇒ Object
Validates the current configuration, raising errors for invalid settings
57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/mudis.rb', line 57 def validate_config! # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity if config.max_value_bytes && config.max_value_bytes > config.max_bytes raise ArgumentError, "max_value_bytes cannot exceed max_bytes" end raise ArgumentError, "max_value_bytes must be > 0" if config.max_value_bytes && config.max_value_bytes <= 0 raise ArgumentError, "buckets must be > 0" if config.buckets && config.buckets <= 0 raise ArgumentError, "max_ttl must be > 0" if config.max_ttl && config.max_ttl <= 0 raise ArgumentError, "default_ttl must be > 0" if config.default_ttl && config.default_ttl <= 0 end |
.with_namespace(namespace) ⇒ Object
Executes a block with a specific namespace, restoring the old namespace afterwards
439 440 441 442 443 444 445 |
# File 'lib/mudis.rb', line 439 def with_namespace(namespace) old_ns = Thread.current[:mudis_namespace] Thread.current[:mudis_namespace] = namespace yield ensure Thread.current[:mudis_namespace] = old_ns end |
.write(*a, **k) ⇒ Object
rubocop:disable Naming/MethodParameterName,Style/GlobalVars
229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 |
# File 'lib/mudis.rb', line 229 def write(key, value, expires_in: nil, namespace: nil) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/PerceivedComplexity key = namespaced_key(key, namespace) raw = serializer.dump(value) raw = Zlib::Deflate.deflate(raw) if compress size = key.bytesize + raw.bytesize return if max_value_bytes && raw.bytesize > max_value_bytes if hard_memory_limit && current_memory_bytes + size > max_memory_bytes metric(:rejected) return end # Ensure expires_in respects max_ttl and default_ttl expires_in = effective_ttl(expires_in) idx = bucket_index(key) mutex = @mutexes[idx] store = @stores[idx] mutex.synchronize do evict_key(idx, key) if store[key] while @current_bytes[idx] + size > (@threshold_bytes / buckets) && @lru_tails[idx] evict_key(idx, @lru_tails[idx].key) metric(:evictions) end store[key] = { value: raw, expires_at: expires_in ? Time.now + expires_in : nil, created_at: Time.now, touches: 0 } insert_lru(idx, key) @current_bytes[idx] += size end end |