duel-redis 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8945f91caaf5208b93bdaef3934320e6722dc1ba837d5c6d72d8eb2c8a97245b
4
+ data.tar.gz: a55066487902efb5bb189a982d3de10a8698f984d22e05f62bc538c6a7d542a3
5
+ SHA512:
6
+ metadata.gz: 51a4bf4253ceb07cf1b809e3ed6c124dff48884875bb73a229f2e2fb91764c8ce2a5536f97e8b45ae510a48da3c4f89e6ca872919cf2a75090d0f236003b1c03
7
+ data.tar.gz: d5f2ee1dd4383dd37e8b9cb359f122d7a8459cc445cadf04581daaa7fe510153df4bcc8b051cd0c0adfec81757c9831b2226d431ef1605e0804f4fd222c3801f
data/CHANGELOG.md ADDED
@@ -0,0 +1,49 @@
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-redis is a Duel plugin, open source under the MIT
13
+ license. It needs Ruby 3.4 or later, Duel 0.1, and `redis-server`
14
+ (Redis 6.2 or later, or Valkey) on the `PATH`.
15
+
16
+ ### Added
17
+
18
+ - `c.use :redis`: a Redis server for each Duel worker, on a Unix
19
+ socket, and the `redis_cpu`, `redis_commands` and `redis_round_trips`
20
+ metrics.
21
+ - `Duel::Redis.url` and `Duel::Redis.flush`, for benchmarks: `flush`
22
+ opens a connection of its own, from the worker's `DUEL_REDIS_URL`.
23
+ - `c.use :redis` returns the plugin's own connection, made in each
24
+ worker (`c.in_worker`) and closed as it ends (`at_stop`). Its `value`
25
+ fails outside a worker. The module keeps no connection.
26
+ - `redis_commands` and `redis_round_trips` are in commands and round
27
+ trips (`unit:`), so reports read "1 command → 2 commands".
28
+ - `Duel::Redis.flush` raises `Duel::Error` while a benchmark is measured
29
+ (`Duel.measuring?`): it's for `setup` hooks, and `once` and `untimed`
30
+ blocks, where its `FLUSHALL` isn't counted.
31
+ - Its metrics are costly to read (Duel's `costly`): a benchmark with an
32
+ `around_iteration` hook reads the server's statistics only as each
33
+ sample starts and stops, not twice an iteration, and what the hook
34
+ sends the server (a transaction's `BEGIN` and `ROLLBACK`, say) is
35
+ learned in each worker and subtracted.
36
+ - Each worker's server works in the worker's own directory (`--dir`),
37
+ so persistence that `args:` turn on writes its snapshots and
38
+ append-only files there, never in the project.
39
+ - The plugin's connection (`Duel::Redis::Connection#call`) reads every
40
+ type of reply RESP2 has, whole: a nil bulk string (a missing key's),
41
+ an array (returned as an `Array`, nested ones and nil included, any
42
+ error in it a `RuntimeError`), and an integer, as an `Integer`. It
43
+ never sends `HELLO`, so it reads RESP2 only: a reply of another type
44
+ (RESP3's) closes the connection and raises `IOError`, rather than
45
+ read the next reply out of step.
46
+
47
+
48
+ [Unreleased]: https://github.com/sferik/duel-ruby/compare/duel-redis-v0.1.0...HEAD
49
+ [0.1.0]: https://github.com/sferik/duel-ruby/releases/tag/duel-redis-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,80 @@
1
+ # duel-redis
2
+
3
+ [![Duel](https://raw.githubusercontent.com/sferik/duel-ruby/main/assets/badge.svg)](https://github.com/sferik/duel-ruby)
4
+ [![duel-redis](https://github.com/sferik/duel-ruby/actions/workflows/plugin-redis.yml/badge.svg)](https://github.com/sferik/duel-ruby/actions/workflows/plugin-redis.yml)
5
+ [![Gem Version](https://badge.fury.io/rb/duel-redis.svg)](https://rubygems.org/gems/duel-redis)
6
+
7
+ A [Duel](https://github.com/sferik/duel-ruby) plugin for code that talks
8
+ to Redis: each Duel worker gets a Redis server of its own, and every
9
+ benchmark is judged on what Redis did for it too.
10
+
11
+ | Metric | What it is | A regression |
12
+ |---|---|---|
13
+ | `redis_cpu` | the time Redis spent running the benchmark's commands, in µs | judged like CPU time |
14
+ | `redis_commands` | the commands it ran | any increase |
15
+ | `redis_round_trips` | the replies it wrote: one per round trip | any increase |
16
+
17
+ So a change that sends one more command per job, or stops pipelining,
18
+ fails even when its CPU time on the Ruby side doesn't change.
19
+
20
+ ## Installation
21
+
22
+ ```ruby
23
+ # Gemfile
24
+ group :development do
25
+ gem "duel-redis"
26
+ end
27
+ ```
28
+
29
+ > [!NOTE]
30
+ > It needs `redis-server` (Redis 6.2 or later, or Valkey) on the `PATH`.
31
+
32
+ ## Usage
33
+
34
+ ```ruby
35
+ # .duel.rb
36
+ Duel.configure { |c| c.use :redis }
37
+ ```
38
+
39
+ Each worker's server listens only on a Unix socket in the worker's own
40
+ directory, with persistence off, and works in that directory too
41
+ (`--dir`): if your `args:` turn persistence on, its snapshots and
42
+ append-only files go there, never in your project. Its URL is in
43
+ `DUEL_REDIS_URL`, and in `REDIS_URL` too, so a client that reads that
44
+ finds it and nothing reaches another Redis. Benchmarks can call
45
+ `Duel::Redis.url` and `Duel::Redis.flush`.
46
+
47
+ > [!IMPORTANT]
48
+ > `Duel::Redis.flush` is for setting up: call it in a `setup` hook, or
49
+ > a `once` or `untimed` block. While a benchmark is measured (`Duel.measuring?`), it
50
+ > raises `Duel::Error` rather than have its `FLUSHALL` counted.
51
+
52
+ ```ruby
53
+ # duel/cache_duel.rb
54
+ duel "cache: a hit" do
55
+ once do
56
+ Duel::Redis.flush # empties the worker's server (over a connection of its own)
57
+ @cache = MyCache.new(url: Duel::Redis.url) # its URL
58
+ @cache.write("k", "v")
59
+ end
60
+ @cache.read("k")
61
+ end
62
+ ```
63
+
64
+ Options:
65
+
66
+ ```ruby
67
+ c.use :redis,
68
+ server: "valkey-server", # the program (default: DUEL_REDIS_SERVER, or redis-server)
69
+ args: ["--maxmemory", "64mb"], # more of its options
70
+ env: %w[CACHE_URL] # variables given its URL besides DUEL_REDIS_URL (default: REDIS_URL)
71
+ ```
72
+
73
+ The metrics come from the server's `INFO` (command statistics and
74
+ `total_writes_processed`), read over a connection of the plugin's own,
75
+ opened as each worker starts (Duel's `c.in_worker`) and closed as it
76
+ ends. `c.use :redis` returns it, for a `.duel.rb` that reads the server
77
+ too (`connection.value`, in a worker only). Each reading is a command and a
78
+ reply itself, so Duel learns what readings add, and takes it away. Reports
79
+ count them: `redis_commands 4→6`, `redis_round_trips: 1 round trip → 2
80
+ round trips an iteration`.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Duel
4
+ module Redis
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/duel/redis.rb ADDED
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "duel"
4
+ require "socket"
5
+ require_relative "redis/version"
6
+
7
+ module Duel
8
+ # A Redis server of each worker's own, and what it did for each
9
+ # benchmark:
10
+ #
11
+ # - redis_cpu: the time Redis spent running commands, in microseconds
12
+ # (judged by ratio, as CPU time is)
13
+ # - redis_commands: the commands it ran (counted)
14
+ # - redis_round_trips: the replies it wrote, one per round trip (counted)
15
+ #
16
+ # # .duel.rb
17
+ # Duel.configure { |c| c.use :redis }
18
+ #
19
+ # Benchmarks can call Duel::Redis.url and Duel::Redis.flush.
20
+ #
21
+ # The server listens only on a Unix socket in the worker's directory,
22
+ # and works there too (--dir): what its options make it write, a
23
+ # snapshot or an append-only file, goes there, never in the project.
24
+ # Its URL is in DUEL_REDIS_URL, and in REDIS_URL too (unless +env+
25
+ # names others), so a client that reads that finds it.
26
+ module Redis
27
+ Duel.plugin(:redis, self)
28
+
29
+ URL = "DUEL_REDIS_URL"
30
+
31
+ # Every server's options: no TCP port, and nothing written to disk.
32
+ OPTIONS = ["--port", "0", "--save", "", "--appendonly", "no"].freeze
33
+
34
+ # Configures Duel for the plugin (c.use :redis): the server
35
+ # (+server+ is the program, +args+ more of its options) and its
36
+ # metrics. Returns the plugin's own connection to it, a worker's
37
+ # (see Config#in_worker, and in tests, Duel::Testing.in_worker).
38
+ def self.configure(config, server: ENV.fetch("DUEL_REDIS_SERVER", "redis-server"), args: [], env: %w[REDIS_URL])
39
+ config.prepare(:worker) do |w|
40
+ socket = w.path("redis.sock")
41
+ w.spawn(*[server, "--unixsocket", socket, "--dir", w.dir, *OPTIONS] + args)
42
+ w.wait_until { Connection.ping(socket) }
43
+ url = "unix://#{socket}"
44
+ ([URL] + env).to_h { |name| [name, url] }
45
+ end
46
+ connection = config.in_worker { |w| Connection.new(url).tap { |redis| w.at_stop { redis.close } } }
47
+ config.metric(METRICS) { stats(connection.value) }.costly
48
+ connection
49
+ end
50
+
51
+ METRICS = {
52
+ "redis_cpu" => {kind: :ratio, unit: "µs", floor: 300},
53
+ "redis_commands" => {kind: :counted, unit: %w[command commands]},
54
+ "redis_round_trips" => {kind: :counted, unit: ["round trip", "round trips"]}
55
+ }
56
+
57
+ # What the worker's server has done so far, read over +connection+:
58
+ # every command's time but INFO's own (the reading's, too slight to
59
+ # count but not steady enough to take away), and every command and
60
+ # reply.
61
+ def self.stats(connection)
62
+ info = connection.call("INFO", "all") #: String
63
+ usec = calls = 0
64
+ info.scan(/^cmdstat_([^:]+):calls=(\d+),usec=(\d+)/) do |command, c, u|
65
+ calls += Integer(c)
66
+ usec += Integer(u) unless command == "info"
67
+ end
68
+ {"redis_cpu" => usec, "redis_commands" => calls, "redis_round_trips" => Integer(info[/^total_writes_processed:(\d+)/, 1])}
69
+ end
70
+
71
+ # For benchmarks: the worker's server.
72
+ def self.url = ENV.fetch(URL)
73
+
74
+ # Empties it, over a connection of its own (opened and closed: a Unix
75
+ # socket's is cheap). For setup or untimed blocks only: while a
76
+ # benchmark is measured (Duel.measuring?) it fails, as its FLUSHALL
77
+ # would be counted.
78
+ def self.flush
79
+ Duel.unmeasured!("Duel::Redis.flush")
80
+
81
+ connection = Connection.new(url)
82
+ begin
83
+ connection.call("FLUSHALL") #: String
84
+ ensure
85
+ connection.close
86
+ end
87
+ end
88
+
89
+ # The least a connection needs to read a server's statistics: not the
90
+ # code's own, whose commands are what's measured.
91
+ class Connection
92
+ CLOSED = "Redis closed the connection"
93
+
94
+ # The first byte of a reply: its type, one of RESP2's.
95
+ STATUS, ERROR, INTEGER, BULK, ARRAY = "+-:$*".bytes
96
+
97
+ def self.ping(path)
98
+ socket = UNIXSocket.new(path)
99
+ begin
100
+ socket.write("PING\r\n")
101
+ socket.gets == "+PONG\r\n"
102
+ ensure
103
+ socket.close
104
+ end
105
+ rescue SystemCallError
106
+ false
107
+ end
108
+
109
+ def initialize(url)
110
+ @socket = UNIXSocket.new(url.delete_prefix("unix://"))
111
+ end
112
+
113
+ # Sends a command; returns its reply, read whole (every type RESP2
114
+ # has): a status or a bulk string as a String, an integer as an
115
+ # Integer, a missing one (a nil bulk string or array) as nil, and an
116
+ # array as an Array of replies, any error in it a RuntimeError. An
117
+ # error reply raises it. A reply cut short (the server gone) raises
118
+ # IOError.
119
+ #
120
+ # The connection never sends HELLO, so Redis answers in RESP2. A
121
+ # reply of a type RESP2 doesn't have (RESP3's, after a HELLO 3)
122
+ # closes the connection, as the rest of it can't be read, and raises
123
+ # IOError.
124
+ def call(*args)
125
+ @socket.write(args.map(&:to_s).reduce("*#{args.size}\r\n") { |out, arg| out << "$#{arg.bytesize}\r\n#{arg}\r\n" })
126
+ reply = read
127
+ raise reply if reply.is_a?(Exception)
128
+
129
+ reply
130
+ end
131
+
132
+ def close = @socket.close
133
+
134
+ private
135
+
136
+ # A reply, and every element of an array's: its first line's type
137
+ # (its first byte), then what follows it.
138
+ def read
139
+ line = (@socket.gets or raise IOError, CLOSED)
140
+ line.chomp!
141
+ case line.getbyte(0)
142
+ when STATUS then line.byteslice(1..)
143
+ when BULK then bulk(number(line))
144
+ when INTEGER then number(line)
145
+ when ARRAY then array(number(line))
146
+ when ERROR then RuntimeError.new("Redis: #{line.byteslice(1..)}")
147
+ else
148
+ close
149
+ raise IOError, "Redis: a reply of a type the connection doesn't read (RESP2 only): #{line}"
150
+ end
151
+ end
152
+
153
+ # A line's number, after its type: the type byte made a space rather
154
+ # than cut (which would copy the line), as Integer() skips one.
155
+ def number(line)
156
+ line.setbyte(0, 32)
157
+ Integer(line)
158
+ end
159
+
160
+ # A bulk string of +size+ bytes, and its CRLF (nil for size -1, a
161
+ # missing one).
162
+ def bulk(size)
163
+ return if size == -1
164
+
165
+ @socket.read(size + 2)&.delete_suffix!("\r\n") or raise IOError, CLOSED
166
+ end
167
+
168
+ # An array of +size+ replies (nil for size -1, a missing one).
169
+ def array(size)
170
+ Array.new(size) { read } unless size == -1
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,68 @@
1
+ module Duel
2
+ # A Redis server of each worker's own, and what it did for each
3
+ # benchmark (redis_cpu, redis_commands, redis_round_trips).
4
+ module Redis
5
+ VERSION: String
6
+
7
+ URL: String
8
+
9
+ # Every server's options: no TCP port, and nothing written to disk.
10
+ OPTIONS: Array[String]
11
+
12
+ METRICS: Hash[String, Metric::settings]
13
+
14
+ # Configures Duel for the plugin (c.use :redis): the server (+server+
15
+ # is the program, +args+ more of its options, +env+ the variables
16
+ # besides DUEL_REDIS_URL given its URL) and its metrics. Returns the
17
+ # plugin's own connection to it, a worker's.
18
+ def self.configure: (Config config, ?server: String, ?args: Array[String], ?env: Array[String]) -> InWorker[Connection]
19
+
20
+ # What the worker's server has done so far, read over +connection+.
21
+ def self.stats: (Connection connection) -> Hash[String, Integer]
22
+
23
+ # For benchmarks: the worker's server.
24
+ def self.url: () -> String
25
+
26
+ # Empties it, over a connection of its own: in setup or untimed blocks
27
+ # only (it fails while a benchmark is measured).
28
+ def self.flush: () -> String
29
+
30
+ # The least a connection needs to read a server's statistics.
31
+ class Connection
32
+ # A reply, as Connection#call returns it, or in an array (where an
33
+ # error is one too).
34
+ type reply = String | Integer | RuntimeError | Array[reply] | nil
35
+
36
+ CLOSED: String
37
+
38
+ # The first byte of a reply: its type, one of RESP2's.
39
+ STATUS: Integer
40
+ ERROR: Integer
41
+ INTEGER: Integer
42
+ BULK: Integer
43
+ ARRAY: Integer
44
+
45
+ @socket: UNIXSocket
46
+
47
+ def self.ping: (String path) -> bool
48
+
49
+ def initialize: (String url) -> void
50
+
51
+ # Sends a command; returns its reply (RESP2's, read whole): an error
52
+ # reply raises.
53
+ def call: (*String | Symbol | Integer args) -> (String | Integer | Array[reply] | nil)
54
+
55
+ def close: () -> void
56
+
57
+ private
58
+
59
+ def read: () -> reply
60
+
61
+ def number: (String line) -> Integer
62
+
63
+ def bulk: (Integer size) -> String?
64
+
65
+ def array: (Integer size) -> Array[reply]?
66
+ end
67
+ end
68
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: duel-redis
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 Redis server of its own, and every
27
+ benchmark is judged on the Redis CPU time, commands and round trips 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/redis.rb
38
+ - lib/duel/redis/version.rb
39
+ - sig/duel/redis.rbs
40
+ homepage: https://github.com/sferik/duel-ruby/tree/main/plugins/redis
41
+ licenses:
42
+ - MIT
43
+ metadata:
44
+ source_code_uri: https://github.com/sferik/duel-ruby/tree/main/plugins/redis
45
+ changelog_uri: https://github.com/sferik/duel-ruby/blob/main/plugins/redis/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 Redis server for each Duel worker, and what it did for each benchmark
65
+ test_files: []