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
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ # Requests whose space and index resolve through the schema cache. Includers set @link, @schema_cache and
6
+ # @config, and provide ensure_fork_safe and mark_schema_reloaded.
7
+ # @api private
8
+ module NamedRequests
9
+ private
10
+
11
+ # Every schema fetch and attempt draws on one deadline, so the whole call stays within timeout.
12
+ # Waiting for a connection first means the cache already holds its schema or a newer one.
13
+ def perform_by_name(space, index, timeout, payload, &)
14
+ ensure_fork_safe
15
+ deadline = Transport::Deadline.after(timeout)
16
+ @link.connection(deadline)
17
+
18
+ perform_with_schema_check(space, index, deadline, payload, &)
19
+ rescue TimeoutError
20
+ raise TimeoutError, "no response within #{timeout}s"
21
+ end
22
+
23
+ def lookup_space(space, timeout)
24
+ ensure_fork_safe
25
+ timeout ||= @config.request_timeout
26
+ deadline = Transport::Deadline.after(timeout)
27
+ @link.connection(deadline)
28
+
29
+ @schema_cache.space_info(space, deadline)
30
+ rescue TimeoutError
31
+ raise TimeoutError, "no response within #{timeout}s"
32
+ end
33
+
34
+ def perform_with_schema_check(space, index, deadline, payload, &)
35
+ schema, ids = resolve_name(space, index, deadline, payload)
36
+
37
+ perform_built(schema, ids, deadline, &)
38
+ rescue ServerError, UnknownFieldError => e
39
+ raise if e.is_a?(ServerError) && e.code != Protocol::Constants::ER_WRONG_SCHEMA_VERSION
40
+
41
+ mark_schema_reloaded(payload)
42
+ fresh = @schema_cache.reload(schema, timeout: Transport::Deadline.remaining(deadline))
43
+ schema, ids = resolve_name(space, index, deadline, payload, fresh)
44
+ perform_built(schema, ids, deadline, &)
45
+ end
46
+
47
+ def perform_built(schema, ids, deadline)
48
+ perform_resolved(schema, yield(*ids, schema.space_info(ids.first)), deadline)
49
+ end
50
+
51
+ # The cache returns another schema only when resolving had to reload it.
52
+ def resolve_name(space, index, deadline, payload, schema = @schema_cache.schema)
53
+ @schema_cache.resolve(space, index, deadline, schema).tap do |resolved, _ids|
54
+ mark_schema_reloaded(payload) unless resolved.equal?(schema)
55
+ end
56
+ end
57
+
58
+ # Only the connection the schema came from: once it is lost, another instance may reuse the
59
+ # schema version with other ids, so the request fails with it rather than wait for a reconnect.
60
+ def perform_resolved(schema, request, deadline)
61
+ timeout = Transport::Deadline.remaining(deadline)
62
+
63
+ schema.connection.perform(request, timeout:, schema_version: schema.version)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -3,8 +3,10 @@
3
3
  module Tarantool
4
4
  class Client
5
5
  # Data operations, call, eval and SQL. Includers implement `perform(request, timeout:, on_push: nil)`,
6
- # `perform_on_space(space, index, timeout:) { |space_id, index_id| request }`, which returns the response to
7
- # the request built by the block, and `lookup_space(space, timeout)`, which returns its SpaceInfo.
6
+ # `perform_on_space(space, index, timeout:, format: false) { |space_id, index_id, space_info| request }`, which
7
+ # returns the response to the request built by the block, and `lookup_space(space, timeout)`, which returns its
8
+ # SpaceInfo. With `format: true` the block gets the SpaceInfo of the schema the request is resolved and
9
+ # version-checked against, even for numeric ids; otherwise the third argument may be absent.
8
10
  # @api private
9
11
  module Operations
10
12
  def ping(timeout: nil)
@@ -18,42 +20,57 @@ module Tarantool
18
20
  end
