async-container 0.18.3 → 0.20.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d061fd5261bb0fabc616df89690cddd1c7545d5fc41799bc107050da08bf7635
4
- data.tar.gz: 13a9e58f76b560a9f5602da7977739c936aa71732338f802a97d9fc9a6224480
3
+ metadata.gz: a95321eeaead4fafe8c8db6bfa186d13559c8b39185c566db474ff2111e3add8
4
+ data.tar.gz: 873a71e53157cf3f5c94908daff4848f88b0bb06c35e10fd89c569fe6a646386
5
5
  SHA512:
6
- metadata.gz: 1f162eadf2c624ebe86fe174578096ccb473e5f9f031f75d8135ed77cf9911f006e4b3a9957b8402829a67fa480d80c7fdf3a3abdbb02e959ae1c135e99c3c92
7
- data.tar.gz: 8201e656ac4f8182380bb08af4290c51bddc62d97214fd39bf0e7761f48f339a859f809dd12539c2a53a85219e4b4b6a68bcb9a81c7ef7c9dd4bab3da563d053
6
+ metadata.gz: e974514dc85a284427791fffa2299c1541f4330b60f4d1470a64c9d2591d9c9c5a59250d9560e4b61ca43024869681f5f2cdc89ab086b178a391105318691b9d
7
+ data.tar.gz: 712370e1ea844ede123bb6215422c393109e50133db7991242b748f013e75904494af9300fdcbcd5de490c65c473fb195eac46eabbb5d5f34e1a774235cf5e8e
checksums.yaml.gz.sig CHANGED
Binary file
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2022, by Samuel Williams.
4
+ # Copyright, 2019-2024, by Samuel Williams.
5
5
 
6
- require_relative 'forked'
7
- require_relative 'threaded'
8
- require_relative 'hybrid'
6
+ require_relative "forked"
7
+ require_relative "threaded"
8
+ require_relative "hybrid"
9
9
 
10
10
  module Async
11
11
  module Container
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2020-2022, by Samuel Williams.
4
+ # Copyright, 2020-2024, by Samuel Williams.
5
5
 
6
- require 'json'
6
+ require "json"
7
7
 
8
8
  module Async
9
9
  module Container
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2024, by Samuel Williams.
4
+ # Copyright, 2018-2025, by Samuel Williams.
5
5
 
6
- require_relative 'error'
7
- require_relative 'best'
6
+ require_relative "error"
7
+ require_relative "best"
8
8
 
9
- require_relative 'statistics'
10
- require_relative 'notify'
9
+ require_relative "statistics"
10
+ require_relative "notify"
11
11
 
12
12
  module Async
13
13
  module Container
@@ -26,13 +26,10 @@ module Async
26
26
  @container = nil
27
27
  @container_class = container_class
28
28
 
29
- if @notify = notify
30
- @notify.status!("Initializing...")
31
- end
32
-
29
+ @notify = notify
33
30
  @signals = {}
34
31
 
35
- trap(SIGHUP) do
32
+ self.trap(SIGHUP) do
36
33
  self.restart
37
34
  end
38
35
 
@@ -93,7 +90,12 @@ module Async
93
90
 
94
91
  # Start the container unless it's already running.
95
92
  def start
96
- self.restart unless @container
93
+ unless @container
94
+ Console.info(self) {"Controller starting..."}
95
+ self.restart
96
+ end
97
+
98
+ Console.info(self) {"Controller started..."}
97
99
  end
98
100
 
99
101
  # Stop the container if it's running.
@@ -109,9 +111,9 @@ module Async
109
111
  if @container
110
112
  @notify&.restarting!
111
113
 
112
- Console.logger.debug(self) {"Restarting container..."}
114
+ Console.debug(self) {"Restarting container..."}
113
115
  else
114
- Console.logger.debug(self) {"Starting container..."}
116
+ Console.debug(self) {"Starting container..."}
115
117
  end
116
118
 
117
119
  container = self.create_container
@@ -125,9 +127,9 @@ module Async
125
127
  end
126
128
 
127
129
  # Wait for all child processes to enter the ready state.
128
- Console.logger.debug(self, "Waiting for startup...")
130
+ Console.debug(self, "Waiting for startup...")
129
131
  container.wait_until_ready
