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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE +21 -0
  4. data/README.md +222 -0
  5. data/lib/tarantool/client/backoff.rb +23 -0
  6. data/lib/tarantool/client/box_error.rb +15 -0
  7. data/lib/tarantool/client/callback_guard.rb +23 -0
  8. data/lib/tarantool/client/configuration.rb +71 -0
  9. data/lib/tarantool/client/errors.rb +56 -0
  10. data/lib/tarantool/client/fork_safety.rb +42 -0
  11. data/lib/tarantool/client/index_info.rb +27 -0
  12. data/lib/tarantool/client/interval.rb +25 -0
  13. data/lib/tarantool/client/operations.rb +89 -0
  14. data/lib/tarantool/client/pool/member.rb +141 -0
  15. data/lib/tarantool/client/pool/member_fork_safety.rb +34 -0
  16. data/lib/tarantool/client/pool.rb +127 -0
  17. data/lib/tarantool/client/prepared_statement.rb +25 -0
  18. data/lib/tarantool/client/protocol/auth/chap_sha1.rb +28 -0
  19. data/lib/tarantool/client/protocol/codec.rb +79 -0
  20. data/lib/tarantool/client/protocol/constants.rb +105 -0
  21. data/lib/tarantool/client/protocol/extensions/datetime.rb +39 -0
  22. data/lib/tarantool/client/protocol/extensions/decimal.rb +73 -0
  23. data/lib/tarantool/client/protocol/extensions/error.rb +33 -0
  24. data/lib/tarantool/client/protocol/extensions/interval.rb +59 -0
  25. data/lib/tarantool/client/protocol/extensions/uuid.rb +29 -0
  26. data/lib/tarantool/client/protocol/extensions.rb +27 -0
  27. data/lib/tarantool/client/protocol/greeting.rb +21 -0
  28. data/lib/tarantool/client/protocol/requests/auth.rb +23 -0
  29. data/lib/tarantool/client/protocol/requests/begin.rb +30 -0
  30. data/lib/tarantool/client/protocol/requests/call.rb +16 -0
  31. data/lib/tarantool/client/protocol/requests/commit.rb +16 -0
  32. data/lib/tarantool/client/protocol/requests/delete.rb +19 -0
  33. data/lib/tarantool/client/protocol/requests/eval.rb +16 -0
  34. data/lib/tarantool/client/protocol/requests/execute.rb +21 -0
  35. data/lib/tarantool/client/protocol/requests/id.rb +16 -0
  36. data/lib/tarantool/client/protocol/requests/insert.rb +16 -0
  37. data/lib/tarantool/client/protocol/requests/ping.rb +17 -0
  38. data/lib/tarantool/client/protocol/requests/prepare.rb +16 -0
  39. data/lib/tarantool/client/protocol/requests/replace.rb +16 -0
  40. data/lib/tarantool/client/protocol/requests/rollback.rb +16 -0
  41. data/lib/tarantool/client/protocol/requests/select.rb +32 -0
  42. data/lib/tarantool/client/protocol/requests/unwatch.rb +16 -0
  43. data/lib/tarantool/client/protocol/requests/update.rb +24 -0
  44. data/lib/tarantool/client/protocol/requests/upsert.rb +19 -0
  45. data/lib/tarantool/client/protocol/requests/watch.rb +16 -0
  46. data/lib/tarantool/client/protocol/response.rb +36 -0
  47. data/lib/tarantool/client/schema.rb +69 -0
  48. data/lib/tarantool/client/schema_cache.rb +54 -0
  49. data/lib/tarantool/client/space_info.rb +27 -0
  50. data/lib/tarantool/client/sql_result.rb +24 -0
  51. data/lib/tarantool/client/stream.rb +130 -0
  52. data/lib/tarantool/client/thread_join.rb +19 -0
  53. data/lib/tarantool/client/transaction_schema.rb +57 -0
  54. data/lib/tarantool/client/transaction_scope.rb +28 -0
  55. data/lib/tarantool/client/transport/connection.rb +141 -0
  56. data/lib/tarantool/client/transport/deadline.rb +17 -0
  57. data/lib/tarantool/client/transport/frame_writer.rb +41 -0
  58. data/lib/tarantool/client/transport/future.rb +60 -0
  59. data/lib/tarantool/client/transport/handshake.rb +36 -0
  60. data/lib/tarantool/client/transport/link.rb +150 -0
  61. data/lib/tarantool/client/transport/link_state.rb +115 -0
  62. data/lib/tarantool/client/transport/pending_requests.rb +55 -0
  63. data/lib/tarantool/client/transport/stream_ids.rb +20 -0
  64. data/lib/tarantool/client/transport/timed_lock.rb +53 -0
  65. data/lib/tarantool/client/transport/timed_read.rb +31 -0
  66. data/lib/tarantool/client/uuid.rb +20 -0
  67. data/lib/tarantool/client/version.rb +7 -0
  68. data/lib/tarantool/client/watcher.rb +37 -0
  69. data/lib/tarantool/client/watchers/dispatcher.rb +40 -0
  70. data/lib/tarantool/client/watchers.rb +137 -0
  71. data/lib/tarantool/client.rb +216 -0
  72. metadata +148 -0
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ # Format and indexes of a space from the loaded schema. `indexes` maps names to IndexInfo in id order.
6
+ SpaceInfo = Data.define(:id, :name, :fields, :indexes) do
7
+ def self.from_tuples(space, index_tuples)
8
+ id, _owner, name, _engine, _field_count, _flags, format = space
9
+ fields = (format || []).map { |raw| SpaceInfo::Field.from_raw(raw) }
10
+ indexes = index_tuples.map { |tuple| IndexInfo.from_tuple(tuple, fields) }.sort_by(&:id)
11
+
12
+ new(id:, name:, fields:, indexes: indexes.to_h { |index| [index.name, index] })
13
+ end
14
+
15
+ def primary_index
16
+ indexes.each_value.find { |index| index.id.zero? }
17
+ end
18
+ end
19
+
20
+ SpaceInfo::Field = Data.define(:name, :type, :is_nullable) do
21
+ # A format entry without a type accepts any value.
22
+ def self.from_raw(raw)
23
+ new(name: raw['name'], type: raw.fetch('type', 'any'), is_nullable: raw.fetch('is_nullable', false))
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ # Rows and column names of an SQL query, or the counters of a data-changing statement.
6
+ SqlResult = Data.define(:rows, :columns, :row_count, :autoincrement_ids) do
7
+ def self.from_response(response)
8
+ info = response.body.fetch(Protocol::Constants::SQL_INFO, {})
9
+
10
+ new(
11
+ rows: response.data || [],
12
+ columns: column_names(response.body),
13
+ row_count: info[Protocol::Constants::SQL_INFO_ROW_COUNT],
14
+ autoincrement_ids: info.fetch(Protocol::Constants::SQL_INFO_AUTOINCREMENT_IDS, [])
15
+ )
16
+ end
17
+
18
+ def self.column_names(body)
19
+ body.fetch(Protocol::Constants::METADATA, []).map { |field| field.fetch(Protocol::Constants::FIELD_NAME) }
20
+ end
21
+ private_class_method :column_names
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ # Operations sent over one IPROTO stream inside an interactive transaction; the server runs a
6
+ # stream's requests in order. Created by Client#transaction.
7
+ class Stream
8
+ include Operations
9
+
10
+ LOST = 'connection lost during the transaction; the server rolled it back'
11
+ FINISHED = 'transaction is finished'
12
+
13
+ def initialize(connection:, schema_cache:, id:, request_timeout:)
14
+ @connection = connection
15
+ @schema_cache = schema_cache
16
+ @id = id
17
+ @request_timeout = request_timeout
18
+ @state = :idle
19
+ @pid = Process.pid
20
+ end
21
+
22
+ def transaction(isolation:, timeout:, &)
23
+ raise TransactionError, TransactionScope::NESTED unless @state == :idle
24
+
25
+ start(isolation, timeout)
26
+
27
+ run(&)
28
+ end
29
+
30
+ def prepare(*)
31
+ raise TransactionError,
32
+ 'prepare statements on the client; execute them in the transaction with tx.execute(statement, bindings)'
33
+ end
34
+
35
+ private
36
+
37
+ def start(isolation, timeout)
38
+ response = perform(Protocol::Requests::Begin.new(isolation:, timeout:))
39
+ @schema = TransactionSchema.new(@schema_cache, @connection, response.schema_version)
40
+
41
+ @state = :active
42
+ end
43
+
44
+ # Anything but normal completion of the block, including break and throw, rolls back.
45
+ def run
46
+ result = yield self
47
+ finish(Protocol::Requests::Commit.new)
48
+
49
+ result
50
+ ensure
51
+ abandon if @state == :active
52
+ end
53
+
54
+ def perform(request, timeout: nil, schema_version: nil, on_push: nil)
55
+ check_active
56
+
57
+ send_request(request, timeout: timeout || @request_timeout, schema_version:, on_push:)
58
+ end
59
+
60
+ def perform_on_space(space, index, timeout: nil, &)
61
+ return perform(yield(space, index), timeout:) if space.is_a?(Integer) && index.is_a?(Integer)
62
+
63
+ perform_by_name(space, index, timeout || @request_timeout, &)
64
+ end
65
+
66
+ # Checks for a lost connection first: resolving would otherwise meet the next connection's schema
67
+ # and report a schema change instead.
68
+ def perform_by_name(space, index, timeout)
69
+ check_connection
70
+ deadline = Transport::Deadline.after(timeout)
71
+ request = yield(*@schema.resolve(space, index, deadline))
72
+
73
+ perform(request, timeout: remaining(deadline), schema_version: @schema.version)
74
+ rescue ServerError => e
75
+ raise unless e.code == Protocol::Constants::ER_WRONG_SCHEMA_VERSION
76
+
77
+ @schema.raise_changed(deadline)
78
+ rescue TimeoutError
79
+ raise TimeoutError, "no response within #{timeout}s"
80
+ end
81
+
82
+ def lookup_space(space, timeout)
83
+ check_active
84
+ check_connection
85
+ timeout ||= @request_timeout
86
+
87
+ @schema.space_info(space, Transport::Deadline.after(timeout))
88
+ rescue TimeoutError
89
+ raise TimeoutError, "no response within #{timeout}s"
90
+ end
91
+
92
+ def remaining(deadline)
93
+ Transport::Deadline.remaining(deadline)
94
+ end
95
+
96
+ # State flips to finished before the request so a failed COMMIT is not followed by ROLLBACK:
97
+ # its outcome is unknown (the server may have committed), and a rollback would add nothing.
98
+ def finish(request)
99
+ @state = :finished
100
+
101
+ send_request(request, timeout: @request_timeout)
102
+ end
103
+
104
+ # A stream is bound to its connection's session, so a reconnect can't carry the transaction over.
105
+ def send_request(request, **)
106
+ check_connection
107
+
108
+ @connection.perform(request, stream_id: @id, **)
109
+ end
110
+
111
+ def check_connection
112
+ raise ConnectionError, 'stream was created before fork and cannot be used in a child process' \
113
+ if Process.pid != @pid
114
+ raise TransactionError, LOST if @state != :idle && @connection.closed?
115
+ end
116
+
117
+ def check_active
118
+ raise TransactionError, FINISHED if @state == :finished
119
+ end
120
+
121
+ # Runs while an error or a non-local exit is already propagating, which a failed rollback must
122
+ # not replace. A lost connection takes the transaction with it.
123
+ def abandon
124
+ finish(Protocol::Requests::Rollback.new)
125
+ rescue Error
126
+ nil
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ # Joins an internal thread without re-raising the exception it died with: the failure has already
6
+ # reached whoever was waiting on the thread, and closing must not fail because of it. An exception
7
+ # raised in the joining thread itself while it waits still propagates.
8
+ # @api private
9
+ module ThreadJoin
10
+ module_function
11
+
12
+ def join(thread)
13
+ thread&.join unless Thread.current == thread
14
+ rescue Exception
15
+ raise unless thread.status.nil?
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ # Name lookup for one transaction, pinned to the schema version the server reported for BEGIN.
6
+ # Re-resolving names against a newer schema mid-transaction could send later statements to a
7
+ # different space than earlier ones, so a schema change fails the transaction instead.
8
+ # @api private
9
+ class TransactionSchema
10
+ CHANGED = 'schema changed during the transaction; retry the whole transaction'
11
+
12
+ attr_reader :version
13
+
14
+ def initialize(cache, connection, version)
15
+ @cache = cache
16
+ @connection = connection
17
+ @version = version
18
+ end
19
+
20
+ def resolve(space, index, deadline)
21
+ pinned(deadline).resolve(space, index)
22
+ end
23
+
24
+ def space_info(space, deadline)
25
+ pinned(deadline).space_info(space)
26
+ end
27
+
28
+ # A request rejected for its schema version did not run, so the transaction still rolls back
29
+ # cleanly. The reload spares the retried transaction the same rejection.
30
+ def raise_changed(deadline)
31
+ schema = @cache.schema
32
+ reload(schema, deadline) if schema.version == version
33
+
34
+ raise TransactionError, CHANGED
35
+ end
36
+
37
+ private
38
+
39
+ # A cache older than BEGIN was stale before the transaction started and may catch up;
40
+ # anything newer means the schema changed after BEGIN. A schema of another connection means
41
+ # this one was lost, and the versions of two instances can't be compared.
42
+ def pinned(deadline)
43
+ schema = @cache.schema
44
+ raise TransactionError, Stream::LOST unless schema.connection.equal?(@connection)
45
+
46
+ schema = reload(schema, deadline) if schema.version < version
47
+ raise TransactionError, CHANGED unless schema.version == version
48
+
49
+ schema
50
+ end
51
+
52
+ def reload(schema, deadline)
53
+ @cache.reload(schema, timeout: Transport::Deadline.remaining(deadline))
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ # Clients with a transaction open in the current fiber. Thread#[] is fiber-local and, unlike
6
+ # Fiber storage, not inherited, so a thread or fiber spawned inside a transaction block may
7
+ # open its own transaction.
8
+ # @api private
9
+ module TransactionScope
10
+ NESTED = 'nested transactions are not supported'
11
+
12
+ module_function
13
+
14
+ def enter(client)
15
+ open = Thread.current[:tarantool_transactions] ||= {}
16
+ raise TransactionError, NESTED if open.key?(client)
17
+
18
+ open[client] = true
19
+
20
+ begin
21
+ yield
22
+ ensure
23
+ open.delete(client)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+
5
+ module Tarantool
6
+ class Client
7
+ module Transport
8
+ # One multiplexed IPROTO connection. Any number of threads or fibers may
9
+ # perform requests concurrently; a reader thread routes responses by sync.
10
+ # @api private
11
+ class Connection
12
+ attr_reader :greeting, :server_features
13
+ attr_writer :event_handler, :disconnect_handler
14
+
15
+ def initialize(config)
16
+ @config = config
17
+ @pending = PendingRequests.new
18
+ end
19
+
20
+ def connect
21
+ establish
22
+
23
+ self
24
+ rescue StandardError
25
+ close
26
+ raise
27
+ end
28
+
29
+ # A request sent with no time left could still run on the server while the caller gets TimeoutError.
30
+ def perform(request, timeout:, stream_id: nil, schema_version: nil, on_push: nil)
31
+ deadline = Deadline.after(timeout)
32
+ raise TimeoutError, "no response within #{timeout}s" if Deadline.remaining(deadline)&.zero?
33
+
34
+ sync, future = @pending.register
35
+ frame = Protocol::Codec.encode(request, sync:, stream_id:, schema_version:)
36
+ response = exchange(frame, future, deadline, timeout, &on_push)
37
+ raise ServerError.from_response(response) if response.error?
38
+
39
+ response
40
+ ensure
41
+ @pending.delete(sync) if sync
42
+ end
43
+
44
+ # For requests the server never answers (WATCH, UNWATCH): there is no sync to wait on.
45
+ def notify(request, timeout:)
46
+ write(Protocol::Codec.encode(request, sync: 0), Deadline.after(timeout))
47
+ end
48
+
49
+ def close
50
+ @pending.fail_all(ClientClosedError.new("connection to #{@config.address} closed"))
51
+ @socket&.close
52
+ ThreadJoin.join(@reader)
53
+
54
+ nil
55
+ end
56
+
57
+ def closed?
58
+ !@pending.failure.nil?
59
+ end
60
+
61
+ private
62
+
63
+ def establish
64
+ deadline = Deadline.after(@config.connect_timeout)
65
+ @socket = open_socket(deadline)
66
+ @writer = FrameWriter.new(@socket)
67
+ @greeting = read_greeting(deadline)
68
+ start_reader
69
+
70
+ @server_features = Handshake.perform(self, @config, deadline)
71
+ rescue IOError, SystemCallError, SocketError, TimeoutError, ServerError => e
72
+ raise ConnectionError, "cannot connect to #{@config.address}: #{e.message}"
73
+ end
74
+
75
+ def open_socket(deadline)
76
+ return UNIXSocket.new(@config.path) if @config.path
77
+
78
+ Socket.tcp(@config.host, @config.port, connect_timeout: Deadline.remaining(deadline)).tap do |socket|
79
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
80
+ end
81
+ end
82
+
83
+ def read_greeting(deadline)
84
+ Protocol::Greeting.parse(TimedRead.read_exactly(@socket, Protocol::Constants::GREETING_SIZE, deadline))
85
+ end
86
+
87
+ def start_reader
88
+ @reader = Thread.new { read_loop }
89
+
90
+ @reader.name = "tarantool-reader #{@config.address}"
91
+ end
92
+
93
+ def read_loop
94
+ loop { route(Protocol::Codec.read_response(@socket, max_frame_size: @config.max_frame_size)) }
95
+ rescue IOError, SystemCallError, ProtocolError => e
96
+ reason = e.message
97
+ rescue StandardError, SystemStackError => e
98
+ reason = "#{e.class}: #{e.message}"
99
+ ensure
100
+ break_connection("connection to #{@config.address} lost: #{reason || 'reader stopped'}")
101
+ end
102
+
103
+ # Events carry the sync of whichever WATCH triggered them, so they are routed by type.
104
+ def route(response)
105
+ return @pending.push(response.sync, response.data.first) if response.push?
106
+ return @event_handler&.call(response.event_key, response.event_data) if response.event?
107
+
108
+ @pending.resolve(response.sync, response)
109
+ end
110
+
111
+ # A write that runs out of time, waiting for the lock or for the socket, is reported like a missing reply.
112
+ def exchange(frame, future, deadline, timeout, &)
113
+ write(frame, deadline)
114
+
115
+ future.value(Deadline.remaining(deadline), &)
116
+ rescue TimeoutError
117
+ raise TimeoutError, "no response within #{timeout}s"
118
+ end
119
+
120
+ # A partially written frame corrupts the stream, so any interruption after the
121
+ # first byte (deadline, IO error, Thread#raise, Timeout) kills the connection.
122
+ def write(frame, deadline)
123
+ @writer.write(frame, deadline) { break_connection("write to #{@config.address} did not complete") }
124
+ rescue IOError, SystemCallError => e
125
+ failure = @pending.failure
126
+ raise failure.exception(failure.message) if failure
127
+
128
+ raise ConnectionError, "write to #{@config.address} failed: #{e.message}"
129
+ end
130
+
131
+ # The handler hears only of a loss: close records ClientClosedError first, so it stays silent.
132
+ def break_connection(message)
133
+ lost = @pending.fail_all(ConnectionError.new(message))
134
+ @socket&.close
135
+
136
+ @disconnect_handler&.call if lost
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ module Transport
6
+ # Monotonic-clock deadlines. A nil timeout gives a nil deadline, which never expires.
7
+ # @api private
8
+ module Deadline
9
+ module_function
10
+
11
+ def after(timeout) = timeout && (now + timeout)
12
+ def remaining(deadline) = deadline && [deadline - now, 0].max
13
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ module Transport
6
+ # Serializes whole-frame writes to a socket; both the wait for the lock and the write are bounded by the deadline.
7
+ # Yields when a write is interrupted after part of the frame went out, because
8
+ # the stream is corrupt from then on.
9
+ # @api private
10
+ class FrameWriter
11
+ def initialize(socket)
12
+ @socket = socket
13
+ @lock = TimedLock.new
14
+ end
15
+
16
+ # Async interrupts (Thread#raise, Timeout) are held until the blocking wait, so they
17
+ # never land between a write and the update of the unsent remainder.
18
+ def write(frame, deadline)
19
+ @lock.synchronize(deadline) do
20
+ rest = frame
21
+ Thread.handle_interrupt(Object => :on_blocking) do
22
+ rest = write_some(rest, deadline) until rest.empty?
23
+ end
24
+ ensure
25
+ yield if rest && !rest.empty? && rest.bytesize < frame.bytesize
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def write_some(frame, deadline)
32
+ written = @socket.write_nonblock(frame, exception: false)
33
+ return frame.byteslice(written..) unless written == :wait_writable
34
+ raise TimeoutError, 'socket write timed out' unless @socket.wait_writable(Deadline.remaining(deadline))
35
+
36
+ frame
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ # @api private
6
+ module Transport
7
+ # Result slot for one in-flight request, also carrying its pushed messages. Thread::Queue#pop
8
+ # yields to the Fiber Scheduler when one is active, so waiting never blocks other fibers.
9
+ # @api private
10
+ class Future
11
+ Push = Data.define(:message)
12
+ private_constant :Push
13
+
14
+ MAX_UNREAD_PUSHES = 10_000
15
+ private_constant :MAX_UNREAD_PUSHES
16
+
17
+ def initialize
18
+ @queue = Thread::Queue.new
19
+ @overflow = false
20
+ end
21
+
22
+ def resolve(value)
23
+ @queue.push(value)
24
+ end
25
+
26
+ def reject(error)
27
+ @queue.push(error)
28
+ end
29
+
30
+ def push(message)
31
+ return if @overflow
32
+
33
+ if @queue.size >= MAX_UNREAD_PUSHES
34
+ @overflow = true
35
+ @queue.push(ProtocolError.new('too many unread push messages'))
36
+ return
37
+ end
38
+
39
+ @queue.push(Push.new(message))
40
+ end
41
+
42
+ # Yields each pushed message, in the waiting thread, until the final value arrives.
43
+ def value(timeout)
44
+ deadline = Deadline.after(timeout)
45
+
46
+ loop do
47
+ result = @queue.pop(timeout: Deadline.remaining(deadline))
48
+ raise TimeoutError, "no response within #{timeout}s" if result.nil?
49
+ # One error object is shared by all failed futures; each waiter raises its own copy
50
+ # so concurrent raises don't race on it.
51
+ raise result.exception(result.message) if result.is_a?(Exception)
52
+ return result unless result.is_a?(Push)
53
+
54
+ yield result.message if block_given?
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ module Transport
6
+ # IPROTO_ID feature exchange, then chap-sha1 AUTH for a named user. Returns the server features.
7
+ # @api private
8
+ module Handshake
9
+ module_function
10
+
11
+ def perform(connection, config, deadline)
12
+ id = Protocol::Requests::Id.new(
13
+ version: Protocol::Constants::PROTOCOL_VERSION, features: Protocol::Constants::CLIENT_FEATURES
14
+ )
15
+ response = connection.perform(id, timeout: Deadline.remaining(deadline))
16
+ authenticate(connection, config, deadline) if config.user && config.user != 'guest'
17
+
18
+ response.body.fetch(Protocol::Constants::FEATURES, [])
19
+ end
20
+
21
+ def authenticate(connection, config, deadline)
22
+ scramble = Protocol::Auth::ChapSha1.scramble(config.password.to_s, connection.greeting.salt)
23
+ request = Protocol::Requests::Auth.new(
24
+ user: config.user, mechanism: Protocol::Auth::ChapSha1::MECHANISM, scramble:
25
+ )
26
+
27
+ connection.perform(request, timeout: Deadline.remaining(deadline))
28
+ rescue ServerError => e
29
+ raise AuthenticationError.new(e.message, code: e.code)
30
+ end
31
+
32
+ private_class_method :authenticate
33
+ end
34
+ end
35
+ end
36
+ end