tarantool-client 0.1.0 → 0.3.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -1
  3. data/README.md +96 -1
  4. data/lib/tarantool/client/batch_delete.rb +97 -0
  5. data/lib/tarantool/client/configuration.rb +20 -3
  6. data/lib/tarantool/client/diagnostics.rb +20 -0
  7. data/lib/tarantool/client/errors.rb +8 -1
  8. data/lib/tarantool/client/fork_safety.rb +11 -2
  9. data/lib/tarantool/client/instrumentation/log_instrumenter.rb +35 -0
  10. data/lib/tarantool/client/instrumentation/log_lines.rb +71 -0
  11. data/lib/tarantool/client/instrumentation/request_payload.rb +42 -0
  12. data/lib/tarantool/client/instrumentation.rb +51 -0
  13. data/lib/tarantool/client/instrumented.rb +42 -0
  14. data/lib/tarantool/client/named_requests.rb +67 -0
  15. data/lib/tarantool/client/operations.rb +58 -22
  16. data/lib/tarantool/client/page.rb +18 -0
  17. data/lib/tarantool/client/pool/member.rb +4 -9
  18. data/lib/tarantool/client/pool/member_fork_safety.rb +10 -5
  19. data/lib/tarantool/client/pool/member_role.rb +36 -0
  20. data/lib/tarantool/client/protocol/constants.rb +3 -0
  21. data/lib/tarantool/client/protocol/requests/select.rb +22 -5
  22. data/lib/tarantool/client/protocol/response.rb +1 -0
  23. data/lib/tarantool/client/row.rb +8 -0
  24. data/lib/tarantool/client/schema.rb +4 -3
  25. data/lib/tarantool/client/space_info.rb +2 -0
  26. data/lib/tarantool/client/stream.rb +20 -10
  27. data/lib/tarantool/client/transaction_schema.rb +12 -8
  28. data/lib/tarantool/client/transport/connection.rb +10 -0
  29. data/lib/tarantool/client/transport/link.rb +22 -27
  30. data/lib/tarantool/client/transport/link_draining.rb +35 -0
  31. data/lib/tarantool/client/transport/link_events.rb +48 -0
  32. data/lib/tarantool/client/transport/link_state.rb +39 -5
  33. data/lib/tarantool/client/transport/pending_requests.rb +28 -1
  34. data/lib/tarantool/client/tuple_mapping.rb +65 -0
  35. data/lib/tarantool/client/tuple_shape.rb +25 -0
  36. data/lib/tarantool/client/version.rb +1 -1
  37. data/lib/tarantool/client.rb +25 -48
  38. metadata +17 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3c88acf7dfc96a8094a2c1562b7d98c86ae22670dc1cc853f69b8324df14bada
4
- data.tar.gz: c46e62c20535c8733329f6ec9db242d5a1ab9b5bd0cf0b0205c330ac80961538
3
+ metadata.gz: 312e13dccce60b22bb3e74005e914cadcbf6dbbf096d378759a2697e6c0a1e8c
4
+ data.tar.gz: f0fd02d53f7bbb97ee347f7b81b1e9a9f83476a5af871042036b025c9cbb8060
5
5
  SHA512:
6
- metadata.gz: 44fe72a002d29b8565fa35dcc812dfeaebcc6e8eb67a7993a275deab03136f628f235ffb345eb3df1a31ef41f9e5cedd144b27aa1a9ef53d46e54c931a3a7cf9
7
- data.tar.gz: 424b07a9cd9d143c153303f07aa5986270ea0f2b1fc3acbe1c88230dff7f99d135824ad3a27676adff696cf8d5850e4b18c8486bd087d7f72aeff41d0c149027
6
+ metadata.gz: 81512742b3dfc97f726a1af22d2bbfd578cd083cc2dd75f3cbe3c4004bde4e1e73ee21ba03675581b7586d590c2624e6287bc8040ee281e3b3fe85ef8c093f4e
7
+ data.tar.gz: 8e4390ef152eab60197e2a5822a132266b12c4eb567801017a2dd0308426109823230b86df6700b1e220f9353f9de892cbe634acaa4d89578ff84c99167ead89
data/CHANGELOG.md CHANGED
@@ -1,6 +1,22 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 (unreleased)
3
+ ## 0.3.0 (2026-10-17)
4
+
5
+ - `select(fetch_position: true)` returns a `Page` with the server-side position of the last tuple; `after:` takes that position (String) as well as a tuple.
6
+ - Graceful shutdown: the client subscribes to `box.shutdown`; on the event it stops sending, a `Pool` member leaves the rotation, requests in flight finish, and the connection is closed by the client before the server stops (`shutdown.tarantool` event, `disconnect.tarantool` with the reason `server ... is shutting down`).
7
+
8
+ ## 0.2.0 (2026-10-17)
9
+
10
+ - `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.
11
+ - `insert`, `replace` and `upsert` accept a Hash; missing fields go out as nil, an unknown key raises `ArgumentError`.
12
+ - 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.
13
+ - 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.
14
+ - 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".
15
+ - `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`.
16
+ - `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.
17
+ - `logger:` option: a debug line per request, lifecycle lines, and background warnings that used to go to stderr.
18
+
19
+ ## 0.1.0 (2026-10-16)
4
20
 
