async-utilization 0.3.1 → 0.3.2

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: 4114f3169e309e3062d153202d83d3eb7b38031dc47a5fbb0df0e3cf63296e9d
4
- data.tar.gz: 68eca39b660a7d6414e12bb79ee464d3267d62ec4a1a5ab39c23742a6bb645a4
3
+ metadata.gz: 2494a3f35fcbfd987fe6520ceda64225ccc6592ebe4072f0260f38a430d0d3fc
4
+ data.tar.gz: '0668665f31e13ac0d00c5eded51ad94f7f394927318e05ef83f7a5cd818e22e3'
5
5
  SHA512:
6
- metadata.gz: 45cd92a3a8ae94aa2c2d6847cff090c7e5a1fadd96cd43dad062c2cf1384d9c1743926311151386a32517d41757e267fb35c5e7e276a4f7cead08468a83c9b68
7
- data.tar.gz: 901a1771f0116652411a7cdbb5122669e814b4dc3594646d764179851ba0cf1ef7e8800838191bf2a67e2db3c877513ced9f3df4f58adedc8b4a84f4a0800890
6
+ metadata.gz: 863148a136e0a8696c337c5c24e18f8751bea2b586f1a703235992103c5454d1621d9296c401ded2d75aa532255beab3ae7ee2ad84211a8eb3b9971aac7b5fc8
7
+ data.tar.gz: 4a6e0ee78558c67dd2f8c5e8bb284c0aaed26cb23a3df700b451841a8081dafd9973ff2ef033f7f137cae4e657b60eae29cfeca89a8edd7ca3233f46a7318ce5
checksums.yaml.gz.sig CHANGED
Binary file
@@ -11,16 +11,26 @@ module Async
11
11
  # including the buffer, offset, and type. When the observer changes, the cache
12
12
  # is invalidated and rebuilt on the next access.
13
13
  class Metric
14
+ # Create a new metric for the given field and observer.
15
+ #
16
+ # @parameter field [Symbol] The field name for this metric.
17
+ # @parameter observer [Observer | Nil] The observer to associate with this metric.
18
+ # @returns [Metric] A new metric instance.
19
+ def self.for(field, observer)
20
+ self.new(field).tap do |metric|
21
+ metric.observer = observer
22
+ end
23
+ end
24
+
14
25
  # Initialize a new metric.
15
26
  #
16
27
  # @parameter name [Symbol] The field name for this metric.
17
- # @parameter registry [Registry] The registry instance to use.
18
- def initialize(name, registry)
28
+ def initialize(name)
19
29
  @name = name.to_sym
20
- @registry = registry
21
30
  @value = 0
22
- @cache_valid = false
23
- @cached_field_info = nil
31
+
32
+ @observer = nil
33
+ @cached_field = nil
24
34
  @cached_buffer = nil
25
35
  @guard = Mutex.new
26
36
  end
@@ -34,19 +44,35 @@ module Async
34
44
  # @attribute [Mutex] The mutex for thread safety.
35
45
  attr :guard
36
46
 
37
- # Invalidate the cached field information.
47
+ # Set the observer and rebuild cache.
38
48
  #
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
49
+ # This is called when the registry assigns a new observer (or removes it).
50
+ # The cache is invalidated and then immediately recomputed so that the
51
+ # fast write path doesn't need to re-check the observer on the first write.
52
+ #
53
+ # @parameter observer [Observer | Nil] The new observer (or nil).
54
+ def observer=(observer)
55
+ @guard.synchronize do
56
+ @observer = observer
57
+
58
+ if @observer
59
+ if field = @observer.schema[@name]
60
+ if buffer = @observer.buffer
61
+ @cached_field = field
62
+ @cached_buffer = buffer
63
+
64
+ return write_direct(@value)
65
+ end
66
+ end
67
+ end
68
+
69
+ @cached_field = nil
70
+ @cached_buffer = nil
71
+ end
44
72
  end
45
73
 
46
74
  # Increment the metric value.
47
75
  #
48
- # Uses the fast path (direct buffer write) when cache is valid and observer is available.
49
- #
50
76
  # @returns [Integer] The new value of the field.
51
77
  def increment
52
78
  @guard.synchronize do
@@ -103,28 +129,6 @@ module Async
103
129
 
104
130
  protected
105
131
 
