From eff3f59e17da64417389c093fe81ee0d7798bf16 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 21 Aug 2026 09:52:19 +1200 Subject: [PATCH 1/6] Add shared memory segment allocator Signed-off-by: Samuel Williams --- guides/getting-started/readme.md | 23 +++ lib/async/utilization.rb | 1 + lib/async/utilization/segment_allocator.rb | 170 ++++++++++++++++++++ releases.md | 4 + test/async/utilization/segment_allocator.rb | 85 ++++++++++ 5 files changed, 283 insertions(+) create mode 100644 lib/async/utilization/segment_allocator.rb create mode 100644 test/async/utilization/segment_allocator.rb diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 37b0af7..331d008 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -19,6 +19,7 @@ The key components are: - {ruby Async::Utilization::Registry}: Holds your metrics and optional observer; create one explicitly and pass it to the code that records utilization. - {ruby Async::Utilization::Schema}: Defines the binary layout for serialization. - {ruby Async::Utilization::Observer}: Writes metrics to shared memory using the schema. +- {ruby Async::Utilization::SegmentAllocator}: Allocates shared memory regions and reads their utilization values. - {ruby Async::Utilization::Metric}: The handle you call `increment`, `set`, `track`, etc. on; obtained from the registry. ## Basic Usage @@ -107,3 +108,25 @@ total_requests.increment ``` The observer automatically handles page alignment requirements for memory mapping, so you can use any segment size and offset. The supervisor process can then read these metrics from shared memory to aggregate utilization across all workers. + +## Allocating Shared Memory Segments + +Use {ruby Async::Utilization::SegmentAllocator} when one process coordinates shared memory regions for multiple observers: + +```ruby +path = "/path/to/shared_memory.shm" +allocator = Async::Utilization::SegmentAllocator.new( + path, + segment_size: 512, + replace: true, +) + +offset = allocator.allocate(:worker, schema.to_a) +observer = Async::Utilization::Observer.open(schema, path, 512, offset) + +# After the observer writes metrics: +allocator.read(:worker) +# => {total_requests: 1, active_requests: 0} +``` + +The allocator grows the shared memory file automatically when all segments are in use. Pass `replace: true` only when the coordinating process should replace an existing file, such as when a supervisor restarts. diff --git a/lib/async/utilization.rb b/lib/async/utilization.rb index 5f1569c..ccb78e1 100644 --- a/lib/async/utilization.rb +++ b/lib/async/utilization.rb @@ -9,6 +9,7 @@ require_relative "utilization/registry" require_relative "utilization/observer" require_relative "utilization/metric" +require_relative "utilization/segment_allocator" # @namespace module Async diff --git a/lib/async/utilization/segment_allocator.rb b/lib/async/utilization/segment_allocator.rb new file mode 100644 index 0000000..c2364c6 --- /dev/null +++ b/lib/async/utilization/segment_allocator.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "console" + +module Async + module Utilization + # Represents a shared memory segment allocator for utilization data. + # + # Allocates fixed-size segments from a shared memory file, associates each + # segment with a utilization schema, and reads the resulting values. + class SegmentAllocator + # Initialize the shared memory segment allocator. + # + # @parameter path [String] The path to the shared memory file. + # @parameter size [Integer] The initial size of the shared memory file. + # @parameter segment_size [Integer] The size of each allocation segment. + # @parameter growth_factor [Integer | Float] The factor used to grow the file when all segments are allocated. + # @parameter replace [Boolean] Whether to replace an existing file at the given path. + def initialize(path, size: IO::Buffer::PAGE_SIZE * 8, segment_size: 512, growth_factor: 2, replace: false) + @size = size + @segment_size = segment_size + @growth_factor = growth_factor + + if replace + begin + File.unlink(path) + rescue Errno::ENOENT + # The file does not need to be replaced: + end + end + + @file = File.open(path, "w+bx") + @file.truncate(size) + @buffer = IO::Buffer.map(@file, size) + + @allocations = {} + @free_list = [] + + (0...(@size / @segment_size)).each do |segment_index| + @free_list << (segment_index * @segment_size) + end + end + + # Allocate a segment for the given key. + # + # The shared memory file is automatically resized if no segments are available. + # + # @parameter key [Object] The key used to identify the allocation. + # @parameter schema [Array] The `[key, type, offset]` tuples describing the data layout. + # @returns [Integer | Nil] The offset into the shared memory file, or `nil` if allocation fails. + def allocate(key, schema) + if @free_list.empty? + unless resize(@size * @growth_factor) + return nil + end + end + + offset = @free_list.shift + @allocations[key] = {offset: offset, schema: schema} + + return offset + end + + # Free the segment allocated to the given key. + # + # @parameter key [Object] The key used to identify the allocation. + def free(key) + if allocation = @allocations.delete(key) + @free_list << allocation[:offset] + end + end + + # Get the allocation information for the given key. + # + # @parameter key [Object] The key used to identify the allocation. + # @returns [Hash | Nil] The allocation offset and schema, or `nil` if the key is not allocated. + def allocation(key) + @allocations[key] + end + + # @attribute [Integer] The current size of the shared memory file. + attr :size + + # Update the schema for an existing allocation. + # + # @parameter key [Object] The key used to identify the allocation. + # @parameter schema [Array] The `[key, type, offset]` tuples describing the data layout. + def update_schema(key, schema) + if allocation = @allocations[key] + allocation[:schema] = schema + end + end + + # Read utilization data from an allocated segment. + # + # @parameter key [Object] The key used to identify the allocation. + # @returns [Hash | Nil] The utilization values, or `nil` if the key is not allocated. + def read(key) + allocation = @allocations[key] + return nil unless allocation + + offset = allocation[:offset] + schema = allocation[:schema] + + result = {} + schema.each do |field_key, type, field_offset| + absolute_offset = offset + field_offset + + begin + result[field_key] = @buffer.get_value(type, absolute_offset) + rescue => error + Console.warn(self, "Failed to read value", type: type, key: field_key, offset: absolute_offset, exception: error) + end + end + + return result + end + + # Resize the shared memory file. + # + # The new size is rounded up to the nearest page boundary. + # + # @parameter new_size [Integer] The requested new size of the shared memory file. + # @returns [Boolean] Whether the file was resized successfully. + def resize(new_size) + old_size = @size + return false if new_size <= old_size + + page_size = IO::Buffer::PAGE_SIZE + new_size = (((new_size + page_size - 1) / page_size) * page_size).to_i + + begin + @file.truncate(new_size) + buffer = IO::Buffer.map(@file, new_size) + + @buffer&.free + @buffer = buffer + + old_segment_count = old_size / @segment_size + new_segment_count = new_size / @segment_size + + (old_segment_count...new_segment_count).each do |segment_index| + @free_list << (segment_index * @segment_size) + end + + @size = new_size + + Console.info(self, "Resized shared memory", old_size: old_size, new_size: new_size, segments_added: new_segment_count - old_segment_count) + + return true + rescue => error + Console.error(self, "Failed to resize shared memory", old_size: old_size, new_size: new_size, exception: error) + return false + end + end + + # Close the shared memory file. + def close + @buffer&.free + @buffer = nil + + @file&.close + @file = nil + end + end + end +end diff --git a/releases.md b/releases.md index 0e957d6..dca2bb3 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Add `Async::Utilization::SegmentAllocator` for allocating and reading utilization data in shared memory. + ## v0.4.0 - Add `Async::Utilization::Namespace` for composing registry metric names. diff --git a/test/async/utilization/segment_allocator.rb b/test/async/utilization/segment_allocator.rb new file mode 100644 index 0000000..f8d7e7b --- /dev/null +++ b/test/async/utilization/segment_allocator.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "sus" +require "sus/fixtures/console/null_logger" +require "sus/fixtures/temporary_directory_context" +require "async/utilization" + +describe Async::Utilization::SegmentAllocator do + include Sus::Fixtures::Console::NullLogger + include Sus::Fixtures::TemporaryDirectoryContext + + let(:path) {File.join(root, "utilization.shm")} + let(:page_size) {IO::Buffer::PAGE_SIZE} + let(:schema) do + Async::Utilization::Schema.build( + requests_total: :u64, + requests_active: :u32, + ) + end + + it "allocates, reads, and reuses segments" do + allocator = subject.new(path, size: page_size, segment_size: page_size) + + first_offset = allocator.allocate(:first, []) + expect(first_offset).to be == 0 + expect(allocator.allocation(:first)).to have_keys(offset: be == 0, schema: be == []) + + allocator.update_schema(:first, schema.to_a) + observer = Async::Utilization::Observer.open(schema, path, page_size, first_offset) + observer.buffer.set_value(:u64, 0, 12) + observer.buffer.set_value(:u32, 8, 3) + + expect(allocator.read(:first)).to be == {requests_total: 12, requests_active: 3} + + allocator.free(:first) + expect(allocator.read(:first)).to be_nil + expect(allocator.allocate(:second, schema.to_a)).to be == first_offset + ensure + observer&.buffer&.free + allocator&.close + end + + it "preserves existing observer mappings when resizing" do + allocator = subject.new(path, size: page_size, segment_size: page_size) + first_offset = allocator.allocate(:first, schema.to_a) + observer = Async::Utilization::Observer.open(schema, path, page_size, first_offset) + + observer.buffer.set_value(:u64, 0, 42) + expect(allocator.read(:first)[:requests_total]).to be == 42 + + second_offset = allocator.allocate(:second, schema.to_a) + expect(second_offset).to be == page_size + expect(allocator.size).to be == page_size * 2 + + observer.buffer.set_value(:u64, 0, 99) + expect(allocator.read(:first)[:requests_total]).to be == 99 + ensure + observer&.buffer&.free + allocator&.close + end + + it "only replaces an existing file when requested" do + original = subject.new(path, size: page_size, segment_size: page_size) + original.resize(page_size * 2) + + existing_file = File.open(path, "rb") + original_size = existing_file.size + original.close + + expect do + subject.new(path, size: page_size, segment_size: page_size) + end.to raise_exception(Errno::EEXIST) + + replacement = subject.new(path, size: page_size, segment_size: page_size, replace: true) + expect(replacement.size).to be == page_size + expect(existing_file.size).to be == original_size + ensure + original&.close + replacement&.close + existing_file&.close + end +end From b3e7f8e6b4ff0b776c2975eef019415633586bec Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 21 Aug 2026 10:01:50 +1200 Subject: [PATCH 2/6] Cover segment allocator failures Signed-off-by: Samuel Williams --- test/async/utilization/segment_allocator.rb | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/async/utilization/segment_allocator.rb b/test/async/utilization/segment_allocator.rb index f8d7e7b..4f24003 100644 --- a/test/async/utilization/segment_allocator.rb +++ b/test/async/utilization/segment_allocator.rb @@ -62,6 +62,35 @@ allocator&.close end + it "returns nil when automatic resizing fails" do + allocator = subject.new(path, size: page_size, segment_size: page_size) + allocator.allocate(:first, schema.to_a) + + expect(allocator).to receive(:resize).and_return(false) + expect(allocator.allocate(:second, schema.to_a)).to be_nil + ensure + allocator&.close + end + + it "skips fields that cannot be read" do + allocator = subject.new(path, size: page_size, segment_size: page_size) + allocator.allocate(:worker, [[:invalid, :invalid, 0]]) + + expect(allocator.read(:worker)).to be == {} + ensure + allocator&.close + end + + it "reports resize failures" do + allocator = subject.new(path, size: page_size, segment_size: page_size) + file = allocator.instance_variable_get(:@file) + + expect(file).to receive(:truncate).and_raise(IOError, "Failed to resize") + expect(allocator.resize(page_size * 2)).to be_falsey + ensure + allocator&.close + end + it "only replaces an existing file when requested" do original = subject.new(path, size: page_size, segment_size: page_size) original.resize(page_size * 2) From 9ed599965b26ad26b256a33d0f55ea821df743e1 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 21 Aug 2026 10:23:33 +1200 Subject: [PATCH 3/6] Add SegmentAllocator.open factory Signed-off-by: Samuel Williams --- guides/getting-started/readme.md | 2 +- lib/async/utilization/segment_allocator.rb | 58 ++++++++++++-- test/async/utilization/segment_allocator.rb | 83 +++++++++++++++++++-- 3 files changed, 126 insertions(+), 17 deletions(-) diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 331d008..a4e6619 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -115,7 +115,7 @@ Use {ruby Async::Utilization::SegmentAllocator} when one process coordinates sha ```ruby path = "/path/to/shared_memory.shm" -allocator = Async::Utilization::SegmentAllocator.new( +allocator = Async::Utilization::SegmentAllocator.open( path, segment_size: 512, replace: true, diff --git a/lib/async/utilization/segment_allocator.rb b/lib/async/utilization/segment_allocator.rb index c2364c6..4bb5ea9 100644 --- a/lib/async/utilization/segment_allocator.rb +++ b/lib/async/utilization/segment_allocator.rb @@ -12,17 +12,23 @@ module Utilization # Allocates fixed-size segments from a shared memory file, associates each # segment with a utilization schema, and reads the resulting values. class SegmentAllocator - # Initialize the shared memory segment allocator. + # Open a shared memory segment allocator. # # @parameter path [String] The path to the shared memory file. # @parameter size [Integer] The initial size of the shared memory file. # @parameter segment_size [Integer] The size of each allocation segment. # @parameter growth_factor [Integer | Float] The factor used to grow the file when all segments are allocated. # @parameter replace [Boolean] Whether to replace an existing file at the given path. - def initialize(path, size: IO::Buffer::PAGE_SIZE * 8, segment_size: 512, growth_factor: 2, replace: false) - @size = size - @segment_size = segment_size - @growth_factor = growth_factor + # @yields {|allocator| ...} The allocator, which is closed after the block completes. + # @parameter allocator [SegmentAllocator] The opened allocator. + # @returns [SegmentAllocator | Object] The allocator, or the value returned by the block. + # @raises [ArgumentError] If the allocator configuration is invalid. + # @raises [Errno::EEXIST] If the path already exists and `replace` is `false`. + def self.open(path, size: IO::Buffer::PAGE_SIZE * 8, segment_size: 512, growth_factor: 2, replace: false) + raise ArgumentError, "size must be a positive integer" unless size.is_a?(Integer) && size > 0 + raise ArgumentError, "segment_size must be a positive integer" unless segment_size.is_a?(Integer) && segment_size > 0 + raise ArgumentError, "segment_size must not exceed size" if segment_size > size + raise ArgumentError, "growth_factor must be greater than 1" unless growth_factor.is_a?(Numeric) && growth_factor.real? && growth_factor > 1 if replace begin @@ -32,9 +38,43 @@ def initialize(path, size: IO::Buffer::PAGE_SIZE * 8, segment_size: 512, growth_ end end - @file = File.open(path, "w+bx") - @file.truncate(size) - @buffer = IO::Buffer.map(@file, size) + file = File.open(path, "w+bx") + buffer = nil + + begin + file.truncate(size) + buffer = IO::Buffer.map(file, size) + allocator = new(file, buffer, size: size, segment_size: segment_size, growth_factor: growth_factor) + rescue + buffer&.free + file.close + raise + end + + if block_given? + begin + yield allocator + ensure + allocator.close + end + else + allocator + end + end + + # Initialize the shared memory segment allocator. + # + # @parameter file [File] The open shared memory file. + # @parameter buffer [IO::Buffer] The mapped shared memory buffer. + # @parameter size [Integer] The initial size of the shared memory file. + # @parameter segment_size [Integer] The size of each allocation segment. + # @parameter growth_factor [Integer | Float] The factor used to grow the file when all segments are allocated. + def initialize(file, buffer, size:, segment_size:, growth_factor:) + @file = file + @buffer = buffer + @size = size + @segment_size = segment_size + @growth_factor = growth_factor @allocations = {} @free_list = [] @@ -44,6 +84,8 @@ def initialize(path, size: IO::Buffer::PAGE_SIZE * 8, segment_size: 512, growth_ end end + private_class_method :new + # Allocate a segment for the given key. # # The shared memory file is automatically resized if no segments are available. diff --git a/test/async/utilization/segment_allocator.rb b/test/async/utilization/segment_allocator.rb index 4f24003..a546c63 100644 --- a/test/async/utilization/segment_allocator.rb +++ b/test/async/utilization/segment_allocator.rb @@ -22,7 +22,7 @@ end it "allocates, reads, and reuses segments" do - allocator = subject.new(path, size: page_size, segment_size: page_size) + allocator = subject.open(path, size: page_size, segment_size: page_size) first_offset = allocator.allocate(:first, []) expect(first_offset).to be == 0 @@ -44,7 +44,7 @@ end it "preserves existing observer mappings when resizing" do - allocator = subject.new(path, size: page_size, segment_size: page_size) + allocator = subject.open(path, size: page_size, segment_size: page_size) first_offset = allocator.allocate(:first, schema.to_a) observer = Async::Utilization::Observer.open(schema, path, page_size, first_offset) @@ -63,7 +63,7 @@ end it "returns nil when automatic resizing fails" do - allocator = subject.new(path, size: page_size, segment_size: page_size) + allocator = subject.open(path, size: page_size, segment_size: page_size) allocator.allocate(:first, schema.to_a) expect(allocator).to receive(:resize).and_return(false) @@ -73,7 +73,7 @@ end it "skips fields that cannot be read" do - allocator = subject.new(path, size: page_size, segment_size: page_size) + allocator = subject.open(path, size: page_size, segment_size: page_size) allocator.allocate(:worker, [[:invalid, :invalid, 0]]) expect(allocator.read(:worker)).to be == {} @@ -82,7 +82,7 @@ end it "reports resize failures" do - allocator = subject.new(path, size: page_size, segment_size: page_size) + allocator = subject.open(path, size: page_size, segment_size: page_size) file = allocator.instance_variable_get(:@file) expect(file).to receive(:truncate).and_raise(IOError, "Failed to resize") @@ -92,7 +92,7 @@ end it "only replaces an existing file when requested" do - original = subject.new(path, size: page_size, segment_size: page_size) + original = subject.open(path, size: page_size, segment_size: page_size) original.resize(page_size * 2) existing_file = File.open(path, "rb") @@ -100,10 +100,10 @@ original.close expect do - subject.new(path, size: page_size, segment_size: page_size) + subject.open(path, size: page_size, segment_size: page_size) end.to raise_exception(Errno::EEXIST) - replacement = subject.new(path, size: page_size, segment_size: page_size, replace: true) + replacement = subject.open(path, size: page_size, segment_size: page_size, replace: true) expect(replacement.size).to be == page_size expect(existing_file.size).to be == original_size ensure @@ -111,4 +111,71 @@ replacement&.close existing_file&.close end + + it "validates configuration before replacing an existing file" do + File.write(path, "existing") + + [ + [{size: 0}, "size must be a positive integer"], + [{segment_size: 0}, "segment_size must be a positive integer"], + [{size: page_size, segment_size: page_size * 2}, "segment_size must not exceed size"], + [{growth_factor: 1}, "growth_factor must be greater than 1"], + ].each do |options, message| + expect do + subject.open(path, replace: true, **options) + end.to raise_exception(ArgumentError, message: be == message) + + expect(File.read(path)).to be == "existing" + end + end + + it "closes the allocator after yielding it" do + file = nil + + result = subject.open(path, size: page_size, segment_size: page_size) do |allocator| + file = allocator.instance_variable_get(:@file) + expect(file.closed?).to be_falsey + :result + end + + expect(result).to be == :result + expect(file.closed?).to be_truthy + end + + it "closes the file when mapping fails" do + file = File.open(path, "w+bx") + File.unlink(path) + + expect(File).to receive(:open).with(path, "w+bx").and_return(file) + expect(IO::Buffer).to receive(:map).with(file, page_size).and_raise(IOError, "Failed to map") + + expect do + subject.open(path, size: page_size, segment_size: page_size) + end.to raise_exception(IOError, message: be == "Failed to map") + + expect(file.closed?).to be_truthy + end + + it "releases acquired resources when initialization fails" do + file = File.open(path, "w+bx") + File.unlink(path) + buffer = IO::Buffer.new(page_size) + + expect(File).to receive(:open).with(path, "w+bx").and_return(file) + expect(IO::Buffer).to receive(:map).with(file, page_size).and_return(buffer) + expect(subject).to receive(:new).and_raise(IOError, "Failed to initialize") + + expect do + subject.open(path, size: page_size, segment_size: page_size) + end.to raise_exception(IOError, message: be == "Failed to initialize") + + expect(file.closed?).to be_truthy + expect(buffer.null?).to be_truthy + end + + it "does not expose direct construction" do + expect do + subject.new(nil, nil, size: page_size, segment_size: page_size, growth_factor: 2) + end.to raise_exception(NoMethodError) + end end From 72ab8111d4d317c6e856e95980a4ca067728bd63 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 21 Aug 2026 10:24:50 +1200 Subject: [PATCH 4/6] Keep SegmentAllocator construction public Signed-off-by: Samuel Williams --- lib/async/utilization/segment_allocator.rb | 2 -- test/async/utilization/segment_allocator.rb | 6 ------ 2 files changed, 8 deletions(-) diff --git a/lib/async/utilization/segment_allocator.rb b/lib/async/utilization/segment_allocator.rb index 4bb5ea9..90f051a 100644 --- a/lib/async/utilization/segment_allocator.rb +++ b/lib/async/utilization/segment_allocator.rb @@ -84,8 +84,6 @@ def initialize(file, buffer, size:, segment_size:, growth_factor:) end end - private_class_method :new - # Allocate a segment for the given key. # # The shared memory file is automatically resized if no segments are available. diff --git a/test/async/utilization/segment_allocator.rb b/test/async/utilization/segment_allocator.rb index a546c63..88da75c 100644 --- a/test/async/utilization/segment_allocator.rb +++ b/test/async/utilization/segment_allocator.rb @@ -172,10 +172,4 @@ expect(file.closed?).to be_truthy expect(buffer.null?).to be_truthy end - - it "does not expose direct construction" do - expect do - subject.new(nil, nil, size: page_size, segment_size: page_size, growth_factor: 2) - end.to raise_exception(NoMethodError) - end end From 5466870361b4bd437e26550ce7987e492534aec7 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 21 Aug 2026 10:26:01 +1200 Subject: [PATCH 5/6] Simplify SegmentAllocator.open Signed-off-by: Samuel Williams --- lib/async/utilization/segment_allocator.rb | 22 ++++++++++----------- test/async/utilization/segment_allocator.rb | 8 ++++---- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/lib/async/utilization/segment_allocator.rb b/lib/async/utilization/segment_allocator.rb index 90f051a..e739c66 100644 --- a/lib/async/utilization/segment_allocator.rb +++ b/lib/async/utilization/segment_allocator.rb @@ -25,10 +25,10 @@ class SegmentAllocator # @raises [ArgumentError] If the allocator configuration is invalid. # @raises [Errno::EEXIST] If the path already exists and `replace` is `false`. def self.open(path, size: IO::Buffer::PAGE_SIZE * 8, segment_size: 512, growth_factor: 2, replace: false) - raise ArgumentError, "size must be a positive integer" unless size.is_a?(Integer) && size > 0 - raise ArgumentError, "segment_size must be a positive integer" unless segment_size.is_a?(Integer) && segment_size > 0 - raise ArgumentError, "segment_size must not exceed size" if segment_size > size - raise ArgumentError, "growth_factor must be greater than 1" unless growth_factor.is_a?(Numeric) && growth_factor.real? && growth_factor > 1 + raise ArgumentError, "Size must be a positive integer!" unless size.is_a?(Integer) && size > 0 + raise ArgumentError, "Segment size must be a positive integer!" unless segment_size.is_a?(Integer) && segment_size > 0 + raise ArgumentError, "Segment size must not exceed size!" if segment_size > size + raise ArgumentError, "Growth factor must be greater than 1!" unless growth_factor.is_a?(Numeric) && growth_factor.real? && growth_factor > 1 if replace begin @@ -51,14 +51,12 @@ def self.open(path, size: IO::Buffer::PAGE_SIZE * 8, segment_size: 512, growth_f raise end - if block_given? - begin - yield allocator - ensure - allocator.close - end - else - allocator + return allocator unless block_given? + + begin + yield allocator + ensure + allocator.close end end diff --git a/test/async/utilization/segment_allocator.rb b/test/async/utilization/segment_allocator.rb index 88da75c..6ee7e8c 100644 --- a/test/async/utilization/segment_allocator.rb +++ b/test/async/utilization/segment_allocator.rb @@ -116,10 +116,10 @@ File.write(path, "existing") [ - [{size: 0}, "size must be a positive integer"], - [{segment_size: 0}, "segment_size must be a positive integer"], - [{size: page_size, segment_size: page_size * 2}, "segment_size must not exceed size"], - [{growth_factor: 1}, "growth_factor must be greater than 1"], + [{size: 0}, "Size must be a positive integer!"], + [{segment_size: 0}, "Segment size must be a positive integer!"], + [{size: page_size, segment_size: page_size * 2}, "Segment size must not exceed size!"], + [{growth_factor: 1}, "Growth factor must be greater than 1!"], ].each do |options, message| expect do subject.open(path, replace: true, **options) From e8c4ab44a8b64e54ea15d681ce407ff000edd86f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 21 Aug 2026 11:55:14 +1200 Subject: [PATCH 6/6] Rename SegmentAllocator to SegmentStore Signed-off-by: Samuel Williams --- guides/getting-started/readme.md | 12 ++-- lib/async/utilization.rb | 2 +- ...{segment_allocator.rb => segment_store.rb} | 26 ++++---- releases.md | 2 +- ...{segment_allocator.rb => segment_store.rb} | 66 +++++++++---------- 5 files changed, 54 insertions(+), 54 deletions(-) rename lib/async/utilization/{segment_allocator.rb => segment_store.rb} (88%) rename test/async/utilization/{segment_allocator.rb => segment_store.rb} (69%) diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index a4e6619..698bfde 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -19,7 +19,7 @@ The key components are: - {ruby Async::Utilization::Registry}: Holds your metrics and optional observer; create one explicitly and pass it to the code that records utilization. - {ruby Async::Utilization::Schema}: Defines the binary layout for serialization. - {ruby Async::Utilization::Observer}: Writes metrics to shared memory using the schema. -- {ruby Async::Utilization::SegmentAllocator}: Allocates shared memory regions and reads their utilization values. +- {ruby Async::Utilization::SegmentStore}: Stores shared memory regions and reads their utilization values. - {ruby Async::Utilization::Metric}: The handle you call `increment`, `set`, `track`, etc. on; obtained from the registry. ## Basic Usage @@ -111,22 +111,22 @@ The observer automatically handles page alignment requirements for memory mappin ## Allocating Shared Memory Segments -Use {ruby Async::Utilization::SegmentAllocator} when one process coordinates shared memory regions for multiple observers: +Use {ruby Async::Utilization::SegmentStore} when one process coordinates shared memory regions for multiple observers: ```ruby path = "/path/to/shared_memory.shm" -allocator = Async::Utilization::SegmentAllocator.open( +store = Async::Utilization::SegmentStore.open( path, segment_size: 512, replace: true, ) -offset = allocator.allocate(:worker, schema.to_a) +offset = store.allocate(:worker, schema.to_a) observer = Async::Utilization::Observer.open(schema, path, 512, offset) # After the observer writes metrics: -allocator.read(:worker) +store.read(:worker) # => {total_requests: 1, active_requests: 0} ``` -The allocator grows the shared memory file automatically when all segments are in use. Pass `replace: true` only when the coordinating process should replace an existing file, such as when a supervisor restarts. +The store grows the shared memory file automatically when all segments are in use. Pass `replace: true` only when the coordinating process should replace an existing file, such as when a supervisor restarts. diff --git a/lib/async/utilization.rb b/lib/async/utilization.rb index ccb78e1..a0ee4a4 100644 --- a/lib/async/utilization.rb +++ b/lib/async/utilization.rb @@ -9,7 +9,7 @@ require_relative "utilization/registry" require_relative "utilization/observer" require_relative "utilization/metric" -require_relative "utilization/segment_allocator" +require_relative "utilization/segment_store" # @namespace module Async diff --git a/lib/async/utilization/segment_allocator.rb b/lib/async/utilization/segment_store.rb similarity index 88% rename from lib/async/utilization/segment_allocator.rb rename to lib/async/utilization/segment_store.rb index e739c66..ca2b4c9 100644 --- a/lib/async/utilization/segment_allocator.rb +++ b/lib/async/utilization/segment_store.rb @@ -7,22 +7,22 @@ module Async module Utilization - # Represents a shared memory segment allocator for utilization data. + # Represents a shared memory segment store for utilization data. # - # Allocates fixed-size segments from a shared memory file, associates each + # Stores fixed-size segments in a shared memory file, associates each # segment with a utilization schema, and reads the resulting values. - class SegmentAllocator - # Open a shared memory segment allocator. + class SegmentStore + # Open a shared memory segment store. # # @parameter path [String] The path to the shared memory file. # @parameter size [Integer] The initial size of the shared memory file. # @parameter segment_size [Integer] The size of each allocation segment. # @parameter growth_factor [Integer | Float] The factor used to grow the file when all segments are allocated. # @parameter replace [Boolean] Whether to replace an existing file at the given path. - # @yields {|allocator| ...} The allocator, which is closed after the block completes. - # @parameter allocator [SegmentAllocator] The opened allocator. - # @returns [SegmentAllocator | Object] The allocator, or the value returned by the block. - # @raises [ArgumentError] If the allocator configuration is invalid. + # @yields {|store| ...} The store, which is closed after the block completes. + # @parameter store [SegmentStore] The opened store. + # @returns [SegmentStore | Object] The store, or the value returned by the block. + # @raises [ArgumentError] If the store configuration is invalid. # @raises [Errno::EEXIST] If the path already exists and `replace` is `false`. def self.open(path, size: IO::Buffer::PAGE_SIZE * 8, segment_size: 512, growth_factor: 2, replace: false) raise ArgumentError, "Size must be a positive integer!" unless size.is_a?(Integer) && size > 0 @@ -44,23 +44,23 @@ def self.open(path, size: IO::Buffer::PAGE_SIZE * 8, segment_size: 512, growth_f begin file.truncate(size) buffer = IO::Buffer.map(file, size) - allocator = new(file, buffer, size: size, segment_size: segment_size, growth_factor: growth_factor) + store = new(file, buffer, size: size, segment_size: segment_size, growth_factor: growth_factor) rescue buffer&.free file.close raise end - return allocator unless block_given? + return store unless block_given? begin - yield allocator + yield store ensure - allocator.close + store.close end end - # Initialize the shared memory segment allocator. + # Initialize the shared memory segment store. # # @parameter file [File] The open shared memory file. # @parameter buffer [IO::Buffer] The mapped shared memory buffer. diff --git a/releases.md b/releases.md index dca2bb3..f1cfef2 100644 --- a/releases.md +++ b/releases.md @@ -2,7 +2,7 @@ ## Unreleased - - Add `Async::Utilization::SegmentAllocator` for allocating and reading utilization data in shared memory. + - Add `Async::Utilization::SegmentStore` for allocating and reading utilization data in shared memory. ## v0.4.0 diff --git a/test/async/utilization/segment_allocator.rb b/test/async/utilization/segment_store.rb similarity index 69% rename from test/async/utilization/segment_allocator.rb rename to test/async/utilization/segment_store.rb index 6ee7e8c..9b548dc 100644 --- a/test/async/utilization/segment_allocator.rb +++ b/test/async/utilization/segment_store.rb @@ -8,7 +8,7 @@ require "sus/fixtures/temporary_directory_context" require "async/utilization" -describe Async::Utilization::SegmentAllocator do +describe Async::Utilization::SegmentStore do include Sus::Fixtures::Console::NullLogger include Sus::Fixtures::TemporaryDirectoryContext @@ -22,73 +22,73 @@ end it "allocates, reads, and reuses segments" do - allocator = subject.open(path, size: page_size, segment_size: page_size) + store = subject.open(path, size: page_size, segment_size: page_size) - first_offset = allocator.allocate(:first, []) + first_offset = store.allocate(:first, []) expect(first_offset).to be == 0 - expect(allocator.allocation(:first)).to have_keys(offset: be == 0, schema: be == []) + expect(store.allocation(:first)).to have_keys(offset: be == 0, schema: be == []) - allocator.update_schema(:first, schema.to_a) + store.update_schema(:first, schema.to_a) observer = Async::Utilization::Observer.open(schema, path, page_size, first_offset) observer.buffer.set_value(:u64, 0, 12) observer.buffer.set_value(:u32, 8, 3) - expect(allocator.read(:first)).to be == {requests_total: 12, requests_active: 3} + expect(store.read(:first)).to be == {requests_total: 12, requests_active: 3} - allocator.free(:first) - expect(allocator.read(:first)).to be_nil - expect(allocator.allocate(:second, schema.to_a)).to be == first_offset + store.free(:first) + expect(store.read(:first)).to be_nil + expect(store.allocate(:second, schema.to_a)).to be == first_offset ensure observer&.buffer&.free - allocator&.close + store&.close end it "preserves existing observer mappings when resizing" do - allocator = subject.open(path, size: page_size, segment_size: page_size) - first_offset = allocator.allocate(:first, schema.to_a) + store = subject.open(path, size: page_size, segment_size: page_size) + first_offset = store.allocate(:first, schema.to_a) observer = Async::Utilization::Observer.open(schema, path, page_size, first_offset) observer.buffer.set_value(:u64, 0, 42) - expect(allocator.read(:first)[:requests_total]).to be == 42 + expect(store.read(:first)[:requests_total]).to be == 42 - second_offset = allocator.allocate(:second, schema.to_a) + second_offset = store.allocate(:second, schema.to_a) expect(second_offset).to be == page_size - expect(allocator.size).to be == page_size * 2 + expect(store.size).to be == page_size * 2 observer.buffer.set_value(:u64, 0, 99) - expect(allocator.read(:first)[:requests_total]).to be == 99 + expect(store.read(:first)[:requests_total]).to be == 99 ensure observer&.buffer&.free - allocator&.close + store&.close end it "returns nil when automatic resizing fails" do - allocator = subject.open(path, size: page_size, segment_size: page_size) - allocator.allocate(:first, schema.to_a) + store = subject.open(path, size: page_size, segment_size: page_size) + store.allocate(:first, schema.to_a) - expect(allocator).to receive(:resize).and_return(false) - expect(allocator.allocate(:second, schema.to_a)).to be_nil + expect(store).to receive(:resize).and_return(false) + expect(store.allocate(:second, schema.to_a)).to be_nil ensure - allocator&.close + store&.close end it "skips fields that cannot be read" do - allocator = subject.open(path, size: page_size, segment_size: page_size) - allocator.allocate(:worker, [[:invalid, :invalid, 0]]) + store = subject.open(path, size: page_size, segment_size: page_size) + store.allocate(:worker, [[:invalid, :invalid, 0]]) - expect(allocator.read(:worker)).to be == {} + expect(store.read(:worker)).to be == {} ensure - allocator&.close + store&.close end it "reports resize failures" do - allocator = subject.open(path, size: page_size, segment_size: page_size) - file = allocator.instance_variable_get(:@file) + store = subject.open(path, size: page_size, segment_size: page_size) + file = store.instance_variable_get(:@file) expect(file).to receive(:truncate).and_raise(IOError, "Failed to resize") - expect(allocator.resize(page_size * 2)).to be_falsey + expect(store.resize(page_size * 2)).to be_falsey ensure - allocator&.close + store&.close end it "only replaces an existing file when requested" do @@ -129,11 +129,11 @@ end end - it "closes the allocator after yielding it" do + it "closes the store after yielding it" do file = nil - result = subject.open(path, size: page_size, segment_size: page_size) do |allocator| - file = allocator.instance_variable_get(:@file) + result = subject.open(path, size: page_size, segment_size: page_size) do |store| + file = store.instance_variable_get(:@file) expect(file.closed?).to be_falsey :result end