mudis 0.8.1 → 0.9.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: c5cae2113816ed18a4d28661643657f0ddb64ec111a953df361c6e759c4d5869
4
- data.tar.gz: bcd55c7e26ba1b4c2446f9236a764381f5a72de0f45f4b6088306798847c0da1
3
+ metadata.gz: 283e3b629449e6324d30dd19450d4d363a5d415ad45ace27569d4d82fb7e8066
4
+ data.tar.gz: 5fe63b61e7526eb90c66c9a5f9da721c5156ec40a144398cedb89e11adc7b35e
5
5
  SHA512:
6
- metadata.gz: 1186f39ba21ea5facd7c589cf536f576584fac1dff2cad4fdb5567264b236f40100413d0e32edfeaf13d2f05ac9d10061f72c83bd4c12304abb998246caf1ed7
7
- data.tar.gz: 742ab271a73b033dc746ff3426bcfccea808c996a0fff7d6d6ffd0182375e2c7d904c44c1899d9bb39dc4c5d092f92744c31adbac88b8f9dc57e286cab27dcfb
6
+ metadata.gz: cfc3db7ea2a80a03af39738a6700bbd07b525dbd101bf7b3b8f11b1fa711fdc14a9ad248dcdeff5b469e55bca24efd15d08ac5bf3ff91c83b6614c5ddc925521
7
+ data.tar.gz: 4923b6e5ccb935914e0c5811be65fbe358304db5f6e8d76097b08f25db939e441b0b72557695981ffdae7e300d36dd4a66c3d2b3d2fd163c1e3735dbddbd61e6
data/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  [![RubyMine](https://www.elegantobjects.org/rubymine.svg)](https://www.jetbrains.com/ruby/)
4
4
 
5
5
  [![Gem Version](https://badge.fury.io/rb/mudis.svg?icon=si%3Arubygems&refresh=1&cachebust=0)](https://badge.fury.io/rb/mudis)
6
+ [![Documentation](https://img.shields.io/badge/docs-rubydoc.info-blue.svg)](https://www.rubydoc.info/gems/mudis)
6
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
8
 
8
9
  **Mudis** is a fast, thread-safe, in-memory, sharded LRU (Least Recently Used) cache for Ruby applications. Inspired by Redis, it provides value serialization, optional compression, per-key expiry, and metric tracking in a lightweight, dependency-free package that lives inside your Ruby process.
@@ -26,7 +27,7 @@ Mudis also works naturally in Hanami because it’s a pure Ruby in-memory cache.
26
27
  - [Cache Key Lifecycle](#cache-key-lifecycle)
27
28
  - [Features](#features)
28
29
  - [Installation](#installation)
29
- - [Configuration (Rails)](#configuration-rails)
30
+ - [Configuration (Ruby/Rails)](#configuration-rubyrails)
30
31
  - [Configuration (Hanami)](#configuration-hanami)
31
32
  - [Start and Stop Exipry Thread](#start-and-stop-exipry-thread)
32
33
  - [Starting Exipry Thread](#starting-exipry-thread)
@@ -133,15 +134,13 @@ Or install it manually:
133
134
  ```bash
134
135
  gem install mudis
135
136
  ```
136
-
137
137
  ---
138
138
 
139
- ## Configuration (Rails)
139
+ ## Configuration (Ruby/Rails)
140
140
 
141
- In your Rails app, create an initializer:
141
+ In your Rails app, create an initializer (in your entry point if using Ruby outside of Rails):
142
142
 
143
143
  ```ruby
144
- # config/initializers/mudis.rb
145
144
  Mudis.configure do |c|
146
145
  c.serializer = JSON # or Marshal | Oj
147
146
  c.compress = true # Compress values using Zlib
@@ -276,9 +275,15 @@ require 'mudis'
276
275
  # Write a value with optional TTL
277
276
  Mudis.write('user:123', { name: 'Alice' }, expires_in: 600)
278
277
 
278
+ # Write a value with explicit namespace separation
279
+ Mudis.write('123', { name: 'Alice' }, expires_in: 600, namespace:'user')
280
+
279
281
  # Read it back
280
282
  Mudis.read('user:123') # => { "name" => "Alice" }
281
283
 
284
+ # Read back from namespace
285
+ Mudis.read('123', namespace:'user') # => { "name" => "Alice" }
286
+
282
287
  # Check if it exists
283
288
  Mudis.exists?('user:123') # => true
284
289
 
@@ -929,6 +934,7 @@ Mudis is not intended to be a general-purpose, distributed caching platform. You
929
934
 
930
935
  - [x] Review Mudis for improved readability and reduce complexity in top-level functions
931
936
  - [x] Enhanced guards
937
+ - [ ] Refactor main classes for enhanced readability
932
938
  - [ ] Review for functionality gaps and enhance as needed
933
939
 
934
940
  ---
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Mudis
4
+ # Expiry module handles TTL-based expiration and background cleanup
5
+ module Expiry
6
+ # Starts a thread that periodically removes expired entries
7
+ def start_expiry_thread(interval: 60)
8
+ return if @expiry_thread&.alive?
9
+
10
+ @stop_expiry = false
11
+ @expiry_thread = Thread.new do
12
+ loop do
13
+ break if @stop_expiry
14
+
15
+ sleep interval
16
+ cleanup_expired!
17
+ end
18
+ end
19
+ end
20
+
21
+ # Signals and joins the expiry thread
22
+ def stop_expiry_thread
23
+ @stop_expiry = true
24
+ @expiry_thread&.join
25
+ @expiry_thread = nil
26
+ end
27
+
28
+ # Removes expired keys across all buckets
29
+ def cleanup_expired!
30
+ now = Time.now
31
+ buckets.times do |idx|
32
+ mutex = @mutexes[idx]
33
+ store = @stores[idx]
34
+ mutex.synchronize do
35
+ store.keys.each do |key| # rubocop:disable Style/HashEachMethods
36
+ evict_key(idx, key) if store[key][:expires_at] && now > store[key][:expires_at]
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ # Calculates the effective TTL for an entry, respecting max_ttl if set
45
+ def effective_ttl(expires_in)
46
+ ttl = expires_in || @default_ttl
47
+ return nil unless ttl
48
+ return ttl unless @max_ttl
49
+
50
+ [ttl, @max_ttl].min
51
+ end
52
+ end
53
+ end
data/lib/mudis/lru.rb ADDED
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Mudis
4
+ # Node structure for the LRU doubly-linked list
5
+ class LRUNode
6
+ attr_accessor :key, :prev, :next
7
+
8
+ def initialize(key)
9
+ @key = key
10
+ @prev = nil
11
+ @next = nil
12
+ end
13
+ end
14
+
15
+ # LRU module handles the Least Recently Used eviction strategy
16
+ # Maintains doubly-linked lists per bucket for O(1) promote/evict operations
17
+ module LRU
18
+ private
19
+
20
+ # Removes a key from storage and LRU
21
+ def evict_key(idx, key)
22
+ store = @stores[idx]
23
+ entry = store.delete(key)
24
+ return unless entry
25
+
26
+ @current_bytes[idx] -= (key.bytesize + entry[:value].bytesize)
27
+
28
+ node = @lru_nodes[idx].delete(key)
29
+ remove_node(idx, node) if node
30
+ end
31
+
32
+ # Inserts a key at the head of the LRU list
33
+ def insert_lru(idx, key)
34
+ node = LRUNode.new(key)
35
+ node.next = @lru_heads[idx]
36
+ @lru_heads[idx].prev = node if @lru_heads[idx]
37
+ @lru_heads[idx] = node
38
+ @lru_tails[idx] ||= node
39
+ @lru_nodes[idx][key] = node
40
+ end
41
+
42
+ # Promotes a key to the front of the LRU list
43
+ def promote_lru(idx, key)
44
+ node = @lru_nodes[idx][key]
45
+ return unless node && @lru_heads[idx] != node
46
+
47
+ remove_node(idx, node)
48
+ insert_lru(idx, key)
49
+ end
50
+
51
+ # Removes a node from the LRU list
52
+ def remove_node(idx, node)
53
+ if node.prev
54
+ node.prev.next = node.next
55
+ else
56
+ @lru_heads[idx] = node.next
57
+ end
58
+
59
+ if node.next
60
+ node.next.prev = node.prev
61
+ else
62
+ @lru_tails[idx] = node.prev
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Mudis
4
+ # Metrics module handles tracking of cache hits, misses, evictions and memory usage
5
+ module Metrics
6
+ # Returns a snapshot of metrics (thread-safe)
7
+ def metrics # rubocop:disable Metrics/MethodLength
8
+ @metrics_mutex.synchronize do
9
+ {
10
+ hits: @metrics[:hits],
11
+ misses: @metrics[:misses],
12
+ evictions: @metrics[:evictions],
13
+ rejected: @metrics[:rejected],
14
+ total_memory: current_memory_bytes,
15
+ least_touched: least_touched(10),
16
+ buckets: buckets.times.map do |idx|
17
+ {
18
+ index: idx,
19
+ keys: @stores[idx].size,
20
+ memory_bytes: @current_bytes[idx],
21
+ lru_size: @lru_nodes[idx].size
22
+ }
23
+ end
24
+ }
25
+ end
26
+ end
27
+
28
+ # Resets metric counters (thread-safe)
29
+ def reset_metrics!
30
+ @metrics_mutex.synchronize do
31
+ @metrics = { hits: 0, misses: 0, evictions: 0, rejected: 0 }
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # Thread-safe metric increment
38
+ def metric(name)
39
+ @metrics_mutex.synchronize { @metrics[name] += 1 }
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Mudis
4
+ # Namespace module handles logical key separation and scoping
5
+ module Namespace
6
+ # Returns all keys in a specific namespace
7
+ def keys(namespace:)
8
+ raise ArgumentError, "namespace is required" unless namespace
9
+
10
+ prefix = "#{namespace}:"
11
+ all_keys.select { |key| key.start_with?(prefix) }.map { |key| key.delete_prefix(prefix) }
12
+ end
13
+
14
+ # Clears all keys in a specific namespace
15
+ def clear_namespace(namespace:)
16
+ raise ArgumentError, "namespace is required" unless namespace
17
+
18
+ prefix = "#{namespace}:"
19
+ buckets.times do |idx|
20
+ mutex = @mutexes[idx]
21
+ store = @stores[idx]
22
+
23
+ mutex.synchronize do
24
+ keys_to_delete = store.keys.select { |key| key.start_with?(prefix) }
25
+ keys_to_delete.each { |key| evict_key(idx, key) }
26
+ end
27
+ end
28
+ end
29
+
30
+ # Executes a block with a specific namespace, restoring the old namespace afterwards
31
+ def with_namespace(namespace)
32
+ old_ns = Thread.current[:mudis_namespace]
33
+ Thread.current[:mudis_namespace] = namespace
34
+ yield
35
+ ensure
36
+ Thread.current[:mudis_namespace] = old_ns
37
+ end
38
+
39
+ private
40
+
41
+ # Namespaces a key with an optional namespace
42
+ def namespaced_key(key, namespace = nil)
43
+ ns = namespace || Thread.current[:mudis_namespace]
44
+ ns ? "#{ns}:#{key}" : key
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Mudis
4
+ # Persistence module handles snapshot save/load operations for warm boot support
5
+ module Persistence
6
+ # Saves the current cache state to disk for persistence
7
+ def save_snapshot!
8
+ return unless @persistence_enabled
9
+
10
+ data = snapshot_dump
11
+ safe_write_snapshot(data)
12
+ rescue StandardError => e
13
+ warn "[Mudis] Failed to save snapshot: #{e.class}: #{e.message}"
14
+ end
15
+
16
+ # Loads the cache state from disk for persistence
17
+ def load_snapshot!
18
+ return unless @persistence_enabled
19
+ return unless File.exist?(@persistence_path)
20
+
21
+ data = read_snapshot
22
+ snapshot_restore(data)
23
+ rescue StandardError => e
24
+ warn "[Mudis] Failed to load snapshot: #{e.class}: #{e.message}"
25
+ end
26
+
27
+ # Installs an at_exit hook to save the snapshot on process exit
28
+ def install_persistence_hook!
29
+ return unless @persistence_enabled
30
+ return if defined?(@persistence_hook_installed) && @persistence_hook_installed
31
+
32
+ at_exit { save_snapshot! }
33
+ @persistence_hook_installed = true
34
+ end
35
+
36
+ private
37
+
38
+ # Collect a JSON/Marshal-safe array of { key, value, expires_in }
39
+ def snapshot_dump # rubocop:disable Metrics/MethodLength
40
+ entries = []
41
+ now = Time.now
42
+ @buckets.times do |idx|
43
+ mutex = @mutexes[idx]
44
+ store = @stores[idx]
45
+ mutex.synchronize do
46
+ store.each do |key, raw|
47
+ exp_at = raw[:expires_at]
48
+ next if exp_at && now > exp_at
49
+
50
+ value = decompress_and_deserialize(raw[:value])
51
+ expires_in = exp_at ? (exp_at - now).to_i : nil
52
+ entries << { key: key, value: value, expires_in: expires_in }
53
+ end
54
+ end
55
+ end
56
+ entries
57
+ end
58
+
59
+ # Restore via existing write-path so LRU/limits/compression/TTL are honored
60
+ def snapshot_restore(entries)
61
+ return unless entries && !entries.empty?
62
+
63
+ entries.each do |e|
64
+ begin # rubocop:disable Style/RedundantBegin
65
+ write(e[:key], e[:value], expires_in: e[:expires_in])
66
+ rescue StandardError => ex
67
+ warn "[Mudis] Failed to restore key #{e[:key].inspect}: #{ex.message}"
68
+ end
69
+ end
70
+ end
71
+
72
+ # Serializer for snapshot persistence
73
+ # Defaults to Marshal if not JSON
74
+ def serializer_for_snapshot
75
+ (@persistence_format || :marshal).to_sym == :json ? JSON : :marshal
76
+ end
77
+
78
+ # Safely writes snapshot data to disk
79
+ # Uses safe write if configured
80
+ def safe_write_snapshot(data) # rubocop:disable Metrics/MethodLength
81
+ path = @persistence_path
82
+ dir = File.dirname(path)
83
+ Dir.mkdir(dir) unless Dir.exist?(dir)
84
+
85
+ payload =
86
+ if (@persistence_format || :marshal).to_sym == :json
87
+ serializer_for_snapshot.dump(data)
88
+ else
89
+ Marshal.dump(data)
90
+ end
91
+
92
+ if @persistence_safe_write
93
+ tmp = "#{path}.tmp-#{$$}-#{Thread.current.object_id}"
94
+ File.open(tmp, "wb") { |f| f.write(payload) }
95
+ File.rename(tmp, path)
96
+ else
97
+ File.open(path, "wb") { |f| f.write(payload) }
98
+ end
99
+ end
100
+
101
+ # Reads snapshot data from disk
102
+ # Uses safe read if configured
103
+ def read_snapshot
104
+ if (@persistence_format || :marshal).to_sym == :json
105
+ # Use JSON.parse instead of JSON.load to support symbolize_names option
106
+ serializer_for_snapshot.parse(File.binread(@persistence_path), symbolize_names: true)
107
+ else
108
+ ## safe to use Marshal here as we control the file
109
+ Marshal.load(File.binread(@persistence_path)) # rubocop:disable Security/MarshalLoad
110
+ end
111
+ end
112
+ end
113
+ end
data/lib/mudis/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- MUDIS_VERSION = "0.8.1"
3
+ MUDIS_VERSION = "0.9.0"