19
21
 
20
22
  def select(space, key = [], index: 0, limit: Protocol::Constants::MAX_LIMIT, offset: 0, iterator: :eq,
21
- after: nil, timeout: nil)
22
- perform_on_space(space, index, timeout:) do |space_id, index_id|
23
+ after: nil, as: :tuple, timeout: nil)
24
+ on_space(space, index, as:, timeout:) do |space_id, index_id|
23
25
  Protocol::Requests::Select.new(space_id:, index_id:, key: key_tuple(key), limit:, offset:, iterator:, after:)
24
- end.data
26
+ end
25
27
  end
26
28
 
27
- def insert(space, tuple, timeout: nil)
28
- perform_on_space(space, 0, timeout:) do |space_id|
29
- Protocol::Requests::Insert.new(space_id:, tuple:)
30
- end.data.first
29
+ def insert(space, tuple, as: :tuple, timeout: nil)
30
+ on_space(space, 0, as:, timeout:, format: tuple.is_a?(Hash)) do |space_id, _index_id, space_info|
31
+ Protocol::Requests::Insert.new(space_id:, tuple: tuple_for(tuple, space_info))
32
+ end.first
31
33
  end
32
34
 
33
- def replace(space, tuple, timeout: nil)
34
- perform_on_space(space, 0, timeout:) do |space_id|
35
- Protocol::Requests::Replace.new(space_id:, tuple:)
36
- end.data.first
35
+ def replace(space, tuple, as: :tuple, timeout: nil)
36
+ on_space(space, 0, as:, timeout:, format: tuple.is_a?(Hash)) do |space_id, _index_id, space_info|
37
+ Protocol::Requests::Replace.new(space_id:, tuple: tuple_for(tuple, space_info))
38
+ end.first
37
39
  end
38
40
 
39
- def update(space, key, operations, index: 0, timeout: nil)
40
- perform_on_space(space, index, timeout:) do |space_id, index_id|
41
+ def update(space, key, operations, index: 0, as: :tuple, timeout: nil)
42
+ on_space(space, index, as:, timeout:) do |space_id, index_id|
41
43
  Protocol::Requests::Update.new(space_id:, index_id:, key: key_tuple(key), operations:)
42
- end.data.first
44
+ end.first
43
45
  end
44
46
 
45
47
  def upsert(space, tuple, operations, timeout: nil)
46
- perform_on_space(space, 0, timeout:) do |space_id|
47
- Protocol::Requests::Upsert.new(space_id:, tuple:, operations:)
48
+ perform_on_space(space, 0, timeout:, format: tuple.is_a?(Hash)) do |space_id, _index_id, space_info|
49
+ Protocol::Requests::Upsert.new(space_id:, tuple: tuple_for(tuple, space_info), operations:)
48
50
  end
49
51
 
50
52
  nil
51
53
  end
52
54
 
53
- def delete(space, key, index: 0, timeout: nil)
54
- perform_on_space(space, index, timeout:) do |space_id, index_id|
55
+ def delete(space, key, index: 0, as: :tuple, timeout: nil)
56
+ on_space(space, index, as:, timeout:) do |space_id, index_id|
55
57
  Protocol::Requests::Delete.new(space_id:, index_id:, key: key_tuple(key))
56
- end.data.first
58
+ end.first
59
+ end
60
+
61
+ # Deletes every tuple `select(space, key, index:, iterator:)` finds, which IPROTO DELETE can't do on a
62
+ # non-unique index: selects batch_size tuples at a time, continuing past the last one with `after:`, and
63
+ # deletes each by primary key. Returns the number of tuples deleted. TREE indexes only. Multikey and
64
+ # functional indexes are not supported either: tuple-based `after` raises ServerError there, before
65
+ # deleting anything.
66
+ #
67
+ # Outside a transaction it is not atomic: on failure the tuples deleted so far stay deleted, and the
68
+ # raised Tarantool::Error carries the number confirmed deleted in #deleted (a lower bound). Tuples
69
+ # inserted meanwhile behind the cursor are not deleted. Inside `transaction { |tx| tx.delete_by(...) }`
70
+ # it is atomic (memtx needs memtx_use_mvcc_engine). `timeout:` bounds the whole operation; nil gives
71
+ # each request request_timeout.
72
+ def delete_by(space, key, index:, iterator: :eq, batch_size: 1000, timeout: nil)
73
+ BatchDelete.new(self, space, key, index:, iterator:, batch_size:, timeout:).call
57
74
  end
