valkey-glide-rb 0.9.0 → 0.9.1

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.
@@ -41,10 +41,29 @@ end
41
41
  client.del("otel_key", "otel_pipe_1")
42
42
  client.close
43
43
 
44
+ # Distributed tracing: register a parent_span_context_provider so command spans
45
+ # become children of the app's current trace instead of independent root spans.
46
+ # Here we hand-roll a fake W3C trace context (no dependency on the real
47
+ # opentelemetry-ruby gem); in a real app this would read from
48
+ # ::OpenTelemetry::Trace.current_span.context (see README).
49
+ fake_trace_id = "4bf92f3577b34da6a3ce929d0e0e4736"
50
+ fake_span_id = "00f067aa0ba902b7"
51
+
52
+ Valkey::OpenTelemetry.set_parent_span_context_provider do
53
+ { trace_id: fake_trace_id, span_id: fake_span_id, trace_flags: 1, tracestate: nil }
54
+ end
55
+
56
+ client = Valkey.new(host: host, port: port)
57
+ client.set("otel_traced_key", "value") # span will be a child of fake_trace_id/fake_span_id
58
+ client.close
59
+
60
+ Valkey::OpenTelemetry.set_parent_span_context_provider(nil)
61
+
44
62
  # Allow exporter flush
45
63
  sleep 2
46
64
 
47
65
  puts "OpenTelemetry initialized: #{Valkey::OpenTelemetry.initialized?}"
48
66
  puts "Traces file: #{TRACES_FILE} (#{File.size?(TRACES_FILE) || 0} bytes)"
49
67
  puts "Metrics file: #{METRICS_FILE} (#{File.size?(METRICS_FILE) || 0} bytes)"
50
- puts "Done."
68
+ puts "Done. Inspect #{TRACES_FILE} - the SET span for otel_traced_key should have " \
69
+ "trace_id=#{fake_trace_id} and parent_span_id=#{fake_span_id}."
@@ -4,6 +4,23 @@ class Valkey
4
4
  module Bindings
5
5
  extend FFI::Library
6
6
 
7
+ # Detect whether the current Linux system uses musl libc (e.g., Alpine Linux).
8
+ # Uses a three-check cascade — any single match is sufficient.
9
+ def self.musl_libc?
10
+ return false unless FFI::Platform::OS == "linux"
11
+
12
+ # Check 1: RUBY_PLATFORM contains 'musl' (e.g., Alpine-built Ruby)
13
+ return true if RUBY_PLATFORM.include?("musl")
14
+
15
+ # Check 2: /etc/alpine-release exists (Alpine Linux indicator)
16
+ return true if File.exist?("/etc/alpine-release")
17
+
18
+ # Check 3: RbConfig target_os contains 'musl'
19
+ return true if RbConfig::CONFIG["target_os"].to_s.include?("musl")
20
+
21
+ false
22
+ end
23
+
7
24
  # Determine platform-specific library extension and directory name
8
25
  def self.platform_info
9
26
  os = if FFI::Platform.mac?
@@ -40,7 +57,7 @@ class Valkey
40
57
  when "darwin"
41
58
  "#{arch}-apple-darwin"
42
59
  when "linux"
43
- "#{arch}-unknown-linux-gnu"
60
+ musl_libc? ? "#{arch}-unknown-linux-musl" : "#{arch}-unknown-linux-gnu"
44
61
  when "windows"
45
62
  "#{arch}-pc-windows-msvc"
46
63
  end
@@ -309,6 +326,21 @@ class Valkey
309
326
 
310
327
  attach_function :create_batch_otel_span, [], :uint64 # returns span pointer (u64) or 0 on failure
311
328
 
