mudis 0.7.3 → 0.8.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: ff59084c07925cd39d362eb387e32ecd6c1c5038c92bbef4e7782f4fa37cdf23
4
- data.tar.gz: b9e4db5c9faf2cb426d2c941377ee2617abd335cf77d94aa8817fc726560bb89
3
+ metadata.gz: ae292b0f546008c9be0a96ef33f4e6a2f8c3a79fdd5545da375eafd435d9f36b
4
+ data.tar.gz: eee866d44d874439535fab0778e41a17f6bb10b2043dfec8ee7493c083811a75
5
5
  SHA512:
6
- metadata.gz: e0a426f20bae0e68cf840783e1fa131039e2ccfaa17dbb1c557ead4a34da878f43f17a7d4c6753039687a2a7722865f6f4198dc74990f4004ccde1395deaac6a
7
- data.tar.gz: 0d40c4e492fdca8539f95e983c0e28890e882d846c4068d8fd679d0d0868bb15387a17b5c3dea52a3ca50e0c443e3d587212d93b9e8f5f7c2087c8ac5f141407
6
+ metadata.gz: d24edc845960b0c56b9bdf286d51863c919960d8270fabe9acb6729ad1faa173a9dd40b810e89e766360fe383787ebc89e03c8a84234b65d2202a44705e80cfb
7
+ data.tar.gz: f260429d8a394eefdc0a2d637c646b175ce22b48ed1a9fdf04e225d908022656d985ce67041d8762a0cadf95484b4b29e63114b4ae55227f0060c60be97347ad
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  ![mudis_signet](design/mudis.png "Mudis")
2
2
 