106
- # Check if the cache is valid and rebuild if necessary.
107
- #
108
- # Always attempts to build the cache if it's invalid. Returns true if cache
109
- # is now valid (observer exists, field is in schema, and buffer is available), false otherwise.
110
- #
111
- # @returns [bool] True if cache is valid, false otherwise.
112
- def ensure_cache_valid!
113
- unless @cache_valid
114
- if observer = @registry.observer
115
- if field = observer.schema[@name]
116
- if buffer = observer.buffer
117
- @cached_field_info = field
118
- @cached_buffer = buffer
119
- end
120
- end
121
- end
122
-
123
- # 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:
124
- @cache_valid = true
125
- end
126
- end
127
-
128
132
  # Write directly to the cached buffer if available.
129
133
  #
130
134
  # This is the fast path that avoids hash lookups. Always ensures cache is valid
@@ -133,16 +137,12 @@ module Async
133
137
  # @parameter value [Numeric] The value to write.
134
138
  # @returns [Boolean] Whether the write succeeded.
135
139
  def write_direct(value)
136
- self.ensure_cache_valid!
137
-
138
140
  if @cached_buffer
139
- @cached_buffer.set_value(@cached_field_info.type, @cached_field_info.offset, value)
141
+ @cached_buffer.set_value(@cached_field.type, @cached_field.offset, value)
140
142
  end
141
143
 
142
144
  return true
143
145
  rescue => error
144
- # If write fails, log warning but don't invalidate cache
145
- # The error might be transient, and invalidating would force hash lookups
146
146
  Console.warn(self, "Failed to write metric value!", metric: {name: @name, value: value}, exception: error)
147
147
 
148
148
  return false
@@ -3,7 +3,6 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
- require "console"
7
6
  require_relative "schema"
8
7
 
9
8
  module Async
@@ -71,21 +70,6 @@ module Async
71
70
 
72
71
  # @attribute [IO::Buffer] The mapped buffer for shared memory.
73
72
  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
73
  end
90
74
  end
91
75
  end
@@ -3,6 +3,8 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
+ require "console"
7
+
6
8
  module Async
7
9
  module Utilization
8
10
  # Registry for emitting utilization metrics.
@@ -61,26 +63,23 @@ module Async
61
63
 
62
64
  # Set the observer for the registry.
63
65
  #
64
- # When an observer is set, it is notified of all current metric values
65
- # so it can sync its state. The observer must implement `set(field, value)`.
66
- # All cached metrics are invalidated when the observer changes.
66
+ # When an observer is set, all cached metrics are updated so they write
67
+ # directly to the observer's buffer. The observer must expose `schema`
68
+ # and `buffer` attributes.
67
69
  #
68
- # @parameter observer [#set] The observer to set.
70
+ # @parameter observer [Observer | Nil] The observer to set.
69
71
  def observer=(observer)
70
72
  @guard.synchronize do
71
- # Invalidate all cached metrics
73
+ @observer = observer
74
+
75
+ # Invalidate all cached metrics with new observer (or nil)
72
76
  @metrics.each_value do |metric|
73
- metric.invalidate
77
+ metric.observer = observer
74
78
  end
75
79
 
76
- @observer = observer
80
+ # Console.info(self, "Observer assigned", observer: observer, metric_count: @metrics.size)
77
81
  end
78
82
 
79
- # Notify observer of all current metric values (outside guard to avoid deadlock)
80
- @metrics.each do |name, metric|
81
- value = metric.guard.synchronize{metric.value}
82
- observer.set(name, value)
83
- end
84
83
  end
85
84
 
86
85
  # Set a field value.
@@ -135,7 +134,7 @@ module Async
135
134
  field = field.to_sym
136
135
 
137
136
  @guard.synchronize do
138
- @metrics[field] ||= Metric.new(field, self)
137
+ @metrics[field] ||= Metric.for(field, @observer)
139
138
  end
140
139
  end
141
140
  end
@@ -7,6 +7,6 @@
7
7
  module Async
8
8
  # @namespace
9
9
  module Utilization
10
- VERSION = "0.3.1"
10
+ VERSION = "0.3.2"
11
11
  end
12
12
  end
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.3.2
18
+
19
+ - Better observer state handling.
20
+
17
21
  ### v0.3.1
18
22
 
19
23
  - Remove unused `thread-local` dependency.
data/releases.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Releases
2
2
 
3
+ ## v0.3.2
4
+
5
+ - Better observer state handling.
6
+
3
7
  ## v0.3.1
4
8
 
5
9
  - Remove unused `thread-local` dependency.
@@ -154,8 +154,8 @@ describe Async::Utilization::Metric do
154
154
  end
155
155
 
156
156
  it "writes directly to shared memory when observer is set" do
157
- registry.observer = observer
158
157
  metric = registry.metric(:total_requests)