329
+ attach_function :create_otel_span_with_trace_context, [
330
+ :int, # request_type (RequestType enum value)
331
+ :string, # trace_id (32-char lowercase hex, or nil for an independent span)
332
+ :string, # span_id (16-char lowercase hex, or nil for an independent span)
333
+ :uint8, # trace_flags
334
+ :string # trace_state (W3C tracestate header, or nil)
335
+ ], :uint64 # returns span pointer (u64); falls back to an independent span on invalid context
336
+
337
+ attach_function :create_batch_otel_span_with_trace_context, [
338
+ :string, # trace_id (32-char lowercase hex, or nil for an independent span)
339
+ :string, # span_id (16-char lowercase hex, or nil for an independent span)
340
+ :uint8, # trace_flags
341
+ :string # trace_state (W3C tracestate header, or nil)
342
+ ], :uint64 # returns span pointer (u64); falls back to an independent batch span on invalid context
343
+
312
344
  attach_function :drop_otel_span, [
313
345
  :uint64 # span_ptr to close
314
346
  ], :void
@@ -71,13 +71,30 @@ class Valkey
71
71
  send_command(RequestType::CLUSTER_DEL_SLOTS_RANGE, [start_slot, end_slot])
72
72
  end
73
73
 
74
+ # Valid modes for CLUSTER FAILOVER: FORCE, TAKEOVER.
75
+ CLUSTER_FAILOVER_MODES = %i[force takeover].freeze
76
+
74
77
  # Force a failover of the cluster.
75
78
  #
76
- # @param [String] force force the failover
79
+ # Must be sent to a replica node. Without a mode, performs a coordinated
80
+ # failover (the primary is asked to pause clients and hand over). +:force+
81
+ # promotes the replica without coordinating with the primary (useful when
82
+ # the primary is unreachable); +:takeover+ promotes it without any cluster
83
+ # consensus (most aggressive — risks data loss / split brain).
84
+ #
85
+ # @param [Symbol, nil] mode optional failover mode: +:force+ or +:takeover+
77
86
  # @return [String] `"OK"`
78
- def cluster_failover(force = nil)
87
+ # @raise [ArgumentError] if +mode+ is not nil, +:force+, or +:takeover+
88
+ # @see https://valkey.io/commands/cluster-failover/
89
+ def cluster_failover(mode = nil)
79
90
  args = []
80
- args << "FORCE" if force
91
+ unless mode.nil?
92
+ unless CLUSTER_FAILOVER_MODES.include?(mode)
93
+ raise ArgumentError, "invalid CLUSTER FAILOVER mode #{mode.inspect}: expected :force or :takeover"
94
+ end
95
+
96
+ args << mode.to_s.upcase
97
+ end
81
98
  send_command(RequestType::CLUSTER_FAILOVER, args)
82
99
  end
83
100
 
@@ -94,7 +94,7 @@ class Valkey
94
94
  # client(:set_name, "my_app") # => "OK"
95
95
  # client(:list) # => [{"id" => "1", ...}, ...]
96
96
  def client(subcommand, *args)
97
- send("client_#{subcommand.to_s.downcase}", *args)
97
+ public_send("client_#{subcommand.to_s.downcase}", *args)
98
98
  end
99
99
 
100
100
  # Get the current client's ID.
@@ -136,8 +136,10 @@ class Valkey
136
136
 
137
137
  send_command(RequestType::CLIENT_LIST, args) do |reply|
138
138
  reply.lines.map do |line|
139
- entries = line.chomp.split(/[ =]/)
140
- entries.each_slice(2).to_a.to_h
139
+ line.chomp.split.each_with_object({}) do |pair, hash|
140
+ key, value = pair.split("=", 2)
141
+ hash[key] = value || ""
142
+ end
141
143
  end
142
144
  end
143
145
  end
@@ -290,6 +292,12 @@ class Valkey
290
292
  send_command(RequestType::CLIENT_NO_TOUCH, [mode.to_s.upcase])
291
293
  end
292
294
 
295
+ # Client-side caching commands are intentionally private.
296
+ #
297
+ # GLIDE's managed client-side caching is not yet implemented in the Ruby client
298
+ # These commands are not meant to be called directly and will be kept private.
299
+ private :client_tracking, :client_caching, :client_tracking_info, :client_getredir
300
+
293
301
  private
