duel-memcached 0.1.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/CHANGELOG.md +52 -0
- data/LICENSE +21 -0
- data/README.md +114 -0
- data/lib/duel/memcached/version.rb +7 -0
- data/lib/duel/memcached.rb +235 -0
- data/sig/duel/memcached.rbs +92 -0
- metadata +65 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4100ff714345815d7406440fc25bab63b6e3079aab787089d3f43f226fb1dd17
|
|
4
|
+
data.tar.gz: 83f9ed2340221e9b109c1eb84df79eec67a9a6b1f6de2c066d599a6753b17b3c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6d3f2f03e8840190589dd12a95fddc233c776cc5d0bff6d5a9c10af85bd7312bdd4e418d4185f9c668d04febf6c80de1c3c45527f70ea3a565f9479fcbcbc68a
|
|
7
|
+
data.tar.gz: 4477a598c7dafc1340e19c28c8ec68f25932512933fc2fc228b9fa96eec3a0a3105d8e3e6390c0a2b69531867364b8bbbb9244a603bffcbde29ec6df0a22b662
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-10-24
|
|
11
|
+
|
|
12
|
+
The first release. duel-memcached is a Duel plugin, open source under
|
|
13
|
+
the MIT license. It needs Ruby 3.4 or later, Duel 0.1, and `memcached`
|
|
14
|
+
(1.6 or later) on the `PATH`.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `c.use :memcached`: a memcached server for each Duel worker, on a
|
|
19
|
+
Unix socket, and the `memcached_operations`, `memcached_misses` and
|
|
20
|
+
`memcached_bytes` metrics (and `memcached_cpu`, with `cpu: true`), from
|
|
21
|
+
its statistics, read over a connection of the plugin's own whose
|
|
22
|
+
traffic isn't counted.
|
|
23
|
+
- `Duel::Memcached.servers` and `Duel::Memcached.flush`, for benchmarks:
|
|
24
|
+
`flush` opens a connection of its own, from the worker's
|
|
25
|
+
`DUEL_MEMCACHED_SERVERS`. It's for `setup` hooks, and `once` and
|
|
26
|
+
`untimed` blocks: while a benchmark is measured (`Duel.measuring?`) it raises `Duel::Error`,
|
|
27
|
+
as the flush would be counted.
|
|
28
|
+
- `c.use :memcached` returns the plugin's own connection, opened in each
|
|
29
|
+
worker (`c.in_worker`) and closed as it ends (`at_stop`). Its
|
|
30
|
+
`value` fails outside a worker, saying so. The module keeps no
|
|
31
|
+
connection.
|
|
32
|
+
- `memcached_cpu` is a `kind: :ratio` measure, left out of Duel's
|
|
33
|
+
across-the-board check (`across: false`), in microseconds
|
|
34
|
+
(`unit: "µs"`), so reports show it as time. `memcached_bytes` is in
|
|
35
|
+
bytes (`unit: "B"`), shown after the number. `memcached_operations`
|
|
36
|
+
and `memcached_misses` are in operations and misses (`unit:`, each
|
|
37
|
+
with its plural), so reports read "1 operation → 2 operations".
|
|
38
|
+
- Its metrics are costly to read (Duel's `costly`): a benchmark with an
|
|
39
|
+
`around_iteration` hook reads the server's statistics only as each
|
|
40
|
+
sample starts and stops, not twice an iteration, and what the hook
|
|
41
|
+
sends the server (a transaction's `BEGIN` and `ROLLBACK`, say) is
|
|
42
|
+
learned in each worker and subtracted.
|
|
43
|
+
- Reading the statistics allocates little: the reply is read whole into
|
|
44
|
+
a buffer the connection reuses (`Connection#stats_reply`), and each
|
|
45
|
+
metric's statistics are found and read in place, digit by digit. On
|
|
46
|
+
memcached 1.6's 94 statistics, `memcached_cpu`'s reading allocates
|
|
47
|
+
nothing, and the counted metrics' allocates only their Hash.
|
|
48
|
+
`Duel::Memcached.sum` takes that reply.
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
[Unreleased]: https://github.com/sferik/duel-ruby/compare/duel-memcached-v0.1.0...HEAD
|
|
52
|
+
[0.1.0]: https://github.com/sferik/duel-ruby/releases/tag/duel-memcached-v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Erik Berlin
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# duel-memcached
|
|
2
|
+
|
|
3
|
+
[](https://github.com/sferik/duel-ruby)
|
|
4
|
+
[](https://github.com/sferik/duel-ruby/actions/workflows/plugin-memcached.yml)
|
|
5
|
+
[](https://rubygems.org/gems/duel-memcached)
|
|
6
|
+
|
|
7
|
+
A [Duel](https://github.com/sferik/duel-ruby) plugin for code that talks
|
|
8
|
+
to memcached: each Duel worker gets a memcached server of its own, and
|
|
9
|
+
every benchmark is judged on what memcached did for it too.
|
|
10
|
+
|
|
11
|
+
| Metric | What it is | A regression |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `memcached_operations` | the keys it fetched, stored, touched, deleted, incremented or decremented, and flushes | any increase |
|
|
14
|
+
| `memcached_misses` | the operations that found no key | any increase |
|
|
15
|
+
| `memcached_bytes` | the bytes it read and wrote: requests, replies, keys and values | more than one an iteration |
|
|
16
|
+
| `memcached_cpu` | its CPU time, in µs (only with `cpu: true`) | > 5% slower, 99% confident |
|
|
17
|
+
|
|
18
|
+
So a change that fetches one more key per request, stops finding what it
|
|
19
|
+
cached, or caches bigger values fails even when its CPU time on the Ruby
|
|
20
|
+
side doesn't change:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
REGRESSION store: worse memcached_operations, memcached_bytes in every measurement
|
|
24
|
+
memcached_operations: 1 operation → 2 operations an iteration
|
|
25
|
+
memcached_bytes: 34 B → 70 B an iteration
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
# Gemfile
|
|
32
|
+
group :development do
|
|
33
|
+
gem "duel-memcached"
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
> [!NOTE]
|
|
38
|
+
> It needs `memcached` (1.6 or later) on the `PATH`.
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# .duel.rb
|
|
44
|
+
Duel.configure { |c| c.use :memcached }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Each worker's server listens only on a Unix socket in the worker's own
|
|
48
|
+
directory, with 32 MB of memory and one thread. The socket's path is in
|
|
49
|
+
`DUEL_MEMCACHED_SERVERS`, and in `MEMCACHE_SERVERS` too, so a client that
|
|
50
|
+
reads that (Dalli does) finds it and nothing reaches another memcached.
|
|
51
|
+
Benchmarks can call `Duel::Memcached.servers` and
|
|
52
|
+
`Duel::Memcached.flush`:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
# duel/cache_duel.rb
|
|
56
|
+
duel "cache: a hit" do
|
|
57
|
+
once do
|
|
58
|
+
Duel::Memcached.flush # empties the worker's server (over a connection of its own)
|
|
59
|
+
@cache = Dalli::Client.new(Duel::Memcached.servers) # its socket
|
|
60
|
+
@cache.set("k", "v")
|
|
61
|
+
end
|
|
62
|
+
@cache.get("k")
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
> [!IMPORTANT]
|
|
67
|
+
> `Duel::Memcached.flush` is for setting up: call it in a `setup` hook,
|
|
68
|
+
> or a `once` or `untimed` block. While a benchmark is measured (`Duel.measuring?`), it
|
|
69
|
+
> raises `Duel::Error` rather than have the flush counted.
|
|
70
|
+
|
|
71
|
+
Options:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
c.use :memcached,
|
|
75
|
+
server: "/opt/memcached/bin/memcached", # the program (default: DUEL_MEMCACHED_SERVER, or memcached)
|
|
76
|
+
args: ["-m", "256"], # more of its options (they win)
|
|
77
|
+
env: %w[CACHE_SERVERS], # variables given its socket besides DUEL_MEMCACHED_SERVERS (default: MEMCACHE_SERVERS)
|
|
78
|
+
cpu: true # judge memcached_cpu too
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The metrics come from the server's `stats`, read over a connection of
|
|
82
|
+
the plugin's own, opened as each worker starts (Duel's `c.in_worker`)
|
|
83
|
+
and closed as it ends. `c.use :memcached` returns it, for a `.duel.rb`
|
|
84
|
+
that reads the server too (`connection.value`, in a worker only).
|
|
85
|
+
`Duel::Memcached.flush` opens a connection of its own, from the worker's
|
|
86
|
+
`DUEL_MEMCACHED_SERVERS`: a flush is an operation, and its bytes count
|
|
87
|
+
as any client's (so it fails while a benchmark is measured).
|
|
88
|
+
`memcached_operations` counts keys, not commands: a
|
|
89
|
+
`get_multi` of ten keys is ten. Memcached keeps no count of commands or
|
|
90
|
+
replies, so a change from one `get_multi` to ten `get`s shows in
|
|
91
|
+
`memcached_bytes`, not in operations.
|
|
92
|
+
|
|
93
|
+
`memcached_bytes` leaves out the plugin's own traffic, to the byte (a
|
|
94
|
+
reading's reply is longer when the numbers in it are), so it's exact.
|
|
95
|
+
But what the server makes up (CAS ids) grows a digit now and then, and so
|
|
96
|
+
may values of your code's (times, ids), so a byte more an iteration
|
|
97
|
+
isn't a regression.
|
|
98
|
+
|
|
99
|
+
`memcached_cpu` is off unless asked for: memcached's CPU time (from
|
|
100
|
+
`rusage_user` and `rusage_system`, to the microsecond) is the whole
|
|
101
|
+
process's, background threads and all, and depends on how fast the
|
|
102
|
+
client sends it work. It's noisy, and what it would catch (more work for
|
|
103
|
+
the server) usually shows exactly in operations and bytes first. When
|
|
104
|
+
it's on, it's judged above 300µs a sample (on either version), against
|
|
105
|
+
a limit of 1.05, and not across the board (where Duel judges every
|
|
106
|
+
benchmark's median together, with a margin a quarter of the limit's:
|
|
107
|
+
too fine for it).
|
|
108
|
+
|
|
109
|
+
Duel's `c.measure` changes any of their settings, or leaves one out:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
c.measure("memcached_bytes", tolerance: 8) # values whose length varies more
|
|
113
|
+
c.measure("memcached_misses", skip: true)
|
|
114
|
+
```
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "duel"
|
|
4
|
+
require "socket"
|
|
5
|
+
require_relative "memcached/version"
|
|
6
|
+
|
|
7
|
+
module Duel
|
|
8
|
+
# A memcached server of each worker's own, and what it did for each
|
|
9
|
+
# benchmark:
|
|
10
|
+
#
|
|
11
|
+
# - memcached_operations: the keys it fetched, stored, touched,
|
|
12
|
+
# deleted, incremented or decremented, and flushes (counted)
|
|
13
|
+
# - memcached_misses: the operations that found no key (counted)
|
|
14
|
+
# - memcached_bytes: the bytes it read and wrote, the protocol's and
|
|
15
|
+
# the values' (counted)
|
|
16
|
+
# - memcached_cpu: its CPU time, in microseconds (judged like CPU
|
|
17
|
+
# time; only with cpu: true)
|
|
18
|
+
#
|
|
19
|
+
# # .duel.rb
|
|
20
|
+
# Duel.configure { |c| c.use :memcached }
|
|
21
|
+
#
|
|
22
|
+
# Benchmarks can call Duel::Memcached.servers, and
|
|
23
|
+
# Duel::Memcached.flush in setup (or untimed blocks): while a benchmark
|
|
24
|
+
# is measured, it fails.
|
|
25
|
+
#
|
|
26
|
+
# The server listens only on a Unix socket in the worker's directory,
|
|
27
|
+
# with one thread (so its statistics are up to date when they're read);
|
|
28
|
+
# the socket's path is in DUEL_MEMCACHED_SERVERS, and in
|
|
29
|
+
# MEMCACHE_SERVERS too (unless +env+ names others), so a client that
|
|
30
|
+
# reads that (Dalli does) finds it.
|
|
31
|
+
module Memcached
|
|
32
|
+
Duel.plugin(:memcached, self)
|
|
33
|
+
|
|
34
|
+
SERVERS = "DUEL_MEMCACHED_SERVERS"
|
|
35
|
+
|
|
36
|
+
# Configures Duel for the plugin (c.use :memcached): the server
|
|
37
|
+
# (+server+ is the program, +args+ more of its options) and its
|
|
38
|
+
# metrics, memcached_cpu too with +cpu+. Returns the plugin's own
|
|
39
|
+
# connection to it, a worker's (see Config#in_worker, and in tests,
|
|
40
|
+
# Duel::Testing.in_worker).
|
|
41
|
+
def self.configure(config, server: ENV.fetch("DUEL_MEMCACHED_SERVER", "memcached"), args: [], env: %w[MEMCACHE_SERVERS], cpu: false)
|
|
42
|
+
config.prepare(:worker) do |w|
|
|
43
|
+
socket = w.path("memcached.sock")
|
|
44
|
+
w.spawn(*[server, "-s", socket, "-m", "32", "-t", "1"] + args)
|
|
45
|
+
w.wait_until { Connection.ready?(socket) }
|
|
46
|
+
([SERVERS] + env).to_h { |name| [name, socket] }
|
|
47
|
+
end
|
|
48
|
+
connection = config.in_worker { |w| Connection.new(servers).tap { |memcached| w.at_stop { memcached.close } } }
|
|
49
|
+
config.metric(METRICS) { stats(connection.value) }.costly
|
|
50
|
+
config.metric("memcached_cpu", **CPU) { cpu_time(connection.value) }.costly if cpu
|
|
51
|
+
connection
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# (Reading the statistics costs these nothing: stats isn't an
|
|
55
|
+
# operation, and the plugin's own bytes are taken away.)
|
|
56
|
+
METRICS = {
|
|
57
|
+
"memcached_operations" => {kind: :counted, unit: %w[operation operations], calibrate: false},
|
|
58
|
+
"memcached_misses" => {kind: :counted, unit: %w[miss misses], calibrate: false},
|
|
59
|
+
# Values the server makes up (CAS ids, say) grow a digit now and
|
|
60
|
+
# then, and so may the code's own: a byte more an iteration isn't a
|
|
61
|
+
# regression.
|
|
62
|
+
"memcached_bytes" => {kind: :counted, unit: "B", tolerance: 1, calibrate: false}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# memcached_cpu's settings. Its CPU time is the whole process's (its
|
|
66
|
+
# background threads too), and swings with how the client paces it:
|
|
67
|
+
# noisy, so judged only above 300µs, against a limit of its own, not
|
|
68
|
+
# across the board (where the margin is a quarter of that), and only
|
|
69
|
+
# when asked for. (A metric of its own: read around every sample, as a
|
|
70
|
+
# ratio one is, where the counted ones are read only in counting
|
|
71
|
+
# samples.)
|
|
72
|
+
CPU = {kind: :ratio, unit: "µs", floor: 300, limit: 1.05, across: false}
|
|
73
|
+
|
|
74
|
+
# The statistics each is the sum of.
|
|
75
|
+
OPERATIONS = %w[cmd_get cmd_set cmd_touch cmd_flush delete_hits delete_misses incr_hits incr_misses decr_hits decr_misses].freeze
|
|
76
|
+
MISSES = %w[get_misses touch_misses delete_misses incr_misses decr_misses cas_misses].freeze
|
|
77
|
+
BYTES = %w[bytes_read bytes_written].freeze
|
|
78
|
+
RUSAGE = %w[rusage_user rusage_system].freeze
|
|
79
|
+
|
|
80
|
+
# What a statistic's line starts with, by its name (made once each).
|
|
81
|
+
NEEDLES = Hash.new { |needles, name| needles[name] = "STAT #{name} " }
|
|
82
|
+
|
|
83
|
+
# What the worker's server has done so far, read over +connection+
|
|
84
|
+
# (the plugin's own): its traffic isn't counted (it's known to the
|
|
85
|
+
# byte, while what readings write varies with the numbers in them).
|
|
86
|
+
# Read around every counting sample, it allocates only its Hash.
|
|
87
|
+
def self.stats(connection)
|
|
88
|
+
stats = connection.stats_reply
|
|
89
|
+
{"memcached_operations" => sum(stats, OPERATIONS),
|
|
90
|
+
"memcached_misses" => sum(stats, MISSES),
|
|
91
|
+
"memcached_bytes" => sum(stats, BYTES) - connection.own}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Its CPU time so far, in microseconds, read over +connection+: the
|
|
95
|
+
# readings' too, as they're too slight to matter and too unsteady to
|
|
96
|
+
# take away. Read around every sample, it allocates nothing.
|
|
97
|
+
def self.cpu_time(connection) = sum(connection.stats_reply, RUSAGE)
|
|
98
|
+
|
|
99
|
+
# The sum of +names+ in +stats+, a stats reply as the server sent it.
|
|
100
|
+
def self.sum(stats, names) = names.sum { |name| value(stats, name) }
|
|
101
|
+
|
|
102
|
+
# The value of +name+ in +stats+ (a stats reply), read in place, digit
|
|
103
|
+
# by digit, so as to allocate nothing: rusage_* are seconds with six
|
|
104
|
+
# decimals, microseconds without the point.
|
|
105
|
+
def self.value(stats, name)
|
|
106
|
+
needle = NEEDLES[name]
|
|
107
|
+
at = stats.byteindex(needle) or raise KeyError, "key not found: #{name.inspect}"
|
|
108
|
+
at += needle.bytesize
|
|
109
|
+
raise ArgumentError, "memcached: #{name} isn't a number" unless stats.match?(/\G\d+(?:\.\d+)?\r/, at)
|
|
110
|
+
|
|
111
|
+
value = 0
|
|
112
|
+
until (byte = stats.getbyte(at)) == 13 # "\r", where the number ends
|
|
113
|
+
# (A digit, or the point: the value matched, so it's never nil.)
|
|
114
|
+
value = (value * 10) + byte - 48 unless byte == 46 # steep:ignore UnresolvedOverloading
|
|
115
|
+
at += 1
|
|
116
|
+
end
|
|
117
|
+
value
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# For benchmarks: the worker's server (its socket's path).
|
|
121
|
+
def self.servers = ENV.fetch(SERVERS)
|
|
122
|
+
|
|
123
|
+
# Empties it, over a connection of its own (opened and closed: a Unix
|
|
124
|
+
# socket's is cheap). A flush is an operation, and its bytes count as
|
|
125
|
+
# any client's: so it's for setup or untimed blocks only, and fails
|
|
126
|
+
# while a benchmark is measured (Duel.measuring?).
|
|
127
|
+
def self.flush
|
|
128
|
+
Duel.unmeasured!("Duel::Memcached.flush")
|
|
129
|
+
|
|
130
|
+
connection = Connection.new(servers)
|
|
131
|
+
begin
|
|
132
|
+
connection.flush_all
|
|
133
|
+
ensure
|
|
134
|
+
connection.close
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The least a connection needs to read a server's statistics (text
|
|
139
|
+
# protocol): not the code's own, whose operations are what's
|
|
140
|
+
# measured. It counts its own traffic.
|
|
141
|
+
class Connection
|
|
142
|
+
# Its bytes the server had counted when it answered the latest
|
|
143
|
+
# stats: those sent (that stats command's too), and those received
|
|
144
|
+
# before its reply.
|
|
145
|
+
attr_reader :own
|
|
146
|
+
|
|
147
|
+
# The most read at a time: a whole stats reply (some 2.5KB) at once.
|
|
148
|
+
READ = 65_536
|
|
149
|
+
|
|
150
|
+
def self.ready?(path)
|
|
151
|
+
socket = UNIXSocket.new(path)
|
|
152
|
+
begin
|
|
153
|
+
socket.write("version\r\n")
|
|
154
|
+
socket.gets.to_s.start_with?("VERSION ")
|
|
155
|
+
ensure
|
|
156
|
+
socket.close
|
|
157
|
+
end
|
|
158
|
+
rescue SystemCallError, IOError # (JRuby's refusal is an IOError)
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def initialize(path)
|
|
163
|
+
@socket = UNIXSocket.new(path)
|
|
164
|
+
@sent = @received = @own = 0
|
|
165
|
+
@reply = +""
|
|
166
|
+
@chunk = +""
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The server's statistics, by name: each line's name and value
|
|
170
|
+
# sliced from the reply, without a String for each line.
|
|
171
|
+
def stats
|
|
172
|
+
reply = stats_reply
|
|
173
|
+
stats = {} #: Hash[String, String]
|
|
174
|
+
line = 0
|
|
175
|
+
while reply.getbyte(line) == 83 # "S": a STAT line, not the END after them
|
|
176
|
+
name = line + 5 # after "STAT "
|
|
177
|
+
space = reply.byteindex(" ", name) #: Integer
|
|
178
|
+
line = reply.byteindex("\r\n", space) #: Integer
|
|
179
|
+
key = reply.byteslice(name, space - name) #: String
|
|
180
|
+
value = reply.byteslice(space + 1, line - space - 1) #: String
|
|
181
|
+
stats[key] = value
|
|
182
|
+
line += 2
|
|
183
|
+
end
|
|
184
|
+
stats
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The server's statistics as it sent them (a line each, then END),
|
|
188
|
+
# read whole into a buffer of the connection's, which the next
|
|
189
|
+
# reading reuses: nothing's allocated.
|
|
190
|
+
def stats_reply
|
|
191
|
+
request("stats\r\n")
|
|
192
|
+
@own = @sent + @received
|
|
193
|
+
@reply.clear
|
|
194
|
+
receive until @reply.end_with?("\r\n") && (!@reply.start_with?("STAT ") || @reply.end_with?("\nEND\r\n"))
|
|
195
|
+
raise "memcached: #{@reply.chomp}" unless @reply.start_with?("STAT ") || @reply == "END\r\n"
|
|
196
|
+
|
|
197
|
+
@reply
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Empties the server; returns its reply.
|
|
201
|
+
def flush_all
|
|
202
|
+
request("flush_all\r\n")
|
|
203
|
+
reply
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def close = @socket.close
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
# +command+ is a line, its CRLF too. (Written straight to the
|
|
211
|
+
# socket, which write would allocate for.)
|
|
212
|
+
def request(command)
|
|
213
|
+
@sent += @socket.syswrite(command)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Appends what the server sent next to the reply.
|
|
217
|
+
def receive
|
|
218
|
+
@socket.readpartial(READ, @chunk)
|
|
219
|
+
@received += @chunk.bytesize
|
|
220
|
+
@reply << @chunk
|
|
221
|
+
rescue EOFError
|
|
222
|
+
raise IOError, "memcached closed the connection"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def reply
|
|
226
|
+
line = @socket.gets or raise IOError, "memcached closed the connection"
|
|
227
|
+
@received += line.bytesize
|
|
228
|
+
line = line.chomp
|
|
229
|
+
raise "memcached: #{line}" if line.match?(/\A(?:ERROR|CLIENT_ERROR|SERVER_ERROR)/)
|
|
230
|
+
|
|
231
|
+
line
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module Duel
|
|
2
|
+
# A memcached server of each worker's own, and what it did for each
|
|
3
|
+
# benchmark (memcached_operations, memcached_misses, memcached_bytes,
|
|
4
|
+
# and with cpu: true memcached_cpu).
|
|
5
|
+
module Memcached
|
|
6
|
+
VERSION: String
|
|
7
|
+
|
|
8
|
+
SERVERS: String
|
|
9
|
+
|
|
10
|
+
METRICS: Hash[String, Metric::settings]
|
|
11
|
+
|
|
12
|
+
CPU: Metric::settings
|
|
13
|
+
|
|
14
|
+
# The statistics each is the sum of.
|
|
15
|
+
OPERATIONS: Array[String]
|
|
16
|
+
MISSES: Array[String]
|
|
17
|
+
BYTES: Array[String]
|
|
18
|
+
RUSAGE: Array[String]
|
|
19
|
+
|
|
20
|
+
# What a statistic's line starts with, by its name (made once each).
|
|
21
|
+
NEEDLES: Hash[String, String]
|
|
22
|
+
|
|
23
|
+
# Configures Duel for the plugin (c.use :memcached): the server
|
|
24
|
+
# (+server+ is the program, +args+ more of its options, +env+ the
|
|
25
|
+
# variables besides DUEL_MEMCACHED_SERVERS given its socket's path)
|
|
26
|
+
# and its metrics, memcached_cpu too with +cpu+. Returns the plugin's
|
|
27
|
+
# own connection to it, a worker's.
|
|
28
|
+
def self.configure: (Config config, ?server: String, ?args: Array[String], ?env: Array[String], ?cpu: bool) -> InWorker[Connection]
|
|
29
|
+
|
|
30
|
+
# What the worker's server has done so far (but memcached_cpu), read
|
|
31
|
+
# over +connection+ (the plugin's own). It allocates only its Hash.
|
|
32
|
+
def self.stats: (Connection connection) -> Hash[String, Integer]
|
|
33
|
+
|
|
34
|
+
# Its CPU time so far, in microseconds, read over +connection+. It
|
|
35
|
+
# allocates nothing.
|
|
36
|
+
def self.cpu_time: (Connection connection) -> Integer
|
|
37
|
+
|
|
38
|
+
# The sum of +names+ in +stats+, a stats reply as the server sent it.
|
|
39
|
+
def self.sum: (String stats, Array[String] names) -> Integer
|
|
40
|
+
|
|
41
|
+
# The value of +name+ in +stats+ (a stats reply), read in place.
|
|
42
|
+
def self.value: (String stats, String name) -> Integer
|
|
43
|
+
|
|
44
|
+
# For benchmarks: the worker's server (its socket's path).
|
|
45
|
+
def self.servers: () -> String
|
|
46
|
+
|
|
47
|
+
# Empties it, over a connection of its own: in setup or untimed
|
|
48
|
+
# blocks only (it fails while a benchmark is measured).
|
|
49
|
+
def self.flush: () -> String
|
|
50
|
+
|
|
51
|
+
# The least a connection needs to read a server's statistics.
|
|
52
|
+
class Connection
|
|
53
|
+
@socket: UNIXSocket
|
|
54
|
+
@sent: Integer
|
|
55
|
+
@received: Integer
|
|
56
|
+
@reply: String
|
|
57
|
+
@chunk: String
|
|
58
|
+
|
|
59
|
+
# Its bytes the server had counted when it answered the latest stats.
|
|
60
|
+
attr_reader own: Integer
|
|
61
|
+
|
|
62
|
+
# The most read at a time.
|
|
63
|
+
READ: Integer
|
|
64
|
+
|
|
65
|
+
def self.ready?: (String path) -> bool
|
|
66
|
+
|
|
67
|
+
def initialize: (String path) -> void
|
|
68
|
+
|
|
69
|
+
# The server's statistics, by name.
|
|
70
|
+
def stats: () -> Hash[String, String]
|
|
71
|
+
|
|
72
|
+
# The server's statistics as it sent them, in a buffer the next
|
|
73
|
+
# reading reuses.
|
|
74
|
+
def stats_reply: () -> String
|
|
75
|
+
|
|
76
|
+
# Empties the server; returns its reply.
|
|
77
|
+
def flush_all: () -> String
|
|
78
|
+
|
|
79
|
+
def close: () -> void
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# +command+ is a line, its CRLF too.
|
|
84
|
+
def request: (String command) -> Integer
|
|
85
|
+
|
|
86
|
+
# Appends what the server sent next to the reply.
|
|
87
|
+
def receive: () -> String
|
|
88
|
+
|
|
89
|
+
def reply: () -> String
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: duel-memcached
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Erik Berlin
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: duel
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
description: 'A Duel plugin: each worker gets a memcached server of its own, and every
|
|
27
|
+
benchmark is judged on the memcached operations, misses and bytes it took too.'
|
|
28
|
+
email:
|
|
29
|
+
- sferik@gmail.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- CHANGELOG.md
|
|
35
|
+
- LICENSE
|
|
36
|
+
- README.md
|
|
37
|
+
- lib/duel/memcached.rb
|
|
38
|
+
- lib/duel/memcached/version.rb
|
|
39
|
+
- sig/duel/memcached.rbs
|
|
40
|
+
homepage: https://github.com/sferik/duel-ruby/tree/main/plugins/memcached
|
|
41
|
+
licenses:
|
|
42
|
+
- MIT
|
|
43
|
+
metadata:
|
|
44
|
+
source_code_uri: https://github.com/sferik/duel-ruby/tree/main/plugins/memcached
|
|
45
|
+
changelog_uri: https://github.com/sferik/duel-ruby/blob/main/plugins/memcached/CHANGELOG.md
|
|
46
|
+
bug_tracker_uri: https://github.com/sferik/duel-ruby/issues
|
|
47
|
+
rubygems_mfa_required: 'true'
|
|
48
|
+
rdoc_options: []
|
|
49
|
+
require_paths:
|
|
50
|
+
- lib
|
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '3.4'
|
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
requirements: []
|
|
62
|
+
rubygems_version: 4.0.23
|
|
63
|
+
specification_version: 4
|
|
64
|
+
summary: A memcached server for each Duel worker, and what it did for each benchmark
|
|
65
|
+
test_files: []
|