duel-activerecord 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 +36 -0
- data/LICENSE +21 -0
- data/README.md +146 -0
- data/lib/duel/activerecord/version.rb +7 -0
- data/lib/duel/activerecord.rb +198 -0
- data/sig/duel/activerecord.rbs +99 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 56141f41f03d09cf171aeaa7097d59341a1b6b101c855de77ffc472ca0be405a
|
|
4
|
+
data.tar.gz: 2f8a62b553cfd1bd9432311b301ee93bb37fcdac462f2dd7cca83623d978d0c1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 652b932685d313d3a9a345fe2044e5c0ffd6c16b2a30491cac55cf498bb82591a5cb7d71d48e6f8969fced24a5fc5140e4e961dfa7a2e977a6109b4a244bc1dd
|
|
7
|
+
data.tar.gz: 695268da1a1371ff6a78e2021c29f338857321217f05dfe463f74de587242ffde9ba1365c4f44103741949871fae050de6afe9072d5e41f0c25eff4872170cad
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
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-activerecord is a Duel plugin, open source
|
|
13
|
+
under the MIT license. It needs Duel 0.1, Active Record 7.2 or later,
|
|
14
|
+
and Ruby 3.4 or later (or JRuby 10, or TruffleRuby 40).
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `c.use :activerecord`: the `active_record_queries`,
|
|
19
|
+
`active_record_cached_queries`, `active_record_transactions` and
|
|
20
|
+
`active_record_instantiations` metrics, from Active Record's
|
|
21
|
+
notifications, and `active_record_query_time` when asked for
|
|
22
|
+
(`query_time: true`), with `ignore:` to leave queries out. They're
|
|
23
|
+
running totals (Duel's `c.metric`), subscribed to in workers only, as
|
|
24
|
+
each worker opens the metric, and unsubscribed from as it closes it,
|
|
25
|
+
when the worker ends. Where the queries regressed, Duel shows the
|
|
26
|
+
statements each version sent most, and how many times an iteration
|
|
27
|
+
(Duel's `details`). `active_record_query_time` is in nanoseconds
|
|
28
|
+
(`unit: "ns"`), shown as time. The counts are in queries,
|
|
29
|
+
transactions and records (`unit:`, each with its plural), so reports
|
|
30
|
+
read "1 query → 2 queries".
|
|
31
|
+
- Mutation tested: mutant kills every change to the plugin's code, in
|
|
32
|
+
CI.
|
|
33
|
+
- RBS signatures in sig/, checked with Steep.
|
|
34
|
+
|
|
35
|
+
[Unreleased]: https://github.com/sferik/duel-ruby/compare/duel-activerecord-v0.1.0...HEAD
|
|
36
|
+
[0.1.0]: https://github.com/sferik/duel-ruby/releases/tag/duel-activerecord-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,146 @@
|
|
|
1
|
+
# duel-activerecord
|
|
2
|
+
|
|
3
|
+
[](https://github.com/sferik/duel-ruby)
|
|
4
|
+
[](https://github.com/sferik/duel-ruby/actions/workflows/plugin-activerecord.yml)
|
|
5
|
+
[](https://rubygems.org/gems/duel-activerecord)
|
|
6
|
+
|
|
7
|
+
A [Duel](https://github.com/sferik/duel-ruby) plugin for code that uses
|
|
8
|
+
Active Record: every benchmark is judged on what Active Record did for it
|
|
9
|
+
too, whatever the database.
|
|
10
|
+
|
|
11
|
+
| Metric | What it is | A regression |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `active_record_queries` | the queries it sent the database: not schema queries, transactions' own statements (`BEGIN`, `COMMIT`...), or those the query cache answered | any increase |
|
|
14
|
+
| `active_record_cached_queries` | the queries the query cache answered | any increase |
|
|
15
|
+
| `active_record_transactions` | the transactions it opened, and savepoints | any increase |
|
|
16
|
+
| `active_record_instantiations` | the records it instantiated | any increase |
|
|
17
|
+
| `active_record_query_time` (when asked for) | how long its queries took, in elapsed time | > 5% slower, 99% confident |
|
|
18
|
+
|
|
19
|
+
So a change that adds an N+1 query, or saves records one transaction at
|
|
20
|
+
a time, fails on its counts, exactly, whatever its timings say. And on a
|
|
21
|
+
database server, whose work the benchmark's CPU time doesn't include,
|
|
22
|
+
the counts are the sign of it.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
# Gemfile
|
|
28
|
+
group :development do
|
|
29
|
+
gem "duel-activerecord"
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
> [!NOTE]
|
|
34
|
+
> It needs Active Record 7.2 or later.
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# .duel.rb
|
|
40
|
+
Duel.configure { |c| c.use :activerecord }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`c.use` requires the plugin, so `.duel.rb` doesn't need to.
|
|
44
|
+
|
|
45
|
+
The plugin doesn't give workers a database: they connect as your
|
|
46
|
+
benchmarks do (in `duel/duel_helper.rb`, say), to one of their own (an
|
|
47
|
+
in-memory SQLite database, as here, or a file in the directory that
|
|
48
|
+
`c.prepare(:worker)` gives each worker) or to one all of them share. Then any benchmark is judged on
|
|
49
|
+
its queries:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
# duel/duel_helper.rb
|
|
53
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
54
|
+
load "db/schema.rb"
|
|
55
|
+
10.times { |i| Post.create!(title: "post #{i}").comments.create! }
|
|
56
|
+
|
|
57
|
+
# duel/posts_duel.rb
|
|
58
|
+
require_relative "duel_helper"
|
|
59
|
+
|
|
60
|
+
duel "posts: the index page" do
|
|
61
|
+
PostsPresenter.new(Post.all).rows # one query for posts, one for their comments
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A change that loads each post's comments on its own fails, and says
|
|
66
|
+
which statements each version sent most, and how many times an
|
|
67
|
+
iteration:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
! posts: the index page 1.2s n=2 ... active_record_queries 4→22 active_record_cached_queries 0 active_record_transactions 0 active_record_instantiations 40 records ...
|
|
71
|
+
REGRESSION posts: the index page: worse instructions, cpu, active_record_queries, allocations in every measurement
|
|
72
|
+
...
|
|
73
|
+
active_record_queries: 2 queries → 11 queries an iteration
|
|
74
|
+
active_record_queries (candidate): an iteration: 10× SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ?; 1× SELECT "posts".* FROM "posts"
|
|
75
|
+
active_record_queries (baseline): an iteration: 1× SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); 1× SELECT "posts".* FROM "posts"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
> [!TIP]
|
|
79
|
+
> Queries a benchmark makes to set itself up each time go in `untimed`,
|
|
80
|
+
> which pauses every metric, these too.
|
|
81
|
+
|
|
82
|
+
Options:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
c.use :activerecord,
|
|
86
|
+
query_time: true, # active_record_query_time too (default: false)
|
|
87
|
+
ignore: ->(payload) { payload[:sql].start_with?("PRAGMA") } # queries not to count, nor time
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`ignore` gets each query's `sql.active_record` payload (`:sql`, `:name`,
|
|
91
|
+
`:binds`...). Duel's `c.measure` changes a measure's settings, or leaves
|
|
92
|
+
it out (and the plugin doesn't subscribe to what no measure left needs):
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
c.measure("active_record_query_time", limit: 1.1)
|
|
96
|
+
c.measure("active_record_instantiations", skip: true)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## How it measures
|
|
100
|
+
|
|
101
|
+
From Active Record's own notifications (`sql.active_record`,
|
|
102
|
+
`transaction.active_record` and `instantiation.active_record`), in every
|
|
103
|
+
thread of the worker, as running totals (Duel's `c.metric`). Reading
|
|
104
|
+
them queries nothing, so there's nothing for Duel to take away
|
|
105
|
+
(`calibrate: false`). Counts are shown in their units (queries,
|
|
106
|
+
transactions, records: `1 query`, `2 queries`, though the summary line
|
|
107
|
+
leaves out the one a name ends in), and
|
|
108
|
+
`active_record_query_time` is in nanoseconds (`unit: "ns"`), shown as
|
|
109
|
+
time.
|
|
110
|
+
|
|
111
|
+
It subscribes in each worker as Duel opens its metric (the metric's
|
|
112
|
+
`open`), once the benchmark files have loaded, so queries made while
|
|
113
|
+
they load (a schema, seeds) aren't counted, and the runner, which loads
|
|
114
|
+
`.duel.rb` too, never loads Active Support for it. It unsubscribes when
|
|
115
|
+
the worker ends (the metric's `close`).
|
|
116
|
+
|
|
117
|
+
It also keeps track of the statement each of the last 1,000 queries
|
|
118
|
+
sent, by its SQL (with binds, `WHERE id = ?`, the same statement for
|
|
119
|
+
every id). That's a hash of the SQL for each query, and no copy of it
|
|
120
|
+
(nothing a benchmark's allocations would count), but it holds on to the
|
|
121
|
+
SQL of the first eight statements sent since a sample was last
|
|
122
|
+
explained (with [duel-retained](https://github.com/sferik/duel-ruby/tree/main/plugins/retained),
|
|
123
|
+
that's within its allowance), and the rest are told as others. A regression
|
|
124
|
+
shows the three a sample sent most, and how many times an iteration
|
|
125
|
+
(and others'), of its last 1,000 queries, but those in `untimed` blocks,
|
|
126
|
+
with at most 100 characters of each.
|
|
127
|
+
|
|
128
|
+
`active_record_queries` counts what Rails' log counts as queries, but
|
|
129
|
+
leaves out cached ones, which never reach the database: they're
|
|
130
|
+
`active_record_cached_queries`. Transactions are counted on their own, once each
|
|
131
|
+
(not their `BEGIN` and `COMMIT`), and only those Active Record opened
|
|
132
|
+
(an empty `transaction` block opens none).
|
|
133
|
+
|
|
134
|
+
### Why `active_record_query_time` is off unless asked for
|
|
135
|
+
|
|
136
|
+
It's elapsed time, which Duel otherwise never judges: a busy machine
|
|
137
|
+
adds to it, and so does anything else the database is doing. With
|
|
138
|
+
SQLite, it's time the benchmark's CPU time already counts. With a
|
|
139
|
+
database server, it's mostly waiting: on the network, and on a server
|
|
140
|
+
that may be serving other workers too. Paired side by side, the two
|
|
141
|
+
versions see much the same of that, so it can settle a large
|
|
142
|
+
difference, and it's judged like CPU time but with a looser limit (5%),
|
|
143
|
+
and only for samples with 0.3 ms of it. But for what the server did, its
|
|
144
|
+
own statistics are the better measure (as
|
|
145
|
+
[duel-redis](https://github.com/sferik/duel-ruby/tree/main/plugins/redis) reads Redis's), and
|
|
146
|
+
the query counts catch most of what makes it slower.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "duel"
|
|
4
|
+
require_relative "activerecord/version"
|
|
5
|
+
|
|
6
|
+
module Duel
|
|
7
|
+
# What Active Record did for each benchmark, from its notifications,
|
|
8
|
+
# whatever the database:
|
|
9
|
+
#
|
|
10
|
+
# - active_record_queries: the queries it sent the database (counted),
|
|
11
|
+
# but schema queries, transactions' own statements (BEGIN, COMMIT...)
|
|
12
|
+
# and those the query cache answered
|
|
13
|
+
# - active_record_cached_queries: the queries the query cache answered
|
|
14
|
+
# (counted)
|
|
15
|
+
# - active_record_transactions: the transactions, and savepoints, it
|
|
16
|
+
# opened (counted)
|
|
17
|
+
# - active_record_instantiations: the records it instantiated (counted)
|
|
18
|
+
# - active_record_query_time, when asked for: how long the queries
|
|
19
|
+
# took, in elapsed time (a ratio metric, in nanoseconds, like CPU
|
|
20
|
+
# time)
|
|
21
|
+
#
|
|
22
|
+
# # .duel.rb
|
|
23
|
+
# Duel.configure { |c| c.use :activerecord }
|
|
24
|
+
#
|
|
25
|
+
# It subscribes to the notifications in workers only, as its metric is
|
|
26
|
+
# opened, once the benchmarks have loaded (so the runner never loads
|
|
27
|
+
# Active Support for it), and unsubscribes when the worker ends. Where the queries
|
|
28
|
+
# regressed, it says which statements a sample sent most, on each
|
|
29
|
+
# version (Counter#details).
|
|
30
|
+
module ActiveRecord
|
|
31
|
+
Duel.plugin(:activerecord, self)
|
|
32
|
+
|
|
33
|
+
# Counts. Reading them queries nothing, so there's nothing to take
|
|
34
|
+
# away.
|
|
35
|
+
MEASURES = {
|
|
36
|
+
"active_record_queries" => {kind: :counted, unit: %w[query queries], calibrate: false},
|
|
37
|
+
"active_record_cached_queries" => {kind: :counted, unit: %w[query queries], calibrate: false},
|
|
38
|
+
"active_record_transactions" => {kind: :counted, unit: %w[transaction transactions], calibrate: false},
|
|
39
|
+
"active_record_instantiations" => {kind: :counted, unit: %w[record records], calibrate: false}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Elapsed time, measured when asked for (see the README).
|
|
43
|
+
QUERY_TIME = {"active_record_query_time" => {kind: :ratio, unit: "ns", floor: 300_000, limit: 1.05}}
|
|
44
|
+
|
|
45
|
+
# Configures Duel for the plugin (c.use :activerecord): its metrics,
|
|
46
|
+
# and +query_time+ too if true. +ignore+, if given, is called with
|
|
47
|
+
# each query's notification payload, and a query it's true for isn't
|
|
48
|
+
# counted, nor timed. Returns the Counter.
|
|
49
|
+
def self.configure(config, query_time: false, ignore: nil)
|
|
50
|
+
counter = Counter.new(query_time:, ignore:)
|
|
51
|
+
measures = query_time ? MEASURES.merge(QUERY_TIME) : MEASURES
|
|
52
|
+
config.metric(measures) { counter.totals }
|
|
53
|
+
.open { counter.subscribe(measures.keys.reject { |name| config.skip?(name) }) }
|
|
54
|
+
.close { counter.unsubscribe }
|
|
55
|
+
.details { |stretches, sample| counter.details(stretches, sample.n) }
|
|
56
|
+
counter
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Running totals, from the notifications it subscribes to. One object
|
|
60
|
+
# (Active Support calls its #start and #finish, the cheapest kind of
|
|
61
|
+
# subscriber) for every event.
|
|
62
|
+
class Counter
|
|
63
|
+
SQL = "sql.active_record"
|
|
64
|
+
TRANSACTION = "transaction.active_record"
|
|
65
|
+
INSTANTIATION = "instantiation.active_record"
|
|
66
|
+
|
|
67
|
+
# The events each measure needs.
|
|
68
|
+
EVENTS = {
|
|
69
|
+
"active_record_queries" => SQL, "active_record_cached_queries" => SQL, "active_record_query_time" => SQL,
|
|
70
|
+
"active_record_transactions" => TRANSACTION, "active_record_instantiations" => INSTANTIATION
|
|
71
|
+
}.freeze
|
|
72
|
+
|
|
73
|
+
# Where a query's start is kept until it finishes (queries in
|
|
74
|
+
# several threads or fibers at once each have their own).
|
|
75
|
+
STARTED = :duel_activerecord_started
|
|
76
|
+
|
|
77
|
+
# The queries counted last that it keeps track of (which statement
|
|
78
|
+
# each was), for #details.
|
|
79
|
+
KEPT = 1000
|
|
80
|
+
|
|
81
|
+
# The most statements whose SQL it keeps between two #details
|
|
82
|
+
# (the first sent: the rest are told as others).
|
|
83
|
+
STATEMENTS = 8
|
|
84
|
+
|
|
85
|
+
# The most statements shown, and the most shown of each one's SQL.
|
|
86
|
+
SHOWN = 3
|
|
87
|
+
LENGTH = 100
|
|
88
|
+
|
|
89
|
+
def initialize(query_time:, ignore:)
|
|
90
|
+
@query_time = query_time
|
|
91
|
+
@ignore = ignore
|
|
92
|
+
@queries = @cached = @transactions = @instantiations = @time = 0
|
|
93
|
+
@subscriptions = [] #: Array[untyped]
|
|
94
|
+
# Each query's SQL's hash, round (the first KEPT make it).
|
|
95
|
+
@recent = [] #: Array[Integer]
|
|
96
|
+
@sql = {} #: Hash[Integer, String]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Subscribes to the events +measures+ need (see EVENTS).
|
|
100
|
+
def subscribe(measures)
|
|
101
|
+
@timing = measures.include?("active_record_query_time")
|
|
102
|
+
@subscriptions = measures.map { |measure| EVENTS.fetch(measure) }.uniq.map do |event|
|
|
103
|
+
notifications.subscribe(event, self)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The totals so far, by name.
|
|
108
|
+
def totals
|
|
109
|
+
totals = {"active_record_queries" => @queries, "active_record_cached_queries" => @cached,
|
|
110
|
+
"active_record_transactions" => @transactions, "active_record_instantiations" => @instantiations}
|
|
111
|
+
@query_time ? totals.merge("active_record_query_time" => @time) : totals
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# What explains the queries a sample sent, in its stretches (pairs
|
|
115
|
+
# of readings of the totals: see Snapshot#details) of +n+
|
|
116
|
+
# iterations: the statements sent most (of the last KEPT), and how
|
|
117
|
+
# many times an iteration, the SHOWN most and others: "an
|
|
118
|
+
# iteration: 3× SELECT ...; 1× others". Nothing where none were. It
|
|
119
|
+
# forgets their SQL.
|
|
120
|
+
def details(stretches, n)
|
|
121
|
+
oldest = @queries - KEPT
|
|
122
|
+
keys = stretches.flat_map do |started, stopped|
|
|
123
|
+
([queries(started), oldest].max...queries(stopped)).map { |i| @recent.fetch(i % KEPT) }
|
|
124
|
+
end
|
|
125
|
+
return if keys.empty?
|
|
126
|
+
|
|
127
|
+
sent = keys.tally.filter_map { |key, count| [count, @sql.fetch(key)] if @sql.key?(key) } #: Array[[Integer, String]]
|
|
128
|
+
shown = sent.sort_by { |count, sql| [-count, sql] }.first(SHOWN)
|
|
129
|
+
others = keys.size - shown.sum { |count, _| count }
|
|
130
|
+
shown << [others, "others"] if others.positive?
|
|
131
|
+
{"active_record_queries" => "an iteration: #{shown.map { |count, sql| "#{format("%.4g", count.fdiv(n))}× #{shorten(sql)}" }.join("; ")}"}
|
|
132
|
+
ensure
|
|
133
|
+
@sql.clear
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Stops counting.
|
|
137
|
+
def unsubscribe = @subscriptions.each { |subscription| notifications.unsubscribe(subscription) }
|
|
138
|
+
|
|
139
|
+
# (Active Support's, for every event subscribed to.)
|
|
140
|
+
def start(name, _id, _payload)
|
|
141
|
+
(Thread.current[STARTED] ||= []) << monotonic if @timing && name == SQL
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def finish(name, _id, payload)
|
|
145
|
+
case name
|
|
146
|
+
when SQL then query(payload)
|
|
147
|
+
when TRANSACTION then @transactions += 1
|
|
148
|
+
else @instantiations += payload.fetch(:record_count)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def query(payload)
|
|
155
|
+
took = monotonic - Thread.current.fetch(STARTED).pop if @timing
|
|
156
|
+
name = payload[:name]
|
|
157
|
+
return if name == "SCHEMA" || @ignore&.call(payload)
|
|
158
|
+
|
|
159
|
+
@time += took if took
|
|
160
|
+
return if name == "TRANSACTION"
|
|
161
|
+
return @cached += 1 if payload[:cached]
|
|
162
|
+
|
|
163
|
+
sent(payload.fetch(:sql))
|
|
164
|
+
@queries += 1
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Keeps track of the statement a query sent, by its SQL's hash (no
|
|
168
|
+
# copy of it, and nothing allocated: what a benchmark allocates is
|
|
169
|
+
# measured), and its SQL, for the first STATEMENTS.
|
|
170
|
+
def sent(sql)
|
|
171
|
+
key = sql.hash
|
|
172
|
+
@recent[@queries % KEPT] = key
|
|
173
|
+
@sql[key] = sql if @sql.size < STATEMENTS
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The queries counted by a reading of the totals.
|
|
177
|
+
def queries(reading) = reading.total("active_record_queries")
|
|
178
|
+
|
|
179
|
+
# +sql+ on one line, and at most LENGTH long.
|
|
180
|
+
def shorten(sql)
|
|
181
|
+
line = sql.gsub(/\s+/, " ").strip
|
|
182
|
+
(line.length > LENGTH) ? "#{line[0, LENGTH - 3]}..." : line
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
186
|
+
|
|
187
|
+
# Active Support's notifications, loaded when it first subscribes
|
|
188
|
+
# (in a worker: never in the runner).
|
|
189
|
+
# mutant:disable -- the tests have loaded Active Support, so leaving
|
|
190
|
+
# out the require can't fail them in their process
|
|
191
|
+
# (test_subscribes_in_the_worker checks it, in a process of its own)
|
|
192
|
+
def notifications
|
|
193
|
+
require "active_support/notifications"
|
|
194
|
+
::ActiveSupport::Notifications
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
module Duel
|
|
2
|
+
# What Active Record did for each benchmark, from its notifications
|
|
3
|
+
# (active_record_queries, active_record_cached_queries,
|
|
4
|
+
# active_record_transactions, active_record_instantiations and, when
|
|
5
|
+
# asked for, active_record_query_time).
|
|
6
|
+
module ActiveRecord
|
|
7
|
+
# A notification's payload, as Active Record gives it.
|
|
8
|
+
type payload = Hash[Symbol, untyped]
|
|
9
|
+
|
|
10
|
+
VERSION: String
|
|
11
|
+
|
|
12
|
+
# Counts.
|
|
13
|
+
MEASURES: Hash[String, Metric::settings]
|
|
14
|
+
|
|
15
|
+
# Elapsed time, measured when asked for.
|
|
16
|
+
QUERY_TIME: Hash[String, Metric::settings]
|
|
17
|
+
|
|
18
|
+
# Configures Duel for the plugin (c.use :activerecord): its metrics,
|
|
19
|
+
# and +query_time+ too if true. A query +ignore+ is true for isn't
|
|
20
|
+
# counted, nor timed. Returns the Counter.
|
|
21
|
+
def self.configure: (Config config, ?query_time: bool, ?ignore: (^(payload payload) -> boolish)?) -> Counter
|
|
22
|
+
|
|
23
|
+
# Running totals, from the notifications it subscribes to.
|
|
24
|
+
class Counter
|
|
25
|
+
SQL: String
|
|
26
|
+
TRANSACTION: String
|
|
27
|
+
INSTANTIATION: String
|
|
28
|
+
|
|
29
|
+
# The events each measure needs.
|
|
30
|
+
EVENTS: Hash[String, String]
|
|
31
|
+
|
|
32
|
+
# Where a query's start is kept until it finishes.
|
|
33
|
+
STARTED: Symbol
|
|
34
|
+
|
|
35
|
+
# The queries counted last that it keeps track of.
|
|
36
|
+
KEPT: Integer
|
|
37
|
+
|
|
38
|
+
# The most statements whose SQL it keeps between two #details.
|
|
39
|
+
STATEMENTS: Integer
|
|
40
|
+
|
|
41
|
+
# The most statements shown, and the most shown of each one's SQL.
|
|
42
|
+
SHOWN: Integer
|
|
43
|
+
LENGTH: Integer
|
|
44
|
+
|
|
45
|
+
@query_time: bool
|
|
46
|
+
@ignore: (^(payload payload) -> boolish)?
|
|
47
|
+
@timing: bool
|
|
48
|
+
@queries: Integer
|
|
49
|
+
@cached: Integer
|
|
50
|
+
@transactions: Integer
|
|
51
|
+
@instantiations: Integer
|
|
52
|
+
@time: Integer
|
|
53
|
+
@subscriptions: Array[untyped]
|
|
54
|
+
@recent: Array[Integer]
|
|
55
|
+
@sql: Hash[Integer, String]
|
|
56
|
+
|
|
57
|
+
def initialize: (query_time: bool, ignore: (^(payload payload) -> boolish)?) -> void
|
|
58
|
+
|
|
59
|
+
# Subscribes to the events +measures+ need.
|
|
60
|
+
def subscribe: (Array[String] measures) -> void
|
|
61
|
+
|
|
62
|
+
# The totals so far, by name.
|
|
63
|
+
def totals: () -> Hash[String, Integer]
|
|
64
|
+
|
|
65
|
+
# What explains the queries a sample of +n+ iterations sent, in its
|
|
66
|
+
# stretches: the statements sent most, and how many times an
|
|
67
|
+
# iteration. It forgets their SQL.
|
|
68
|
+
def details: (Snapshot::stretches[Integer, nil] stretches, Integer n) -> Hash[String, String]?
|
|
69
|
+
|
|
70
|
+
# Stops counting.
|
|
71
|
+
def unsubscribe: () -> void
|
|
72
|
+
|
|
73
|
+
# (Active Support's, for every event subscribed to.)
|
|
74
|
+
def start: (String name, String id, payload payload) -> void
|
|
75
|
+
|
|
76
|
+
def finish: (String name, String id, payload payload) -> void
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def query: (payload payload) -> void
|
|
81
|
+
|
|
82
|
+
# Keeps track of the statement a query sent, and its SQL, for the
|
|
83
|
+
# first STATEMENTS.
|
|
84
|
+
def sent: (String sql) -> void
|
|
85
|
+
|
|
86
|
+
# The queries counted by a reading of the totals.
|
|
87
|
+
def queries: (Snapshot::Reading[Integer, nil] reading) -> Integer
|
|
88
|
+
|
|
89
|
+
# +sql+ on one line, and at most LENGTH long.
|
|
90
|
+
def shorten: (String sql) -> String
|
|
91
|
+
|
|
92
|
+
def monotonic: () -> Integer
|
|
93
|
+
|
|
94
|
+
# Active Support's notifications, loaded when it first subscribes
|
|
95
|
+
# (untyped: Active Support has no signatures of its own).
|
|
96
|
+
def notifications: () -> untyped
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: duel-activerecord
|
|
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: activerecord
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.2'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.2'
|
|
40
|
+
description: 'A Duel plugin: every benchmark is judged on the SQL queries, cached
|
|
41
|
+
queries, transactions and record instantiations Active Record did for it too, whatever
|
|
42
|
+
the database.'
|
|
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/activerecord.rb
|
|
53
|
+
- lib/duel/activerecord/version.rb
|
|
54
|
+
- sig/duel/activerecord.rbs
|
|
55
|
+
homepage: https://github.com/sferik/duel-ruby/tree/main/plugins/activerecord
|
|
56
|
+
licenses:
|
|
57
|
+
- MIT
|
|
58
|
+
metadata:
|
|
59
|
+
source_code_uri: https://github.com/sferik/duel-ruby/tree/main/plugins/activerecord
|
|
60
|
+
changelog_uri: https://github.com/sferik/duel-ruby/blob/main/plugins/activerecord/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: 'What Active Record did for each Duel benchmark: queries, transactions and
|
|
80
|
+
records'
|
|
81
|
+
test_files: []
|