294
302
 
295
303
  def build_client_tracking_args(options)
@@ -299,8 +307,9 @@ class Valkey
299
307
  when :redirect
300
308
  args << "REDIRECT" << value.to_s
301
309
  when :prefix
302
- args << "PREFIX"
303
- Array(value).each { |prefix| args << prefix }
310
+ # Valkey requires the PREFIX keyword before each prefix value,
311
+ # e.g. PREFIX foo PREFIX bar.
312
+ Array(value).each { |prefix| args << "PREFIX" << prefix }
304
313
  when :bcast
305
314
  args << "BCAST" if value
306
315
  when :optin
@@ -9,6 +9,11 @@ class Valkey
9
9
  module GenericCommands
10
10
  # Scan the keyspace
11
11
  #
12
+ # @note Standalone mode only. glide-core has no defined default route for
13
+ # SCAN in cluster mode, so each call may land on a different node with
14
+ # no cursor continuity between them — results are undefined (missed or
15
+ # duplicated keys) rather than merely partial.
16
+ #
12
17
  # @example Retrieve the first batch of keys
13
18
  # valkey.scan(0)
14
19
  # # => ["4", ["key:21", "key:47", "key:42"]]
@@ -33,6 +38,9 @@ class Valkey
33
38
 
34
39
  # Scan the keyspace
35
40
  #
41
+ # @note Standalone mode only. Built on {#scan}, which has undefined
42
+ # routing behavior in cluster mode (see its note).
43
+ #
36
44
  # @example Retrieve all of the keys (with possible duplicates)
37
45
  # valkey.scan_each.to_a
38
46
  # # => ["key:21", "key:47", "key:42"]
@@ -52,16 +60,16 @@ class Valkey
52
60
  #
53
61
  # @return [Enumerator] an enumerator for all found keys
54
62
  #
55
- # def scan_each(**options, &block)
56
- # return to_enum(:scan_each, **options) unless block_given?
57
- #
58
- # cursor = 0
59
- # loop do
60
- # cursor, keys = scan(cursor, **options)
61
- # keys.each(&block)
62
- # break if cursor == "0"
63
- # end
64
- # end
63
+ def scan_each(**options, &block)
64
+ return to_enum(:scan_each, **options) unless block_given?
65
+
66
+ cursor = 0
67
+ loop do
68
+ cursor, keys = scan(cursor, **options)
69
+ keys.each(&block)
70
+ break if cursor == "0"
71
+ end
72
+ end
65
73
 
66
74
  # Remove the expiration from a key.
67
75
  #
@@ -520,6 +528,98 @@ class Valkey
520
528
 
521
529
  send_command(command, args, &block)
522
530
  end
