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 +4 -4
- data/README.md +11 -5
- data/lib/mudis/expiry.rb +53 -0
- data/lib/mudis/lru.rb +66 -0
- data/lib/mudis/metrics.rb +42 -0
- data/lib/mudis/namespace.rb +47 -0
- data/lib/mudis/persistence.rb +113 -0
- data/lib/mudis/version.rb +1 -1
- data/lib/mudis.rb +15 -285
- data/lib/mudis_client.rb +63 -21
- data/lib/mudis_ipc_config.rb +13 -0
- data/lib/mudis_proxy.rb +2 -0
- data/lib/mudis_server.rb +73 -54
- data/sig/mudis.rbs +6 -0
- data/sig/mudis_client.rbs +3 -1
- data/sig/mudis_expiry.rbs +13 -0
- data/sig/mudis_ipc_config.rbs +10 -0
- data/sig/mudis_lru.rbs +21 -0
- data/sig/mudis_metrics.rbs +11 -0
- data/sig/mudis_namespace.rbs +13 -0
- data/sig/mudis_persistence.rbs +19 -0
- data/sig/mudis_server.rbs +12 -2
- data/spec/api_compatibility_spec.rb +155 -0
- data/spec/modules/expiry_spec.rb +170 -0
- data/spec/modules/lru_spec.rb +149 -0
- data/spec/modules/metrics_spec.rb +105 -0
- data/spec/modules/namespace_spec.rb +157 -0
- data/spec/modules/persistence_spec.rb +125 -0
- data/spec/mudis_client_spec.rb +15 -5
- data/spec/mudis_server_spec.rb +23 -17
- metadata +46 -2
data/lib/mudis.rb
CHANGED
|
@@ -5,10 +5,21 @@ require "thread" # rubocop:disable Lint/RedundantRequireStatement
|
|
|
5
5
|
require "zlib"
|
|
6
6
|
|
|
7
7
|
require_relative "mudis_config"
|
|
8
|
+
require_relative "mudis/lru"
|
|
9
|
+
require_relative "mudis/persistence"
|
|
10
|
+
require_relative "mudis/metrics"
|
|
11
|
+
require_relative "mudis/namespace"
|
|
12
|
+
require_relative "mudis/expiry"
|
|
8
13
|
|
|
9
14
|
# Mudis is a thread-safe, in-memory, sharded, LRU cache with optional compression and expiry.
|
|
10
15
|
# It is designed for high concurrency and performance within a Ruby application.
|
|
11
16
|
class Mudis # rubocop:disable Metrics/ClassLength
|
|
17
|
+
extend LRU
|
|
18
|
+
extend Persistence
|
|
19
|
+
extend Metrics
|
|
20
|
+
extend Namespace
|
|
21
|
+
extend Expiry
|
|
22
|
+
|
|
12
23
|
# --- Global Configuration and State ---
|
|
13
24
|
|
|
14
25
|
@serializer = JSON # Default serializer (can be changed to Marshal or Oj)
|
|
@@ -20,6 +31,8 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
20
31
|
@max_ttl = nil # Optional maximum TTL for cache entries
|
|
21
32
|
@default_ttl = nil # Default TTL for cache entries if not specified
|
|
22
33
|
|
|
34
|
+
# --- Configuration Management ---
|
|
35
|
+
|
|
23
36
|
class << self
|
|
24
37
|
attr_accessor :serializer, :compress, :hard_memory_limit, :max_ttl, :default_ttl
|
|
25
38
|
attr_reader :max_bytes, :max_value_bytes
|
|
@@ -76,35 +89,6 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
76
89
|
raise ArgumentError, "default_ttl must be > 0" if config.default_ttl && config.default_ttl <= 0
|
|
77
90
|
end
|
|
78
91
|
|
|
79
|
-
# Returns a snapshot of metrics (thread-safe)
|
|
80
|
-
def metrics # rubocop:disable Metrics/MethodLength
|
|
81
|
-
@metrics_mutex.synchronize do
|
|
82
|
-
{
|
|
83
|
-
hits: @metrics[:hits],
|
|
84
|
-
misses: @metrics[:misses],
|
|
85
|
-
evictions: @metrics[:evictions],
|
|
86
|
-
rejected: @metrics[:rejected],
|
|
87
|
-
total_memory: current_memory_bytes,
|
|
88
|
-
least_touched: least_touched(10),
|
|
89
|
-
buckets: buckets.times.map do |idx|
|
|
90
|
-
{
|
|
91
|
-
index: idx,
|
|
92
|
-
keys: @stores[idx].size,
|
|
93
|
-
memory_bytes: @current_bytes[idx],
|
|
94
|
-
lru_size: @lru_nodes[idx].size
|
|
95
|
-
}
|
|
96
|
-
end
|
|
97
|
-
}
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# Resets metric counters (thread-safe)
|
|
102
|
-
def reset_metrics!
|
|
103
|
-
@metrics_mutex.synchronize do
|
|
104
|
-
@metrics = { hits: 0, misses: 0, evictions: 0, rejected: 0 }
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
|
|
108
92
|
# Fully resets all internal state (except config)
|
|
109
93
|
def reset!
|
|
110
94
|
stop_expiry_thread
|
|
@@ -138,17 +122,6 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
138
122
|
end
|
|
139
123
|
end
|
|
140
124
|
|
|
141
|
-
# Node structure for the LRU doubly-linked list
|
|
142
|
-
class LRUNode
|
|
143
|
-
attr_accessor :key, :prev, :next
|
|
144
|
-
|
|
145
|
-
def initialize(key)
|
|
146
|
-
@key = key
|
|
147
|
-
@prev = nil
|
|
148
|
-
@next = nil
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
|
|
152
125
|
# Number of cache buckets (shards). Default: 32
|
|
153
126
|
def self.buckets
|
|
154
127
|
return @buckets if @buckets
|
|
@@ -172,29 +145,9 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
172
145
|
@expiry_thread = nil # Background thread for expiry cleanup
|
|
173
146
|
@hard_memory_limit = false # Whether to enforce hard memory cap
|
|
174
147
|
|
|
175
|
-
|
|
176
|
-
# Starts a thread that periodically removes expired entries
|
|
177
|
-
def start_expiry_thread(interval: 60)
|
|
178
|
-
return if @expiry_thread&.alive?
|
|
179
|
-
|
|
180
|
-
@stop_expiry = false
|
|
181
|
-
@expiry_thread = Thread.new do
|
|
182
|
-
loop do
|
|
183
|
-
break if @stop_expiry
|
|
184
|
-
|
|
185
|
-
sleep interval
|
|
186
|
-
cleanup_expired!
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
# Signals and joins the expiry thread
|
|
192
|
-
def stop_expiry_thread
|
|
193
|
-
@stop_expiry = true
|
|
194
|
-
@expiry_thread&.join
|
|
195
|
-
@expiry_thread = nil
|
|
196
|
-
end
|
|
148
|
+
# --- Core Cache Operations ---
|
|
197
149
|
|
|
150
|
+
class << self
|
|
198
151
|
# Computes which bucket a key belongs to
|
|
199
152
|
def bucket_index(key)
|
|
200
153
|
key.hash % buckets
|
|
@@ -367,20 +320,6 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
367
320
|
end
|
|
368
321
|
end
|
|
369
322
|
|
|
370
|
-
# Removes expired keys across all buckets
|
|
371
|
-
def cleanup_expired!
|
|
372
|
-
now = Time.now
|
|
373
|
-
buckets.times do |idx|
|
|
374
|
-
mutex = @mutexes[idx]
|
|
375
|
-
store = @stores[idx]
|
|
376
|
-
mutex.synchronize do
|
|
377
|
-
store.keys.each do |key| # rubocop:disable Style/HashEachMethods
|
|
378
|
-
evict_key(idx, key) if store[key][:expires_at] && now > store[key][:expires_at]
|
|
379
|
-
end
|
|
380
|
-
end
|
|
381
|
-
end
|
|
382
|
-
end
|
|
383
|
-
|
|
384
323
|
# Returns an array of all cache keys
|
|
385
324
|
def all_keys
|
|
386
325
|
keys = []
|
|
@@ -392,30 +331,6 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
392
331
|
keys
|
|
393
332
|
end
|
|
394
333
|
|
|
395
|
-
# Returns all keys in a specific namespace
|
|
396
|
-
def keys(namespace:)
|
|
397
|
-
raise ArgumentError, "namespace is required" unless namespace
|
|
398
|
-
|
|
399
|
-
prefix = "#{namespace}:"
|
|
400
|
-
all_keys.select { |key| key.start_with?(prefix) }.map { |key| key.delete_prefix(prefix) }
|
|
401
|
-
end
|
|
402
|
-
|
|
403
|
-
# Clears all keys in a specific namespace
|
|
404
|
-
def clear_namespace(namespace:)
|
|
405
|
-
raise ArgumentError, "namespace is required" unless namespace
|
|
406
|
-
|
|
407
|
-
prefix = "#{namespace}:"
|
|
408
|
-
buckets.times do |idx|
|
|
409
|
-
mutex = @mutexes[idx]
|
|
410
|
-
store = @stores[idx]
|
|
411
|
-
|
|
412
|
-
mutex.synchronize do
|
|
413
|
-
keys_to_delete = store.keys.select { |key| key.start_with?(prefix) }
|
|
414
|
-
keys_to_delete.each { |key| evict_key(idx, key) }
|
|
415
|
-
end
|
|
416
|
-
end
|
|
417
|
-
end
|
|
418
|
-
|
|
419
334
|
# Returns the least-touched keys across all buckets
|
|
420
335
|
def least_touched(n = 10) # rubocop:disable Metrics/MethodLength,Naming/MethodParameterName
|
|
421
336
|
keys_with_touches = []
|
|
@@ -444,15 +359,6 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
444
359
|
@max_bytes
|
|
445
360
|
end
|
|
446
361
|
|
|
447
|
-
# Executes a block with a specific namespace, restoring the old namespace afterwards
|
|
448
|
-
def with_namespace(namespace)
|
|
449
|
-
old_ns = Thread.current[:mudis_namespace]
|
|
450
|
-
Thread.current[:mudis_namespace] = namespace
|
|
451
|
-
yield
|
|
452
|
-
ensure
|
|
453
|
-
Thread.current[:mudis_namespace] = old_ns
|
|
454
|
-
end
|
|
455
|
-
|
|
456
362
|
private
|
|
457
363
|
|
|
458
364
|
# Decompresses and deserializes a raw value
|
|
@@ -460,181 +366,5 @@ class Mudis # rubocop:disable Metrics/ClassLength
|
|
|
460
366
|
val = compress ? Zlib::Inflate.inflate(raw) : raw
|
|
461
367
|
serializer.load(val)
|
|
462
368
|
end
|
|
463
|
-
|
|
464
|
-
# Thread-safe metric increment
|
|
465
|
-
def metric(name)
|
|
466
|
-
@metrics_mutex.synchronize { @metrics[name] += 1 }
|
|
467
|
-
end
|
|
468
|
-
|
|
469
|
-
# Removes a key from storage and LRU
|
|
470
|
-
def evict_key(idx, key)
|
|
471
|
-
store = @stores[idx]
|
|
472
|
-
entry = store.delete(key)
|
|
473
|
-
return unless entry
|
|
474
|
-
|
|
475
|
-
@current_bytes[idx] -= (key.bytesize + entry[:value].bytesize)
|
|
476
|
-
|
|
477
|
-
node = @lru_nodes[idx].delete(key)
|
|
478
|
-
remove_node(idx, node) if node
|
|
479
|
-
end
|
|
480
|
-
|
|
481
|
-
# Inserts a key at the head of the LRU list
|
|
482
|
-
def insert_lru(idx, key)
|
|
483
|
-
node = LRUNode.new(key)
|
|
484
|
-
node.next = @lru_heads[idx]
|
|
485
|
-
@lru_heads[idx].prev = node if @lru_heads[idx]
|
|
486
|
-
@lru_heads[idx] = node
|
|
487
|
-
@lru_tails[idx] ||= node
|
|
488
|
-
@lru_nodes[idx][key] = node
|
|
489
|
-
end
|
|
490
|
-
|
|
491
|
-
# Promotes a key to the front of the LRU list
|
|
492
|
-
def promote_lru(idx, key)
|
|
493
|
-
node = @lru_nodes[idx][key]
|
|
494
|
-
return unless node && @lru_heads[idx] != node
|
|
495
|
-
|
|
496
|
-
remove_node(idx, node)
|
|
497
|
-
insert_lru(idx, key)
|
|
498
|
-
end
|
|
499
|
-
|
|
500
|
-
# Removes a node from the LRU list
|
|
501
|
-
def remove_node(idx, node)
|
|
502
|
-
if node.prev
|
|
503
|
-
node.prev.next = node.next
|
|
504
|
-
else
|
|
505
|
-
@lru_heads[idx] = node.next
|
|
506
|
-
end
|
|
507
|
-
|
|
508
|
-
if node.next
|
|
509
|
-
node.next.prev = node.prev
|
|
510
|
-
else
|
|
511
|
-
@lru_tails[idx] = node.prev
|
|
512
|
-
end
|
|
513
|
-
end
|
|
514
|
-
|
|
515
|
-
# Namespaces a key with an optional namespace
|
|
516
|
-
def namespaced_key(key, namespace = nil)
|
|
517
|
-
ns = namespace || Thread.current[:mudis_namespace]
|
|
518
|
-
ns ? "#{ns}:#{key}" : key
|
|
519
|
-
end
|
|
520
|
-
|
|
521
|
-
# Calculates the effective TTL for an entry, respecting max_ttl if set
|
|
522
|
-
def effective_ttl(expires_in)
|
|
523
|
-
ttl = expires_in || @default_ttl
|
|
524
|
-
return nil unless ttl
|
|
525
|
-
return ttl unless @max_ttl
|
|
526
|
-
|
|
527
|
-
[ttl, @max_ttl].min
|
|
528
|
-
end
|
|
529
|
-
end
|
|
530
|
-
|
|
531
|
-
class << self
|
|
532
|
-
# Saves the current cache state to disk for persistence
|
|
533
|
-
def save_snapshot!
|
|
534
|
-
return unless @persistence_enabled
|
|
535
|
-
|
|
536
|
-
data = snapshot_dump
|
|
537
|
-
safe_write_snapshot(data)
|
|
538
|
-
rescue StandardError => e
|
|
539
|
-
warn "[Mudis] Failed to save snapshot: #{e.class}: #{e.message}"
|
|
540
|
-
end
|
|
541
|
-
|
|
542
|
-
# Loads the cache state from disk for persistence
|
|
543
|
-
def load_snapshot!
|
|
544
|
-
return unless @persistence_enabled
|
|
545
|
-
return unless File.exist?(@persistence_path)
|
|
546
|
-
|
|
547
|
-
data = read_snapshot
|
|
548
|
-
snapshot_restore(data)
|
|
549
|
-
rescue StandardError => e
|
|
550
|
-
warn "[Mudis] Failed to load snapshot: #{e.class}: #{e.message}"
|
|
551
|
-
end
|
|
552
|
-
|
|
553
|
-
# Installs an at_exit hook to save the snapshot on process exit
|
|
554
|
-
def install_persistence_hook!
|
|
555
|
-
return unless @persistence_enabled
|
|
556
|
-
return if defined?(@persistence_hook_installed) && @persistence_hook_installed
|
|
557
|
-
|
|
558
|
-
at_exit { save_snapshot! }
|
|
559
|
-
@persistence_hook_installed = true
|
|
560
|
-
end
|
|
561
|
-
end
|
|
562
|
-
|
|
563
|
-
class << self
|
|
564
|
-
private
|
|
565
|
-
|
|
566
|
-
# Collect a JSON/Marshal-safe array of { key, value, expires_in }
|
|
567
|
-
def snapshot_dump # rubocop:disable Metrics/MethodLength
|
|
568
|
-
entries = []
|
|
569
|
-
now = Time.now
|
|
570
|
-
@buckets.times do |idx|
|
|
571
|
-
mutex = @mutexes[idx]
|
|
572
|
-
store = @stores[idx]
|
|
573
|
-
mutex.synchronize do
|
|
574
|
-
store.each do |key, raw|
|
|
575
|
-
exp_at = raw[:expires_at]
|
|
576
|
-
next if exp_at && now > exp_at
|
|
577
|
-
|
|
578
|
-
value = decompress_and_deserialize(raw[:value])
|
|
579
|
-
expires_in = exp_at ? (exp_at - now).to_i : nil
|
|
580
|
-
entries << { key: key, value: value, expires_in: expires_in }
|
|
581
|
-
end
|
|
582
|
-
end
|
|
583
|
-
end
|
|
584
|
-
entries
|
|
585
|
-
end
|
|
586
|
-
|
|
587
|
-
# Restore via existing write-path so LRU/limits/compression/TTL are honored
|
|
588
|
-
def snapshot_restore(entries)
|
|
589
|
-
return unless entries && !entries.empty?
|
|
590
|
-
|
|
591
|
-
entries.each do |e|
|
|
592
|
-
begin # rubocop:disable Style/RedundantBegin
|
|
593
|
-
write(e[:key], e[:value], expires_in: e[:expires_in])
|
|
594
|
-
rescue StandardError => ex
|
|
595
|
-
warn "[Mudis] Failed to restore key #{e[:key].inspect}: #{ex.message}"
|
|
596
|
-
end
|
|
597
|
-
end
|
|
598
|
-
end
|
|
599
|
-
|
|
600
|
-
# Serializer for snapshot persistence
|
|
601
|
-
# Defaults to Marshal if not JSON
|
|
602
|
-
def serializer_for_snapshot
|
|
603
|
-
(@persistence_format || :marshal).to_sym == :json ? JSON : :marshal
|
|
604
|
-
end
|
|
605
|
-
|
|
606
|
-
# Safely writes snapshot data to disk
|
|
607
|
-
# Uses safe write if configured
|
|
608
|
-
def safe_write_snapshot(data) # rubocop:disable Metrics/MethodLength
|
|
609
|
-
path = @persistence_path
|
|
610
|
-
dir = File.dirname(path)
|
|
611
|
-
Dir.mkdir(dir) unless Dir.exist?(dir)
|
|
612
|
-
|
|
613
|
-
payload =
|
|
614
|
-
if (@persistence_format || :marshal).to_sym == :json
|
|
615
|
-
serializer_for_snapshot.dump(data)
|
|
616
|
-
else
|
|
617
|
-
Marshal.dump(data)
|
|
618
|
-
end
|
|
619
|
-
|
|
620
|
-
if @persistence_safe_write
|
|
621
|
-
tmp = "#{path}.tmp-#{$$}-#{Thread.current.object_id}"
|
|
622
|
-
File.open(tmp, "wb") { |f| f.write(payload) }
|
|
623
|
-
File.rename(tmp, path)
|
|
624
|
-
else
|
|
625
|
-
File.open(path, "wb") { |f| f.write(payload) }
|
|
626
|
-
end
|
|
627
|
-
end
|
|
628
|
-
|
|
629
|
-
# Reads snapshot data from disk
|
|
630
|
-
# Uses safe read if configured
|
|
631
|
-
def read_snapshot
|
|
632
|
-
if (@persistence_format || :marshal).to_sym == :json
|
|
633
|
-
serializer_for_snapshot.load(File.binread(@persistence_path))
|
|
634
|
-
else
|
|
635
|
-
## safe to use Marshal here as we control the file
|
|
636
|
-
Marshal.load(File.binread(@persistence_path)) # rubocop:disable Security/MarshalLoad
|
|
637
|
-
end
|
|
638
|
-
end
|
|
639
369
|
end
|
|
640
370
|
end
|
data/lib/mudis_client.rb
CHANGED
|
@@ -2,64 +2,106 @@
|
|
|
2
2
|
|
|
3
3
|
require "socket"
|
|
4
4
|
require "json"
|
|
5
|
+
require_relative "mudis_ipc_config"
|
|
5
6
|
|
|
6
|
-
#
|
|
7
|
+
# Thread-safe client for communicating with the MudisServer
|
|
8
|
+
# Automatically uses UNIX sockets on Linux/macOS and TCP on Windows
|
|
7
9
|
class MudisClient
|
|
8
|
-
|
|
10
|
+
include MudisIPCConfig
|
|
9
11
|
|
|
10
12
|
def initialize
|
|
11
13
|
@mutex = Mutex.new
|
|
12
14
|
end
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
# Open a connection to the server (TCP or UNIX)
|
|
17
|
+
def open_connection
|
|
18
|
+
if MudisIPCConfig.use_tcp?
|
|
19
|
+
TCPSocket.new(TCP_HOST, TCP_PORT)
|
|
20
|
+
else
|
|
21
|
+
UNIXSocket.open(SOCKET_PATH)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Send a request to the MudisServer and return the response
|
|
26
|
+
# @param payload [Hash] The request payload
|
|
27
|
+
# @return [Object] The response value from the server
|
|
28
|
+
def request(payload) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
15
29
|
@mutex.synchronize do
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
sock = open_connection
|
|
31
|
+
sock.puts(JSON.dump(payload))
|
|
32
|
+
response = sock.gets
|
|
33
|
+
sock.close
|
|
34
|
+
|
|
35
|
+
return nil unless response
|
|
36
|
+
|
|
37
|
+
res = JSON.parse(response, symbolize_names: true)
|
|
38
|
+
raise res[:error] unless res[:ok]
|
|
39
|
+
|
|
40
|
+
res[:value]
|
|
41
|
+
rescue Errno::ENOENT, Errno::ECONNREFUSED
|
|
42
|
+
warn "[MudisClient] Cannot connect to MudisServer. Is it running?"
|
|
43
|
+
nil
|
|
44
|
+
rescue JSON::ParserError
|
|
45
|
+
warn "[MudisClient] Invalid JSON response from server"
|
|
46
|
+
nil
|
|
47
|
+
rescue IOError, SystemCallError => e
|
|
48
|
+
warn "[MudisClient] Connection error: #{e.message}"
|
|
25
49
|
nil
|
|
26
50
|
end
|
|
27
51
|
end
|
|
28
52
|
|
|
53
|
+
# --- Forwarded Mudis methods ---
|
|
54
|
+
|
|
55
|
+
# Read a value from the Mudis server
|
|
29
56
|
def read(key, namespace: nil)
|
|
30
|
-
|
|
57
|
+
command("read", key:, namespace:)
|
|
31
58
|
end
|
|
32
59
|
|
|
60
|
+
# Write a value to the Mudis server
|
|
33
61
|
def write(key, value, expires_in: nil, namespace: nil)
|
|
34
|
-
|
|
62
|
+
command("write", key:, value:, ttl: expires_in, namespace:)
|
|
35
63
|
end
|
|
36
64
|
|
|
65
|
+
# Delete a value from the Mudis server
|
|
37
66
|
def delete(key, namespace: nil)
|
|
38
|
-
|
|
67
|
+
command("delete", key:, namespace:)
|
|
39
68
|
end
|
|
40
69
|
|
|
70
|
+
# Check if a key exists in the Mudis server
|
|
41
71
|
def exists?(key, namespace: nil)
|
|
42
|
-
|
|
72
|
+
command("exists", key:, namespace:)
|
|
43
73
|
end
|
|
44
74
|
|
|
75
|
+
# Fetch a value, computing and storing it if not present
|
|
45
76
|
def fetch(key, expires_in: nil, namespace: nil)
|
|
46
|
-
val = read(key, namespace:
|
|
77
|
+
val = read(key, namespace:)
|
|
47
78
|
return val if val
|
|
48
79
|
|
|
49
80
|
new_val = yield
|
|
50
|
-
write(key, new_val, expires_in
|
|
81
|
+
write(key, new_val, expires_in:, namespace:)
|
|
51
82
|
new_val
|
|
52
83
|
end
|
|
53
84
|
|
|
85
|
+
# Retrieve metrics from the Mudis server
|
|
54
86
|
def metrics
|
|
55
|
-
|
|
87
|
+
command("metrics")
|
|
56
88
|
end
|
|
57
89
|
|
|
90
|
+
# Reset metrics on the Mudis server
|
|
58
91
|
def reset_metrics!
|
|
59
|
-
|
|
92
|
+
command("reset_metrics")
|
|
60
93
|
end
|
|
61
94
|
|
|
95
|
+
# Reset the Mudis server cache state
|
|
62
96
|
def reset!
|
|
63
|
-
|
|
97
|
+
command("reset")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# Helper to send a command with options
|
|
103
|
+
def command(cmd, **opts)
|
|
104
|
+
request({ cmd:, **opts })
|
|
64
105
|
end
|
|
106
|
+
|
|
65
107
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shared configuration for IPC mode (server and client)
|
|
4
|
+
module MudisIPCConfig
|
|
5
|
+
SOCKET_PATH = "/tmp/mudis.sock"
|
|
6
|
+
TCP_HOST = "127.0.0.1"
|
|
7
|
+
TCP_PORT = 9876
|
|
8
|
+
|
|
9
|
+
# Check if TCP mode should be used (Windows or forced via ENV)
|
|
10
|
+
def self.use_tcp?
|
|
11
|
+
ENV["MUDIS_FORCE_TCP"] == "true" || Gem.win_platform?
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/mudis_proxy.rb
CHANGED
|
@@ -24,6 +24,8 @@ unless defined?($mudis) && $mudis # rubocop:disable Style/GlobalVars
|
|
|
24
24
|
return
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# --- Proxy method forwarding ---
|
|
28
|
+
|
|
27
29
|
class << Mudis
|
|
28
30
|
def read(*a, **k) = $mudis.read(*a, **k) # rubocop:disable Naming/MethodParameterName,Style/GlobalVars
|
|
29
31
|
def write(*a, **k) = $mudis.write(*a, **k) # rubocop:disable Naming/MethodParameterName,Style/GlobalVars
|
data/lib/mudis_server.rb
CHANGED
|
@@ -3,19 +3,56 @@
|
|
|
3
3
|
require "socket"
|
|
4
4
|
require "json"
|
|
5
5
|
require_relative "mudis"
|
|
6
|
+
require_relative "mudis_ipc_config"
|
|
6
7
|
|
|
7
|
-
#
|
|
8
|
+
# Socket server for handling Mudis operations via IPC mode
|
|
9
|
+
# Automatically uses UNIX sockets on Linux/macOS and TCP on Windows
|
|
8
10
|
class MudisServer
|
|
9
|
-
|
|
11
|
+
include MudisIPCConfig
|
|
12
|
+
|
|
13
|
+
# Define command handlers mapping
|
|
14
|
+
# Each command maps to a lambda that takes a request hash and performs the corresponding Mudis operation.
|
|
15
|
+
COMMANDS = {
|
|
16
|
+
"read" => ->(r) { Mudis.read(r[:key], namespace: r[:namespace]) },
|
|
17
|
+
"write" => ->(r) { Mudis.write(r[:key], r[:value], expires_in: r[:ttl], namespace: r[:namespace]) },
|
|
18
|
+
"delete" => ->(r) { Mudis.delete(r[:key], namespace: r[:namespace]) },
|
|
19
|
+
"exists" => ->(r) { Mudis.exists?(r[:key], namespace: r[:namespace]) },
|
|
20
|
+
"fetch" => ->(r) { Mudis.fetch(r[:key], expires_in: r[:ttl], namespace: r[:namespace]) { r[:fallback] } },
|
|
21
|
+
"metrics" => ->(_) { Mudis.metrics },
|
|
22
|
+
"reset_metrics" => ->(_) { Mudis.reset_metrics! },
|
|
23
|
+
"reset" => ->(_) { Mudis.reset! }
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# Start the MudisServer
|
|
27
|
+
# Automatically selects TCP on Windows, UNIX sockets elsewhere
|
|
28
|
+
# This will run in a separate thread and handle incoming client connections.
|
|
29
|
+
def self.start!
|
|
30
|
+
if MudisIPCConfig.use_tcp?
|
|
31
|
+
start_tcp_server!
|
|
32
|
+
else
|
|
33
|
+
start_unix_server!
|
|
34
|
+
end
|
|
35
|
+
end
|
|
10
36
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
37
|
+
# Start TCP server (for Windows or development)
|
|
38
|
+
def self.start_tcp_server!
|
|
39
|
+
warn "[MudisServer] Using TCP mode - recommended for development only"
|
|
40
|
+
server = TCPServer.new(TCP_HOST, TCP_PORT)
|
|
41
|
+
puts "[MudisServer] Listening on TCP #{TCP_HOST}:#{TCP_PORT}"
|
|
42
|
+
accept_connections(server)
|
|
43
|
+
end
|
|
14
44
|
|
|
45
|
+
# Start UNIX socket server (production mode for Linux/macOS)
|
|
46
|
+
def self.start_unix_server! # rubocop:disable Metrics/MethodLength
|
|
47
|
+
File.unlink(SOCKET_PATH) if File.exist?(SOCKET_PATH)
|
|
15
48
|
server = UNIXServer.new(SOCKET_PATH)
|
|
16
49
|
server.listen(128)
|
|
17
|
-
puts "[MudisServer] Listening on #{SOCKET_PATH}"
|
|
50
|
+
puts "[MudisServer] Listening on UNIX socket #{SOCKET_PATH}"
|
|
51
|
+
accept_connections(server)
|
|
52
|
+
end
|
|
18
53
|
|
|
54
|
+
# Accept connections in a loop (works for both TCP and UNIX)
|
|
55
|
+
def self.accept_connections(server)
|
|
19
56
|
Thread.new do
|
|
20
57
|
loop do
|
|
21
58
|
client = server.accept
|
|
@@ -26,56 +63,38 @@ class MudisServer
|
|
|
26
63
|
end
|
|
27
64
|
end
|
|
28
65
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
sock.puts(JSON.dump({ ok: true, value: result }))
|
|
45
|
-
|
|
46
|
-
when "write"
|
|
47
|
-
Mudis.write(key, val, expires_in: ttl, namespace: ns)
|
|
48
|
-
sock.puts(JSON.dump({ ok: true }))
|
|
49
|
-
|
|
50
|
-
when "delete"
|
|
51
|
-
Mudis.delete(key, namespace: ns)
|
|
52
|
-
sock.puts(JSON.dump({ ok: true }))
|
|
53
|
-
|
|
54
|
-
when "exists"
|
|
55
|
-
sock.puts(JSON.dump({ ok: true, value: Mudis.exists?(key, namespace: ns) }))
|
|
56
|
-
|
|
57
|
-
when "fetch"
|
|
58
|
-
result = Mudis.fetch(key, expires_in: ttl, namespace: ns) { req[:fallback] }
|
|
59
|
-
sock.puts(JSON.dump({ ok: true, value: result }))
|
|
60
|
-
|
|
61
|
-
when "metrics"
|
|
62
|
-
sock.puts(JSON.dump({ ok: true, value: Mudis.metrics }))
|
|
66
|
+
# Handle a single client connection
|
|
67
|
+
# Reads the request, processes it, and sends back the response
|
|
68
|
+
# @param socket [Socket] The client socket (TCP or UNIX)
|
|
69
|
+
# @return [void]
|
|
70
|
+
def self.handle_client(socket)
|
|
71
|
+
request = JSON.parse(socket.gets, symbolize_names: true)
|
|
72
|
+
return unless request
|
|
73
|
+
|
|
74
|
+
response = process_request(request)
|
|
75
|
+
write_response(socket, ok: true, value: response)
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
write_response(socket, ok: false, error: e.message)
|
|
78
|
+
ensure
|
|
79
|
+
socket.close
|
|
80
|
+
end
|
|
63
81
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
82
|
+
# Process a request hash and return the result
|
|
83
|
+
# Raises an error if the command is unknown
|
|
84
|
+
# @param req [Hash] The request hash containing :cmd and other parameters
|
|
85
|
+
# @return [Object] The result of the command execution
|
|
86
|
+
def self.process_request(req)
|
|
87
|
+
handler = COMMANDS[req[:cmd]]
|
|
88
|
+
raise "Unknown command: #{req[:cmd]}" unless handler
|
|
67
89
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
sock.puts(JSON.dump({ ok: true }))
|
|
90
|
+
handler.call(req)
|
|
91
|
+
end
|
|
71
92
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
sock.close
|
|
79
|
-
end
|
|
93
|
+
# Write a response to the client socket
|
|
94
|
+
# @param socket [Socket] The client socket
|
|
95
|
+
# @param payload [Hash] The response payload
|
|
96
|
+
# @return [void]
|
|
97
|
+
def self.write_response(socket, payload)
|
|
98
|
+
socket.puts(JSON.dump(payload))
|
|
80
99
|
end
|
|
81
100
|
end
|
data/sig/mudis.rbs
CHANGED
data/sig/mudis_client.rbs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
class MudisClient
|
|
2
|
-
|
|
2
|
+
include MudisIPCConfig
|
|
3
3
|
|
|
4
4
|
def initialize: () -> void
|
|
5
5
|
|
|
6
|
+
def open_connection: () -> (TCPSocket | UNIXSocket)
|
|
7
|
+
|
|
6
8
|
def request: (payload: { cmd: String, key?: String, value?: untyped, ttl?: Integer?, namespace?: String? }) -> untyped
|
|
7
9
|
|
|
8
10
|
def read: (key: String, namespace?: String?) -> untyped
|