rsmp 0.48.3 → 0.51.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/Gemfile.lock +19 -20
  4. data/README.md +27 -3
  5. data/Rakefile +3 -1
  6. data/documentation/cli.md +79 -4
  7. data/documentation/collecting_message.md +90 -170
  8. data/documentation/message_distribution.md +17 -3
  9. data/documentation/tasks.md +65 -117
  10. data/lib/rsmp/cli.rb +93 -5
  11. data/lib/rsmp/collect/collector.rb +117 -134
  12. data/lib/rsmp/collect/distributor.rb +16 -3
  13. data/lib/rsmp/collect/queue.rb +14 -6
  14. data/lib/rsmp/collect/receiver.rb +1 -1
  15. data/lib/rsmp/collect/state_collector.rb +11 -2
  16. data/lib/rsmp/collection.rb +12 -0
  17. data/lib/rsmp/completion.rb +49 -0
  18. data/lib/rsmp/component/component_base.rb +3 -3
  19. data/lib/rsmp/convert/export/json_schema/index.rb +8 -3
  20. data/lib/rsmp/convert/import/yaml.rb +12 -8
  21. data/lib/rsmp/delivery.rb +18 -0
  22. data/lib/rsmp/event.rb +19 -0
  23. data/lib/rsmp/event_source.rb +36 -0
  24. data/lib/rsmp/exchange.rb +10 -0
  25. data/lib/rsmp/failure.rb +23 -0
  26. data/lib/rsmp/helpers/error.rb +14 -23
  27. data/lib/rsmp/message.rb +9 -7
  28. data/lib/rsmp/node/node.rb +3 -21
  29. data/lib/rsmp/node/site/connections.rb +4 -9
  30. data/lib/rsmp/node/site/site.rb +31 -16
  31. data/lib/rsmp/node/supervisor/modules/connection.rb +19 -5
  32. data/lib/rsmp/node/supervisor/modules/sites.rb +16 -5
  33. data/lib/rsmp/node/supervisor/supervisor.rb +13 -14
  34. data/lib/rsmp/node/task.rb +59 -53
  35. data/lib/rsmp/proxy/modules/acknowledgements.rb +22 -9
  36. data/lib/rsmp/proxy/modules/lifecycle.rb +134 -0
  37. data/lib/rsmp/proxy/modules/receive.rb +109 -66
  38. data/lib/rsmp/proxy/modules/send.rb +109 -51
  39. data/lib/rsmp/proxy/modules/state.rb +15 -5
  40. data/lib/rsmp/proxy/modules/tasks.rb +96 -70
  41. data/lib/rsmp/proxy/modules/versions.rb +8 -8
  42. data/lib/rsmp/proxy/modules/watchdogs.rb +15 -2
  43. data/lib/rsmp/proxy/proxy.rb +4 -66
  44. data/lib/rsmp/proxy/site/connection.rb +27 -22
  45. data/lib/rsmp/proxy/site/modules/aggregated_status.rb +18 -8
  46. data/lib/rsmp/proxy/site/modules/alarms.rb +45 -38
  47. data/lib/rsmp/proxy/site/modules/commands.rb +18 -8
  48. data/lib/rsmp/proxy/site/modules/status.rb +44 -19
  49. data/lib/rsmp/proxy/site/site_proxy.rb +12 -10
  50. data/lib/rsmp/proxy/supervisor/modules/aggregated_status.rb +1 -1
  51. data/lib/rsmp/proxy/supervisor/modules/alarms.rb +1 -1
  52. data/lib/rsmp/proxy/supervisor/modules/commands.rb +1 -1
  53. data/lib/rsmp/proxy/supervisor/modules/message_buffer.rb +8 -9
  54. data/lib/rsmp/proxy/supervisor/modules/status.rb +1 -1
  55. data/lib/rsmp/proxy/supervisor/modules/status_updates.rb +1 -1
  56. data/lib/rsmp/proxy/supervisor/supervisor_proxy.rb +34 -30
  57. data/lib/rsmp/result.rb +95 -0
  58. data/lib/rsmp/schema/validation.rb +5 -3
  59. data/lib/rsmp/sxl/interface.rb +8 -1
  60. data/lib/rsmp/sxl/processing/catalogue.rb +61 -0
  61. data/lib/rsmp/sxl/processing/conflict_checker.rb +30 -0
  62. data/lib/rsmp/sxl/processing/dependency_graph.rb +37 -0
  63. data/lib/rsmp/sxl/processing/document.rb +158 -0
  64. data/lib/rsmp/sxl/processing/error.rb +8 -0
  65. data/lib/rsmp/sxl/processing/manifest.rb +144 -0
  66. data/lib/rsmp/sxl/processing/natural_sort.rb +22 -0
  67. data/lib/rsmp/sxl/processing/resolver.rb +94 -0
  68. data/lib/rsmp/sxl/processing/version_requirement.rb +86 -0
  69. data/lib/rsmp/sxl/processing.rb +18 -0
  70. data/lib/rsmp/sxl/supervisor_interface.rb +8 -1
  71. data/lib/rsmp/termination.rb +10 -0
  72. data/lib/rsmp/tlc/modules/plans.rb +0 -2
  73. data/lib/rsmp/tlc/proxy/control.rb +50 -16
  74. data/lib/rsmp/tlc/proxy/detectors.rb +15 -4
  75. data/lib/rsmp/tlc/proxy/io.rb +46 -31
  76. data/lib/rsmp/tlc/proxy/plans.rb +36 -19
  77. data/lib/rsmp/tlc/proxy/plans_bang.rb +44 -0
  78. data/lib/rsmp/tlc/proxy/status.rb +47 -23
  79. data/lib/rsmp/tlc/proxy/system.rb +16 -4
  80. data/lib/rsmp/tlc/supervisor_interface.rb +7 -1
  81. data/lib/rsmp/tlc/traffic_controller_site.rb +6 -9
  82. data/lib/rsmp/validation.rb +22 -0
  83. data/lib/rsmp/version.rb +1 -1
  84. data/lib/rsmp.rb +19 -4
  85. data/rsmp.gemspec +1 -1
  86. data/schemas/tlc/1.0.10/commands/M0001.json +3 -3
  87. data/schemas/tlc/1.0.10/source/sxl.yaml +3 -3
  88. data/schemas/tlc/1.0.10/sxl_index.json +3 -3
  89. data/schemas/tlc/1.0.13/commands/M0001.json +3 -3
  90. data/schemas/tlc/1.0.13/source/sxl.yaml +3 -3
  91. data/schemas/tlc/1.0.13/sxl_index.json +3 -3
  92. data/schemas/tlc/1.0.14/commands/M0001.json +3 -3
  93. data/schemas/tlc/1.0.14/source/sxl.yaml +3 -3
  94. data/schemas/tlc/1.0.14/sxl_index.json +3 -3
  95. data/schemas/tlc/1.0.15/commands/M0001.json +3 -3
  96. data/schemas/tlc/1.0.15/source/sxl.yaml +3 -3
  97. data/schemas/tlc/1.0.15/sxl_index.json +3 -3
  98. data/schemas/tlc/1.0.7/commands/M0001.json +3 -3
  99. data/schemas/tlc/1.0.7/source/sxl.yaml +3 -3
  100. data/schemas/tlc/1.0.7/sxl_index.json +3 -3
  101. data/schemas/tlc/1.0.8/commands/M0001.json +3 -3
  102. data/schemas/tlc/1.0.8/source/sxl.yaml +3 -3
  103. data/schemas/tlc/1.0.8/sxl_index.json +3 -3
  104. data/schemas/tlc/1.0.9/commands/M0001.json +3 -3
  105. data/schemas/tlc/1.0.9/source/sxl.yaml +3 -3
  106. data/schemas/tlc/1.0.9/sxl_index.json +3 -3
  107. metadata +25 -4
  108. data/lib/rsmp/collect/collector/status.rb +0 -34
