tarantool-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +222 -0
- data/lib/tarantool/client/backoff.rb +23 -0
- data/lib/tarantool/client/box_error.rb +15 -0
- data/lib/tarantool/client/callback_guard.rb +23 -0
- data/lib/tarantool/client/configuration.rb +71 -0
- data/lib/tarantool/client/errors.rb +56 -0
- data/lib/tarantool/client/fork_safety.rb +42 -0
- data/lib/tarantool/client/index_info.rb +27 -0
- data/lib/tarantool/client/interval.rb +25 -0
- data/lib/tarantool/client/operations.rb +89 -0
- data/lib/tarantool/client/pool/member.rb +141 -0
- data/lib/tarantool/client/pool/member_fork_safety.rb +34 -0
- data/lib/tarantool/client/pool.rb +127 -0
- data/lib/tarantool/client/prepared_statement.rb +25 -0
- data/lib/tarantool/client/protocol/auth/chap_sha1.rb +28 -0
- data/lib/tarantool/client/protocol/codec.rb +79 -0
- data/lib/tarantool/client/protocol/constants.rb +105 -0
- data/lib/tarantool/client/protocol/extensions/datetime.rb +39 -0
- data/lib/tarantool/client/protocol/extensions/decimal.rb +73 -0
- data/lib/tarantool/client/protocol/extensions/error.rb +33 -0
- data/lib/tarantool/client/protocol/extensions/interval.rb +59 -0
- data/lib/tarantool/client/protocol/extensions/uuid.rb +29 -0
- data/lib/tarantool/client/protocol/extensions.rb +27 -0
- data/lib/tarantool/client/protocol/greeting.rb +21 -0
- data/lib/tarantool/client/protocol/requests/auth.rb +23 -0
- data/lib/tarantool/client/protocol/requests/begin.rb +30 -0
- data/lib/tarantool/client/protocol/requests/call.rb +16 -0
- data/lib/tarantool/client/protocol/requests/commit.rb +16 -0
- data/lib/tarantool/client/protocol/requests/delete.rb +19 -0
- data/lib/tarantool/client/protocol/requests/eval.rb +16 -0
- data/lib/tarantool/client/protocol/requests/execute.rb +21 -0
- data/lib/tarantool/client/protocol/requests/id.rb +16 -0
- data/lib/tarantool/client/protocol/requests/insert.rb +16 -0
- data/lib/tarantool/client/protocol/requests/ping.rb +17 -0
- data/lib/tarantool/client/protocol/requests/prepare.rb +16 -0
- data/lib/tarantool/client/protocol/requests/replace.rb +16 -0
- data/lib/tarantool/client/protocol/requests/rollback.rb +16 -0
- data/lib/tarantool/client/protocol/requests/select.rb +32 -0
- data/lib/tarantool/client/protocol/requests/unwatch.rb +16 -0
- data/lib/tarantool/client/protocol/requests/update.rb +24 -0
- data/lib/tarantool/client/protocol/requests/upsert.rb +19 -0
- data/lib/tarantool/client/protocol/requests/watch.rb +16 -0
- data/lib/tarantool/client/protocol/response.rb +36 -0
- data/lib/tarantool/client/schema.rb +69 -0
- data/lib/tarantool/client/schema_cache.rb +54 -0
- data/lib/tarantool/client/space_info.rb +27 -0
- data/lib/tarantool/client/sql_result.rb +24 -0
- data/lib/tarantool/client/stream.rb +130 -0
- data/lib/tarantool/client/thread_join.rb +19 -0
- data/lib/tarantool/client/transaction_schema.rb +57 -0
- data/lib/tarantool/client/transaction_scope.rb +28 -0
- data/lib/tarantool/client/transport/connection.rb +141 -0
- data/lib/tarantool/client/transport/deadline.rb +17 -0
- data/lib/tarantool/client/transport/frame_writer.rb +41 -0
- data/lib/tarantool/client/transport/future.rb +60 -0
- data/lib/tarantool/client/transport/handshake.rb +36 -0
- data/lib/tarantool/client/transport/link.rb +150 -0
- data/lib/tarantool/client/transport/link_state.rb +115 -0
- data/lib/tarantool/client/transport/pending_requests.rb +55 -0
- data/lib/tarantool/client/transport/stream_ids.rb +20 -0
- data/lib/tarantool/client/transport/timed_lock.rb +53 -0
- data/lib/tarantool/client/transport/timed_read.rb +31 -0
- data/lib/tarantool/client/uuid.rb +20 -0
- data/lib/tarantool/client/version.rb +7 -0
- data/lib/tarantool/client/watcher.rb +37 -0
- data/lib/tarantool/client/watchers/dispatcher.rb +40 -0
- data/lib/tarantool/client/watchers.rb +137 -0
- data/lib/tarantool/client.rb +216 -0
- metadata +148 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
module Transport
|
|
6
|
+
# Keeps one instance connected. Owns the current Connection; once it is lost, a background thread
|
|
7
|
+
# opens new ones with backoff while requests wait for one to become ready.
|
|
8
|
+
# States: connecting -> ready -> reconnecting -> closed.
|
|
9
|
+
# @api private
|
|
10
|
+
class Link
|
|
11
|
+
attr_writer :event_handler
|
|
12
|
+
|
|
13
|
+
# on_connect runs on each new connection before requests can use it and may raise to fail the
|
|
14
|
+
# attempt; on_ready runs once the connection is current; on_lost runs when a reconnect starts,
|
|
15
|
+
# before its first attempt.
|
|
16
|
+
def initialize(config, on_connect:, on_ready:, on_lost: nil, backoff: nil)
|
|
17
|
+
@config = config
|
|
18
|
+
@on_connect = on_connect
|
|
19
|
+
@on_ready = on_ready
|
|
20
|
+
@on_lost = on_lost
|
|
21
|
+
@backoff = backoff || Backoff.new(base: config.reconnect_delay, max: config.reconnect_max_delay)
|
|
22
|
+
@state = LinkState.new(config.address, reconnect: config.reconnect)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def connect
|
|
26
|
+
install(open_connection)
|
|
27
|
+
connected = true
|
|
28
|
+
|
|
29
|
+
self
|
|
30
|
+
ensure
|
|
31
|
+
close unless connected
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def perform(request, timeout:, **)
|
|
35
|
+
deadline = Deadline.after(timeout)
|
|
36
|
+
|
|
37
|
+
current(deadline).perform(request, timeout: Deadline.remaining(deadline), **)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def notify(request, timeout:)
|
|
41
|
+
deadline = Deadline.after(timeout)
|
|
42
|
+
|
|
43
|
+
current(deadline).notify(request, timeout: Deadline.remaining(deadline))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# For requests that must stay on one connection: a stream, or a request resolved by name.
|
|
47
|
+
def connection(deadline = nil) = current(deadline)
|
|
48
|
+
|
|
49
|
+
def greeting = @state.connection.greeting
|
|
50
|
+
def connected? = @state.connected?
|
|
51
|
+
|
|
52
|
+
# Closing the attempt first fails a handshake or on_connect blocked on it, so the join is short.
|
|
53
|
+
def close
|
|
54
|
+
reconnector, attempt = @state.close
|
|
55
|
+
attempt&.close
|
|
56
|
+
begin
|
|
57
|
+
ThreadJoin.join(reconnector)
|
|
58
|
+
ensure
|
|
59
|
+
@state.connection&.close
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def current(deadline)
|
|
68
|
+
@state.current([deadline, Deadline.after(@config.connect_timeout)].compact.min)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def open_connection
|
|
72
|
+
connection = Connection.new(@config)
|
|
73
|
+
connection.event_handler = @event_handler
|
|
74
|
+
connection.disconnect_handler = -> { lost(connection) }
|
|
75
|
+
@state.attempting(connection)
|
|
76
|
+
connection.connect
|
|
77
|
+
@on_connect.call(connection)
|
|
78
|
+
opened = true
|
|
79
|
+
|
|
80
|
+
connection
|
|
81
|
+
ensure
|
|
82
|
+
connection&.close unless opened
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A connection that broke before it became current was ignored by #lost, hence the last check. It runs
|
|
86
|
+
# even when on_ready raises, which it likely does on such a connection.
|
|
87
|
+
def install(connection)
|
|
88
|
+
return connection.close unless @state.install(connection)
|
|
89
|
+
|
|
90
|
+
@on_ready.call(connection)
|
|
91
|
+
ensure
|
|
92
|
+
lost(connection) if connection.closed?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def lost(connection)
|
|
96
|
+
return unless @config.reconnect
|
|
97
|
+
|
|
98
|
+
@state.start_reconnect(connection) do |previous|
|
|
99
|
+
Thread.new { reconnect(previous) }.tap { |thread| thread.name = "tarantool-reconnect #{@config.address}" }
|
|
100
|
+
end
|
|
101
|
+
rescue ThreadError => e
|
|
102
|
+
@state.give_up(ConnectionError.new("cannot reconnect to #{@config.address}: #{e.message}"))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Joining the previous thread first lets close join only the newest one. Nothing may escape: a dead
|
|
106
|
+
# thread would leave the link reconnecting forever, and its exception would resurface in every join.
|
|
107
|
+
def reconnect(previous)
|
|
108
|
+
ThreadJoin.join(previous)
|
|
109
|
+
run_hook('on_lost') { @on_lost&.call }
|
|
110
|
+
|
|
111
|
+
reconnect_with_backoff
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
warn(e.full_message)
|
|
114
|
+
@state.give_up(ConnectionError.new("reconnect to #{@config.address} failed: #{e.class}: #{e.message}"))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def reconnect_with_backoff
|
|
118
|
+
attempt = 0
|
|
119
|
+
|
|
120
|
+
while @state.pause(@backoff.delay(attempt))
|
|
121
|
+
result = attempt_connect
|
|
122
|
+
return run_hook('on_ready') { install(result) } if result.is_a?(Connection)
|
|
123
|
+
|
|
124
|
+
attempt += 1
|
|
125
|
+
return give_up(attempt, result) if attempt >= @config.reconnect_attempts
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def attempt_connect
|
|
130
|
+
open_connection
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
e
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A failing hook is the caller's bug, not a reason to stop reconnecting or to drop the new connection.
|
|
136
|
+
def run_hook(name)
|
|
137
|
+
yield
|
|
138
|
+
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
|
+
)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
module Transport
|
|
6
|
+
# The current connection of a Link and its state under one lock. Waiters wake on every state change.
|
|
7
|
+
# @api private
|
|
8
|
+
class LinkState
|
|
9
|
+
attr_reader :connection
|
|
10
|
+
|
|
11
|
+
def initialize(address, reconnect:)
|
|
12
|
+
@address = address
|
|
13
|
+
@reconnect = reconnect
|
|
14
|
+
@lock = Mutex.new
|
|
15
|
+
@changed = ConditionVariable.new
|
|
16
|
+
@state = :connecting
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def connected?
|
|
20
|
+
@lock.synchronize { @state == :ready && !@connection.closed? }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Records the connection being opened so that close can abort it; raises once the link is closed.
|
|
24
|
+
def attempting(connection)
|
|
25
|
+
@lock.synchronize do
|
|
26
|
+
raise @error.exception(@error.message) if @state == :closed
|
|
27
|
+
|
|
28
|
+
@attempt = connection
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns false when the link was closed meanwhile.
|
|
33
|
+
def install(connection)
|
|
34
|
+
@lock.synchronize do
|
|
35
|
+
next false if @state == :closed
|
|
36
|
+
|
|
37
|
+
@connection = connection
|
|
38
|
+
@attempt = nil
|
|
39
|
+
@state = :ready
|
|
40
|
+
@changed.broadcast
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Leaves ready only for the current connection, so a loss is handled once. The block gets the previous
|
|
46
|
+
# reconnect thread and returns the new one; the state flips only once that thread exists.
|
|
47
|
+
def start_reconnect(connection)
|
|
48
|
+
@lock.synchronize do
|
|
49
|
+
next unless @state == :ready && @connection.equal?(connection)
|
|
50
|
+
|
|
51
|
+
@reconnector = yield @reconnector
|
|
52
|
+
@state = :reconnecting
|
|
53
|
+
@changed.broadcast
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Returns the reconnect thread and the connection it is opening, for the caller to abort and join.
|
|
58
|
+
def close
|
|
59
|
+
@lock.synchronize do
|
|
60
|
+
@error = ClientClosedError.new("connection to #{@address} closed")
|
|
61
|
+
unless @state == :closed
|
|
62
|
+
@state = :closed
|
|
63
|
+
@changed.broadcast
|
|
64
|
+
end
|
|
65
|
+
[@reconnector, @attempt]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def give_up(error)
|
|
70
|
+
@lock.synchronize do
|
|
71
|
+
next if @state == :closed
|
|
72
|
+
|
|
73
|
+
@state = :closed
|
|
74
|
+
@error = error
|
|
75
|
+
@changed.broadcast
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def current(wait_until)
|
|
80
|
+
@lock.synchronize do
|
|
81
|
+
loop do
|
|
82
|
+
return @connection if usable?
|
|
83
|
+
raise @error.exception(@error.message) if @state == :closed
|
|
84
|
+
|
|
85
|
+
remaining = Deadline.remaining(wait_until)
|
|
86
|
+
raise ConnectionError, "not connected to #{@address}" if remaining&.zero?
|
|
87
|
+
|
|
88
|
+
@changed.wait(@lock, remaining)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns false once closed; close cuts the sleep short.
|
|
94
|
+
def pause(delay)
|
|
95
|
+
wake_at = Deadline.after(delay)
|
|
96
|
+
|
|
97
|
+
@lock.synchronize do
|
|
98
|
+
until @state == :closed || (remaining = Deadline.remaining(wake_at)).zero?
|
|
99
|
+
@changed.wait(@lock, remaining)
|
|
100
|
+
end
|
|
101
|
+
@state != :closed
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# A connection fails its requests before its disconnect handler starts the reconnect; with reconnect
|
|
108
|
+
# on, a request in that gap waits for the next connection instead of failing on the dead one.
|
|
109
|
+
def usable?
|
|
110
|
+
@state == :ready && !(@reconnect && @connection.closed?)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
module Transport
|
|
6
|
+
# Thread-safe table of in-flight requests keyed by IPROTO sync.
|
|
7
|
+
# @api private
|
|
8
|
+
class PendingRequests
|
|
9
|
+
def initialize
|
|
10
|
+
@lock = Mutex.new
|
|
11
|
+
@futures = {}
|
|
12
|
+
@last_sync = 0
|
|
13
|
+
@failure = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def register
|
|
17
|
+
@lock.synchronize do
|
|
18
|
+
raise @failure.exception(@failure.message) if @failure
|
|
19
|
+
|
|
20
|
+
sync = @last_sync += 1
|
|
21
|
+
[sync, @futures[sync] = Future.new]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def resolve(sync, response)
|
|
26
|
+
delete(sync)&.resolve(response)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def push(sync, message)
|
|
30
|
+
@lock.synchronize { @futures[sync] }&.push(message)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def delete(sync)
|
|
34
|
+
@lock.synchronize { @futures.delete(sync) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def failure
|
|
38
|
+
@lock.synchronize { @failure }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns whether this call recorded the failure; later calls keep the first one.
|
|
42
|
+
def fail_all(error)
|
|
43
|
+
first, failure, futures = @lock.synchronize do
|
|
44
|
+
first = @failure.nil?
|
|
45
|
+
@failure ||= error
|
|
46
|
+
[first, @failure, @futures.values.tap { @futures.clear }]
|
|
47
|
+
end
|
|
48
|
+
futures.each { |future| future.reject(failure) }
|
|
49
|
+
|
|
50
|
+
first
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
module Transport
|
|
6
|
+
# Thread-safe allocator of unique, non-zero IPROTO stream ids.
|
|
7
|
+
# @api private
|
|
8
|
+
class StreamIds
|
|
9
|
+
def initialize
|
|
10
|
+
@lock = Mutex.new
|
|
11
|
+
@last_id = 0
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def next
|
|
15
|
+
@lock.synchronize { @last_id += 1 }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
module Transport
|
|
6
|
+
# Mutual exclusion whose wait is bounded by a deadline, which Mutex#synchronize can't do. Not reentrant.
|
|
7
|
+
# @api private
|
|
8
|
+
class TimedLock
|
|
9
|
+
def initialize
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
@released = ConditionVariable.new
|
|
12
|
+
@locked = false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Async interrupts are held outside blocking waits, so none can land between taking the lock
|
|
16
|
+
# and entering the block, where the lock would never be released.
|
|
17
|
+
def synchronize(deadline, &block)
|
|
18
|
+
Thread.handle_interrupt(Object => :on_blocking) do
|
|
19
|
+
acquire(deadline)
|
|
20
|
+
begin
|
|
21
|
+
Thread.handle_interrupt(Object => :immediate, &block)
|
|
22
|
+
ensure
|
|
23
|
+
release
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def acquire(deadline)
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
while @locked
|
|
33
|
+
remaining = Deadline.remaining(deadline)
|
|
34
|
+
raise TimeoutError, 'lock wait timed out' if remaining&.zero?
|
|
35
|
+
|
|
36
|
+
@released.wait(@mutex, remaining)
|
|
37
|
+
end
|
|
38
|
+
@locked = true
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def release
|
|
43
|
+
Thread.handle_interrupt(Object => :never) do
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
@locked = false
|
|
46
|
+
@released.signal
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
module Transport
|
|
6
|
+
# Reads under one deadline for the whole read. IO#timeout bounds each read instead, so a peer
|
|
7
|
+
# sending a byte at a time could stretch it to size times the timeout.
|
|
8
|
+
# @api private
|
|
9
|
+
module TimedRead
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def read_exactly(io, size, deadline)
|
|
13
|
+
data = String.new(capacity: size, encoding: Encoding::BINARY)
|
|
14
|
+
while data.bytesize < size
|
|
15
|
+
chunk = io.read_nonblock(size - data.bytesize, exception: false)
|
|
16
|
+
raise EOFError, 'connection closed by server' if chunk.nil?
|
|
17
|
+
|
|
18
|
+
chunk == :wait_readable ? wait_readable(io, deadline) : data << chunk
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
data
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def wait_readable(io, deadline)
|
|
25
|
+
raise TimeoutError, 'read timed out' unless io.wait_readable(Deadline.remaining(deadline))
|
|
26
|
+
end
|
|
27
|
+
private_class_method :wait_readable
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
# A Tarantool uuid value; only instances of this type are sent as MP_EXT uuid, never plain strings.
|
|
6
|
+
UUID = Data.define(:value) do
|
|
7
|
+
self::FORMAT = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
8
|
+
|
|
9
|
+
def initialize(value:)
|
|
10
|
+
raise ArgumentError, "invalid UUID: #{value.inspect}" unless value.is_a?(String) && value.match?(UUID::FORMAT)
|
|
11
|
+
|
|
12
|
+
super(value: value.downcase.freeze)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_s
|
|
16
|
+
value
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
# Subscription returned by Client#watch.
|
|
6
|
+
class Watcher
|
|
7
|
+
attr_reader :key
|
|
8
|
+
|
|
9
|
+
def initialize(key, watchers, callback)
|
|
10
|
+
@key = key
|
|
11
|
+
@watchers = watchers
|
|
12
|
+
@callback = callback
|
|
13
|
+
@active = true
|
|
14
|
+
@notified = false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def unwatch
|
|
18
|
+
@active = false
|
|
19
|
+
@watchers.remove(self)
|
|
20
|
+
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @api private
|
|
25
|
+
def notified? = @notified
|
|
26
|
+
|
|
27
|
+
# @api private
|
|
28
|
+
def notify(value)
|
|
29
|
+
return unless @active
|
|
30
|
+
|
|
31
|
+
@notified = true
|
|
32
|
+
|
|
33
|
+
CallbackGuard.run("tarantool watcher #{key.inspect}") { @callback.call(key, value) }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
class Watchers
|
|
6
|
+
# Runs jobs in order on one thread, started by the first job and again by the first one after it died.
|
|
7
|
+
# Close drops the jobs still queued.
|
|
8
|
+
# @api private
|
|
9
|
+
class Dispatcher
|
|
10
|
+
def initialize
|
|
11
|
+
@lock = Mutex.new
|
|
12
|
+
@jobs = Thread::Queue.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def push(&job)
|
|
16
|
+
@lock.synchronize do
|
|
17
|
+
@thread = Thread.new { run }.tap { |thread| thread.name = 'tarantool-watchers' } unless @thread&.alive?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
@jobs.push(job)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def close
|
|
24
|
+
@jobs.clear
|
|
25
|
+
@jobs.close
|
|
26
|
+
|
|
27
|
+
ThreadJoin.join(@thread)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def run
|
|
33
|
+
while (job = @jobs.pop)
|
|
34
|
+
job.call
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
class Client
|
|
5
|
+
# Watchers of one client over IPROTO_WATCH. The server tracks keys, not watchers: WATCH goes
|
|
6
|
+
# out for the first watcher of a key and UNWATCH after the last. Callbacks run on one
|
|
7
|
+
# dispatcher thread so they never stall the reader; the ack (WATCH again) follows them, letting
|
|
8
|
+
# the server coalesce changes made meanwhile. Watched keys outlive the socket so a reconnect
|
|
9
|
+
# can subscribe to them again. A key has at most one event queued: a newer value replaces the
|
|
10
|
+
# pending one, as the server itself only promises the latest.
|
|
11
|
+
# @api private
|
|
12
|
+
class Watchers
|
|
13
|
+
def initialize(link, timeout:)
|
|
14
|
+
@link = link
|
|
15
|
+
@timeout = timeout
|
|
16
|
+
@lock = Mutex.new
|
|
17
|
+
@watchers = {}
|
|
18
|
+
@values = {}
|
|
19
|
+
@pending = {}
|
|
20
|
+
@dispatcher = Dispatcher.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def add(key, callback)
|
|
24
|
+
watcher = Watcher.new(key, self, callback)
|
|
25
|
+
@lock.synchronize do
|
|
26
|
+
send_request(Protocol::Requests::Watch.new(key:)) unless @watchers.key?(key)
|
|
27
|
+
(@watchers[key] ||= []) << watcher
|
|
28
|
+
end
|
|
29
|
+
@dispatcher.push { catch_up(watcher) }
|
|
30
|
+
|
|
31
|
+
watcher
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Errors are swallowed: the watcher is already gone locally, and a server-side subscription left
|
|
35
|
+
# behind by an unsent UNWATCH yields at most one event, which is ignored and never acked.
|
|
36
|
+
def remove(watcher)
|
|
37
|
+
@lock.synchronize do
|
|
38
|
+
watchers = @watchers[watcher.key]
|
|
39
|
+
next unless watchers&.delete(watcher) && watchers.empty?
|
|
40
|
+
|
|
41
|
+
@watchers.delete(watcher.key)
|
|
42
|
+
@values.delete(watcher.key)
|
|
43
|
+
@pending.delete(watcher.key)
|
|
44
|
+
send_request(Protocol::Requests::Unwatch.new(key: watcher.key))
|
|
45
|
+
end
|
|
46
|
+
rescue Error
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def dispatch(key, value)
|
|
51
|
+
@lock.synchronize do
|
|
52
|
+
next unless @watchers.key?(key)
|
|
53
|
+
|
|
54
|
+
queued = @pending.key?(key)
|
|
55
|
+
@pending[key] = value
|
|
56
|
+
@dispatcher.push { deliver(key) } unless queued
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Runs the block on the dispatcher after the events queued so far.
|
|
61
|
+
def defer(&job)
|
|
62
|
+
@dispatcher.push { CallbackGuard.run('tarantool watchers: deferred job', &job) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A new session has no subscriptions. WATCH also makes the server send each key's current value,
|
|
66
|
+
# so watchers learn what changed while the client was away. A failed key does not skip the rest.
|
|
67
|
+
def resubscribe(connection)
|
|
68
|
+
@lock.synchronize do
|
|
69
|
+
@watchers.each_key do |key|
|
|
70
|
+
connection.notify(Protocol::Requests::Watch.new(key:), timeout: @timeout)
|
|
71
|
+
rescue Error
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Registrations survive a fork; values and the dispatcher are per process and come back after resubscribe.
|
|
78
|
+
def restart_after_fork(new_link)
|
|
79
|
+
@link = new_link
|
|
80
|
+
@values = {}
|
|
81
|
+
@pending = {}
|
|
82
|
+
@dispatcher = Dispatcher.new
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def close
|
|
86
|
+
@dispatcher.close
|
|
87
|
+
@lock.synchronize do
|
|
88
|
+
@watchers.clear
|
|
89
|
+
@values.clear
|
|
90
|
+
@pending.clear
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def deliver(key)
|
|
97
|
+
watchers, value = take_pending(key)
|
|
98
|
+
return unless watchers
|
|
99
|
+
|
|
100
|
+
watchers.each { |watcher| watcher.notify(value) }
|
|
101
|
+
|
|
102
|
+
acknowledge(key)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A job can outlive its event: the key may have been unwatched, and watched again, since it was queued.
|
|
106
|
+
def take_pending(key)
|
|
107
|
+
@lock.synchronize do
|
|
108
|
+
next unless @pending.key?(key)
|
|
109
|
+
|
|
110
|
+
value = @values[key] = @pending.delete(key)
|
|
111
|
+
[@watchers[key].dup, value]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The server sends a key's value once per WATCH, so a watcher joining a watched key
|
|
116
|
+
# gets the cached one, unless an event queued before this job already reached it.
|
|
117
|
+
def catch_up(watcher)
|
|
118
|
+
known, value = @lock.synchronize { [@values.key?(watcher.key), @values[watcher.key]] }
|
|
119
|
+
|
|
120
|
+
watcher.notify(value) if known && !watcher.notified?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Failure means the connection is gone, and the subscription with it.
|
|
124
|
+
def acknowledge(key)
|
|
125
|
+
@lock.synchronize do
|
|
126
|
+
send_request(Protocol::Requests::Watch.new(key:)) if @watchers.key?(key)
|
|
127
|
+
end
|
|
128
|
+
rescue Error
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def send_request(request)
|
|
133
|
+
@link.notify(request, timeout: @timeout)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|