58
75
 
59
76
  # on_push runs in the calling thread for each box.session.push message; an exception
@@ -81,6 +98,21 @@ module Tarantool
81
98
 
82
99
  private
83
100
 
101
+ # A retry after a schema change calls the block again, so the SpaceInfo kept is the one the response came from.
102
+ def on_space(space, index, as:, timeout:, format: false)
103
+ TupleShape.check(as)
104
+ space_info = nil
105
+ needs_format = format || as != :tuple
106
+ response = perform_on_space(space, index, timeout:, format: needs_format) do |space_id, index_id, info|
107
+ space_info = info
108
+ yield space_id, index_id, info
109
+ end
110
+
111
+ response.data.map { |tuple| TupleShape.apply(as, tuple, space_info) }
112
+ end
113
+
114
+ def tuple_for(tuple, space_info) = tuple.is_a?(Hash) ? space_info.to_tuple(tuple) : tuple
115
+
84
116
  def key_tuple(key)
85
117
  key.is_a?(Array) ? key : [key]
86
118
  end
@@ -8,11 +8,13 @@ module Tarantool
8
8
  # @api private
9
9
  class Member
10
10
  include Pool::MemberForkSafety
11
+ include Pool::MemberRole
11
12
 
12
13
  def initialize(uri, options)
13
14
  @uri = uri
14
15
  @options = options
15
16
  @config = Configuration.build(uri, **options)
17
+ @instrumentation = Instrumentation.build(@config)
16
18
  @lock = Mutex.new
17
19
  @changed = ConditionVariable.new
18
20
  @closed = false
@@ -75,13 +77,6 @@ module Tarantool
75
77
  client.close if closed
76
78
  end
77
79
 
78
- def update(status)
79
- @lock.synchronize do
80
- @read_only = status['is_ro']
81
- @changed.broadcast
82
- end
83
- end
84
-
85
80
  def wait_for_status
86
81
  deadline = Transport::Deadline.after(@config.connect_timeout)
87
82
 
@@ -115,7 +110,7 @@ module Tarantool
115
110
  attempt += 1
116
111
  end
117
112
  rescue StandardError => e
118
- warn "tarantool pool: gave up connecting to #{address}: #{e.class}: #{e.message}"
113
+ Diagnostics.warn(@config.logger, "tarantool pool: gave up connecting to #{address}: #{e.class}: #{e.message}")
119
114
  end
120
115
 
121
116
  def try_connect
@@ -125,7 +120,7 @@ module Tarantool
125
120
  rescue Error
126
121
  false
127
122
  rescue StandardError => e
128
- warn "tarantool pool: connecting to #{address} failed: #{e.class}: #{e.message}"
123
+ Diagnostics.warn(@config.logger, "tarantool pool: connecting to #{address} failed: #{e.class}: #{e.message}")
129
124
  false
130
125
  end
131
126
 
@@ -9,7 +9,16 @@ module Tarantool
9
9
  # to the parent: closing the client in the child only drops its socket copy, dead threads join at once.
10
10
  # Nothing inherited may stop the reset, or the child's pool would never start its members.
11
11
  def reset_for_fork