@@ -0,0 +1,36 @@
1
+ module RSMP
2
+ # Reactor-confined, ordered delivery of background lifecycle events.
3
+ module EventSource
4
+ attr_reader :event_receivers
5
+
6
+ def initialize_event_source
7
+ @event_receivers = []
8
+ end
9
+
10
+ def add_event_receiver(receiver)
11
+ raise ArgumentError, 'event receiver must respond to receive_event' unless receiver.respond_to?(:receive_event)
12
+
13
+ @event_receivers << receiver unless @event_receivers.include?(receiver)
14
+ receiver
15
+ end
16
+
17
+ def remove_event_receiver(receiver)
18
+ @event_receivers.delete(receiver)
19
+ receiver
20
+ end
21
+
22
+ def publish_event(event)
23
+ raise ArgumentError, 'event must be an RSMP::Event' unless event.is_a?(Event)
24
+
25
+ @event_receivers.dup.each do |receiver|
26
+ receiver.receive_event(event)
27
+ rescue StandardError => e
28
+ remove_event_receiver(receiver)
29
+ raise unless receiver.respond_to?(:crash)
30
+
31
+ receiver.crash(e)
32
+ end
33
+ event
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,10 @@
1
+ require 'forwardable'
2
+
3
+ module RSMP
4
+ # A sent request and the immutable collection produced in response.
5
+ Exchange = Data.define(:request, :collection) do
6
+ extend Forwardable
7
+
8
+ def_delegators :collection, :messages, :reached, :matcher_status
9
+ end
10
+ end
@@ -0,0 +1,23 @@
1
+ module RSMP
2
+ # An expected operational failure. Failures are values: they describe why an
3
+ # operation could not complete without changing Async task control flow.
4
+ Failure = Data.define(:code, :message, :source, :context, :cause) do
5
+ def initialize(code:, message:, source:, context: {}, cause: nil)
6
+ raise ArgumentError, 'failure code must be a Symbol' unless code.is_a?(Symbol)
7
+ raise ArgumentError, 'failure source must be a Symbol' unless source.is_a?(Symbol)
8
+ raise ArgumentError, 'failure context must be a Hash' unless context.is_a?(Hash)
9
+
10
+ super(
11
+ code: code,
12
+ message: message.to_s.freeze,
13
+ source: source,
14
+ context: context.dup.freeze,
15
+ cause: cause
16
+ )
17
+ end
18
+
19
+ def to_s
20
+ message.empty? ? code.to_s : message
21
+ end
22
+ end
23
+ end
@@ -8,24 +8,24 @@ module RSMP
8
8
  class MalformedMessage < Error
