async-container 0.19.0 → 0.20.1

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: 2edeb6fa679f328736bb4a65c3b342b4aea94f2fc79557d4fc6e9e84bee77a2e
4
- data.tar.gz: 5c1c52a4f90c8710efebb411d97e5a8342ea99d1bbe09082deed562b8acdce99
3
+ metadata.gz: 2435dd48259ab4b6bbad01780c21dc91bdf2946485f1dd682c8388b9ee68410d
4
+ data.tar.gz: c8e3564e5c889f469658eb044444bc6884575090ed38e73f07bcffdfe893f823
5
5
  SHA512:
6
- metadata.gz: ee365ff16248e3064b136cdfeddec8e0f01ef77decbd514b94fc4a3bce1a38c2c51ffa5ba1e071b2fcd6bad6b7c4d4e322f26e8e5a530e4f45a68266d67429da
7
- data.tar.gz: 171a93c5855cad3a112622232bbc7e93b880d4c77223cb8a17c7772a0687a0be40af3143a0ecad2b14a9e9aa656010cdf47eb42f80e14b08148cd02f4a4eaa6c
6
+ metadata.gz: fa56a0207d1bab96685c25ca8f3617a5e5aefeb673998d836780baf75e1ff9ee9618ac4e506f893de206f425277a7abb0dc12f748850d59e037f5f155763b9d9
7
+ data.tar.gz: 1198eaa52f0ea7dfe3b411c1e6cf6866f720f9aa4b5b1913fee2fbb535144115b4e2e3e21e313fa01faa64e724e7e786e32732c0889d2c8601c8ccce72dfe967
checksums.yaml.gz.sig CHANGED
Binary file
@@ -116,6 +116,24 @@ module Async
116
116
  self.close_write
117
117
  end
118
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
+
119
137
  # Set the name of the process.
120
138
  # Invokes {::Process.setproctitle} if invoked in the child process.
121
139
  def name= value
@@ -4,6 +4,7 @@
4
4
  # Copyright, 2019-2024, by Samuel Williams.
5
5
 
6
6
  require "etc"
7
+ require "async/clock"
7
8
 
8
9
  require_relative "group"
9
10
  require_relative "keyed"
@@ -38,7 +39,7 @@ module Async
38
39
  UNNAMED = "Unnamed"
39
40
 
40
41
  def initialize(**options)
41
- @group = Group.new
42
+ @group = Group.new(**options)
42
43
  @running = true
43
44
 
44
45
  @state = {}
@@ -47,8 +48,10 @@ module Async
47
48
  @keyed = {}
48
49
  end
49
50
 
51
+ # @attribute [Group] The group of running children instances.
50
52
  attr :group
51
53
 
54
+ # @attribute [Hash(Child, Hash)] The state of each child instance.
52
55
  attr :state
53
56
 
54
57
  # A human readable representation of the container.
@@ -141,7 +144,8 @@ module Async
141
144
  # @parameter name [String] The name of the child instance.
142
145
  # @parameter restart [Boolean] Whether to restart the child instance if it fails.
143
146
  # @parameter key [Symbol] A key used for reloading child instances.
144
- def spawn(name: nil, restart: false, key: nil, &block)
147
+ # @parameter health_check_timeout [Numeric | Nil] The maximum time a child instance can run without updating its state, before it is terminated as unhealthy.
148
+ def spawn(name: nil, restart: false, key: nil, health_check_timeout: nil, &block)
145
149
  name ||= UNNAMED
146
150
 
147
151
  if mark?(key)
@@ -157,9 +161,24 @@ module Async
157
161
 
158
162
  state = insert(key, child)
159
163
 
164
+ # 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.
165
+ if health_check_timeout
166
+ age_clock = state[:age] = Clock.start
167
+ end
168
+
160
169
  begin
161
170
  status = @group.wait_for(child) do |message|
162
- state.update(message)
171
+ case message
172
+ when :health_check!
173
+ if health_check_timeout&.<(age_clock.total)
174
+ Console.warn(self, "Child failed health check!", child: child, age: age_clock.total, health_check_timeout: health_check_timeout)
175
+ # If the child has failed the health check, we assume the worst and terminate it (SIGTERM).
176
+ child.terminate!
177
+ end
178
+ else
179
+ state.update(message)
180
+ age_clock&.reset!
181
+ end
163
182
  end
164
183
  ensure
165
184
  delete(key, child)
@@ -13,7 +13,12 @@ module Async
13
13
  # Manages a group of running processes.
14
14
  class Group
15
15
  # Initialize an empty group.
