psi 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: aac38eed9cc273f4e71ec2b49e98875f8e0a755161e6ec907881b37fb93ea2ea
4
+ data.tar.gz: effca4aee4a4d0b90f9069e5993acde56a7a6a1a3b19b77cee565323930da2cb
5
+ SHA512:
6
+ metadata.gz: 48d898561c14f61b909e4f990ce3f54be04152394cc2d1063bec7cdc03d8707e174a1b8920d71e223001a18261b69d40b4fb57bfd9edc26866a6c276062c0c6e
7
+ data.tar.gz: 91af3741be9074610ddad23191362a7251cdd00813429bd9ece59ab208bea147c7e7ac8edb829e00f10cfe679652f68cc2f840c72503e11402c6168bc9b0e4e6
data/.yardopts ADDED
@@ -0,0 +1,3 @@
1
+ --markup markdown
2
+ --output-dir doc
3
+ lib/**/*.rb
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # PSI
2
+
3
+ PSI is a pure Ruby interface to Linux Pressure Stall Information. It reads
4
+ system-wide and cgroup v2 pressure metrics, calculates exact interval ratios,
5
+ and monitors kernel PSI triggers without a C extension.
6
+
7
+ ## Requirements
8
+
9
+ - Ruby 3.2 or later
10
+ - Linux 4.20 or later with `CONFIG_PSI=y` for readings
11
+ - Linux 5.2 or later and write permission on a pressure file for triggers
12
+
13
+ Some distributions built with `CONFIG_PSI_DEFAULT_DISABLED=y` also require the
14
+ `psi=1` kernel command-line option.
15
+
16
+ ## Installation
17
+
18
+ ```sh
19
+ bundle add psi
20
+ ```
21
+
22
+ ## Read pressure
23
+
24
+ ```ruby
25
+ require "psi"
26
+
27
+ reading = PSI.read(:memory)
28
+ reading.some.avg10 # percentage over the last 10 seconds
29
+ reading.full.total # cumulative stalled microseconds
30
+
31
+ PSI.resources # => [:cpu, :memory, :io] plus :irq where available
32
+ PSI.read_all # => { cpu: PSI::Reading, ... }
33
+ ```
34
+
35
+ `some` means at least one task was stalled. `full` means every non-idle task
36
+ was stalled. System-wide CPU pressure normally has only `some`; IRQ pressure,
37
+ available since Linux 6.1, has only `full`.
38
+
39
+ For an exact interval ratio, use cumulative totals instead of rolling averages:
40
+
41
+ ```ruby
42
+ sampler = PSI::Sampler.new(:memory)
43
+ sampler.sample # => nil
44
+ sleep 5
45
+ sampler.sample # => #<data PSI::Delta some_ratio=..., full_ratio=..., elapsed=...>
46
+ ```
47
+
48
+ `PSI::Sampler` is intentionally not thread-safe; give each sampling thread its
49
+ own instance.
50
+
51
+ ## Read the current cgroup
52
+
53
+ Containers should prefer cgroup values because `/proc/pressure` can expose the
54
+ host's system-wide pressure:
55
+
56
+ ```ruby
57
+ cgroup = PSI.current_cgroup
58
+ PSI.read(:memory, cgroup: cgroup)
59
+ ```
60
+
61
+ ## Wait for a trigger
62
+
63
+ Trigger files must be writable. `/proc/pressure/*` normally requires root;
64
+ delegated cgroup v2 pressure files can be used without root.
65
+ On current kernels, an unprivileged trigger's window must be a multiple of two
66
+ seconds; privileged triggers retain the full 0.5–10 second range.
67
+
68
+ ```ruby
69
+ PSI::Trigger.open(:memory, kind: :some, stall: 0.15, window: 1.0) do |trigger|
70
+ warn "memory pressure" if trigger.wait(timeout: 10)
71
+ end
72
+ ```
73
+
74
+ `window` must be 0.5–10 seconds and `stall` cannot exceed it. Start with a
75
+ `some` trigger around 10–20% of the window for early warning and a higher
76
+ `full` trigger for load shedding, then tune from measurements on the real
77
+ workload. There is no portable 10–30 second warning threshold: reclaim,
78
+ working-set size, and cgroup limits determine the lead time.
79
+
80
+ ## Monitor several triggers
81
+
82
+ ```ruby
83
+ monitor = PSI::Monitor.new
84
+ monitor.on_error { |error| warn error.full_message }
85
+ monitor.on(:memory, stall: 0.1, window: 1.0) { |event| warn event }
86
+ monitor.on(:io, stall: 0.3, window: 2.0) { |event| warn event }
87
+ monitor.start
88
+
89
+ # Later, during shutdown:
90
+ monitor.stop
91
+ ```
92
+
93
+ Monitor uses one thread and `IO.select`'s priority set. Callback exceptions are
94
+ sent to `on_error` and do not stop monitoring. `stop` wakes the thread and
95
+ closes every trigger.
96
+
97
+ See `examples/load_shedding.rb` for Rack/Puma-style 503 shedding,
98
+ `examples/prometheus_exporter.rb` for a dependency-free metrics endpoint, and
99
+ `benchmark/monitor_idle.rb` for idle CPU measurement.
100
+
101
+ ## Unsupported and constrained environments
102
+
103
+ Requiring the gem always succeeds; `PSI.supported?` reports whether the
104
+ system-wide PSI directory exists, and use on an unsupported kernel raises
105
+ `PSI::UnsupportedError`.
106
+
107
+ | Environment | Limitation |
108
+ |---|---|
109
+ | macOS and Windows | No Linux procfs PSI interface. |
110
+ | WSL2 with an old or PSI-disabled kernel | `/proc/pressure` is absent. |
111
+ | Docker Desktop and other containers | `/proc/pressure` may represent the host; trigger writes are commonly denied. Prefer a delegated cgroup. |
112
+ | GitHub-hosted runners | Readings usually work, but trigger tests require root and the host kernel cannot be changed. |
113
+ | Linux before 4.20 | PSI is unavailable. Linux before 5.2 supports readings but not triggers. |
114
+
115
+ ## Development
116
+
117
+ ```sh
118
+ bundle install
119
+ bundle exec rake test:unit
120
+ bundle exec rbs validate
121
+ bundle exec yard
122
+ ```
123
+
124
+ Linux system tests require PSI trigger write permission:
125
+
126
+ ```sh
127
+ sudo --preserve-env=PATH,GEM_HOME,GEM_PATH bundle exec rake test:system
128
+ ```
129
+
130
+ ## License
131
+
132
+ MIT. See [LICENSE.txt](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "yard"
6
+
7
+ RSpec::Core::RakeTask.new(:spec) { |task| task.pattern = "spec/*_spec.rb" }
8
+ RSpec::Core::RakeTask.new("test:system") { |task| task.pattern = "spec/system/*_spec.rb" }
9
+ YARD::Rake::YardocTask.new
10
+
11
+ namespace :test do
12
+ task unit: :spec
13
+ task :gc_stress do
14
+ ruby "-Ilib", "spec/gc_stress.rb"
15
+ end
16
+ end
17
+
18
+ task :compile
19
+ task default: "test:unit"
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "psi"
4
+
5
+ duration = Float(ENV.fetch("DURATION", 5))
6
+ monitor = PSI::Monitor.new.on(:memory, stall: 1.0, window: 10.0, cgroup: ENV["CGROUP"]) {}
7
+ cpu_started = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
8
+ wall_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
9
+ monitor.start
10
+ sleep duration
11
+ monitor.stop
12
+ cpu = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) - cpu_started
13
+ wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_started
14
+ puts "idle CPU: #{(cpu / wall * 100).round(3)}% (#{wall.round(3)}s)"
@@ -0,0 +1,23 @@
1
+ # PSI priority-event investigation
2
+
3
+ Tested on 2026-08-23 with Ruby 3.4.10 and Linux 6.8.0-64-generic in a
4
+ privileged Colima container.
5
+
6
+ Two CPU-pressure triggers used `some 1000 500000`. Sixteen busy child
7
+ processes generated contention. Both Ruby APIs reported the priority event:
8
+
9
+ ```text
10
+ IO#wait(IO::PRIORITY, 5) => true
11
+ IO.select(nil, nil, [io], 5)[2] => [io]
12
+ ```
13
+
14
+ The implementation therefore uses `IO#wait` for a single trigger and the
15
+ exception set of `IO.select` for `PSI::Monitor`. No C extension is needed.
16
+
17
+ Trigger registration must terminate the single write with a newline or NUL.
18
+ An unterminated write produced `EINVAL` for valid boundary values on this
19
+ kernel, while the same values with a newline registered successfully.
20
+
21
+ The check requires Linux with PSI trigger support and write permission for
22
+ `/proc/pressure/cpu`. A privileged container was required; an ordinary
23
+ container returned `EACCES`.
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "psi"
4
+
5
+ # Rack middleware that briefly rejects requests after a memory-pressure event.
6
+ class PressureShedder
7
+ def initialize(app)
8
+ @app = app
9
+ @mutex = Mutex.new
10
+ @reject_until = 0
11
+ @monitor = PSI::Monitor.new
12
+ @monitor.on(:memory, stall: 0.2, window: 2.0, cgroup: ENV["CGROUP"] || PSI.current_cgroup) do |event|
13
+ warn event
14
+ @mutex.synchronize { @reject_until = monotonic + 10 }
15
+ end
16
+ @monitor.start
17
+ end
18
+
19
+ def call(env)
20
+ return [503, { "content-type" => "text/plain", "retry-after" => "10" }, ["server under pressure\n"]] if rejecting?
21
+
22
+ @app.call(env)
23
+ end
24
+
25
+ private
26
+
27
+ def rejecting?
28
+ @mutex.synchronize { monotonic < @reject_until }
29
+ end
30
+
31
+ def monotonic
32
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
+ end
34
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "psi"
5
+
6
+ server = TCPServer.new("0.0.0.0", Integer(ENV.fetch("PORT", 9394)))
7
+ cgroup = ENV["CGROUP"]
8
+
9
+ loop do
10
+ client = server.accept
11
+ body = PSI.read_all(cgroup: cgroup).flat_map do |resource, reading|
12
+ %i[some full].filter_map do |kind|
13
+ metrics = reading.public_send(kind)
14
+ next unless metrics
15
+
16
+ [10, 60, 300].map { |seconds| "psi_avg{resource=\"#{resource}\",kind=\"#{kind}\",seconds=\"#{seconds}\"} #{metrics.avg(seconds)}" } +
17
+ ["psi_total_microseconds{resource=\"#{resource}\",kind=\"#{kind}\"} #{metrics.total}"]
18
+ end
19
+ end.flatten.join("\n") << "\n"
20
+ client.write("HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}")
21
+ ensure
22
+ client&.close
23
+ end
data/lib/psi/event.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PSI
4
+ # A pressure event delivered by Monitor.
5
+ class Event
6
+ attr_reader :resource, :kind, :stall, :window, :reading, :at
7
+
8
+ def initialize(trigger:, reading:, at: Time.now)
9
+ @resource = trigger.resource
10
+ @kind = trigger.kind
11
+ @stall = trigger.stall
12
+ @window = trigger.window
13
+ @reading = reading
14
+ @at = at
15
+ end
16
+
17
+ # Formats the pressure and threshold for logs.
18
+ # @return [String]
19
+ def to_s
20
+ average = reading.public_send(kind)&.avg10
21
+ value = average ? " avg10=#{average}%" : ""
22
+ "#{resource} #{kind}#{value} (threshold #{duration(stall)}/#{duration(window)})"
23
+ end
24
+
25
+ private
26
+
27
+ def duration(seconds)
28
+ value = seconds < 1 ? seconds * 1000 : seconds
29
+ unit = seconds < 1 ? "ms" : "s"
30
+ "#{value.round(3).to_s.delete_suffix(".0")}#{unit}"
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PSI
4
+ # Multiplexes PSI triggers on one background thread.
5
+ class Monitor
6
+ def initialize
7
+ @entries = []
8
+ @wake_reader, @wake_writer = IO.pipe
9
+ @error_handler = ->(error) { warn "PSI monitor: #{error.message}" }
10
+ end
11
+
12
+ def on(resource, **options, &callback)
13
+ raise ArgumentError, "a callback is required" unless callback
14
+ raise Error, "monitor is already running" if @thread
15
+ raise Error, "monitor is stopped" if stopped?
16
+
17
+ @entries << [Trigger.new(resource, **options), callback]
18
+ self
19
+ end
20
+
21
+ def on_error(&handler)
22
+ raise ArgumentError, "an error handler is required" unless handler
23
+
24
+ @error_handler = handler
25
+ self
26
+ end
27
+
28
+ def start
29
+ raise Error, "monitor is stopped" if stopped?
30
+ return self if @thread&.alive?
31
+
32
+ @thread = Thread.new { run }
33
+ self
34
+ end
35
+
36
+ # Stops monitoring, wakes the select call, and closes every trigger.
37
+ # @return [Monitor]
38
+ def stop
39
+ return self if stopped?
40
+
41
+ @stop = true
42
+ @wake_writer.write_nonblock(".") if @thread&.alive?
43
+ @thread.join if @thread && @thread != Thread.current
44
+ close unless @thread
45
+ self
46
+ rescue Errno::EPIPE, IOError
47
+ self
48
+ end
49
+
50
+ private
51
+
52
+ def run
53
+ triggers = @entries.to_h { |trigger, callback| [trigger.to_io, [trigger, callback]] }
54
+ until @stop
55
+ readable, _, priority = IO.select([@wake_reader], nil, triggers.keys)
56
+ break if readable.include?(@wake_reader)
57
+
58
+ priority.each { |io| notify(*triggers.fetch(io)) }
59
+ end
60
+ ensure
61
+ close
62
+ end
63
+
64
+ def notify(trigger, callback)
65
+ callback.call(Event.new(trigger: trigger, reading: PSI.read(trigger.resource, cgroup: trigger.cgroup)))
66
+ rescue StandardError => e
67
+ report_error(e)
68
+ end
69
+
70
+ def report_error(error)
71
+ @error_handler.call(error)
72
+ rescue StandardError => e
73
+ warn "PSI monitor error handler failed: #{e.message}"
74
+ end
75
+
76
+ def close
77
+ @entries.each { |trigger, _| trigger.close }
78
+ @wake_reader.close unless @wake_reader.closed?
79
+ @wake_writer.close unless @wake_writer.closed?
80
+ end
81
+
82
+ def stopped?
83
+ @wake_reader.closed?
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PSI
4
+ # Pressure averages and the cumulative stall time for one PSI category.
5
+ class Metrics
6
+ attr_reader :total
7
+
8
+ def initialize(averages:, total:)
9
+ @averages = averages.freeze
10
+ @total = total
11
+ end
12
+
13
+ # Returns the rolling average for a window in seconds.
14
+ # @param seconds [Integer]
15
+ # @return [Float, nil]
16
+ def avg(seconds)
17
+ @averages[Integer(seconds)]
18
+ end
19
+
20
+ # @return [Float, nil] ten-second average
21
+ def avg10 = avg(10)
22
+ # @return [Float, nil] sixty-second average
23
+ def avg60 = avg(60)
24
+ # @return [Float, nil] three-hundred-second average
25
+ def avg300 = avg(300)
26
+
27
+ # Converts dynamic averages and total time to a hash.
28
+ # @return [Hash]
29
+ def to_h
30
+ @averages.sort.to_h { |seconds, value| [:"avg#{seconds}", value] }.merge(total: total)
31
+ end
32
+ end
33
+
34
+ # A snapshot read from one PSI resource.
35
+ class Reading
36
+ attr_reader :resource, :some, :full, :read_at, :metrics
37
+
38
+ # Parses the contents of a Linux pressure file.
39
+ # @param resource [Symbol]
40
+ # @param text [String]
41
+ # @param read_at [Time]
42
+ # @return [Reading]
43
+ def self.parse(resource, text, read_at: Time.now)
44
+ metrics = text.each_line.filter_map do |line|
45
+ kind, *fields = line.split
46
+ next unless kind
47
+
48
+ values = fields.filter_map { |field| field.split("=", 2) if field.include?("=") }.to_h
49
+ next unless %w[some full].include?(kind) || values.key?("total")
50
+
51
+ averages = values.filter_map do |key, value|
52
+ [Integer(key.delete_prefix("avg")), Float(value)] if key.match?(/\Aavg\d+\z/)
53
+ end.to_h
54
+ [kind.to_sym, Metrics.new(averages: averages, total: Integer(values.fetch("total")))]
55
+ end.to_h
56
+
57
+ new(resource: resource, some: metrics[:some], full: metrics[:full], read_at: read_at, metrics: metrics)
58
+ rescue ArgumentError, KeyError => e
59
+ raise Error, "invalid PSI data: #{e.message}"
60
+ end
61
+
62
+ def initialize(resource:, some:, full:, read_at:, metrics: nil)
63
+ @resource = resource
64
+ @some = some
65
+ @full = full
66
+ @read_at = read_at
67
+ @metrics = (metrics || { some: some, full: full }.compact).freeze
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PSI
4
+ # Stall ratios calculated from two consecutive readings.
5
+ Delta = Data.define(:some_ratio, :full_ratio, :elapsed)
6
+
7
+ # Calculates exact stall ratios from cumulative PSI totals.
8
+ class Sampler
9
+ def initialize(resource, cgroup: nil)
10
+ @resource = resource
11
+ @cgroup = cgroup
12
+ end
13
+
14
+ # Takes a sample and returns a delta after the first call.
15
+ # @return [Delta, nil]
16
+ def sample
17
+ reading = PSI.read(@resource, cgroup: @cgroup)
18
+ sampled_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
19
+ return remember(reading, sampled_at) unless @previous
20
+
21
+ elapsed = sampled_at - @sampled_at
22
+ previous = @previous
23
+ remember(reading, sampled_at)
24
+ Delta.new(
25
+ some_ratio: ratio(reading.some, previous.some, elapsed),
26
+ full_ratio: ratio(reading.full, previous.full, elapsed),
27
+ elapsed: elapsed
28
+ )
29
+ end
30
+
31
+ private
32
+
33
+ def remember(reading, sampled_at)
34
+ @previous = reading
35
+ @sampled_at = sampled_at
36
+ nil
37
+ end
38
+
39
+ def ratio(current, previous, elapsed)
40
+ return unless current && previous
41
+
42
+ [current.total - previous.total, 0].max / (elapsed * 1_000_000)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/wait"
4
+
5
+ module PSI
6
+ # A kernel PSI trigger tied to an open pressure file descriptor.
7
+ class Trigger
8
+ # Triggerable pressure categories.
9
+ KINDS = %i[some full].freeze
10
+
11
+ attr_reader :resource, :kind, :stall, :window, :cgroup
12
+
13
+ # Opens a trigger and closes it after the optional block.
14
+ # @return [Trigger, Object] the trigger, or the block result
15
+ def self.open(...)
16
+ trigger = new(...)
17
+ return trigger unless block_given?
18
+
19
+ begin
20
+ yield trigger
21
+ ensure
22
+ trigger.close
23
+ end
24
+ end
25
+
26
+ def initialize(resource, kind: :some, stall:, window:, cgroup: nil)
27
+ @resource = resource.respond_to?(:to_sym) ? resource.to_sym : resource
28
+ @kind = kind.respond_to?(:to_sym) ? kind.to_sym : kind
29
+ @stall = Float(stall)
30
+ @window = Float(window)
31
+ @cgroup = cgroup
32
+ validate!
33
+ register
34
+ end
35
+
36
+ def wait(timeout: nil)
37
+ raise Error, "trigger is closed" if closed?
38
+
39
+ !!@io.wait(IO::PRIORITY, timeout)
40
+ end
41
+
42
+ # Closes the descriptor and unregisters the kernel trigger.
43
+ # @return [nil]
44
+ def close
45
+ @io&.close unless closed?
46
+ nil
47
+ end
48
+
49
+ def closed?
50
+ !@io || @io.closed?
51
+ end
52
+
53
+ def to_io
54
+ raise Error, "trigger is closed" if closed?
55
+
56
+ @io
57
+ end
58
+
59
+ private
60
+
61
+ def validate!
62
+ raise ArgumentError, "kind must be :some or :full" unless KINDS.include?(kind)
63
+ raise ArgumentError, "stall must be greater than zero" unless stall.finite? && stall_us.positive?
64
+ raise ArgumentError, "window must be between 0.5 and 10.0 seconds" unless window.finite? && window.between?(0.5, 10.0)
65
+ raise ArgumentError, "stall must not exceed window" if stall > window
66
+ raise ArgumentError, "cpu has no full pressure metric" if resource == :cpu && kind == :full && !cgroup
67
+
68
+ PSI.path_for(resource, cgroup: cgroup)
69
+ warn "PSI: cpu full pressure may be unavailable for this cgroup" if resource == :cpu && kind == :full
70
+ end
71
+
72
+ def register
73
+ @io = File.open(PSI.path_for(resource, cgroup: cgroup), File::RDWR)
74
+ @io.sync = true
75
+ @io.write("#{kind} #{stall_us} #{window_us}\n")
76
+ rescue Errno::ENOENT => e
77
+ discard_io
78
+ raise UnsupportedError, e.message
79
+ rescue Errno::EINVAL, Errno::EBUSY, Errno::ENOMEM, Errno::EOPNOTSUPP, Errno::EACCES, Errno::EPERM => e
80
+ discard_io
81
+ raise TriggerError, "cannot register #{resource} #{kind} trigger (#{e.class.name}): #{e.message}"
82
+ rescue StandardError
83
+ discard_io
84
+ raise
85
+ end
86
+
87
+ def stall_us = (stall * 1_000_000).round
88
+ def window_us = (window * 1_000_000).round
89
+
90
+ def discard_io
91
+ @io&.close
92
+ @io = nil
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PSI
4
+ # Current gem version.
5
+ VERSION = "1.0.0"
6
+ end
data/lib/psi.rb ADDED
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "psi/version"
4
+ require_relative "psi/reading"
5
+ require_relative "psi/sampler"
6
+ require_relative "psi/trigger"
7
+ require_relative "psi/event"
8
+ require_relative "psi/monitor"
9
+
10
+ # Reads Linux Pressure Stall Information (PSI).
11
+ module PSI
12
+ # Resource names exposed by Linux PSI.
13
+ RESOURCES = %i[cpu memory io irq].freeze
14
+
15
+ # Base error for PSI-specific failures.
16
+ class Error < StandardError; end
17
+ # Raised when the running kernel does not expose the requested PSI feature.
18
+ class UnsupportedError < Error; end
19
+ # Raised when the kernel rejects trigger registration.
20
+ class TriggerError < Error; end
21
+
22
+ class << self
23
+ attr_writer :procfs_root
24
+
25
+ def procfs_root
26
+ @procfs_root ||= "/proc"
27
+ end
28
+
29
+ def supported?
30
+ File.directory?(File.join(procfs_root, "pressure"))
31
+ end
32
+
33
+ # Returns resources available under the configured procfs root.
34
+ # @return [Array<Symbol>]
35
+ def resources
36
+ RESOURCES.select { |resource| File.file?(path_for(resource)) }
37
+ end
38
+
39
+ # Reads one system-wide or cgroup pressure resource.
40
+ # @param resource [Symbol, String]
41
+ # @param cgroup [String, nil] cgroup v2 directory, or nil for procfs
42
+ # @return [Reading]
43
+ def read(resource, cgroup: nil)
44
+ resource = validate_resource(resource)
45
+ Reading.parse(resource, File.read(path_for(resource, cgroup: cgroup)))
46
+ rescue Errno::ENOENT, Errno::EOPNOTSUPP => e
47
+ raise UnsupportedError, e.message
48
+ end
49
+
50
+ # Reads every pressure resource available at the target location.
51
+ # @param cgroup [String, nil] cgroup v2 directory, or nil for procfs
52
+ # @return [Hash{Symbol => Reading}]
53
+ def read_all(cgroup: nil)
54
+ available = cgroup ? RESOURCES.select { |resource| File.file?(path_for(resource, cgroup: cgroup)) } : resources
55
+ available.to_h { |resource| [resource, read(resource, cgroup: cgroup)] }
56
+ end
57
+
58
+ # Resolves this process's unified cgroup v2 directory.
59
+ # @return [String]
60
+ def current_cgroup
61
+ entry = File.foreach(File.join(procfs_root, "self/cgroup")).find { |line| line.start_with?("0::") }
62
+ raise UnsupportedError, "cgroup v2 is not mounted" unless entry
63
+
64
+ File.join("/sys/fs/cgroup", entry.split("::", 2).last.strip.delete_prefix("/"))
65
+ rescue Errno::ENOENT => e
66
+ raise UnsupportedError, e.message
67
+ end
68
+
69
+ # Builds the backing file path for a resource.
70
+ # @api private
71
+ def path_for(resource, cgroup: nil)
72
+ resource = validate_resource(resource)
73
+ return File.join(cgroup, "#{resource}.pressure") if cgroup
74
+
75
+ File.join(procfs_root, "pressure", resource.to_s)
76
+ end
77
+
78
+ private
79
+
80
+ def validate_resource(resource)
81
+ resource = resource.to_sym if resource.respond_to?(:to_sym)
82
+ raise ArgumentError, "unknown resource: #{resource.inspect}" unless RESOURCES.include?(resource)
83
+
84
+ resource
85
+ end
86
+ end
87
+ end
data/sig/psi.rbs ADDED
@@ -0,0 +1,91 @@
1
+ module PSI
2
+ VERSION: String
3
+ RESOURCES: Array[Symbol]
4
+
5
+ class Error < StandardError
6
+ end
7
+
8
+ class UnsupportedError < Error
9
+ end
10
+
11
+ class TriggerError < Error
12
+ end
13
+
14
+ def self.procfs_root: () -> String
15
+ def self.procfs_root=: (String?) -> String?
16
+ def self.supported?: () -> bool
17
+ def self.resources: () -> Array[Symbol]
18
+ def self.read: (Symbol | String resource, ?cgroup: String?) -> Reading
19
+ def self.read_all: (?cgroup: String?) -> Hash[Symbol, Reading]
20
+ def self.current_cgroup: () -> String
21
+ def self.path_for: (Symbol | String resource, ?cgroup: String?) -> String
22
+
23
+ class Metrics
24
+ attr_reader total: Integer
25
+
26
+ def initialize: (averages: Hash[Integer, Float], total: Integer) -> void
27
+ def avg: (Integer seconds) -> Float?
28
+ def avg10: () -> Float?
29
+ def avg60: () -> Float?
30
+ def avg300: () -> Float?
31
+ def to_h: () -> Hash[Symbol, Float | Integer]
32
+ end
33
+
34
+ class Reading
35
+ attr_reader resource: Symbol
36
+ attr_reader some: Metrics?
37
+ attr_reader full: Metrics?
38
+ attr_reader read_at: Time
39
+ attr_reader metrics: Hash[Symbol, Metrics]
40
+
41
+ def self.parse: (Symbol resource, String text, ?read_at: Time) -> Reading
42
+ def initialize: (resource: Symbol, some: Metrics?, full: Metrics?, read_at: Time, ?metrics: Hash[Symbol, Metrics]?) -> void
43
+ end
44
+
45
+ class Delta
46
+ attr_reader some_ratio: Float?
47
+ attr_reader full_ratio: Float?
48
+ attr_reader elapsed: Float
49
+ end
50
+
51
+ class Sampler
52
+ def initialize: (Symbol | String resource, ?cgroup: String?) -> void
53
+ def sample: () -> Delta?
54
+ end
55
+
56
+ class Trigger
57
+ attr_reader resource: Symbol
58
+ attr_reader kind: Symbol
59
+ attr_reader stall: Float
60
+ attr_reader window: Float
61
+ attr_reader cgroup: String?
62
+
63
+ def self.open: [A] (Symbol | String resource, ?kind: Symbol | String, stall: Numeric, window: Numeric, ?cgroup: String?) { (Trigger) -> A } -> A
64
+ | (Symbol | String resource, ?kind: Symbol | String, stall: Numeric, window: Numeric, ?cgroup: String?) -> Trigger
65
+ def initialize: (Symbol | String resource, ?kind: Symbol | String, stall: Numeric, window: Numeric, ?cgroup: String?) -> void
66
+ def wait: (?timeout: Numeric?) -> bool
67
+ def close: () -> nil
68
+ def closed?: () -> bool
69
+ def to_io: () -> IO
70
+ end
71
+
72
+ class Event
73
+ attr_reader resource: Symbol
74
+ attr_reader kind: Symbol
75
+ attr_reader stall: Float
76
+ attr_reader window: Float
77
+ attr_reader reading: Reading
78
+ attr_reader at: Time
79
+
80
+ def initialize: (trigger: Trigger, reading: Reading, ?at: Time) -> void
81
+ def to_s: () -> String
82
+ end
83
+
84
+ class Monitor
85
+ def initialize: () -> void
86
+ def on: (Symbol | String resource, **untyped options) { (Event) -> void } -> self
87
+ def on_error: () { (Exception) -> void } -> self
88
+ def start: () -> self
89
+ def stop: () -> self
90
+ end
91
+ end
metadata ADDED
@@ -0,0 +1,60 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: psi
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: A pure Ruby API for Linux PSI readings, triggers, and monitoring.
13
+ email:
14
+ - t.yudai92@gmail.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - ".yardopts"
20
+ - LICENSE.txt
21
+ - README.md
22
+ - Rakefile
23
+ - benchmark/monitor_idle.rb
24
+ - docs/poll-investigation.md
25
+ - examples/load_shedding.rb
26
+ - examples/prometheus_exporter.rb
27
+ - lib/psi.rb
28
+ - lib/psi/event.rb
29
+ - lib/psi/monitor.rb
30
+ - lib/psi/reading.rb
31
+ - lib/psi/sampler.rb
32
+ - lib/psi/trigger.rb
33
+ - lib/psi/version.rb
34
+ - sig/psi.rbs
35
+ homepage: https://github.com/ydah/psi
36
+ licenses:
37
+ - MIT
38
+ metadata:
39
+ allowed_push_host: https://rubygems.org
40
+ homepage_uri: https://github.com/ydah/psi
41
+ source_code_uri: https://github.com/ydah/psi
42
+ rubygems_mfa_required: 'true'
43
+ rdoc_options: []
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: 3.2.0
51
+ required_rubygems_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ requirements: []
57
+ rubygems_version: 4.0.19
58
+ specification_version: 4
59
+ summary: Read and monitor Linux Pressure Stall Information
60
+ test_files: []