9
9
  end
10
10
 
11
+ # Semantic failures which may be raised while processing a schema-valid peer
12
+ # message. The receive boundary converts only this explicit family to a peer
13
+ # Failure; unrelated exceptions retain their original stack trace.
14
+ class PeerMessageError < Error
15
+ end
16
+
11
17
  # Raised when schema validation fails.
12
18
  class SchemaError < Error
13
19
  attr_accessor :schemas
14
20
  end
15
21
 
16
- class InvalidMessage < Error
22
+ class InvalidMessage < PeerMessageError
17
23
  end
18
24
 
19
25
  class UnknownMessage < Error
20
26
  end
21
27
 
22
- class MissingAcknowledgment < Error
23
- end
24
-
25
- class MissingWatchdog < Error
26
- end
27
-
28
- class MessageRejected < Error
28
+ class MessageRejected < PeerMessageError
29
29
  end
30
30
 
31
31
  class MissingAttribute < InvalidMessage
@@ -37,36 +37,27 @@ module RSMP
37
37
  class HandshakeError < FatalError
38
38
  end
39
39
 
40
- class NotReady < Error
41
- end
42
-
43
- class TimeoutError < Error
44
- end
45
-
46
- class DisconnectError < Error
47
- end
48
-
49
40
  class ConnectionError < Error