130
- Console.logger.debug(self, "Finished startup.")
132
+ Console.debug(self, "Finished startup.")
131
133
 
132
134
  if container.failed?
133
135
  @notify&.error!("Container failed to start!")
@@ -143,7 +145,7 @@ module Async
143
145
  container = nil
144
146
 
145
147
  if old_container
146
- Console.logger.debug(self, "Stopping old container...")
148
+ Console.debug(self, "Stopping old container...")
147
149
  old_container&.stop(@graceful_stop)
148
150
  end
149
151
 
@@ -157,7 +159,7 @@ module Async
157
159
  def reload
158
160
  @notify&.reloading!
159
161
 
160
- Console.logger.info(self) {"Reloading container: #{@container}..."}
162
+ Console.info(self) {"Reloading container: #{@container}..."}
161
163
 
162
164
  begin
163
165
  self.setup(@container)
@@ -166,11 +168,11 @@ module Async
166
168
  end
167
169
 
168
170
  # Wait for all child processes to enter the ready state.
169
- Console.logger.debug(self, "Waiting for startup...")
171
+ Console.debug(self, "Waiting for startup...")
170
172
 
171
173
  @container.wait_until_ready
172
174
 
173
- Console.logger.debug(self, "Finished startup.")
175
+ Console.debug(self, "Finished startup.")
174
176
 
175
177
  if @container.failed?
176
178
  @notify.error!("Container failed to reload!")
@@ -183,10 +185,41 @@ module Async
183
185
 
184
186
  # Enter the controller run loop, trapping `SIGINT` and `SIGTERM`.
185
187
  def run
188
+ @notify&.status!("Initializing...")
189
+
190
+ with_signal_handlers do
191
+ self.start
192
+
193
+ while @container&.running?
194
+ begin
195
+ @container.wait
196
+ rescue SignalException => exception
197
+ if handler = @signals[exception.signo]
198
+ begin
199
+ handler.call
200
+ rescue SetupError => error
201
+ Console.error(self, error)
202
+ end
203
+ else
204
+ raise
205
+ end
206
+ end
207
+ end
208
+ end
209
+ rescue Interrupt
210
+ self.stop
211
+ rescue Terminate
212
+ self.stop(false)
213
+ ensure
214
+ self.stop(false)
215
+ end
216
+
217
+ private def with_signal_handlers
186
218
  # I thought this was the default... but it doesn't always raise an exception unless you do this explicitly.
187
- # We use `Thread.current.raise(...)` so that exceptions are filtered through `Thread.handle_interrupt` correctly.
219
+
188
220
  interrupt_action = Signal.trap(:INT) do
189
- # $stderr.puts "Received INT signal, terminating...", caller
221
+ # We use `Thread.current.raise(...)` so that exceptions are filtered through `Thread.handle_interrupt` correctly.
222
+ # $stderr.puts "Received INT signal, interrupting...", caller
190
223
  ::Thread.current.raise(Interrupt)
191
224
  end
192
225
 
@@ -197,33 +230,13 @@ module Async
197
230
 
198
231
  hangup_action = Signal.trap(:HUP) do
199
232
  # $stderr.puts "Received HUP signal, restarting...", caller
200
- ::Thread.current.raise(Hangup)
233
+ ::Thread.current.raise(Restart)
201
234
  end
202
235
 
203
- self.start
204
-
205
- while @container&.running?
206
- begin
207
- @container.wait
208
- rescue SignalException => exception
209
- if handler = @signals[exception.signo]
210
- begin
211
- handler.call
212
- rescue SetupError => error
213
- Console.logger.error(self) {error}
214
- end
215
- else
216
- raise
217
- end
218
- end
236
+ ::Thread.handle_interrupt(SignalException => :never) do
237
+ yield
219
238
  end
220
- rescue Interrupt
221
- self.stop
222
- rescue Terminate
223
- self.stop(false)
224
239
  ensure
225
- self.stop(false)
226
-
227
240
  # Restore the interrupt handler:
228
241
  Signal.trap(:INT, interrupt_action)
229
242
  Signal.trap(:TERM, terminate_action)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2022, by Samuel Williams.
4
+ # Copyright, 2019-2025, by Samuel Williams.
5
5
 
6
6
  module Async
7
7
  module Container
