async-service-supervisor 0.11.1 → 0.12.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: 8ae8e8b4a4a8853a5c4c7ff0044a8be8bf6a1da1a9929acd09ddb9284b77b083
4
- data.tar.gz: 261ccf70f647cf5676494fc77a952eea875ba342428284df2d56abf4ec1922f9
3
+ metadata.gz: af7b7a67aaac7ed8eadf666de3a01345e949ba59829aa6c864e94af19f5d422a
4
+ data.tar.gz: 9765b0474d90eff9294e7865708a0c772a87e8ff7b0787baf0478f67e742b22b
5
5
  SHA512:
6
- metadata.gz: 5ed659e705698067c9d1d59e1a61674bc82586445c6bf9ace4a932c5e9e79fcb426204f527a2670fffe9e0532adb80e99f20427eb8ab3325ede2a20d4ff4c735
7
- data.tar.gz: 12fba3a0e3c4fdbe2379ea8aacd89e271488ac8c8f99e2920b6c63e867e98f4e0552b55442785cb70a21ceee34be9048bf9c2651aab41a8950bdc3b6df5fb9c8
6
+ metadata.gz: b678aebfa3fe28e7930c4c2426daae4f18acd8da24aa423994d8271c6a2ef8674a0dcdaa138d40c2db98e15faf4d2aa1e85815d63faae3a4cecdd374784409bc
7
+ data.tar.gz: 118ab77fbf30089894f3537ba839e292d59b5263056efd07aa34af35f1dca599d087a0896a0b1dbe5b0bb1f6654751f87b2f66c98ff2fb5e15d68ac20f372637
checksums.yaml.gz.sig CHANGED
Binary file
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  def initialize(...)
7
7
  super
@@ -133,14 +133,17 @@ service "supervisor" do
133
133
  Async::Service::Supervisor::MemoryMonitor.new(
134
134
  interval: 10,
135
135
  maximum_size_limit: 1024 * 1024 * 500 # 500MB limit per process
136
+ ),
137
+
138
+ # Aggregate application-level metrics (connections, requests) from workers:
139
+ Async::Service::Supervisor::UtilizationMonitor.new(
140
+ interval: 10
136
141
  )
137
142
  ]
138
143
  end
139
144
  end
