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,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