solid-redis 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.
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidRedis
4
+ class Pool
5
+ attr_reader :size
6
+
7
+ def initialize(config, size: 5, timeout: 1.0)
8
+ @config = config
9
+ @size = Integer(size)
10
+ raise ArgumentError, "Pool size must be positive" unless @size.positive?
11
+
12
+ @timeout = Float(timeout)
13
+ raise ArgumentError, "Pool timeout must be positive" unless @timeout.positive?
14
+
15
+ @available = []
16
+ @created = 0
17
+ @mutex = Mutex.new
18
+ @resource = ConditionVariable.new
19
+ @closed = false
20
+ @owner = Ractor.current
21
+ end
22
+
23
+ def with
24
+ ensure_owner!
25
+ client = checkout
26
+ yield client
27
+ ensure
28
+ checkin(client) if client
29
+ end
30
+ alias_method :then, :with
31
+
32
+ def call(*command)
33
+ with { |client| client.call(*command) }
34
+ end
35
+
36
+ def call_v(command)
37
+ with { |client| client.call_v(command) }
38
+ end
39
+
40
+ def pipelined(&block)
41
+ with { |client| client.pipelined(&block) }
42
+ end
43
+
44
+ def close
45
+ ensure_owner!
46
+ clients = @mutex.synchronize do
47
+ @closed = true
48
+ @resource.broadcast
49
+ @available.shift(@available.length)
50
+ end
51
+ clients.each(&:close)
52
+ self
53
+ end
54
+
55
+ private
56
+
57
+ def checkout
58
+ deadline = monotonic_time + @timeout
59
+ @mutex.synchronize do
60
+ loop do
61
+ raise ClosedError, "Redis pool is closed" if @closed
62
+ return @available.pop unless @available.empty?
63
+
64
+ if @created < size
65
+ @created += 1
66
+ return @config.new_client
67
+ end
68
+
69
+ remaining = deadline - monotonic_time
70
+ unless remaining.positive?
71
+ raise CheckoutTimeoutError, "Redis pool checkout timed out after #{@timeout}s"
72
+ end
73
+
74
+ @resource.wait(@mutex, remaining)
75
+ end
76
+ end
77
+ end
78
+
79
+ def checkin(client)
80
+ @mutex.synchronize do
81
+ if @closed
82
+ @created -= 1
83
+ client.close
84
+ else
85
+ @available << client
86
+ @resource.signal
87
+ end
88
+ end
89
+ end
90
+
91
+ def ensure_owner!
92
+ return if Ractor.current == @owner
93
+
94
+ raise Ractor::IsolationError, "SolidRedis::Pool cannot cross Ractor boundaries"
95
+ end
96
+
97
+ def monotonic_time
98
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidRedis
4
+ module RESP
5
+ CRLF = "\r\n"
6
+
7
+ module_function
8
+
9
+ def encode(command)
10
+ command = command.flatten(1)
11
+ raise ArgumentError, "Redis command cannot be empty" if command.empty?
12
+
13
+ command.each_with_object(+"*#{command.length}#{CRLF}") do |argument, buffer|
14
+ value = encode_argument(argument)
15
+ buffer << "$#{value.bytesize}#{CRLF}#{value}#{CRLF}"
16
+ end
17
+ end
18
+
19
+ def encode_argument(value)
20
+ case value
21
+ when String then value
22
+ when Symbol, Integer, Float then value.to_s
23
+ when true then "1"
24
+ when false then "0"
25
+ when nil then ""
26
+ else value.to_s
27
+ end
28
+ end
29
+
30
+ class Reader
31
+ def initialize(io, read_timeout:)
32
+ @io = io
33
+ @read_timeout = read_timeout
34
+ @buffer = +""
35
+ end
36
+
37
+ def read(exception: true)
38
+ case (type = read_bytes(1))
39
+ when "+" then read_line
40
+ when "-" then error_response(read_line, exception)
41
+ when ":" then Integer(read_line)
42
+ when "$" then read_bulk
43
+ when "*" then read_array(exception)
44
+ when "_" then read_line && nil
45
+ when "#" then read_line == "t"
46
+ when "," then Float(read_line)
47
+ when "(" then Integer(read_line)
48
+ when "%" then read_map(exception)
49
+ when "~", ">" then read_collection(exception)
50
+ when "=" then read_verbatim
51
+ when "!" then error_response(read_sized_string, exception)
52
+ when "|" then read_map(exception) && read(exception: exception)
53
+ else raise ProtocolError, "Unknown RESP type byte: #{type.inspect}"
54
+ end
55
+ rescue EOFError
56
+ raise ConnectionError, "Redis closed the connection"
57
+ rescue ArgumentError => error
58
+ raise ProtocolError, "Invalid Redis response: #{error.message}", cause: error
59
+ end
60
+
61
+ private
62
+
63
+ def read_bulk
64
+ length = Integer(read_line)
65
+ return if length == -1
66
+
67
+ read_sized_value(length)
68
+ end
69
+
70
+ def read_array(exception)
71
+ length = Integer(read_line)
72
+ return if length == -1
73
+
74
+ Array.new(length) { read(exception: exception) }
75
+ end
76
+
77
+ def read_collection(exception)
78
+ length = Integer(read_line)
79
+ Array.new(length) { read(exception: exception) }
80
+ end
81
+
82
+ def read_map(exception)
83
+ length = Integer(read_line)
84
+ {}.tap do |map|
85
+ length.times { map[read(exception: exception)] = read(exception: exception) }
86
+ end
87
+ end
88
+
89
+ def read_verbatim
90
+ read_sized_string.byteslice(4..)
91
+ end
92
+
93
+ def read_sized_string
94
+ read_sized_value(Integer(read_line))
95
+ end
96
+
97
+ def read_sized_value(length)
98
+ value = read_bytes(length)
99
+ actual = read_bytes(2)
100
+ raise ProtocolError, "Expected CRLF, got #{actual.inspect}" unless actual == CRLF
101
+
102
+ value
103
+ end
104
+
105
+ def read_line
106
+ loop do
107
+ if (index = @buffer.index(CRLF))
108
+ return @buffer.slice!(0, index + 2).byteslice(0, index)
109
+ end
110
+ fill_buffer
111
+ end
112
+ end
113
+
114
+ def read_bytes(length)
115
+ fill_buffer while @buffer.bytesize < length
116
+ @buffer.slice!(0, length)
117
+ end
118
+
119
+ def fill_buffer
120
+ loop do
121
+ if @io.respond_to?(:to_io)
122
+ unless IO.select([@io], nil, nil, @read_timeout)
123
+ raise TimeoutError, "Redis read timed out after #{@read_timeout}s"
124
+ end
125
+
126
+ chunk = @io.read_nonblock(16_384, exception: false)
127
+ else
128
+ chunk = @io.read(16_384)
129
+ end
130
+
131
+ next if chunk == :wait_readable
132
+ raise EOFError if chunk.nil?
133
+
134
+ @buffer << chunk
135
+ return
136
+ end
137
+ rescue IOError, SystemCallError => error
138
+ raise ConnectionError, error.message, cause: error
139
+ end
140
+
141
+ def error_response(message, exception)
142
+ error = if message.start_with?("NOAUTH", "WRONGPASS")
143
+ AuthenticationError.new(message)
144
+ else
145
+ CommandError.new(message)
146
+ end
147
+ raise error if exception
148
+
149
+ error
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidRedis
4
+ module Sentinel
5
+ class ResolveService < Service::Base
6
+ def call
7
+ @endpoints.each do |endpoint|
8
+ client = Config.new(**@specification.sentinel_client_options, **endpoint).new_client
9
+ target = resolve_target(client)
10
+ unless target
11
+ append_error(
12
+ :target_not_found,
13
+ "#{endpoint_label(endpoint)} does not know #{@specification.name.inspect}",
14
+ )
15
+ next
16
+ end
17
+
18
+ return {
19
+ target: target,
20
+ endpoint: endpoint,
21
+ discovered: discover_sentinels(client),
22
+ }
23
+ rescue SolidRedis::Error, KeyError, ArgumentError => error
24
+ append_error(:sentinel_unavailable, "#{endpoint_label(endpoint)}: #{error.message}")
25
+ sleep SentinelState::SENTINEL_DELAY
26
+ ensure
27
+ client&.close
28
+ end
29
+
30
+ nil
31
+ end
32
+
33
+ private
34
+
35
+ def resolve_target(client)
36
+ if @specification.role == :master
37
+ resolve_master(client)
38
+ else
39
+ resolve_replica(client)
40
+ end
41
+ end
42
+
43
+ def resolve_master(client)
44
+ response = client.call("SENTINEL", "get-master-addr-by-name", @specification.name)
45
+ return unless response && response[0] && response[1]
46
+
47
+ { host: response[0], port: Integer(response[1]) }
48
+ end
49
+
50
+ def resolve_replica(client)
51
+ replicas = client.call("SENTINEL", "replicas", @specification.name)
52
+ candidates = replicas.filter_map do |entry|
53
+ attributes = attributes(entry)
54
+ flags = attributes["flags"].to_s.split(",")
55
+ next if flags.any? { |flag| %w[s_down o_down disconnected].include?(flag) }
56
+
57
+ { host: attributes.fetch("ip"), port: Integer(attributes.fetch("port")) }
58
+ end
59
+ candidates.sample
60
+ end
61
+
62
+ def discover_sentinels(client)
63
+ client.call("SENTINEL", "sentinels", @specification.name).map do |entry|
64
+ values = attributes(entry)
65
+ { host: values.fetch("ip"), port: Integer(values.fetch("port")) }
66
+ end
67
+ end
68
+
69
+ def attributes(entry)
70
+ entry.is_a?(Hash) ? entry : Hash[*entry]
71
+ end
72
+
73
+ def endpoint_label(endpoint)
74
+ "#{endpoint[:host]}:#{endpoint[:port]}"
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidRedis
4
+ class SentinelConfig
5
+ LOCAL_STATE_KEY = :solid_redis_sentinel_states
6
+ DEFAULT_SENTINEL_PORT = 26_379
7
+
8
+ attr_reader :name, :role, :reconnect_attempts, :redis_client_options,
9
+ :sentinel_client_options, :sentinel_endpoints
10
+
11
+ def initialize(
12
+ name:,
13
+ sentinels:,
14
+ role: :master,
15
+ sentinel_username: nil,
16
+ sentinel_password: nil,
17
+ sentinel_ssl: false,
18
+ sentinel_ssl_params: nil,
19
+ **redis_options
20
+ )
21
+ @name = String(name).dup.freeze
22
+ @role = normalize_role(role)
23
+ endpoints = sentinels.map { |endpoint| normalize_endpoint(endpoint) }
24
+ @sentinel_endpoints = Shareable.copy(endpoints, label: "sentinels")
25
+ raise ArgumentError, "At least one Sentinel endpoint is required" if @sentinel_endpoints.empty?
26
+
27
+ @reconnect_attempts = Integer(redis_options.fetch(:reconnect_attempts, 2))
28
+ raise ArgumentError, "reconnect_attempts must not be negative" if @reconnect_attempts.negative?
29
+
30
+ @redis_client_options = Shareable.copy(redis_options, label: "Redis options")
31
+ @sentinel_client_options = Shareable.copy(
32
+ sentinel_options(
33
+ redis_options,
34
+ username: sentinel_username,
35
+ password: sentinel_password,
36
+ ssl: sentinel_ssl,
37
+ ssl_params: sentinel_ssl_params,
38
+ ),
39
+ label: "Sentinel options",
40
+ )
41
+ Ractor.make_shareable(self)
42
+ end
43
+
44
+ def sentinel?
45
+ true
46
+ end
47
+
48
+ def resolved?
49
+ state.resolved?
50
+ end
51
+
52
+ def resolve
53
+ state.resolve
54
+ end
55
+
56
+ def reset
57
+ state.reset
58
+ self
59
+ end
60
+
61
+ def host
62
+ resolve.host
63
+ end
64
+
65
+ def port
66
+ resolve.port
67
+ end
68
+
69
+ def path
70
+ nil
71
+ end
72
+
73
+ def server_key
74
+ resolve.server_key
75
+ end
76
+
77
+ def server_url
78
+ resolve.server_url
79
+ end
80
+
81
+ def sentinels
82
+ state.sentinel_endpoints
83
+ end
84
+
85
+ def new_client(**options)
86
+ Client.new(self, **options)
87
+ end
88
+
89
+ def new_pool(**options)
90
+ Pool.new(self, **options)
91
+ end
92
+
93
+ def notify(event, *arguments)
94
+ callbacks = redis_client_options[:callbacks]
95
+ return unless callbacks&.respond_to?(event)
96
+
97
+ callbacks.respond_with(event, *arguments)
98
+ end
99
+
100
+ def inspect
101
+ "#<#{self.class.name} name=#{name.inspect} role=#{role.inspect} sentinels=#{sentinel_endpoints.length}>"
102
+ end
103
+
104
+ private
105
+
106
+ def state
107
+ registry = Ractor.current[LOCAL_STATE_KEY]
108
+ unless registry
109
+ registry = { mutex: Mutex.new, states: {} }
110
+ Ractor.current[LOCAL_STATE_KEY] = registry
111
+ end
112
+
113
+ registry[:mutex].synchronize do
114
+ registry[:states][self] ||= SentinelState.new(self)
115
+ end
116
+ end
117
+
118
+ def normalize_role(role)
119
+ role = role.to_sym
120
+ role = :replica if role == :slave
121
+ return role if %i[master replica].include?(role)
122
+
123
+ raise ArgumentError, "role must be :master or :replica"
124
+ end
125
+
126
+ def normalize_endpoint(endpoint)
127
+ return endpoint.transform_keys(&:to_sym) unless endpoint.is_a?(String)
128
+
129
+ uri = URI.parse(endpoint)
130
+ unless %w[redis rediss].include?(uri.scheme)
131
+ raise ArgumentError, "Unsupported Sentinel URL scheme: #{uri.scheme.inspect}"
132
+ end
133
+
134
+ {
135
+ host: uri.host || "127.0.0.1",
136
+ port: uri.port || DEFAULT_SENTINEL_PORT,
137
+ username: decode(uri.user),
138
+ password: decode(uri.password),
139
+ ssl: uri.scheme == "rediss",
140
+ }.compact
141
+ rescue URI::InvalidURIError => error
142
+ raise ArgumentError, "Invalid Sentinel URL: #{error.message}", cause: error
143
+ end
144
+
145
+ def sentinel_options(redis_options, **credentials)
146
+ timeout = redis_options.fetch(:timeout, Config::DEFAULT_TIMEOUT)
147
+ {
148
+ **credentials,
149
+ connect_timeout: redis_options.fetch(:connect_timeout, timeout),
150
+ read_timeout: redis_options.fetch(:read_timeout, timeout),
151
+ write_timeout: redis_options.fetch(:write_timeout, timeout),
152
+ reconnect_attempts: 0,
153
+ }.compact
154
+ end
155
+
156
+ def decode(value)
157
+ URI.decode_www_form_component(value) if value
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidRedis
4
+ class SentinelState
5
+ SENTINEL_DELAY = 0.05
6
+
7
+ def initialize(specification)
8
+ @specification = specification
9
+ @sentinels = specification.sentinel_endpoints.dup
10
+ @resolved = nil
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def resolve
15
+ @mutex.synchronize do
16
+ @resolved ||= resolve_target
17
+ end
18
+ end
19
+
20
+ def reset
21
+ @mutex.synchronize { @resolved = nil }
22
+ end
23
+
24
+ def resolved?
25
+ @mutex.synchronize { !@resolved.nil? }
26
+ end
27
+
28
+ def sentinel_endpoints
29
+ @mutex.synchronize { @sentinels.dup }
30
+ end
31
+
32
+ private
33
+
34
+ def resolve_target
35
+ result = Sentinel::ResolveService.call(
36
+ specification: @specification,
37
+ endpoints: @sentinels.dup,
38
+ )
39
+ unless result.successful? && result.result
40
+ details = result.errors.map(&:message).join("; ")
41
+ raise ConnectionError, "No Sentinel available for #{@specification.name.inspect}: #{details}"
42
+ end
43
+
44
+ resolution = result.result
45
+ resolution[:discovered].each do |attributes|
46
+ endpoint = Shareable.copy(
47
+ attributes,
48
+ label: "discovered Sentinel",
49
+ )
50
+ @sentinels << endpoint unless same_endpoint?(endpoint)
51
+ end
52
+ promote(resolution[:endpoint])
53
+ target = Config.new(**@specification.redis_client_options, **resolution[:target])
54
+ @specification.notify(:resolved, @specification.name, target.server_url)
55
+ target
56
+ end
57
+
58
+ def same_endpoint?(candidate)
59
+ @sentinels.any? do |endpoint|
60
+ endpoint[:host] == candidate[:host] && endpoint[:port] == candidate[:port]
61
+ end
62
+ end
63
+
64
+ def promote(endpoint)
65
+ @sentinels.delete(endpoint)
66
+ @sentinels.unshift(endpoint)
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidRedis
4
+ module Shareable
5
+ module_function
6
+
7
+ def copy(value, label:)
8
+ Ractor.make_shareable(value, copy: true)
9
+ rescue Ractor::Error, TypeError => error
10
+ raise ArgumentError, "#{label} must be Ractor-shareable: #{error.message}", cause: error
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidRedis
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base_service"
4
+ require "callback_collection"
5
+
6
+ require_relative "solid_redis/version"
7
+ require_relative "solid_redis/errors"
8
+ require_relative "solid_redis/shareable"
9
+ require_relative "solid_redis/config"
10
+ require_relative "solid_redis/resp"
11
+ require_relative "solid_redis/client"
12
+ require_relative "solid_redis/sentinel/resolve_service"
13
+ require_relative "solid_redis/sentinel_state"
14
+ require_relative "solid_redis/sentinel_config"
15
+ require_relative "solid_redis/pool"
16
+
17
+ module SolidRedis
18
+ module_function
19
+
20
+ def config(**options)
21
+ Config.new(**options)
22
+ end
23
+
24
+ def sentinel(**options)
25
+ SentinelConfig.new(**options)
26
+ end
27
+ end