@@ -12,15 +12,15 @@ module Async
12
12
 
13
13
  # Similar to {Interrupt}, but represents `SIGTERM`.
14
14
  class Terminate < SignalException
15
- SIGTERM = Signal.list['TERM']
15
+ SIGTERM = Signal.list["TERM"]
16
16
 
17
17
  def initialize
18
18
  super(SIGTERM)
19
19
  end
20
20
  end
21
21
 
22
- class Hangup < SignalException
23
- SIGHUP = Signal.list['HUP']
22
+ class Restart < SignalException
23
+ SIGHUP = Signal.list["HUP"]
24
24
 
25
25
  def initialize
26
26
  super(SIGHUP)
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2017-2022, by Samuel Williams.
4
+ # Copyright, 2017-2024, by Samuel Williams.
5
5
 
6
- require_relative 'generic'
7
- require_relative 'process'
6
+ require_relative "error"
7
+
8
+ require_relative "generic"
9
+ require_relative "channel"
10
+ require_relative "notify/pipe"
8
11
 
9
12
  module Async
10
13
  module Container
@@ -15,11 +18,208 @@ module Async
15
18
  true
16
19
  end
17
20
 
21
+ # Represents a running child process from the point of view of the parent container.
22
+ class Child < Channel
23
+ # Represents a running child process from the point of view of the child process.
24
+ class Instance < Notify::Pipe
25
+ # Wrap an instance around the {Process} instance from within the forked child.
26
+ # @parameter process [Process] The process intance to wrap.
27
+ def self.for(process)
28
+ instance = self.new(process.out)
29
+
30
+ # The child process won't be reading from the channel:
31
+ process.close_read
32
+
33
+ instance.name = process.name
34
+
35
+ return instance
36
+ end
37
+
38
+ def initialize(io)
39
+ super
40
+
41
+ @name = nil
42
+ end
43
+
44
+ # Set the process title to the specified value.
45
+ # @parameter value [String] The name of the process.
46
+ def name= value
47
+ if @name = value
48
+ ::Process.setproctitle(@name)
49
+ end
50
+ end
51
+
52
+ # The name of the process.
53
+ # @returns [String]
54
+ def name
55
+ @name
56
+ end
57
+
58
+ # Replace the current child process with a different one. Forwards arguments and options to {::Process.exec}.
59
+ # This method replaces the child process with the new executable, thus this method never returns.
60
+ def exec(*arguments, ready: true, **options)
61
+ if ready
62
+ self.ready!(status: "(exec)")
63
+ else
64
+ self.before_spawn(arguments, options)
65
+ end
66
+
67
+ ::Process.exec(*arguments, **options)
68
+ end
69
+ end
70
+
71
+ # Fork a child process appropriate for a container.
72
+ # @returns [Process]
73
+ def self.fork(**options)
74
+ # $stderr.puts fork: caller
75
+ self.new(**options) do |process|
76
+ ::Process.fork do
77
+ # We use `Thread.current.raise(...)` so that exceptions are filtered through `Thread.handle_interrupt` correctly.
78
+ Signal.trap(:INT) {::Thread.current.raise(Interrupt)}
79
+ Signal.trap(:TERM) {::Thread.current.raise(Terminate)}
80
+ Signal.trap(:HUP) {::Thread.current.raise(Restart)}
81
+
82
+ # This could be a configuration option:
83
+ ::Thread.handle_interrupt(SignalException => :immediate) do
84
+ yield Instance.for(process)
85
+ rescue Interrupt
86
+ # Graceful exit.
87
+ rescue Exception => error
88
+ Console.error(self, error)
89
+
90
+ exit!(1)
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ def self.spawn(*arguments, name: nil, **options)
97
+ self.new(name: name) do |process|
98
+ Notify::Pipe.new(process.out).before_spawn(arguments, options)
99
+
100
+ ::Process.spawn(*arguments, **options)
101
+ end
102
+ end
103
+
104
+ # Initialize the process.
105
+ # @parameter name [String] The name to use for the child process.
106
+ def initialize(name: nil)
107
+ super()
108
+
109
+ @name = name
110
+ @status = nil
111
+ @pid = nil
112
+
113
+ @pid = yield(self)
114
+
115
+ # The parent process won't be writing to the channel:
116
+ self.close_write
117
+ end
118
+
119
+ # Convert the child process to a hash, suitable for serialization.
120
+ #
121
+ # @returns [Hash] The request as a hash.
122
+ def as_json(...)
123
+ {
124
+ name: @name,
125
+ pid: @pid,
126
+ status: @status&.to_i,
127
+ }
128
+ end
129
+
130
+ # Convert the request to JSON.
131
+ #
132
+ # @returns [String] The request as JSON.
133
+ def to_json(...)
134
+ as_json.to_json(...)
135
+ end
136
+
137
+ # Set the name of the process.
138
+ # Invokes {::Process.setproctitle} if invoked in the child process.
139
+ def name= value
140
+ @name = value
141
+
142
+ # If we are the child process:
143
+ ::Process.setproctitle(@name) if @pid.nil?
144
+ end
145
+
146
+ # The name of the process.
147
+ # @attribute [String]
148
+ attr :name
149
+
150
+ # @attribute [Integer] The process identifier.
151
+ attr :pid
152
+
153
+ # A human readable representation of the process.
154
+ # @returns [String]
155
+ def inspect
156
+ "\#<#{self.class} name=#{@name.inspect} status=#{@status.inspect} pid=#{@pid.inspect}>"
157
+ end
158
+
159
+ alias to_s inspect
160
+
161
+ # Invoke {#terminate!} and then {#wait} for the child process to exit.
162
+ def close
163
+ self.terminate!
164
+ self.wait
165
+ ensure
166
+ super
167
+ end
168
+
169
+ # Send `SIGINT` to the child process.
170
+ def interrupt!
171
+ unless @status
172
+ ::Process.kill(:INT, @pid)
173
+ end
174
+ end
175
+
176
+ # Send `SIGTERM` to the child process.
177
+ def terminate!
178
+ unless @status
179
+ ::Process.kill(:TERM, @pid)
180
+ end
181
+ end
182
+
183
+ # Send `SIGHUP` to the child process.
184
+ def restart!
185
+ unless @status
186
+ ::Process.kill(:HUP, @pid)
187
+ end
188
+ end
189
+
190
+ # Wait for the child process to exit.
191
+ # @asynchronous This method may block.
192
+ #
193
+ # @returns [::Process::Status] The process exit status.
194
+ def wait
195
+ if @pid && @status.nil?
196
+ Console.debug(self, "Waiting for process to exit...", pid: @pid)
197
+
198
+ _, @status = ::Process.wait2(@pid, ::Process::WNOHANG)
199
+
200
+ while @status.nil?
201
+ sleep(0.1)
202
+
203
+ _, @status = ::Process.wait2(@pid, ::Process::WNOHANG)
204
+
205
+ if @status.nil?
206
+ Console.warn(self) {"Process #{@pid} is blocking, has it exited?"}
207
+ end
208
+ end
209
+ end
210
+
211
+ Console.debug(self, "Process exited.", pid: @pid, status: @status)
212
+
213
+ return @status
214
+ end
215
+ end
216
+
217
+
18
218
  # Start a named child process and execute the provided block in it.