12
- old_retrier, old_client = @lock.synchronize do
12
+ old_retrier, old_client = clear_fork_state
13
+ ThreadJoin.join(old_retrier)
14
+ close_inherited(old_client)
15
+ @instrumentation&.notify('fork.tarantool')
16
+ end
17
+
18
+ private
19
+
20
+ def clear_fork_state
21
+ @lock.synchronize do
13
22
  retrier = @retrier
14
23
  client = @client
15
24
  @retrier = nil
@@ -17,12 +26,8 @@ module Tarantool
17
26
  @read_only = nil
18
27
  [retrier, client]
19
28
  end
20
- ThreadJoin.join(old_retrier)
21
- close_inherited(old_client)
22
29
  end
23
30
 
24
- private
25
-
26
31
  def close_inherited(client)
27
32
  client&.close
28
33
  rescue StandardError
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ class Pool
6
+ # The role a member's instance reports through box.status. Includers set @lock, @changed, @read_only and
7
+ # @instrumentation.
8
+ # @api private
9
+ module MemberRole
10
+ private
11
+
12
+ def update(status)
13
+ read_only = status['is_ro']
14
+ changed = @lock.synchronize do
15
+ @read_only = read_only
16
+ @changed.broadcast
17
+ role_changed?(read_only)
18
+ end
19
+
20
+ @instrumentation&.notify('role_change.tarantool', read_only:) if changed
21
+ end
22
+
23
+ # Compared against the last known role rather than @read_only, which disconnect and fork reset:
24
+ # a reconnect that brings back the same role is not a change. A status without is_ro is routing
25
+ # information only, never a role to compare against.
26
+ def role_changed?(read_only)
27
+ return false if read_only.nil?
28
+
29
+ previous = @known_read_only
30
+ @known_read_only = read_only
31
+ !previous.nil? && previous != read_only
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ # A tuple as the server returned it, with its fields by name (SpaceInfo#to_hash). Returned for `as: :row`.
6
+ Row = Data.define(:tuple, :attributes)
7
+ end
8
+ end
@@ -39,7 +39,7 @@ module Tarantool
39
39
  end
40
40
 
41
41
  def space_info(space)
42
- @space_infos.fetch(space_id(space)) { raise SchemaError, "space '#{space}' not found" }
42
+ @space_infos.fetch(space_id(space))
43
43
  end
44
44
 
45
45
  private
@@ -52,9 +52,10 @@ module Tarantool
52
52
  end
53
53
 
54
54
  def space_id(space)
55
- return space if space.is_a?(Integer)
55
+ id = space.is_a?(Integer) ? space : @space_ids[space.to_s]
56
+ return id if @space_infos.key?(id)
56
57
 
57
- @space_ids.fetch(space.to_s) { raise SchemaError, "space '#{space}' not found" }
58
+ raise SchemaError, "space '#{space}' not found"
58
59
  end
59
60
 
60
61
  def index_id(space_id, index)
@@ -4,6 +4,8 @@ module Tarantool
4
4
  class Client
5
5
  # Format and indexes of a space from the loaded schema. `indexes` maps names to IndexInfo in id order.
6
6
  SpaceInfo = Data.define(:id, :name, :fields, :indexes) do
7
+ include TupleMapping
8
+
7
9
  def self.from_tuples(space, index_tuples)
8
10
  id, _owner, name, _engine, _field_count, _flags, format = space
9
11
  fields = (format || []).map { |raw| SpaceInfo::Field.from_raw(raw) }
@@ -6,15 +6,17 @@ module Tarantool
6
6
  # stream's requests in order. Created by Client#transaction.
7
7
  class Stream
8
8
  include Operations
9
+ include Instrumented
9
10
 
10
11
  LOST = 'connection lost during the transaction; the server rolled it back'
11
12
  FINISHED = 'transaction is finished'
12
13
 
13
- def initialize(connection:, schema_cache:, id:, request_timeout:)
14
+ def initialize(connection:, schema_cache:, id:, request_timeout:, instrumentation: nil)
14
15
  @connection = connection