531
+
532
+ # Send any command as plain arguments and get the raw reply back, with no
533
+ # per-command method needed. Escape hatch for commands without a dedicated
534
+ # method yet, matching `redis-client`'s `#call`.
535
+ #
536
+ # @example Basic dispatch
537
+ # valkey.call("SET", "mykey", "value")
538
+ # # => "OK"
539
+ # @example Integers/Floats auto-stringify
540
+ # valkey.call("SET", "mykey", 42)
541
+ # # equivalent to call("SET", "mykey", "42")
542
+ # @example Arrays flatten
543
+ # valkey.call("LPUSH", "list", [1, 2, 3])
544
+ # # equivalent to call("LPUSH", "list", "1", "2", "3")
545
+ # @example Hashes flatten to alternating key/value
546
+ # valkey.call("HMSET", "hash", { "foo" => "1" })
547
+ # # equivalent to call("HMSET", "hash", "foo", "1")
548
+ # @example Keyword args become command flags; falsy/nil flags are dropped
549
+ # valkey.call("SET", "k", "v", nx: true, ex: 60)
550
+ # # equivalent to call("SET", "k", "v", "NX", "EX", "60")
551
+ #
552
+ # @param [Array<String, Integer, Float, Array, Hash>] argv command name and its arguments
553
+ # @param [Hash] kwargs trailing command flags; truthy values emit the upcased flag name,
554
+ # non-boolean values also emit the stringified value; falsy/nil values are dropped
555
+ # @return [Object] the raw reply, with no type-casting based on the command name
556
+ #
557
+ # @see https://valkey.io/commands/
558
+ def call(*argv, **kwargs)
559
+ send_command(RequestType::CUSTOM_COMMAND, flatten_call_args(argv).concat(call_flags(kwargs)))
560
+ end
561
+
562
+ # Send any command as a single Array of arguments and get the raw reply back.
563
+ # Same as {#call} but takes the whole command as one Array instead of splatted
564
+ # args, useful when the command is built dynamically. Matches `redis-client`'s
565
+ # `#call_v` — no keyword flags.
566
+ #
567
+ # @example
568
+ # valkey.call_v(["MGET"] + keys)
569
+ #
570
+ # @param [Array<String, Integer, Float, Array, Hash>] argv command name and its arguments
571
+ # @return [Object] the raw reply, with no type-casting based on the command name
572
+ #
573
+ # @see https://valkey.io/commands/
574
+ def call_v(argv)
575
+ send_command(RequestType::CUSTOM_COMMAND, flatten_call_args(argv))
576
+ end
577
+
578
+ private
579
+
580
+ # Flattens Arrays and Hashes (to alternating key/value) and stringifies
581
+ # Integers/Floats, matching `redis-client`'s documented `#call`/`#call_v` behavior.
582
+ #
583
+ # @example
584
+ # flatten_call_args(["CMD", [1, [2, 3]], { "a" => 1, "b" => [2, 3] }])
585
+ # # => ["CMD", "1", "2", "3", "a", "1", "b", "2", "3"]
586
+ def flatten_call_args(args)
587
+ acc = []
588
+ # Ruby doesn't have a built in queue, so we use a reversed stack instead
589
+ # Insertions into the stack needs to be reversed to maintain the Queue LIFO
590
+ # ordering
591
+ stack = args.reverse
592
+
593
+ until stack.empty?
594
+ arg = stack.pop
595
+ case arg
596
+ when Array
597
+ stack.concat(arg.reverse)
598
+ when Hash
599
+ arg.to_a.reverse_each { |pair| stack.concat(pair.reverse) }
600
+ else
601
+ acc << arg.to_s
602
+ end
603
+ end
604
+
605
+ acc
606
+ end
607
+
608
+ # Converts `call`'s **kwargs into trailing command flags: a truthy value emits
609
+ # the upcased flag name, and a non-boolean truthy value also emits the
610
+ # stringified value. Falsy/nil values are dropped entirely, not stringified.
611
+ #
612
+ # @example
613
+ # call_flags(nx: true, ex: 60, cond: false, ttl: nil)
614
+ # # => ["NX", "EX", "60"]
615
+ def call_flags(kwargs)
616
+ kwargs.each_with_object([]) do |(name, value), flags|
617
+ next unless value
618
+
619
+ flags << name.to_s.upcase
620
+ flags << value.to_s unless value == true
621
+ end
622
+ end
523
623
  end
524
624
  end
525
625
  end
@@ -499,13 +499,19 @@ class Valkey
499
499
  # @see https://valkey.io/commands/failover/
500
500
  def failover(to: nil, force: false, abort: false, timeout: nil)
501
501
  args = []
502
- if to
503
- host, port = to.split
504
- args << "TO" << host << port
502
+ if abort
503
+ # ABORT is mutually exclusive: cancels an ongoing failover and
504
+ # ignores any other arguments (matches the core GLIDE clients).
505
+ args << "ABORT"
506
+ else
507
+ if to
508
+ host, port = to.split
509
+ args << "TO" << host << port
510
+ # FORCE is only valid in combination with TO.
511
+ args << "FORCE" if force
512
+ end
513
+ args << "TIMEOUT" << timeout.to_s if timeout
505
514
  end
