async-utilization 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5ddd4fc21308eeba6b47fbd44df20d4694ee4bd5b78488a44395d0ab5e2da500
4
+ data.tar.gz: 8699da59ebaa0497ca112cf4f1ca65805935d11061b9ed6e1475a56f05a58795
5
+ SHA512:
6
+ metadata.gz: '079cbc3d3a93cda55ebcce48f93c23e5460db7ae8019336b442d0a24ffd783ec6e614cd2d36fc7c937f388cf729ce7d4d6944c5aefae754c4165560a4f353a98'
7
+ data.tar.gz: '049b0ea1ae9d8bc15c12aa69c3d595e32424d1d200003425067f70111f804079fda1f23ef21cc3ed0a0a0d698b36e3b00cbe0cb9ce3803dde197c8e03c8599b1'
checksums.yaml.gz.sig ADDED
Binary file
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Async
7
+ module Utilization
8
+ # A cached metric reference that avoids hash lookups on the fast path.
9
+ #
10
+ # This class caches all the details needed to write directly to shared memory,
11
+ # including the buffer, offset, and type. When the observer changes, the cache
12
+ # is invalidated and rebuilt on the next access.
13
+ class Metric
14
+ # Initialize a new metric.
15
+ #
16
+ # @parameter name [Symbol] The field name for this metric.
17
+ # @parameter registry [Registry] The registry instance to use.
18
+ def initialize(name, registry)
19
+ @name = name.to_sym
20
+ @registry = registry
21
+ @value = 0
22
+ @cache_valid = false
23
+ @cached_field_info = nil
24
+ @cached_buffer = nil
25
+ @guard = Mutex.new
26
+ end
27
+
28
+ # @attribute [Symbol] The field name for this metric.
29
+ attr :name
30
+
31
+ # @attribute [Numeric] The current value of this metric.
32
+ attr :value
33
+
34
+ # @attribute [Mutex] The mutex for thread safety.
35
+ attr :guard
36
+
37
+ # Invalidate the cached field information.
38
+ #
39
+ # Called when the observer changes to force cache rebuild.
40
+ def invalidate
41
+ @cache_valid = false
42
+ @cached_field_info = nil
43
+ @cached_buffer = nil
44
+ end
45
+
46
+ # Increment the metric value, optionally with a block that auto-decrements.
47
+ #
48
+ # Uses the fast path (direct buffer write) when cache is valid and observer is available.
49
+ #
50
+ # @yield Optional block - if provided, decrements the field after the block completes.
51
+ # @returns [Integer] The new value of the field.
52
+ def increment(&block)
53
+ @guard.synchronize do
54
+ @value += 1
55
+ write_direct(@value)
56
+ end
57
+
58
+ if block_given?
59
+ begin
60
+ yield
61
+ ensure
62
+ # Decrement after block completes
63
+ decrement
64
+ end
65
+ end
66
+
67
+ @value
68
+ end
69
+
70
+ # Decrement the metric value.
71
+ #
72
+ # Uses the fast path (direct buffer write) when cache is valid and observer is available.
73
+ #
74
+ # @returns [Integer] The new value of the field.
75
+ def decrement
76
+ @guard.synchronize do
77
+ @value -= 1
78
+ write_direct(@value)
79
+ end
80
+
81
+ @value
82
+ end
83
+
84
+ # Set the metric value.
85
+ #
86
+ # Uses the fast path (direct buffer write) when cache is valid and observer is available.
87
+ #
88
+ # @parameter value [Numeric] The value to set.
89
+ def set(value)
90
+ @guard.synchronize do
91
+ @value = value
92
+ write_direct(@value)
93
+ end
94
+ end
95
+
96
+ protected
97
+
98
+ # Check if the cache is valid and rebuild if necessary.
99
+ #
100
+ # Always attempts to build the cache if it's invalid. Returns true if cache
101
+ # is now valid (observer exists, field is in schema, and buffer is available), false otherwise.
102
+ #
103
+ # @returns [bool] True if cache is valid, false otherwise.
104
+ def ensure_cache_valid!
105
+ unless @cache_valid
106
+ if observer = @registry.observer
107
+ if field = observer.schema[@name]
108
+ if buffer = observer.buffer
109
+ @cached_field_info = field
110
+ @cached_buffer = buffer
111
+ end
112
+ end
113
+ end
114
+
115
+ # Once we've validated the cache, even if there was no observer or buffer, we mark it as valid, so that we don't try to revalidate it again:
116
+ @cache_valid = true
117
+ end
118
+ end
119
+
120
+ # Write directly to the cached buffer if available.
121
+ #
122
+ # This is the fast path that avoids hash lookups. Always ensures cache is valid
123
+ # first. If there's no observer or buffer, silently does nothing.
124
+ #
125
+ # @parameter value [Numeric] The value to write.
126
+ # @returns [Boolean] Whether the write succeeded.
127
+ def write_direct(value)
128
+ self.ensure_cache_valid!
129
+
130
+ if @cached_buffer
131
+ @cached_buffer.set_value(@cached_field_info.type, @cached_field_info.offset, value)
132
+ end
133
+
134
+ return true
135
+ rescue => error
136
+ # If write fails, log warning but don't invalidate cache
137
+ # The error might be transient, and invalidating would force hash lookups
138
+ Console.warn(self, "Failed to write metric value!", metric: {name: @name, value: value}, exception: error)
139
+
140
+ return false
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "console"
7
+ require_relative "schema"
8
+
9
+ module Async
10
+ module Utilization
11
+ # Shared memory observer for utilization metrics.
12
+ #
13
+ # Writes metrics to shared memory using a schema to define the
14
+ # serialization layout. The schema is required to know how to
15
+ # serialize values efficiently.
16
+ class Observer
17
+ # Open a shared memory observer from a file.
18
+ #
19
+ # Maps the shared memory file and creates an Observer instance.
20
+ # The file descriptor is closed after mapping - the memory mapping
21
+ # persists independently on Unix/Linux systems.
22
+ #
23
+ # Note: mmap requires page-aligned offsets and sizes. This method handles
24
+ # non-page-aligned offsets by mapping from the nearest page boundary
25
+ # and adjusting field offsets accordingly.
26
+ #
27
+ # @parameter schema [Schema] The schema defining field types and layout.
28
+ # @parameter path [String] Path to the shared memory file.
29
+ # @parameter size [Integer] Size of the shared memory region to map.
30
+ # @parameter offset [Integer] Offset into the shared memory buffer.
31
+ # @returns [Observer] A new Observer instance.
32
+ def self.open(schema, path, size, offset)
33
+ page_size = IO::Buffer::PAGE_SIZE
34
+
35
+ # Round offset down to nearest page boundary:
36
+ page_aligned_offset = (offset / page_size) * page_size
37
+ offset_adjustment = offset - page_aligned_offset
38
+
39
+ # Calculate how many pages we need to cover the segment:
40
+ segment_end = offset + size
41
+ page_aligned_end = ((segment_end + page_size - 1) / page_size) * page_size
42
+
43
+ # Ensure we map at least one full page:
44
+ map_size = [page_aligned_end - page_aligned_offset, page_size].max
45
+
46
+ buffer = File.open(path, "r+b") do |file|
47
+ mapped_buffer = IO::Buffer.map(file, map_size, page_aligned_offset)
48
+
49
+ # If we had to adjust the offset, create a view into the buffer:
50
+ if offset_adjustment > 0 || map_size > size
51
+ mapped_buffer.slice(offset_adjustment, size)
52
+ else
53
+ mapped_buffer
54
+ end
55
+ end
56
+
57
+ new(schema, buffer)
58
+ end
59
+
60
+ # Initialize a new shared memory observer.
61
+ #
62
+ # @parameter schema [Schema] The schema defining field types and layout.
63
+ # @parameter buffer [IO::Buffer] The mapped buffer for shared memory.
64
+ def initialize(schema, buffer)
65
+ @schema = schema
66
+ @buffer = buffer
67
+ end
68
+
69
+ # @attribute [Schema] The schema used for serialization.
70
+ attr :schema
71
+
72
+ # @attribute [IO::Buffer] The mapped buffer for shared memory.
73
+ attr :buffer
74
+
75
+ # Set a field value.
76
+ #
77
+ # Writes the value to shared memory at the offset defined by the schema.
78
+ # Only fields defined in the schema will be written.
79
+ #
80
+ # @parameter field [Symbol] The field name to set.
81
+ # @parameter value [Numeric] The value to set.
82
+ def set(field, value)
83
+ if field = @schema[field]
84
+ @buffer.set_value(field.type, field.offset, value)
85
+ end
86
+ rescue => error
87
+ Console.warn(self, "Failed to set field in shared memory!", field: field, exception: error)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "thread/local"
7
+
8
+ module Async
9
+ module Utilization
10
+ # Registry for emitting utilization metrics.
11
+ #
12
+ # The registry tracks values directly and notifies a registered observer
13
+ # when values change. The observer (like Observer) can write to its backend.
14
+ #
15
+ # Each thread gets its own instance of the registry, providing
16
+ # thread-local behavior through the thread-local gem.
17
+ #
18
+ # When an observer is added, it is immediately notified of all current values
19
+ # so it can sync its state. When values change, the observer is notified.
20
+ #
21
+ # @example Create a registry and emit metrics:
22
+ # registry = Async::Utilization::Registry.new
23
+ #
24
+ # # Emit metrics - values tracked in registry
25
+ # registry.increment(:total_requests)
26
+ # registry.increment(:active_requests) do
27
+ # # Handle request - auto-decrements when block completes
28
+ # end
29
+ #
30
+ # # Add shared memory observer when supervisor connects
31
+ # # Observer will be notified of all current values automatically
32
+ # schema = Async::Utilization::Schema.build(
33
+ # total_requests: :u64,
34
+ # active_requests: :u32
35
+ # )
36
+ # observer = Async::Utilization::Observer.open(schema, "/path/to/shm", 4096, 0)
37
+ # registry.observer = observer
38
+ class Registry
39
+ extend Thread::Local
40
+
41
+ # Initialize a new registry.
42
+ def initialize
43
+ @observer = nil
44
+ @metrics = {}
45
+
46
+ @guard = Mutex.new
47
+ end
48
+
49
+ # @attribute [Object | Nil] The registered observer.
50
+ attr :observer
51
+
52
+ # @attribute [Mutex] The mutex for thread safety.
53
+ attr :guard
54
+
55
+ # Get the current values for all metrics.
56
+ #
57
+ # @returns [Hash] Hash mapping field names to their current values.
58
+ def values
59
+ @metrics.transform_values do |metric|
60
+ metric.guard.synchronize{metric.value}
61
+ end
62
+ end
63
+
64
+ # Set the observer for the registry.
65
+ #
66
+ # When an observer is set, it is notified of all current metric values
67
+ # so it can sync its state. The observer must implement `set(field, value)`.
68
+ # All cached metrics are invalidated when the observer changes.
69
+ #
70
+ # @parameter observer [#set] The observer to set.
71
+ def observer=(observer)
72
+ @guard.synchronize do
73
+ # Invalidate all cached metrics
74
+ @metrics.each_value do |metric|
75
+ metric.invalidate
76
+ end
77
+
78
+ @observer = observer
79
+ end
80
+
81
+ # Notify observer of all current metric values (outside guard to avoid deadlock)
82
+ @metrics.each do |name, metric|
83
+ value = metric.guard.synchronize{metric.value}
84
+ observer.set(name, value)
85
+ end
86
+ end
87
+
88
+ # Set a field value.
89
+ #
90
+ # Delegates to the metric instance for the given field.
91
+ #
92
+ # @parameter field [Symbol] The field name to set.
93
+ # @parameter value [Numeric] The value to set.
94
+ def set(field, value)
95
+ metric(field).set(value)
96
+ end
97
+
98
+ # Increment a field value, optionally with a block that auto-decrements.
99
+ #
100
+ # Delegates to the metric instance for the given field.
101
+ #
102
+ # @parameter field [Symbol] The field name to increment.
103
+ # @yield Optional block - if provided, decrements the field after the block completes.
104
+ # @returns [Integer] The new value of the field.
105
+ def increment(field, &block)
106
+ metric(field).increment(&block)
107
+ end
108
+
109
+ # Decrement a field value.
110
+ #
111
+ # Delegates to the metric instance for the given field.
112
+ #
113
+ # @parameter field [Symbol] The field name to decrement.
114
+ # @returns [Integer] The new value of the field.
115
+ def decrement(field)
116
+ metric(field).decrement
117
+ end
118
+
119
+ # Get a cached metric reference for a field.
120
+ #
121
+ # Returns a {Metric} instance that caches all details needed for fast writes.
122
+ # Metrics are cached per field and invalidated when the observer changes.
123
+ #
124
+ # @parameter field [Symbol] The field name to get a metric for.
125
+ # @returns [Metric] A metric instance for the given field.
126
+ def metric(field)
127
+ field = field.to_sym
128
+
129
+ @guard.synchronize do
130
+ @metrics[field] ||= Metric.new(field, self)
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Async
7
+ module Utilization
8
+ # Defines a schema for shared memory serialization.
9
+ #
10
+ # The schema defines the layout and types for serializing utilization
11
+ # metrics to shared memory. It's only needed when using Observer for
12
+ # shared memory storage - the Registry itself doesn't require a schema.
13
+ #
14
+ # @example Assign a schema to an observer:
15
+ # schema = Async::Utilization::Schema.build(
16
+ # total_requests: :u64,
17
+ # active_requests: :u32,
18
+ # )
19
+ #
20
+ # registry = Async::Utilization::Registry.new
21
+ # observer = Async::Utilization::Observer.open(schema, "/path/to/shm", 4096, 0)
22
+ # registry.observer = observer
23
+ class Schema
24
+ # Represents a field in the schema with its name, type, and offset.
25
+ Field = Data.define(:name, :type, :offset)
26
+
27
+ # Build a schema from raw fields.
28
+ #
29
+ # Factory method that takes a hash of field names to types and creates
30
+ # Field instances with calculated offsets.
31
+ #
32
+ # @parameter fields [Hash] Hash mapping field names to IO::Buffer type symbols (:u32, :u64, :i32, :i64, :f32, :f64).
33
+ # @returns [Schema] A new schema instance.
34
+ def self.build(fields)
35
+ field_instances = []
36
+ offset = 0
37
+
38
+ fields.each do |key, type|
39
+ field_instances << Field.new(name: key.to_sym, type: type, offset: offset)
40
+ offset += IO::Buffer.size_of(type)
41
+ end
42
+
43
+ new(field_instances)
44
+ end
45
+
46
+ # Initialize a new schema.
47
+ #
48
+ # @parameter fields [Array<Field>] Array of Field instances.
49
+ def initialize(fields)
50
+ @fields = fields.freeze
51
+
52
+ # Build an offsets cache mapping field names to Field objects for fast lookup
53
+ @offsets = {}
54
+ @fields.each do |field|
55
+ @offsets[field.name] = field
56
+ end
57
+ @offsets.freeze
58
+ end
59
+
60
+ # @attribute [Array<Field>] The fields in this schema.
61
+ attr :fields
62
+
63
+ # Get field information for a given field.
64
+ #
65
+ # @parameter field [Symbol] The field name to look up.
66
+ # @returns [Field] Field object containing name, type and offset, or nil if field not found.
67
+ def [](field)
68
+ @offsets[field.to_sym]
69
+ end
70
+
71
+ # Convert schema to array format for shared memory.
72
+ #
73
+ # @returns [Array] Array of [key, type, offset] tuples.
74
+ def to_a
75
+ @fields.map do |field|
76
+ [field.name, field.type, field.offset]
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ # @namespace
7
+ module Async
8
+ # @namespace
9
+ module Utilization
10
+ VERSION = "0.1.0"
11
+ end
12
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "utilization/version"
7
+ require_relative "utilization/schema"
8
+ require_relative "utilization/registry"
9
+ require_relative "utilization/observer"
10
+ require_relative "utilization/metric"
11
+
12
+ # @namespace
13
+ module Async
14
+ # Provides high-performance utilization metrics for Async services using shared memory.
15
+ #
16
+ # This module provides a convenient interface for tracking utilization metrics
17
+ # that can be synchronized to shared memory for inter-process communication.
18
+ # Each thread gets its own instance of the underlying {Registry}, providing
19
+ # thread-local behavior.
20
+ #
21
+ # See the {file:guides/getting-started/readme.md Getting Started} guide for usage examples.
22
+ module Utilization
23
+ # Set the observer for utilization metrics.
24
+ #
25
+ # When an observer is set, it is notified of all current metric values
26
+ # so it can sync its state. The observer must implement `set(field, value)`.
27
+ #
28
+ # Delegates to the thread-local {Registry} instance.
29
+ #
30
+ # @parameter observer [#set] The observer to set.
31
+ def self.observer=(observer)
32
+ Registry.instance.observer = observer
33
+ end
34
+
35
+ # Get a cached metric reference for a field.
36
+ #
37
+ # Returns a {Metric} instance that caches all details needed for fast writes
38
+ # to shared memory, avoiding hash lookups on the fast path.
39
+ #
40
+ # This is the recommended way to access metrics for optimal performance.
41
+ #
42
+ # Delegates to the thread-local {Registry} instance.
43
+ #
44
+ # @parameter field [Symbol] The field name to get a metric for.
45
+ # @returns [Metric] A metric instance for the given field.
46
+ # @example Get a metric and increment it:
47
+ # current_requests = Async::Utilization.metric(:current_requests)
48
+ # current_requests.increment
49
+ # current_requests.increment do
50
+ # # Handle request - auto-decrements when block completes
51
+ # end
52
+ def self.metric(field)
53
+ Registry.instance.metric(field)
54
+ end
55
+ end
56
+ end
data/license.md ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright, 2026, by Samuel Williams.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/readme.md ADDED
@@ -0,0 +1,37 @@
1
+ # Async::Utilization
2
+
3
+ High-performance utilization metrics for Async services using shared memory.
4
+
5
+ [![Development Status](https://github.com/socketry/async-utilization/workflows/Test/badge.svg)](https://github.com/socketry/async-utilization/actions?workflow=Test)
6
+
7
+ ## Usage
8
+
9
+ Please see the [project documentation](https://socketry.github.io/async-utilization/) for more details.
10
+
11
+ - [Getting Started](https://socketry.github.io/async-utilization/guides/getting-started/index) - This guide explains how to get started with `async-utilization` to track high-performance utilization metrics for Async services using shared memory.
12
+
13
+ ## Releases
14
+
15
+ Please see the [project releases](https://socketry.github.io/async-utilization/releases/index) for all releases.
16
+
17
+ ### v0.1.0
18
+
19
+ - Initial implementation.
20
+
21
+ ## Contributing
22
+
23
+ We welcome contributions to this project.
24
+
25
+ 1. Fork it.
26
+ 2. Create your feature branch (`git checkout -b my-new-feature`).
27
+ 3. Commit your changes (`git commit -am 'Add some feature'`).
28
+ 4. Push to the branch (`git push origin my-new-feature`).
29
+ 5. Create new Pull Request.
30
+
31
+ ### Developer Certificate of Origin
32
+
33
+ In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed.
34
+
35
+ ### Community Guidelines
36
+
37
+ This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers.
data/releases.md ADDED
@@ -0,0 +1,5 @@
1
+ # Releases
2
+
3
+ ## v0.1.0
4
+
5
+ - Initial implementation.
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "sus"
7
+ require "sus/fixtures/console/captured_logger"
8
+ require "sus/fixtures/temporary_directory_context"
9
+ require "async/utilization"
10
+
11
+ describe Async::Utilization::Metric do
12
+ include Sus::Fixtures::Console::CapturedLogger
13
+ include Sus::Fixtures::TemporaryDirectoryContext
14
+
15
+ let(:shm_path) {File.join(root, "test.shm")}
16
+ let(:schema) do
17
+ Async::Utilization::Schema.build(
18
+ total_requests: :u64,
19
+ active_requests: :u32
20
+ )
21
+ end
22
+
23
+ let(:page_size) {IO::Buffer::PAGE_SIZE}
24
+ let(:segment_size) {512}
25
+ let(:file_size) {[segment_size, page_size].max}
26
+ let(:offset) {0}
27
+
28
+ let(:observer) do
29
+ Async::Utilization::Observer.open(schema, shm_path, segment_size, offset)
30
+ end
31
+
32
+ before do
33
+ File.open(shm_path, "w+b") do |file|
34
+ file.truncate(file_size)
35
+ end
36
+
37
+ # Reset the registry to ensure clean state between tests
38
+ registry = Async::Utilization::Registry.instance
39
+ registry.instance_variable_set(:@values, Hash.new(0))
40
+ registry.instance_variable_set(:@metrics, {})
41
+ registry.instance_variable_set(:@observer, nil)
42
+ end
43
+
44
+ it "can create a metric from a field name" do
45
+ metric = Async::Utilization.metric(:total_requests)
46
+
47
+ expect(metric).to be_a(Async::Utilization::Metric)
48
+ expect(metric.name).to be == :total_requests
49
+ end
50
+
51
+ it "can increment a metric" do
52
+ Async::Utilization.observer = observer
53
+ metric = Async::Utilization.metric(:total_requests)
54
+
55
+ value = metric.increment
56
+ expect(value).to be == 1
57
+ expect(metric.value).to be == 1
58
+
59
+ value = metric.increment
60
+ expect(value).to be == 2
61
+ expect(metric.value).to be == 2
62
+ end
63
+
64
+ it "can decrement a metric" do
65
+ Async::Utilization.observer = observer
66
+ metric = Async::Utilization.metric(:total_requests)
67
+
68
+ metric.increment
69
+ metric.increment
70
+
71
+ value = metric.decrement
72
+ expect(value).to be == 1
73
+ expect(metric.value).to be == 1
74
+ end
75
+
76
+ it "can set a metric value" do
77
+ Async::Utilization.observer = observer
78
+ metric = Async::Utilization.metric(:total_requests)
79
+
80
+ metric.set(42)
81
+ expect(metric.value).to be == 42
82
+
83
+ metric.set(100)
84
+ expect(metric.value).to be == 100
85
+ end
86
+
87
+ it "can increment with auto-decrement block" do
88
+ Async::Utilization.observer = observer
89
+ metric = Async::Utilization.metric(:active_requests)
90
+
91
+ metric.increment do
92
+ expect(metric.value).to be == 1
93
+ end
94
+
95
+ expect(metric.value).to be == 0
96
+ end
97
+
98
+ it "decrements even if block raises an error" do
99
+ Async::Utilization.observer = observer
100
+ metric = Async::Utilization.metric(:active_requests)
101
+
102
+ begin
103
+ metric.increment do
104
+ raise "Test error"
105
+ end
106
+ rescue => error
107
+ expect(error.message).to be == "Test error"
108
+ end
109
+
110
+ expect(metric.value).to be == 0
111
+ end
112
+
113
+ it "writes directly to shared memory when observer is set" do
114
+ Async::Utilization.observer = observer
115
+ metric = Async::Utilization.metric(:total_requests)
116
+
117
+ metric.set(42)
118
+
119
+ # Read back from file to verify
120
+ buffer = IO::Buffer.map(File.open(shm_path, "r+b"), file_size, 0)
121
+ expect(buffer.get_value(:u64, 0)).to be == 42
122
+ end
123
+
124
+ it "invalidates cache when observer changes" do
125
+ Async::Utilization.observer = observer
126
+ metric = Async::Utilization.metric(:total_requests)
127
+
128
+ # Set a value - cache should be built
129
+ metric.set(10)
130
+ expect(metric.value).to be == 10
131
+
132
+ # Create a new observer with different schema
133
+ new_schema = Async::Utilization::Schema.build(
134
+ total_requests: :u64,
135
+ active_requests: :u32
136
+ )
137
+ new_shm_path = File.join(root, "test2.shm")
138
+ File.open(new_shm_path, "w+b"){|f| f.truncate(file_size)}
139
+ new_observer = Async::Utilization::Observer.open(new_schema, new_shm_path, segment_size, 0)
140
+
141
+ # Change observer - cache should be invalidated
142
+ Async::Utilization.observer = new_observer
143
+
144
+ # Set a new value - cache should be rebuilt
145
+ metric.set(20)
146
+ expect(metric.value).to be == 20
147
+
148
+ # Verify it was written to the new shared memory file
149
+ buffer = IO::Buffer.map(File.open(new_shm_path, "r+b"), file_size, 0)
150
+ expect(buffer.get_value(:u64, 0)).to be == 20
151
+ end
152
+
153
+ it "works without an observer" do
154
+ metric = Async::Utilization.metric(:total_requests)
155
+
156
+ # Should work fine without observer (uses fallback path)
157
+ metric.increment
158
+ expect(metric.value).to be == 1
159
+
160
+ metric.set(5)
161
+ expect(metric.value).to be == 5
162
+
163
+ # Set observer and verify it works with fast path
164
+ Async::Utilization.observer = observer
165
+ metric.set(10)
166
+ expect(metric.value).to be == 10
167
+ end
168
+
169
+ it "returns the same metric instance for the same field" do
170
+ metric1 = Async::Utilization.metric(:total_requests)
171
+ metric2 = Async::Utilization.metric(:total_requests)
172
+
173
+ expect(metric1).to be == metric2
174
+ end
175
+
176
+ it "falls back to observer.set when write_direct fails" do
177
+ Async::Utilization.observer = observer
178
+ metric = Async::Utilization.metric(:total_requests)
179
+
180
+ # Force cache to be invalid by invalidating it
181
+ metric.invalidate
182
+
183
+ # Set a value - should fall back to observer.set
184
+ metric.set(42)
185
+ expect(metric.value).to be == 42
186
+
187
+ # Verify it was written to shared memory
188
+ buffer = IO::Buffer.map(File.open(shm_path, "r+b"), file_size, 0)
189
+ expect(buffer.get_value(:u64, 0)).to be == 42
190
+ end
191
+
192
+ it "handles write errors gracefully" do
193
+ Async::Utilization.observer = observer
194
+ metric = Async::Utilization.metric(:total_requests)
195
+
196
+ # Set a value first to build the cache
197
+ metric.set(10)
198
+
199
+ # Verify cache is built
200
+ expect(metric.instance_variable_get(:@cache_valid)).to be == true
201
+ cached_buffer = metric.instance_variable_get(:@cached_buffer)
202
+
203
+ # Create an invalid buffer that will raise an error
204
+ invalid_buffer = Object.new
205
+ def invalid_buffer.set_value(type, offset, value)
206
+ raise IOError, "Buffer error"
207
+ end
208
+
209
+ metric.instance_variable_set(:@cached_buffer, invalid_buffer)
210
+
211
+ # Should not raise, but log warning and keep cache valid
212
+ metric.set(42)
213
+ expect(metric.value).to be == 42
214
+
215
+ # Cache should remain valid (not invalidated on error)
216
+ expect(metric.instance_variable_get(:@cache_valid)).to be == true
217
+
218
+ # Assert that a warning was logged
219
+ expect_console.to have_logged(
220
+ severity: be == :warn,
221
+ subject: be_a(Async::Utilization::Metric),
222
+ message: be == "Failed to write metric value!"
223
+ )
224
+ end
225
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "sus"
7
+ require "sus/fixtures/console/captured_logger"
8
+ require "sus/fixtures/temporary_directory_context"
9
+ require "async/utilization"
10
+ require "fileutils"
11
+
12
+ describe Async::Utilization::Observer do
13
+ include Sus::Fixtures::Console::CapturedLogger
14
+ include Sus::Fixtures::TemporaryDirectoryContext
15
+
16
+ let(:shm_path) {File.join(root, "test.shm")}
17
+ let(:schema) do
18
+ Async::Utilization::Schema.build(
19
+ total_requests: :u64,
20
+ active_requests: :u32
21
+ )
22
+ end
23
+
24
+ let(:page_size) {IO::Buffer::PAGE_SIZE}
25
+ let(:segment_size) {512}
26
+ let(:file_size) {[segment_size, page_size].max}
27
+ let(:offset) {0}
28
+
29
+ let(:observer) do
30
+ Async::Utilization::Observer.open(schema, shm_path, segment_size, offset)
31
+ end
32
+
33
+ before do
34
+ File.open(shm_path, "w+b") do |file|
35
+ file.truncate(file_size)
36
+ end
37
+ end
38
+
39
+ it "can create an observer from a file" do
40
+ expect(observer).to be_a(Async::Utilization::Observer)
41
+ expect(observer.schema).to be == schema
42
+ end
43
+
44
+ it "can write values to shared memory" do
45
+ observer.set(:total_requests, 42)
46
+ observer.set(:active_requests, 5)
47
+
48
+ # Read back from file to verify
49
+ buffer = IO::Buffer.map(File.open(shm_path, "r+b"), file_size, 0)
50
+ expect(buffer.get_value(:u64, 0)).to be == 42
51
+ expect(buffer.get_value(:u32, 8)).to be == 5
52
+ end
53
+
54
+ with "non-page-aligned offsets" do
55
+ let(:file_size) {IO::Buffer::PAGE_SIZE * 2}
56
+ let(:offset) {100} # Not page-aligned
57
+
58
+ it "handles non-page-aligned offsets" do
59
+ observer.set(:total_requests, 100)
60
+ observer.set(:active_requests, 20)
61
+
62
+ # Read back from file at the correct offset
63
+ buffer = IO::Buffer.map(File.open(shm_path, "r+b"), file_size, 0)
64
+ expect(buffer.get_value(:u64, offset)).to be == 100
65
+ expect(buffer.get_value(:u32, offset + 8)).to be == 20
66
+ end
67
+ end
68
+
69
+ it "ignores fields not in schema" do
70
+ # Should not raise an error
71
+ observer.set(:unknown_field, 999)
72
+
73
+ # Verify nothing was written
74
+ buffer = IO::Buffer.map(File.open(shm_path, "r+b"), file_size, 0)
75
+ expect(buffer.get_value(:u64, 0)).to be == 0
76
+ end
77
+
78
+ with "page-aligned offsets" do
79
+ let(:file_size) {page_size * 2}
80
+ let(:segment_size) {page_size}
81
+
82
+ it "handles page-aligned offsets without slicing" do
83
+ expect(observer).to be_a(Async::Utilization::Observer)
84
+ observer.set(:total_requests, 123)
85
+
86
+ # Verify value was written
87
+ buffer = IO::Buffer.map(File.open(shm_path, "r+b"), file_size, 0)
88
+ expect(buffer.get_value(:u64, 0)).to be == 123
89
+ end
90
+ end
91
+
92
+ it "handles errors gracefully when setting values" do
93
+ # Create an invalid buffer that will cause an error
94
+ # We'll mock the buffer to raise an error
95
+ buffer = observer.instance_variable_get(:@buffer)
96
+ expect(buffer).to receive(:set_value).and_raise(IOError, "Buffer error")
97
+
98
+ # Should not raise, but log a warning
99
+ observer.set(:total_requests, 42)
100
+
101
+ # Assert that a warning was logged
102
+ expect_console.to have_logged(
103
+ severity: be == :warn,
104
+ subject: be_a(Async::Utilization::Observer),
105
+ message: be == "Failed to set field in shared memory!"
106
+ )
107
+ end
108
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "sus"
7
+ require "async/utilization"
8
+
9
+ describe Async::Utilization::Registry do
10
+ let(:registry) {Async::Utilization::Registry.new}
11
+
12
+ it "can increment a value" do
13
+ value = registry.increment(:test_field)
14
+ expect(value).to be == 1
15
+ expect(registry.values[:test_field]).to be == 1
16
+ end
17
+
18
+ it "can increment multiple times" do
19
+ registry.increment(:test_field)
20
+ registry.increment(:test_field)
21
+ registry.increment(:test_field)
22
+
23
+ expect(registry.values[:test_field]).to be == 3
24
+ end
25
+
26
+ it "can decrement a value" do
27
+ registry.increment(:test_field)
28
+ registry.increment(:test_field)
29
+
30
+ value = registry.decrement(:test_field)
31
+ expect(value).to be == 1
32
+ expect(registry.values[:test_field]).to be == 1
33
+ end
34
+
35
+ it "can set a value directly" do
36
+ registry.set(:test_field, 42)
37
+ expect(registry.values[:test_field]).to be == 42
38
+ end
39
+
40
+ it "can auto-decrement with a block" do
41
+ registry.increment(:test_field) do
42
+ expect(registry.values[:test_field]).to be == 1
43
+ end
44
+
45
+ expect(registry.values[:test_field]).to be == 0
46
+ end
47
+
48
+ it "decrements even if block raises an error" do
49
+ begin
50
+ registry.increment(:test_field) do
51
+ raise "Error!"
52
+ end
53
+ rescue
54
+ end
55
+
56
+ expect(registry.values[:test_field]).to be == 0
57
+ end
58
+
59
+ it "can set an observer" do
60
+ observer = Object.new
61
+ def observer.set(field, value); end
62
+
63
+ registry.set(:test_field, 10)
64
+ registry.observer = observer
65
+
66
+ expect(registry.observer).to be == observer
67
+ end
68
+
69
+ it "notifies observer when values change" do
70
+ values_set = []
71
+
72
+ # Create a proper observer with schema
73
+ schema = Async::Utilization::Schema.build(test_field: :u64)
74
+ observer = Object.new
75
+
76
+ # Define methods on observer
77
+ observer.define_singleton_method(:set) do |field, value|
78
+ values_set << [field, value]
79
+ end
80
+ observer.define_singleton_method(:schema){schema}
81
+ observer.define_singleton_method(:buffer){nil} # No buffer, so write_direct will return false
82
+
83
+ registry.set(:test_field, 5)
84
+ registry.observer = observer
85
+
86
+ # Observer should be notified of existing values
87
+ expect(values_set).to be(:include?, [:test_field, 5])
88
+
89
+ # Clear and test new changes
90
+ # Note: Since observer has no buffer, write_direct will return false
91
+ # and no notification will occur (as per new design)
92
+ values_set.clear
93
+ registry.increment(:test_field)
94
+
95
+ # With no buffer, write_direct fails silently, so no notification
96
+ expect(values_set).to be == []
97
+ end
98
+
99
+ it "uses metric method for fast path" do
100
+ registry.metric(:module_test).increment
101
+ registry.metric(:module_test).increment
102
+
103
+ expect(registry.values).to have_keys(module_test: be == 2)
104
+ end
105
+
106
+ it "can use metric for decrement" do
107
+ registry.metric(:module_decrement_test).increment
108
+ registry.metric(:module_decrement_test).increment
109
+ registry.metric(:module_decrement_test).decrement
110
+
111
+ expect(registry.values).to have_keys(module_decrement_test: be == 1)
112
+ end
113
+
114
+ it "can use metric for set" do
115
+ registry.metric(:module_set_test).set(99)
116
+
117
+ expect(registry.values).to have_keys(module_set_test: be == 99)
118
+ end
119
+
120
+ it "can set observer" do
121
+ observer = Object.new
122
+ def observer.set(field, value); end
123
+
124
+ registry.observer = observer
125
+
126
+ expect(registry.observer).to be == observer
127
+ end
128
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "sus"
7
+ require "async/utilization"
8
+
9
+ describe Async::Utilization::Schema do
10
+ it "can build a schema from fields" do
11
+ schema = Async::Utilization::Schema.build(
12
+ total_requests: :u64,
13
+ active_requests: :u32
14
+ )
15
+
16
+ expect(schema).to be_a(Async::Utilization::Schema)
17
+ expect(schema.fields.size).to be == 2
18
+ end
19
+
20
+ it "calculates offsets correctly" do
21
+ schema = Async::Utilization::Schema.build(
22
+ total_requests: :u64,
23
+ active_requests: :u32
24
+ )
25
+
26
+ field1 = schema[:total_requests]
27
+ field2 = schema[:active_requests]
28
+
29
+ expect(field1).to be_a(Async::Utilization::Schema::Field)
30
+ expect(field1.name).to be == :total_requests
31
+ expect(field1.type).to be == :u64
32
+ expect(field1.offset).to be == 0
33
+
34
+ expect(field2).to be_a(Async::Utilization::Schema::Field)
35
+ expect(field2.name).to be == :active_requests
36
+ expect(field2.type).to be == :u32
37
+ expect(field2.offset).to be == 8 # u64 is 8 bytes
38
+ end
39
+
40
+ it "can convert to array format" do
41
+ schema = Async::Utilization::Schema.build(
42
+ total_requests: :u64,
43
+ active_requests: :u32
44
+ )
45
+
46
+ array = schema.to_a
47
+ expect(array).to be == [
48
+ [:total_requests, :u64, 0],
49
+ [:active_requests, :u32, 8]
50
+ ]
51
+ end
52
+
53
+ it "returns nil for unknown fields" do
54
+ schema = Async::Utilization::Schema.build(
55
+ total_requests: :u64
56
+ )
57
+
58
+ expect(schema[:unknown_field]).to be_nil
59
+ end
60
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "sus"
7
+ require "async/utilization"
8
+
9
+ describe Async::Utilization do
10
+ it "has a version number" do
11
+ expect(Async::Utilization::VERSION).to be =~ /\d+\.\d+\.\d+/
12
+ end
13
+ end
data.tar.gz.sig ADDED
Binary file
metadata ADDED
@@ -0,0 +1,110 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: async-utilization
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Samuel Williams
8
+ bindir: bin
9
+ cert_chain:
10
+ - |
11
+ -----BEGIN CERTIFICATE-----
12
+ MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11
13
+ ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK
14
+ CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz
15
+ MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd
16
+ MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj
17
+ bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB
18
+ igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2
19
+ 9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW
20
+ sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE
21
+ e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN
22
+ XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss
23
+ RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn
24
+ tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM
25
+ zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW
26
+ xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O
27
+ BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs
28
+ aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs
29
+ aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE
30
+ cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl
31
+ xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/
32
+ c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp
33
+ 8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws
34
+ JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP
35
+ eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt
36
+ Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8
37
+ voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg=
38
+ -----END CERTIFICATE-----
39
+ date: 1980-01-02 00:00:00.000000000 Z
40
+ dependencies:
41
+ - !ruby/object:Gem::Dependency
42
+ name: console
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: thread-local
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.0'
69
+ executables: []
70
+ extensions: []
71
+ extra_rdoc_files: []
72
+ files:
73
+ - lib/async/utilization.rb
74
+ - lib/async/utilization/metric.rb
75
+ - lib/async/utilization/observer.rb
76
+ - lib/async/utilization/registry.rb
77
+ - lib/async/utilization/schema.rb
78
+ - lib/async/utilization/version.rb
79
+ - license.md
80
+ - readme.md
81
+ - releases.md
82
+ - test/async/utilization.rb
83
+ - test/async/utilization/metric.rb
84
+ - test/async/utilization/observer.rb
85
+ - test/async/utilization/registry.rb
86
+ - test/async/utilization/schema.rb
87
+ homepage: https://github.com/socketry/async-utilization
88
+ licenses:
89
+ - MIT
90
+ metadata:
91
+ documentation_uri: https://socketry.github.io/async-utilization/
92
+ source_code_uri: https://github.com/socketry/async-utilization.git
93
+ rdoc_options: []
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '3.2'
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ requirements: []
107
+ rubygems_version: 4.0.3
108
+ specification_version: 4
109
+ summary: High-performance utilization metrics for Async services using shared memory.
110
+ test_files: []
metadata.gz.sig ADDED
Binary file