async-utilization 0.3.2 → 0.5.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: 2494a3f35fcbfd987fe6520ceda64225ccc6592ebe4072f0260f38a430d0d3fc
4
- data.tar.gz: '0668665f31e13ac0d00c5eded51ad94f7f394927318e05ef83f7a5cd818e22e3'
3
+ metadata.gz: 64e873daf011f6226b574b609cecde9b402edb2a2ff5144f68526195575492ea
4
+ data.tar.gz: b5e25e805f9e8b359af97653bc1496706c9ac81d8c8c2ea42c1bbd1680be271b
5
5
  SHA512:
6
- metadata.gz: 863148a136e0a8696c337c5c24e18f8751bea2b586f1a703235992103c5454d1621d9296c401ded2d75aa532255beab3ae7ee2ad84211a8eb3b9971aac7b5fc8
7
- data.tar.gz: 4a6e0ee78558c67dd2f8c5e8bb284c0aaed26cb23a3df700b451841a8081dafd9973ff2ef033f7f137cae4e657b60eae29cfeca89a8edd7ca3233f46a7318ce5
6
+ metadata.gz: e486c1307fead9186ae112f2c258ec4e5ed25b7c6cb8d49d56974634ed2e250400ccb8a57caccb736871df8cd592e5be5379bfd9ac738cf9ace363812e651a90
7
+ data.tar.gz: 4c0380449a03ea7b1ba5c42cc0b70e86cbe76fb360e0bdccaf7f3b5477dccd26d0171921e91f2e535433a236c60c8abb8a97f3dcaf1c6e34b17d7e8c87a72a17
checksums.yaml.gz.sig CHANGED
Binary file
@@ -38,11 +38,14 @@ module Async
38
38
  # @attribute [Symbol] The field name for this metric.
39
39
  attr :name
40
40
 
41
- # @attribute [Numeric] The current value of this metric.
42
- attr :value
43
-
44
- # @attribute [Mutex] The mutex for thread safety.
45
- attr :guard
41
+ # Get the current in-memory metric value.
42
+ #
43
+ # @returns [Numeric] The last value written to this metric.
44
+ def value
45
+ @guard.synchronize do
46
+ @value
47
+ end
48
+ end
46
49
 
47
50
  # Set the observer and rebuild cache.
48
51
  #
@@ -0,0 +1,51 @@
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 registry-like view that prefixes metric names.
9
+ #
10
+ # Namespaces let components use generic metric names while applications decide
11
+ # how those names are composed in the shared registry.
12
+ class Namespace
13
+ # Initialize a new namespace.
14
+ #
15
+ # @parameter registry [Registry] The underlying registry.
16
+ # @parameter name [Symbol] The namespace name.
17
+ def initialize(registry, name)
18
+ @registry = registry
19
+ @name = name.to_sym
20
+ end
21
+
22
+ # @attribute [Registry] The underlying registry.
23
+ attr :registry
24
+
25
+ # @attribute [Symbol] The namespace name.
26
+ attr :name
27
+
28
+ # Get a metric in this namespace.
29
+ #
30
+ # @parameter name [Symbol] The metric name.
31
+ # @returns [Metric] A metric instance for the namespaced field.
32
+ def metric(name)
33
+ @registry.metric(metric_name(name))
34
+ end
35
+
36
+ # Get a nested namespace.
37
+ #
38
+ # @parameter name [Symbol] The nested namespace name.
39
+ # @returns [Namespace] A namespace view with the composed name.
40
+ def namespace(name)
41
+ self.class.new(@registry, metric_name(name))
42
+ end
43
+
44
+ private
45
+
46
+ def metric_name(name)
47
+ :"#{@name}_#{name}"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -4,6 +4,7 @@
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
6
  require "console"
7
+ require_relative "namespace"
7
8
 
8
9
  module Async
9
10
  module Utilization
@@ -22,9 +23,11 @@ module Async
22
23
  # @example Create a registry and emit metrics:
23
24
  # registry = Async::Utilization::Registry.new
24
25
  #
