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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -1
  3. data/README.md +79 -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 +2 -1
  9. data/lib/tarantool/client/instrumentation/log_instrumenter.rb +35 -0
  10. data/lib/tarantool/client/instrumentation/log_lines.rb +66 -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 +53 -21
  16. data/lib/tarantool/client/pool/member.rb +4 -9
  17. data/lib/tarantool/client/pool/member_fork_safety.rb +10 -5
  18. data/lib/tarantool/client/pool/member_role.rb +36 -0
  19. data/lib/tarantool/client/row.rb +8 -0
  20. data/lib/tarantool/client/schema.rb +4 -3
  21. data/lib/tarantool/client/space_info.rb +2 -0
  22. data/lib/tarantool/client/stream.rb +20 -10
  23. data/lib/tarantool/client/transaction_schema.rb +12 -8
  24. data/lib/tarantool/client/transport/connection.rb +2 -0
  25. data/lib/tarantool/client/transport/link.rb +15 -15
  26. data/lib/tarantool/client/transport/link_events.rb +41 -0
  27. data/lib/tarantool/client/transport/link_state.rb +10 -0
  28. data/lib/tarantool/client/tuple_mapping.rb +65 -0
  29. data/lib/tarantool/client/tuple_shape.rb +25 -0
  30. data/lib/tarantool/client/version.rb +1 -1
  31. data/lib/tarantool/client.rb +23 -48
  32. metadata +15 -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: 239078d6bcc2b8d55971852a3af66f3b1db2e40822bfec113f81442d7946bf08
4
+ data.tar.gz: 8244a106fca7d641f7eaaf42377679de89ed2d1641d2c964f665d3bdff7953c9
5
5
  SHA512:
6
- metadata.gz: 44fe72a002d29b8565fa35dcc812dfeaebcc6e8eb67a7993a275deab03136f628f235ffb345eb3df1a31ef41f9e5cedd144b27aa1a9ef53d46e54c931a3a7cf9
7
- data.tar.gz: 424b07a9cd9d143c153303f07aa5986270ea0f2b1fc3acbe1c88230dff7f99d135824ad3a27676adff696cf8d5850e4b18c8486bd087d7f72aeff41d0c149027
6
+ metadata.gz: c3fb785f1d6362c9f3a6e94f951b1c761b73152ea7b76d7b1666726b1e130892aada79ea38c7f46be339557e4000dee41da8db0e547e0ee47e8f1c3df76a8daf
7
+ data.tar.gz: 81fc563d05f48cc9cfa4d640fb00671d435951bea5cea85092394ab56914040967d99b89c0cc577bf6e37d71e0775c956060fe4fc5caecc4ec42941282e59495
data/CHANGELOG.md CHANGED
@@ -1,6 +1,17 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 (unreleased)
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.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.
@@ -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