5
21
  First release.
6
22
 
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,33 @@ 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
+ With `fetch_position: true` the server hands back a position instead, so the caller carries an opaque String rather than the last tuple (`IPROTO_FETCH_POSITION`, `IPROTO_POSITION`, `IPROTO_AFTER_POSITION`; 2.11+). `select` then returns a `Client::Page`: `tuples` (converted by `as:` as usual, also reachable through `each`), and `position`, nil once a page comes back empty. A position is tied to the index and iterator of the select that produced it; don't parse it or reuse it with another index. `after:` accepts a position (String) or a tuple (Array).
76
+
77
+ ```ruby
78
+ page = client.select(:users, [18], index: :age, iterator: :ge, limit: 50, fetch_position: true)
79
+ until page.empty?
80
+ page.each { |user| ... }
81
+ page = client.select(:users, [18], index: :age, iterator: :ge, limit: 50, fetch_position: true, after: page.position)
82
+ end
83
+ ```
84
+
85
+ `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.
86
+
87
+ ```ruby
88
+ client.delete_by(:users, [18], index: :age) # => 42
89
+ client.delete_by(:users, [18], index: :age, iterator: :lt, batch_size: 500, timeout: 30)
90
+ ```
91
+
92
+ 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`.
93
+
94
+ Inside a transaction it is atomic — a failure rolls back every delete:
95
+
96
+ ```ruby
97
+ client.transaction { |tx| tx.delete_by(:users, [18], index: :age) }
98
+ ```
99
+
100
+ This needs `memtx_use_mvcc_engine = true` on the server, like any transaction on memtx.
101
+
73
102
  `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
103
 
75
104
  ```ruby
@@ -79,6 +108,17 @@ info.primary_index.parts.map(&:name) # => ["id"]
79
108
  info.indexes['age'].unique # => false
80
109
  ```
81
110
 
111
+ `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`.
112
+
113
+ ```ruby
114
+ client.insert(:users, { id: 42, email: 'a@b.c', age: 0 })
115
+ client.select(:users, [42], as: :hash) # => [{ id: 42, email: "a@b.c", age: 0 }]
116
+ row = client.select(:users, [42], as: :row).first
117
+ row.tuple # => [42, "a@b.c", 0]
118
+ row.attributes # => { id: 42, email: "a@b.c", age: 0 }
119
+ info.to_hash([42, 'a@b.c', 0]) # => { id: 42, email: "a@b.c", age: 0 }
120
+ ```
121
+
82
122
  ## Lua
83
123
 
84
124
  ```ruby
@@ -158,6 +198,12 @@ pool.close
158
198
 
159
199
  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
200
 
201
+ ## Graceful shutdown
202
+
203
+ Tarantool 2.10+ announces `SIGTERM` through the `box.shutdown` event and waits, up to `box.ctl.set_on_shutdown_timeout` (3 s by default), for the connections subscribed to it to close. Every `Client` subscribes. When the event arrives the client stops sending: `connected?` turns false, a `Pool` member leaves the rotation at once, and new requests on a bare `Client` wait for the next connection as they do while reconnecting. Requests already in flight get their replies; once none is left, or after `request_timeout`, the client closes the connection itself with the reason `server <address> is shutting down` and the usual reconnect follows. A transaction open on that connection fails on its next request, as after any loss. Nothing is retried elsewhere.
204
+
205
+ Watching `box.shutdown` yourself works as before; the client's own subscription is never unwatched.
206
+
161
207
  ## Forking servers
