A plugin packages what Duel needs for a server or a library, such as a server for each worker, hooks, and metrics, so that any project can use it. This document describes how to write a plugin and how to test it. For the plugins that are available, and how to use one, see Plugins.
By convention, a plugin is a gem named duel- and what it's for, such
as duel-redis. A project uses it by name, with c.use :redis in
.duel.rb, which requires duel/redis. So the plugin's file is
lib/duel/redis.rb, and it registers the plugin under the same name,
:redis.
The plugin's module registers itself with Duel.plugin, and defines
configure, which c.use calls with the configuration and the options
that it's given. configure calls c.prepare, c.in_worker,
c.metric, and the hooks (c.before_sample, say), the same way that
any .duel.rb does, and returns what the project's tests or benchmarks
might want, such as the plugin's own connection to its server:
# lib/duel/redis.rb
require "duel"
module Duel
module Redis
Duel.plugin(:redis, self)
# Configures Duel for the plugin: c.use :redis, server: "redis-server"
def self.configure(config, server: "redis-server")
config.prepare(:worker) do |w|
socket = w.path("redis.sock")
w.spawn(server, "--port", "0", "--unixsocket", socket)
w.wait_until { File.socket?(socket) }
{"REDIS_URL" => "unix://#{socket}"}
end
connection = config.in_worker { |w| Connection.new.tap { |redis| w.at_stop { redis.close } } }
config.metric("redis_commands") { connection.value.commands }.costly
connection
end
end
endDuel calls configure once for each configuration, the first time
that the plugin is used. A plugin that needs another, such as a plugin
for an app that needs a database's, calls config.use itself, with the
options it needs. If the project used that plugin first, with options
of its own, the project's use stands. A later use can repeat the
options of the first, but another option raises Duel::Error, so pass
only the options that you need. A plugin can also add its own kind of
benchmark to the DSL. For more information, see
Add to the DSL.
Duel puts the require paths of every gem named duel or duel-* that
the runner loaded on each worker's load path, ahead of the version's own
bundle. A worker that runs a version's own bundle (with
BUNDLE_GEMFILE set to the version's Gemfile, say) can still require
your plugin, even if that bundle doesn't have it, and both versions are
measured with the runner's copy. Duel doesn't do the same for your
plugin's dependencies, such as the library that it measures: those come
from the version's bundle.
When you write a plugin, follow these guidelines:
- Name its measures after the plugin, such as
redis_*, so that two plugins' measures never clash. - Give benchmarks helpers as module functions, such as
Duel::Redis.url. - Inside
module Duel, write::Redisfor the library's own constants, becauseRedisis the plugin's there. - Make what a worker needs for itself with
c.in_worker, and return what it returns fromconfigure. Don't keep it in the module. - Set up what a whole version needs once, such as its bundle, with
c.prepare(:version), not in each worker'sc.prepare(:worker). Run its commands on the version's Ruby:v.envputs it first onPATH.
Important
A helper that's meant for setup, such as one that runs a query, must
call Duel.unmeasured! first, for example
Duel.unmeasured!("Duel::Redis.flush"). It raises an error during a
timed stretch (when Duel.measuring? is true), where what the helper
does would be counted. That includes the block of
Duel::Testing.measure, but not the block's untimed blocks.
Duel raises Duel::Error when something is used wrong, such as a
setting that doesn't exist, or a helper that's called where it would be
counted. A prepare block that fails (w.failed, w.run, or w.wait_until),
or a worker that dies or hangs, raises Duel::Failed, a subclass of
Duel::Error. A plugin's prepare block can raise it too.
To give benchmarks a kind of benchmark of the plugin's own, such as a
request to an app, define a module named DSL inside the plugin's
module. Its public methods are DSL methods: once .duel.rb uses the
plugin, files of benchmarks can call them. Duel loads the files of
benchmarks after .duel.rb, so the plugin's registration with
Duel.plugin is all that a plugin needs. Duel calls a DSL method on the
object that files of benchmarks run on, so it can call duel:
# lib/duel/my_app.rb
module Duel
module MyApp
Duel.plugin(:my_app, self)
def self.configure(config)
# ...
end
module DSL
def duel_request(name, path, **options, &prepare)
duel(name, around_iteration: ::MyApp::ROLLBACK, **options) do
env = ::MyApp.env_for(path)
instance_exec(env, &prepare) if prepare # in the benchmark's context
::MyApp.call(env)
end
end
end
end
end
# .duel.rb
Duel.configure { |c| c.use :my_app }
# duel/requests_duel.rb
duel_request "posts: index", "/posts"Inside the plugin's module, DSL is the plugin's own module, so write
Duel::DSL for Duel's.
A benchmark that the method defines, with duel or duel_process,
belongs to the file of benchmarks that called the method, not to the
plugin's file. It gets that file's hooks, the same as a benchmark that
the file defines with duel. That holds even if the block that the
method gives duel is the plugin's, as in the previous example, or if
the method calls another DSL method. A hook that the method adds, such
as before_sample { ... }, is the calling file's too.
Pass the benchmark's own hooks on to duel, as options, such as
setup:, so that your method takes them the same way that duel does.
If a method's name is taken, c.use raises Duel::Error and names
what took it. A name is taken if the DSL already has a method of that
name, Duel's own or another plugin's, or if every object has one, such
as Kernel's test, which the DSL method would hide. So name the method
after the plugin, such as duel_request for a plugin for apps. A DSL
method is also a name that a project's helpers can't have. For more
information, see
Share code between benchmarks.
Reopening Duel::DSL instead isn't supported: a method that you define
that way can silently replace Duel's or another plugin's.
A plugin can give hooks for every benchmark that runs in a worker,
with the same names that .duel.rb uses. For what each hook does, and
the order of hooks at several levels, see
Run code around a benchmark.
A plugin's hooks are the configuration's, so they run before a file's
and a benchmark's own, in the order that they were given, and they run
where they're written, with the plugin's module as self:
c.before_sample { |benchmark| ... }runs before every sample, warm-up samples included, and isn't measured. Use it to put back what earlier samples changed, such as reloading a database's fixtures, so that every sample starts alike. The block receives the benchmark, with itsname,only, andpeak_memory, if it takes it.c.after_iterations { ... }runs after each sample's iterations, and is the one hook that Duel measures, with them. Use it to do the work that the iterations started and left to be done later, such as jobs that they enqueued, so that the work counts as the benchmark's. For a benchmark with anaround_iterationhook, Duel runs it after each iteration, inside the iteration, before the hook undoes anything.c.around_iteration { |iteration| ... }runs around each iteration of every benchmark, and callsiteration.callonce. Only the iteration is measured. A benchmark whose block takes the number of iterations can't run inside one, so a plugin that gives one for every benchmark rules those out. To apply one to some benchmarks, such as rolling back each request, make it a lambda that a project can give to a file or to one benchmark (around_iteration: MyApp::ROLLBACK), or pass it toduelfrom the plugin's DSL method.
def self.configure(config)
config.before_sample { MyJobs.clear }
config.after_iterations { MyJobs.run_pending }
endA process benchmark (duel_process) runs no hooks.
A metric often needs something of its own in each worker, such as a subscription to notifications. Running totals and a metric class use the same names for it:
- For running totals, call
openandclosewith blocks on whatc.metricreturns. Duel runs theopenblock when the worker opens the metric, before the metric's first reading, and thecloseblock when the worker ends.open,close,details, andcostlyeach return the metric, so you can chain them. - For a subclass of
Duel::Metric, defineopenandclosemethods. Duel makes and opens each metric once in each worker, after thec.in_workerblocks, and callsclosewhen the worker ends. Its per-sample methods arestart_sample,stop_sample,pause_sample, andresume_sample.
def self.configure(config)
counter = Counter.new
config.metric(MEASURES) { counter.totals }
.open { counter.subscribe }
.close { counter.unsubscribe }
.details { |stretches, sample| counter.details(stretches, sample.n) }
counter
endDuel closes a worker's metrics last first, and then runs what its
c.in_worker blocks gave at_stop. A metric whose measures are all
left out (c.measure(name, skip: true)) isn't made, so it's neither
opened nor closed. To set up nothing for one measure that's left out,
open can call c.skip?(name).
A metric can say what it saw in a sample, so that a regression comes with its cause, such as the statements that a benchmark sent most. Duel shows what the metric said under each regression, for each version.
For running totals, call details with a block on what c.metric
returns. The block receives the following:
stretches: an Array of the sample's measured stretches. Each stretch is a pair of readings,[started, stopped], taken when Duel started measuring and when it stopped. A sample has one stretch, and one more for eachuntimedblock in it. A sample of a benchmark with anaround_iterationhook has one stretch for each iteration, and two more, before the first iteration and after the last, which measure next to nothing. For a costly metric (see Account for the cost of reading), it has one stretch, and one more for eachuntimedblock, as any other sample does.sample: the sample.sample.nis its number of iterations, andsample.benchmarkis the benchmark, with itsname,only, andpeak_memory.
A reading has the following:
totals: the totals that the metric's block read, by name. A total that the block couldn't read isnil.total(name): one total. It raisesDuel::Errorfor a total that couldn't be read, or one that the metric doesn't measure.context: what else the block read with the totals, if it returned[totals, context], ornil.
What a sample measured is the sum, over its stretches, of what each
total grew by, less what reading the totals costs. For a subclass of
Duel::Metric, details is a method, which explains the sample that
the metric last stopped, from what its start_sample and stop_sample
kept.
The block, or method, returns one of the following:
- A String, which explains each of the metric's measures.
- A Hash of Strings by measure name, which explains only those measures.
nil, which explains nothing.
Duel calls it in the worker, right after each sample stops, outside what
it measures. That's after every sample, including warm-up samples and
those that a worker takes to calibrate an around_iteration hook, not
only after a regression. Duel doesn't call it after a stretch that
untimed paused.
So keep it cheap: return nil unless something looks worse, for
example return if within?(more, sample.n). For a count, Duel shows
what the metric said of the sample that the count came from. For a
timing, it shows what the metric said of the latest sample.
require "duel/testing" provides what a plugin's tests need:
config = Duel::Config.new
config.use :redis
Duel::Testing.prepare(config) do |env| # prepared as a worker is, env set
Duel::Testing.measure(config) { Redis.new.set("k", "v") } # => {"redis_commands" => 1, ...}, with #details
end
base = Duel::Testing.project(dir, "lib/my_cache.rb" => "...", "duel/cache_duel.rb" => "...", ".duel.rb" => "...")
Duel::Testing.compare(base, candidate).passed? # whole comparisons, in workersDuel::Testing has the following methods:
prepare(config): runs the configuration'sc.prepare(:version)blocks, named for the version ("candidate"), and then itsc.prepare(:worker)blocks, as Duel prepares a worker. It sets the environment that they return while its block runs, and stops what they started after.name:names the worker ("candidate-1"by default), androot:is the version's tree (the configuration's root by default).cache:is wherew.cachedbuilds go. By default, that's one directory for the whole test process. To look inside it, give your own.measure(config): measures a sample of its block in a worker of its own, which runs itsc.in_workerblocks and makes its metrics the way that a worker does, and which ends after the sample. It runs the configuration's hooks as a worker runs a benchmark's. Its block receives the benchmark's context, withuntimed, as a benchmark's does:measure(config) { |s| s.untimed { ... } }.n:is the sample's number of iterations, andonly:makes the sample a benchmark's that has, for example,only: :timing. It also takes the benchmark's own hooks, asdueldoes, such asbefore_sample:. It returns a Hash of what was measured, by name, whosedetailsare what the metrics said of the sample, by measure. With anaround_iterationhook, the configuration's or its own, the block is one iteration:measureruns itntimes, each inside the hook, the way that a worker runs a benchmark with one, including what it learns first to subtract the hook's work from a costly metric.in_worker(config) { ... }: runs the block as a worker, and ends it the way that a worker ends, so what itsc.in_workerblocks gaveat_stopruns after the block. Everymeasurein the block is that worker's, and so is thevalueof whatc.in_workerreturned. Inside the block,Duel::Testing.metrics(config)returns the worker's metric instances. Check what a worker holds, such as a subscription or an open connection, inside the block: after ameasureof its own, a worker has ended.project(dir, files): writes a small project of the given files.compare(base, candidate): compares two projects in workers. See the following paragraphs.sample(n: 1): a stand-in sample ofniterations, which measures nothing, for a metric'sstart_sampleor adetailsblock.name:,only:, andpeak_memory:describe its benchmark.reading(totals, context): a stand-in reading of running totals, by name, with the context that was read with them. A sample's stretches are pairs of readings.details(metric, stretches, sample): what thedetailsblock of running totals says of the stretches of a sample.metricis whatc.metricreturned, andsampleissampleby default.
measure runs the configuration's before_sample hooks before the
sample, and its after_iterations hooks after its block, measured with
it, the same way that a worker does. With an around_iteration hook,
it runs the after_iterations hooks after each iteration. The
benchmark's own hooks run in its context, the same as a benchmark's
own hooks do, while the block keeps its own self.
A plugin whose metrics read a server says that they're costly, so that
a benchmark with an around_iteration hook doesn't make a round trip to
the server for each iteration. To check that what such a hook sends the
server isn't counted, measure with one:
rolled_back = lambda do |iteration|
pg.exec("BEGIN")
iteration.call
pg.exec("ROLLBACK")
end
Duel::Testing.measure(config, n: 20, around_iteration: rolled_back) { pg.exec("INSERT INTO items VALUES (1)") }
# => {"postgres_queries" => 20, "postgres_rows" => 20, ...}To test or benchmark what a metric says of a sample, without a worker, use the stand-ins:
metric = config.metrics.first # what c.metric returned
stretches = [[Duel::Testing.reading({"rows" => 1}, views_before), Duel::Testing.reading({"rows" => 4}, views_after)]]
Duel::Testing.details(metric, stretches, Duel::Testing.sample(n: 3)) # => {"rows" => "an iteration: 1× ..."}A plugin's tests find what the plugin added in config.metrics, the
metrics in order. Each metric has its measures and their settings,
with every setting filled in. For example,
config.metrics.first.settings.fetch("redis_cpu") is
{kind: :ratio, unit: "µs", floor: 300, ...}.
compare takes duel's options as keywords: processes: (two unless
given), timeout:, limit:, warm:, and filter: (a Regexp). It
returns the comparison: passed?, its output (what duel would have
printed), and its results, one for each benchmark.
comparison["name"] finds one. A result has the following:
- Its
name,passed?, and the measures that itregressedon. When no one timing was slower in every measurement, but each measurement had one that was, those are the timings that were slower in any. - Each
timedmeasure's"verdict","median"ratio, and"interval"(over its"pairs"of samples), the"limit"that it was judged by, whether it's judged"across"the board, and each version's median ("baseline"and"candidate"), by name. - Each
countedmeasure's fewest on each version ("baseline"and"candidate") over a sample's"n"iterations, with its"tolerance"and"allowance"(both with their"unit"and"plural"), by name. - What metrics said of it (
details), by measure and by version. - Its peak
memory, by version, when it was asked for. - Its iterations a sample (
n), and itsseconds. - Why it was
skipped(the baseline couldn't run it), or itserror. - Its
confirmations: the measurements that confirmed it was worse.