16
- def initialize
16
+ #
17
+ # @parameter health_check_interval [Numeric | Nil] The (biggest) interval at which health checks are performed.
18
+ def initialize(health_check_interval: 1.0)
19
+ @health_check_interval = health_check_interval
20
+
21
+ # The running fibers, indexed by IO:
17
22
  @running = {}
18
23
 
19
24
  # This queue allows us to wait for processes to complete, without spawning new processes as a result.
@@ -57,8 +62,36 @@ module Async
57
62
  def wait
58
63
  self.resume
59
64
 
60
- while self.running?
61
- self.wait_for_children
65
+ with_health_checks do |duration|
66
+ self.wait_for_children(duration)
67
+ end
68
+ end
69
+
70
+ private def with_health_checks
71
+ if @health_check_interval
72
+ health_check_clock = Clock.start
73
+
74
+ while self.running?
75
+ duration = [@health_check_interval - health_check_clock.total, 0].max
76
+
77
+ yield duration
78
+
79
+ if health_check_clock.total > @health_check_interval
80
+ self.health_check!
81
+ health_check_clock.reset!
82
+ end
83
+ end
84
+ else
85
+ while self.running?
86
+ yield nil
87
+ end
88
+ end
89
+ end
90
+
91
+ # Perform a health check on all running processes.
92
+ def health_check!
93
+ @running.each_value do |fiber|
94
+ fiber.resume(:health_check!)
62
95
  end
63
96
  end
64
97
 
@@ -119,15 +152,19 @@ module Async
119
152
  @running[io] = Fiber.current
120
153
 
121
154
  while @running.key?(io)
155
+ # Wait for some event on the channel:
122
156
  result = Fiber.yield
123
157
 
124
158
  if result == Interrupt
125
159
  channel.interrupt!
126
160
  elsif result == Terminate
127
161
  channel.terminate!
162
+ elsif result
163
+ yield result
128
164
  elsif message = channel.receive
129
165
  yield message
130
166
  else
167
+ # Wait for the channel to exit:
131
168
  return channel.wait
132
169
  end
133
170
  end
@@ -15,7 +15,8 @@ module Async
15
15
  # @parameter count [Integer] The number of instances to start.
16
16
  # @parameter forks [Integer] The number of processes to fork.
17
17
  # @parameter threads [Integer] the number of threads to start.
18
- def run(count: nil, forks: nil, threads: nil, **options, &block)
18
+ # @parameter health_check_timeout [Numeric] The timeout for health checks, in seconds. Passed into the child {Threaded} containers.
19
+ def run(count: nil, forks: nil, threads: nil, health_check_timeout: nil, **options, &block)
19
20
  processor_count = Container.processor_count
20
21
  count ||= processor_count ** 2
21
22
  forks ||= [processor_count, count].min
@@ -25,7 +26,7 @@ module Async
25
26
  self.spawn(**options) do |instance|
26
27
  container = Threaded.new
27
28
 
28
- container.run(count: threads, **options, &block)
29
+ container.run(count: threads, health_check_timeout: health_check_timeout, **options, &block)
29
30
 
30
31
  container.wait_until_ready
31
32
  instance.ready!
@@ -34,6 +35,7 @@ module Async
34
35
  rescue Async::Container::Terminate
35
36
  # Stop it immediately:
36
37
  container.stop(false)
38
+ raise
37
39
  ensure
38
40
  # Stop it gracefully (also code path for Interrupt):
39
41
  container.stop
@@ -90,7 +90,10 @@ module Async
90
90
  def self.fork(**options)
91
91
  self.new(**options) do |thread|
92
92
  ::Thread.new do
93
- yield Instance.for(thread)
93
+ # This could be a configuration option (see forked implementation too):
94
+ ::Thread.handle_interrupt(SignalException => :immediate) do
95
+ yield Instance.for(thread)
96
+ end
94
97
  end
95
98
  end
96
99
  end
@@ -122,6 +125,23 @@ module Async
122
125
  end
123
126
  end
124
127
 
128
+ # Convert the child process to a hash, suitable for serialization.
129
+ #
130
+ # @returns [Hash] The request as a hash.
131
+ def as_json(...)
132
+ {
133
+ name: @thread.name,
134
+ status: @status&.as_json,
135
+ }
136
+ end
137
+
138
+ # Convert the request to JSON.
139
+ #
140
+ # @returns [String] The request as JSON.
141
+ def to_json(...)
142
+ as_json.to_json(...)
143
+ end
144
+
125
145
  # Set the name of the thread.
126
146
  # @parameter value [String] The name to set.
127
147
  def name= value
@@ -188,6 +208,14 @@ module Async
188
208
  @error.nil?
189
209
  end
190
210
 
211
+ def as_json(...)
212
+ if @error
213
+ @error.inspect
214
+ else
215
+ true
216
+ end
217
+ end
218
+
191
219
  # A human readable representation of the status.