25
- # # Emit metrics - values tracked in registry
26
- # registry.increment(:total_requests)
27
- # registry.track(:active_requests) do
26
+ # total_requests = registry.metric(:total_requests)
27
+ # total_requests.increment
28
+ #
29
+ # active_requests = registry.metric(:active_requests)
30
+ # active_requests.track do
28
31
  # # Handle request - auto-decrements when block completes
29
32
  # end
30
33
  #
@@ -37,7 +40,6 @@ module Async
37
40
  # observer = Async::Utilization::Observer.open(schema, "/path/to/shm", 4096, 0)
38
41
  # registry.observer = observer
39
42
  class Registry
40
-
41
43
  # Initialize a new registry.
42
44
  def initialize
43
45
  @observer = nil
@@ -49,15 +51,12 @@ module Async
49
51
  # @attribute [Object | Nil] The registered observer.
50
52
  attr :observer
51
53
 
52
- # @attribute [Mutex] The mutex for thread safety.
53
- attr :guard
54
-
55
54
  # Get the current values for all metrics.
56
55
  #
57
56
  # @returns [Hash] Hash mapping field names to their current values.
58
57
  def values
59
58
  @metrics.transform_values do |metric|
60
- metric.guard.synchronize{metric.value}
59
+ metric.value
61
60
  end
62
61
  end
63
62
 
@@ -79,48 +78,6 @@ module Async
79
78
 
80
79
  # Console.info(self, "Observer assigned", observer: observer, metric_count: @metrics.size)
81
80
  end
82
-
83
- end
84
-
85
- # Set a field value.
86
- #
87
- # Delegates to the metric instance for the given field.
88
- #
89
- # @parameter field [Symbol] The field name to set.
90
- # @parameter value [Numeric] The value to set.
91
- def set(field, value)
92
- metric(field).set(value)
93
- end
94
-
95
- # Increment a field value.
96
- #
97
- # Delegates to the metric instance for the given field.
98
- #
99
- # @parameter field [Symbol] The field name to increment.
100
- # @returns [Integer] The new value of the field.
101
- def increment(field)
102
- metric(field).increment
103
- end
104
-
105
- # Track an operation: increment before the block, decrement after it completes.
106
- #
107
- # Delegates to the metric instance for the given field.
108
- #
109
- # @parameter field [Symbol] The field name to track.
110
- # @yield The operation to track.
111
- # @returns [Object] The block's return value.
112
- def track(field, &block)
113
- metric(field).track(&block)
114
- end
115
-
116
- # Decrement a field value.
117
- #
118
- # Delegates to the metric instance for the given field.
119
- #
120
- # @parameter field [Symbol] The field name to decrement.
121
- # @returns [Integer] The new value of the field.
122
- def decrement(field)
123
- metric(field).decrement
124
81
  end
125
82
 
126
83
  # Get a cached metric reference for a field.
@@ -137,6 +94,14 @@ module Async
137
94
  @metrics[field] ||= Metric.for(field, @observer)
138
95
  end
139
96
  end
97
+
98
+ # Get a namespace view of this registry.
99
+ #
100
+ # @parameter name [Symbol] The namespace name.
101
+ # @returns [Namespace] A registry-like namespace view.
102
+ def namespace(name)
103
+ Namespace.new(self, name)
104
+ end
140
105
  end
141
106
  end
142
107
  end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "console"