19
219
  # @parameter name [String] The name (title) of the child process.
20
220
  # @parameter block [Proc] The block to execute in the child process.
21
221
  def start(name, &block)
22
- Process.fork(name: name, &block)
222
+ Child.fork(name: name, &block)
23
223
  end
24
224
  end
25
225
  end
@@ -3,18 +3,17 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2019-2024, by Samuel Williams.
5
5
 
6
- require 'async'
6
+ require "etc"
7
+ require "async/clock"
7
8
 
8
- require 'etc'
9
-
10
- require_relative 'group'
11
- require_relative 'keyed'
12
- require_relative 'statistics'
9
+ require_relative "group"
10
+ require_relative "keyed"
11
+ require_relative "statistics"
13
12
 
14
13
  module Async
15
14
  module Container
16
15
  # An environment variable key to override {.processor_count}.
17
- ASYNC_CONTAINER_PROCESSOR_COUNT = 'ASYNC_CONTAINER_PROCESSOR_COUNT'
16
+ ASYNC_CONTAINER_PROCESSOR_COUNT = "ASYNC_CONTAINER_PROCESSOR_COUNT"
18
17
 
19
18
  # The processor count which may be used for the default number of container threads/processes. You can override the value provided by the system by specifying the `ASYNC_CONTAINER_PROCESSOR_COUNT` environment variable.
