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