158
+ registry.observer = observer
159
159
 
160
160
  metric.set(42)
161
161
 
@@ -216,22 +216,6 @@ describe Async::Utilization::Metric do
216
216
  expect(metric1).to be == metric2
217
217
  end
218
218
 
219
- it "falls back to observer.set when write_direct fails" do
220
- registry.observer = observer
221
- metric = registry.metric(:total_requests)
222
-
223
- # Force cache to be invalid by invalidating it
224
- metric.invalidate
225
-
226
- # Set a value - should fall back to observer.set
227
- metric.set(42)
228
- expect(metric.value).to be == 42
229
-
230
- # Verify it was written to shared memory
231
- buffer = IO::Buffer.map(File.open(shm_path, "r+b"), file_size, 0)
232
- expect(buffer.get_value(:u64, 0)).to be == 42
233
- end
234
-
235
219
  it "handles write errors gracefully" do
236
220
  registry.observer = observer
237
221
  metric = registry.metric(:total_requests)
@@ -239,10 +223,6 @@ describe Async::Utilization::Metric do
239
223
  # Set a value first to build the cache
240
224
  metric.set(10)
241
225
 
242
- # Verify cache is built
243
- expect(metric.instance_variable_get(:@cache_valid)).to be == true
244
- cached_buffer = metric.instance_variable_get(:@cached_buffer)
245
-
246
226
  # Create an invalid buffer that will raise an error
247
227
  invalid_buffer = Object.new
248
228
  def invalid_buffer.set_value(type, offset, value)
@@ -251,13 +231,10 @@ describe Async::Utilization::Metric do
251
231
 
252
232
  metric.instance_variable_set(:@cached_buffer, invalid_buffer)
253
233
 
254
- # Should not raise, but log warning and keep cache valid
234
+ # Should not raise, but log warning
255
235
  metric.set(42)
256
236
  expect(metric.value).to be == 42
257
237
 
258
- # Cache should remain valid (not invalidated on error)
259
- expect(metric.instance_variable_get(:@cache_valid)).to be == true
260
-
261
238
  # Assert that a warning was logged
262
239
  expect_console.to have_logged(
263
240
  severity: be == :warn,
@@ -265,4 +242,27 @@ describe Async::Utilization::Metric do
265
242
  message: be == "Failed to write metric value!"
266
243
  )
267
244
  end
245
+
246
+ it "clears cache when observer is removed" do
247
+ registry.observer = observer
248
+ metric = registry.metric(:total_requests)
249
+ metric.set(10)
250
+
251
+ # Remove observer — cache should be cleared
252
+ registry.observer = nil
253
+
254
+ # Write should not go to the old buffer
255
+ metric.set(99)
256
+
257
+ buffer = IO::Buffer.map(File.open(shm_path, "r+b"), file_size, 0)
258
+ expect(buffer.get_value(:u64, 0)).to be == 10
259
+
260
+ # In-memory value is still updated
261
+ expect(metric.value).to be == 99
262
+
263
+ # Re-attaching observer should sync the current value
264
+ registry.observer = observer
265
+ buffer = IO::Buffer.map(File.open(shm_path, "r+b"), file_size, 0)
266
+ expect(buffer.get_value(:u64, 0)).to be == 99
267
+ end
268
268
  end
@@ -4,13 +4,11 @@
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
6
  require "sus"
7
- require "sus/fixtures/console/captured_logger"
8
7
  require "sus/fixtures/temporary_directory_context"
9
8
  require "async/utilization"
10
9
  require "fileutils"
11
10
 
12
11
  describe Async::Utilization::Observer do
13
- include Sus::Fixtures::Console::CapturedLogger
14
12
  include Sus::Fixtures::TemporaryDirectoryContext
15
13
 
16
14
  let(:shm_path) {File.join(root, "test.shm")}
@@ -41,68 +39,30 @@ describe Async::Utilization::Observer do
41
39
  expect(observer.schema).to be == schema
42
40
  end
43
41
 
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
42
  with "non-page-aligned offsets" do
55
43
  let(:file_size) {IO::Buffer::PAGE_SIZE * 2}
56
44
  let(:offset) {100} # Not page-aligned
57
45
 
58
- it "handles non-page-aligned offsets" do
59
- observer.set(:total_requests, 100)
60
- observer.set(:active_requests, 20)
46
+ it "maps values at the correct offset" do
47
+ observer.buffer.set_value(:u64, schema[:total_requests].offset, 100)
48
+ observer.buffer.set_value(:u32, schema[:active_requests].offset, 20)
61
49
 