3
+ [![RubyMine](https://www.elegantobjects.org/rubymine.svg)](https://www.jetbrains.com/ruby/)
4
+
3
5
  [![Gem Version](https://badge.fury.io/rb/mudis.svg?icon=si%3Arubygems&refresh=1&cachebust=0)](https://badge.fury.io/rb/mudis)
4
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
7
 
@@ -36,12 +38,13 @@ Mudis also works naturally in Hanami because it’s a pure Ruby in-memory cache.
36
38
  - [Rails Service Integration](#rails-service-integration)
37
39
  - [Metrics](#metrics)
38
40
  - [Advanced Configuration](#advanced-configuration)
39
- - [Benchmarks](#benchmarks)
40
- - [Graceful Shutdown](#graceful-shutdown)
41
- - [Known Limitations](#known-limitations)
41
+ - [Soft Persistence (Snapshots)](#soft-persistence-snapshots)
42
42
  - [Inter-Process Caching (IPC Mode)](#inter-process-caching-ipc-mode)
43
43
  - [Overview](#overview)
44
44
  - [Setup (Puma)](#setup-puma)
45
+ - [Benchmarks](#benchmarks)
46
+ - [Graceful Shutdown](#graceful-shutdown)
47
+ - [Known Limitations](#known-limitations)
45
48
  - [Create a Mudis Web Cache Server](#create-a-mudis-web-cache-server)
46
49
  - [Minimal Setup](#minimal-setup)
47
50
  - [Project Philosophy](#project-philosophy)
@@ -528,100 +531,69 @@ When setting `serializer`, be mindful of the below
528
531
 
529
532
  ---
530
533
 
531
- ## Benchmarks
532
-
533
- #### Serializer(s)
534
-
535
- _100000 iterations_
536
-
537
- | Serializer | Total Time (s) | Ops/sec |
538
- |----------------|------------|----------------|
539
- | oj | 0.1342 | 745320 |
540
- | marshal | 0.3228 | 309824 |
541
- | json | 0.9035 | 110682 |
542
- | oj + zlib | 1.8050 | 55401 |
543
- | marshal + zlib | 1.8057 | 55381 |
544
- | json + zlib | 2.7949 | 35780 |
545
-
546
- > If opting for OJ, you will need to install the dependency in your project and configure as needed.
547
-
548
- #### Mudis vs Rails.cache
549
-
550
- Mudis is marginally slower than `Rails.cache` by design; it trades raw speed for control, observability, and safety.
551
-
552
- _10000 iterations of 1MB, Marshal (to match MemoryStore default), compression ON_
553
-
554
- | Operation | `Rails.cache` | `Mudis` | Delta |
555
- | --------- | ------------- | ----------- | --------- |
556
- | Write | 2.139 ms/op | 2.417 ms/op | +0.278 ms |
557
- | Read | 0.007 ms/op | 0.810 ms/op | +0.803 ms |
534
+ ## Soft Persistence (Snapshots)
558
535
 
559
- > For context: a typical database query or HTTP call takes 10–50ms. A difference of less than 1ms per operation is negligible for most apps.
560
-
561
- #### **Why this overhead exists**
562
-
563
- Mudis includes features that MemoryStore doesn’t:
564
-
565
- | Feature | Mudis | Rails.cache (MemoryStore) |
566
- | ------------------ | ---------------------- | --------------------------- |
567
- | Per-key TTL expiry | ✅ | ⚠️ on access |
568
- | True LRU eviction | ✅ | ❌ |
569
- | Hard memory limits | ✅ | ❌ |
570
- | Value compression | ✅ | ❌ |
571
- | Thread safety | ✅ Bucket-level mutexes | ✅ Global mutex |
572
- | Observability | ✅ | ❌ |
573
- | Namespacing | ✅ | ❌ Manual scoping |
574
- | IPC Aware | ✅ (if enabled) | ❌ Requires manual configuration and additional gems |
536
+ Mudis can optionally soft-persist its in-memory cache to disk between process restarts.
537
+ When enabled, it will automatically:
575
538
 
576
- It will be down to the developer to decide if a fraction of a millisecond is worth
539
+ - **Save** a snapshot of the current cache at shutdown
540
+ - **Reload** it on startup
577
541
 
578
- - Predictable eviction
579
- - Configurable expiry
580
- - Memory protection
581
- - Namespace scoping
582
- - Real-time metrics for hits, misses, evictions, memory usage
542
+ This feature is **off by default** and can be enabled via configuration.
583
543
 
584
- _10000 iterations of 1MB, Marshal (to match MemoryStore default), compression OFF (to match MemoryStore default)_
544
+ Example configuration:
585
545
 
586
- | Operation | `Rails.cache` | `Mudis` | Delta |
587
- | --------- | ------------- | ----------- | ------------- |
588
- | Write | 2.342 ms/op | 0.501 ms/op | **−1.841 ms** |
589
- | Read | 0.007 ms/op | 0.011 ms/op | +0.004 ms |
546
+ ```ruby
547
+ Mudis.configure do |config|
548
+ config.persistence_enabled = true
549
+ config.persistence_path = "tmp/mudis_snapshot.dump"
550
+ config.persistence_format = :marshal # or :json
551
+ config.persistence_safe_write = true # atomic temp write + rename
552
+ end
590
553
 
591
- With compression disabled, Mudis writes significanty faster and reads are virtually identical. Optimisation and configuration of Mudis will be determined by your individual needs.
554
+ ## Optionally install the exit hook manually (usually automatic)
555
+ # Mudis.install_persistence_hook!
556
+ ```
592
557
 
593
- #### Other Benchmarks
558
+ From your startup routine:
594
559
 
595
- _10000 iterations of 512KB, JSON, compression OFF (to match MemoryStore default)_
560
+ ```ruby
561
+ # Restore snapshot on startup
562
+ Mudis.load_snapshot!
563
+ ```
596
564
 
597
- | Operation | `Rails.cache` | `Mudis` | Delta |
598
- | --------- | ------------- | ----------- | ------------- |
599
- | Write | 1.291 ms/op | 0.32 ms/op | **−0.971 ms** |
600
- | Read | 0.011 ms/op | 0.048 ms/op | +0.037 ms |
565
+ *This feature works identically in Rails, Hanami, and standalone Ruby scripts, as long as `Mudis.configure` is called prior to `Mudis.load_snapshot!`.*
601
566
 
602
- _10000 iterations of 512KB, JSON, compression ON_
567
+ ### Behavior
603
568
 
604
- | Operation | `Rails.cache` | `Mudis` | Delta |
605
- | --------- | ------------- | ----------- | ------------- |
606
- | Write | 1.11 ms/op | 1.16 ms/op | +0.05 ms |
607
- | Read | 0.07 ms/op | 0.563 ms/op | +0.493 ms |
569
+ | Operation | Description |
570
+ |------------|-------------|
571
+ | `save_snapshot!` | Dumps all non-expired cache entries to disk. |
572
+ | `load_snapshot!` | Reads snapshot and restores entries via normal write path. |
573
+ | `install_persistence_hook!` | Registers an `at_exit` hook to automatically save on process exit. |
608
574
 
609
- ---
575
+ #### Notes
610
576
 
611
- ## Graceful Shutdown
577
+ - Disabled by default for backward compatibility.
578
+ - Uses Ruby’s `Marshal` by default for fastest serialization; `:json` also supported.
579
+ - Respects TTLs: expired entries are never written to disk.
580
+ - Thread-safe: snapshots are taken under shard-level locks.
581
+ - Safe write: when `persistence_safe_write = true`, Mudis writes to a temp file and renames it atomically.
612
582
 
613
- Don’t forget to stop the expiry thread when your app exits:
583
+ #### Example Flow
614
584
 
615
- ```ruby
616
- at_exit { Mudis.stop_expiry_thread }
617
- ```
585
+ ![mudis_persistence](design/mudis_persistence.png "Mudis Persistence Strategy")
618
586
 
619
- ---
587
+ 1. On startup, `Mudis.load_snapshot!` repopulates the cache.
588
+ 2. Your app uses the cache as normal (`write`, `read`, `fetch`, etc.).
589
+ 3. When the process exits, `at_exit` automatically triggers `save_snapshot!`.
590
+ 4. A file (e.g., `tmp/mudis_snapshot.dump`) holds your persisted cache.
620
591
 
621
- ## Known Limitations
592
+ #### Safety
622
593
 
623
- - Data is **non-persistent**.
624
- - Compression introduces CPU overhead.
594
+ If the snapshot file is **missing** or **corrupted**, Mudis will:
595
+ - Log a warning via `warn "[Mudis] Failed to load snapshot..."`, and
596
+ - Continue booting normally with an empty cache.
625
597
 
626
598
  ---
627
599
 
@@ -712,6 +684,103 @@ end
712
684
 
713
685
  ---
714
686
 
687
+ ## Benchmarks
688
+
689
+ #### Serializer(s)
690
+
691
+ _100000 iterations_
692
+
693
+ | Serializer | Total Time (s) | Ops/sec |
694
+ |----------------|------------|----------------|
695
+ | oj | 0.1342 | 745320 |
696
+ | marshal | 0.3228 | 309824 |
697
+ | json | 0.9035 | 110682 |
698
+ | oj + zlib | 1.8050 | 55401 |
699
+ | marshal + zlib | 1.8057 | 55381 |
700
+ | json + zlib | 2.7949 | 35780 |
701
+
702
+ > If opting for OJ, you will need to install the dependency in your project and configure as needed.
703
+
704
+ #### Mudis vs Rails.cache
705
+
706
+ Mudis is marginally slower than `Rails.cache` by design; it trades raw speed for control, observability, and safety.
707
+
708
+ _10000 iterations of 1MB, Marshal (to match MemoryStore default), compression ON_
709
+
710
+ | Operation | `Rails.cache` | `Mudis` | Delta |
711
+ | --------- | ------------- | ----------- | --------- |
712
+ | Write | 2.139 ms/op | 2.417 ms/op | +0.278 ms |
713
+ | Read | 0.007 ms/op | 0.810 ms/op | +0.803 ms |
714
+
715
+ > For context: a typical database query or HTTP call takes 10–50ms. A difference of less than 1ms per operation is negligible for most apps.
716
+
717
+ #### **Why this overhead exists**
718
+
719
+ Mudis includes features that MemoryStore doesn’t:
720
+
721
+ | Feature | Mudis | Rails.cache (MemoryStore) |
722
+ | ------------------ | ---------------------- | --------------------------- |
723
+ | Per-key TTL expiry | ✅ | ⚠️ on access |
724
+ | True LRU eviction | ✅ | ❌ |
725
+ | Hard memory limits | ✅ | ❌ |
726
+ | Value compression | ✅ | ❌ |
727
+ | Thread safety | ✅ Bucket-level mutexes | ✅ Global mutex |
728
+ | Observability | ✅ | ❌ |
729
+ | Namespacing | ✅ | ❌ Manual scoping |
730
+ | IPC Aware | ✅ (if enabled) | ❌ Requires manual configuration and additional gems |
731
+
732
+ It will be down to the developer to decide if a fraction of a millisecond is worth
733
+
734
+ - Predictable eviction
735
+ - Configurable expiry
736
+ - Memory protection
737
+ - Namespace scoping
738
+ - Real-time metrics for hits, misses, evictions, memory usage
739
+
740
+ _10000 iterations of 1MB, Marshal (to match MemoryStore default), compression OFF (to match MemoryStore default)_
741
+
742
+ | Operation | `Rails.cache` | `Mudis` | Delta |
743
+ | --------- | ------------- | ----------- | ------------- |
744
+ | Write | 2.342 ms/op | 0.501 ms/op | **−1.841 ms** |
745
+ | Read | 0.007 ms/op | 0.011 ms/op | +0.004 ms |
746
+
747
+ With compression disabled, Mudis writes significanty faster and reads are virtually identical. Optimisation and configuration of Mudis will be determined by your individual needs.
748
+
749
+ #### Other Benchmarks
750
+
751
+ _10000 iterations of 512KB, JSON, compression OFF (to match MemoryStore default)_
752
+
753
+ | Operation | `Rails.cache` | `Mudis` | Delta |
754
+ | --------- | ------------- | ----------- | ------------- |
755
+ | Write | 1.291 ms/op | 0.32 ms/op | **−0.971 ms** |
756
+ | Read | 0.011 ms/op | 0.048 ms/op | +0.037 ms |
757
+
758
+ _10000 iterations of 512KB, JSON, compression ON_
759
+
760
+ | Operation | `Rails.cache` | `Mudis` | Delta |
761
+ | --------- | ------------- | ----------- | ------------- |
762
+ | Write | 1.11 ms/op | 1.16 ms/op | +0.05 ms |
763
+ | Read | 0.07 ms/op | 0.563 ms/op | +0.493 ms |
764
+
765
+ ---
766
+
767
+ ## Graceful Shutdown
768
+
769
+ Don’t forget to stop the expiry thread when your app exits:
770
+
771
+ ```ruby
772
+ at_exit { Mudis.stop_expiry_thread }
773
+ ```
774
+
775
+ ---
776
+
777
+ ## Known Limitations
778
+
779
+ - Data is **non-persistent**; only soft-persistence is optionally provided.
780
+ - Compression introduces CPU overhead.
781
+
782
+ ---
783
+
715
784
  ## Create a Mudis Web Cache Server
716
785
 
717
786
  ### Minimal Setup
data/lib/mudis/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- MUDIS_VERSION = "0.7.3"
3
+ MUDIS_VERSION = "0.8.0"
data/lib/mudis.rb CHANGED
@@ -47,6 +47,11 @@ class Mudis # rubocop:disable Metrics/ClassLength
47
47
  self.max_ttl = config.max_ttl
48
48
  self.default_ttl = config.default_ttl
49
49
 
50
+ @persistence_enabled = config.persistence_enabled
51
+ @persistence_path = config.persistence_path
52
+ @persistence_format = config.persistence_format
53
+ @persistence_safe_write = config.persistence_safe_write
54
+
50
55
  if config.buckets # rubocop:disable Style/GuardClause
51
56
  @buckets = config.buckets
52
57
  reset!
@@ -518,4 +523,109 @@ class Mudis # rubocop:disable Metrics/ClassLength
518
523
  [ttl, @max_ttl].min
519
524
  end
520
525
  end
526
+
527
+ class << self
528
+
529
+ # Saves the current cache state to disk for persistence
530
+ def save_snapshot!
531
+ return unless @persistence_enabled
532
+ data = snapshot_dump
533
+ safe_write_snapshot(data)
534
+ rescue => e
535
+ warn "[Mudis] Failed to save snapshot: #{e.class}: #{e.message}"
536
+ end
537
+
538
+ # Loads the cache state from disk for persistence
539
+ def load_snapshot!
540
+ return unless @persistence_enabled
541
+ return unless File.exist?(@persistence_path)
542
+ data = read_snapshot
543
+ snapshot_restore(data)
544
+ rescue => e
545
+ warn "[Mudis] Failed to load snapshot: #{e.class}: #{e.message}"
546
+ end
547
+
548
+ # Installs an at_exit hook to save the snapshot on process exit
549
+ def install_persistence_hook!
550
+ return unless @persistence_enabled
551
+ return if defined?(@persistence_hook_installed) && @persistence_hook_installed
552
+ at_exit { save_snapshot! }
553
+ @persistence_hook_installed = true
554
+ end
555
+ end
556
+
557
+ class << self
558
+ private
559
+ # Collect a JSON/Marshal-safe array of { key, value, expires_in }
560
+ def snapshot_dump
561
+ entries = []
562
+ now = Time.now
563
+ @buckets.times do |idx|
564
+ mutex = @mutexes[idx]
565
+ store = @stores[idx]
566
+ mutex.synchronize do
567
+ store.each do |key, raw|
568
+ exp_at = raw[:expires_at]
569
+ next if exp_at && now > exp_at
570
+ value = decompress_and_deserialize(raw[:value])
571
+ expires_in = exp_at ? (exp_at - now).to_i : nil
572
+ entries << { key: key, value: value, expires_in: expires_in }
573
+ end
574
+ end
575
+ end
576
+ entries
577
+ end
578
+
579
+ # Restore via existing write-path so LRU/limits/compression/TTL are honored
580
+ def snapshot_restore(entries)
581
+ return unless entries && !entries.empty?
582
+ entries.each do |e|
583
+ begin
584
+ write(e[:key], e[:value], expires_in: e[:expires_in])
585
+ rescue => ex
586
+ warn "[Mudis] Failed to restore key #{e[:key].inspect}: #{ex.message}"
587
+ end
588
+ end
589
+ end
590
+
591
+ # Serializer for snapshot persistence
592
+ # Defaults to Marshal if not JSON
593
+ def serializer_for_snapshot
594
+ (@persistence_format || :marshal).to_sym == :json ? JSON : :marshal
595
+ end
596
+
597
+ # Safely writes snapshot data to disk
598
+ # Uses safe write if configured
599
+ def safe_write_snapshot(data)
600
+ path = @persistence_path
601
+ dir = File.dirname(path)
602
+ Dir.mkdir(dir) unless Dir.exist?(dir)
603
+
604
+ payload =
605
+ if (@persistence_format || :marshal).to_sym == :json
606
+ serializer_for_snapshot.dump(data)
607
+ else
608
+ Marshal.dump(data)
609
+ end
610
+
611
+ if @persistence_safe_write
612
+ tmp = "#{path}.tmp-#{$$}-#{Thread.current.object_id}"
613
+ File.open(tmp, "wb") { |f| f.write(payload) }
614
+ File.rename(tmp, path)
615
+ else
616
+ File.open(path, "wb") { |f| f.write(payload) }
617
+ end
618
+ end
619
+
620
+ # Reads snapshot data from disk
621
+ # Uses safe read if configured
622
+ def read_snapshot
623
+ if (@persistence_format || :marshal).to_sym == :json
624
+ serializer_for_snapshot.load(File.binread(@persistence_path))
625
+ else
626
+ Marshal.load(File.binread(@persistence_path))
627
+ end
628
+ end
629
+ end
630
+
521
631
  end
data/lib/mudis_config.rb CHANGED
@@ -10,7 +10,12 @@ class MudisConfig
10
10
  :max_bytes,
11
11
  :buckets,
12
12
  :max_ttl,
13
- :default_ttl
13
+ :default_ttl,
14
+ # Persistence settings
15
+ :persistence_enabled,
16
+ :persistence_path,
17
+ :persistence_format,
18
+ :persistence_safe_write
14
19
 
15
20
  def initialize
16
21
  @serializer = JSON # Default serialization strategy
@@ -21,5 +26,10 @@ class MudisConfig
21
26
  @buckets = nil # use nil to signal fallback to ENV or default
22
27
  @max_ttl = nil # Max TTL for cache entries (optional)
23
28
  @default_ttl = nil # Default TTL for cache entries (optional)
29
+ # Persistence settings
30
+ @persistence_enabled = false # Whether persistence is enabled
31
+ @persistence_path = 'mudis_data' # Default path for persistence files
32
+ @persistence_format = :json # Default persistence file format
33
+ @persistence_safe_write = true # Whether to use safe write for persistence
24
34
  end
25
35
  end
data/lib/mudis_proxy.rb CHANGED
@@ -19,7 +19,7 @@ unless defined?(MudisClient)
19
19
  return
20
20
  end
21
21
 
22
- unless defined?($mudis) && $mudis
22
+ unless defined?($mudis) && $mudis # rubocop:disable Style/GlobalVars
23
23
  warn "[MudisProxy] $mudis not set: proxy not activated"
24
24
  return
25
25
  end
@@ -34,4 +34,4 @@ class << Mudis
34
34
  def reset! = $mudis.reset! # rubocop:disable Style/GlobalVars
35
35
  end
36
36
 
37
- warn "[MudisProxy] Proxy activated: forwarding calls to $mudis"
37
+ warn "[MudisProxy] Proxy activated: forwarding calls to $mudis"
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+ require "fileutils"
4
+
5
+ RSpec.describe "Mudis soft persistence" do
6
+ let(:path) { "tmp/test_mudis_snapshot.dump" }
7
+
8
+ before do
9
+ FileUtils.rm_f(path)
10
+ Mudis.reset!
11
+ Mudis.configure do |c|
12
+ c.persistence_enabled = true
13
+ c.persistence_path = path
14
+ c.persistence_format = :marshal
15
+ end
16
+ end
17
+
18
+ it "saves on exit and loads on next boot" do
19
+ Mudis.write("k1", "v1", expires_in: 60)
20
+ # simulate shutdown
21
+ Mudis.save_snapshot!
22
+ Mudis.reset!
23
+
24
+ # simulate fresh boot (apply config + load)
25
+ Mudis.apply_config! # if not public, re-configure to trigger load
26
+ Mudis.load_snapshot!
27
+
28
+ expect(Mudis.read("k1")).to eq("v1")
29
+ end
30
+
31
+ it "does nothing when file missing" do
32
+ Mudis.reset!
33
+ Mudis.configure { |c| c.persistence_enabled = true; c.persistence_path = path }
34
+ Mudis.load_snapshot! # no file
35
+ expect(Mudis.read("any")).to be_nil
36
+ end
37
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mudis
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.3
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kiebor81
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-10-24 00:00:00.000000000 Z
10
+ date: 2025-10-25 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: climate_control
@@ -67,6 +67,7 @@ files:
67
67
  - spec/mudis_server_spec.rb
68
68
  - spec/mudis_spec.rb
69
69
  - spec/namespace_spec.rb
70
+ - spec/persistence_spec.rb
70
71
  - spec/reset_spec.rb
71
72
  homepage: https://github.com/kiebor81/mudis
72
73
  licenses:
@@ -99,4 +100,5 @@ test_files:
99
100
  - spec/mudis_server_spec.rb
100
101
  - spec/mudis_spec.rb
101
102
  - spec/namespace_spec.rb
103
+ - spec/persistence_spec.rb
102
104
  - spec/reset_spec.rb