mudis 0.9.0 → 0.9.4

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: 283e3b629449e6324d30dd19450d4d363a5d415ad45ace27569d4d82fb7e8066
4
- data.tar.gz: 5fe63b61e7526eb90c66c9a5f9da721c5156ec40a144398cedb89e11adc7b35e
3
+ metadata.gz: 987b789b060c3e66ed1ce29fc8fc868f9955e986349a48da34f2d06f29bf21f4
4
+ data.tar.gz: cb846d56202f3934cb1f4e47ede9d12ef3cd8af050cedd6e5f901945b95c41ef
5
5
  SHA512:
6
- metadata.gz: cfc3db7ea2a80a03af39738a6700bbd07b525dbd101bf7b3b8f11b1fa711fdc14a9ad248dcdeff5b469e55bca24efd15d08ac5bf3ff91c83b6614c5ddc925521
7
- data.tar.gz: 4923b6e5ccb935914e0c5811be65fbe358304db5f6e8d76097b08f25db939e441b0b72557695981ffdae7e300d36dd4a66c3d2b3d2fd163c1e3735dbddbd61e6
6
+ metadata.gz: c14266d9bf5121b085f79029678ca4bf429451adf9335710d2046d152a9a17618051d141e2091fc6a260d5bcf9ec522680462037f9576d2d2504d09ddc8418e2
7
+ data.tar.gz: 217d6a415f9f7b9bea4240d6300337e4ea86e7aa10308a92dfb8c673ad8410f13cefb77c424bd00c7b2b138c698aefec030d47709fa78bd0b87dcb1e797874e7
data/README.md CHANGED
@@ -29,8 +29,8 @@ Mudis also works naturally in Hanami because it’s a pure Ruby in-memory cache.
29
29
  - [Installation](#installation)
30
30
  - [Configuration (Ruby/Rails)](#configuration-rubyrails)
31
31
  - [Configuration (Hanami)](#configuration-hanami)
32
- - [Start and Stop Exipry Thread](#start-and-stop-exipry-thread)
33
- - [Starting Exipry Thread](#starting-exipry-thread)
32
+ - [Start and Stop Expiry Thread](#start-and-stop-expiry-thread)
33
+ - [Starting Expiry Thread](#starting-expiry-thread)
34
34
  - [Graceful Shutdown](#graceful-shutdown)
35
35
  - [Basic Usage](#basic-usage)
36
36
  - [Developer Utilities](#developer-utilities)
@@ -96,15 +96,57 @@ There are plenty out there, in various states of maintenance and in many shapes
96
96
 
97
97
  #### Internal Structure and Behaviour
98
98
 
99
- ![mudis_flow](design/mudis_obj.png "Mudis Internals")
99
+ ```mermaid
100
+ flowchart TD
101
+ A[Mudis] --> C[Config]
102
+ A --> B[Shards/Buckets]
103
+ B --> S[Store Hash]
104
+ B --> L[LRU List]
105
+ B --> M[Mutex]
106
+ A --> E[Expiry Thread]
107
+ A --> P[Persistence]
108
+ A --> R[Metrics]
109
+ A --> N[Namespace]
110
+ ```
100
111
 
101
112
  #### Write - Read - Eviction
102
113
 
103
- ![mudis_flow](design/mudis_flow.png "Write - Read - Eviction")
114
+ ```mermaid
115
+ flowchart TD
116
+ W[Write] --> S[Serialize]
117
+ S --> Z{Compress?}
118
+ Z -- Yes --> ZL[Zlib Deflate]
119
+ Z -- No --> SZ[Raw]
120
+ ZL --> G[Size/Memory Guardrails]
121
+ SZ --> G
122
+ G --> T[Set TTL]
123
+ T --> I[Insert in Bucket]
124
+ I --> L[LRU Insert]
125
+ L --> M[Update Bytes]
126
+ R[Read] --> LK[Lookup]
127
+ LK --> X{Expired?}
128
+ X -- Yes --> EV[Evict]
129
+ X -- No --> D[Deserialize/Inflate]
130
+ D --> PR[Promote LRU]
131
+ PR --> OUT[Return Value]
132
+ E[Evict] --> LRU[LRU Tail]
133
+ ```
104
134
 
105
135
  #### Cache Key Lifecycle
106
136
 
107
- ![mudis_lru](design/mudis_lru.png "Mudis Cache Key Lifecycle")
137
+ ```mermaid
138
+ sequenceDiagram
139
+ participant Client
140
+ participant Mudis
141
+ participant LRU as LRU List
142
+ Client->>Mudis: write(key, value)
143
+ Mudis->>LRU: insert(key) at head
144
+ Client->>Mudis: read(key)
145
+ Mudis->>LRU: promote(key) to head
146
+ Note over Mudis: if over threshold, evict tail
147
+ Mudis->>LRU: evict(tail)
148
+ Mudis-->>Client: value or nil
149
+ ```
108
150
 
109
151
  ---
110
152
 
@@ -115,9 +157,10 @@ There are plenty out there, in various states of maintenance and in many shapes
115
157
  - **LRU Eviction**: Automatically evicts least recently used items as memory fills up.
116
158
  - **Expiry Support**: Optional TTL per key with background cleanup thread.
117
159
  - **Compression**: Optional Zlib compression for large values.
118
- - **Metrics**: Tracks hits, misses, and evictions.
160
+ - **Metrics**: Tracks hits, misses, and evictions, with optional per-namespace counters.
119
161
  - **IPC Mode**: Shared cross-process caching for multi-process aplications.
120
162
  - **Soft-persistence**: Data snapshot and reload.
163
+ - **Caller Binding**: Optional scoped cache wrappers via `Mudis.bind`.
121
164
 
122
165
  ---
123
166
 
@@ -147,6 +190,7 @@ Mudis.configure do |c|
147
190
  c.max_value_bytes = 2_000_000 # Reject values > 2MB
148
191
  c.hard_memory_limit = true # enforce hard memory limits
149
192
  c.max_bytes = 1_073_741_824 # set maximum cache size
193
+ c.eviction_threshold = 0.9 # evict when a bucket exceeds 90% of max_bytes
150
194
  end
151
195
 
152
196
  Mudis.start_expiry_thread(interval: 60) # Cleanup every 60s
@@ -164,6 +208,7 @@ Mudis.compress = true # Compress values using Zlib
164
208
  Mudis.max_value_bytes = 2_000_000 # Reject values > 2MB
165
209
  Mudis.hard_memory_limit = true # enforce hard memory limits
166
210
  Mudis.max_bytes = 1_073_741_824 # set maximum cache size
211
+ Mudis.eviction_threshold = 0.9 # evict when a bucket exceeds 90% of max_bytes
167
212
 
168
213
  Mudis.start_expiry_thread(interval: 60) # Cleanup every 60s
169
214
 
@@ -245,11 +290,11 @@ end
245
290
 
246
291
  ---
247
292
 
248
- ## Start and Stop Exipry Thread
293
+ ## Start and Stop Expiry Thread
249
294
 
250
295
  The expiry thread is not triggered automatically when your application starts. You must add the start and stop in the respective process hooks.
251
296
 
252
- ### Starting Exipry Thread
297
+ ### Starting Expiry Thread
253
298
 
254
299
  To enable background expiration and removal, you must start the expiry thread at start up after configuration.
255
300
 
@@ -289,11 +334,31 @@ Mudis.exists?('user:123') # => true
289
334
 
290
335
  # Atomically update
291
336
  Mudis.update('user:123') { |data| data.merge(age: 30) }
337
+ # Note: update refreshes TTL based on the original TTL duration.
292
338
 
293
339
  # Delete a key
294
340
  Mudis.delete('user:123')
295
341
  ```
296
342
 
343
+ ### Caller-Scoped Cache (Bound)
344
+
345
+ For caller-bound caching (per-tenant or per-service scoping), use `Mudis.bind` to create a scoped wrapper:
346
+
347
+ ```ruby
348
+ cache = Mudis.bind(
349
+ namespace: "caller:123",
350
+ default_ttl: 60,
351
+ max_ttl: 300,
352
+ max_value_bytes: 128_000
353
+ )
354
+
355
+ cache.write("profile", { name: "Alice" })
356
+ cache.read("profile") # => { "name" => "Alice" }
357
+ cache.fetch("expensive", singleflight: true) { expensive_query }
358
+ ```
359
+
360
+ The wrapper delegates to Mudis but automatically applies the namespace and optional per-caller guardrails.
361
+
297
362
  ### Developer Utilities
298
363
 
299
364
  Mudis provides utility methods to help with test environments, console debugging, and dev tool resets.
@@ -419,7 +484,7 @@ class MudisService
419
484
  # @param force [Boolean] force recomputation
420
485
  # @yield return value if key is missing
421
486
  def fetch(expires_in: nil, force: false)
422
- Mudis.fetch(cache_key, expires_in: expires_in, force: force, namespace: namespace) do
487
+ Mudis.fetch(cache_key, expires_in: expires_in, force: force, namespace: namespace, singleflight: true) do
423
488
  yield
424
489
  end
425
490
  end
@@ -477,6 +542,13 @@ Mudis.metrics
477
542
 
478
543
  ```
479
544
 
545
+ You can also query metrics for a specific namespace:
546
+
547
+ ```ruby
548
+ Mudis.metrics(namespace: "users")
549
+ # => { hits: 10, misses: 2, evictions: 1, rejected: 0, namespace: "users" }
550
+ ```
551
+
480
552
  Optionally, expose Mudis metrics from a controller or action for remote analysis and monitoring.
481
553
 
482
554
  **Rails:**
@@ -546,6 +618,7 @@ end
546
618
  | `Mudis.start_expiry_thread` | Background TTL cleanup loop (every N sec) | Disabled by default|
547
619
  | `Mudis.hard_memory_limit` | Enforce hard memory limits on key size and reject if exceeded | `false`|
548
620
  | `Mudis.max_bytes` | Maximum allowed cache size | `1GB`|
621
+ | `Mudis.eviction_threshold` | Evict when a bucket exceeds this ratio of max_bytes | `0.9` |
549
622
  | `Mudis.max_ttl` | Set the maximum permitted TTL | `nil` (no limit) |
550
623
  | `Mudis.default_ttl` | Set the default TTL for fallback when none is provided | `nil` |
551
624
 
@@ -611,7 +684,16 @@ Mudis.load_snapshot!
611
684
 
612
685
  #### Example Flow
613
686
 
614
- ![mudis_persistence](design/mudis_persistence.png "Mudis Persistence Strategy")
687
+ ```mermaid
688
+ flowchart LR
689
+ A[App Boot] --> B[Configure Mudis]
690
+ B --> C[Load Snapshot]
691
+ C --> D[Cache Warm]
692
+ D --> E[Normal Operations]
693
+ E --> F[Exit Hook]
694
+ F --> G[Save Snapshot]
695
+ G --> H[Snapshot File]
696
+ ```
615
697
 
616
698
  1. On startup, `Mudis.load_snapshot!` repopulates the cache.
617
699
  2. Your app uses the cache as normal (`write`, `read`, `fetch`, etc.).
@@ -644,10 +726,21 @@ This design allows multiple workers to share the same cache without duplicating
644
726
  | **Shared Cache Across Processes** | All Puma workers share one Mudis instance via IPC. |
645
727
  | **Zero External Dependencies** | No Redis, Memcached, or separate daemon required. |
646
728
  | **Memory Efficient** | Cache data stored only once, not duplicated per worker. |
647
- | **Full Feature Support** | All Mudis features (TTL, compression, metrics, etc.) work transparently. |
729
+ | **Feature Coverage** | Core cache ops + introspection (keys, inspect, least_touched) and metrics are supported. |
648
730
  | **Safe & Local** | Communication is limited to the host system’s UNIX socket, ensuring isolation and speed. |
649
731
 
650
- ![mudis_ipc](design/mudis_ipc.png "Mudis IPC")
732
+ ```mermaid
733
+ flowchart LR
734
+ M[Master Process] --> S[MudisServer]
735
+ W1[Worker 1] --> C1[MudisClient]
736
+ W2[Worker 2] --> C2[MudisClient]
737
+ W3[Worker 3] --> C3[MudisClient]
738
+ C1 --> U[Unix Socket/TCP]
739
+ C2 --> U
740
+ C3 --> U
741
+ U --> S
742
+ S --> Cache[Mudis Cache]
743
+ ```
651
744
 
652
745
  ### Setup (Puma)
653
746
 
@@ -701,13 +794,18 @@ if defined?($mudis) && $mudis
701
794
  def self.delete(*a, **k) = $mudis.delete(*a, **k)
702
795
  def self.fetch(*a, **k, &b) = $mudis.fetch(*a, **k, &b)
703
796
  def self.metrics = $mudis.metrics
704
- def self.reset_metrics! = $mudis.reset_metrics!
705
- def self.reset! = $mudis.reset!
706
797
  end
707
798
 
708
799
  end
709
800
  ```
710
801
 
802
+ **IPC safety limitations:**
803
+
804
+ - Administrative operations are **not** exposed over IPC (e.g., `reset!`, `reset_metrics!`, `save_snapshot!`, `load_snapshot!`).
805
+ - IPC client requests use timeouts and retries:
806
+ - `MUDIS_IPC_TIMEOUT` (seconds, default: `1`)
807
+ - `MUDIS_IPC_RETRIES` (default: `1`)
808
+
711
809
  **Use IPC mode when:**
712
810
 
713
811
  - Running Rails or Rack apps under Puma cluster or multi-process background job workers.
@@ -799,7 +897,7 @@ _10000 iterations of 512KB, JSON, compression ON_
799
897
  ## Known Limitations
800
898
 
801
899
  - Data is **non-persistent**; only soft-persistence is optionally provided.
802
- - No SQL or equivallent query interface for cached data. Data is per Key retrieval only.
900
+ - No SQL or equivallent query interface for cached data. Data is per Key retrieval only (see [Mudis-QL](https://github.com/kiebor81/mudis-ql)).
803
901
  - Compression introduces CPU overhead.
804
902
 
805
903
  ---
@@ -934,7 +1032,7 @@ Mudis is not intended to be a general-purpose, distributed caching platform. You
934
1032
 
935
1033
  - [x] Review Mudis for improved readability and reduce complexity in top-level functions
936
1034
  - [x] Enhanced guards
937
- - [ ] Refactor main classes for enhanced readability
1035
+ - [x] Refactor main classes for enhanced readability
938
1036
  - [ ] Review for functionality gaps and enhance as needed
939
1037
 
940
1038
  ---
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ class Mudis
6
+ # Scoped wrapper for caller-bound access with optional per-caller policy.
7
+ class Bound
8
+ def initialize(namespace:, default_ttl: nil, max_ttl: nil, max_value_bytes: nil)
9
+ raise ArgumentError, "namespace is required" if namespace.nil? || namespace.to_s.empty?
10
+
11
+ @namespace = namespace
12
+ @default_ttl = default_ttl
13
+ @max_ttl = max_ttl
14
+ @max_value_bytes = max_value_bytes
15
+ @inflight_mutexes_lock = Mutex.new
16
+ @inflight_mutexes = {}
17
+ end
18
+
19
+ attr_reader :namespace
20
+
21
+ def read(key)
22
+ Mudis.read(key, namespace: @namespace)
23
+ end
24
+
25
+ def write(key, value, expires_in: nil)
26
+ return if exceeds_max_value_bytes?(value)
27
+
28
+ Mudis.write(key, value, expires_in: effective_ttl(expires_in), namespace: @namespace)
29
+ end
30
+
31
+ def update(key)
32
+ Mudis.update(key, namespace: @namespace) do |current|
33
+ next_value = yield(current)
34
+ exceeds_max_value_bytes?(next_value) ? current : next_value
35
+ end
36
+ end
37
+
38
+ def delete(key)
39
+ Mudis.delete(key, namespace: @namespace)
40
+ end
41
+
42
+ def exists?(key)
43
+ Mudis.exists?(key, namespace: @namespace)
44
+ end
45
+
46
+ def fetch(key, expires_in: nil, force: false, singleflight: false)
47
+ return fetch_without_lock(key, expires_in:, force:) { yield } unless singleflight
48
+
49
+ with_inflight_lock(key) do
50
+ fetch_without_lock(key, expires_in:, force:) { yield }
51
+ end
52
+ end
53
+
54
+ def clear(key)
55
+ delete(key)
56
+ end
57
+
58
+ def replace(key, value, expires_in: nil)
59
+ return if exceeds_max_value_bytes?(value)
60
+
61
+ Mudis.replace(key, value, expires_in: effective_ttl(expires_in), namespace: @namespace)
62
+ end
63
+
64
+ def inspect(key)
65
+ Mudis.inspect(key, namespace: @namespace)
66
+ end
67
+
68
+ def keys
69
+ Mudis.keys(namespace: @namespace)
70
+ end
71
+
72
+ def metrics
73
+ Mudis.metrics(namespace: @namespace)
74
+ end
75
+
76
+ def clear_namespace
77
+ Mudis.clear_namespace(namespace: @namespace)
78
+ end
79
+
80
+ private
81
+
82
+ def effective_ttl(expires_in)
83
+ ttl = expires_in || @default_ttl
84
+ return nil unless ttl
85
+ return ttl unless @max_ttl
86
+
87
+ [ttl, @max_ttl].min
88
+ end
89
+
90
+ def exceeds_max_value_bytes?(value)
91
+ return false unless @max_value_bytes
92
+
93
+ raw = Mudis.serializer.dump(value)
94
+ raw = Zlib::Deflate.deflate(raw) if Mudis.compress
95
+ raw.bytesize > @max_value_bytes
96
+ end
97
+
98
+ def fetch_without_lock(key, expires_in:, force:)
99
+ unless force
100
+ cached = read(key)
101
+ return cached if cached
102
+ end
103
+
104
+ value = yield
105
+ return nil if exceeds_max_value_bytes?(value)
106
+
107
+ write(key, value, expires_in: expires_in)
108
+ value
109
+ end
110
+
111
+ def with_inflight_lock(lock_key)
112
+ entry = nil
113
+ @inflight_mutexes_lock.synchronize do
114
+ entry = (@inflight_mutexes[lock_key] ||= { mutex: Mutex.new, count: 0 })
115
+ entry[:count] += 1
116
+ end
117
+
118
+ entry[:mutex].synchronize { yield }
119
+ ensure
120
+ @inflight_mutexes_lock.synchronize do
121
+ next unless entry
122
+
123
+ entry[:count] -= 1
124
+ @inflight_mutexes.delete(lock_key) if entry[:count] <= 0
125
+ end
126
+ end
127
+ end
128
+ end
data/lib/mudis/metrics.rb CHANGED
@@ -4,7 +4,9 @@ class Mudis
4
4
  # Metrics module handles tracking of cache hits, misses, evictions and memory usage
5
5
  module Metrics
6
6
  # Returns a snapshot of metrics (thread-safe)
7
- def metrics # rubocop:disable Metrics/MethodLength
7
+ def metrics(namespace: nil) # rubocop:disable Metrics/MethodLength
8
+ return namespace_metrics(namespace) if namespace
9
+
8
10
  @metrics_mutex.synchronize do
9
11
  {
10
12
  hits: @metrics[:hits],
@@ -30,13 +32,30 @@ class Mudis
30
32
  @metrics_mutex.synchronize do
31
33
  @metrics = { hits: 0, misses: 0, evictions: 0, rejected: 0 }
32
34
  end
35
+
36
+ @metrics_by_namespace_mutex.synchronize do
37
+ @metrics_by_namespace = {}
38
+ end
33
39
  end
34
40
 
35
41
  private
36
42
 
37
43
  # Thread-safe metric increment
38
- def metric(name)
44
+ def metric(name, namespace: nil)
39
45
  @metrics_mutex.synchronize { @metrics[name] += 1 }
46
+ return unless namespace
47
+
48
+ @metrics_by_namespace_mutex.synchronize do
49
+ @metrics_by_namespace[namespace] ||= { hits: 0, misses: 0, evictions: 0, rejected: 0 }
50
+ @metrics_by_namespace[namespace][name] += 1
51
+ end
52
+ end
53
+
54
+ def namespace_metrics(namespace)
55
+ @metrics_by_namespace_mutex.synchronize do
56
+ entry = @metrics_by_namespace[namespace] || { hits: 0, misses: 0, evictions: 0, rejected: 0 }
57
+ entry.merge(namespace: namespace)
58
+ end
40
59
  end
41
60
  end
42
61
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+
3
5
  class Mudis
4
6
  # Persistence module handles snapshot save/load operations for warm boot support
5
7
  module Persistence
@@ -80,7 +82,7 @@ class Mudis
80
82
  def safe_write_snapshot(data) # rubocop:disable Metrics/MethodLength
81
83
  path = @persistence_path
82
84
  dir = File.dirname(path)
83
- Dir.mkdir(dir) unless Dir.exist?(dir)
85
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
84
86
 
85
87
  payload =
86
88
  if (@persistence_format || :marshal).to_sym == :json
data/lib/mudis/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- MUDIS_VERSION = "0.9.0"
3
+ MUDIS_VERSION = "0.9.4"