duel-mysql 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: dbe9a6c464029e259d365dbd4592760e1341e6f1bcc4f8d202ea3c16a884f514
4
+ data.tar.gz: 493b672d01e30c73936dc7460f1b464cc88a9e71cbb9050cc4b3c8ce70f6a808
5
+ SHA512:
6
+ metadata.gz: 924c9565a2f71df2002086bf3a5906d3fedfd4a222081e009ff3824aee26344dfee15cc4fddcc3358656aa5528b28a44e36cb2923b2335da8c017e122b4c422b
7
+ data.tar.gz: cc290806a425dd3b52e48278640a85f18e573a35537e965b245dc289e00ec07286d94d9dd24a1fb2ed8bfb806acb527c412cbc045aded7ea942cf2d5d9bd1fd6
data/CHANGELOG.md ADDED
@@ -0,0 +1,70 @@
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-mysql is a Duel plugin, open source under the
13
+ MIT license, and needs Duel 0.1, CRuby 3.4 or later (the trilogy gem is
14
+ a C extension, and TruffleRuby 40.0 fails inside it), and MySQL 8.0.30
15
+ or later (not MariaDB).
16
+
17
+ ### Added
18
+
19
+ - `c.use :mysql`: a MySQL server for each Duel worker, on a Unix
20
+ socket, its data directory copied from one made once (with Duel's
21
+ `w.cached`, in the project's `tmp/duel/cache`), and the
22
+ `mysql_queries`, `mysql_rows`, `mysql_rows_examined` and `mysql_time`
23
+ metrics, from its status and `performance_schema`, as running totals
24
+ (`c.metric`).
25
+ - `Duel::MySQL.url`, `Duel::MySQL.socket`, `Duel::MySQL.execute` and
26
+ `Duel::MySQL.reset`, for benchmarks: `execute` and `reset` open a
27
+ connection of their own, from the worker's `DUEL_MYSQL_SOCKET`.
28
+ They're for `setup` hooks, and `once` and `untimed` blocks: while a
29
+ benchmark is measured (`Duel.measuring?`) they raise `Duel::Error`, as what they run
30
+ would be counted.
31
+ - A regression is explained by the statements (by digest) that did the
32
+ most of it, on each version (Duel's `details`): how many times each
33
+ ran an iteration, for `mysql_queries`, and their shares, for the rest.
34
+ - `mysql_time` is in µs (`unit: "µs"`), shown as time. `mysql_queries`,
35
+ `mysql_rows` and `mysql_rows_examined` are in queries and rows
36
+ (`unit:`, each with its plural), so reports read "1 query → 2
37
+ queries".
38
+ - `mysql_time` is a `kind: :ratio` measure, left out of Duel's
39
+ across-the-board check (`across: false`).
40
+ - `c.use :mysql` returns the plugin's own connection, opened in
41
+ each worker (`c.in_worker`) and closed as it ends (`at_stop`). Its
42
+ `value` fails outside a worker. The module keeps no connection.
43
+ - Built on Duel's plugin API: the server is spawned and set up with
44
+ `Duel::Resource#spawn` and `#run`, its measures can be tuned or
45
+ skipped like any (`c.measure("mysql_time", limit: 1.1)`), and its tests
46
+ use `duel/testing`.
47
+ - Its metrics are costly to read (Duel's `costly`): a benchmark with an
48
+ `around_iteration` hook reads the server's statistics only as each
49
+ sample starts and stops, not twice an iteration, and what the hook
50
+ sends the server (a transaction's `BEGIN` and `ROLLBACK`, say) is
51
+ learned in each worker and subtracted.
52
+ - A reading reads only the statements that ran since the one before it
53
+ (by their `LAST_SEEN`, from a second before it, so one counted while
54
+ that one read is read again), and says which changed: explaining a
55
+ stretch whose readings are one right after the other looks at those
56
+ only, and one with a reading in between at every statement. Reading
57
+ and explaining a sample cost what ran in it, not every statement the
58
+ server has run so far. With 2000 statements and none run, the plugin's
59
+ Ruby for a sample (two readings and its details) takes about 8µs and
60
+ 58 objects.
61
+ - A reading waits for clients only, not for the event scheduler or an
62
+ event that's running: those whose thread shows a statement in
63
+ `performance_schema.threads`, or counted in `Threads_running`, less
64
+ the server's event threads.
65
+ - Mutation tested: mutant kills every change to the plugin's code, in
66
+ CI.
67
+ - RBS signatures in sig/, checked with Steep.
68
+
69
+ [Unreleased]: https://github.com/sferik/duel-ruby/compare/duel-mysql-v0.1.0...HEAD
70
+ [0.1.0]: https://github.com/sferik/duel-ruby/releases/tag/duel-mysql-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,143 @@
1
+ # duel-mysql
2
+
3
+ [![Duel](https://raw.githubusercontent.com/sferik/duel-ruby/main/assets/badge.svg)](https://github.com/sferik/duel-ruby)
4
+ [![duel-mysql](https://github.com/sferik/duel-ruby/actions/workflows/plugin-mysql.yml/badge.svg)](https://github.com/sferik/duel-ruby/actions/workflows/plugin-mysql.yml)
5
+ [![Gem Version](https://badge.fury.io/rb/duel-mysql.svg)](https://rubygems.org/gems/duel-mysql)
6
+
7
+ A [Duel](https://github.com/sferik/duel-ruby) plugin for code that talks
8
+ to MySQL: each Duel worker gets a MySQL server of its own, and every
9
+ benchmark is judged on what MySQL did for it too.
10
+
11
+ | Metric | What it is | A regression |
12
+ |---|---|---|
13
+ | `mysql_queries` | the statements clients sent it (`Questions`) | any increase |
14
+ | `mysql_rows` | the rows they returned or changed | any increase |
15
+ | `mysql_rows_examined` | the rows it read to run them | any increase |
16
+ | `mysql_time` | the time it spent running them, in µs | judged like CPU time |
17
+
18
+ So a change that adds a query per record (an N+1), fetches rows it
19
+ doesn't use, or stops a query using an index (and so examines every row
20
+ of a table) fails even when its CPU time on the Ruby side doesn't
21
+ change.
22
+
23
+ A regression says which statements did it: under each version's
24
+ numbers, the three that did the most of that measure, by their digests
25
+ (Duel's `details`), with how many times each ran an iteration for
26
+ `mysql_queries`, and their shares of it for the rest:
27
+
28
+ ```
29
+ mysql_rows_examined: 2 rows → 1001 rows an iteration
30
+ mysql_rows_examined (candidate): SELECT NAME FROM `items` WHERE `id` = ? (99.9%); SELECT * FROM `users` WHERE `id` = ? (0.0999%)
31
+ mysql_rows_examined (baseline): SELECT * FROM `users` WHERE `id` = ? (50%); SELECT NAME FROM `items` WHERE `id` = ? (50%)
32
+ ```
33
+
34
+ They're of the sample the numbers came from, but for its `untimed`
35
+ blocks, if it has any. Queries and rows are counted in their units
36
+ (`1 query`, `2 rows`, though the summary line leaves out the one a name
37
+ ends in: `mysql_queries 4→8`), and `mysql_time` is shown as time
38
+ (`mysql_time: 412µs → 1.31ms an iteration`).
39
+
40
+ ## Installation
41
+
42
+ ```ruby
43
+ # Gemfile
44
+ group :development do
45
+ gem "duel-mysql"
46
+ end
47
+ ```
48
+
49
+ > [!NOTE]
50
+ > It needs MySQL 8.0.30 or later (not MariaDB: its `performance_schema`
51
+ > differs) and the [trilogy](https://github.com/trilogy-libraries/trilogy)
52
+ > gem, so CRuby (TruffleRuby 40.0 fails inside trilogy once benchmarks
53
+ > run). Your code can use any client: trilogy is only the plugin's own.
54
+
55
+ ## Usage
56
+
57
+ ```ruby
58
+ # .duel.rb
59
+ Duel.configure { |c| c.use :mysql }
60
+ ```
61
+
62
+ `c.use` requires the plugin, so `.duel.rb` doesn't need to.
63
+
64
+ Each worker's server listens only on a Unix socket in the worker's own
65
+ directory (no `my.cnf` is read), with no binary log, little memory,
66
+ nothing written to disk that needn't be, and InnoDB's statistics left as
67
+ they are (recalculated on their own, they'd change a query's plan while
68
+ it's measured: `ANALYZE TABLE` in a setup if you need them). Its URL is
69
+ in `DUEL_MYSQL_URL`, and in `DATABASE_URL` too, so a client that reads
70
+ that finds it and nothing reaches another database. The socket's path
71
+ is in `DUEL_MYSQL_SOCKET`. Benchmarks can call `Duel::MySQL.url`,
72
+ `Duel::MySQL.socket`, `Duel::MySQL.execute` and `Duel::MySQL.reset`
73
+ (each of these two opens a connection of its own, from the worker's
74
+ `DUEL_MYSQL_SOCKET`, and closes it).
75
+
76
+ > [!IMPORTANT]
77
+ > `execute` and `reset` are for setting up: call them in a `setup` hook,
78
+ > or a `once` or `untimed` block, not the code that's measured, where their statements
79
+ > would count, as any client's. While a benchmark is measured
80
+ > (`Duel.measuring?`) they raise `Duel::Error`.
81
+
82
+ ```ruby
83
+ # duel/orders_duel.rb
84
+ setup do
85
+ Duel::MySQL.reset # empties every table
86
+ Duel::MySQL.execute("INSERT INTO orders (total) VALUES (1), (2), (3)")
87
+ @store = OrderStore.new(socket: Duel::MySQL.socket) # its socket
88
+ end
89
+
90
+ duel("orders: the largest ten") { @store.largest(10) }
91
+ ```
92
+
93
+ Options:
94
+
95
+ ```ruby
96
+ c.use :mysql,
97
+ server: "/usr/local/mysql/bin/mysqld", # the program (default: DUEL_MYSQL_SERVER, or mysqld)
98
+ args: ["--sort-buffer-size=4M"], # more of its options
99
+ setup: File.read("db/structure.sql"), # SQL each worker's database runs first
100
+ env: %w[DATABASE_URL PRIMARY_URL], # variables given its URL besides DUEL_MYSQL_URL
101
+ scheme: "trilogy" # the URL's (Rails takes it for the adapter, mysql2 by default)
102
+ ```
103
+
104
+ > [!WARNING]
105
+ > `env` matters with another plugin that sets `DATABASE_URL`
106
+ > (`duel-postgresql`, say): two prepare blocks that set a variable differently
107
+ > fail, so give one of them other names (`env: %w[PRIMARY_URL]`, or
108
+ > `env: []`).
109
+
110
+ Initializing a data directory takes seconds, so it's done once (Duel's
111
+ `w.cached`): the first worker to start makes one, with the `duel`
112
+ database created in it, in your project's `tmp/duel/cache`, and every
113
+ worker (in that run and the next ones) starts with a copy. One is made
114
+ for each server program (and version of the plugin): another MySQL gets
115
+ its own. `duel --clean` deletes them. (`args` are the running server's,
116
+ not its initialization's.)
117
+
118
+ The metrics come from the server's status (`Questions`) and
119
+ `performance_schema` (its statement summaries), which MySQL updates as
120
+ each statement ends (MySQL 8.0's just after its client hears it has: a
121
+ reading waits, a second at most, until no other client is running a
122
+ statement: the server's own threads, the event scheduler's and its
123
+ events', aren't waited for), read over a connection of the plugin's own, opened as
124
+ each worker starts (Duel's `c.in_worker`, and closed as it ends): running totals (Duel's
125
+ `c.metric`). `c.use` returns that connection, for a `.duel.rb` that
126
+ reads the server too (`connection.value`, in a worker only). Each
127
+ reading takes that connection's own totals from the server's, so its statements
128
+ are left out (and connections that have closed still count), and
129
+ there's nothing to take away (`calibrate: false`). The
130
+ statements a regression names come from the statement summaries by
131
+ digest (`events_statements_summary_by_digest`), the plugin's readings
132
+ left out: each reading reads only the statements run since the one
133
+ before it (by their `LAST_SEEN`, from a second before it), so it costs
134
+ what ran, not what the server has run so far.
135
+
136
+ `mysql_time` is the server's time on the clock, not CPU time, and
137
+ another process's, so what else the machine does shows in it: it's
138
+ judged for each benchmark, but left out of Duel's across-the-board
139
+ check (`across: false`), whose margin is a quarter of its limit.
140
+
141
+ Like any measure, each can be tuned or left out in `.duel.rb`:
142
+ `c.measure("mysql_time", limit: 1.1)`,
143
+ `c.measure("mysql_rows_examined", skip: true)`.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Duel
4
+ module MySQL
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/duel/mysql.rb ADDED
@@ -0,0 +1,450 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "duel"
4
+ require "digest"
5
+ require "fileutils"
6
+ require "trilogy"
7
+ require_relative "mysql/version"
8
+
9
+ module Duel
10
+ # A MySQL server of each worker's own, and what it did for each
11
+ # benchmark, from its status and performance_schema:
12
+ #
13
+ # - mysql_queries: the statements clients sent it (Questions) (counted)
14
+ # - mysql_rows: the rows they returned or changed (counted)
15
+ # - mysql_rows_examined: the rows it read to run them (counted)
16
+ # - mysql_time: the time it spent running them, in microseconds
17
+ # (judged like CPU time)
18
+ #
19
+ # Where one regressed, it says which statements did the most of it, on
20
+ # each version (Statements#details).
21
+ #
22
+ # # .duel.rb
23
+ # Duel.configure { |c| c.use :mysql }
24
+ #
25
+ # Benchmarks can call Duel::MySQL.url and .socket, and .execute and
26
+ # .reset in setup (or untimed blocks): while a benchmark is measured,
27
+ # they fail.
28
+ #
29
+ # The server listens only on a Unix socket in the worker's directory;
30
+ # its URL is in DUEL_MYSQL_URL, and in DATABASE_URL too (unless +env+
31
+ # names others), and the socket's path in DUEL_MYSQL_SOCKET.
32
+ module MySQL
33
+ Duel.plugin(:mysql, self)
34
+
35
+ URL = "DUEL_MYSQL_URL"
36
+ SOCKET = "DUEL_MYSQL_SOCKET"
37
+ DATABASE = "duel"
38
+ USER = "root"
39
+
40
+ # The server's options (after --no-defaults: no my.cnf of the
41
+ # machine's): no network, nothing to disk that needn't be, little
42
+ # memory, no Linux AIO contexts (there are only so many, for every
43
+ # server on the machine), and nothing running on its own
44
+ # (recalculating a table's statistics would change its plans while
45
+ # they're measured).
46
+ OPTIONS = %w[
47
+ --skip-networking --mysqlx=OFF --skip-log-bin
48
+ --innodb-buffer-pool-size=32M --innodb-redo-log-capacity=8M --innodb-doublewrite=OFF --innodb-use-native-aio=OFF
49
+ --innodb-flush-log-at-trx-commit=0 --innodb-stats-auto-recalc=OFF --event-scheduler=OFF --performance-schema=ON
50
+ ].freeze
51
+
52
+ # And once it's initialized: stopped like a crash, nothing flushed (as
53
+ # what's in it is thrown away).
54
+ RUNNING = %w[--innodb-fast-shutdown=2].freeze
55
+
56
+ # Configures Duel for the plugin (c.use :mysql): the server and its
57
+ # metrics. +server+ is the program, +args+ more of its options,
58
+ # +setup+ SQL each worker's database runs when it's started (a
59
+ # schema, say), +env+ the variables given its URL besides
60
+ # DUEL_MYSQL_URL, and +scheme+ the URL's (Rails takes it for the
61
+ # adapter). A data directory is initialized once (Resource#cached),
62
+ # and each worker's is a copy. Returns the plugin's own connection
63
+ # to it, a worker's (see Config#in_worker, and in tests,
64
+ # Duel::Testing.in_worker).
65
+ def self.configure(config, server: ENV.fetch("DUEL_MYSQL_SERVER", "mysqld"), args: [], setup: nil, env: %w[DATABASE_URL],
66
+ scheme: "mysql2")
67
+ config.prepare(:worker) do |w|
68
+ program = DataDir.find_server(server) || w.failed("no #{server} on the PATH (give it as server:, or DUEL_MYSQL_SERVER)")
69
+ data = w.path("data")
70
+ DataDir.new(w, program).create(data)
71
+ socket = w.path("mysqld.sock")
72
+ w.spawn(program, *DataDir.options(data), "--socket=#{socket}", "--pid-file=#{w.path("mysqld.pid")}", "--tmpdir=#{w.dir}",
73
+ *(RUNNING + args))
74
+ w.wait_until { Connection.ping(socket) }
75
+ run(socket, setup) if setup
76
+ url = "#{scheme}://#{USER}@localhost/#{DATABASE}?socket=#{socket}"
77
+ {URL => url, SOCKET => socket}.merge(env.to_h { |name| [name, url] })
78
+ end
79
+ connection = config.in_worker { |w| Connection.new(socket).tap { |mysql| w.at_stop { mysql.close } } }
80
+ statements = Statements.new(connection)
81
+ config.metric(METRICS) { statements.read }.costly.details { |stretches, sample| statements.details(stretches, sample.n) }
82
+ connection
83
+ end
84
+
85
+ # Runs SQL on a connection of its own to the server on +socket+;
86
+ # returns the last statement's result.
87
+ def self.run(socket, sql)
88
+ connection = Connection.new(socket)
89
+ begin
90
+ connection.execute(sql)
91
+ ensure
92
+ connection.close
93
+ end
94
+ end
95
+
96
+ # Server time isn't the worker's CPU time: another process's, on the
97
+ # clock, so what else the machine does shows in it. It's judged on
98
+ # each benchmark, but left out across the board, where the margin is
99
+ # too small for it. Its readings leave themselves out (STATS), so
100
+ # there's nothing to take away.
101
+ METRICS = {
102
+ "mysql_queries" => {kind: :counted, unit: %w[query queries], calibrate: false},
103
+ "mysql_rows" => {kind: :counted, unit: %w[row rows], calibrate: false},
104
+ "mysql_rows_examined" => {kind: :counted, unit: %w[row rows], calibrate: false},
105
+ "mysql_time" => {kind: :ratio, unit: "µs", floor: 300, across: false}
106
+ }
107
+
108
+ # What the worker's server has done so far, but for this connection
109
+ # (the plugin's own, reading it): the server's totals less the
110
+ # connection's, so nothing's left to take away. Both are as of the
111
+ # last statement that ended (see SETTLE).
112
+ STATS = <<~SQL
113
+ SELECT
114
+ CAST((SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Questions') -
115
+ (SELECT VARIABLE_VALUE FROM performance_schema.session_status WHERE VARIABLE_NAME = 'Questions') AS SIGNED),
116
+ CAST(server.sent - mine.sent AS SIGNED), CAST(server.examined - mine.examined AS SIGNED),
117
+ CAST(server.wait - mine.wait AS SIGNED)
118
+ FROM
119
+ (SELECT SUM(SUM_ROWS_SENT + SUM_ROWS_AFFECTED) sent, SUM(SUM_ROWS_EXAMINED) examined, SUM(SUM_TIMER_WAIT) wait
120
+ FROM performance_schema.events_statements_summary_global_by_event_name) server,
121
+ (SELECT SUM(SUM_ROWS_SENT + SUM_ROWS_AFFECTED) sent, SUM(SUM_ROWS_EXAMINED) examined, SUM(SUM_TIMER_WAIT) wait
122
+ FROM performance_schema.events_statements_summary_by_thread_by_event_name
123
+ WHERE THREAD_ID = PS_CURRENT_THREAD_ID()) mine
124
+ SQL
125
+
126
+ # Whether a client besides the reading one is running a statement.
127
+ # MySQL answers a statement's client before it's done with it: the
128
+ # statement is counted (in performance_schema) after, so a reading
129
+ # waits for every client to be done with what it was answered, for
130
+ # SETTLE seconds at most (one running a statement still is counted in
131
+ # the next reading instead). A client's thread shows a statement
132
+ # (its PROCESSLIST_COMMAND isn't Sleep) until just before it's
133
+ # counted, and Threads_running counts it until just after, so a
134
+ # reading waits while either does. Threads_running counts the
135
+ # server's event threads too (the event scheduler's, and each event's
136
+ # while it runs), which no client waits for: those are taken away.
137
+ # (It counts no other thread. An event's is counted a little less
138
+ # long than it's in performance_schema, so the two together don't
139
+ # miss a statement while one starts or ends.)
140
+ BUSY = <<~SQL
141
+ SELECT (SELECT COUNT(*) FROM performance_schema.threads
142
+ WHERE CONNECTION_TYPE IS NOT NULL AND PROCESSLIST_COMMAND != 'Sleep' AND THREAD_ID != PS_CURRENT_THREAD_ID()) +
143
+ ((SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Threads_running') -
144
+ (SELECT COUNT(*) FROM performance_schema.threads WHERE NAME IN ('thread/sql/event_scheduler', 'thread/sql/event_worker')) > 1)
145
+ SQL
146
+ SETTLE = 1.0
147
+
148
+ # What it has done so far by statement (its digest: a statement with
149
+ # ? for each constant), any connection's, for explaining a regression:
150
+ # of the statements run since a time (in seconds since the epoch, on
151
+ # the server's clock, which is the machine's), but these digests
152
+ # (Connection#quote'd: the plugin's own readings'). A statement's run
153
+ # (LAST_SEEN) is when it ended, but it's counted just after.
154
+ DIGESTS = <<~SQL
155
+ SELECT DIGEST, COUNT_STAR, SUM_ROWS_SENT + SUM_ROWS_AFFECTED, SUM_ROWS_EXAMINED, SUM_TIMER_WAIT
156
+ FROM performance_schema.events_statements_summary_by_digest
157
+ WHERE DIGEST IS NOT NULL AND UNIX_TIMESTAMP(LAST_SEEN) >= %d AND DIGEST NOT IN (%s)
158
+ SQL
159
+
160
+ # The digests of these statements (Connection#quote'd), and the texts
161
+ # of these digests.
162
+ DIGEST_OF = "SELECT STATEMENT_DIGEST(%s)"
163
+ TEXTS = "SELECT DIGEST, DIGEST_TEXT FROM performance_schema.events_statements_summary_by_digest WHERE DIGEST IN (%s)"
164
+
165
+ # What the worker's server has done so far, but for the plugin's own
166
+ # connection, which reads it (+connection+): [queries, rows, rows
167
+ # examined, time (ps)].
168
+ def self.totals(connection)
169
+ settle(connection)
170
+ connection.query(STATS).rows.flatten # (one row)
171
+ end
172
+
173
+ # Waits, SETTLE seconds at most, until the connection reading
174
+ # (+connection+) is the only one running a statement.
175
+ def self.settle(connection)
176
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + SETTLE
177
+ while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
178
+ return if connection.query(BUSY).rows.flatten.first.zero?
179
+ end
180
+ end
181
+
182
+ # The same by measure (time in µs).
183
+ def self.stats(connection) = Statements.by_measure(totals(connection))
184
+
185
+ # What the server has done so far, by statement, read over
186
+ # +connection+: those run since +since+ (seconds since the epoch), but
187
+ # the +own+ digests, each one's [digest, queries, rows, rows examined,
188
+ # time (ps)].
189
+ def self.digests(connection, since, own)
190
+ connection.query(format(DIGESTS, since, own.map { |digest| connection.quote(digest) }.join(", "))).rows
191
+ end
192
+
193
+ # The digest of each of +statements+, read over +connection+.
194
+ def self.digests_of(connection, statements) = statements.flat_map { |sql| connection.query(format(DIGEST_OF, connection.quote(sql))).rows.flatten }
195
+
196
+ # The texts of the statements with these digests, by digest, read
197
+ # over +connection+.
198
+ def self.texts(connection, digests)
199
+ connection.query(format(TEXTS, digests.map { |digest| connection.quote(digest) }.join(", "))).to_h
200
+ end
201
+
202
+ # For benchmarks: the worker's server.
203
+ def self.url = ENV.fetch(URL)
204
+
205
+ # Its socket's path (for a client that takes it, not a URL).
206
+ def self.socket = ENV.fetch(SOCKET)
207
+
208
+ # Runs SQL (statements, ;-separated) on it, over a connection of its
209
+ # own; returns the last one's result. For setting up: it fails while
210
+ # a benchmark is measured (Duel.measuring?), as what it runs would be
211
+ # counted.
212
+ def self.execute(sql)
213
+ Duel.unmeasured!("Duel::MySQL.execute")
214
+ run(socket, sql)
215
+ end
216
+
217
+ # Empties every table in its database, restarting their
218
+ # AUTO_INCREMENTs. For setting up, as execute is.
219
+ def self.reset
220
+ Duel.unmeasured!("Duel::MySQL.reset")
221
+ tables = run(socket, "SELECT table_name FROM information_schema.tables " \
222
+ "WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'").map(&:first)
223
+ # (Only this connection's: it's closed after.)
224
+ run(socket, "SET FOREIGN_KEY_CHECKS = 0#{tables.map { |table| "; TRUNCATE TABLE `#{table}`" }.join}")
225
+ end
226
+
227
+ # Running totals of what the worker's server did, all told (#read),
228
+ # and by statement, to explain a regression with the statements that
229
+ # did the most of it (#details).
230
+ class Statements
231
+ # The measures, in the order of a statement's numbers.
232
+ NAMES = METRICS.keys.freeze
233
+
234
+ # The statements a regression is explained with, and how much of
235
+ # each one's text.
236
+ SHOWN = 3
237
+ TEXT = 80
238
+
239
+ # A statement not read yet, as a row of MySQL.digests: it did
240
+ # nothing.
241
+ UNREAD = [nil, 0, 0, 0, 0].freeze
242
+
243
+ # A statement that did nothing yet.
244
+ NOTHING = [0, 0, 0, 0].freeze
245
+
246
+ # Nothing to say (no statement ran).
247
+ NONE = {} #: Hash[String, String]
248
+
249
+ # No statement changed.
250
+ UNCHANGED = [] #: Array[String]
251
+
252
+ # A reading's context: every statement's row as of it, by digest (a
253
+ # Hash it shares with the last reading, if none changed), the last
254
+ # reading's, and the digests of those that changed since.
255
+ Reading = Data.define(:rows, :previous, :changed)
256
+
257
+ # How long before a reading (in seconds) the next one reads the
258
+ # statements run since: a statement's counted just after it's run
259
+ # (see DIGESTS), so one counted while the reading read is read again
260
+ # (and the clock may be set back a little).
261
+ OVERLAP = 1
262
+
263
+ # A statement's numbers (or the server's, all told) by measure, time
264
+ # in µs.
265
+ def self.by_measure(numbers)
266
+ NAMES.each_with_index.to_h do |name, i|
267
+ number = numbers.fetch(i)
268
+ [name, (name == "mysql_time") ? number.fdiv(1_000_000) : number]
269
+ end
270
+ end
271
+
272
+ # Read over +connection+, the plugin's own (a worker's).
273
+ def initialize(connection)
274
+ @connection = connection
275
+ @texts = {} # (each statement's, once)
276
+ # (The last reading's rows.)
277
+ @rows = {} #: Hash[String, row]
278
+ @since = 0 # (when the next reading reads statements run since)
279
+ end
280
+
281
+ # What the server has done so far, all told, by measure, and by
282
+ # statement (the reading's context, a Reading, for #details).
283
+ def read
284
+ connection = @connection.value
285
+ [Statements.by_measure(MySQL.totals(connection)), reading(connection)]
286
+ end
287
+
288
+ # The statements that did the most of each measure in a sample's
289
+ # stretches (pairs of #read's readings: see Snapshot#details), by
290
+ # measure, but its own readings': how many times each ran an
291
+ # iteration (+n+ of them), "an iteration: 2× SELECT `name` FROM
292
+ # `items` WHERE `id` = ?", and each one's share of the rest,
293
+ # "SELECT `name` FROM `items` WHERE `id` = ? (75%)".
294
+ def details(stretches, n)
295
+ # (A stretch whose readings share their rows: none changed.)
296
+ return NONE if stretches.all? { |started, stopped| stopped.context.rows.equal?(started.context.rows) }
297
+
298
+ grew = grew(stretches)
299
+ texts = texts(grew.keys)
300
+ NAMES.each_with_index.filter_map do |name, i|
301
+ top = grew.filter_map { |digest, more| [more.fetch(i), texts.fetch(digest, digest)] if more.fetch(i).positive? } #: Array[[Integer, String]]
302
+ next if top.empty?
303
+
304
+ shown = top.sort_by { |value, text| [-value, text] }.first(SHOWN)
305
+ told = if i.zero? # (queries)
306
+ "an iteration: #{shown.map { |value, text| "#{format("%.4g", value.fdiv(n))}× #{text}" }.join("; ")}"
307
+ else
308
+ all = top.sum { |value, _| value }
309
+ shown.map { |value, text| "#{text} (#{format("%.3g%%", value * 100.0 / all)})" }.join("; ")
310
+ end
311
+ [name, told]
312
+ end.to_h
313
+ end
314
+
315
+ private
316
+
317
+ # A Reading, read over +connection+: the server's statements run
318
+ # since (a little before) the last reading, those that changed since
319
+ # put in a copy of its rows.
320
+ def reading(connection)
321
+ own = @own ||= MySQL.digests_of(connection, [BUSY, STATS, format(DIGESTS, 0, "''")])
322
+ since = @since
323
+ @since = Process.clock_gettime(Process::CLOCK_REALTIME, :second) - OVERLAP
324
+ previous = @rows
325
+ rows = previous
326
+ changed = UNCHANGED
327
+ MySQL.digests(connection, since, own).each do |row|
328
+ digest = row.fetch(0)
329
+ next if row == previous[digest]
330
+
331
+ # (The last reading's Hash is its own: copied, at the first change.)
332
+ if rows.equal?(previous)
333
+ rows = previous.dup
334
+ changed = [] #: Array[String]
335
+ end
336
+ rows[digest] = row
337
+ changed << digest
338
+ end
339
+ # (None changed, again: the last Reading does. A copy is no
340
+ # reading's previous rows.)
341
+ last = @last
342
+ return last if last && last.previous.equal?(rows)
343
+
344
+ @rows = rows.freeze
345
+ @last = Reading.new(rows, previous, changed)
346
+ end
347
+
348
+ # What each statement did in the +stretches+, all told, by digest:
349
+ # those that did anything.
350
+ def grew(stretches)
351
+ grew = {} #: Hash[String, numbers]
352
+ stretches.each do |started, stopped|
353
+ before = started.context.rows
354
+ after = stopped.context
355
+ # (Read right after +started+, it says which changed since: the
356
+ # others can't have done anything.)
357
+ digests = after.previous.equal?(before) ? after.changed : after.rows.keys
358
+ digests.each do |digest|
359
+ now = after.rows.fetch(digest)
360
+ was = before.fetch(digest, UNREAD)
361
+ so_far = grew.fetch(digest, NOTHING)
362
+ grew[digest] = Array.new(NAMES.size) { |i| so_far.fetch(i) + now.fetch(i + 1) - was.fetch(i + 1) }
363
+ end
364
+ end
365
+ grew.select { |_, more| more.any?(&:positive?) }
366
+ end
367
+
368
+ # Each statement's text, by digest: those not yet known read from
369
+ # the server.
370
+ def texts(digests)
371
+ unknown = digests.reject { |digest| @texts.key?(digest) }
372
+ MySQL.texts(@connection.value, unknown).each { |digest, text| @texts[digest] = shorten(text) } unless unknown.empty?
373
+ @texts
374
+ end
375
+
376
+ def shorten(text) = (text.length > TEXT) ? "#{text[0, TEXT - 1]}…" : text
377
+ end
378
+
379
+ # A connection to a worker's server, as root, to its database.
380
+ class Connection
381
+ # Whether a server answers on +socket+.
382
+ def self.ping(socket)
383
+ new(socket).close
384
+ rescue Trilogy::Error
385
+ false
386
+ end
387
+
388
+ def initialize(socket)
389
+ # (As root: trilogy's user, unless it's given another.)
390
+ @client = Trilogy.new(socket: socket, database: DATABASE, multi_statement: true)
391
+ end
392
+
393
+ def query(sql) = @client.query(sql)
394
+
395
+ # +text+ as a string literal.
396
+ def quote(text) = "'#{@client.escape(text)}'"
397
+
398
+ # Runs statements; returns the last one's result.
399
+ def execute(sql)
400
+ result = query(sql)
401
+ result = @client.next_result while @client.more_results_exist?
402
+ result
403
+ end
404
+
405
+ # Closes it without telling the server (no QUIT, which the server
406
+ # could read after a benchmark's started, and count); true.
407
+ def close = @client.discard!
408
+ end
409
+
410
+ # A worker's data directory: a copy of one initialized once, with the
411
+ # database created in it.
412
+ class DataDir
413
+ # The server program: +server+ if it's a path, or the one by that
414
+ # name on +path+; nil if there's none.
415
+ def self.find_server(server, path = ENV.fetch("PATH", ""))
416
+ files = server.include?(File::SEPARATOR) ? [server] : path.split(File::PATH_SEPARATOR).map { |dir| File.join(dir, server) }
417
+ files.find { |file| File.executable?(file) }
418
+ end
419
+
420
+ # The options the server's run with, initializing +data+ or not.
421
+ # (They come first: mysqld takes --no-defaults only there.)
422
+ def self.options(data) = ["--no-defaults", "--datadir=#{data}", *OPTIONS]
423
+
424
+ def initialize(resource, server)
425
+ @resource = resource
426
+ @server = server
427
+ end
428
+
429
+ # Makes one in +data+: a copy of the one made for this server.
430
+ def create(data) = FileUtils.cp_r(@resource.cached(key) { |dir| make(dir) }, data)
431
+
432
+ private
433
+
434
+ # What a data directory is good for: this server (until it changes),
435
+ # and this version of the plugin.
436
+ def key
437
+ server = File.realpath(@server)
438
+ "mysql-#{VERSION}-#{Digest::SHA256.hexdigest([server, File.mtime(server).to_f].join("\0"))[0, 16]}"
439
+ end
440
+
441
+ # Initializes it (with root's password empty), and creates the
442
+ # database.
443
+ def make(data)
444
+ init = @resource.path("init.sql")
445
+ File.write(init, "CREATE DATABASE #{DATABASE};\n")
446
+ @resource.run(@server, *DataDir.options(data), "--initialize-insecure", "--init-file=#{init}", log: "mysqld-initialize")
447
+ end
448
+ end
449
+ end
450
+ end
@@ -0,0 +1,212 @@
1
+ module Duel
2
+ # A MySQL server of each worker's own, and what it did for each
3
+ # benchmark (mysql_queries, mysql_rows, mysql_rows_examined,
4
+ # mysql_time).
5
+ module MySQL
6
+ VERSION: String
7
+
8
+ URL: String
9
+ SOCKET: String
10
+ DATABASE: String
11
+ USER: String
12
+
13
+ # The server's options (after --no-defaults).
14
+ OPTIONS: Array[String]
15
+
16
+ # And once it's initialized.
17
+ RUNNING: Array[String]
18
+
19
+ METRICS: Hash[String, Metric::settings]
20
+
21
+ # What the worker's server has done so far, but for this connection.
22
+ STATS: String
23
+
24
+ # Whether a client besides the reading one is running a statement,
25
+ # and how long a reading waits until none is.
26
+ BUSY: String
27
+ SETTLE: Float
28
+
29
+ # What it has done so far by statement (digest), any connection's:
30
+ # of those run since a time, but some digests.
31
+ DIGESTS: String
32
+
33
+ # The digests of statements, and the texts of digests.
34
+ DIGEST_OF: String
35
+ TEXTS: String
36
+
37
+ # A statement's numbers (or the server's, all told): queries, rows,
38
+ # rows examined, time (ps).
39
+ type numbers = Array[Integer]
40
+
41
+ # A statement's row, as MySQL.digests reads it: [digest, *numbers].
42
+ type row = Array[untyped]
43
+
44
+ # Configures Duel for the plugin (c.use :mysql): the server (+server+
45
+ # the program, +args+ more of its options, +setup+ SQL each worker's
46
+ # database runs when it's started, +env+ the variables besides
47
+ # DUEL_MYSQL_URL given its URL, +scheme+ the URL's) and its metrics.
48
+ # Returns the plugin's own connection to it, a worker's.
49
+ def self.configure: (Config config, ?server: String, ?args: Array[String], ?setup: String?, ?env: Array[String],
50
+ ?scheme: String) -> InWorker[Connection]
51
+
52
+ # Runs SQL on a connection of its own to the server on +socket+;
53
+ # returns the last statement's result.
54
+ def self.run: (String socket, String sql) -> ::Trilogy::Result
55
+
56
+ # What the worker's server has done so far, but for the plugin's own
57
+ # connection, which reads it.
58
+ def self.totals: (Connection connection) -> numbers
59
+
60
+ # Waits, SETTLE seconds at most, until +connection+ is the only one
61
+ # running a statement.
62
+ def self.settle: (Connection connection) -> void
63
+
64
+ # The same by measure.
65
+ def self.stats: (Connection connection) -> Snapshot::totals[Metric::value]
66
+
67
+ # What the server has done so far, by statement, read over
68
+ # +connection+: those run since +since+, but the +own+ digests, each
69
+ # one's [digest, *numbers].
70
+ def self.digests: (Connection connection, Integer since, Array[String] own) -> Array[row]
71
+
72
+ # The digest of each of +statements+, read over +connection+.
73
+ def self.digests_of: (Connection connection, Array[String] statements) -> Array[String]
74
+
75
+ # The texts of the statements with these digests, by digest, read over
76
+ # +connection+.
77
+ def self.texts: (Connection connection, Array[String] digests) -> Hash[String, String]
78
+
79
+ # For benchmarks: the worker's server.
80
+ def self.url: () -> String
81
+
82
+ # Its socket's path.
83
+ def self.socket: () -> String
84
+
85
+ # Runs SQL on it, over a connection of its own; returns the last
86
+ # statement's result. For setting up: it fails while a benchmark is
87
+ # measured.
88
+ def self.execute: (String sql) -> ::Trilogy::Result
89
+
90
+ # Empties every table in its database. For setting up, as execute is.
91
+ def self.reset: () -> ::Trilogy::Result
92
+
93
+ # Running totals of what the worker's server did, all told, and by
94
+ # statement, to explain a regression.
95
+ class Statements
96
+ # The measures, in the order of a statement's numbers.
97
+ NAMES: Array[String]
98
+
99
+ # The statements a regression is explained with, and how much of
100
+ # each one's text.
101
+ SHOWN: Integer
102
+ TEXT: Integer
103
+
104
+ # A statement not read yet, as a row of MySQL.digests.
105
+ UNREAD: row
106
+
107
+ # A statement that did nothing yet.
108
+ NOTHING: numbers
109
+
110
+ # Nothing to say.
111
+ NONE: Hash[String, String]
112
+
113
+ # No statement changed.
114
+ UNCHANGED: Array[String]
115
+
116
+ # A reading's context: every statement's row as of it, by digest, the
117
+ # last reading's, and the digests of those that changed since.
118
+ class Reading
119
+ attr_reader rows: Hash[String, row]
120
+ attr_reader previous: Hash[String, row]
121
+ attr_reader changed: Array[String]
122
+
123
+ def initialize: (Hash[String, row] rows, Hash[String, row] previous, Array[String] changed) -> void
124
+ end
125
+
126
+ # How long before a reading (in seconds) the next one reads the
127
+ # statements run since.
128
+ OVERLAP: Integer
129
+
130
+ @connection: InWorker[Connection]
131
+ @texts: Hash[String, String]
132
+ @rows: Hash[String, row]
133
+ @last: Reading?
134
+ @since: Integer
135
+ @own: Array[String]?
136
+
137
+ # A statement's numbers (or the server's) by measure, time in µs.
138
+ def self.by_measure: (numbers numbers) -> Snapshot::totals[Metric::value]
139
+
140
+ # Read over +connection+, the plugin's own (a worker's).
141
+ def initialize: (InWorker[Connection] connection) -> void
142
+
143
+ # What the server has done so far, all told, by measure, and by
144
+ # statement (the reading's context, for #details).
145
+ def read: () -> [Snapshot::totals[Metric::value], Reading]
146
+
147
+ # The statements that did the most of each measure in a sample's
148
+ # stretches, by measure, but its own readings': how many times each
149
+ # ran an iteration (+n+ of them), and each one's share of the rest.
150
+ def details: (Snapshot::stretches[Metric::value, Reading] stretches, Integer n) -> Hash[String, String]
151
+
152
+ private
153
+
154
+ # A Reading, read over +connection+.
155
+ def reading: (Connection connection) -> Reading
156
+
157
+ # What each statement did in the stretches, all told, by digest.
158
+ def grew: (Snapshot::stretches[Metric::value, Reading] stretches) -> Hash[String, numbers]
159
+
160
+ # Each statement's text, by digest.
161
+ def texts: (Array[String] digests) -> Hash[String, String]
162
+
163
+ def shorten: (String text) -> String
164
+ end
165
+
166
+ # A connection to a worker's server, as root, to its database.
167
+ class Connection
168
+ @client: ::Trilogy
169
+
170
+ # Whether a server answers on +socket+.
171
+ def self.ping: (String socket) -> bool
172
+
173
+ def initialize: (String socket) -> void
174
+
175
+ def query: (String sql) -> ::Trilogy::Result
176
+
177
+ # +text+ as a string literal.
178
+ def quote: (String text) -> String
179
+
180
+ # Runs statements; returns the last one's result.
181
+ def execute: (String sql) -> ::Trilogy::Result
182
+
183
+ # Closes it without telling the server (no QUIT); true.
184
+ def close: () -> bool
185
+ end
186
+
187
+ # A worker's data directory: a copy of one initialized once.
188
+ class DataDir
189
+ @resource: Resource
190
+ @server: String
191
+
192
+ # The server program: +server+ if it's a path, or the one by that
193
+ # name on +path+; nil if there's none.
194
+ def self.find_server: (String server, ?String path) -> String?
195
+
196
+ # The options the server's run with, initializing a data directory
197
+ # or not.
198
+ def self.options: (String data) -> Array[String]
199
+
200
+ def initialize: (Resource resource, String server) -> void
201
+
202
+ # Makes one in +data+: a copy of the one made for this server.
203
+ def create: (String data) -> void
204
+
205
+ private
206
+
207
+ def key: () -> String
208
+
209
+ def make: (String data) -> void
210
+ end
211
+ end
212
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: duel-mysql
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
+ - !ruby/object:Gem::Dependency
27
+ name: trilogy
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.9'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.9'
40
+ description: 'A Duel plugin: each worker gets a MySQL server of its own, and every
41
+ benchmark is judged on the statements it sent, the rows they read and their time
42
+ too.'
43
+ email:
44
+ - sferik@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE
51
+ - README.md
52
+ - lib/duel/mysql.rb
53
+ - lib/duel/mysql/version.rb
54
+ - sig/duel/mysql.rbs
55
+ homepage: https://github.com/sferik/duel-ruby/tree/main/plugins/mysql
56
+ licenses:
57
+ - MIT
58
+ metadata:
59
+ source_code_uri: https://github.com/sferik/duel-ruby/tree/main/plugins/mysql
60
+ changelog_uri: https://github.com/sferik/duel-ruby/blob/main/plugins/mysql/CHANGELOG.md
61
+ bug_tracker_uri: https://github.com/sferik/duel-ruby/issues
62
+ rubygems_mfa_required: 'true'
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '3.4'
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 4.0.23
78
+ specification_version: 4
79
+ summary: A MySQL server for each Duel worker, and what it did for each benchmark
80
+ test_files: []