tarantool-client 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/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +222 -0
- data/lib/tarantool/client/backoff.rb +23 -0
- data/lib/tarantool/client/box_error.rb +15 -0
- data/lib/tarantool/client/callback_guard.rb +23 -0
- data/lib/tarantool/client/configuration.rb +71 -0
- data/lib/tarantool/client/errors.rb +56 -0
- data/lib/tarantool/client/fork_safety.rb +42 -0
- data/lib/tarantool/client/index_info.rb +27 -0
- data/lib/tarantool/client/interval.rb +25 -0
- data/lib/tarantool/client/operations.rb +89 -0
- data/lib/tarantool/client/pool/member.rb +141 -0
- data/lib/tarantool/client/pool/member_fork_safety.rb +34 -0
- data/lib/tarantool/client/pool.rb +127 -0
- data/lib/tarantool/client/prepared_statement.rb +25 -0
- data/lib/tarantool/client/protocol/auth/chap_sha1.rb +28 -0
- data/lib/tarantool/client/protocol/codec.rb +79 -0
- data/lib/tarantool/client/protocol/constants.rb +105 -0
- data/lib/tarantool/client/protocol/extensions/datetime.rb +39 -0
- data/lib/tarantool/client/protocol/extensions/decimal.rb +73 -0
- data/lib/tarantool/client/protocol/extensions/error.rb +33 -0
- data/lib/tarantool/client/protocol/extensions/interval.rb +59 -0
- data/lib/tarantool/client/protocol/extensions/uuid.rb +29 -0
- data/lib/tarantool/client/protocol/extensions.rb +27 -0
- data/lib/tarantool/client/protocol/greeting.rb +21 -0
- data/lib/tarantool/client/protocol/requests/auth.rb +23 -0
- data/lib/tarantool/client/protocol/requests/begin.rb +30 -0
- data/lib/tarantool/client/protocol/requests/call.rb +16 -0
- data/lib/tarantool/client/protocol/requests/commit.rb +16 -0
- data/lib/tarantool/client/protocol/requests/delete.rb +19 -0
- data/lib/tarantool/client/protocol/requests/eval.rb +16 -0
- data/lib/tarantool/client/protocol/requests/execute.rb +21 -0
- data/lib/tarantool/client/protocol/requests/id.rb +16 -0
- data/lib/tarantool/client/protocol/requests/insert.rb +16 -0
- data/lib/tarantool/client/protocol/requests/ping.rb +17 -0
- data/lib/tarantool/client/protocol/requests/prepare.rb +16 -0
- data/lib/tarantool/client/protocol/requests/replace.rb +16 -0
- data/lib/tarantool/client/protocol/requests/rollback.rb +16 -0
- data/lib/tarantool/client/protocol/requests/select.rb +32 -0
- data/lib/tarantool/client/protocol/requests/unwatch.rb +16 -0
- data/lib/tarantool/client/protocol/requests/update.rb +24 -0
- data/lib/tarantool/client/protocol/requests/upsert.rb +19 -0
- data/lib/tarantool/client/protocol/requests/watch.rb +16 -0
- data/lib/tarantool/client/protocol/response.rb +36 -0
- data/lib/tarantool/client/schema.rb +69 -0
- data/lib/tarantool/client/schema_cache.rb +54 -0
- data/lib/tarantool/client/space_info.rb +27 -0
- data/lib/tarantool/client/sql_result.rb +24 -0
- data/lib/tarantool/client/stream.rb +130 -0
- data/lib/tarantool/client/thread_join.rb +19 -0
- data/lib/tarantool/client/transaction_schema.rb +57 -0
- data/lib/tarantool/client/transaction_scope.rb +28 -0
- data/lib/tarantool/client/transport/connection.rb +141 -0
- data/lib/tarantool/client/transport/deadline.rb +17 -0
- data/lib/tarantool/client/transport/frame_writer.rb +41 -0
- data/lib/tarantool/client/transport/future.rb +60 -0
- data/lib/tarantool/client/transport/handshake.rb +36 -0
- data/lib/tarantool/client/transport/link.rb +150 -0
- data/lib/tarantool/client/transport/link_state.rb +115 -0
- data/lib/tarantool/client/transport/pending_requests.rb +55 -0
- data/lib/tarantool/client/transport/stream_ids.rb +20 -0
- data/lib/tarantool/client/transport/timed_lock.rb +53 -0
- data/lib/tarantool/client/transport/timed_read.rb +31 -0
- data/lib/tarantool/client/uuid.rb +20 -0
- data/lib/tarantool/client/version.rb +7 -0
- data/lib/tarantool/client/watcher.rb +37 -0
- data/lib/tarantool/client/watchers/dispatcher.rb +40 -0
- data/lib/tarantool/client/watchers.rb +137 -0
- data/lib/tarantool/client.rb +216 -0
- metadata +148 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
class Pool
|
|
6
|
+
# One pool address: its client once connected and the role from the instance's latest box.status.
|
|
7
|
+
# An address that is down at start keeps being retried in the background.
|
|
8
|
+
# @api private
|
|
9
|
+
class Member
|
|
10
|
+
include Pool::MemberForkSafety
|
|
11
|
+
|
|
12
|
+
def initialize(uri, options)
|
|
13
|
+
@uri = uri
|
|
14
|
+
@options = options
|
|
15
|
+
@config = Configuration.build(uri, **options)
|
|
16
|
+
@lock = Mutex.new
|
|
17
|
+
@changed = ConditionVariable.new
|
|
18
|
+
@closed = false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def address = @config.address
|
|
22
|
+
def client = @lock.synchronize { @client }
|
|
23
|
+
def inspect = "#<#{self.class.name} #{address}>"
|
|
24
|
+
|
|
25
|
+
# Connects and waits up to connect_timeout for the first box.status. Returns the error when the
|
|
26
|
+
# instance is unreachable.
|
|
27
|
+
def start
|
|
28
|
+
connect
|
|
29
|
+
wait_for_status
|
|
30
|
+
|
|
31
|
+
nil
|
|
32
|
+
rescue Error => e
|
|
33
|
+
retry_in_background if @config.reconnect
|
|
34
|
+
e
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# nil while disconnected or before the current connection's first box.status, else whether the
|
|
38
|
+
# instance is read-only. The role is dropped as soon as the connection is lost; it can briefly come
|
|
39
|
+
# back only while the watcher dispatcher still delivers that connection's queued box.status.
|
|
40
|
+
def read_only
|
|
41
|
+
@lock.synchronize { @read_only if @client&.connected? }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def close
|
|
45
|
+
retrier, client = @lock.synchronize do
|
|
46
|
+
@closed = true
|
|
47
|
+
@changed.broadcast
|
|
48
|
+
[@retrier, @client]
|
|
49
|
+
end
|
|
50
|
+
ThreadJoin.join(retrier)
|
|
51
|
+
|
|
52
|
+
client&.close
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def connect
|
|
58
|
+
client = Client.new(@uri, **@options)
|
|
59
|
+
# A role belongs to one session; the next one must report its own before the member is routed to.
|
|
60
|
+
client.disconnect_handler = -> { @lock.synchronize { @read_only = nil } }
|
|
61
|
+
client.watch('box.status') { |_key, status| update(status) }
|
|
62
|
+
|
|
63
|
+
install(client)
|
|
64
|
+
rescue Error
|
|
65
|
+
client&.close
|
|
66
|
+
raise
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def install(client)
|
|
70
|
+
closed = @lock.synchronize do
|
|
71
|
+
@client = client unless @closed
|
|
72
|
+
@closed
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
client.close if closed
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def update(status)
|
|
79
|
+
@lock.synchronize do
|
|
80
|
+
@read_only = status['is_ro']
|
|
81
|
+
@changed.broadcast
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def wait_for_status
|
|
86
|
+
deadline = Transport::Deadline.after(@config.connect_timeout)
|
|
87
|
+
|
|
88
|
+
@lock.synchronize do
|
|
89
|
+
while @read_only.nil? && !@closed
|
|
90
|
+
remaining = Transport::Deadline.remaining(deadline)
|
|
91
|
+
break if remaining&.zero?
|
|
92
|
+
|
|
93
|
+
@changed.wait(@lock, remaining)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def retry_in_background
|
|
99
|
+
@lock.synchronize do
|
|
100
|
+
next if @closed
|
|
101
|
+
|
|
102
|
+
@retrier = Thread.new { retry_connect }
|
|
103
|
+
@retrier.name = "tarantool-pool-connect #{address}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The thread must never die with an exception: #close joins it and would re-raise it.
|
|
108
|
+
def retry_connect
|
|
109
|
+
backoff = Backoff.new(base: @config.reconnect_delay, max: @config.reconnect_max_delay)
|
|
110
|
+
attempt = 0
|
|
111
|
+
|
|
112
|
+
while attempt < @config.reconnect_attempts && pause(backoff.delay(attempt))
|
|
113
|
+
return if try_connect
|
|
114
|
+
|
|
115
|
+
attempt += 1
|
|
116
|
+
end
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
warn "tarantool pool: gave up connecting to #{address}: #{e.class}: #{e.message}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def try_connect
|
|
122
|
+
connect
|
|
123
|
+
|
|
124
|
+
true
|
|
125
|
+
rescue Error
|
|
126
|
+
false
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
warn "tarantool pool: connecting to #{address} failed: #{e.class}: #{e.message}"
|
|
129
|
+
false
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def pause(delay)
|
|
133
|
+
@lock.synchronize do
|
|
134
|
+
@changed.wait(@lock, delay) unless @closed
|
|
135
|
+
!@closed
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
class Pool
|
|
6
|
+
# @api private
|
|
7
|
+
module MemberForkSafety
|
|
8
|
+
# Pool re-runs its startup in a forked child after this. The inherited client and retry thread belong
|
|
9
|
+
# to the parent: closing the client in the child only drops its socket copy, dead threads join at once.
|
|
10
|
+
# Nothing inherited may stop the reset, or the child's pool would never start its members.
|
|
11
|
+
def reset_for_fork
|
|
12
|
+
old_retrier, old_client = @lock.synchronize do
|
|
13
|
+
retrier = @retrier
|
|
14
|
+
client = @client
|
|
15
|
+
@retrier = nil
|
|
16
|
+
@client = nil
|
|
17
|
+
@read_only = nil
|
|
18
|
+
[retrier, client]
|
|
19
|
+
end
|
|
20
|
+
ThreadJoin.join(old_retrier)
|
|
21
|
+
close_inherited(old_client)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def close_inherited(client)
|
|
27
|
+
client&.close
|
|
28
|
+
rescue StandardError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
# Clients for the instances of one replica set. Each request goes to an instance picked by mode
|
|
6
|
+
# from the role it reports through box.status, round-robin among the matches. Starts with the
|
|
7
|
+
# addresses that answer; the others keep being retried in the background.
|
|
8
|
+
class Pool
|
|
9
|
+
MODES = %i[rw ro prefer_ro prefer_rw any].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(addresses, **options)
|
|
12
|
+
raise ArgumentError, 'a pool needs at least one address' if addresses.empty?
|
|
13
|
+
|
|
14
|
+
@members = addresses.map { |address| Member.new(address, options) }
|
|
15
|
+
@lock = Mutex.new
|
|
16
|
+
@reinit_lock = Mutex.new
|
|
17
|
+
@turns = Hash.new(0)
|
|
18
|
+
@pid = Process.pid
|
|
19
|
+
start_members
|
|
20
|
+
rescue StandardError
|
|
21
|
+
close
|
|
22
|
+
raise
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Each instance resolves names against its own schema, so requests are delegated, not built here.
|
|
26
|
+
Operations.public_instance_methods.each do |name|
|
|
27
|
+
define_method(name) do |*args, mode: :any, **options, &block|
|
|
28
|
+
client_for(mode).public_send(name, *args, **options, &block)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def transaction(**, &)
|
|
33
|
+
client_for(:rw).transaction(**, &)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def close
|
|
37
|
+
errors = (@members || []).filter_map do |member|
|
|
38
|
+
member.close
|
|
39
|
+
nil
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
e
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
raise errors.first if errors.any?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def inspect
|
|
48
|
+
"#<#{self.class.name} #{@members.map(&:address).join(', ')}>"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def start_members
|
|
54
|
+
results = start_in_parallel
|
|
55
|
+
unexpected = results.find { |result| result.is_a?(Exception) && !result.is_a?(Error) }
|
|
56
|
+
raise unexpected if unexpected
|
|
57
|
+
|
|
58
|
+
raise NoAvailableInstanceError, "no instance reachable: #{results.map(&:message).join('; ')}" if results.all?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Each member's start error, or the exception its thread raised. Joins every thread before
|
|
62
|
+
# returning so none outlives a failed Pool.new.
|
|
63
|
+
def start_in_parallel
|
|
64
|
+
threads = @members.map { |member| start_thread(member) }
|
|
65
|
+
|
|
66
|
+
threads.map do |thread|
|
|
67
|
+
thread.value
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
e
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def start_thread(member)
|
|
74
|
+
Thread.new { member.start }.tap do |thread|
|
|
75
|
+
thread.name = "tarantool-pool-start #{member.address}"
|
|
76
|
+
thread.report_on_exception = false
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def client_for(mode)
|
|
81
|
+
ensure_fork_safe
|
|
82
|
+
members = candidates(mode)
|
|
83
|
+
raise NoAvailableInstanceError, "no instance available for mode #{mode.inspect}" if members.empty?
|
|
84
|
+
|
|
85
|
+
members[@lock.synchronize { @turns[mode] += 1 } % members.size].client
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def ensure_fork_safe
|
|
89
|
+
return if @pid == Process.pid
|
|
90
|
+
|
|
91
|
+
@reinit_lock.synchronize do
|
|
92
|
+
return if @pid == Process.pid
|
|
93
|
+
|
|
94
|
+
@members.each(&:reset_for_fork)
|
|
95
|
+
start_members
|
|
96
|
+
ensure
|
|
97
|
+
@pid = Process.pid
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def candidates(mode)
|
|
102
|
+
writable, read_only = split_by_role
|
|
103
|
+
|
|
104
|
+
case mode
|
|
105
|
+
when :rw then writable
|
|
106
|
+
when :ro then read_only
|
|
107
|
+
when :prefer_ro then fallback(read_only, writable)
|
|
108
|
+
when :prefer_rw then fallback(writable, read_only)
|
|
109
|
+
when :any then writable + read_only
|
|
110
|
+
else raise ArgumentError, "unknown mode #{mode.inspect}, expected one of #{MODES.join(', ')}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def fallback(preferred, other) = preferred.empty? ? other : preferred
|
|
115
|
+
|
|
116
|
+
# Members with a known role: [writable, read-only].
|
|
117
|
+
def split_by_role
|
|
118
|
+
known = @members.filter_map do |member|
|
|
119
|
+
read_only = member.read_only
|
|
120
|
+
[member, read_only] unless read_only.nil?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
known.partition { |_member, read_only| !read_only }.map { |pairs| pairs.map(&:first) }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
# Server-side prepared SQL statement. Runs through the client or stream that prepared it.
|
|
6
|
+
class PreparedStatement
|
|
7
|
+
attr_reader :id
|
|
8
|
+
|
|
9
|
+
def initialize(id, perform)
|
|
10
|
+
@id = id
|
|
11
|
+
@perform = perform
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def execute(bindings = [], timeout: nil)
|
|
15
|
+
SqlResult.from_response(@perform.call(Protocol::Requests::Execute.new(statement: id, bindings:), timeout:))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def close(timeout: nil)
|
|
19
|
+
@perform.call(Protocol::Requests::Prepare.new(statement: id), timeout:)
|
|
20
|
+
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest/sha1'
|
|
4
|
+
|
|
5
|
+
module Tarantool
|
|
6
|
+
class Client
|
|
7
|
+
module Protocol
|
|
8
|
+
# @api private
|
|
9
|
+
module Auth
|
|
10
|
+
# chap-sha1: proves the password to the server without sending it.
|
|
11
|
+
# @api private
|
|
12
|
+
module ChapSha1
|
|
13
|
+
MECHANISM = 'chap-sha1'
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def scramble(password, salt)
|
|
18
|
+
hash1 = Digest::SHA1.digest(password)
|
|
19
|
+
hash2 = Digest::SHA1.digest(hash1)
|
|
20
|
+
hash3 = Digest::SHA1.digest(salt.byteslice(0, Constants::SCRAMBLE_SIZE) + hash2)
|
|
21
|
+
|
|
22
|
+
hash1.bytes.zip(hash3.bytes).map { |left, right| left ^ right }.pack('C*')
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
module Protocol
|
|
6
|
+
# IPROTO framing: MP_UINT32 size prefix, header map, body map.
|
|
7
|
+
# @api private
|
|
8
|
+
module Codec
|
|
9
|
+
SIZE_MARKER = 0xce
|
|
10
|
+
PREFIX_SIZE = 5
|
|
11
|
+
FACTORY = MessagePack::Factory.new.tap do |factory|
|
|
12
|
+
Extensions.each { |extension| factory.register_type(extension.type, extension.klass, **extension.options) }
|
|
13
|
+
end.freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def encode(request, sync:, stream_id: nil, schema_version: nil)
|
|
18
|
+
header = { Constants::REQUEST_TYPE => request.type, Constants::SYNC => sync }
|
|
19
|
+
header[Constants::STREAM_ID] = stream_id if stream_id
|
|
20
|
+
header[Constants::SCHEMA_VERSION] = schema_version if schema_version
|
|
21
|
+
payload = FACTORY.dump(header) + FACTORY.dump(request.body)
|
|
22
|
+
|
|
23
|
+
[SIZE_MARKER, payload.bytesize].pack('CN') + payload
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def read_response(io, max_frame_size: nil)
|
|
27
|
+
marker, size = read_exactly(io, PREFIX_SIZE).unpack('CN')
|
|
28
|
+
raise ProtocolError, format('unexpected frame size marker 0x%02x', marker) unless marker == SIZE_MARKER
|
|
29
|
+
|
|
30
|
+
if max_frame_size && size > max_frame_size
|
|
31
|
+
raise ProtocolError, "frame of #{size} bytes exceeds max_frame_size #{max_frame_size}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
decode(read_exactly(io, size))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def decode(payload)
|
|
38
|
+
unpacker = FACTORY.unpacker(allow_unknown_ext: true)
|
|
39
|
+
unpacker.feed(payload)
|
|
40
|
+
header, body = unpack_frame(unpacker)
|
|
41
|
+
|
|
42
|
+
validate_header(header)
|
|
43
|
+
validate_body(body)
|
|
44
|
+
raise ProtocolError, 'malformed frame: trailing bytes' unless unpacker.buffer.empty?
|
|
45
|
+
|
|
46
|
+
Response.new(header, body)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def unpack_frame(unpacker)
|
|
50
|
+
header = unpacker.read
|
|
51
|
+
body = unpacker.buffer.empty? ? {} : unpacker.read
|
|
52
|
+
|
|
53
|
+
[header, body]
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
raise ProtocolError, "malformed frame: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate_header(header)
|
|
59
|
+
return if header.is_a?(Hash) && header.key?(Constants::REQUEST_TYPE)
|
|
60
|
+
|
|
61
|
+
raise ProtocolError, 'malformed frame: header must be a map with a request type'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_body(body)
|
|
65
|
+
raise ProtocolError, 'malformed frame: body must be a map' unless body.is_a?(Hash)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def read_exactly(io, size)
|
|
69
|
+
data = io.read(size)
|
|
70
|
+
raise EOFError, 'connection closed by server' if data.nil? || data.bytesize < size
|
|
71
|
+
|
|
72
|
+
data
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private_class_method :unpack_frame, :validate_header, :validate_body
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
# @api private
|
|
6
|
+
module Protocol
|
|
7
|
+
# IPROTO keys and codes: https://www.tarantool.io/en/doc/latest/reference/internals/iproto/keys/
|
|
8
|
+
# @api private
|
|
9
|
+
module Constants
|
|
10
|
+
REQUEST_TYPE = 0x00
|
|
11
|
+
SYNC = 0x01
|
|
12
|
+
SCHEMA_VERSION = 0x05
|
|
13
|
+
STREAM_ID = 0x0a
|
|
14
|
+
|
|
15
|
+
SPACE_ID = 0x10
|
|
16
|
+
INDEX_ID = 0x11
|
|
17
|
+
LIMIT = 0x12
|
|
18
|
+
OFFSET = 0x13
|
|
19
|
+
ITERATOR = 0x14
|
|
20
|
+
KEY = 0x20
|
|
21
|
+
TUPLE = 0x21
|
|
22
|
+
FUNCTION_NAME = 0x22
|
|
23
|
+
USER_NAME = 0x23
|
|
24
|
+
EXPR = 0x27
|
|
25
|
+
OPS = 0x28
|
|
26
|
+
AFTER_TUPLE = 0x2f
|
|
27
|
+
DATA = 0x30
|
|
28
|
+
ERROR_24 = 0x31
|
|
29
|
+
METADATA = 0x32
|
|
30
|
+
SQL_TEXT = 0x40
|
|
31
|
+
SQL_BIND = 0x41
|
|
32
|
+
SQL_INFO = 0x42
|
|
33
|
+
STMT_ID = 0x43
|
|
34
|
+
ERROR = 0x52
|
|
35
|
+
VERSION = 0x54
|
|
36
|
+
FEATURES = 0x55
|
|
37
|
+
TIMEOUT = 0x56
|
|
38
|
+
EVENT_KEY = 0x57
|
|
39
|
+
EVENT_DATA = 0x58
|
|
40
|
+
TXN_ISOLATION = 0x59
|
|
41
|
+
|
|
42
|
+
OK = 0x00
|
|
43
|
+
SELECT = 0x01
|
|
44
|
+
INSERT = 0x02
|
|
45
|
+
REPLACE = 0x03
|
|
46
|
+
UPDATE = 0x04
|
|
47
|
+
DELETE = 0x05
|
|
48
|
+
AUTH = 0x07
|
|
49
|
+
EVAL = 0x08
|
|
50
|
+
UPSERT = 0x09
|
|
51
|
+
CALL = 0x0a
|
|
52
|
+
EXECUTE = 0x0b
|
|
53
|
+
PREPARE = 0x0d
|
|
54
|
+
BEGIN_TRANSACTION = 0x0e
|
|
55
|
+
COMMIT = 0x0f
|
|
56
|
+
ROLLBACK = 0x10
|
|
57
|
+
PING = 0x40
|
|
58
|
+
ID = 0x49
|
|
59
|
+
WATCH = 0x4a
|
|
60
|
+
UNWATCH = 0x4b
|
|
61
|
+
EVENT = 0x4c
|
|
62
|
+
CHUNK = 0x80
|
|
63
|
+
TYPE_ERROR = 0x8000
|
|
64
|
+
|
|
65
|
+
ER_WRONG_SCHEMA_VERSION = 109
|
|
66
|
+
|
|
67
|
+
FIELD_NAME = 0x00
|
|
68
|
+
SQL_INFO_ROW_COUNT = 0x00
|
|
69
|
+
SQL_INFO_AUTOINCREMENT_IDS = 0x01
|
|
70
|
+
|
|
71
|
+
MP_DECIMAL = 0x01
|
|
72
|
+
MP_UUID = 0x02
|
|
73
|
+
MP_ERROR = 0x03
|
|
74
|
+
MP_DATETIME = 0x04
|
|
75
|
+
MP_INTERVAL = 0x06
|
|
76
|
+
ERROR_STACK = 0x00
|
|
77
|
+
ERROR_FIELDS = {
|
|
78
|
+
0x00 => :type, 0x01 => :file, 0x02 => :line, 0x03 => :message,
|
|
79
|
+
0x04 => :errno, 0x05 => :code, 0x06 => :fields
|
|
80
|
+
}.freeze
|
|
81
|
+
|
|
82
|
+
ITERATORS = {
|
|
83
|
+
eq: 0, req: 1, all: 2, lt: 3, le: 4, ge: 5, gt: 6,
|
|
84
|
+
bits_all_set: 7, bits_any_set: 8, bits_all_not_set: 9, overlaps: 10, neighbor: 11,
|
|
85
|
+
np: 12, pp: 13
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
ISOLATION_LEVELS = { default: 0, read_committed: 1, read_confirmed: 2, best_effort: 3, linearizable: 4 }.freeze
|
|
89
|
+
|
|
90
|
+
PROTOCOL_VERSION = 3
|
|
91
|
+
FEATURE_STREAMS = 0
|
|
92
|
+
FEATURE_TRANSACTIONS = 1
|
|
93
|
+
FEATURE_ERROR_EXTENSION = 2
|
|
94
|
+
FEATURE_WATCHERS = 3
|
|
95
|
+
CLIENT_FEATURES = [FEATURE_STREAMS, FEATURE_TRANSACTIONS, FEATURE_ERROR_EXTENSION, FEATURE_WATCHERS].freeze
|
|
96
|
+
|
|
97
|
+
GREETING_SIZE = 128
|
|
98
|
+
SCRAMBLE_SIZE = 20
|
|
99
|
+
MAX_LIMIT = 0xffff_ffff
|
|
100
|
+
VSPACE_ID = 281
|
|
101
|
+
VINDEX_ID = 289
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
module Protocol
|
|
6
|
+
module Extensions
|
|
7
|
+
# MP_EXT datetime: int64 epoch seconds, then optional int32 nsec, int16 tzoffset, int16 tzindex.
|
|
8
|
+
# tzindex is ignored: Time keeps the offset, not the zone name.
|
|
9
|
+
# @api private
|
|
10
|
+
module Datetime
|
|
11
|
+
SECONDS = 'q<'
|
|
12
|
+
FULL = 'q<l<s<s<'
|
|
13
|
+
LAYOUTS = { 8 => SECONDS, 16 => FULL }.freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def pack(time)
|
|
18
|
+
offset, remainder = time.utc_offset.divmod(60)
|
|
19
|
+
raise ArgumentError, "utc_offset #{time.utc_offset}s is not in whole minutes" unless remainder.zero?
|
|
20
|
+
return [time.to_i].pack(SECONDS) if time.nsec.zero? && offset.zero?
|
|
21
|
+
|
|
22
|
+
[time.to_i, time.nsec, offset, 0].pack(FULL)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def unpack(payload)
|
|
26
|
+
layout = LAYOUTS.fetch(payload.bytesize) do
|
|
27
|
+
raise ArgumentError, "malformed datetime: #{payload.bytesize} bytes"
|
|
28
|
+
end
|
|
29
|
+
seconds, nsec, offset = payload.unpack(layout)
|
|
30
|
+
|
|
31
|
+
Time.at(seconds, nsec.to_i, :nsec, in: offset.to_i.zero? ? 'UTC' : offset * 60)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Extensions.register(Constants::MP_DATETIME, Time, packer: method(:pack), unpacker: method(:unpack))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
module Protocol
|
|
6
|
+
module Extensions
|
|
7
|
+
# MP_EXT decimal: msgpack int scale, then BCD digits with a trailing sign nibble.
|
|
8
|
+
# @api private
|
|
9
|
+
module Decimal
|
|
10
|
+
MAX_DIGITS = 38
|
|
11
|
+
# Digits, the sign and the zero nibble that pads them to whole bytes.
|
|
12
|
+
MAX_NIBBLES = MAX_DIGITS + 2
|
|
13
|
+
# Tarantool's decimal context: exponents up to 999_999, and down to -1_000_074 for subnormals and zeros.
|
|
14
|
+
SCALES = (-999_999..1_000_074)
|
|
15
|
+
NEGATIVE_SIGNS = %w[b d].freeze
|
|
16
|
+
BCD_NIBBLES = /\A\d+[a-f]\z/
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def pack(value)
|
|
21
|
+
raise ArgumentError, "cannot encode #{value} as decimal" unless value.finite?
|
|
22
|
+
|
|
23
|
+
scale, nibbles = scale_and_nibbles(value)
|
|
24
|
+
|
|
25
|
+
MessagePack.pack(scale) + [nibbles].pack('H*')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def unpack(payload)
|
|
29
|
+
unpacker = MessagePack::Unpacker.new.feed(payload)
|
|
30
|
+
scale = unpacker.read
|
|
31
|
+
nibbles = unpacker.buffer.read_all.unpack1('H*')
|
|
32
|
+
raise ArgumentError, "malformed decimal of #{payload.bytesize} bytes" unless valid?(scale, nibbles)
|
|
33
|
+
|
|
34
|
+
sign = NEGATIVE_SIGNS.include?(nibbles[-1]) ? '-' : ''
|
|
35
|
+
|
|
36
|
+
BigDecimal("#{sign}#{nibbles[0...-1]}e#{-scale}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def valid?(scale, nibbles)
|
|
40
|
+
scale.is_a?(Integer) && SCALES.cover?(scale) && nibbles.length <= MAX_NIBBLES &&
|
|
41
|
+
nibbles.match?(BCD_NIBBLES) && nibbles.delete_prefix('0').length <= MAX_DIGITS + 1
|
|
42
|
+
end
|
|
43
|
+
private_class_method :valid?
|
|
44
|
+
|
|
45
|
+
def scale_and_nibbles(value)
|
|
46
|
+
sign, digits, _base, exponent = value.split
|
|
47
|
+
if digits.length > MAX_DIGITS
|
|
48
|
+
raise ArgumentError, "decimal exceeds #{MAX_DIGITS} digits: #{value.to_s('F')}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
scale = value.zero? ? 0 : digits.length - exponent
|
|
52
|
+
scale, digits = expand_negative_scale(scale, digits)
|
|
53
|
+
nibbles = digits + (sign.negative? ? 'd' : 'c')
|
|
54
|
+
|
|
55
|
+
[scale, nibbles.length.odd? ? "0#{nibbles}" : nibbles]
|
|
56
|
+
end
|
|
57
|
+
private_class_method :scale_and_nibbles
|
|
58
|
+
|
|
59
|
+
# Tarantool itself writes whole numbers with scale 0 and trailing zero digits;
|
|
60
|
+
# keeping a negative scale when that would overflow MAX_DIGITS stays lossless.
|
|
61
|
+
def expand_negative_scale(scale, digits)
|
|
62
|
+
return [scale, digits] if scale >= 0 || digits.length - scale > MAX_DIGITS
|
|
63
|
+
|
|
64
|
+
[0, digits + ('0' * -scale)]
|
|
65
|
+
end
|
|
66
|
+
private_class_method :expand_negative_scale
|
|
67
|
+
|
|
68
|
+
Extensions.register(Constants::MP_DECIMAL, BigDecimal, packer: method(:pack), unpacker: method(:unpack))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
module Protocol
|
|
6
|
+
module Extensions
|
|
7
|
+
# MP_ERROR: a box.error stack decoded into a BoxError chain. Error fields may hold further MP_ERRORs,
|
|
8
|
+
# each decoded by a nested call; the depth sits in fiber-local storage, so concurrent decoders never share it.
|
|
9
|
+
# @api private
|
|
10
|
+
module Error
|
|
11
|
+
MAX_DEPTH = 16
|
|
12
|
+
DEPTH_KEY = :tarantool_mp_error_depth
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def unpack(payload)
|
|
17
|
+
depth = Thread.current[DEPTH_KEY] || 0
|
|
18
|
+
raise ProtocolError, "MP_ERROR nested deeper than #{MAX_DEPTH} levels" if depth >= MAX_DEPTH
|
|
19
|
+
|
|
20
|
+
Thread.current[DEPTH_KEY] = depth + 1
|
|
21
|
+
stack = Codec::FACTORY.load(payload, allow_unknown_ext: true).fetch(Constants::ERROR_STACK)
|
|
22
|
+
|
|
23
|
+
Client::BoxError.from_stack(stack)
|
|
24
|
+
ensure
|
|
25
|
+
Thread.current[DEPTH_KEY] = depth
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Extensions.register(Constants::MP_ERROR, Client::BoxError, unpacker: method(:unpack))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|