62
- # Read back from file at the correct offset
50
+ # Read back from file at the correct byte position
63
51
  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
52
+ expect(buffer.get_value(:u64, offset + schema[:total_requests].offset)).to be == 100
53
+ expect(buffer.get_value(:u32, offset + schema[:active_requests].offset)).to be == 20
66
54
  end
67
55
  end
68
56
 
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
57
  with "page-aligned offsets" do
79
58
  let(:file_size) {page_size * 2}
80
59
  let(:segment_size) {page_size}
81
60
 
82
- it "handles page-aligned offsets without slicing" do
83
- expect(observer).to be_a(Async::Utilization::Observer)
84
- observer.set(:total_requests, 123)
61
+ it "maps values at the correct offset" do
62
+ observer.buffer.set_value(:u64, schema[:total_requests].offset, 123)
85
63
 
86
- # Verify value was written
87
64
  buffer = IO::Buffer.map(File.open(shm_path, "r+b"), file_size, 0)
88
- expect(buffer.get_value(:u64, 0)).to be == 123
65
+ expect(buffer.get_value(:u64, schema[:total_requests].offset)).to be == 123
89
66
  end
90
67
  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
68
  end
@@ -56,44 +56,24 @@ describe Async::Utilization::Registry do
56
56
  expect(registry.values[:test_field]).to be == 0
57
57
  end
58
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
59
  it "notifies observer when values change" do
70
- values_set = []
71
-
72
- # Create a proper observer with schema
73
60
  schema = Async::Utilization::Schema.build(test_field: :u64)
74
- observer = Object.new
61
+ buffer = IO::Buffer.new(8)
75
62
 
76
- # Define methods on observer
77
- observer.define_singleton_method(:set) do |field, value|
78
- values_set << [field, value]
79
- end
63
+ observer = Object.new
80
64
  observer.define_singleton_method(:schema){schema}
81
- observer.define_singleton_method(:buffer){nil} # No buffer, so write_direct will return false
65
+ observer.define_singleton_method(:buffer){buffer}
82
66
 
83
67
  registry.set(:test_field, 5)
84
68
  registry.observer = observer
85
69
 
86
- # Observer should be notified of existing values
87
- expect(values_set).to be(:include?, [:test_field, 5])
70
+ # Buffer should be synced with the existing value on observer assignment
71
+ expect(buffer.get_value(:u64, 0)).to be == 5
88
72
 
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
73
  registry.increment(:test_field)
94
74
 
95
- # With no buffer, write_direct fails silently, so no notification
96
- expect(values_set).to be == []
75
+ # Buffer should reflect the incremented value
76
+ expect(buffer.get_value(:u64, 0)).to be == 6
97
77
  end
98
78
 
99
79
  it "uses metric method for fast path" do
@@ -117,12 +97,35 @@ describe Async::Utilization::Registry do
117
97
  expect(registry.values).to have_keys(module_set_test: be == 99)
118
98
  end
119
99
 
120
- it "can set observer" do
100
+ it "wires up existing metrics when observer is assigned" do
101
+ schema = Async::Utilization::Schema.build(test_field: :u64)
102
+ buffer = IO::Buffer.new(8)
103
+ observer = Object.new
104
+ observer.define_singleton_method(:schema){schema}
105
+ observer.define_singleton_method(:buffer){buffer}
106
+
107
+ registry.set(:test_field, 7)
108
+ registry.observer = observer
109
+
110
+ expect(buffer.get_value(:u64, 0)).to be == 7
111
+ end
112
+
113
+ it "stops writing to buffer when observer is removed" do
114
+ schema = Async::Utilization::Schema.build(test_field: :u64)
115
+ buffer = IO::Buffer.new(8)
121
116
  observer = Object.new
122
- def observer.set(field, value); end
117
+ observer.define_singleton_method(:schema){schema}
118
+ observer.define_singleton_method(:buffer){buffer}
123
119
 
124
120
  registry.observer = observer
121
+ registry.set(:test_field, 5)
122
+ expect(buffer.get_value(:u64, 0)).to be == 5
123
+
124
+ registry.observer = nil
125
+ registry.set(:test_field, 99)
125
126
 
126
- expect(registry.observer).to be == observer
127
+ # Buffer unchanged, in-memory value updated
128
+ expect(buffer.get_value(:u64, 0)).to be == 5
129
+ expect(registry.values[:test_field]).to be == 99
127
130
  end
128
131
  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.1
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
metadata.gz.sig CHANGED
Binary file