50
41
  end
51
42
 
52
- class UnknownComponent < Error
43
+ class UnknownComponent < PeerMessageError
53
44
  end
54
45
 
55
- class UnknownCommand < Error
46
+ class UnknownCommand < PeerMessageError
56
47
  end
57
48
 
58
- class UnknownStatus < Error
49
+ class UnknownStatus < PeerMessageError
59
50
  end
60
51
 
61
52
  class ConfigurationError < Error
62
53
  end
63
54
 
64
- class RepeatedAlarmError < Error
55
+ class RepeatedAlarmError < PeerMessageError
65
56
  end
66
57
 
67
- class RepeatedStatusError < Error
58
+ class RepeatedStatusError < PeerMessageError
68
59
  end
69
60
 
70
- class TimestampError < Error
61
+ class TimestampError < PeerMessageError
71
62
  end
72
63
  end
data/lib/rsmp/message.rb CHANGED
@@ -164,11 +164,14 @@ module RSMP
164
164
  end
165
165
 
166
166
  def validate(schemas)
167
- errors = RSMP::Schema.validate attributes, schemas
168
- return unless errors
167
+ RSMP::Schema.validate attributes, schemas
168
+ end
169
+
170
+ def validate!(schemas)
171
+ validation = validate(schemas)
172
+ return self if validation.valid?
169
173
 
170
- error_string = errors.map { |item| item.reject { |e| e == '' } }.compact.join(', ').strip
171
- err = SchemaError.new error_string.to_s
174
+ err = SchemaError.new validation.message
172
175
  err.schemas = schemas
173
176
  raise err
174
177
  end
@@ -199,13 +202,12 @@ module RSMP
199
202
 
200
203
  # Represents a malformed message with invalid attributes.
201
204
  class Malformed < Message
202
- # rubocop:disable Lint/MissingSuper
205
+ # rubocop:disable-next Lint/MissingSuper
203
206
  def initialize(attributes = {})
204
207
  # don't call super, just copy (potentially invalid) attributes
205
208
  @attributes = {}
206
209
  @invalid_attributes = attributes
207
210
  end
208
- # rubocop:enable Lint/MissingSuper
209
211
  end
210
212
 
211
213
  # Version message, lists supported versions and SXL information.
@@ -391,7 +393,7 @@ module RSMP
391
393
  end
392
394
 
393
395
  def original=(message)
394
- raise InvalidArgument unless message
396
+ raise ArgumentError, 'original message is required' unless message
395
397
 
396
398
  @original = message
397
399
  end
@@ -3,16 +3,16 @@ module RSMP
3
3
  class Node
4
4
  include Logging
5
5
  include Task
6
+ include EventSource
6
7
 
7
- attr_reader :archive, :logger, :task, :deferred, :error_queue, :clock, :collector
8
+ attr_reader :archive, :logger, :task, :deferred, :clock, :collector
8
9
 
9
10
  def initialize(options = {})
10
11
  initialize_logging options
11
12
  initialize_task
13
+ initialize_event_source
12
14
  @deferred = []
13
15
  @clock = Clock.new
14
- @error_queue = Async::Queue.new
15
- @ignore_errors = []
16
16
  @collect = options[:collect]
17
17
  end
18
18
 
@@ -31,24 +31,6 @@ module RSMP
31
31
  super
32
32
  end
33
33
 
34
- def ignore_errors(classes)
35
- was = @ignore_errors
36
- @ignore_errors = [classes].flatten
37
- yield
38
- ensure
39
- @ignore_errors = was
40
- end
41
-
42
- def distribute_error(error, options = {})
43
- return if @ignore_errors.find { |klass| error.is_a? klass }
44
-
45
- if options[:level] == :internal
46
- log ["#{error} in task: #{Async::Task.current}", error.backtrace].flatten.join("\n"),
47
- level: :error
48
- end
49
- @error_queue.enqueue error
50
- end
51
-
52
34
  def defer(key, item = nil)