7
+
8
+ module Async
9
+ module Utilization
10
+ # Represents a shared memory segment store for utilization data.
11
+ #
12
+ # Stores fixed-size segments in a shared memory file, associates each
13
+ # segment with a utilization schema, and reads the resulting values.
14
+ class SegmentStore
15
+ # Open a shared memory segment store.
16
+ #
17
+ # @parameter path [String] The path to the shared memory file.
18
+ # @parameter size [Integer] The initial size of the shared memory file.
19
+ # @parameter segment_size [Integer] The size of each allocation segment.
20
+ # @parameter growth_factor [Integer | Float] The factor used to grow the file when all segments are allocated.
21
+ # @parameter replace [Boolean] Whether to replace an existing file at the given path.
22
+ # @yields {|store| ...} The store, which is closed after the block completes.
23
+ # @parameter store [SegmentStore] The opened store.
24
+ # @returns [SegmentStore | Object] The store, or the value returned by the block.
25
+ # @raises [ArgumentError] If the store configuration is invalid.
26
+ # @raises [Errno::EEXIST] If the path already exists and `replace` is `false`.
27
+ def self.open(path, size: IO::Buffer::PAGE_SIZE * 8, segment_size: 512, growth_factor: 2, replace: false)
28
+ raise ArgumentError, "Size must be a positive integer!" unless size.is_a?(Integer) && size > 0
29
+ raise ArgumentError, "Segment size must be a positive integer!" unless segment_size.is_a?(Integer) && segment_size > 0
30
+ raise ArgumentError, "Segment size must not exceed size!" if segment_size > size
31
+ raise ArgumentError, "Growth factor must be greater than 1!" unless growth_factor.is_a?(Numeric) && growth_factor.real? && growth_factor > 1
32
+
33
+ if replace
34
+ begin
35
+ File.unlink(path)
36
+ rescue Errno::ENOENT
37
+ # The file does not need to be replaced:
38
+ end
39
+ end
40
+
41
+ file = File.open(path, "w+bx")
42
+ buffer = nil
43
+
44
+ begin
45
+ file.truncate(size)
46
+ buffer = IO::Buffer.map(file, size)
47
+ store = new(file, buffer, size: size, segment_size: segment_size, growth_factor: growth_factor)
48
+ rescue
49
+ buffer&.free
50
+ file.close
51
+ raise
52
+ end
53
+
54
+ return store unless block_given?
55
+
56
+ begin
57
+ yield store
58
+ ensure
59
+ store.close
60
+ end
61
+ end
62
+
63
+ # Initialize the shared memory segment store.
64
+ #
65
+ # @parameter file [File] The open shared memory file.
66
+ # @parameter buffer [IO::Buffer] The mapped shared memory buffer.
67
+ # @parameter size [Integer] The initial size of the shared memory file.
68
+ # @parameter segment_size [Integer] The size of each allocation segment.
69
+ # @parameter growth_factor [Integer | Float] The factor used to grow the file when all segments are allocated.
70
+ def initialize(file, buffer, size:, segment_size:, growth_factor:)
71
+ @file = file
72
+ @buffer = buffer
73
+ @size = size
74
+ @segment_size = segment_size
75
+ @growth_factor = growth_factor
76
+
77
+ @allocations = {}
78
+ @free_list = []
79
+
80
+ (0...(@size / @segment_size)).each do |segment_index|
81
+ @free_list << (segment_index * @segment_size)
82
+ end
83
+ end
84
+
85
+ # Allocate a segment for the given key.
86
+ #
87
+ # The shared memory file is automatically resized if no segments are available.
88
+ #
89
+ # @parameter key [Object] The key used to identify the allocation.
90
+ # @parameter schema [Array] The `[key, type, offset]` tuples describing the data layout.
91
+ # @returns [Integer | Nil] The offset into the shared memory file, or `nil` if allocation fails.
92
+ def allocate(key, schema)
93
+ if @free_list.empty?
94
+ unless resize(@size * @growth_factor)
95
+ return nil
96
+ end
97
+ end
98
+
99
+ offset = @free_list.shift
100
+ @allocations[key] = {offset: offset, schema: schema}
101
+
102
+ return offset
103
+ end
104
+
105
+ # Free the segment allocated to the given key.
106
+ #
107
+ # @parameter key [Object] The key used to identify the allocation.
108
+ def free(key)
109
+ if allocation = @allocations.delete(key)
110
+ @free_list << allocation[:offset]
111
+ end
112
+ end
113
+
114
+ # Get the allocation information for the given key.
115
+ #
116
+ # @parameter key [Object] The key used to identify the allocation.
117
+ # @returns [Hash | Nil] The allocation offset and schema, or `nil` if the key is not allocated.
118
+ def allocation(key)
119
+ @allocations[key]
120
+ end
121
+
122
+ # @attribute [Integer] The current size of the shared memory file.
123
+ attr :size
124
+
125
+ # Update the schema for an existing allocation.
126
+ #
127
+ # @parameter key [Object] The key used to identify the allocation.
128
+ # @parameter schema [Array] The `[key, type, offset]` tuples describing the data layout.
129
+ def update_schema(key, schema)
130
+ if allocation = @allocations[key]
131
+ allocation[:schema] = schema
132
+ end
133
+ end
134
+
135
+ # Read utilization data from an allocated segment.
136
+ #
137
+ # @parameter key [Object] The key used to identify the allocation.
138
+ # @returns [Hash | Nil] The utilization values, or `nil` if the key is not allocated.
139
+ def read(key)
140
+ allocation = @allocations[key]
141
+ return nil unless allocation
142
+
143
+ offset = allocation[:offset]
144
+ schema = allocation[:schema]
145
+
146
+ result = {}
147
+ schema.each do |field_key, type, field_offset|
148
+ absolute_offset = offset + field_offset
149
+
150
+ begin
151
+ result[field_key] = @buffer.get_value(type, absolute_offset)
152
+ rescue => error
153
+ Console.warn(self, "Failed to read value", type: type, key: field_key, offset: absolute_offset, exception: error)
154
+ end
155
+ end
156
+
157
+ return result
158
+ end
159
+
160
+ # Resize the shared memory file.
161
+ #
162
+ # The new size is rounded up to the nearest page boundary.
163
+ #
164
+ # @parameter new_size [Integer] The requested new size of the shared memory file.
165
+ # @returns [Boolean] Whether the file was resized successfully.
166
+ def resize(new_size)
167
+ old_size = @size
168
+ return false if new_size <= old_size
169
+
170
+ page_size = IO::Buffer::PAGE_SIZE
171
+ new_size = (((new_size + page_size - 1) / page_size) * page_size).to_i
172
+
173
+ begin
174
+ @file.truncate(new_size)
175
+ buffer = IO::Buffer.map(@file, new_size)
176
+
177
+ @buffer&.free
178
+ @buffer = buffer
179
+
180
+ old_segment_count = old_size / @segment_size
181
+ new_segment_count = new_size / @segment_size
182
+
183
+ (old_segment_count...new_segment_count).each do |segment_index|
184
+ @free_list << (segment_index * @segment_size)
185
+ end
186
+
187
+ @size = new_size
188
+
189
+ Console.info(self, "Resized shared memory", old_size: old_size, new_size: new_size, segments_added: new_segment_count - old_segment_count)
190
+
191
+ return true
192
+ rescue => error
193
+ Console.error(self, "Failed to resize shared memory", old_size: old_size, new_size: new_size, exception: error)
194
+ return false
195
+ end
196
+ end
197
+
198
+ # Close the shared memory file.
199
+ def close
200
+ @buffer&.free
201
+ @buffer = nil
202
+
203
+ @file&.close
204
+ @file = nil
205
+ end
206
+ end
207
+ end
208
+ end
@@ -7,6 +7,6 @@
7
7
  module Async