140
145
  ```
141
146
 
142
- See the {ruby Async::Service::Supervisor::MemoryMonitor Memory Monitor} and {ruby Async::Service::Supervisor::ProcessMonitor Process Monitor} guides for detailed configuration options and best practices.
143
-
144
147
  ### Collecting Diagnostics
145
148
 
146
149
  The supervisor can collect various diagnostics from workers on demand:
data/context/index.yaml CHANGED
@@ -23,3 +23,8 @@ files:
23
23
  title: Process Monitor
24
24
  description: This guide explains how to use the <code class="language-ruby">Async::Service::Supervisor::ProcessMonitor</code>
25
25
  to log CPU and memory metrics for your worker processes.
26
+ - path: utilization-monitor.md
27
+ title: Utilization Monitor
28
+ description: This guide explains how to use the <code class="language-ruby">Async::Service::Supervisor::UtilizationMonitor</code>
29
+ to collect and aggregate application-level utilization metrics from your worker
30
+ processes.
@@ -0,0 +1,229 @@
1
+ # Utilization Monitor
2
+
3
+ This guide explains how to use the {ruby Async::Service::Supervisor::UtilizationMonitor} to collect and aggregate application-level utilization metrics from your worker processes.
4
+
5
+ ## Overview
6
+
7
+ While the {ruby Async::Service::Supervisor::ProcessMonitor} captures OS-level metrics (CPU, memory) and the {ruby Async::Service::Supervisor::MemoryMonitor} takes action when limits are exceeded, the `UtilizationMonitor` focuses on **application-level metrics**—connections, requests, queue depths, and other business-specific utilization data. Without it, you can't easily answer questions like "How many active connections do my workers have?" or "What is the total request throughput across all workers?"
8
+
9
+ The `UtilizationMonitor` solves this by using shared memory to efficiently collect metrics from workers and aggregate them by service name. Workers write metrics to a shared memory segment; the supervisor periodically reads and aggregates them without any IPC overhead during collection.
10
+
11
+ Use the `UtilizationMonitor` when you need:
12
+
13
+ - **Application observability**: Track connections, requests, queue depths, or custom metrics across workers.
14
+ - **Service-level aggregation**: See totals per service (e.g., "echo" service: 42 connections, 1000 messages).
15
+ - **Lightweight collection**: Avoid IPC or network calls—metrics are read directly from shared memory.
16
+ - **Integration with logging**: Emit aggregated metrics to your logging pipeline for dashboards and alerts.
17
+
18
+ The monitor uses the `async-utilization` gem for schema definition and shared memory layout. Workers must include {ruby Async::Service::Supervisor::Supervised} and define a `utilization_schema` to participate.
19
+
20
+ ## Usage
21
+
22
+ ### Supervisor Configuration
23
+
24
+ Add a utilization monitor to your supervisor service:
25
+
26
+ ```ruby
27
+ service "supervisor" do
28
+ include Async::Service::Supervisor::Environment
29
+
30
+ monitors do
31
+ [
32
+ Async::Service::Supervisor::UtilizationMonitor.new(
33
+ path: File.expand_path("utilization.shm", root),
34
+ interval: 10 # Aggregate and emit metrics every 10 seconds
35
+ )
36
+ ]
37
+ end
38
+ end
39
+ ```
40
+
41
+ ### Worker Configuration
42
+
43
+ Workers must include {ruby Async::Service::Supervisor::Supervised} and define a `utilization_schema` that describes the metrics they expose:
44
+
45
+ ```ruby
46
+ service "echo" do
47
+ include Async::Service::Managed::Environment
48
+ include Async::Service::Supervisor::Supervised
49
+
50
+ service_class EchoService
51
+
52
+ utilization_schema do
53
+ {
54
+ connections_total: :u64,
55
+ connections_active: :u32,
56
+ messages_total: :u64
57
+ }
58
+ end
59
+ end
60
+ ```
61
+
62
+ ### Emitting Metrics from Workers
63
+
64
+ Workers obtain a utilization registry from the evaluator and use it to update metrics:
65
+
66
+ ```ruby
67
+ def run(instance, evaluator)
68
+ evaluator.prepare!(instance)
69
+ instance.ready!
70
+
71
+ registry = evaluator.utilization_registry
72
+ connections_total = registry.metric(:connections_total)
73
+ connections_active = registry.metric(:connections_active)
74
+ messages_total = registry.metric(:messages_total)
75
+
76
+ @bound_endpoint.accept do |peer|
77
+ connections_total.increment
78
+ connections_active.track do
79
+ peer.each_line do |line|
80
+ messages_total.increment
81
+ peer.write(line)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ ```
87
+
88
+ The supervisor aggregates these metrics by service name and emits them at the configured interval. For example:
89
+
90
+ ```json
91
+ {
92
+ "echo": {
93
+ "connections_total": 150,
94
+ "connections_active": 12,
95
+ "messages_total": 45000
96
+ }
97
+ }
98
+ ```
99
+
100
+ ## Configuration Options
101
+
102
+ ### `path`
103
+
104
+ Path to the shared memory file used for worker metrics. Default: `"utilization.shm"` (relative to current working directory).
105
+
106
+ Be explicit about the path when using {ruby Async::Service::Supervisor::Environment} so supervisor and workers resolve the same file regardless of working directory:
107
+
108
+ ```ruby
109
+ monitors do
110
+ [
111
+ Async::Service::Supervisor::UtilizationMonitor.new(
112
+ path: File.expand_path("utilization.shm", root),
113
+ interval: 10
114
+ )
115
+ ]
116
+ end
117
+ ```
118
+
119
+ ### `interval`
120
+
121
+ The interval (in seconds) at which to aggregate and emit utilization metrics. Default: `10` seconds.
122
+
123
+ ```ruby
124
+ # Emit every second for high-frequency monitoring
125
+ Async::Service::Supervisor::UtilizationMonitor.new(interval: 1)
126
+
127
+ # Emit every 5 minutes for low-overhead monitoring
128
+ Async::Service::Supervisor::UtilizationMonitor.new(interval: 300)
129
+ ```
130
+
131
+ ### `size`
132
+
133
+ Total size of the shared memory buffer. Default: `IO::Buffer::PAGE_SIZE * 8`. The buffer grows automatically when more workers are registered than segments available.
134
+
135
+ ```ruby
136
+ Async::Service::Supervisor::UtilizationMonitor.new(
137
+ size: IO::Buffer::PAGE_SIZE * 32 # Larger initial buffer for many workers
138
+ )
139
+ ```
140
+
141
+ ### `segment_size`
142
+
143
+ Size of each allocation segment per worker. Default: `512` bytes. Must accommodate your schema; the `async-utilization` gem lays out fields according to type (e.g., `u64` = 8 bytes, `u32` = 4 bytes).
144
+
145
+ ```ruby
146
+ Async::Service::Supervisor::UtilizationMonitor.new(
147
+ segment_size: 256 # Smaller segments if schema is compact
148
+ )
149
+ ```
150
+
151
+ ## Schema Types
152
+
153
+ The `utilization_schema` maps metric names to types supported by {ruby IO::Buffer}:
154
+
155
+ | Type | Size | Use case |
156
+ |------|------|----------|
157
+ | `:u32` | 4 bytes | Counters that may wrap (e.g., connections_active) |
158
+ | `:u64` | 8 bytes | Monotonically increasing counters (e.g., requests_total) |
159
+ | `:i32` | 4 bytes | Signed 32-bit values |
160
+ | `:i64` | 8 bytes | Signed 64-bit values |
161
+ | `:f32` | 4 bytes | Single-precision floats |
162
+ | `:f64` | 8 bytes | Double-precision floats |
163
+
164
+ Prefer `:u64` for totals that only increase; use `:u32` for gauges or values that may decrease.
165
+
166
+ ## Default Schema
167
+
168
+ The {ruby Async::Service::Supervisor::Supervised} mixin provides a default schema if you don't override `utilization_schema`:
169
+
170
+ ```ruby
171
+ {
172
+ connections_active: :u32,
173
+ connections_total: :u64,
174
+ requests_active: :u32,
175
+ requests_total: :u64
176
+ }
177
+ ```
178
+
179
+ Override it when your service has different metrics:
180
+
181
+ ```ruby
182
+ utilization_schema do
183
+ {
184
+ connections_active: :u32,
185
+ connections_total: :u64,
186
+ messages_total: :u64,
187
+ queue_depth: :u32
188
+ }
189
+ end
190
+ ```
191
+
192
+ ## Metric API
193
+
194
+ The utilization registry provides methods to update metrics:
195
+
196
+ - **`increment`**: Increment a counter by 1.
197
+ - **`set(value)`**: Set a gauge to a specific value.
198
+ - **`track { ... }`**: Execute a block and increment/decrement a gauge around it (e.g., `connections_active` while handling a connection).
199
+
200
+ ```ruby
201
+ connections_total = registry.metric(:connections_total)
202
+ connections_active = registry.metric(:connections_active)
203
+
204
+ # Increment total connections when a client connects
205
+ connections_total.increment
206
+
207
+ # Track active connections for the duration of the block
208
+ connections_active.track do
209
+ handle_client(peer)
210
+ end
211
+ ```
212
+
213
+ ## Aggregation Behavior
214
+
215
+ Metrics are aggregated by service name (from `supervisor_worker_state[:name]`). Values are summed across workers of the same service. For example, with 4 workers each reporting `connections_active: 3`, the aggregated value is `12`.
216
+
217
+ ## Best Practices
218
+
219
+ - **Define a minimal schema**: Only include metrics you need; each field consumes shared memory.
220
+ - **Use appropriate types**: `u64` for ever-increasing counters; `u32` for gauges.
221
+ - **Match schema across workers**: All workers of the same service should use the same schema for consistent aggregation.
222
+ - **Combine with other monitors**: Use `UtilizationMonitor` alongside `ProcessMonitor` and `MemoryMonitor` for full observability.
223
+
224
+ ## Common Pitfalls
225
+
226
+ - **Workers without schema**: Workers that don't define `utilization_schema` (or return `nil`) are not registered. They won't contribute to utilization metrics.
227
+ - **Schema mismatch**: If workers of the same service use different schemas, aggregation may produce incorrect or partial results.
228
+ - **Path permissions**: Ensure the shared memory path is accessible to all worker processes (e.g., same user, or appropriate permissions).
229
+ - **Segment size**: If your schema is large, increase `segment_size` to avoid allocation failures.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require "async/bus/client"
7
7
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require "io/endpoint/unix_endpoint"
7
7
 
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require "async/service/environment"
7
7
  require "async/service/managed/environment"
8
8
 
9
9
  require_relative "service"
10
+ require_relative "server"
10
11
 
11
12
  module Async
12
13
  module Service
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Released under the MIT License.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
+
3
6
  module Async
4
7
  module Service
5
8
  module Supervisor
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require "memory/leak/cluster"
7
7
  require "set"
@@ -85,11 +85,26 @@ module Async
85
85
  end
86
86
  end
87
87
 
88
+ # The key used when this monitor's status is aggregated with others.
89
+ def self.monitor_type
90
+ :memory_monitor
91
+ end
92
+
93
+ # Serialize memory cluster data for JSON.
94
+ def as_json
95
+ @cluster.as_json
96
+ end
97
+
98
+ # Serialize to JSON string.
99
+ def to_json(...)
100
+ as_json.to_json(...)
101
+ end
102
+
88
103
  # Get status for the memory monitor.
89
104
  #
90
- # @returns [Hash] Status including the memory cluster.
105
+ # @returns [Hash] Hash with type and data keys.
91
106
  def status
92
- {memory_monitor: @cluster.as_json}
107
+ {type: self.class.monitor_type, data: as_json}
93
108
  end
94
109
 
95
110
  # Invoked when a memory leak is detected.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require "process/metrics"
7
7
  require_relative "loop"
@@ -57,11 +57,26 @@ module Async
57
57
  Process::Metrics::General.capture(ppid: @ppid).transform_values!(&:as_json)
58
58
  end
59
59
 
60
+ # The key used when this monitor's status is aggregated with others.
61
+ def self.monitor_type
62
+ :process_monitor
63
+ end
64
+
65
+ # Serialize process metrics for JSON.
66
+ def as_json
67
+ {ppid: @ppid, metrics: self.metrics}
68
+ end
69
+
70
+ # Serialize to JSON string.
71
+ def to_json(...)
72
+ as_json.to_json(...)
73
+ end
74
+
60
75
  # Get status for the process monitor.
61
76
  #
62
- # @returns [Hash] Status including process metrics.
77
+ # @returns [Hash] Hash with type and data keys.
63
78
  def status
64
- {process_monitor: {ppid: @ppid, metrics: self.metrics}}
79
+ {type: self.class.monitor_type, data: as_json}
65
80
  end
66
81
 
67
82
  # Run the process monitor.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require "async/bus/server"
7
7
  require_relative "supervisor_controller"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require "async"
7
7
  require "async/service/managed/service"
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require "async/service/environment"
7
+ require "async/utilization"
8
+
9
+ require_relative "worker"
7
10
 
8
11
  module Async
9
12
  module Service
@@ -30,10 +33,38 @@ module Async
30
33
  {name: self.name}
31
34
  end
32
35
 
36
+ # A default schema for utilization metrics.
37
+ # @returns [Hash | Nil] The utilization schema or nil if utilization is disabled.
38
+ def utilization_schema
39
+ {
40
+ connections_active: :u32,
41
+ connections_total: :u64,
42
+ requests_active: :u32,
43
+ requests_total: :u64,
44
+ }
45
+ end
46
+
47
+ # Get the utilization registry for this service.
48
+ #
49
+ # Creates a new registry instance for tracking utilization metrics.
50
+ # This registry is used by workers to emit metrics that can be collected
51
+ # by the supervisor's utilization monitor.
52
+ #
53
+ # @returns [Async::Utilization::Registry] A new utilization registry instance.
54
+ def utilization_registry
55
+ Async::Utilization::Registry.new
56
+ end
57
+
33
58
  # The supervised worker for the current process.
34
59
  # @returns [Worker] The worker client.
35
60
  def supervisor_worker
36
- Worker.new(process_id: Process.pid, endpoint: supervisor_endpoint, state: self.supervisor_worker_state)
61
+ Worker.new(
62
+ process_id: Process.pid,
63
+ endpoint: supervisor_endpoint,
64
+ state: self.supervisor_worker_state,
65
+ utilization_schema: self.utilization_schema,
66
+ utilization_registry: self.utilization_registry,
67
+ )
37
68
  end
38
69
 
39
70
  # Create a supervised worker for the given instance.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2026, by Samuel Williams.
5
5
 
6
6
  require "async/bus/controller"
7
7
 
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require "set"
7
+
8
+ require_relative "loop"
9
+ require "async/utilization"
10
+
11
+ module Async
12
+ module Service
13
+ module Supervisor
14
+ # Monitors worker utilization metrics aggregated by service name.
15
+ #
16
+ # Uses shared memory to efficiently collect utilization metrics from workers
17
+ # and aggregates them by service name for monitoring and reporting.
18
+ class UtilizationMonitor
19
+ # Allocates and manages shared memory segments for worker utilization data.
20
+ #
21
+ # Manages a shared memory file that workers can write utilization metrics to.
22
+ # Allocates segments to workers and maintains a free list for reuse.
23
+ # Each process (supervisor and workers) maps the shared memory file independently.
24
+ class SegmentAllocator
25
+ # Initialize a new shared memory manager.
26
+ #
27
+ # Creates and maps the shared memory file. Workers will map the same file
28
+ # independently using the provided path.
29
+ #
30
+ # @parameter path [String] Path to the shared memory file.
31
+ # @parameter size [Integer] Total size of the shared memory buffer.
32
+ # @parameter segment_size [Integer] Size of each allocation segment (default: 512 bytes).
33
+ # @parameter growth_factor [Integer, Float] Factor to grow by when resizing (default: 2, doubles the size).
34
+ # Can be less than 2 or a floating point value; the result will be page-aligned to an integer.
35
+ def initialize(path, size: IO::Buffer::PAGE_SIZE * 8, segment_size: 512, growth_factor: 2)
36
+ @path = path
37
+ @size = size
38
+ @segment_size = segment_size
39
+ @growth_factor = growth_factor
40
+
41
+ @file = File.open(path, "w+b")
42
+ @file.truncate(size)
43
+ # Supervisor maps the file for reading worker data
44
+ @buffer = IO::Buffer.map(@file, size)
45
+
46
+ # Track allocated segments: worker_id => {offset: Integer, schema: Array}
47
+ @allocations = {}
48
+
49
+ # Free list of segment offsets
50
+ @free_list = []
51
+
52
+ # Initialize free list with all segments
53
+ (0...(@size / @segment_size)).each do |segment_index|
54
+ @free_list << (segment_index * @segment_size)
55
+ end
56
+ end
57
+
58
+ # Allocate a segment for a worker.
59
+ #
60
+ # Automatically resizes the shared memory file if no segments are available.
61
+ #
62
+ # @parameter worker_id [Integer] The ID of the worker.
63
+ # @parameter schema [Array] Array of [key, type, offset] tuples describing the data layout.
64
+ # @returns [Integer] The offset into the shared memory buffer, or nil if allocation fails.
65
+ def allocate(worker_id, schema)
66
+ # Try to resize if we're out of segments
67
+ if @free_list.empty?
68
+ unless resize(@size * @growth_factor)
69
+ return nil
70
+ end
71
+ end
72
+
73
+ offset = @free_list.shift
74
+ @allocations[worker_id] = {offset: offset, schema: schema}
75
+
76
+ return offset
77
+ end
78
+
79
+ # Free a segment allocated to a worker.
80
+ #
81
+ # @parameter worker_id [Integer] The ID of the worker.
82
+ def free(worker_id)
83
+ if allocation = @allocations.delete(worker_id)
84
+ @free_list << allocation[:offset]
85
+ end
86
+ end
87
+
88
+ # Get the allocation information for a worker.
89
+ #
90
+ # @parameter worker_id [Integer] The ID of the worker.
91
+ # @returns [Hash] Allocation info with :offset and :schema, or nil if not allocated.
92
+ def allocation(worker_id)
93
+ @allocations[worker_id]
94
+ end
95
+
96
+ # Get the current size of the shared memory file.
97
+ #
98
+ # @returns [Integer] The current size of the shared memory file.
99
+ def size
100
+ @size
101
+ end
102
+
103
+ # Update the schema for an existing allocation.
104
+ #
105
+ # @parameter worker_id [Integer] The ID of the worker.
106
+ # @parameter schema [Array] Array of [key, type, offset] tuples describing the data layout.
107
+ def update_schema(worker_id, schema)
108
+ if allocation = @allocations[worker_id]
109
+ allocation[:schema] = schema
110
+ end
111
+ end
112
+
113
+ # Read utilization data from a worker's allocated segment.
114
+ #
115
+ # @parameter worker_id [Integer] The ID of the worker.
116
+ # @returns [Hash] Hash mapping keys to their values, or nil if not allocated.
117
+ def read(worker_id)
118
+ allocation = @allocations[worker_id]
119
+ return nil unless allocation
120
+
121
+ offset = allocation[:offset]
122
+ schema = allocation[:schema]
123
+
124
+ result = {}
125
+ schema.each do |key, type, field_offset|
126
+ absolute_offset = offset + field_offset
127
+
128
+ # Use IO::Buffer type symbols directly (i32, u32, i64, u64, f32, f64)
129
+ # IO::Buffer accepts both lowercase and uppercase versions
130
+ begin
131
+ result[key] = @buffer.get_value(type, absolute_offset)
132
+ rescue => error
133
+ Console.warn(self, "Failed to read value", type: type, key: key, offset: absolute_offset, exception: error)
134
+ end
135
+ end
136
+
137
+ return result
138
+ end
139
+
140
+ # Resize the shared memory file.
141
+ #
142
+ # Extends the file to the new size, remaps the buffer, and adds new segments
143
+ # to the free list. The new size must be larger than the current size and should
144
+ # be page-aligned for optimal performance.
145
+ #
146
+ # @parameter new_size [Integer] The new size for the shared memory file.
147
+ # @returns [Boolean] True if resize was successful, false otherwise.
148
+ def resize(new_size)
149
+ old_size = @size
150
+ return false if new_size <= old_size
151
+
152
+ # Ensure new size is page-aligned (rounds up to nearest page boundary)
153
+ page_size = IO::Buffer::PAGE_SIZE
154
+ new_size = (((new_size + page_size - 1) / page_size) * page_size).to_i
155
+
156
+ begin
157
+ # Extend the file:
158
+ @file.truncate(new_size)
159
+
160
+ # Remap the buffer to the new size:
161
+ @buffer&.free
162
+ @buffer = IO::Buffer.map(@file, new_size)
163
+
164
+ # Calculate new segments to add to free list:
165
+ old_segment_count = old_size / @segment_size
166
+ new_segment_count = new_size / @segment_size
167
+
168
+ # Add new segments to free list:
169
+ (old_segment_count...new_segment_count).each do |segment_index|
170
+ @free_list << (segment_index * @segment_size)
171
+ end
172
+
173
+ @size = new_size
174
+
175
+ Console.info(self, "Resized shared memory", old_size: old_size, new_size: new_size, segments_added: new_segment_count - old_segment_count)
176
+
177
+ return true
178
+ rescue => error
179
+ Console.error(self, "Failed to resize shared memory", old_size: old_size, new_size: new_size, exception: error)
180
+ return false
181
+ end
182
+ end
183
+
184
+ # Close the shared memory file.
185
+ def close
186
+ @file&.close
187
+ @buffer = nil
188
+ end
189
+ end
190
+ # Initialize a new utilization monitor.
191
+ #
192
+ # @parameter path [String] Path to the shared memory file.
193
+ # @parameter interval [Integer] Interval in seconds to aggregate and update metrics.
194
+ # @parameter size [Integer] Total size of the shared memory buffer.
195
+ # @parameter segment_size [Integer] Size of each allocation segment (default: 512 bytes).
196
+ def initialize(path: "utilization.shm", interval: 10, size: IO::Buffer::PAGE_SIZE * 8, segment_size: 512)
197
+ @path = path
198
+ @interval = interval
199
+ @segment_size = segment_size
200
+
201
+ @allocator = SegmentAllocator.new(path, size: size, segment_size: segment_size)
202
+
203
+ # Track workers: worker_id => supervisor_controller
204
+ @workers = {}
205
+
206
+ @guard = Mutex.new
207
+ end
208
+
209
+ # Register a worker with the utilization monitor.
210
+ #
211
+ # Allocates a segment of shared memory and instructs the worker
212
+ # to map the shared memory file and expose utilization information at the allocated offset.
213
+ # The worker maps the file independently and returns its schema.
214
+ #
215
+ # @parameter supervisor_controller [SupervisorController] The supervisor controller for the worker.
216
+ def register(supervisor_controller)
217
+ @guard.synchronize do
218
+ worker_id = supervisor_controller.id
219
+ return unless worker_id
220
+
221
+ # Allocate a segment first (we'll get schema from worker)
222
+ offset = @allocator.allocate(worker_id, [])
223
+
224
+ unless offset
225
+ Console.warn(self, "Failed to allocate utilization segment", worker_id: worker_id)
226
+ return
227
+ end
228
+
229
+ # Inform worker of the shared memory path, size, and allocated offset
230
+ # The worker will map the file itself and return its schema
231
+ begin
232
+ worker = supervisor_controller.worker
233
+
234
+ if worker
235
+ # Pass the segment size - observer will handle page alignment and file mapping
236
+ schema = worker.setup_utilization_observer(@path, @segment_size, offset)
237
+
238
+ # Update the allocation with the actual schema
239
+ if schema && !schema.empty?
240
+ @allocator.update_schema(worker_id, schema)
241
+ @workers[worker_id] = supervisor_controller
242
+
243
+ Console.info(self, "Registered worker utilization", worker_id: worker_id, offset: offset, schema: schema)
244
+ else
245
+ # Worker didn't provide schema, free the allocation
246
+ @allocator.free(worker_id)
247
+ Console.info(self, "Worker did not provide utilization schema", worker_id: worker_id)
248
+ end
249
+ end
250
+ rescue => error
251
+ Console.error(self, "Error setting up worker utilization", worker_id: worker_id, exception: error)
252
+ @allocator.free(worker_id)
253
+ end
254
+ end
255
+ end
256
+
257
+ # Remove a worker from the utilization monitor.
258
+ #
259
+ # Returns the allocated segment back to the free list.
260
+ #
261
+ # @parameter supervisor_controller [SupervisorController] The supervisor controller for the worker.
262
+ def remove(supervisor_controller)
263
+ @guard.synchronize do
264
+ worker_id = supervisor_controller.id
265
+ return unless worker_id
266
+
267
+ @workers.delete(worker_id)
268
+ @allocator.free(worker_id)
269
+
270
+ Console.debug(self, "Freed utilization segment", worker_id: worker_id)
271
+ end
272
+ end
273
+
274
+ # The key used when this monitor's status is aggregated with others.
275
+ def self.monitor_type
276
+ :utilization_monitor
277
+ end
278
+
279
+ # Serialize utilization data for JSON.
280
+ #
281
+ # @returns [Hash] Hash mapping service names to aggregated utilization metrics.
282
+ def as_json
283
+ @guard.synchronize do
284
+ aggregated = {}
285
+
286
+ @workers.each do |worker_id, supervisor_controller|
287
+ service_name = supervisor_controller.state[:name] || "unknown"
288
+
289
+ data = @allocator.read(worker_id)
290
+ next unless data
291
+
292
+ # Initialize service aggregation if needed
293
+ aggregated[service_name] ||= {}
294
+
295
+ # Sum up all numeric fields
296
+ data.each do |key, value|
297
+ if value.is_a?(Numeric)
298
+ aggregated[service_name][key] ||= 0
299
+ aggregated[service_name][key] += value
300
+ else
301
+ # For non-numeric values, we could handle differently
302
+ # For now, just store the last value
303
+ aggregated[service_name][key] = value
304
+ end
305
+ end
306
+ end
307
+
308
+ aggregated
309
+ end
310
+ end
311
+
312
+ # Serialize to JSON string.
313
+ def to_json(...)
314
+ as_json.to_json(...)
315
+ end
316
+
317
+ # Get aggregated utilization status by service name.
318
+ #
319
+ # Reads utilization data from all registered workers and aggregates it
320
+ # by service name (from supervisor_controller.state[:name]).
321
+ #
322
+ # @returns [Hash] Hash with type and data keys.
323
+ def status
324
+ {type: self.class.monitor_type, data: as_json}
325
+ end
326
+
327
+ # Emit the utilization metrics.
328
+ #
329
+ # @parameter status [Hash] The utilization metrics.
330
+ def emit(metrics)
331
+ Console.info(self, "Utilization:", metrics: metrics)
332
+ end
333
+
334
+ # Run the utilization monitor.
335
+ #
336
+ # Periodically aggregates utilization data from all workers.
337
+ #
338
+ # @returns [Async::Task] The task that is running the utilization monitor.
339
+ def run
340
+ Async do
341
+ Loop.run(interval: @interval) do
342
+ self.emit(self.as_json)
343
+ end
344
+ end
345
+ end
346
+ end
347
+ end
348
+ end
349
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  # @namespace
7
7
  module Async
@@ -9,7 +9,7 @@ module Async
9
9
  module Service
10
10
  # @namespace
11
11
  module Supervisor
12
- VERSION = "0.11.1"
12
+ VERSION = "0.12.0"
13
13
  end
14
14
  end
15
15
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "client"
7
7
  require_relative "worker_controller"
@@ -26,12 +26,17 @@ module Async
26
26
  # @parameter process_id [Integer] The process ID to register with the supervisor.
27
27
  # @parameter endpoint [IO::Endpoint] The supervisor endpoint to connect to.
28
28
  # @parameter state [Hash] Optional state to associate with this worker (e.g., service name).
29
- def initialize(process_id: Process.pid, endpoint: Supervisor.endpoint, state: {})
29
+ # @parameter utilization_schema [Hash | Nil] Optional utilization schema definition.
30
+ # @parameter utilization_registry [Registry, nil] Optional utilization registry. If nil, a new registry is created.
31
+ def initialize(process_id: Process.pid, endpoint: Supervisor.endpoint, state: {}, utilization_schema: nil, utilization_registry: nil)
30
32
  super(endpoint: endpoint)
31
33
 
32
34
  @id = nil
33
35
  @process_id = process_id
34
36
  @state = state
37
+
38
+ @utilization_schema = utilization_schema
39
+ @utilization_registry = utilization_registry || require("async/utilization") && Async::Utilization::Registry.new
35
40
  end
36
41
 
37
42
  # @attribute [Integer] The ID assigned by the supervisor.
@@ -43,6 +48,34 @@ module Async
43
48
  # @attribute [Hash] State associated with this worker (e.g., service name).
44
49
  attr_accessor :state
45
50
 
51
+ # @attribute [Hash | Nil] Utilization schema definition.
52
+ attr :utilization_schema
53
+
54
+ # @attribute [Registry] The utilization registry for this worker.
55
+ attr :utilization_registry
56
+
57
+ # Setup utilization observer for this worker.
58
+ #
59
+ # Maps the shared memory file and configures the utilization registry to write
60
+ # metrics to it. Called by the supervisor (via WorkerController) to inform the
61
+ # worker of the shared memory file path and allocated offset.
62
+ #
63
+ # @parameter path [String] Path to the shared memory file that the worker should map.
64
+ # @parameter size [Integer] Size of the shared memory region to map.
65
+ # @parameter offset [Integer] Offset into the shared memory buffer allocated for this worker.
66
+ # @returns [Array] Array of [key, type, offset] tuples describing the utilization schema.
67
+ # Returns empty array if no utilization schema is configured.
68
+ def setup_utilization_observer(path, size, offset)
69
+ return [] unless @utilization_schema
70
+
71
+ schema = Async::Utilization::Schema.build(@utilization_schema)
72
+ observer = Async::Utilization::Observer.open(schema, path, size, offset)
73
+ @utilization_registry.observer = observer
74
+
75
+ # Pass the schema back to the supervisor so it can be used to aggregate the metrics:
76
+ observer.schema.to_a
77
+ end
78
+
46
79
  protected def connected!(connection)
47
80
  super
48
81
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require "async/bus/controller"
7
7
  require "stringio"
@@ -101,6 +101,19 @@ module Async
101
101
  ensure
102
102
  GC::Profiler.disable
103
103
  end
104
+
105
+ # Setup utilization observer for this worker.
106
+ #
107
+ # Delegates to the worker to map the shared memory file and configure its
108
+ # utilization registry. Called by the supervisor via RPC.
109
+ #
110
+ # @parameter path [String] Path to the shared memory file that the worker should map.
111
+ # @parameter size [Integer] Size of the shared memory region to map.
112
+ # @parameter offset [Integer] Offset into the shared memory buffer allocated for this worker.
113
+ # @returns [Array] Array of [key, type, offset] tuples describing the utilization schema.
114
+ def setup_utilization_observer(path, size, offset)
115
+ @worker.setup_utilization_observer(path, size, offset)
116
+ end
104
117
  end
105
118
  end
106
119
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "supervisor/version"
7
7
 
@@ -12,6 +12,7 @@ require_relative "supervisor/client"
12
12
 
13
13
  require_relative "supervisor/memory_monitor"
14
14
  require_relative "supervisor/process_monitor"
15
+ require_relative "supervisor/utilization_monitor"
15
16
 
16
17
  require_relative "supervisor/environment"
17
18
  require_relative "supervisor/supervised"
data/license.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MIT License
2
2
 
3
- Copyright, 2025, by Samuel Williams.
3
+ Copyright, 2025-2026, by Samuel Williams.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/readme.md CHANGED
@@ -24,10 +24,16 @@ Please see the [project documentation](https://socketry.github.io/async-service-
24
24
 
25
25
  - [Process Monitor](https://socketry.github.io/async-service-supervisor/guides/process-monitor/index) - This guide explains how to use the <code class="language-ruby">Async::Service::Supervisor::ProcessMonitor</code> to log CPU and memory metrics for your worker processes.
26
26
 
27
+ - [Utilization Monitor](https://socketry.github.io/async-service-supervisor/guides/utilization-monitor/index) - This guide explains how to use the <code class="language-ruby">Async::Service::Supervisor::UtilizationMonitor</code> to collect and aggregate application-level utilization metrics from your worker processes.
28
+
27
29
  ## Releases
28
30
 
29
31
  Please see the [project releases](https://socketry.github.io/async-service-supervisor/releases/index) for all releases.
30
32
 
33
+ ### v0.12.0
34
+
35
+ - Introduce `UtilizationMonitor`, that uses shared memory to track worker utilization metrics, like total and active requests, connections, etc.
36
+
31
37
  ### v0.11.0
32
38
 
33
39
  - Add `state` attribute to `SupervisorController` to store per-worker metadata (e.g., service name).
@@ -73,10 +79,6 @@ Please see the [project releases](https://socketry.github.io/async-service-super
73
79
 
74
80
  - Fix timed out RPCs and subsequent responses which should be ignored.
75
81
 
76
- ### v0.6.0
77
-
78
- - Add `async:container:supervisor:reload` command to restart the container (blue/green deployment).
79
-
80
82
  ## Contributing
81
83
 
82
84
  We welcome contributions to this project.
data/releases.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Releases
2
2
 
3
+ ## v0.12.0
4
+
5
+ - Introduce `UtilizationMonitor`, that uses shared memory to track worker utilization metrics, like total and active requests, connections, etc.
6
+
3
7
  ## v0.11.0
4
8
 
5
9
  - Add `state` attribute to `SupervisorController` to store per-worker metadata (e.g., service name).
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-service-supervisor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.1
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '0.15'
69
+ - !ruby/object:Gem::Dependency
70
+ name: async-utilization
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.3'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.3'
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: io-endpoint
71
85
  requirement: !ruby/object:Gem::Requirement
@@ -132,6 +146,7 @@ files:
132
146
  - context/memory-monitor.md
133
147
  - context/migration.md
134
148
  - context/process-monitor.md
149
+ - context/utilization-monitor.md
135
150
  - lib/async/service/supervisor.rb
136
151
  - lib/async/service/supervisor/client.rb
137
152
  - lib/async/service/supervisor/endpoint.rb
@@ -143,6 +158,7 @@ files:
143
158
  - lib/async/service/supervisor/service.rb
144
159
  - lib/async/service/supervisor/supervised.rb
145
160
  - lib/async/service/supervisor/supervisor_controller.rb
161
+ - lib/async/service/supervisor/utilization_monitor.rb
146
162
  - lib/async/service/supervisor/version.rb
147
163
  - lib/async/service/supervisor/worker.rb
148
164
  - lib/async/service/supervisor/worker_controller.rb
@@ -162,7 +178,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
162
178
  requirements:
163
179
  - - ">="
164
180
  - !ruby/object:Gem::Version
165
- version: '3.2'
181
+ version: '3.3'
166
182
  required_rubygems_version: !ruby/object:Gem::Requirement
167
183
  requirements:
168
184
  - - ">="
metadata.gz.sig CHANGED
Binary file