53
35
  @deferred << [key, item]
54
36
  end
@@ -9,30 +9,25 @@ module RSMP
9
9
  end
10
10
  end
11
11
 
12
- def listen_for_supervisors
12
+ def listen_for_supervisors(parent:)
13
13
  ip = @site_settings['ip'] || '0.0.0.0'
14
14
  port = @site_settings['port']
15
15
  log "Starting #{site_type_name} listener on #{ip}:#{port}", level: :info, timestamp: @clock.now
16
16
  @endpoint = IO::Endpoint.tcp(ip, port)
17
- @accept_task = Async::Task.current.async do |task|
17
+ @accept_task = parent.async do |task|
18
18
  task.annotate 'site accept loop'
19
19
  accept_supervisor_connections
20
20
  end
21
21
 
22
22
  @ready_condition.signal
23
- @accept_task.wait
23
+ @accept_task
24
24
  end
25
25
 
26
26
  def accept_supervisor_connections
27
27
  @endpoint.accept do |socket|
28
28
  accept_supervisor_connection socket
29
- rescue StandardError => e
30
- distribute_error e, level: :internal
31
29
  end
32
- rescue Async::Stop
33
- # Expected during shutdown - no action needed
34
- rescue StandardError => e
35
- distribute_error e, level: :internal
30
+ Async::Task.current.wait_all
36
31
  end
37
32
 
38
33
  def accept_supervisor_connection(socket)
@@ -106,13 +106,24 @@ module RSMP
106
106
 
107
107
  def run
108
108
  log_site_starting
109
- start_status_timer
109
+ barrier = Async::Barrier.new(parent: @task)
110
+ termination_task = barrier.async { wait_for_termination }
111
+ start_auxiliary_tasks(barrier)
110
112
  if server_role?
111
- listen_for_supervisors
113
+ listen_for_supervisors(parent: barrier)
112
114
  else
113
- @proxies.each(&:start)
114
- @proxies.each(&:wait)
115
+ @proxies.each { |proxy| proxy.start(parent: barrier) }
115
116
  end
117
+ barrier.wait do |finished|
118
+ result = finished.wait
119
+ break result if finished.equal?(termination_task)
120
+ end
121
+ ensure
122
+ barrier.cancel if barrier && !barrier.empty?
123
+ end
124
+
125
+ def start_auxiliary_tasks(parent)
126
+ start_status_timer(parent: parent)
116
127
  end
117
128
 
118
129
  def aggregated_status_changed(component, _options = {})
@@ -135,16 +146,16 @@ module RSMP
135
146
 
136
147
  def send_alarm(alarm)
137
148
  @proxies.each do |proxy|
138
- proxy.send_message alarm if proxy.receive_alarms?
149
+ proxy.send_generated_message alarm if proxy.receive_alarms?
139
150
  end
140
151
  end
141
152
 
142
- def start_status_timer
153
+ def start_status_timer(parent:)
143
154
  return if @status_timer
144
155
 
145
156
  interval = @site_settings['intervals']['timer'] || 1
146
157
  log "Starting site status timer with interval #{interval} seconds", level: :debug
147
- @status_timer = @task.async do |task|
158
+ @status_timer = parent.async do |task|
148
159
  task.annotate 'site status timer'
149
160
  run_status_timer task, interval
150
161
  end
@@ -155,11 +166,8 @@ module RSMP
155
166
  loop do
156
167
  now = Clock.now
157
168
  tick_status_subscriptions now
158
- rescue StandardError => e
159
- distribute_error e, level: :internal
160
- ensure
161
169
  next_time += interval
162
- duration = next_time - Time.now.to_f
170
+ duration = [next_time - Time.now.to_f, 0].max
163
171
  task.sleep duration
164
172
  end
165
173
  end
