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 +7 -0
- checksums.yaml.gz.sig +0 -0
- data/lib/async/utilization/metric.rb +144 -0
- data/lib/async/utilization/observer.rb +91 -0
- data/lib/async/utilization/registry.rb +135 -0
- data/lib/async/utilization/schema.rb +81 -0
- data/lib/async/utilization/version.rb +12 -0
- data/lib/async/utilization.rb +56 -0
- data/license.md +21 -0
- data/readme.md +37 -0
- data/releases.md +5 -0
- data/test/async/utilization/metric.rb +225 -0
- data/test/async/utilization/observer.rb +108 -0
- data/test/async/utilization/registry.rb +128 -0
- data/test/async/utilization/schema.rb +60 -0
- data/test/async/utilization.rb +13 -0
- data.tar.gz.sig +0 -0
- metadata +110 -0
- metadata.gz.sig +0 -0
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,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
|
+
[](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,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
|