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 +7 -0
- data/.yardopts +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +132 -0
- data/Rakefile +19 -0
- data/benchmark/monitor_idle.rb +14 -0
- data/docs/poll-investigation.md +23 -0
- data/examples/load_shedding.rb +34 -0
- data/examples/prometheus_exporter.rb +23 -0
- data/lib/psi/event.rb +33 -0
- data/lib/psi/monitor.rb +86 -0
- data/lib/psi/reading.rb +70 -0
- data/lib/psi/sampler.rb +45 -0
- data/lib/psi/trigger.rb +95 -0
- data/lib/psi/version.rb +6 -0
- data/lib/psi.rb +87 -0
- data/sig/psi.rbs +91 -0
- metadata +60 -0
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
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
|
data/lib/psi/monitor.rb
ADDED
|
@@ -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
|
data/lib/psi/reading.rb
ADDED
|
@@ -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
|
data/lib/psi/sampler.rb
ADDED
|
@@ -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
|
data/lib/psi/trigger.rb
ADDED
|
@@ -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
|
data/lib/psi/version.rb
ADDED
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: []
|