20
19
  # @returns [Integer] The number of hardware processors which can run threads/processes simultaneously.
@@ -104,16 +103,23 @@ module Async
104
103
  # @returns [Boolean] The children all became ready.
105
104
  def wait_until_ready
106
105
  while true
107
- Console.logger.debug(self) do |buffer|
106
+ Console.debug(self) do |buffer|
108
107
  buffer.puts "Waiting for ready:"
109
108
  @state.each do |child, state|
110
- buffer.puts "\t#{child.class}: #{state.inspect}"
109
+ buffer.puts "\t#{child.inspect}: #{state}"
111
110
  end
112
111
  end
113
112
 
114
113
  self.sleep
115
114
 
116
115
  if self.status?(:ready)
116
+ Console.logger.debug(self) do |buffer|
117
+ buffer.puts "All ready:"
118
+ @state.each do |child, state|
119
+ buffer.puts "\t#{child.inspect}: #{state}"
120
+ end
121
+ end
122
+
117
123
  return true
118
124
  end
119
125
  end
@@ -126,7 +132,7 @@ module Async
126
132
  @group.stop(timeout)
127
133
 
128
134
  if @group.running?
129
- Console.logger.warn(self) {"Group is still running after stopping it!"}
135
+ Console.warn(self) {"Group is still running after stopping it!"}
130
136
  end
131
137
  ensure
132
138
  @running = true
@@ -136,11 +142,12 @@ module Async
136
142
  # @parameter name [String] The name of the child instance.
137
143
  # @parameter restart [Boolean] Whether to restart the child instance if it fails.
138
144
  # @parameter key [Symbol] A key used for reloading child instances.
139
- def spawn(name: nil, restart: false, key: nil, &block)
145
+ # @parameter health_check_timeout [Numeric | Nil] The maximum time a child instance can run without updating its state, before it is terminated as unhealthy.
146
+ def spawn(name: nil, restart: false, key: nil, health_check_timeout: nil, &block)
140
147
  name ||= UNNAMED
141
148
 
142
149
  if mark?(key)
143
- Console.logger.debug(self) {"Reusing existing child for #{key}: #{name}"}
150
+ Console.debug(self) {"Reusing existing child for #{key}: #{name}"}
144
151
  return false
145
152
  end
146
153
 
@@ -152,19 +159,34 @@ module Async
152
159
 
153
160
  state = insert(key, child)
154
161
 
162
+ # If a health check is specified, we will monitor the child process and terminate it if it does not update its state within the specified time.
163
+ if health_check_timeout
164
+ age_clock = state[:age] = Clock.start
165
+ end
166
+
155
167
  begin
156
168
  status = @group.wait_for(child) do |message|
157
- state.update(message)
169
+ case message
170
+ when :health_check!
171
+ if health_check_timeout&.<(age_clock.total)
172
+ Console.warn(self, "Child failed health check!", child: child, age: age_clock.total, health_check_timeout: health_check_timeout)
173
+ # If the child has failed the health check, we assume the worst and terminate it (SIGTERM).
174
+ child.terminate!
175
+ end
176
+ else
177
+ state.update(message)
178
+ age_clock&.reset!
179
+ end
158
180
  end
159
181
  ensure
160
182
  delete(key, child)
161
183
  end
162
184
 
163
185
  if status.success?
164
- Console.logger.debug(self) {"#{child} exited with #{status}"}
186
+ Console.debug(self) {"#{child} exited with #{status}"}
165
187
  else
166
188
  @statistics.failure!
167
- Console.logger.error(self) {status}
189
+ Console.error(self, status: status)
168
190
  end
169
191
 
170
192
  if restart
@@ -190,8 +212,12 @@ module Async
190
212
 
191
213
  # @deprecated Please use {spawn} or {run} instead.
192
214
  def async(**options, &block)
215
+ # warn "#{self.class}##{__method__} is deprecated, please use `spawn` or `run` instead.", uplevel: 1
216
+
217
+ require "async"
218
+
193
219
  spawn(**options) do |instance|
194
- Async::Reactor.run(instance, &block)
220
+ Async(instance, &block)
195
221
  end
196
222
  end
197
223