@@ -169,14 +177,14 @@ module RSMP
169
177
  end
170
178
 
171
179
  def stop_status_timer
172
- @status_timer&.stop
180
+ @status_timer&.cancel if @status_timer&.running?
173
181
  ensure
174
182
  @status_timer = nil
175
183
  end
176
184
 
177
185
  def stop_subtasks
178
186
  stop_status_timer
179
- @accept_task&.stop
187
+ @accept_task&.cancel if @accept_task&.running?
180
188
  @accept_task = nil
181
189
  @endpoint = nil
182
190
  super
@@ -190,11 +198,18 @@ module RSMP
190
198
 
191
199
  def wait_for_supervisor(ip, timeout:)
192
200
  supervisor = find_supervisor ip
193
- return supervisor if supervisor
201
+ return Result.success(supervisor) if supervisor
194
202
 
195
203
  wait_for_condition(@proxies_condition, timeout: timeout) { find_supervisor ip }
196
- rescue Async::TimeoutError
197
- raise RSMP::TimeoutError, "Supervisor '#{ip}' did not connect within #{timeout}s"
204
+ .map { |proxy| proxy }
205
+ end
206
+
207
+ def wait_for_supervisor!(ip, timeout:)
208
+ result = wait_for_supervisor(ip, timeout: timeout)
209
+ return result.value! if result.success?
210
+
211
+ failure = result.failure.with(message: "Supervisor '#{ip}' did not connect within #{timeout}s")
212
+ Result.failure(failure: failure).value!
198
213
  end
199
214
 
200
215
  def find_supervisor(ip)
@@ -15,15 +15,29 @@ module RSMP
15
15
  reject_connection socket, info
16
16
  end
17
17
  rescue ConnectionError, HandshakeError => e
18
- log "Rejected connection from #{remote_ip}:#{remote_port}, #{e}", level: :warning
19
- distribute_error e
20
- rescue StandardError => e
21
- log "Connection: #{e}", exception: e, level: :error
22
- distribute_error e, level: :internal
18
+ report_connection_rejection(e, remote_ip, remote_port)
23
19
  ensure
24
20
  close socket, info
25
21
  end
26
22
 
23
+ def report_connection_rejection(error, remote_ip, remote_port)
24
+ log "Rejected connection from #{remote_ip}:#{remote_port}, #{error}", level: :warning
25
+ failure = Failure.new(
26
+ code: error.is_a?(HandshakeError) ? :handshake_failed : :connection_rejected,
27
+ message: error.message,
28
+ source: :peer,
29
+ context: { ip: remote_ip, port: remote_port }
30
+ )
31
+ publish_event(
32
+ Event.new(
33
+ type: :connection_rejected,
34
+ source: self,
35
+ failure: failure,
36
+ context: { ip: remote_ip, port: remote_port }
37
+ )
38
+ )
39
+ end
40
+
27
41
  def accept?(_socket, _info)
28
42
  true
29
43
  end
@@ -23,24 +23,35 @@ module RSMP
23
23
 
24
24
  def wait_for_site(site_id, timeout:)
25
25
  site = find_site site_id
26
- return site if site
26
+ return Result.success(site) if site
27
27
 
28
28
  wait_for_condition(@site_id_condition, timeout: timeout) do
29
29
  find_site site_id
30
30
  end
31
- rescue Async::TimeoutError
31
+ end
32
+
33
+ def wait_for_site!(site_id, timeout:)
34
+ result = wait_for_site(site_id, timeout: timeout)
35
+ return result.value! if result.success?
36
+
32
37
  str = if site_id == :any
33
38
  'No site connected'
34
39
  else
35
40
  "Site '#{site_id}' did not connect"
36
41
  end
37
- raise RSMP::TimeoutError, "#{str} within #{timeout}s"
42
+ Result.failure(failure: result.failure.with(message: "#{str} within #{timeout}s")).value!
38
43
  end
39
44
 
40
45
  def wait_for_site_disconnect(site_id, timeout:)
