mudis-ql 0.1.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 +7 -0
- data/README.md +596 -0
- data/lib/mudis-ql/metrics_scope.rb +175 -0
- data/lib/mudis-ql/scope.rb +184 -0
- data/lib/mudis-ql/store.rb +79 -0
- data/lib/mudis-ql/version.rb +5 -0
- data/lib/mudis-ql.rb +49 -0
- data/spec/mudis-ql/error_handling_spec.rb +330 -0
- data/spec/mudis-ql/integration_spec.rb +337 -0
- data/spec/mudis-ql/metrics_scope_spec.rb +332 -0
- data/spec/mudis-ql/performance_spec.rb +295 -0
- data/spec/mudis-ql/scope_spec.rb +169 -0
- data/spec/mudis-ql/store_spec.rb +77 -0
- data/spec/mudis-ql_spec.rb +52 -0
- metadata +118 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MudisQL
|
|
4
|
+
# MetricsScope provides queryable access to mudis metrics data
|
|
5
|
+
class MetricsScope
|
|
6
|
+
attr_reader :metrics_data
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@metrics_data = Mudis.metrics
|
|
10
|
+
@conditions = []
|
|
11
|
+
@order_by = nil
|
|
12
|
+
@order_direction = :asc
|
|
13
|
+
@limit_value = nil
|
|
14
|
+
@offset_value = 0
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Get top-level metrics (hits, misses, evictions, etc.)
|
|
18
|
+
#
|
|
19
|
+
# @return [Hash] top-level metrics
|
|
20
|
+
def summary
|
|
21
|
+
@metrics_data.reject { |k, _| [:least_touched, :buckets].include?(k) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Query least touched keys
|
|
25
|
+
#
|
|
26
|
+
# @return [Scope] a new scope for least_touched array
|
|
27
|
+
def least_touched
|
|
28
|
+
data = @metrics_data[:least_touched] || []
|
|
29
|
+
create_scope_for_array(data.map { |key, count| { key: key, access_count: count } })
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Query bucket metrics
|
|
33
|
+
#
|
|
34
|
+
# @return [Scope] a new scope for buckets array
|
|
35
|
+
def buckets
|
|
36
|
+
data = @metrics_data[:buckets] || []
|
|
37
|
+
# Normalize to symbol keys
|
|
38
|
+
normalized = data.map do |bucket|
|
|
39
|
+
bucket.transform_keys(&:to_sym)
|
|
40
|
+
end
|
|
41
|
+
create_scope_for_array(normalized)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get total number of keys across all buckets
|
|
45
|
+
#
|
|
46
|
+
# @return [Integer] total key count
|
|
47
|
+
def total_keys
|
|
48
|
+
buckets_data = @metrics_data[:buckets] || []
|
|
49
|
+
buckets_data.sum { |b| (b[:keys] || b["keys"] || 0) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get total memory across all buckets
|
|
53
|
+
#
|
|
54
|
+
# @return [Integer] total memory in bytes
|
|
55
|
+
def total_memory
|
|
56
|
+
@metrics_data[:total_memory] || 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Get hit rate as a percentage
|
|
60
|
+
#
|
|
61
|
+
# @return [Float] hit rate percentage
|
|
62
|
+
def hit_rate
|
|
63
|
+
hits = @metrics_data[:hits] || 0
|
|
64
|
+
misses = @metrics_data[:misses] || 0
|
|
65
|
+
total = hits + misses
|
|
66
|
+
return 0.0 if total.zero?
|
|
67
|
+
|
|
68
|
+
(hits.to_f / total * 100).round(2)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Get cache efficiency metrics
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash] efficiency statistics
|
|
74
|
+
def efficiency
|
|
75
|
+
{
|
|
76
|
+
hit_rate: hit_rate,
|
|
77
|
+
miss_rate: (100 - hit_rate).round(2),
|
|
78
|
+
eviction_rate: eviction_rate,
|
|
79
|
+
rejection_rate: rejection_rate
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Find buckets with high memory usage
|
|
84
|
+
#
|
|
85
|
+
# @param threshold [Integer] memory threshold in bytes
|
|
86
|
+
# @return [Array<Hash>] buckets exceeding threshold
|
|
87
|
+
def high_memory_buckets(threshold)
|
|
88
|
+
buckets_data = @metrics_data[:buckets] || []
|
|
89
|
+
buckets_data.select { |b| ((b[:memory_bytes] || b["memory_bytes"]) || 0) > threshold }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Find buckets with many keys
|
|
93
|
+
#
|
|
94
|
+
# @param threshold [Integer] key count threshold
|
|
95
|
+
# @return [Array<Hash>] buckets exceeding threshold
|
|
96
|
+
def high_key_buckets(threshold)
|
|
97
|
+
buckets_data = @metrics_data[:buckets] || []
|
|
98
|
+
buckets_data.select { |b| ((b[:keys] || b["keys"]) || 0) > threshold }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Get bucket distribution statistics
|
|
102
|
+
#
|
|
103
|
+
# @return [Hash] distribution stats
|
|
104
|
+
def bucket_distribution
|
|
105
|
+
buckets_data = @metrics_data[:buckets] || []
|
|
106
|
+
return default_distribution if buckets_data.empty?
|
|
107
|
+
|
|
108
|
+
key_counts = buckets_data.map { |b| (b[:keys] || b["keys"]) || 0 }
|
|
109
|
+
memory_values = buckets_data.map { |b| (b[:memory_bytes] || b["memory_bytes"]) || 0 }
|
|
110
|
+
|
|
111
|
+
{
|
|
112
|
+
total_buckets: buckets_data.size,
|
|
113
|
+
avg_keys_per_bucket: (key_counts.sum.to_f / buckets_data.size).round(2),
|
|
114
|
+
max_keys_per_bucket: key_counts.max || 0,
|
|
115
|
+
min_keys_per_bucket: key_counts.min || 0,
|
|
116
|
+
avg_memory_per_bucket: (memory_values.sum.to_f / buckets_data.size).round(2),
|
|
117
|
+
max_memory_per_bucket: memory_values.max || 0,
|
|
118
|
+
min_memory_per_bucket: memory_values.min || 0
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Get keys that have never been accessed
|
|
123
|
+
#
|
|
124
|
+
# @return [Array<String>] keys with 0 access count
|
|
125
|
+
def never_accessed_keys
|
|
126
|
+
data = @metrics_data[:least_touched] || []
|
|
127
|
+
data.select { |_key, count| count.zero? }.map(&:first)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Refresh metrics data
|
|
131
|
+
#
|
|
132
|
+
# @return [MetricsScope] self
|
|
133
|
+
def refresh
|
|
134
|
+
@metrics_data = Mudis.metrics
|
|
135
|
+
self
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def eviction_rate
|
|
141
|
+
evictions = @metrics_data[:evictions] || 0
|
|
142
|
+
total_ops = (@metrics_data[:hits] || 0) + (@metrics_data[:misses] || 0)
|
|
143
|
+
return 0.0 if total_ops.zero?
|
|
144
|
+
|
|
145
|
+
(evictions.to_f / total_ops * 100).round(2)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def rejection_rate
|
|
149
|
+
rejected = @metrics_data[:rejected] || 0
|
|
150
|
+
total_ops = (@metrics_data[:hits] || 0) + (@metrics_data[:misses] || 0)
|
|
151
|
+
return 0.0 if total_ops.zero?
|
|
152
|
+
|
|
153
|
+
(rejected.to_f / total_ops * 100).round(2)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def create_scope_for_array(data)
|
|
157
|
+
# Create a temporary store-like object for the array data
|
|
158
|
+
temp_store = Object.new
|
|
159
|
+
temp_store.define_singleton_method(:all) { data }
|
|
160
|
+
Scope.new(temp_store)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def default_distribution
|
|
164
|
+
{
|
|
165
|
+
total_buckets: 0,
|
|
166
|
+
avg_keys_per_bucket: 0.0,
|
|
167
|
+
max_keys_per_bucket: 0,
|
|
168
|
+
min_keys_per_bucket: 0,
|
|
169
|
+
avg_memory_per_bucket: 0.0,
|
|
170
|
+
max_memory_per_bucket: 0,
|
|
171
|
+
min_memory_per_bucket: 0
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MudisQL
|
|
4
|
+
# Scope provides a chainable DSL for querying mudis cache data
|
|
5
|
+
class Scope
|
|
6
|
+
attr_reader :store
|
|
7
|
+
|
|
8
|
+
def initialize(store)
|
|
9
|
+
@store = store
|
|
10
|
+
@conditions = []
|
|
11
|
+
@order_by = nil
|
|
12
|
+
@order_direction = :asc
|
|
13
|
+
@limit_value = nil
|
|
14
|
+
@offset_value = 0
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Create a new independent scope instance
|
|
18
|
+
def clone
|
|
19
|
+
cloned = self.class.new(@store)
|
|
20
|
+
cloned.instance_variable_set(:@conditions, @conditions.dup)
|
|
21
|
+
cloned.instance_variable_set(:@order_by, @order_by)
|
|
22
|
+
cloned.instance_variable_set(:@order_direction, @order_direction)
|
|
23
|
+
cloned.instance_variable_set(:@limit_value, @limit_value)
|
|
24
|
+
cloned.instance_variable_set(:@offset_value, @offset_value)
|
|
25
|
+
cloned
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Filter records by conditions
|
|
29
|
+
#
|
|
30
|
+
# @param conditions [Hash] hash of field => value or field => proc
|
|
31
|
+
# @return [Scope] self for chaining
|
|
32
|
+
#
|
|
33
|
+
# @example
|
|
34
|
+
# query.where(status: "active")
|
|
35
|
+
# query.where(age: ->(v) { v > 18 })
|
|
36
|
+
# query.where(name: /^A/)
|
|
37
|
+
def where(conditions)
|
|
38
|
+
@conditions = @conditions.dup unless @conditions.frozen?
|
|
39
|
+
@conditions << conditions
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Order results by a field
|
|
44
|
+
#
|
|
45
|
+
# @param field [Symbol, String] the field to order by
|
|
46
|
+
# @param direction [Symbol] :asc or :desc
|
|
47
|
+
# @return [Scope] self for chaining
|
|
48
|
+
def order(field, direction = :asc)
|
|
49
|
+
@order_by = field.to_s
|
|
50
|
+
@order_direction = direction
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Limit the number of results
|
|
55
|
+
#
|
|
56
|
+
# @param value [Integer] maximum number of results
|
|
57
|
+
# @return [Scope] self for chaining
|
|
58
|
+
def limit(value)
|
|
59
|
+
@limit_value = value
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Skip the first N results
|
|
64
|
+
#
|
|
65
|
+
# @param value [Integer] number of results to skip
|
|
66
|
+
# @return [Scope] self for chaining
|
|
67
|
+
def offset(value)
|
|
68
|
+
@offset_value = value
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Execute the query and return all matching records
|
|
73
|
+
#
|
|
74
|
+
# @return [Array<Hash>] array of matching records
|
|
75
|
+
def all
|
|
76
|
+
results = store.all
|
|
77
|
+
results = apply_conditions(results)
|
|
78
|
+
results = apply_order(results)
|
|
79
|
+
results = apply_pagination(results)
|
|
80
|
+
results
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Execute query and return first result
|
|
84
|
+
#
|
|
85
|
+
# @return [Hash, nil] first matching record or nil
|
|
86
|
+
def first
|
|
87
|
+
limit(1).all.first
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Execute query and return last result
|
|
91
|
+
#
|
|
92
|
+
# @return [Hash, nil] last matching record or nil
|
|
93
|
+
def last
|
|
94
|
+
all.last
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Count matching records
|
|
98
|
+
#
|
|
99
|
+
# @return [Integer] number of matching records
|
|
100
|
+
def count
|
|
101
|
+
apply_conditions(store.all).size
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Check if any records match
|
|
105
|
+
#
|
|
106
|
+
# @return [Boolean] true if at least one record matches
|
|
107
|
+
def exists?
|
|
108
|
+
count > 0
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Pluck specific fields from results
|
|
112
|
+
#
|
|
113
|
+
# @param fields [Array<Symbol, String>] fields to extract
|
|
114
|
+
# @return [Array] array of field values or arrays of values
|
|
115
|
+
def pluck(*fields)
|
|
116
|
+
fields = fields.map(&:to_s)
|
|
117
|
+
results = all
|
|
118
|
+
|
|
119
|
+
if fields.size == 1
|
|
120
|
+
results.map { |record| record[fields.first] }
|
|
121
|
+
else
|
|
122
|
+
results.map { |record| fields.map { |f| record[f] } }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def apply_conditions(results)
|
|
129
|
+
@conditions.reduce(results) do |filtered, condition|
|
|
130
|
+
filtered.select do |record|
|
|
131
|
+
condition.all? do |key, matcher|
|
|
132
|
+
field = key.to_s
|
|
133
|
+
value = record[field]
|
|
134
|
+
|
|
135
|
+
match_condition?(value, matcher)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def match_condition?(value, matcher)
|
|
142
|
+
case matcher
|
|
143
|
+
when Proc
|
|
144
|
+
matcher.call(value)
|
|
145
|
+
when Regexp
|
|
146
|
+
value.to_s =~ matcher
|
|
147
|
+
when Range
|
|
148
|
+
matcher.include?(value)
|
|
149
|
+
else
|
|
150
|
+
value == matcher
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def apply_order(results)
|
|
155
|
+
return results unless @order_by
|
|
156
|
+
|
|
157
|
+
begin
|
|
158
|
+
sorted = results.sort_by do |record|
|
|
159
|
+
val = record[@order_by]
|
|
160
|
+
# Handle nil values and type-safe comparison
|
|
161
|
+
if val.nil?
|
|
162
|
+
[@order_direction == :asc ? 1 : -1, ""]
|
|
163
|
+
elsif val.is_a?(Numeric)
|
|
164
|
+
[0, val]
|
|
165
|
+
else
|
|
166
|
+
[0, val.to_s]
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
@order_direction == :desc ? sorted.reverse : sorted
|
|
171
|
+
rescue ArgumentError => e
|
|
172
|
+
# If sorting fails, return unsorted results
|
|
173
|
+
results
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def apply_pagination(results)
|
|
178
|
+
offset = [@offset_value, 0].max
|
|
179
|
+
results = results.drop(offset) if offset > 0
|
|
180
|
+
results = results.take(@limit_value) if @limit_value && @limit_value >= 0
|
|
181
|
+
results
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mudis"
|
|
4
|
+
|
|
5
|
+
module MudisQL
|
|
6
|
+
# Store wraps mudis operations for a specific namespace
|
|
7
|
+
class Store
|
|
8
|
+
attr_reader :namespace
|
|
9
|
+
|
|
10
|
+
def initialize(namespace = nil)
|
|
11
|
+
@namespace = namespace
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Retrieve all keys from the namespace
|
|
15
|
+
#
|
|
16
|
+
# @return [Array<String>] array of keys
|
|
17
|
+
def keys
|
|
18
|
+
if namespace
|
|
19
|
+
Mudis.keys(namespace: namespace) || []
|
|
20
|
+
else
|
|
21
|
+
# When no namespace, mudis uses default internal namespace
|
|
22
|
+
# We need to call without the namespace parameter
|
|
23
|
+
[] # mudis doesn't support listing keys without namespace
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Read a value from the cache
|
|
28
|
+
#
|
|
29
|
+
# @param key [String] the key to read
|
|
30
|
+
# @return [Object, nil] the cached value or nil
|
|
31
|
+
def read(key)
|
|
32
|
+
if namespace
|
|
33
|
+
Mudis.read(key, namespace: namespace)
|
|
34
|
+
else
|
|
35
|
+
Mudis.read(key)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Write a value to the cache
|
|
40
|
+
#
|
|
41
|
+
# @param key [String] the key to write
|
|
42
|
+
# @param value [Object] the value to store
|
|
43
|
+
# @param expires_in [Integer, nil] optional TTL in seconds
|
|
44
|
+
def write(key, value, expires_in: nil)
|
|
45
|
+
if namespace
|
|
46
|
+
Mudis.write(key, value, expires_in: expires_in, namespace: namespace)
|
|
47
|
+
else
|
|
48
|
+
Mudis.write(key, value, expires_in: expires_in)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Delete a key from the cache
|
|
53
|
+
#
|
|
54
|
+
# @param key [String] the key to delete
|
|
55
|
+
def delete(key)
|
|
56
|
+
if namespace
|
|
57
|
+
Mudis.delete(key, namespace: namespace)
|
|
58
|
+
else
|
|
59
|
+
Mudis.delete(key)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Retrieve all records from the namespace
|
|
64
|
+
#
|
|
65
|
+
# @return [Array<Hash>] array of records with their keys
|
|
66
|
+
def all
|
|
67
|
+
keys.map do |key|
|
|
68
|
+
value = read(key)
|
|
69
|
+
next unless value
|
|
70
|
+
|
|
71
|
+
if value.is_a?(Hash)
|
|
72
|
+
value.merge("_key" => key)
|
|
73
|
+
else
|
|
74
|
+
{ "_key" => key, "value" => value }
|
|
75
|
+
end
|
|
76
|
+
end.compact
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/mudis-ql.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mudis-ql/version"
|
|
4
|
+
require_relative "mudis-ql/store"
|
|
5
|
+
require_relative "mudis-ql/scope"
|
|
6
|
+
require_relative "mudis-ql/metrics_scope"
|
|
7
|
+
|
|
8
|
+
# MudisQL provides a simple query DSL for mudis cache
|
|
9
|
+
module MudisQL
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
# Create a new scope for the given namespace
|
|
13
|
+
#
|
|
14
|
+
# @param namespace [String, nil] the mudis namespace to query (nil for default/no namespace)
|
|
15
|
+
# @return [MudisQL::Scope] a new scope object
|
|
16
|
+
def self.from(namespace = nil)
|
|
17
|
+
Scope.new(Store.new(namespace))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Access mudis metrics with queryable interface
|
|
21
|
+
#
|
|
22
|
+
# @return [MudisQL::MetricsScope] a metrics scope object
|
|
23
|
+
def self.metrics
|
|
24
|
+
MetricsScope.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Configure MudisQL defaults
|
|
28
|
+
#
|
|
29
|
+
# @yield [Configuration] configuration object
|
|
30
|
+
def self.configure
|
|
31
|
+
yield configuration
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Get the current configuration
|
|
35
|
+
#
|
|
36
|
+
# @return [Configuration] current configuration
|
|
37
|
+
def self.configuration
|
|
38
|
+
@configuration ||= Configuration.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Configuration class for MudisQL
|
|
42
|
+
class Configuration
|
|
43
|
+
attr_accessor :default_limit
|
|
44
|
+
|
|
45
|
+
def initialize
|
|
46
|
+
@default_limit = 100
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|