506
- args << "FORCE" if force
507
- args << "ABORT" if abort
508
- args << "TIMEOUT" << timeout.to_s if timeout
509
515
  send_command(RequestType::FAIL_OVER, args)
510
516
  end
511
517
 
@@ -44,6 +44,7 @@ class Valkey
44
44
  module OpenTelemetry
45
45
  @initialized = false
46
46
  @config = nil
47
+ @parent_span_context_provider = nil
47
48
 
48
49
  class << self
49
50
  # Initialize OpenTelemetry in the Valkey GLIDE core.
@@ -67,12 +68,16 @@ class Valkey
67
68
  # @param flush_interval_ms [Integer, nil] Flush interval in milliseconds (default: 5000)
68
69
  # Must be a positive integer
69
70
  #
71
+ # @param parent_span_context_provider [Proc, nil] Optional callable returning a Hash describing
72
+ # the current application span context (see {set_parent_span_context_provider}). Equivalent to
73
+ # calling {set_parent_span_context_provider} separately; provided here for convenience.
74
+ #
70
75
  # @raise [ArgumentError] if neither traces nor metrics is provided
71
76
  # @raise [ArgumentError] if sample_percentage is not between 0-100
72
77
  # @raise [RuntimeError] if initialization fails
73
78
  #
74
79
  # @return [void]
75
- def init(traces: nil, metrics: nil, flush_interval_ms: nil)
80
+ def init(traces: nil, metrics: nil, flush_interval_ms: nil, parent_span_context_provider: nil)
76
81
  if @initialized
77
82
  warn "Valkey::OpenTelemetry already initialized - ignoring new configuration"
78
83
  return
@@ -106,6 +111,7 @@ class Valkey
106
111
 
107
112
  @initialized = true
108
113
  @config = { traces: traces, metrics: metrics, flush_interval_ms: flush_interval_ms }
114
+ set_parent_span_context_provider(parent_span_context_provider) if parent_span_context_provider
109
115
 
110
116
  puts "✅ Valkey OpenTelemetry initialized successfully"
111
117
  puts " Traces: #{traces ? traces[:endpoint] : 'disabled'}"
@@ -135,16 +141,88 @@ class Valkey
135
141
  # @return [Hash, nil] the configuration hash or nil if not initialized
136
142
  attr_reader :config
137
143
 
144
+ # Register a callable that returns the current application span context, so that
145
+ # spans created for Valkey commands become children of it instead of independent
146
+ # root spans. This is how distributed tracing context (e.g. from the Rails request
147
+ # span) is propagated into the native OpenTelemetry spans created for each command.
148
+ #
149
+ # @param callable [Proc, nil] Called with no arguments before each command. Must return
150
+ # either nil (no active context - the command gets an independent span) or a Hash with:
151
+ # - :trace_id [String] 32-character lowercase hex trace ID
152
+ # - :span_id [String] 16-character lowercase hex span ID
153
+ # - :trace_flags [Integer] 0-255
154
+ # - :tracestate [String, nil] W3C tracestate header, optional
155
+ # Pass nil (and no block) to clear a previously registered provider.
156
+ #
157
+ # @example
158
+ # Valkey::OpenTelemetry.set_parent_span_context_provider do
159
+ # span = ::OpenTelemetry::Trace.current_span
160
+ # next nil unless span.context.valid?
161
+ #
162
+ # {
163
+ # trace_id: span.context.hex_trace_id,
164
+ # span_id: span.context.hex_span_id,
165
+ # trace_flags: span.context.trace_flags.sampled? ? 1 : 0,
166
+ # tracestate: span.context.tracestate.to_s
167
+ # }
168
+ # end
169
+ #
170
+ # @return [void]
171
+ def set_parent_span_context_provider(callable = nil, &block)
172
+ @parent_span_context_provider = block || callable
173
+ end
174
+
175
+ # Invoke the registered parent-span-context provider (if any) and return a validated
176
+ # context Hash, or nil if no provider is registered, the provider returned nil, the
177
+ # provider raised, or the returned context failed validation.
178
+ #
179
+ # @return [Hash, nil]
180
+ def parent_span_context
181
+ return nil unless @parent_span_context_provider
182
+
183
+ ctx = @parent_span_context_provider.call
184
+ return nil if ctx.nil?
185
+
186
+ validate_parent_span_context!(ctx)
187
+ ctx
188
+ rescue StandardError => e
189
+ warn "Valkey::OpenTelemetry parent_span_context_provider failed: #{e.message}"
190
+ nil
191
+ end
192
+
138
193
  # Reset initialization state (for testing only).