162
208
 
163
209
  `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.
@@ -166,6 +212,43 @@ Streams (interactive transactions) cannot cross a fork boundary. Using a stream
166
212
 
167
213
  No configuration is needed; the detection is a single PID comparison on every request.
168
214
 
215
+ ## Instrumentation
216
+
217
+ ```ruby
218
+ client = Tarantool::Client.new(uri, instrumenter: ActiveSupport::Notifications, logger: Rails.logger)
219
+ ```
220
+
221
+ `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.
222
+
223
+ 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:
224
+
225
+ | Key | Value |
226
+ |---|---|
227
+ | `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` |
228
+ | `space`, `index` | as passed (name or id); `index` only for select, update, delete, and only once the request is built |
229
+ | `function` | function name of a call |
230
+ | `sql` / `statement_id` | SQL text, or the id of a prepared statement |
231
+ | `address` | `host:port` or socket path of the instance |
232
+ | `schema_reloaded` | `true` when the call reloaded the schema |
233
+ | `exception`, `exception_object` | `[class name, message]` and the exception, as ActiveSupport sets them |
234
+
235
+ 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.
236
+
237
+ 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.
238
+
239
+ | Event | Payload | Log level |
240
+ |---|---|---|
241
+ | `disconnect.tarantool` | `reason` | warn |
242
+ | `reconnect.tarantool` | `attempts` | info |
243
+ | `reconnect_failed.tarantool` | `attempts` (when known), `exception`, `exception_object` | error |
244
+ | `fork.tarantool` | | info |
245
+ | `role_change.tarantool` | `read_only` (pool members) | info |
246
+ | `shutdown.tarantool` | `pending` (requests in flight when the server announced its shutdown) | info |
247
+
248
+ `role_change.tarantool` fires only when the role actually changes, not on the first report after connecting.
249
+
250
+ 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.
251
+
169
252
  ## Errors
170
253
 
171
254
  ```
@@ -181,9 +264,13 @@ Tarantool::Error
181
264
  └── NoAvailableInstanceError
182
265
  ```
183
266
 
267
+ `#deleted` is set only on errors raised by `delete_by` (see Data); it is `nil` otherwise.
268
+
184
269
  The error classes are flat under `Tarantool::`, e.g. `Tarantool::ServerError`, not `Tarantool::Client::ServerError`.
185
270
  tarantool-crud and tarantool-model errors also descend from `Tarantool::Error`.
186
271
 
272
+ `Tarantool::UnknownFieldError` (an `ArgumentError`, not under `Tarantool::Error`) is raised for a Hash tuple key naming a field missing from the format.
273
+
187
274
  `box.error` objects inside returned data decode into `Tarantool::Client::BoxError`.
188
275
 
189
276
  ## Development
@@ -197,6 +284,12 @@ INTEGRATION=1 TARANTOOL_URI=tcp://app:secret@localhost:3301 bundle exec rspec
197
284
  bundle exec rubocop
198
285
  ```
199
286
 
287
+ Instrumentation overhead against the container on 3301:
288
+
289
+ ```sh
290
+ bundle exec ruby -Ilib benchmark/request_overhead.rb
291
+ ```
292
+
200
293
  Reconnect and pool specs restart their own pair of instances:
201
294
 
202
295
  ```sh
@@ -208,10 +301,12 @@ bundle exec rspec --tag failover
208
301
 
209
302
  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
303
 
304
+ 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.
305
+
211
306
  ## Public API
212
307
 
213
308
  `Tarantool::Client`, `Tarantool::Client::Pool`, `Tarantool::Client::Stream` (yielded by `transaction`),
214
- `Watcher` (returned by `watch`), `SqlResult`, `PreparedStatement`, `SpaceInfo`, `IndexInfo`, `Configuration`
309
+ `Watcher` (returned by `watch`), `SqlResult`, `PreparedStatement`, `SpaceInfo`, `IndexInfo`, `Row`, `Page`, `Configuration`
215
310
  (returned by `client.config`), `Tarantool::Client::UUID`, `Interval`, `BoxError`, and the error classes above.
216
311
 