8
8
  # @namespace
9
9
  module Utilization
10
- VERSION = "0.3.2"
10
+ VERSION = "0.5.0"
11
11
  end
12
12
  end
@@ -5,9 +5,11 @@
5
5
 
6
6
  require_relative "utilization/version"
7
7
  require_relative "utilization/schema"
8
+ require_relative "utilization/namespace"
8
9
  require_relative "utilization/registry"
9
10
  require_relative "utilization/observer"
10
11
  require_relative "utilization/metric"
12
+ require_relative "utilization/segment_store"
11
13
 
12
14
  # @namespace
13
15
  module Async
data/readme.md CHANGED
@@ -14,6 +14,15 @@ Please see the [project documentation](https://socketry.github.io/async-utilizat
14
14
 
15
15
  Please see the [project releases](https://socketry.github.io/async-utilization/releases/index) for all releases.
16
16
 
17
+ ### v0.5.0
18
+
19
+ - Add `Async::Utilization::SegmentStore` for allocating and reading utilization data in shared memory.
20
+
21
+ ### v0.4.0
22
+
23
+ - Add `Async::Utilization::Namespace` for composing registry metric names.
24
+ - `Async::Utilization::Metric` is the primary interface, remove `#set`, `#increment`, `#decrement` and `#track` from `Registry`.
25
+
17
26
  ### v0.3.2
18
27
 
19
28
  - Better observer state handling.
@@ -34,11 +43,27 @@ Please see the [project releases](https://socketry.github.io/async-utilization/r
34
43
 
35
44
  We welcome contributions to this project.
36
45
 
37
- 1. Fork it.
46
+ 1. Fork the repository.
38
47
  2. Create your feature branch (`git checkout -b my-new-feature`).
39
- 3. Commit your changes (`git commit -am 'Add some feature'`).
48
+ 3. Commit your changes (`git commit -am 'Add some feature.'`).
40
49
  4. Push to the branch (`git push origin my-new-feature`).
41
- 5. Create new Pull Request.
50
+ 5. Create a new pull request.
51
+
52
+ ### Running Tests
53
+
54
+ To run the test suite:
55
+
56
+ ``` bash
57
+ $ bundle exec sus
58
+ ```
59
+
60
+ ### Making Releases
61
+
62
+ To make a new release:
63
+
64
+ ``` bash
65
+ $ bundle exec bake gem:release:patch # or minor or major
66
+ ```
42
67
 
43
68
  ### Developer Certificate of Origin
44
69
 
data/releases.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Releases
2
2
 
3
+ ## v0.5.0
4
+
5
+ - Add `Async::Utilization::SegmentStore` for allocating and reading utilization data in shared memory.
6
+
7
+ ## v0.4.0
8
+
9
+ - Add `Async::Utilization::Namespace` for composing registry metric names.
10
+ - `Async::Utilization::Metric` is the primary interface, remove `#set`, `#increment`, `#decrement` and `#track` from `Registry`.
11
+
3
12
  ## v0.3.2
4
13
 
5
14
  - Better observer state handling.
@@ -0,0 +1,53 @@
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::Namespace do
10
+ let(:registry) {Async::Utilization::Registry.new}
11
+ let(:namespace) {registry.namespace(:socket_accept)}
12
+
13
+ it "uses namespaced metric names" do
14
+ metric = namespace.metric(:acquired_count)
15
+
16
+ expect(metric).to be_a(Async::Utilization::Metric)
17
+ expect(metric.name).to be == :socket_accept_acquired_count
18
+
19
+ metric.set(2)
20
+ expect(registry.values).to have_keys(socket_accept_acquired_count: be == 2)
21
+ end
22
+
23
+ it "returns the same metric instance for the same namespaced field" do
24
+ metric1 = namespace.metric(:waiting_count)
25
+ metric2 = namespace.metric(:waiting_count)
26
+
27
+ expect(metric1).to be == metric2
28
+ end
29
+
30
+ it "supports nested namespaces" do
31
+ metric = namespace.namespace(:long_task).metric(:waiting_count)
32
+
33
+ expect(metric.name).to be == :socket_accept_long_task_waiting_count
34
+
35
+ metric.increment
36
+ expect(registry.values).to have_keys(socket_accept_long_task_waiting_count: be == 1)
37
+ end
38
+
39
+ it "writes namespaced metrics to an observer" do
40
+ schema = Async::Utilization::Schema.build(socket_accept_acquired_count: :u64)
41
+ buffer = IO::Buffer.new(8)
42
+
43
+ observer = Object.new
44
+ observer.define_singleton_method(:schema){schema}
45
+ observer.define_singleton_method(:buffer){buffer}
46
+
47
+ registry.observer = observer
48
+
49
+ namespace.metric(:acquired_count).set(5)
50
+
51
+ expect(buffer.get_value(:u64, 0)).to be == 5
52
+ end
53
+ end
@@ -8,37 +8,38 @@ require "async/utilization"
8
8
 
9
9
  describe Async::Utilization::Registry do
10
10
  let(:registry) {Async::Utilization::Registry.new}
11
+ let(:test_field_metric) {registry.metric(:test_field)}
11
12
 
12
13
  it "can increment a value" do
13
- value = registry.increment(:test_field)
14
+ value = test_field_metric.increment
14
15
  expect(value).to be == 1
15
16
  expect(registry.values[:test_field]).to be == 1
16
17
  end
17
18
 
18
19
  it "can increment multiple times" do
19
- registry.increment(:test_field)
20
- registry.increment(:test_field)
21
- registry.increment(:test_field)
20
+ test_field_metric.increment
21
+ test_field_metric.increment
22
+ test_field_metric.increment
22
23
 
23
24
  expect(registry.values[:test_field]).to be == 3
24
25
  end
25
26
 
26
27
  it "can decrement a value" do
27
- registry.increment(:test_field)
28
- registry.increment(:test_field)
28
+ test_field_metric.increment
29
+ test_field_metric.increment
29
30
 
30
- value = registry.decrement(:test_field)
31
+ value = test_field_metric.decrement
31
32
  expect(value).to be == 1
32
33
  expect(registry.values[:test_field]).to be == 1
33
34
  end
34
35
 
35
36
  it "can set a value directly" do
36
- registry.set(:test_field, 42)
37
+ test_field_metric.set(42)
37
38
  expect(registry.values[:test_field]).to be == 42
38
39
  end
39
40
 
40
41
  it "can track an operation with auto-decrement" do
41
- registry.track(:test_field) do
42
+ test_field_metric.track do
42
43
  expect(registry.values[:test_field]).to be == 1
43
44
  end
44
45
 
@@ -47,7 +48,7 @@ describe Async::Utilization::Registry do
47
48
 
48
49
  it "decrements even if track block raises an error" do
49
50
  begin
50
- registry.track(:test_field) do
51
+ test_field_metric.track do
51
52
  raise "Error!"
52
53
  end
53
54
  rescue
@@ -64,13 +65,13 @@ describe Async::Utilization::Registry do
64
65
  observer.define_singleton_method(:schema){schema}
65
66
  observer.define_singleton_method(:buffer){buffer}
66
67
 
67
- registry.set(:test_field, 5)
68
+ test_field_metric.set(5)
68
69
  registry.observer = observer
69
70
 
70
71
  # Buffer should be synced with the existing value on observer assignment
71
72
  expect(buffer.get_value(:u64, 0)).to be == 5
72
73
 
73
- registry.increment(:test_field)
74
+ test_field_metric.increment
74
75
 
75
76
  # Buffer should reflect the incremented value
76
77
  expect(buffer.get_value(:u64, 0)).to be == 6
@@ -83,6 +84,14 @@ describe Async::Utilization::Registry do
83
84
  expect(registry.values).to have_keys(module_test: be == 2)
84
85
  end
85
86
 
87
+ it "can create a namespace" do
88
+ namespace = registry.namespace(:socket_accept)
89
+
90
+ expect(namespace).to be_a(Async::Utilization::Namespace)
91
+ expect(namespace.registry).to be == registry
92
+ expect(namespace.name).to be == :socket_accept
93
+ end
94
+
86
95
  it "can use metric for decrement" do
87
96
  registry.metric(:module_decrement_test).increment
88
97
  registry.metric(:module_decrement_test).increment
@@ -104,7 +113,7 @@ describe Async::Utilization::Registry do
104
113
  observer.define_singleton_method(:schema){schema}
105
114
  observer.define_singleton_method(:buffer){buffer}
106
115
 
107
- registry.set(:test_field, 7)
116
+ test_field_metric.set(7)
108
117
  registry.observer = observer
109
118
 
110
119
  expect(buffer.get_value(:u64, 0)).to be == 7
@@ -118,11 +127,11 @@ describe Async::Utilization::Registry do
118
127
  observer.define_singleton_method(:buffer){buffer}
119
128
 
120
129
  registry.observer = observer
121
- registry.set(:test_field, 5)
130
+ test_field_metric.set(5)
122
131
  expect(buffer.get_value(:u64, 0)).to be == 5
123
132
 
124
133
  registry.observer = nil
125
- registry.set(:test_field, 99)
134
+ test_field_metric.set(99)
126
135
 
127
136
  # Buffer unchanged, in-memory value updated
128
137
  expect(buffer.get_value(:u64, 0)).to be == 5
@@ -0,0 +1,175 @@
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/null_logger"
8
+ require "sus/fixtures/temporary_directory_context"
9
+ require "async/utilization"
10
+
11
+ describe Async::Utilization::SegmentStore do
12
+ include Sus::Fixtures::Console::NullLogger
13
+ include Sus::Fixtures::TemporaryDirectoryContext
14
+
15
+ let(:path) {File.join(root, "utilization.shm")}
16
+ let(:page_size) {IO::Buffer::PAGE_SIZE}
17
+ let(:schema) do
18
+ Async::Utilization::Schema.build(
19
+ requests_total: :u64,
20
+ requests_active: :u32,
21
+ )
22
+ end
23
+
24
+ it "allocates, reads, and reuses segments" do
25
+ store = subject.open(path, size: page_size, segment_size: page_size)
26
+
27
+ first_offset = store.allocate(:first, [])
28
+ expect(first_offset).to be == 0
29
+ expect(store.allocation(:first)).to have_keys(offset: be == 0, schema: be == [])
30
+
31
+ store.update_schema(:first, schema.to_a)
32
+ observer = Async::Utilization::Observer.open(schema, path, page_size, first_offset)
33
+ observer.buffer.set_value(:u64, 0, 12)
34
+ observer.buffer.set_value(:u32, 8, 3)
35
+
36
+ expect(store.read(:first)).to be == {requests_total: 12, requests_active: 3}
37
+
38
+ store.free(:first)
39
+ expect(store.read(:first)).to be_nil
40
+ expect(store.allocate(:second, schema.to_a)).to be == first_offset
41
+ ensure
42
+ observer&.buffer&.free
43
+ store&.close
44
+ end
45
+
46
+ it "preserves existing observer mappings when resizing" do
47
+ store = subject.open(path, size: page_size, segment_size: page_size)
48
+ first_offset = store.allocate(:first, schema.to_a)
49
+ observer = Async::Utilization::Observer.open(schema, path, page_size, first_offset)
50
+
51
+ observer.buffer.set_value(:u64, 0, 42)
52
+ expect(store.read(:first)[:requests_total]).to be == 42
53
+
54
+ second_offset = store.allocate(:second, schema.to_a)
55
+ expect(second_offset).to be == page_size
56
+ expect(store.size).to be == page_size * 2
57
+
58
+ observer.buffer.set_value(:u64, 0, 99)
59
+ expect(store.read(:first)[:requests_total]).to be == 99
60
+ ensure
61
+ observer&.buffer&.free
62
+ store&.close
63
+ end
64
+
65
+ it "returns nil when automatic resizing fails" do
66
+ store = subject.open(path, size: page_size, segment_size: page_size)
67
+ store.allocate(:first, schema.to_a)
68
+
69
+ expect(store).to receive(:resize).and_return(false)
70
+ expect(store.allocate(:second, schema.to_a)).to be_nil
71
+ ensure
72
+ store&.close
73
+ end
74
+
75
+ it "skips fields that cannot be read" do
76
+ store = subject.open(path, size: page_size, segment_size: page_size)
77
+ store.allocate(:worker, [[:invalid, :invalid, 0]])
78
+
79
+ expect(store.read(:worker)).to be == {}
80
+ ensure
81
+ store&.close
82
+ end
83
+
84
+ it "reports resize failures" do
85
+ store = subject.open(path, size: page_size, segment_size: page_size)
86
+ file = store.instance_variable_get(:@file)
87
+
88
+ expect(file).to receive(:truncate).and_raise(IOError, "Failed to resize")
89
+ expect(store.resize(page_size * 2)).to be_falsey
90
+ ensure
91
+ store&.close
92
+ end
93
+
94
+ it "only replaces an existing file when requested" do
95
+ original = subject.open(path, size: page_size, segment_size: page_size)
96
+ original.resize(page_size * 2)
97
+
98
+ existing_file = File.open(path, "rb")
99
+ original_size = existing_file.size
100
+ original.close
101
+
102
+ expect do
103
+ subject.open(path, size: page_size, segment_size: page_size)
104
+ end.to raise_exception(Errno::EEXIST)
105
+
106
+ replacement = subject.open(path, size: page_size, segment_size: page_size, replace: true)
107
+ expect(replacement.size).to be == page_size
108
+ expect(existing_file.size).to be == original_size
109
+ ensure
110
+ original&.close
111
+ replacement&.close
112
+ existing_file&.close
113
+ end
114
+
115
+ it "validates configuration before replacing an existing file" do
116
+ File.write(path, "existing")
117
+
118
+ [
119
+ [{size: 0}, "Size must be a positive integer!"],
120
+ [{segment_size: 0}, "Segment size must be a positive integer!"],
121
+ [{size: page_size, segment_size: page_size * 2}, "Segment size must not exceed size!"],
122
+ [{growth_factor: 1}, "Growth factor must be greater than 1!"],
123
+ ].each do |options, message|
124
+ expect do
125
+ subject.open(path, replace: true, **options)
126
+ end.to raise_exception(ArgumentError, message: be == message)
127
+
128
+ expect(File.read(path)).to be == "existing"
129
+ end
130
+ end
131
+
132
+ it "closes the store after yielding it" do
133
+ file = nil
134
+
135
+ result = subject.open(path, size: page_size, segment_size: page_size) do |store|
136
+ file = store.instance_variable_get(:@file)
137
+ expect(file.closed?).to be_falsey
138
+ :result
139
+ end
140
+
141
+ expect(result).to be == :result
142
+ expect(file.closed?).to be_truthy
143
+ end
144
+
145
+ it "closes the file when mapping fails" do
146
+ file = File.open(path, "w+bx")
147
+ File.unlink(path)
148
+
149
+ expect(File).to receive(:open).with(path, "w+bx").and_return(file)
150
+ expect(IO::Buffer).to receive(:map).with(file, page_size).and_raise(IOError, "Failed to map")
151
+
152
+ expect do
153
+ subject.open(path, size: page_size, segment_size: page_size)
154
+ end.to raise_exception(IOError, message: be == "Failed to map")
155
+
156
+ expect(file.closed?).to be_truthy
157
+ end
158
+
159
+ it "releases acquired resources when initialization fails" do
160
+ file = File.open(path, "w+bx")
161
+ File.unlink(path)
162
+ buffer = IO::Buffer.new(page_size)
163
+
164
+ expect(File).to receive(:open).with(path, "w+bx").and_return(file)
165
+ expect(IO::Buffer).to receive(:map).with(file, page_size).and_return(buffer)
166
+ expect(subject).to receive(:new).and_raise(IOError, "Failed to initialize")
167
+
168
+ expect do
169
+ subject.open(path, size: page_size, segment_size: page_size)
170
+ end.to raise_exception(IOError, message: be == "Failed to initialize")
171
+
172
+ expect(file.closed?).to be_truthy
173
+ expect(buffer.null?).to be_truthy
174
+ end
175
+ end
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-utilization
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -58,22 +58,28 @@ extra_rdoc_files: []
58
58
  files:
59
59
  - lib/async/utilization.rb
60
60
  - lib/async/utilization/metric.rb
61
+ - lib/async/utilization/namespace.rb
61
62
  - lib/async/utilization/observer.rb
62
63
  - lib/async/utilization/registry.rb
63
64
  - lib/async/utilization/schema.rb
65
+ - lib/async/utilization/segment_store.rb
64
66
  - lib/async/utilization/version.rb
65
67
  - license.md
66
68
  - readme.md
67
69
  - releases.md
68
70
  - test/async/utilization.rb
69
71
  - test/async/utilization/metric.rb
72
+ - test/async/utilization/namespace.rb
70
73
  - test/async/utilization/observer.rb
71
74
  - test/async/utilization/registry.rb
72
75
  - test/async/utilization/schema.rb
76
+ - test/async/utilization/segment_store.rb
73
77
  homepage: https://github.com/socketry/async-utilization
74
78
  licenses:
75
79
  - MIT
76
80
  metadata:
81
+ bug_tracker_uri: https://github.com/socketry/async-utilization/issues
82
+ changelog_uri: https://github.com/socketry/async-utilization/blob/main/releases.md
77
83
  documentation_uri: https://socketry.github.io/async-utilization/
78
84
  source_code_uri: https://github.com/socketry/async-utilization.git
79
85
  rdoc_options: []
@@ -90,7 +96,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
90
96
  - !ruby/object:Gem::Version
91
97
  version: '0'
92
98
  requirements: []
93
- rubygems_version: 4.0.3
99
+ rubygems_version: 4.0.10
94
100
  specification_version: 4
95
101
  summary: High-performance utilization metrics for Async services using shared memory.
96
102
  test_files: []
metadata.gz.sig CHANGED
Binary file