139
194
  #
140
195
  # @api private
141
196
  def reset!
142
197
  @initialized = false
143
198
  @config = nil
199
+ @parent_span_context_provider = nil
144
200
  end
145
201
 
146
202
  private
147
203
 
204
+ def validate_parent_span_context!(ctx)
205
+ raise ArgumentError, "parent span context must be a Hash, got: #{ctx.class}" unless ctx.is_a?(Hash)
206
+
207
+ unless ctx[:trace_id].is_a?(String) && ctx[:trace_id].match?(/\A[0-9a-f]{32}\z/)
208
+ raise ArgumentError, "trace_id must be a 32-character lowercase hex string, got: #{ctx[:trace_id].inspect}"
209
+ end
210
+
211
+ unless ctx[:span_id].is_a?(String) && ctx[:span_id].match?(/\A[0-9a-f]{16}\z/)
212
+ raise ArgumentError, "span_id must be a 16-character lowercase hex string, got: #{ctx[:span_id].inspect}"
213
+ end
214
+
215
+ trace_flags = ctx[:trace_flags]
216
+ unless trace_flags.is_a?(Integer) && trace_flags >= 0 && trace_flags <= 255
217
+ raise ArgumentError, "trace_flags must be an integer between 0 and 255, got: #{trace_flags.inspect}"
218
+ end
219
+
220
+ tracestate = ctx[:tracestate]
221
+ return if tracestate.nil? || tracestate.is_a?(String)
222
+
223
+ raise ArgumentError, "tracestate must be a String or nil, got: #{tracestate.class}"
224
+ end
225
+
148
226
  def build_config(traces, metrics, flush_interval_ms)
149
227
  config_struct = Bindings::OpenTelemetryConfig.new
150
228
 
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Valkey
4
+ #
5
+ # this module defines constants for the `read_from:` connection option.
6
+ # Each constant is the canonical GLIDE string for that read-routing strategy,
7
+ # matching the RequestType/ResponseType constant modules' convention in this
8
+ # gem. Ruby symbols (`:prefer_replica`) and the exact-match strings
9
+ # (`"PreferReplica"`) are still accepted directly by `Valkey.new` -- these
10
+ # constants are purely an additional, IDE-completion-friendly way to write
11
+ # the same values, not a new validation mechanism.
12
+ #
13
+ module ReadFrom
14
+ PRIMARY = "Primary"
15
+ PREFER_REPLICA = "PreferReplica"
16
+ AZ_AFFINITY = "AZAffinity"
17
+ AZ_AFFINITY_REPLICAS_AND_PRIMARY = "AZAffinityReplicasAndPrimary"
18
+
19
+ # "LowestLatency" is a valid GLIDE value but not yet usable via the vendored
20
+ # native library (panics in ConnectionRequest::from, see types.rs) -- not
21
+ # defined as a constant here since it can't work today, though passing the
22
+ # raw string through directly is still forwarded to the core unchanged.
23
+ end
24
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Valkey
4
- VERSION = "0.9.0"
4
+ VERSION = "0.9.1"
5
5
  end