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 +7 -0
- data/CHANGELOG.md +70 -0
- data/LICENSE +21 -0
- data/README.md +143 -0
- data/lib/duel/mysql/version.rb +7 -0
- data/lib/duel/mysql.rb +450 -0
- data/sig/duel/mysql.rbs +212 -0
- metadata +80 -0
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
|
+
[](https://github.com/sferik/duel-ruby)
|
|
4
|
+
[](https://github.com/sferik/duel-ruby/actions/workflows/plugin-mysql.yml)
|
|
5
|
+
[](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)`.
|
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
|
data/sig/duel/mysql.rbs
ADDED
|
@@ -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: []
|