15
16
  @schema_cache = schema_cache
16
17
  @id = id
17
18
  @request_timeout = request_timeout
19
+ @instrumentation = instrumentation
18
20
  @state = :idle
19
21
  @pid = Process.pid
20
22
  end
@@ -51,34 +53,42 @@ module Tarantool
51
53
  abandon if @state == :active
52
54
  end
53
55
 
54
- def perform(request, timeout: nil, schema_version: nil, on_push: nil)
56
+ def transmit(request, timeout: nil, schema_version: nil, on_push: nil)
55
57
  check_active
56
58
 
57
59
  send_request(request, timeout: timeout || @request_timeout, schema_version:, on_push:)
58
60
  end
59
61
 
60
- def perform_on_space(space, index, timeout: nil, &)
61
- return perform(yield(space, index), timeout:) if space.is_a?(Integer) && index.is_a?(Integer)
62
+ def transmit_on_space(space, index, timeout: nil, format: false, payload: nil, &)
63
+ return transmit(yield(space, index), timeout:) if !format && space.is_a?(Integer) && index.is_a?(Integer)
62
64
 
63
- perform_by_name(space, index, timeout || @request_timeout, &)
65
+ perform_by_name(space, index, timeout || @request_timeout, payload, &)
64
66
  end
65
67
 
66
68
  # Checks for a lost connection first: resolving would otherwise meet the next connection's schema
67
69
  # and report a schema change instead.
68
- def perform_by_name(space, index, timeout)
70
+ def perform_by_name(space, index, timeout, payload, &)
69
71
  check_connection
70
72
  deadline = Transport::Deadline.after(timeout)
71
- request = yield(*@schema.resolve(space, index, deadline))
73
+ request = build_request(space, index, deadline, payload, &)
72
74
 
73
- perform(request, timeout: remaining(deadline), schema_version: @schema.version)
75
+ transmit(request, timeout: remaining(deadline), schema_version: @schema.version)
74
76
  rescue ServerError => e
75
77
  raise unless e.code == Protocol::Constants::ER_WRONG_SCHEMA_VERSION
76
78
 
77
- @schema.raise_changed(deadline)
79
+ mark_schema_reloaded(payload) if @schema.refresh?(deadline)
80
+ raise TransactionError, TransactionSchema::CHANGED
78
81
  rescue TimeoutError
79
82
  raise TimeoutError, "no response within #{timeout}s"
80
83
  end
81
84
 
85
+ def build_request(space, index, deadline, payload)
86
+ (space_id, index_id), reloaded = @schema.resolve(space, index, deadline)
87
+ mark_schema_reloaded(payload) if reloaded
88
+
89
+ yield(space_id, index_id, @schema.space_info(space_id, deadline))
90
+ end
91
+
82
92
  def lookup_space(space, timeout)
83
93
  check_active
84
94
  check_connection
@@ -98,7 +108,7 @@ module Tarantool
98
108
  def finish(request)
99
109
  @state = :finished
100
110
 
101
- send_request(request, timeout: @request_timeout)
111
+ instrumented(request) { send_request(request, timeout: @request_timeout) }
102
112
  end
103
113
 
104
114
  # A stream is bound to its connection's session, so a reconnect can't carry the transaction over.
@@ -17,21 +17,26 @@ module Tarantool
17
17
  @version = version
18
18
  end
19
19
 
20
+ # The ids, and whether the cache had to catch up with the BEGIN version first.
20
21
  def resolve(space, index, deadline)
21
- pinned(deadline).resolve(space, index)
22
+ cached = @cache.schema
23
+ schema = pinned(cached, deadline)
24
+
25
+ [schema.resolve(space, index), !schema.equal?(cached)]
22
26
  end
23
27
 
24
28
  def space_info(space, deadline)
25
- pinned(deadline).space_info(space)
29
+ pinned(@cache.schema, deadline).space_info(space)
26
30
  end