41
46
  wait_for_condition(@site_id_condition, timeout: timeout) { true unless find_site site_id }
42
- rescue Async::TimeoutError
43
- raise RSMP::TimeoutError, "Site '#{site_id}' did not disconnect within #{timeout}s"
47
+ end
48
+
49
+ def wait_for_site_disconnect!(site_id, timeout:)
50
+ result = wait_for_site_disconnect(site_id, timeout: timeout)
51
+ return result.value! if result.success?
52
+
53
+ failure = result.failure.with(message: "Site '#{site_id}' did not disconnect within #{timeout}s")
54
+ Result.failure(failure: failure).value!
44
55
  end
45
56
 
46
57
  def check_site_id(site_id)
@@ -38,23 +38,19 @@ module RSMP
38
38
  timestamp: @clock.now
39
39
 
40
40
  @endpoint = IO::Endpoint.tcp('0.0.0.0', @supervisor_settings['port'])
41
- @accept_task = Async::Task.current.async do |task|
41
+ barrier = Async::Barrier.new(parent: @task)
42
+ @accept_task = barrier.async do |task|
42
43
  task.annotate 'supervisor accept loop'
43
- @endpoint.accept do |socket| # creates fibers
44
+ @endpoint.accept do |socket|
44
45
  handle_connection(socket)
45
- rescue StandardError => e
46
- distribute_error e, level: :internal
47
46
  end
48
- rescue Async::Stop
49
- # Expected during shutdown - no action needed
50
- rescue StandardError => e
51
- distribute_error e, level: :internal
47
+ task.wait_all
52
48
  end
53
49
 
54
50
  @ready_condition.signal
55
- @accept_task.wait
56
- rescue StandardError => e
57
- distribute_error e, level: :internal
51
+ barrier.wait
52
+ ensure
53
+ barrier.cancel if barrier && !barrier.empty?
58
54
  end
59
55
 
60
56
  def connect_to_sites
@@ -63,8 +59,11 @@ module RSMP
63
59
  timestamp: @clock.now
64
60
  build_outbound_proxies
65
61
  @ready_condition.signal
66
- @proxies.each(&:start)
67
- @proxies.each(&:wait)
62
+ barrier = Async::Barrier.new(parent: @task)
63
+ @proxies.each { |proxy| proxy.start(parent: barrier) }
64
+ barrier.wait
65
+ ensure
66
+ barrier.cancel if barrier && !barrier.empty?
68
67
  end
69
68
 
70
69
  def build_outbound_proxies
@@ -93,7 +92,7 @@ module RSMP
93
92
  def stop
94
93
  log "Stopping supervisor #{@supervisor_settings['site_id']}", level: :info
95
94
 
96
- @accept_task&.stop
95
+ @accept_task&.cancel if @accept_task&.running?
97
96
  @accept_task = nil
98
97
 
99
98
  @endpoint = nil
@@ -1,95 +1,101 @@
1
1
  module RSMP
2
- class Restart < StandardError
3
- end
4
-
5
- # Task helpers for starting and managing an Async task lifecycle.
2
+ # Explicit ownership for a long-running Async task tree.
6
3
  module Task
4
+ class ConditionWaitTimeout < Async::TimeoutError; end
5
+
7
6
  attr_reader :task
8
7
 
9
8
  def initialize_task
10
9
  @task = nil
10
+ @termination_completion = Completion.new
11
11
  end
12
12
 
