tarantool-client 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +12 -1
- data/README.md +79 -1
- data/lib/tarantool/client/batch_delete.rb +97 -0
- data/lib/tarantool/client/configuration.rb +20 -3
- data/lib/tarantool/client/diagnostics.rb +20 -0
- data/lib/tarantool/client/errors.rb +8 -1
- data/lib/tarantool/client/fork_safety.rb +2 -1
- data/lib/tarantool/client/instrumentation/log_instrumenter.rb +35 -0
- data/lib/tarantool/client/instrumentation/log_lines.rb +66 -0
- data/lib/tarantool/client/instrumentation/request_payload.rb +42 -0
- data/lib/tarantool/client/instrumentation.rb +51 -0
- data/lib/tarantool/client/instrumented.rb +42 -0
- data/lib/tarantool/client/named_requests.rb +67 -0
- data/lib/tarantool/client/operations.rb +53 -21
- data/lib/tarantool/client/pool/member.rb +4 -9
- data/lib/tarantool/client/pool/member_fork_safety.rb +10 -5
- data/lib/tarantool/client/pool/member_role.rb +36 -0
- data/lib/tarantool/client/row.rb +8 -0
- data/lib/tarantool/client/schema.rb +4 -3
- data/lib/tarantool/client/space_info.rb +2 -0
- data/lib/tarantool/client/stream.rb +20 -10
- data/lib/tarantool/client/transaction_schema.rb +12 -8
- data/lib/tarantool/client/transport/connection.rb +2 -0
- data/lib/tarantool/client/transport/link.rb +15 -15
- data/lib/tarantool/client/transport/link_events.rb +41 -0
- data/lib/tarantool/client/transport/link_state.rb +10 -0
- data/lib/tarantool/client/tuple_mapping.rb +65 -0
- data/lib/tarantool/client/tuple_shape.rb +25 -0
- data/lib/tarantool/client/version.rb +1 -1
- data/lib/tarantool/client.rb +23 -48
- metadata +15 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 239078d6bcc2b8d55971852a3af66f3b1db2e40822bfec113f81442d7946bf08
|
|
4
|
+
data.tar.gz: 8244a106fca7d641f7eaaf42377679de89ed2d1641d2c964f665d3bdff7953c9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c3fb785f1d6362c9f3a6e94f951b1c761b73152ea7b76d7b1666726b1e130892aada79ea38c7f46be339557e4000dee41da8db0e547e0ee47e8f1c3df76a8daf
|
|
7
|
+
data.tar.gz: 81fc563d05f48cc9cfa4d640fb00671d435951bea5cea85092394ab56914040967d99b89c0cc577bf6e37d71e0775c956060fe4fc5caecc4ec42941282e59495
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.2.0 (2026-10-17)
|
|
4
|
+
|
|
5
|
+
- `as: :hash` and `as: :row` on `select`, `insert`, `replace`, `update` and `delete` return tuples as Hashes by the space format (`Client::Row` keeps the raw tuple too); `SpaceInfo#to_hash` and `#to_tuple` do the conversion.
|
|
6
|
+
- `insert`, `replace` and `upsert` accept a Hash; missing fields go out as nil, an unknown key raises `ArgumentError`.
|
|
7
|
+
- The format comes from the schema the request runs against; a schema change makes the server reject the request and the retry rebuilds it by the new format. A numeric space id unknown to the cache costs one reload too, like a name.
|
|
8
|
+
- A Hash key unknown to the cached format costs one schema reload; a field still missing after it raises `UnknownFieldError` (an `ArgumentError`). Inside a transaction there is no reload.
|
|
9
|
+
- An unknown numeric space id paired with a named index now raises `SchemaError` with "space 'N' not found" instead of "index '...' not found in space N".
|
|
10
|
+
- `delete_by`: delete every tuple matching a key on any TREE index, including non-unique ones, in keyset batches; not atomic outside a transaction, the error carries `#deleted`.
|
|
11
|
+
- `instrumenter:` option on `Client` and `Pool`: `request.tarantool` for every call and transaction step, plus `disconnect`, `reconnect`, `reconnect_failed`, `fork` and `role_change` events. Payloads carry no keys, tuples, arguments, bindings or credentials; exceptions use ActiveSupport's `exception` and `exception_object` keys.
|
|
12
|
+
- `logger:` option: a debug line per request, lifecycle lines, and background warnings that used to go to stderr.
|
|
13
|
+
|
|
14
|
+
## 0.1.0 (2026-10-16)
|
|
4
15
|
|
|
5
16
|
First release.
|
|
6
17
|
|
data/README.md
CHANGED
|
@@ -42,6 +42,8 @@ URIs: `tcp://user:password@host:port`, `host:port`, `unix:/path/to/socket`. Keyw
|
|
|
42
42
|
| `reconnect_delay` | `0.1` | base delay of the exponential backoff |
|
|
43
43
|
| `reconnect_max_delay` | `5` | cap of the backoff delay |
|
|
44
44
|
| `max_frame_size` | `67108864` (64 MB) | maximum allowed response frame size in bytes; larger frames raise `ProtocolError` and break the connection |
|
|
45
|
+
| `instrumenter` | `nil` | receives request and lifecycle events; see [Instrumentation](#instrumentation) |
|
|
46
|
+
| `logger` | `nil` | `Logger`-compatible; one debug line per request, lifecycle lines, background warnings |
|
|
45
47
|
|
|
46
48
|
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
49
|
|
|
@@ -70,6 +72,23 @@ page = client.select(:users, [18], index: :age, iterator: :ge, limit: 50)
|
|
|
70
72
|
next_page = client.select(:users, [18], index: :age, iterator: :ge, limit: 50, after: page.last)
|
|
71
73
|
```
|
|
72
74
|
|
|
75
|
+
`delete_by` deletes every tuple a select finds, including by a non-unique index, which IPROTO `DELETE` can't do. It selects `batch_size` tuples at a time (default 1000), continues past the last one with `after:`, deletes each by primary key, and returns the number deleted. TREE indexes only, like `after:`. Multikey and functional indexes are not supported either: tuple-based `after` raises `ServerError` there, before deleting anything.
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
client.delete_by(:users, [18], index: :age) # => 42
|
|
79
|
+
client.delete_by(:users, [18], index: :age, iterator: :lt, batch_size: 500, timeout: 30)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Outside a transaction `delete_by` is **not atomic**: the batches are separate requests, and if one fails the tuples deleted so far stay deleted. The raised error is the original one (`TimeoutError`, `ConnectionError`, `ServerError`, ...) with `#deleted` set to the number of deletes confirmed before the failure (a delete cut off by a timeout or a lost connection may still have run, so treat it as a lower bound); `deleted` is filled only by `delete_by` and is `nil` on every other error. Tuples inserted while it runs are deleted if they land ahead of the cursor; tuples inserted **behind the cursor are not deleted**. `timeout:` bounds the whole operation; without it each request gets `request_timeout`.
|
|
83
|
+
|
|
84
|
+
Inside a transaction it is atomic — a failure rolls back every delete:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
client.transaction { |tx| tx.delete_by(:users, [18], index: :age) }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
This needs `memtx_use_mvcc_engine = true` on the server, like any transaction on memtx.
|
|
91
|
+
|
|
73
92
|
`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
93
|
|
|
75
94
|
```ruby
|
|
@@ -79,6 +98,17 @@ info.primary_index.parts.map(&:name) # => ["id"]
|
|
|
79
98
|
info.indexes['age'].unique # => false
|
|
80
99
|
```
|
|
81
100
|
|
|
101
|
+
`as: :hash` returns tuples as Hashes keyed by field name; fields beyond the format are keyed by their 0-based position. `as: :row` returns `Client::Row` with the raw `tuple` and its `attributes`. `insert`, `replace` and `upsert` take a Hash too: Symbol or String names, missing fields go out as nil, an unknown name raises `ArgumentError`. A key may also be an Integer position past the format, up to 1024 positions beyond it; a negative position or one further out also raises `ArgumentError`. The format comes from the same schema the request carries the version of, so an alter in between makes the server reject the request and the client rebuilds it by the new format. This works the same in transactions and on a `Pool`.
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
client.insert(:users, { id: 42, email: 'a@b.c', age: 0 })
|
|
105
|
+
client.select(:users, [42], as: :hash) # => [{ id: 42, email: "a@b.c", age: 0 }]
|
|
106
|
+
row = client.select(:users, [42], as: :row).first
|
|
107
|
+
row.tuple # => [42, "a@b.c", 0]
|
|
108
|
+
row.attributes # => { id: 42, email: "a@b.c", age: 0 }
|
|
109
|
+
info.to_hash([42, 'a@b.c', 0]) # => { id: 42, email: "a@b.c", age: 0 }
|
|
110
|
+
```
|
|
111
|
+
|
|
82
112
|
## Lua
|
|
83
113
|
|
|
84
114
|
```ruby
|
|
@@ -166,6 +196,42 @@ Streams (interactive transactions) cannot cross a fork boundary. Using a stream
|
|
|
166
196
|
|
|
167
197
|
No configuration is needed; the detection is a single PID comparison on every request.
|
|
168
198
|
|
|
199
|
+
## Instrumentation
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
client = Tarantool::Client.new(uri, instrumenter: ActiveSupport::Notifications, logger: Rails.logger)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`instrumenter:` takes any object with `instrument(name, payload) { }`, so `ActiveSupport::Notifications` works as is. `logger:` takes any `Logger`-compatible object. `Pool` accepts both and passes them to every member. Without either, no payload is built and nothing is timed; the request path only goes through a few extra method calls. `benchmark/request_overhead.rb` measures this.
|
|
206
|
+
|
|
207
|
+
Every call emits `request.tarantool`, transactions included (`begin`, each operation, `commit` or `rollback`). A retry after a schema change is part of the same event. `delete_by` emits one `request.tarantool` per select or delete batch; there is no separate event for it. A key appears only when it has a value:
|
|
208
|
+
|
|
209
|
+
| Key | Value |
|
|
210
|
+
|---|---|
|
|
211
|
+
| `request` | absent when a space, index or field name failed to resolve, so the request was never built; otherwise `:select`, `:insert`, `:replace`, `:update`, `:upsert`, `:delete`, `:call`, `:eval`, `:execute`, `:prepare`, `:ping`, `:begin`, `:commit`, `:rollback` |
|
|
212
|
+
| `space`, `index` | as passed (name or id); `index` only for select, update, delete, and only once the request is built |
|
|
213
|
+
| `function` | function name of a call |
|
|
214
|
+
| `sql` / `statement_id` | SQL text, or the id of a prepared statement |
|
|
215
|
+
| `address` | `host:port` or socket path of the instance |
|
|
216
|
+
| `schema_reloaded` | `true` when the call reloaded the schema |
|
|
217
|
+
| `exception`, `exception_object` | `[class name, message]` and the exception, as ActiveSupport sets them |
|
|
218
|
+
|
|
219
|
+
Keys, tuples, update operations, call and eval arguments, SQL bindings, the eval expression and credentials are never included. The exception message and the SQL text can still carry data: a duplicate-key `ServerError` quotes the tuple, and `sql` is the statement as passed.
|
|
220
|
+
|
|
221
|
+
Lifecycle events have the instance `address` too. Subscribers should be fast: `disconnect.tarantool` is emitted on the reconnect thread before it backs off and retries, and `Client#close` waits for that thread to finish.
|
|
222
|
+
|
|
223
|
+
| Event | Payload | Log level |
|
|
224
|
+
|---|---|---|
|
|
225
|
+
| `disconnect.tarantool` | `reason` | warn |
|
|
226
|
+
| `reconnect.tarantool` | `attempts` | info |
|
|
227
|
+
| `reconnect_failed.tarantool` | `attempts` (when known), `exception`, `exception_object` | error |
|
|
228
|
+
| `fork.tarantool` | | info |
|
|
229
|
+
| `role_change.tarantool` | `read_only` (pool members) | info |
|
|
230
|
+
|
|
231
|
+
`role_change.tarantool` fires only when the role actually changes, not on the first report after connecting.
|
|
232
|
+
|
|
233
|
+
The logger writes requests at debug, one line each: `tarantool select space=users index=email 1.24ms 127.0.0.1:3301`. A failed request's line shows the exception class and, when it has one, its code, e.g. `error=Tarantool::ServerError(3)`, never the message. Background warnings (a failing callback hook, a pool member that gave up) go to the logger as well, or to stderr without one.
|
|
234
|
+
|
|
169
235
|
## Errors
|
|
170
236
|
|
|
171
237
|
```
|
|
@@ -181,9 +247,13 @@ Tarantool::Error
|
|
|
181
247
|
└── NoAvailableInstanceError
|
|
182
248
|
```
|
|
183
249
|
|
|
250
|
+
`#deleted` is set only on errors raised by `delete_by` (see Data); it is `nil` otherwise.
|
|
251
|
+
|
|
184
252
|
The error classes are flat under `Tarantool::`, e.g. `Tarantool::ServerError`, not `Tarantool::Client::ServerError`.
|
|
185
253
|
tarantool-crud and tarantool-model errors also descend from `Tarantool::Error`.
|
|
186
254
|
|
|
255
|
+
`Tarantool::UnknownFieldError` (an `ArgumentError`, not under `Tarantool::Error`) is raised for a Hash tuple key naming a field missing from the format.
|
|
256
|
+
|
|
187
257
|
`box.error` objects inside returned data decode into `Tarantool::Client::BoxError`.
|
|
188
258
|
|
|
189
259
|
## Development
|
|
@@ -197,6 +267,12 @@ INTEGRATION=1 TARANTOOL_URI=tcp://app:secret@localhost:3301 bundle exec rspec
|
|
|
197
267
|
bundle exec rubocop
|
|
198
268
|
```
|
|
199
269
|
|
|
270
|
+
Instrumentation overhead against the container on 3301:
|
|
271
|
+
|
|
272
|
+
```sh
|
|
273
|
+
bundle exec ruby -Ilib benchmark/request_overhead.rb
|
|
274
|
+
```
|
|
275
|
+
|
|
200
276
|
Reconnect and pool specs restart their own pair of instances:
|
|
201
277
|
|
|
202
278
|
```sh
|
|
@@ -208,10 +284,12 @@ bundle exec rspec --tag failover
|
|
|
208
284
|
|
|
209
285
|
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
286
|
|
|
287
|
+
Don't create multikey indexes on these containers: with `memtx_use_mvcc_engine = true`, inserting into a space with a multikey index segfaults Tarantool 2.11, and 3.x refuses to create the index.
|
|
288
|
+
|
|
211
289
|
## Public API
|
|
212
290
|
|
|
213
291
|
`Tarantool::Client`, `Tarantool::Client::Pool`, `Tarantool::Client::Stream` (yielded by `transaction`),
|
|
214
|
-
`Watcher` (returned by `watch`), `SqlResult`, `PreparedStatement`, `SpaceInfo`, `IndexInfo`, `Configuration`
|
|
292
|
+
`Watcher` (returned by `watch`), `SqlResult`, `PreparedStatement`, `SpaceInfo`, `IndexInfo`, `Row`, `Configuration`
|
|
215
293
|
(returned by `client.config`), `Tarantool::Client::UUID`, `Interval`, `BoxError`, and the error classes above.
|
|
216
294
|
|
|
217
295
|
Everything else, including anything tagged `@api private` in its source comment, is an implementation detail
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
# Deletes every tuple a select over a TREE index finds: keyset pages via `after`, then one delete per tuple
|
|
6
|
+
# by primary key. Drives only the public operations of its target, so it runs the same on Client and Stream.
|
|
7
|
+
# @api private
|
|
8
|
+
class BatchDelete
|
|
9
|
+
# Internal signal that the overall deadline ran out; `call` turns it into the public delete_by message.
|
|
10
|
+
class DeadlineExceeded < StandardError; end
|
|
11
|
+
private_constant :DeadlineExceeded
|
|
12
|
+
|
|
13
|
+
def initialize(target, space, key, index:, iterator:, batch_size:, timeout:)
|
|
14
|
+
validate_batch_size(batch_size)
|
|
15
|
+
|
|
16
|
+
@target = target
|
|
17
|
+
@space = space
|
|
18
|
+
@key = key
|
|
19
|
+
@index = index
|
|
20
|
+
@iterator = iterator
|
|
21
|
+
@batch_size = batch_size
|
|
22
|
+
@timeout = timeout
|
|
23
|
+
@deadline = Transport::Deadline.after(timeout)
|
|
24
|
+
@deleted = 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
delete_pages(primary_fieldnos)
|
|
29
|
+
rescue DeadlineExceeded
|
|
30
|
+
raise deadline_timeout_error, cause: nil
|
|
31
|
+
rescue TimeoutError => e
|
|
32
|
+
raise deadline_timeout_error if @deadline
|
|
33
|
+
|
|
34
|
+
e.deleted = @deleted
|
|
35
|
+
raise
|
|
36
|
+
rescue Error => e
|
|
37
|
+
e.deleted = @deleted
|
|
38
|
+
raise
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def validate_batch_size(batch_size)
|
|
44
|
+
return if batch_size.is_a?(Integer) && batch_size.positive?
|
|
45
|
+
|
|
46
|
+
raise ArgumentError, "batch_size must be a positive Integer, got #{batch_size.inspect}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def delete_pages(fieldnos)
|
|
50
|
+
cursor = nil
|
|
51
|
+
|
|
52
|
+
loop do
|
|
53
|
+
batch = fetch(cursor)
|
|
54
|
+
delete_all(batch, fieldnos)
|
|
55
|
+
return @deleted if batch.size < @batch_size
|
|
56
|
+
|
|
57
|
+
cursor = batch.last
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def primary_fieldnos
|
|
62
|
+
parts = @target.space_info(@space, timeout: remaining).primary_index.parts
|
|
63
|
+
raise ArgumentError, 'delete_by cannot build a primary key from a JSON path part' if parts.any?(&:path)
|
|
64
|
+
|
|
65
|
+
parts.map(&:fieldno)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def fetch(cursor)
|
|
69
|
+
@target.select(@space, @key, index: @index, iterator: @iterator, limit: @batch_size, after: cursor,
|
|
70
|
+
timeout: remaining)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def delete_all(batch, fieldnos)
|
|
74
|
+
batch.each do |tuple|
|
|
75
|
+
@deleted += 1 if @target.delete(@space, tuple.values_at(*fieldnos), timeout: remaining)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def deadline_timeout_error
|
|
80
|
+
error = TimeoutError.new("delete_by did not finish within #{@timeout}s")
|
|
81
|
+
error.deleted = @deleted
|
|
82
|
+
|
|
83
|
+
error
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# nil leaves each request on the connection's request_timeout.
|
|
87
|
+
def remaining
|
|
88
|
+
return unless @deadline
|
|
89
|
+
|
|
90
|
+
left = Transport::Deadline.remaining(@deadline)
|
|
91
|
+
raise DeadlineExceeded if left.zero?
|
|
92
|
+
|
|
93
|
+
left
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -8,7 +8,7 @@ module Tarantool
|
|
|
8
8
|
Configuration = Data.define(
|
|
9
9
|
:host, :port, :path, :user, :password, :connect_timeout, :request_timeout,
|
|
10
10
|
:reconnect, :reconnect_attempts, :reconnect_delay, :reconnect_max_delay,
|
|
11
|
-
:max_frame_size
|
|
11
|
+
:max_frame_size, :instrumenter, :logger
|
|
12
12
|
) do
|
|
13
13
|
def self.build(uri = nil, **options)
|
|
14
14
|
new(**defaults, **parse_uri(uri), **options)
|
|
@@ -18,7 +18,7 @@ module Tarantool
|
|
|
18
18
|
{
|
|
19
19
|
host: 'localhost', port: 3301, path: nil, user: nil, password: nil, connect_timeout: 3, request_timeout: 10,
|
|
20
20
|
reconnect: true, reconnect_attempts: Float::INFINITY, reconnect_delay: 0.1, reconnect_max_delay: 5,
|
|
21
|
-
max_frame_size: 64 * 1024 * 1024
|
|
21
|
+
max_frame_size: 64 * 1024 * 1024, instrumenter: nil, logger: nil
|
|
22
22
|
}
|
|
23
23
|
end
|
|
24
24
|
|
|
@@ -59,13 +59,30 @@ module Tarantool
|
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
def inspect
|
|
62
|
-
fields = to_h.
|
|
62
|
+
fields = to_h.map { |key, value| "#{key}=#{inspect_field(key, value)}" }.join(', ')
|
|
63
63
|
|
|
64
64
|
"#<data #{self.class.name} #{fields}>"
|
|
65
65
|
end
|
|
66
66
|
alias_method :to_s, :inspect
|
|
67
67
|
|
|
68
68
|
def pretty_print(printer) = printer.text(inspect)
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def inspect_field(key, value)
|
|
73
|
+
case key
|
|
74
|
+
when :password then (value && '[FILTERED]').inspect
|
|
75
|
+
when :instrumenter, :logger then inspect_object(value)
|
|
76
|
+
else value.inspect
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def inspect_object(value)
|
|
81
|
+
return 'nil' unless value
|
|
82
|
+
|
|
83
|
+
klass = value.is_a?(Module) ? value : value.class
|
|
84
|
+
klass.name ? "#<#{klass.name}>" : klass.inspect
|
|
85
|
+
end
|
|
69
86
|
end
|
|
70
87
|
end
|
|
71
88
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
# Warnings from background threads: to the configured logger, else to stderr.
|
|
6
|
+
# @api private
|
|
7
|
+
module Diagnostics
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Called from rescue blocks of background threads, which must outlive a broken logger.
|
|
11
|
+
def warn(logger, message)
|
|
12
|
+
return Kernel.warn(message) unless logger
|
|
13
|
+
|
|
14
|
+
logger.warn(message)
|
|
15
|
+
rescue StandardError
|
|
16
|
+
Kernel.warn(message)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Tarantool
|
|
4
4
|
# Base class for every error raised by tarantool-ruby gems.
|
|
5
|
-
class Error < StandardError
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
# Rows delete_by confirmed deleted before it failed (a lower bound); nil for errors raised by any other operation.
|
|
7
|
+
# Writer is @api private, for BatchDelete only.
|
|
8
|
+
attr_accessor :deleted
|
|
9
|
+
end
|
|
6
10
|
|
|
7
11
|
# Socket-level failure: refused, lost or broken connection.
|
|
8
12
|
class ConnectionError < Error; end
|
|
@@ -29,6 +33,9 @@ module Tarantool
|
|
|
29
33
|
# Space or index name cannot be resolved.
|
|
30
34
|
class SchemaError < Error; end
|
|
31
35
|
|
|
36
|
+
# A Hash tuple names a field missing from the space format.
|
|
37
|
+
class UnknownFieldError < ArgumentError; end
|
|
38
|
+
|
|
32
39
|
# Transaction misuse or a transaction that can no longer proceed.
|
|
33
40
|
class TransactionError < Error; end
|
|
34
41
|
|
|
@@ -17,6 +17,7 @@ module Tarantool
|
|
|
17
17
|
def reopen_after_fork
|
|
18
18
|
close_inherited_link
|
|
19
19
|
open_link
|
|
20
|
+
@instrumentation&.notify('fork.tarantool')
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
# Joining a thread that died with an exception re-raises it; nothing from the parent may stop the reopen.
|
|
@@ -28,7 +29,7 @@ module Tarantool
|
|
|
28
29
|
|
|
29
30
|
# Watcher registrations outlive a fork; on_ready resubscribes them on the new link.
|
|
30
31
|
def open_link
|
|
31
|
-
@link = Transport::Link.new(@config, **link_hooks)
|
|
32
|
+
@link = Transport::Link.new(@config, instrumentation: @instrumentation, **link_hooks)
|
|
32
33
|
@watchers&.restart_after_fork(@link)
|
|
33
34
|
@watchers ||= Watchers.new(@link, timeout: @config.request_timeout)
|
|
34
35
|
@link.event_handler = @watchers.method(:dispatch)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
class Instrumentation
|
|
6
|
+
# A logger as an instrumenter, around the user's instrumenter if there is one. The request line is built
|
|
7
|
+
# only when the logger takes debug.
|
|
8
|
+
# @api private
|
|
9
|
+
class LogInstrumenter
|
|
10
|
+
def initialize(logger, instrumenter)
|
|
11
|
+
@logger = logger
|
|
12
|
+
@instrumenter = instrumenter
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def instrument(name, payload, &)
|
|
16
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
17
|
+
|
|
18
|
+
@instrumenter ? @instrumenter.instrument(name, payload, &) : yield
|
|
19
|
+
ensure
|
|
20
|
+
log(name, payload, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def log(name, payload, duration)
|
|
26
|
+
level = LogLines.level(name)
|
|
27
|
+
|
|
28
|
+
@logger.public_send(level) { LogLines.line(name, payload, duration) } if level
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
Kernel.warn("tarantool logger failed: #{e.class}: #{e.message}")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
class Instrumentation
|
|
6
|
+
# Log level and line of each event the client emits.
|
|
7
|
+
# @api private
|
|
8
|
+
module LogLines
|
|
9
|
+
LEVELS = {
|
|
10
|
+
'request.tarantool' => :debug, 'disconnect.tarantool' => :warn, 'reconnect.tarantool' => :info,
|
|
11
|
+
'reconnect_failed.tarantool' => :error, 'fork.tarantool' => :info, 'role_change.tarantool' => :info
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
LINES = {
|
|
15
|
+
'request.tarantool' => ->(payload, duration) { request(payload, duration) },
|
|
16
|
+
'disconnect.tarantool' => lambda do |payload, _duration|
|
|
17
|
+
"tarantool disconnected from #{payload[:address]}: #{payload[:reason]}"
|
|
18
|
+
end,
|
|
19
|
+
'reconnect.tarantool' => lambda do |payload, _duration|
|
|
20
|
+
"tarantool reconnected to #{payload[:address]} after #{attempts(payload[:attempts])}"
|
|
21
|
+
end,
|
|
22
|
+
'reconnect_failed.tarantool' => lambda do |payload, _duration|
|
|
23
|
+
"tarantool gave up reconnecting to #{payload[:address]}: #{error(payload)}"
|
|
24
|
+
end,
|
|
25
|
+
'fork.tarantool' => lambda do |payload, _duration|
|
|
26
|
+
"tarantool reopened connection to #{payload[:address]} after fork"
|
|
27
|
+
end,
|
|
28
|
+
'role_change.tarantool' => lambda do |payload, _duration|
|
|
29
|
+
"tarantool #{payload[:address]} is now #{payload[:read_only] ? 'read-only' : 'writable'}"
|
|
30
|
+
end
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def level(name) = LEVELS[name]
|
|
36
|
+
|
|
37
|
+
def line(name, payload, duration) = LINES[name]&.call(payload, duration)
|
|
38
|
+
|
|
39
|
+
def request(payload, duration)
|
|
40
|
+
parts = [request_name(payload)]
|
|
41
|
+
%i[space index function].each { |key| parts << "#{key}=#{payload[key]}" if payload.key?(key) }
|
|
42
|
+
parts << format('%.2fms', duration * 1000) << payload[:address]
|
|
43
|
+
parts << "error=#{request_error(payload)}" if payload.key?(:exception)
|
|
44
|
+
parts << 'schema_reloaded' if payload[:schema_reloaded]
|
|
45
|
+
|
|
46
|
+
parts.join(' ')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def request_name(payload) = payload[:request] ? "tarantool #{payload[:request]}" : 'tarantool'
|
|
50
|
+
|
|
51
|
+
def attempts(count) = count == 1 ? '1 attempt' : "#{count} attempts"
|
|
52
|
+
def error(payload) = payload[:exception].join(': ')
|
|
53
|
+
|
|
54
|
+
# The message may quote data (ER_TUPLE_FOUND repeats the tuple), so the request line keeps only the
|
|
55
|
+
# class and, when the exception has one, its code.
|
|
56
|
+
def request_error(payload)
|
|
57
|
+
exception = payload[:exception_object]
|
|
58
|
+
klass = exception ? exception.class.name : payload[:exception].first
|
|
59
|
+
code = exception.respond_to?(:code) ? exception.code : nil
|
|
60
|
+
|
|
61
|
+
code ? "#{klass}(#{code})" : klass
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
class Instrumentation
|
|
6
|
+
# Request event fields taken from a request. Keys, tuples, arguments and bindings stay out: they may hold
|
|
7
|
+
# personal data.
|
|
8
|
+
# @api private
|
|
9
|
+
module RequestPayload
|
|
10
|
+
NAMES = {
|
|
11
|
+
Protocol::Requests::Ping => :ping, Protocol::Requests::Select => :select,
|
|
12
|
+
Protocol::Requests::Insert => :insert, Protocol::Requests::Replace => :replace,
|
|
13
|
+
Protocol::Requests::Update => :update, Protocol::Requests::Upsert => :upsert,
|
|
14
|
+
Protocol::Requests::Delete => :delete, Protocol::Requests::Call => :call,
|
|
15
|
+
Protocol::Requests::Eval => :eval, Protocol::Requests::Execute => :execute,
|
|
16
|
+
Protocol::Requests::Prepare => :prepare, Protocol::Requests::Begin => :begin,
|
|
17
|
+
Protocol::Requests::Commit => :commit, Protocol::Requests::Rollback => :rollback
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def build(request) = describe({}, request)
|
|
23
|
+
|
|
24
|
+
# Space operations pass the resolved index in; only requests that have one keep it.
|
|
25
|
+
def describe(payload, request, index = nil)
|
|
26
|
+
payload[:request] = NAMES.fetch(request.class)
|
|
27
|
+
payload[:index] = index if request.respond_to?(:index_id)
|
|
28
|
+
case request
|
|
29
|
+
when Protocol::Requests::Call then payload[:function] = request.function_name
|
|
30
|
+
when Protocol::Requests::Execute, Protocol::Requests::Prepare then add_statement(payload, request.statement)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
payload
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def add_statement(payload, statement)
|
|
37
|
+
payload[statement.is_a?(Integer) ? :statement_id : :sql] = statement
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
# Emits request and lifecycle events to the configured instrumenter, with the logger wrapped around it.
|
|
6
|
+
# Exception keys follow ActiveSupport::Notifications, which writes the same values again, so a payload looks
|
|
7
|
+
# the same whatever the instrumenter.
|
|
8
|
+
# @api private
|
|
9
|
+
class Instrumentation
|
|
10
|
+
REQUEST = 'request.tarantool'
|
|
11
|
+
|
|
12
|
+
def self.build(config)
|
|
13
|
+
instrumenter = config.instrumenter
|
|
14
|
+
instrumenter = LogInstrumenter.new(config.logger, instrumenter) if config.logger
|
|
15
|
+
|
|
16
|
+
new(instrumenter, config) if instrumenter
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.exception_keys(exception)
|
|
20
|
+
{ exception: [exception.class.name, exception.message], exception_object: exception }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(instrumenter, config)
|
|
24
|
+
@instrumenter = instrumenter
|
|
25
|
+
@address = config.address
|
|
26
|
+
@logger = config.logger
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The block's value is kept here: a duck-typed instrumenter need not return it.
|
|
30
|
+
def request(payload)
|
|
31
|
+
payload[:address] = @address
|
|
32
|
+
result = nil
|
|
33
|
+
@instrumenter.instrument(REQUEST, payload) do
|
|
34
|
+
result = yield
|
|
35
|
+
rescue Exception => e
|
|
36
|
+
payload.merge!(self.class.exception_keys(e))
|
|
37
|
+
raise
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
result
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Lifecycle events come from background threads, which a failing instrumenter must not stop.
|
|
44
|
+
def notify(name, payload = {})
|
|
45
|
+
@instrumenter.instrument(name, payload.merge(address: @address)) { nil }
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
Diagnostics.warn(@logger, "tarantool #{name} instrumentation failed: #{e.class}: #{e.message}")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
# Request events around the requests of Client and Stream. Includers implement
|
|
6
|
+
# transmit(request, timeout:, schema_version:, on_push:) and
|
|
7
|
+
# transmit_on_space(space, index, timeout:, format:, payload:, &), and set @instrumentation,
|
|
8
|
+
# nil when nothing is configured.
|
|
9
|
+
# @api private
|
|
10
|
+
module Instrumented
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def perform(request, timeout: nil, schema_version: nil, on_push: nil)
|
|
14
|
+
instrumented(request) { transmit(request, timeout:, schema_version:, on_push:) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def perform_on_space(space, index, timeout: nil, format: false, &)
|
|
18
|
+
return transmit_on_space(space, index, timeout:, format:, &) unless @instrumentation
|
|
19
|
+
|
|
20
|
+
perform_on_space_instrumented(space, index, timeout, format, &)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def instrumented(request, &)
|
|
24
|
+
return yield unless @instrumentation
|
|
25
|
+
|
|
26
|
+
@instrumentation.request(Instrumentation::RequestPayload.build(request), &)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The request is built only once names resolve to ids, so its type and index join the payload then.
|
|
30
|
+
def perform_on_space_instrumented(space, index, timeout, format, &)
|
|
31
|
+
payload = { space: }
|
|
32
|
+
@instrumentation.request(payload) do
|
|
33
|
+
transmit_on_space(space, index, timeout:, format:, payload:) do |*resolved|
|
|
34
|
+
yield(*resolved).tap { |request| Instrumentation::RequestPayload.describe(payload, request, index) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def mark_schema_reloaded(payload) = payload&.store(:schema_reloaded, true)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|