217
312
  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.merge(password: password && '[FILTERED]').map { |k, v| "#{k}=#{v.inspect}" }.join(', ')
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; end
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.
@@ -26,16 +27,24 @@ module Tarantool
26
27
  nil
27
28
  end
28
29
 
29
- # Watcher registrations outlive a fork; on_ready resubscribes them on the new link.
30
+ # Watcher registrations outlive a fork; on_ready resubscribes them on the new link, the shutdown
31
+ # subscription among them.
30
32
  def open_link
31
- @link = Transport::Link.new(@config, **link_hooks)
33
+ @link = Transport::Link.new(@config, instrumentation: @instrumentation, **link_hooks)
32
34
  @watchers&.restart_after_fork(@link)
35
+ first = @watchers.nil?
33
36
  @watchers ||= Watchers.new(@link, timeout: @config.request_timeout)
34
37
  @link.event_handler = @watchers.method(:dispatch)
35
38
  @schema_cache = SchemaCache.new
36
39
  @stream_ids = Transport::StreamIds.new
37
40
  @link.connect
38
41
  @pid = Process.pid
42
+ subscribe_to_shutdown if first
43
+ end
44
+
45
+ # box.shutdown is true once the server starts stopping; the value on subscribing is nil before that.
46
+ def subscribe_to_shutdown
47
+ @watchers.add('box.shutdown', ->(_key, value) { @link.drain if value })
39
48
  end
40
49
  end
41
50
  end
@@ -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,71 @@
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
+ 'shutdown.tarantool' => :info
13
+ }.freeze
14
+
15
+ LINES = {
16
+ 'request.tarantool' => ->(payload, duration) { request(payload, duration) },
17
+ 'disconnect.tarantool' => lambda do |payload, _duration|
18
+ "tarantool disconnected from #{payload[:address]}: #{payload[:reason]}"
19
+ end,
20
+ 'reconnect.tarantool' => lambda do |payload, _duration|
21
+ "tarantool reconnected to #{payload[:address]} after #{attempts(payload[:attempts])}"
22
+ end,
23
+ 'reconnect_failed.tarantool' => lambda do |payload, _duration|
24
+ "tarantool gave up reconnecting to #{payload[:address]}: #{error(payload)}"
25
+ end,
26
+ 'fork.tarantool' => lambda do |payload, _duration|
27
+ "tarantool reopened connection to #{payload[:address]} after fork"
28
+ end,
29
+ 'role_change.tarantool' => lambda do |payload, _duration|
30
+ "tarantool #{payload[:address]} is now #{payload[:read_only] ? 'read-only' : 'writable'}"
31
+ end,
32
+ 'shutdown.tarantool' => lambda do |payload, _duration|
33
+ "tarantool #{payload[:address]} is shutting down, #{requests(payload[:pending])} in flight"
34
+ end
35
+ }.freeze
36
+
37
+ module_function
38
+
39
+ def level(name) = LEVELS[name]
40
+
41
+ def line(name, payload, duration) = LINES[name]&.call(payload, duration)
42
+
43
+ def request(payload, duration)
44
+ parts = [request_name(payload)]
45
+ %i[space index function].each { |key| parts << "#{key}=#{payload[key]}" if payload.key?(key) }
46
+ parts << format('%.2fms', duration * 1000) << payload[:address]
47
+ parts << "error=#{request_error(payload)}" if payload.key?(:exception)
48
+ parts << 'schema_reloaded' if payload[:schema_reloaded]
49
+
50
+ parts.join(' ')
51
+ end
52
+
53
+ def request_name(payload) = payload[:request] ? "tarantool #{payload[:request]}" : 'tarantool'
54
+
55
+ def attempts(count) = count == 1 ? '1 attempt' : "#{count} attempts"
56
+ def requests(count) = count == 1 ? '1 request' : "#{count} requests"
57
+ def error(payload) = payload[:exception].join(': ')
58
+
59
+ # The message may quote data (ER_TUPLE_FOUND repeats the tuple), so the request line keeps only the
60
+ # class and, when the exception has one, its code.
61
+ def request_error(payload)
62
+ exception = payload[:exception_object]
63
+ klass = exception ? exception.class.name : payload[:exception].first
64
+ code = exception.respond_to?(:code) ? exception.code : nil
65
+
66
+ code ? "#{klass}(#{code})" : klass
67
+ end
68
+ end
69
+ end
70
+ end
71
+ 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