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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3c88acf7dfc96a8094a2c1562b7d98c86ae22670dc1cc853f69b8324df14bada
4
+ data.tar.gz: c46e62c20535c8733329f6ec9db242d5a1ab9b5bd0cf0b0205c330ac80961538
5
+ SHA512:
6
+ metadata.gz: 44fe72a002d29b8565fa35dcc812dfeaebcc6e8eb67a7993a275deab03136f628f235ffb345eb3df1a31ef41f9e5cedd144b27aa1a9ef53d46e54c931a3a7cf9
7
+ data.tar.gz: 424b07a9cd9d143c153303f07aa5986270ea0f2b1fc3acbe1c88230dff7f99d135824ad3a27676adff696cf8d5850e4b18c8486bd087d7f72aeff41d0c149027
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ First release.
6
+
7
+ - IPROTO client for Tarantool 2.11+ and 3.x: CRUD by space and index name, call, eval.
8
+ - SQL: `execute`, `prepare`.
9
+ - Interactive transactions over streams.
10
+ - `box.session.push` messages and watchers.
11
+ - Background reconnect with exponential backoff and full jitter.
12
+ - `Pool` with `:rw`, `:ro`, `:prefer_ro`, `:prefer_rw` and `:any` modes driven by `box.status`.
13
+ - `space_info`: space format and index parts from the schema cache.
14
+ - `select(after:)` keyset pagination.
15
+ - `max_frame_size` configuration option (default 64 MB) caps response frame size; frames exceeding it raise `ProtocolError` and break the connection.
16
+ - Per-request unread push messages capped at 10 000; exceeding the cap fails the request with `ProtocolError` without breaking the connection.
17
+ - Fork safety: `Client` and `Pool` reinitialize transparently in a child process on first use; streams created before `fork` raise `ConnectionError` in the child.
18
+ - `MP_ERROR` values nested deeper than 16 levels raise `ProtocolError` and break the connection instead of overflowing the stack.
19
+ - A malformed decimal (more than 38 digits or a scale outside Tarantool's exponent range) raises `ProtocolError` and breaks the connection; the message carries only the payload size.
20
+ - `connect_timeout` bounds the whole greeting read, not each byte of it.
21
+ - Watcher events are coalesced: events for unwatched keys are dropped and a key keeps only its latest pending value.
22
+ - A watcher callback raising any non-fatal exception is reported and no longer stops the dispatcher.
23
+ - `close` no longer re-raises an exception that killed an internal thread; a `Pool` reset after `fork` survives inherited threads that died.
24
+ - `Configuration#to_h` keeps the password, so `Tarantool::Client.new(**client.config.to_h)` works; `inspect`, `to_s` and `pp` still mask it.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Starlexxx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,222 @@
1
+ # tarantool-client
2
+
3
+ Ruby client for [Tarantool](https://www.tarantool.io) 2.11+ and 3.x over the binary IPROTO protocol.
4
+
5
+ - One connection serves any number of threads or fibers at once; responses are matched by sync.
6
+ - Works under a Fiber Scheduler (e.g. `async`) without depending on it.
7
+ - Reconnects in the background with exponential backoff.
8
+ - A pool routes requests by instance role (`box.status`).
9
+ - Single runtime dependency: `msgpack`.
10
+
11
+ ## Installation
12
+
13
+ Requirements: Ruby >= 3.2, Tarantool 2.11+ or 3.x.
14
+
15
+ ```ruby
16
+ gem 'tarantool-client'
17
+ ```
18
+
19
+ ```ruby
20
+ require 'tarantool/client'
21
+ ```
22
+
23
+ ## Connecting
24
+
25
+ ```ruby
26
+ client = Tarantool::Client.new('tcp://app:secret@localhost:3301', connect_timeout: 1, request_timeout: 5)
27
+ client.ping # => true
28
+ client.server_version # => "3.2.0"
29
+ client.config # => the resolved Configuration
30
+ client.close
31
+ ```
32
+
33
+ URIs: `tcp://user:password@host:port`, `host:port`, `unix:/path/to/socket`. Keyword options win over the URI.
34
+
35
+ | Option | Default | Meaning |
36
+ |---|---|---|
37
+ | `user`, `password` | `nil` | chap-sha1 authentication; no user means guest |
38
+ | `connect_timeout` | `3` | seconds for connect and handshake, and for a request to wait for a reconnect |
39
+ | `request_timeout` | `10` | default timeout of every request; each call accepts `timeout:` |
40
+ | `reconnect` | `true` | reconnect in the background after the connection is lost |
41
+ | `reconnect_attempts` | `Float::INFINITY` | failed attempts before the client gives up |
42
+ | `reconnect_delay` | `0.1` | base delay of the exponential backoff |
43
+ | `reconnect_max_delay` | `5` | cap of the backoff delay |
44
+ | `max_frame_size` | `67108864` (64 MB) | maximum allowed response frame size in bytes; larger frames raise `ProtocolError` and break the connection |
45
+
46
+ Tarantool Community Edition has no TLS support. Authentication uses chap-sha1; the password is never sent in clear. `client.inspect` leaves the password out, and `Configuration#inspect`, `#to_s` and `pp` replace it with `[FILTERED]`. `Configuration#to_h` keeps it, so `Tarantool::Client.new(**client.config.to_h)` connects with the same credentials; don't log it.
47
+
48
+ ## Data
49
+
50
+ Spaces and indexes accept names (Symbol or String) or numeric ids.
51
+
52
+ ```ruby
53
+ client.select(:users, [42])
54
+ client.select(:users, ['a@b.c'], index: :email, limit: 10, offset: 0, iterator: :ge)
55
+ client.insert(:users, [42, 'a@b.c', 0])
56
+ client.replace(:users, [42, 'x@y.z', 0])
57
+ client.update(:users, [42], [['=', 1, 'new@b.c']])
58
+ client.upsert(:users, [42, 'a@b.c', 0], [['+', 2, 1]])
59
+ client.delete(:users, [42])
60
+ ```
61
+
62
+ IPROTO update operations use 0-based field numbers: field 0 is the first field, field 1 the second, and so on.
63
+
64
+ Names are resolved through a schema cache loaded from `_vspace` and `_vindex`. A request resolved by name carries the schema version. If the schema changed meanwhile, the server rejects the request without running it; the client then reloads the schema once and retries within the same timeout.
65
+
66
+ `after:` continues a `select` past a tuple (keyset pagination, `IPROTO_AFTER_TUPLE`). It works on TREE indexes, including non-unique ones and `:lt`/`:le` (descending); a HASH index raises `ServerError` (code 112).
67
+
68
+ ```ruby
69
+ page = client.select(:users, [18], index: :age, iterator: :ge, limit: 50)
70
+ next_page = client.select(:users, [18], index: :age, iterator: :ge, limit: 50, after: page.last)
71
+ ```
72
+
73
+ `space_info` returns the format and indexes from the same schema cache; an unknown name costs one reload. Inside a transaction it reads the schema the transaction started with; on a `Pool` it accepts `mode:`.
74
+
75
+ ```ruby
76
+ info = client.space_info(:users)
77
+ info.fields.map(&:name) # => ["id", "email", "age"]
78
+ info.primary_index.parts.map(&:name) # => ["id"]
79
+ info.indexes['age'].unique # => false
80
+ ```
81
+
82
+ ## Lua
83
+
84
+ ```ruby
85
+ client.call('api.get_user', [42])
86
+ client.eval('return ...', [1, 2])
87
+ client.call('long_job', [], on_push: ->(message) { puts message }) # box.session.push messages
88
+ ```
89
+
90
+ `api.get_user` and `long_job` stand for Lua functions you define on the server; `call` needs `execute` on them.
91
+
92
+ ## SQL
93
+
94
+ ```ruby
95
+ result = client.execute('SELECT * FROM "users" WHERE "id" = ?', [42])
96
+ result.rows # => [[42, "a@b.c", 0]]
97
+ result.columns # => ["id", "email", "visits"]
98
+ result.row_count # DML only
99
+
100
+ statement = client.prepare('SELECT * FROM "users" WHERE "id" = ?')
101
+ statement.execute([42])
102
+ statement.close
103
+ ```
104
+
105
+ Tarantool 2.x uppercases unquoted identifiers, so `FROM users` fails with "Space 'USERS' does not exist" on 2.11; quote both table and column names to work on both versions. `SqlResult` is not `Enumerable`; iterate rows with `result.rows.each`.
106
+
107
+ ## Transactions
108
+
109
+ Interactive transactions run on their own IPROTO stream and need `memtx_use_mvcc_engine = true` on the server.
110
+
111
+ ```ruby
112
+ client.transaction(isolation: :read_committed, timeout: 5) do |tx|
113
+ tx.insert(:users, [43, 'b@c.d', 0])
114
+ tx.update(:users, [42], [['+', 2, 1]])
115
+ end
116
+ ```
117
+
118
+ The block argument is a `Tarantool::Client::Stream` and supports the same data operations as `Client`. The block's value is returned after COMMIT. Any other exit from the block rolls the transaction back. Isolation levels: `:default`, `:read_committed`, `:read_confirmed`, `:best_effort`, `:linearizable`. A nested `transaction` raises `TransactionError`, and so does a schema change during the transaction (retry the whole transaction).
119
+
120
+ ## Watchers
121
+
122
+ ```ruby
123
+ watcher = client.watch('box.status') { |key, value| puts value['is_ro'] }
124
+ watcher.unwatch
125
+ ```
126
+
127
+ The block gets the current value first (`nil` if the key was never broadcast), then every change. Blocks run on one dispatcher thread per client; exceptions are reported with `warn`.
128
+
129
+ ## Reconnect
130
+
131
+ - When the connection is lost, every request waiting for a reply fails with `ConnectionError`. Requests are never retried automatically.
132
+ - A background thread reconnects with exponential backoff and full jitter. Rejected credentials count as a failed attempt.
133
+ - Requests issued meanwhile wait for the new connection up to `connect_timeout` (and never past their own timeout), then raise `ConnectionError`.
134
+ - After reconnecting, the client performs the handshake and loads the schema of the new instance before any request can use it. It then subscribes the watchers again, and each watcher gets the current value.
135
+ - A transaction cannot survive a reconnect: the server rolls it back, and the next call in the block raises `TransactionError`.
136
+ - Prepared statements belong to the old session and are gone.
137
+ - After `reconnect_attempts` failures the client stops, and every request raises `ConnectionError`. With `reconnect: false`, requests after a loss raise `ConnectionError` right away.
138
+
139
+ ## Pool
140
+
141
+ ```ruby
142
+ pool = Tarantool::Client::Pool.new(%w[tcp://db1:3301 tcp://db2:3301], user: 'app', password: 'secret')
143
+ pool.select(:users, [42], mode: :prefer_ro)
144
+ pool.insert(:users, [44, 'c@d.e', 0], mode: :rw)
145
+ pool.transaction { |tx| tx.delete(:users, [44]) } # always on a writable instance
146
+ pool.close
147
+ ```
148
+
149
+ `watch`, `server_version`, and `connected?` are available on `Client` only, not on `Pool`. Every other operation accepts `mode:`:
150
+
151
+ | Mode | Instances |
152
+ |---|---|
153
+ | `:any` (default) | every instance whose role is known |
154
+ | `:rw` | writable ones |
155
+ | `:ro` | read-only ones |
156
+ | `:prefer_ro` | read-only ones, writable if none |
157
+ | `:prefer_rw` | writable ones, read-only if none |
158
+
159
+ Roles come from `box.status` (`is_ro`), so a switchover is picked up as soon as the instances report it. Requests are spread round-robin among matching instances. If none matches, the call raises `NoAvailableInstanceError`. `Pool.new` raises it only if no address answers; addresses that are down keep being retried in the background. That retrying covers start-up only: once connected, a member relies on its client's reconnect, so with a finite `reconnect_attempts` a member that gives up stays out of rotation. `Pool#close` may wait for an in-progress background connect attempt, up to `connect_timeout` + `request_timeout`. Options are passed to each client. A prepared statement from `pool.prepare` stays bound to the instance that prepared it.
160
+
161
+ ## Forking servers
162
+
163
+ `Client` and `Pool` are fork-safe. The first request in a child process after `fork` reinitializes the connection transparently — a new socket is opened, background threads are restarted, and watchers are resubscribed. The parent's connection is unaffected.
164
+
165
+ Streams (interactive transactions) cannot cross a fork boundary. Using a stream that was created before `fork` in a child raises `ConnectionError`.
166
+
167
+ No configuration is needed; the detection is a single PID comparison on every request.
168
+
169
+ ## Errors
170
+
171
+ ```
172
+ Tarantool::Error
173
+ ├── ConnectionError # refused, lost or broken connection; low-level errors are in #cause
174
+ │ └── ClientClosedError
175
+ ├── TimeoutError
176
+ ├── AuthenticationError # #code
177
+ ├── ProtocolError
178
+ ├── SchemaError # unknown space or index name
179
+ ├── ServerError # #code, #error_stack, #schema_version
180
+ ├── TransactionError
181
+ └── NoAvailableInstanceError
182
+ ```
183
+
184
+ The error classes are flat under `Tarantool::`, e.g. `Tarantool::ServerError`, not `Tarantool::Client::ServerError`.
185
+ tarantool-crud and tarantool-model errors also descend from `Tarantool::Error`.
186
+
187
+ `box.error` objects inside returned data decode into `Tarantool::Client::BoxError`.
188
+
189
+ ## Development
190
+
191
+ ```sh
192
+ bundle install # from the repo root; the gem has no Gemfile of its own
193
+ cd tarantool-client
194
+ bundle exec rspec # unit specs
195
+ docker compose -f docker-compose.yml up -d --wait
196
+ INTEGRATION=1 TARANTOOL_URI=tcp://app:secret@localhost:3301 bundle exec rspec # 2.11; 3302 for 3.x
197
+ bundle exec rubocop
198
+ ```
199
+
200
+ Reconnect and pool specs restart their own pair of instances:
201
+
202
+ ```sh
203
+ docker compose -p tarantool-failover -f docker-compose.yml --profile failover up -d --wait failover-a failover-b
204
+ TARANTOOL_FAILOVER_URIS=tcp://app:secret@localhost:3311,tcp://app:secret@localhost:3312 \
205
+ TARANTOOL_FAILOVER_CONTAINERS=tarantool-failover-failover-a-1,tarantool-failover-failover-b-1 \
206
+ bundle exec rspec --tag failover
207
+ ```
208
+
209
+ Using podman instead of docker: prefix the `bundle exec rspec --tag failover` command above with `CONTAINER_CLI=podman`; the reconnect spec uses it to restart containers (default `docker`).
210
+
211
+ ## Public API
212
+
213
+ `Tarantool::Client`, `Tarantool::Client::Pool`, `Tarantool::Client::Stream` (yielded by `transaction`),
214
+ `Watcher` (returned by `watch`), `SqlResult`, `PreparedStatement`, `SpaceInfo`, `IndexInfo`, `Configuration`
215
+ (returned by `client.config`), `Tarantool::Client::UUID`, `Interval`, `BoxError`, and the error classes above.
216
+
217
+ Everything else, including anything tagged `@api private` in its source comment, is an implementation detail
218
+ and may change in any release.
219
+
220
+ ## License
221
+
222
+ MIT
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ # Exponential backoff with full jitter: the delay before attempt n is uniformly random in
6
+ # [0, min(max, base * 2**n)), so clients that lost the same server don't reconnect in lockstep.
7
+ # @api private
8
+ class Backoff
9
+ # Keeps 2**n small for endless retries; the cap is reached long before.
10
+ MAX_EXPONENT = 32
11
+
12
+ def initialize(base:, max:, random: Random.new)
13
+ @base = base
14
+ @max = max
15
+ @random = random
16
+ end
17
+
18
+ def delay(attempt)
19
+ @random.rand * [@max, @base * (2**[attempt, MAX_EXPONENT].min)].min
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ # A box.error object returned as data (MP_ERROR extension), with its cause chain.
6
+ BoxError = Data.define(:type, :message, :code, :file, :line, :errno, :fields, :cause) do
7
+ def self.from_stack(entries)
8
+ entries.reverse_each.reduce(nil) do |cause, entry|
9
+ attributes = entry.transform_keys(Protocol::Constants::ERROR_FIELDS)
10
+ new(**Protocol::Constants::ERROR_FIELDS.values.to_h { |field| [field, attributes[field]] }, cause:)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ # Runs user code on the shared watcher dispatcher, which must outlive whatever that code raises. Exit and
6
+ # signals go to the main thread, where they would have ended the process without the dispatcher;
7
+ # NoMemoryError still ends the worker, and the next job starts a new one.
8
+ # @api private
9
+ module CallbackGuard
10
+ module_function
11
+
12
+ def run(description)
13
+ yield
14
+ rescue SystemExit, SignalException => e
15
+ Thread.main.raise(e)
16
+ rescue NoMemoryError
17
+ raise
18
+ rescue Exception => e
19
+ warn("#{description} failed: #{e.class}: #{e.message}")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Tarantool
6
+ class Client
7
+ # Immutable connection settings built from a URI and keyword options; options win.
8
+ Configuration = Data.define(
9
+ :host, :port, :path, :user, :password, :connect_timeout, :request_timeout,
10
+ :reconnect, :reconnect_attempts, :reconnect_delay, :reconnect_max_delay,
11
+ :max_frame_size
12
+ ) do
13
+ def self.build(uri = nil, **options)
14
+ new(**defaults, **parse_uri(uri), **options)
15
+ end
16
+
17
+ def self.defaults
18
+ {
19
+ host: 'localhost', port: 3301, path: nil, user: nil, password: nil, connect_timeout: 3, request_timeout: 10,
20
+ reconnect: true, reconnect_attempts: Float::INFINITY, reconnect_delay: 0.1, reconnect_max_delay: 5,
21
+ max_frame_size: 64 * 1024 * 1024
22
+ }
23
+ end
24
+
25
+ def self.parse_uri(uri)
26
+ return {} if uri.nil?
27
+ return parse_unix_uri(uri) if uri.match?(%r{\Aunix/?:})
28
+
29
+ parse_tcp_uri(uri)
30
+ end
31
+
32
+ def self.parse_unix_uri(uri)
33
+ match = %r{\Aunix/?:(?://(?=/))?(.+)\z}.match(uri)
34
+ path = match && match[1]
35
+ raise ArgumentError, 'invalid Tarantool URI' if path.nil? || path.match?(%r{\A/*\z})
36
+
37
+ { path: path }
38
+ end
39
+
40
+ def self.parse_tcp_uri(uri)
41
+ parsed = URI.parse(uri.include?('://') ? uri : "tcp://#{uri}")
42
+ user = parsed.user == '' ? nil : parsed.user
43
+
44
+ {
45
+ host: parsed.hostname,
46
+ port: parsed.port,
47
+ user: user && URI.decode_uri_component(user),
48
+ password: parsed.password && URI.decode_uri_component(parsed.password)
49
+ }.compact
50
+ rescue URI::InvalidURIError
51
+ raise ArgumentError, 'invalid Tarantool URI'
52
+ end
53
+ private_class_method :defaults, :parse_uri, :parse_unix_uri, :parse_tcp_uri
54
+
55
+ def address
56
+ return path if path
57
+
58
+ host.include?(':') ? "[#{host}]:#{port}" : "#{host}:#{port}"
59
+ end
60
+
61
+ def inspect
62
+ fields = to_h.merge(password: password && '[FILTERED]').map { |k, v| "#{k}=#{v.inspect}" }.join(', ')
63
+
64
+ "#<data #{self.class.name} #{fields}>"
65
+ end
66
+ alias_method :to_s, :inspect
67
+
68
+ def pretty_print(printer) = printer.text(inspect)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ # Base class for every error raised by tarantool-ruby gems.
5
+ class Error < StandardError; end
6
+
7
+ # Socket-level failure: refused, lost or broken connection.
8
+ class ConnectionError < Error; end
9
+
10
+ # Request issued after the connection was closed by the client.
11
+ class ClientClosedError < ConnectionError; end
12
+
13
+ # No response within the request timeout.
14
+ class TimeoutError < Error; end
15
+
16
+ # Server rejected the credentials.
17
+ class AuthenticationError < Error
18
+ attr_reader :code
19
+
20
+ def initialize(message = nil, code: nil)
21
+ super(message)
22
+ @code = code
23
+ end
24
+ end
25
+
26
+ # Malformed or unexpected data from the server.
27
+ class ProtocolError < Error; end
28
+
29
+ # Space or index name cannot be resolved.
30
+ class SchemaError < Error; end
31
+
32
+ # Transaction misuse or a transaction that can no longer proceed.
33
+ class TransactionError < Error; end
34
+
35
+ # No pool instance can serve a request in the requested mode.
36
+ class NoAvailableInstanceError < Error; end
37
+
38
+ # Error returned by the server for a request.
39
+ class ServerError < Error
40
+ attr_reader :code, :error_stack, :schema_version
41
+
42
+ def self.from_response(response)
43
+ new(
44
+ response.error_message,
45
+ code: response.error_code, error_stack: response.error_stack, schema_version: response.schema_version
46
+ )
47
+ end
48
+
49
+ def initialize(message, code:, error_stack: [], schema_version: nil)
50
+ super(message)
51
+ @code = code
52
+ @error_stack = error_stack
53
+ @schema_version = schema_version
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ # A client created before fork reopens its link in the child on first use: the parent's reader, reconnect
6
+ # and dispatcher threads don't exist there. Closing the inherited socket in the child sends nothing.
7
+ # @api private
8
+ module ForkSafety
9
+ private
10
+
11
+ def ensure_fork_safe
12
+ return if @pid == Process.pid
13
+
14
+ @reinit_lock.synchronize { reopen_after_fork unless @pid == Process.pid }
15
+ end
16
+
17
+ def reopen_after_fork
18
+ close_inherited_link
19
+ open_link
20
+ end
21
+
22
+ # Joining a thread that died with an exception re-raises it; nothing from the parent may stop the reopen.
23
+ def close_inherited_link
24
+ @link.close
25
+ rescue StandardError
26
+ nil
27
+ end
28
+
29
+ # Watcher registrations outlive a fork; on_ready resubscribes them on the new link.
30
+ def open_link
31
+ @link = Transport::Link.new(@config, **link_hooks)
32
+ @watchers&.restart_after_fork(@link)
33
+ @watchers ||= Watchers.new(@link, timeout: @config.request_timeout)
34
+ @link.event_handler = @watchers.method(:dispatch)
35
+ @schema_cache = SchemaCache.new
36
+ @stream_ids = Transport::StreamIds.new
37
+ @link.connect
38
+ @pid = Process.pid
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ # One index of a space as listed in _vindex.
6
+ IndexInfo = Data.define(:id, :name, :type, :unique, :parts) do
7
+ def self.from_tuple(tuple, fields)
8
+ _space_id, id, name, type, opts, parts = tuple
9
+
10
+ new(
11
+ id:, name:, type:, unique: (opts || {}).fetch('unique', true),
12
+ parts: (parts || []).map { |raw| IndexInfo::Part.from_raw(raw, fields) }
13
+ )
14
+ end
15
+ end
16
+
17
+ # `fieldno` is 0-based; `name` comes from the space format and is nil for a field beyond it.
18
+ IndexInfo::Part = Data.define(:fieldno, :name, :type, :is_nullable, :path) do
19
+ # _vindex keeps parts either as [fieldno, type] or as a map with nullability and a JSON path.
20
+ def self.from_raw(raw, fields)
21
+ fieldno, type, is_nullable, path = raw.is_a?(Hash) ? raw.values_at('field', 'type', 'is_nullable', 'path') : raw
22
+
23
+ new(fieldno:, name: fields[fieldno]&.name, type:, is_nullable: is_nullable || false, path:)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ # A Tarantool datetime.interval; adjust is :none, :excess or :last.
6
+ Interval = Data.define(:year, :month, :week, :day, :hour, :min, :sec, :nsec, :adjust) do
7
+ def initialize(year: 0, month: 0, week: 0, day: 0, hour: 0, min: 0, sec: 0, nsec: 0, adjust: :none)
8
+ units = { year:, month:, week:, day:, hour:, min:, sec:, nsec: }
9
+ validate_units(units)
10
+ super
11
+ end
12
+
13
+ private
14
+
15
+ def validate_units(units)
16
+ offenders = units.reject { |_name, value| value.is_a?(Integer) }
17
+ return if offenders.empty?
18
+
19
+ details = offenders.map { |name, value| "#{name}=#{value.inspect}" }.join(', ')
20
+
21
+ raise ArgumentError, "interval units must be integers: #{details}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ # Data operations, call, eval and SQL. Includers implement `perform(request, timeout:, on_push: nil)`,
6
+ # `perform_on_space(space, index, timeout:) { |space_id, index_id| request }`, which returns the response to
7
+ # the request built by the block, and `lookup_space(space, timeout)`, which returns its SpaceInfo.
8
+ # @api private
9
+ module Operations
10
+ def ping(timeout: nil)
11
+ perform(Protocol::Requests::Ping.new, timeout:)
12
+
13
+ true
14
+ end
15
+
16
+ def space_info(space, timeout: nil)
17
+ lookup_space(space, timeout)
18
+ end
19
+
20
+ def select(space, key = [], index: 0, limit: Protocol::Constants::MAX_LIMIT, offset: 0, iterator: :eq,
21
+ after: nil, timeout: nil)
22
+ perform_on_space(space, index, timeout:) do |space_id, index_id|
23
+ Protocol::Requests::Select.new(space_id:, index_id:, key: key_tuple(key), limit:, offset:, iterator:, after:)
24
+ end.data
25
+ end
26
+
27
+ def insert(space, tuple, timeout: nil)
28
+ perform_on_space(space, 0, timeout:) do |space_id|
29
+ Protocol::Requests::Insert.new(space_id:, tuple:)
30
+ end.data.first
31
+ end
32
+
33
+ def replace(space, tuple, timeout: nil)
34
+ perform_on_space(space, 0, timeout:) do |space_id|
35
+ Protocol::Requests::Replace.new(space_id:, tuple:)
36
+ end.data.first
37
+ end
38
+
39
+ def update(space, key, operations, index: 0, timeout: nil)
40
+ perform_on_space(space, index, timeout:) do |space_id, index_id|
41
+ Protocol::Requests::Update.new(space_id:, index_id:, key: key_tuple(key), operations:)
42
+ end.data.first
43
+ end
44
+
45
+ def upsert(space, tuple, operations, timeout: nil)
46
+ perform_on_space(space, 0, timeout:) do |space_id|
47
+ Protocol::Requests::Upsert.new(space_id:, tuple:, operations:)
48
+ end
49
+
50
+ nil
51
+ end
52
+
53
+ def delete(space, key, index: 0, timeout: nil)
54
+ perform_on_space(space, index, timeout:) do |space_id, index_id|
55
+ Protocol::Requests::Delete.new(space_id:, index_id:, key: key_tuple(key))
56
+ end.data.first
57
+ end
58
+
59
+ # on_push runs in the calling thread for each box.session.push message; an exception
60
+ # raised by it abandons the call.
61
+ def call(function_name, arguments = [], timeout: nil, on_push: nil)
62
+ perform(Protocol::Requests::Call.new(function_name:, arguments:), timeout:, on_push:).data
63
+ end
64
+
65
+ def eval(expression, arguments = [], timeout: nil)
66
+ perform(Protocol::Requests::Eval.new(expression:, arguments:), timeout:).data
67
+ end
68
+
69
+ def execute(sql, bindings = [], timeout: nil)
70
+ statement = sql.is_a?(PreparedStatement) ? sql.id : sql
71
+
72
+ SqlResult.from_response(perform(Protocol::Requests::Execute.new(statement:, bindings:), timeout:))
73
+ end
74
+
75
+ # A prepared statement belongs to the session, so a reconnect discards it.
76
+ def prepare(sql, timeout: nil)
77
+ response = perform(Protocol::Requests::Prepare.new(statement: sql), timeout:)
78
+
79
+ PreparedStatement.new(response.body.fetch(Protocol::Constants::STMT_ID), method(:perform))
80
+ end
81
+
82
+ private
83
+
84
+ def key_tuple(key)
85
+ key.is_a?(Array) ? key : [key]
86
+ end
87
+ end
88
+ end
89
+ end