192
220
  def to_s
193
221
  "\#<#{self.class} #{success? ? "success" : "failure"}>"
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Async
7
7
  module Container
8
- VERSION = "0.19.0"
8
+ VERSION = "0.20.1"
9
9
  end
10
10
  end
data/readme.md CHANGED
@@ -14,7 +14,24 @@ Provides containers which implement parallelism for clients and servers.
14
14
 
15
15
  ## Usage
16
16
 
17
- Please see the [project documentation](https://socketry.github.io/async-container/).
17
+ Please see the [project documentation](https://socketry.github.io/async-container/) for more details.
18
+
19
+ - [Getting Started](https://socketry.github.io/async-container/guides/getting-started/index) - This guide explains how to use `async-container` to build basic scalable systems.
20
+
21
+ ## Releases
22
+
23
+ Please see the [project releases](https://socketry.github.io/async-container/releases/index) for all releases.
24
+
25
+ ### v0.20.1
26
+
27
+ - Fix compatibility between <code class="language-ruby">Async::Container::Hybrid</code> and the health check.
28
+ - <code class="language-ruby">Async::Container::Generic\#initialize</code> passes unused arguments through to <code class="language-ruby">Async::Container::Group</code>.
29
+
30
+ ### v0.20.0
31
+
32
+ - Improve container signal handling reliability by using `Thread.handle_interrupt` except at known safe points.
33
+ - Improved logging when child process fails and container startup.
34
+ - [Add `health_check_timeout` for detecting hung processes.](https://socketry.github.io/async-container/releases/index#add-health_check_timeout-for-detecting-hung-processes.)
18
35
 
19
36
  ## Contributing
20
37
 
data/releases.md CHANGED
@@ -1,6 +1,47 @@
1
1
  # Releases
2
2
 
3
- ## Unreleased
3
+ ## v0.20.1
4
+
5
+ - Fix compatibility between {ruby Async::Container::Hybrid} and the health check.
6
+ - {ruby Async::Container::Generic\#initialize} passes unused arguments through to {ruby Async::Container::Group}.
7
+
8
+ ## v0.20.0
4
9
 
5
10
  - Improve container signal handling reliability by using `Thread.handle_interrupt` except at known safe points.
6
11
  - Improved logging when child process fails and container startup.
12
+
13
+ ### Add `health_check_timeout` for detecting hung processes.
14
+
15
+ In order to detect hung processes, a `health_check_timeout` can be specified when spawning children workers. If the health check does not complete within the specified timeout, the child process is killed.
16
+
17
+ ``` ruby
18
+ require "async/container"
19
+
20
+ container = Async::Container.new
21
+
22
+ container.run(count: 1, restart: true, health_check_timeout: 1) do |instance|
23
+ while true
24
+ # This example will fail sometimes:
25
+ sleep(0.5 + rand)
26
+ instance.ready!
27
+ end
28
+ end
29
+
30
+ container.wait
31
+ ```
32
+
33
+ If the health check does not complete within the specified timeout, the child process is killed:
34
+
35
+ ```
36
+ 3.01s warn: Async::Container::Forked [oid=0x1340] [ec=0x1348] [pid=27100] [2025-02-20 13:24:55 +1300]
37
+ | Child failed health check!
38
+ | {
39
+ | "child": {
40
+ | "name": "Unnamed",
41
+ | "pid": 27101,
42
+ | "status": null
43
+ | },
44
+ | "age": 1.0612829999881797,
45
+ | "health_check_timeout": 1
46
+ | }
47
+ ```
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-container
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.0
4
+ version: 0.20.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -40,7 +40,7 @@ cert_chain:
40
40
  Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8
41
41
  voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg=
42
42
  -----END CERTIFICATE-----
43
- date: 2025-02-04 00:00:00.000000000 Z
43
+ date: 2025-02-20 00:00:00.000000000 Z
44
44
  dependencies:
45
45
  - !ruby/object:Gem::Dependency
46
46
  name: async
@@ -48,14 +48,14 @@ dependencies:
48
48
  requirements:
49
49
  - - "~>"
50
50
  - !ruby/object:Gem::Version
51
- version: '2.10'
51
+ version: '2.22'
52
52
  type: :runtime
53
53
  prerelease: false
54
54
  version_requirements: !ruby/object:Gem::Requirement
55
55
  requirements:
56
56
  - - "~>"
57
57
  - !ruby/object:Gem::Version
58
- version: '2.10'
58
+ version: '2.22'
59
59
  executables: []
60
60
  extensions: []
61
61
  extra_rdoc_files: []
metadata.gz.sig CHANGED
Binary file