async-utilization 0.4.0 → 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: 522f4b5822343c23f14422555147e2159e2bac50afbd230a06e985e2cfe1cee4
4
- data.tar.gz: caa61db8daf6f65a77e319a210ee0079f9a647deed614e7cc5333507967964fa
3
+ metadata.gz: 64e873daf011f6226b574b609cecde9b402edb2a2ff5144f68526195575492ea
4
+ data.tar.gz: b5e25e805f9e8b359af97653bc1496706c9ac81d8c8c2ea42c1bbd1680be271b
5
5
  SHA512:
6
- metadata.gz: ebaabb2e0959e3664e4e2a18812d71efa75d967a2fb01a89f8d63af02eb9e36bc564edfc50c7315b4efbbe8ee5f60e865250cb660daa274fa185e6a538994f6c
7
- data.tar.gz: 7760f55b1383998120a3f7bf0b65b578c8da04bf648c0407f6b3e57040edec2b1487b82356f6d11eaf3bdb02cab9ddc8797eb3c8ee67c930a03d35fec08a5e60
6
+ metadata.gz: e486c1307fead9186ae112f2c258ec4e5ed25b7c6cb8d49d56974634ed2e250400ccb8a57caccb736871df8cd592e5be5379bfd9ac738cf9ace363812e651a90
7
+ data.tar.gz: 4c0380449a03ea7b1ba5c42cc0b70e86cbe76fb360e0bdccaf7f3b5477dccd26d0171921e91f2e535433a236c60c8abb8a97f3dcaf1c6e34b17d7e8c87a72a17
checksums.yaml.gz.sig CHANGED
Binary file
@@ -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.4.0"
10
+ VERSION = "0.5.0"
11
11
  end
12
12
  end
@@ -9,6 +9,7 @@ require_relative "utilization/namespace"
9
9
  require_relative "utilization/registry"
10
10
  require_relative "utilization/observer"
11
11
  require_relative "utilization/metric"
12
+ require_relative "utilization/segment_store"
12
13
 
13
14
  # @namespace
14
15
  module Async
data/readme.md CHANGED
@@ -14,6 +14,10 @@ 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
+
17
21
  ### v0.4.0
18
22
 
19
23
  - Add `Async::Utilization::Namespace` for composing registry metric names.
@@ -39,26 +43,26 @@ Please see the [project releases](https://socketry.github.io/async-utilization/r
39
43
 
40
44
  We welcome contributions to this project.
41
45
 
42
- 1. Fork it.
46
+ 1. Fork the repository.
43
47
  2. Create your feature branch (`git checkout -b my-new-feature`).
44
- 3. Commit your changes (`git commit -am 'Add some feature'`).
48
+ 3. Commit your changes (`git commit -am 'Add some feature.'`).
45
49
  4. Push to the branch (`git push origin my-new-feature`).
46
- 5. Create new Pull Request.
50
+ 5. Create a new pull request.
47
51
 
48
52
  ### Running Tests
49
53
 
50
54
  To run the test suite:
51
55
 
52
- ``` shell
53
- bundle exec sus
56
+ ``` bash
57
+ $ bundle exec sus
54
58
  ```
55
59
 
56
60
  ### Making Releases
57
61
 
58
62
  To make a new release:
59
63
 
60
- ``` shell
61
- bundle exec bake gem:release:patch # or minor or major
64
+ ``` bash
65
+ $ bundle exec bake gem:release:patch # or minor or major
62
66
  ```
63
67
 
64
68
  ### Developer Certificate of Origin
data/releases.md CHANGED
@@ -1,5 +1,9 @@
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
+
3
7
  ## v0.4.0
4
8
 
5
9
  - Add `Async::Utilization::Namespace` for composing registry metric names.
@@ -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.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -62,6 +62,7 @@ files:
62
62
  - lib/async/utilization/observer.rb
63
63
  - lib/async/utilization/registry.rb
64
64
  - lib/async/utilization/schema.rb
65
+ - lib/async/utilization/segment_store.rb
65
66
  - lib/async/utilization/version.rb
66
67
  - license.md
67
68
  - readme.md
@@ -72,10 +73,13 @@ files:
72
73
  - test/async/utilization/observer.rb
73
74
  - test/async/utilization/registry.rb
74
75
  - test/async/utilization/schema.rb
76
+ - test/async/utilization/segment_store.rb
75
77
  homepage: https://github.com/socketry/async-utilization
76
78
  licenses:
77
79
  - MIT
78
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
79
83
  documentation_uri: https://socketry.github.io/async-utilization/
80
84
  source_code_uri: https://github.com/socketry/async-utilization.git
81
85
  rdoc_options: []
@@ -92,7 +96,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
92
96
  - !ruby/object:Gem::Version
93
97
  version: '0'
94
98
  requirements: []
95
- rubygems_version: 4.0.6
99
+ rubygems_version: 4.0.10
96
100
  specification_version: 4
97
101
  summary: High-performance utilization metrics for Async services using shared memory.
98
102
  test_files: []
metadata.gz.sig CHANGED
Binary file