27
31
 
28
32
  # A request rejected for its schema version did not run, so the transaction still rolls back
29
- # cleanly. The reload spares the retried transaction the same rejection.
30
- def raise_changed(deadline)
33
+ # cleanly. The reload spares the retried transaction the same rejection. Returns whether it reloaded.
34
+ def refresh?(deadline)
31
35
  schema = @cache.schema
32
- reload(schema, deadline) if schema.version == version
36
+ return false unless schema.version == version
33
37
 
34
- raise TransactionError, CHANGED
38
+ reload(schema, deadline)
39
+ true
35
40
  end
36
41
 
37
42
  private
@@ -39,8 +44,7 @@ module Tarantool
39
44
  # A cache older than BEGIN was stale before the transaction started and may catch up;
40
45
  # anything newer means the schema changed after BEGIN. A schema of another connection means
41
46
  # this one was lost, and the versions of two instances can't be compared.
42
- def pinned(deadline)
43
- schema = @cache.schema
47
+ def pinned(schema, deadline)
44
48
  raise TransactionError, Stream::LOST unless schema.connection.equal?(@connection)
45
49
 
46
50
  schema = reload(schema, deadline) if schema.version < version
@@ -58,6 +58,8 @@ module Tarantool
58
58
  !@pending.failure.nil?
59
59
  end
60
60
 
61
+ def failure = @pending.failure
62
+
61
63
  private
62
64
 
63
65
  def establish
@@ -8,18 +8,21 @@ module Tarantool
8
8
  # States: connecting -> ready -> reconnecting -> closed.
9
9
  # @api private
10
10
  class Link
11
+ include LinkEvents
12
+
11
13
  attr_writer :event_handler
12
14
 
13
15
  # on_connect runs on each new connection before requests can use it and may raise to fail the
14
16
  # attempt; on_ready runs once the connection is current; on_lost runs when a reconnect starts,
15
17
  # before its first attempt.
16
- def initialize(config, on_connect:, on_ready:, on_lost: nil, backoff: nil)
18
+ def initialize(config, on_connect:, on_ready:, on_lost: nil, backoff: nil, instrumentation: nil)
17
19
  @config = config
18
20
  @on_connect = on_connect
19
21
  @on_ready = on_ready
20
22
  @on_lost = on_lost
21
23
  @backoff = backoff || Backoff.new(base: config.reconnect_delay, max: config.reconnect_max_delay)
22
24
  @state = LinkState.new(config.address, reconnect: config.reconnect)
25
+ @instrumentation = instrumentation
23
26
  end
24
27
 
25
28
  def connect
@@ -93,25 +96,28 @@ module Tarantool
93
96
  end
94
97
 
95
98
  def lost(connection)
96
- return unless @config.reconnect
99
+ return report_lost(connection) unless @config.reconnect
97
100
 
98
101
  @state.start_reconnect(connection) do |previous|
99
- Thread.new { reconnect(previous) }.tap { |thread| thread.name = "tarantool-reconnect #{@config.address}" }
102
+ Thread.new { reconnect(previous, connection) }.tap do |thread|
103
+ thread.name = "tarantool-reconnect #{@config.address}"
104
+ end
100
105
  end
101
106
  rescue ThreadError => e
102
- @state.give_up(ConnectionError.new("cannot reconnect to #{@config.address}: #{e.message}"))
107
+ fail_reconnect(ConnectionError.new("cannot reconnect to #{@config.address}: #{e.message}"))
103
108
  end
104
109
 
105
110
  # Joining the previous thread first lets close join only the newest one. Nothing may escape: a dead
106
111
  # thread would leave the link reconnecting forever, and its exception would resurface in every join.
107
- def reconnect(previous)
112
+ def reconnect(previous, connection)
108
113
  ThreadJoin.join(previous)