13
- # start our async tasks and return immediately
14
- # run() will be called inside the task to perform actual long-running work
15
- def start
16
- return if @task
17
-
18
- # Use current task context if available, otherwise create new reactor
19
- if Async::Task.current?
20
- Async::Task.current.async do |task|
21
- task.annotate "#{self.class.name} main task"
22
- @task = task
23
- run
24
- stop_subtasks
25
- @task = nil
26
- end
27
- else
28
- Async do |task|
29
- task.annotate "#{self.class.name} main task"
30
- @task = task
31
- run
32
- stop_subtasks
33
- @task = nil
34
- end
13
+ # Start under an explicit Async parent. A current task is accepted for
14
+ # convenience, but this library never creates a hidden root reactor.
15
+ def start(parent: Async::Task.current)
16
+ return @task if @task&.running?
17
+
18
+ valid_parent = parent.respond_to?(:async) && (!parent.respond_to?(:running?) || parent.running?)
19
+ raise ArgumentError, 'an active Async parent task is required' unless valid_parent
20
+
21
+ child = parent.async do |task|
22
+ task.annotate "#{self.class.name} main task"
23
+ @task = task
24
+ run
25
+ ensure
26
+ stop_subtasks
35
27
  end
36
- self
28
+ @task = child
37
29
  end
38
30
 
39
- # initiate restart by raising a Restart exception
40
31
  def restart
41
- raise Restart, "restart initiated by #{self.class.name}:#{object_id}"
32
+ termination_completion.succeed(Termination.new(reason: :restart, source: self))
33
+ end
34
+
35
+ def wait_for_termination
36
+ termination_completion.wait
42
37
  end
43
38
 
44
- # get the status of our task, or nil of no task
45
39
  def task_status
46
40
  @task&.status
47
41
  end
48
42
 
49
- # perform any long-running work
50
- # the method will be called from an async task, and should not return
51
- # if subtasks are needed, the method should call wait() on each of them
52
- # once running, ready() must be called
53
43
  def run
54
44
  start_subtasks
55
45
  end
56
46
 
57
- # wait for our task to complete
47
+ # Async::Task#wait raises unexpected child failures and returns nil for
48
+ # cancellation, matching Async's native task contract.
58
49
  def wait
59
50
  @task&.wait
60
51
  end
61
52
 
62
- # stop our task
63
53
  def stop
64
- stop_subtasks
65
- stop_task if @task
54
+ stop_task
66
55
  end
67
56
 
68
57
  def stop_subtasks; end
69
58
 
70
- # stop our task and any subtask
71
59
  def stop_task
72
- @task.stop
73
- @task = nil
60
+ task = @task
61
+ return unless task&.running?
62
+
63
+ task.cancel
64
+ task.wait unless task.current?
74
65
  end
75
66
 
76
- # wait for an async condition to signal, then yield to block
77
- # if block returns true we're done. otherwise, wait again
67
+ # Wait for an edge-triggered condition and return an expected timeout as a
68
+ # Result. Async cancellation is not rescued and therefore propagates.
78
69
  def wait_for_condition(condition, timeout:, task: Async::Task.current, &block)
79
- raise "Can't wait without a task" unless task
70
+ raise ArgumentError, 'an active Async task is required' unless task&.running?
80
71
 
81
- task.with_timeout(timeout) do
82
- while task.running?
83
- value = condition.wait
84
- return value unless block
72
+ value = task.with_timeout(timeout, ConditionWaitTimeout) do
73
+ loop do
74
+ signalled = condition.wait
75
+ break signalled unless block
85
76
 
86
- result = yield value
87
- return result if result
77
+ matched = yield(signalled)
78
+ break matched if matched
88
79
  end
89
- raise "Can't wait for condition because task #{task.object_id} #{task.annotation} is not running"
90
80
  end
91
- rescue Async::TimeoutError
92
- raise RSMP::TimeoutError
81
+ Result.success(value)
82
+ rescue ConditionWaitTimeout => e
83
+ Result.failure(
84
+ :timeout,
85
+ message: "Condition was not met within #{timeout}s",
86
+ source: :timeout,
87
+ cause: e
88
+ )
89
+ end
90
+
91
+ def wait_for_condition!(...)
92
+ wait_for_condition(...).value!
93
+ end
94
+
95
+ private
96
+
97
+ def termination_completion
98
+ @termination_completion ||= Completion.new
93
99
  end
94
100
  end
95
101
  end