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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE +21 -0
  4. data/README.md +222 -0
  5. data/lib/tarantool/client/backoff.rb +23 -0
  6. data/lib/tarantool/client/box_error.rb +15 -0
  7. data/lib/tarantool/client/callback_guard.rb +23 -0
  8. data/lib/tarantool/client/configuration.rb +71 -0
  9. data/lib/tarantool/client/errors.rb +56 -0
  10. data/lib/tarantool/client/fork_safety.rb +42 -0
  11. data/lib/tarantool/client/index_info.rb +27 -0
  12. data/lib/tarantool/client/interval.rb +25 -0
  13. data/lib/tarantool/client/operations.rb +89 -0
  14. data/lib/tarantool/client/pool/member.rb +141 -0
  15. data/lib/tarantool/client/pool/member_fork_safety.rb +34 -0
  16. data/lib/tarantool/client/pool.rb +127 -0
  17. data/lib/tarantool/client/prepared_statement.rb +25 -0
  18. data/lib/tarantool/client/protocol/auth/chap_sha1.rb +28 -0
  19. data/lib/tarantool/client/protocol/codec.rb +79 -0
  20. data/lib/tarantool/client/protocol/constants.rb +105 -0
  21. data/lib/tarantool/client/protocol/extensions/datetime.rb +39 -0
  22. data/lib/tarantool/client/protocol/extensions/decimal.rb +73 -0
  23. data/lib/tarantool/client/protocol/extensions/error.rb +33 -0
  24. data/lib/tarantool/client/protocol/extensions/interval.rb +59 -0
  25. data/lib/tarantool/client/protocol/extensions/uuid.rb +29 -0
  26. data/lib/tarantool/client/protocol/extensions.rb +27 -0
  27. data/lib/tarantool/client/protocol/greeting.rb +21 -0
  28. data/lib/tarantool/client/protocol/requests/auth.rb +23 -0
  29. data/lib/tarantool/client/protocol/requests/begin.rb +30 -0
  30. data/lib/tarantool/client/protocol/requests/call.rb +16 -0
  31. data/lib/tarantool/client/protocol/requests/commit.rb +16 -0
  32. data/lib/tarantool/client/protocol/requests/delete.rb +19 -0
  33. data/lib/tarantool/client/protocol/requests/eval.rb +16 -0
  34. data/lib/tarantool/client/protocol/requests/execute.rb +21 -0
  35. data/lib/tarantool/client/protocol/requests/id.rb +16 -0
  36. data/lib/tarantool/client/protocol/requests/insert.rb +16 -0
  37. data/lib/tarantool/client/protocol/requests/ping.rb +17 -0
  38. data/lib/tarantool/client/protocol/requests/prepare.rb +16 -0
  39. data/lib/tarantool/client/protocol/requests/replace.rb +16 -0
  40. data/lib/tarantool/client/protocol/requests/rollback.rb +16 -0
  41. data/lib/tarantool/client/protocol/requests/select.rb +32 -0
  42. data/lib/tarantool/client/protocol/requests/unwatch.rb +16 -0
  43. data/lib/tarantool/client/protocol/requests/update.rb +24 -0
  44. data/lib/tarantool/client/protocol/requests/upsert.rb +19 -0
  45. data/lib/tarantool/client/protocol/requests/watch.rb +16 -0
  46. data/lib/tarantool/client/protocol/response.rb +36 -0
  47. data/lib/tarantool/client/schema.rb +69 -0
  48. data/lib/tarantool/client/schema_cache.rb +54 -0
  49. data/lib/tarantool/client/space_info.rb +27 -0
  50. data/lib/tarantool/client/sql_result.rb +24 -0
  51. data/lib/tarantool/client/stream.rb +130 -0
  52. data/lib/tarantool/client/thread_join.rb +19 -0
  53. data/lib/tarantool/client/transaction_schema.rb +57 -0
  54. data/lib/tarantool/client/transaction_scope.rb +28 -0
  55. data/lib/tarantool/client/transport/connection.rb +141 -0
  56. data/lib/tarantool/client/transport/deadline.rb +17 -0
  57. data/lib/tarantool/client/transport/frame_writer.rb +41 -0
  58. data/lib/tarantool/client/transport/future.rb +60 -0
  59. data/lib/tarantool/client/transport/handshake.rb +36 -0
  60. data/lib/tarantool/client/transport/link.rb +150 -0
  61. data/lib/tarantool/client/transport/link_state.rb +115 -0
  62. data/lib/tarantool/client/transport/pending_requests.rb +55 -0
  63. data/lib/tarantool/client/transport/stream_ids.rb +20 -0
  64. data/lib/tarantool/client/transport/timed_lock.rb +53 -0
  65. data/lib/tarantool/client/transport/timed_read.rb +31 -0
  66. data/lib/tarantool/client/uuid.rb +20 -0
  67. data/lib/tarantool/client/version.rb +7 -0
  68. data/lib/tarantool/client/watcher.rb +37 -0
  69. data/lib/tarantool/client/watchers/dispatcher.rb +40 -0
  70. data/lib/tarantool/client/watchers.rb +137 -0
  71. data/lib/tarantool/client.rb +216 -0
  72. 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ class Client
5
+ VERSION = '0.1.0'
6
+ end
7
+ 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