114
+ emit('disconnect.tarantool', reason: connection.failure&.message)
109
115
  run_hook('on_lost') { @on_lost&.call }
110
116
 
111
117
  reconnect_with_backoff
112
118
  rescue StandardError => e
113
- warn(e.full_message)
114
- @state.give_up(ConnectionError.new("reconnect to #{@config.address} failed: #{e.class}: #{e.message}"))
119
+ Diagnostics.warn(@config.logger, e.full_message)
120
+ fail_reconnect(ConnectionError.new("reconnect to #{@config.address} failed: #{e.class}: #{e.message}"))
115
121
  end
116
122
 
117
123
  def reconnect_with_backoff
@@ -119,7 +125,7 @@ module Tarantool
119
125
 
120
126
  while @state.pause(@backoff.delay(attempt))
121
127
  result = attempt_connect
122
- return run_hook('on_ready') { install(result) } if result.is_a?(Connection)
128
+ return reconnected(result, attempt + 1) if result.is_a?(Connection)
123
129
 
124
130
  attempt += 1
125
131
  return give_up(attempt, result) if attempt >= @config.reconnect_attempts
@@ -136,13 +142,7 @@ module Tarantool
136
142
  def run_hook(name)
137
143
  yield
138
144
  rescue StandardError => e
139
- warn("#{name} failed for #{@config.address}: #{e.full_message}")
140
- end
141
-
142
- def give_up(attempts, error)
143
- @state.give_up(
144
- ConnectionError.new("cannot reconnect to #{@config.address} after #{attempts} attempts: #{error.message}")
145
- )
145
+ Diagnostics.warn(@config.logger, "#{name} failed for #{@config.address}: #{e.full_message}")
146
146
  end
147
147
  end
148
148
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ module Transport
6
+ # Lifecycle events of a Link. Includers set @config, @state and @instrumentation, and provide
7
+ # #run_hook and #install.
8
+ # @api private
9
+ module LinkEvents
10
+ private
11
+
12
+ # Without reconnect the link keeps the lost connection, so the loss of the current one is all to
13
+ # report. The reader thread's disconnect handler and install's own loss check can both reach this
14
+ # for the same connection, so LinkState claims the loss once.
15
+ def report_lost(connection)
16
+ emit('disconnect.tarantool', reason: connection.failure&.message) if @state.claim_loss(connection)
17
+ end
18
+
19
+ # A connection the link was closed under, or that broke during on_ready, is no reconnect to report.
20
+ def reconnected(connection, attempts)
21
+ run_hook('on_ready') { install(connection) }
22
+ emit('reconnect.tarantool', attempts:) unless connection.closed?
23
+ end
24
+
25
+ def give_up(attempts, error)
26
+ message = "cannot reconnect to #{@config.address} after #{attempts} attempts: #{error.message}"
27
+
28
+ fail_reconnect(ConnectionError.new(message), attempts:)
29
+ end
30
+
31
+ def fail_reconnect(error, **payload)
32
+ @state.give_up(error)
33
+ emit('reconnect_failed.tarantool', **payload, **Instrumentation.exception_keys(error))
34
+ end
35
+
36
+ # Link already has a public #notify for WATCH/UNWATCH requests, so events use a different name.
37
+ def emit(name, **payload) = @instrumentation&.notify(name, payload)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -20,6 +20,16 @@ module Tarantool
20
20
  @lock.synchronize { @state == :ready && !@connection.closed? }
21
21
  end
22
22
 
23
+ # Without reconnect the link stays on the lost connection; the first caller reports its loss.
24
+ def claim_loss(connection)
25
+ @lock.synchronize do
26
+ next false unless @connection.equal?(connection) && !@lost.equal?(connection)
27
+
28
+ @lost = connection
29
+ true
30
+ end
31
+ end
32
+
23
33
  # Records the connection being opened so that close can abort it; raises once the link is closed.
24
34
  def attempting(connection)
25
35
  @lock.synchronize do