NAME
Mojolicious::Plugin::Buffer - Optimise, compress and validate Mojolicious responses
VERSION
Version 0.001.0
SYNOPSIS
1. The smallest set-up
One line gives every page compression, an ETag, a Last-Modified
header and 304 Not Modified answers:
use Mojolicious::Lite;
plugin 'Buffer';
get '/' => sub {
my $c = shift;
$c->render(template => 'index');
};
app->start;
2. Smaller HTML
Also remove extra white space from HTML pages. Install HTML::Packer to remove comments and shrink embedded CSS as well:
plugin 'Buffer' => {optimise_content => 1};
3. A server-side cache for slow pages
Keep finished pages in a cache, so a slow action runs once and later visitors get the stored copy. Call the helper first in the action, but after any check that decides who may see the page:
use CHI;
plugin 'Buffer' => {cache => CHI->new(driver => 'File', root_dir => '/var/cache/myapp')};
get '/report' => sub {
my $c = shift;
# Access checks come first: a cached page is sent without them
return $c->reply->not_found unless $c->session('user');
return if $c->buffer_is_cached; # sent from the cache: stop here
$c->render(text => slow_report()); # only runs on a miss
};
4. Several workers on one machine
With hypnotoad there are several worker processes. Share the cache between them, and give them a lock directory, so only one worker builds a missing page:
plugin 'Buffer' => {
cache => CHI->new(driver => 'File', root_dir => '/var/cache/myapp'),
cache_lock_dir => '/var/lib/myapp/buffer-locks',
};
For workers on several machines, see "Multiple hosts".
5. One response is different
Put a buffer value in the stash. 0 leaves the response alone; a hash
changes some options for this response only:
get '/download' => sub {
my $c = shift;
$c->stash(buffer => 0); # already compressed: do not touch it
$c->render(data => $zip_file, format => 'zip');
};
get '/raw' => sub {
my $c = shift;
$c->stash(buffer => {optimise_content => 0});
$c->render(template => 'raw');
};
6. A whole section is different
Some options can be set once for a group of routes:
my $docs = app->routes->under('/docs')->to(optimise_content => 1);
$docs->get('/intro' => sub { shift->render(template => 'docs/intro') });
7. Pages in several languages
Add Accept-Language to the cache key, so each language is stored
separately:
plugin 'Buffer' => {
cache => $cache,
vary_by => ['cookie', 'accept-language'],
};
8. Translate the "busy" message
While one request builds a page, a request that waits too long gets
503 Service Unavailable with a short text. Change the text with
messages:
plugin 'Buffer' => {
cache => $cache,
messages => {retry_later => "Bitte versuchen Sie es gleich noch einmal.\n"},
};
9. A full application
package MyApp;
use Mojo::Base 'Mojolicious';
use CHI;
sub startup {
my $self = shift;
$self->plugin('Buffer', {
optimise_content => 1,
cache => CHI->new(driver => 'File', root_dir => '/var/cache/myapp'),
cache_lock_dir => '/var/lib/myapp/buffer-locks',
cache_duration => '10 minutes',
vary_by => [], # these pages are the same for everyone
});
$self->routes->get('/')->to('pages#home');
}
1;
DESCRIPTION
Mojolicious::Plugin::Buffer makes the pages of a Mojolicious
application smaller and faster to deliver, without changes to your actions.
It is the Mojolicious version of CGI::Buffer and FCGI::Buffer.
What it does
After your action has made a page, and before the page is sent, the plugin can:
- make HTML smaller by removing extra white space and, with HTML::Packer, comments and extra characters in embedded CSS and JavaScript. It also shortens links to your own site;
- compress the page with Brotli, Zstandard or gzip, whichever the browser accepts and the server has installed;
- add headers that let browsers reuse a copy they already have
(
ETagandLast-Modified), and answer "you already have this" with a short304 Not Modifiedreply instead of the whole page; - set the correct
Content-Lengthafter all of these changes; - keep finished pages in a server-side cache, if you ask it to, so that a slow action does not have to run again for the next visitor (see "buffer_is_cached").
How it works, in plain words
-
- When the application starts, "register" checks your options and
installs a hook: a small function that Mojolicious calls for every
response, after the action has finished (
after_dispatch).
- When the application starts, "register" checks your options and
installs a hook: a small function that Mojolicious calls for every
response, after the action has finished (
-
- A request arrives and your action runs. If the action calls "buffer_is_cached" and a fresh copy of the page is in the cache, the copy is sent and the action stops early.
-
- Otherwise the action makes the page as usual.
-
- The hook looks at the finished response. It leaves alone anything it should not touch: streamed responses, static files, empty bodies, statuses that have no body (1xx, 204, 206, 304), responses that are already compressed, and media types listed in "excluded_types".
-
- For an HTML page it removes white space, if "optimise_content" is on.
-
- It compresses the page for this browser, if the media type is in "compressible_types".
-
- It stores the uncompressed page in the cache, if the action called "buffer_is_cached" and the page may be stored.
-
- It adds the
ETagandLast-Modifiedheaders. If the browser already has this version, it sends304 Not Modifiedwith no body and stops.
- It adds the
-
- Otherwise it puts the compressed (or plain) page into the
response, sets
Content-Length, and the page is sent.
- Otherwise it puts the compressed (or plain) page into the
response, sets
Error pages such as 404 go through the same steps, but only 200
responses get an ETag or are stored in the cache.
The plugin works through a hook, so it also sees responses built with
$c->res->body rather than $c->render. Unlike the CGI modules
it needs no source filter and no END block.
Words used in this document
-
cache
A store that keeps finished pages for a while, for example CHI. The plugin needs an object with
get,setandremovemethods. -
cache key
The name under which a page is kept in the cache. By default it is made from the host, path, query string and the request headers listed in "vary_by".
-
ETag
A short fingerprint of the page, sent in a header. A browser that has a copy sends the fingerprint back; if it still matches, the server answers
304. -
304 Not Modified
A reply with no body that means "the copy you have is still correct".
-
content coding
The compression method of a response, such as
gziporbr(Brotli). The browser lists what it accepts inAccept-Encoding. -
worker
One of several copies of the application that a server such as hypnotoad runs at the same time. Workers do not share memory.
-
single-flight
When many requests ask for the same missing page at once, only one of them builds it; the others wait and then get the result.
-
stash
Mojolicious's per-request hash of values,
$c->stash. The plugin reads its per-response options from$c->stash('buffer').
OPTIONS
All options can be given to plugin() and, except where noted, overridden
per request through the buffer stash value. Options given to plugin()
are validated when the plugin is registered; values in the buffer stash
are not, and an unusable one falls back to its default where that is safe.
A few options describe a section of a site rather than one page and can be
declared once for it; see "Declaring options for a group of pages".
Declaring options for a group of pages
# one route
$r->get('/about' => {optimise_content => 1} => sub {...});
# everything beneath an under route
my $docs = $r->under('/docs')->to(optimise_content => 1);
$docs->get('/intro' => sub {...});
# a controller, and through a base class its whole namespace
package MyApp::Controller::Docs;
use Mojo::Base 'Mojolicious::Controller';
has buffer_optimise_content => 1;
A route default is an ordinary stash value, so $app->defaults(...) and
a before_action that sets one work as well. The controller form is a
method named buffer_ followed by the option name, and is only consulted
when the stash says nothing: has is the declarative way to write it, and a
plain sub buffer_optimise_content { 1 } covers a value that depends on the
request. A controller method that dies is logged at debug level and
ignored, so the plugin option stays in force.
The most specific declaration wins:
-
- the
bufferstash, for a single response
- the
-
- a route default, which includes one an
underroute sets
- a route default, which includes one an
-
- the controller declaration
-
- the plugin option
A route default of 0 therefore opts one page back out of a group that has
the option on, and the buffer stash overrides both.
Only the options that describe the content of a section are declarable:
- "compress_content" - a section serving files that are already compressed
- "optimise_content" - a static section that is always minified
- "rewrite_links" - a section whose HTML is used elsewhere, so its absolute links must survive
- "trust_last_modified" - a section that renders templates and nothing else
Everything else, including "generate_etag" and "generate_304", stays a
per-response or per-application decision; those sections say why. Any option
can still be set for one response through the buffer stash.
examples/grouped-sections is a runnable site with one section declared on
an under route and one declared in a controller.
compress_content
Compress eligible responses, defaults to 1. Set to 0 to only optimise (and remember that the plugin turns off "compress" in Mojolicious::Renderer, so a response will then not be compressed at all). The encodings offered to clients are controlled by "encodings".
It can be declared for a group of pages (see "Declaring options for a group of pages"), which is the point of a section that serves files it has already compressed.
optimise_content
How hard to optimise HTML, defaults to 0 (off). Registration rejects any value other than 0, 1 or 2:
- 0 - leave the HTML alone
- 1 - remove superfluous white space, rewrite same-site links and, when HTML::Packer is installed, remove comments and minify embedded CSS
- 2 - as 1, plus aggressive JavaScript minification when HTML::Packer
is installed, which can break scripts that rely on the
<!-- ... -->hiding technique
Optimisation only applies to text/html and application/xhtml+xml
responses. The content of <pre>, <textarea>,
<script> and <style> elements is never touched by the
white-space squashing, because white space there is significant.
HTML::Packer is optional: without it the white-space squashing and link
rewriting still run, but comments and embedded CSS and JavaScript are left
alone, as they are by CGI::Buffer and FCGI::Buffer. Install
HTML::Packer for the full effect.
It can be declared for a group of pages (see "Declaring options for a group of pages"), for a static section that is always minified.
rewrite_links
When optimising, rewrite <a href> and <img src> links that
point at the current site as absolute URLs into root-relative links, and
links to the current script with a query string into just the query string.
Defaults to 1. Has no effect unless "optimise_content" is enabled. Only
double-quoted attribute values are recognised.
It can be declared for a group of pages (see "Declaring options for a group of pages"), which matters for a section whose HTML is not used in place: a preview of a newsletter or a page rendered for another site needs its absolute URLs kept while still being optimised elsewhere.
generate_etag
Add an ETag header to 200 responses to GET and HEAD requests,
defaults to 1. The tag is the MD5 of the response body before compression,
with a suffix for the content coding, so a gzip and an identity
representation of the same page have different tags. An ETag set by the
action is kept.
It is not declarable for a group of pages (see
"Declaring options for a group of pages"): whether a digest is worth
computing is a decision for one endpoint, not a policy for a section. Skip
the digest for one response with $c->stash(buffer => {generate_etag => 0}), or for the whole application with the plugin option.
generate_304
Answer "you already have this page" with 304 Not Modified, defaults to
1. A browser that has a copy of a page asks the server whether its copy is
still correct. It can ask in two ways:
If-None-Match: "my copy has thisETag". This is exact, because theETagis a fingerprint of the page itself.If-Modified-Since: "my copy is from this date". This is only a guess, because the date (see "last_modified") describes your code and templates, not the data shown on the page.
The plugin uses them like this:
-
- If the request has
If-None-Match, only theETagdecides. The date is ignored, as RFC 7232 requires.
- If the request has
-
- If the page has an
ETag("generate_etag" is on, the default),If-Modified-Sincealone is not trusted. A database row can change while the template stays the same, and a304would then keep showing the browser old data. Browsers sendIf-None-Matchonce they have seen anETag, so they are not affected.
- If the page has an
-
- If the page has no
ETag, or "trust_last_modified" is on, the date is used.
- If the page has no
It is not declarable for a group of pages (see
"Declaring options for a group of pages"). It decides whether a browser is
told that its copy is correct, and a section that could also turn it off
would contradict "trust_last_modified". Use
$c->stash(buffer => {generate_304 => 0}) where one endpoint must always
send a body.
last_modified
Add a Last-Modified header, defaults to 1. The header says when the page
last changed. The value may be:
- 0 - do not add the header
- 1 - the newest modification time of the application script (
$0) and of every file and directory under the renderer's template paths. Symbolic links are followed, each directory is read once, and the scan stops 32 directories below a template path, which also ends a symbolic link that points back up the tree. - a number - a Unix epoch (seconds since 1970)
- a date string - anything Mojo::Date can read, such as
'Sun, 06 Nov 1994 08:49:37 GMT' - a code reference - called with the controller; it returns one of the values above (a code reference that returns another code reference counts as 0)
Some rules:
- The header is only added to
200responses toGETandHEAD. ALast-Modifiedheader that the action set itself is kept. - With the default (1), editing a template changes the date. (Separately, and whatever this option says, the server-side cache throws away entries built before the newest change to the script or a template; see "buffer_is_cached".)
- The date describes the code, not your data. A change in the
database does not move it. For pages built from data, use a code reference
that returns the data's own date, or set
last_modified => 0and rely on theETag(see "generate_304" for why that is safe). - The default looks at every template file on every request, unless "template_mtime_cache" is set. For a large template tree, or an application without templates, an epoch or a code reference is cheaper. A server-side cache lookup also looks at the template files, whatever this option says; see "Last-Modified performance".
morboandhypnotoadset$0to your application script. Under PSGI or another wrapper,$0may be the wrapper instead; pass an epoch or a code reference there.
trust_last_modified
Also answer If-Modified-Since when the page has an ETag, defaults to 0.
By default the plugin does not trust the date when it has a fingerprint
(see "generate_304"). Turn this on only for pages that are built from the
application and its templates and nothing else: no database, no session,
no other input that can change while the templates stay the same. Then the
date is accurate, and clients that only send If-Modified-Since can get a
304 too. If-None-Match still comes first, so a browser that sends the
ETag is always answered correctly.
plugin 'Buffer' => {
trust_last_modified => 1, # this application only renders templates
};
Use it for one response with
$c->stash(buffer => {trust_last_modified => 1}), or declare it for a
group of pages (see "Declaring options for a group of pages"), for a
section that renders templates and nothing else. It has no effect when
"generate_304" is off. It cannot make a page safe; it only tells the
plugin that the templates really are the only input.
Last-Modified performance
The scan costs one stat() per file and directory under the renderer's
paths. On a tree of 10,000 templates in 100 directories, a request using the
default scan took about 40 milliseconds of CPU on the machine used for these
measurements, against about 1 millisecond for the same request with an
explicit epoch; that is roughly 4 microseconds per tree entry. With
"template_mtime_cache" in front of it, the same request also took about 1
millisecond, the cache lookup adding only a few tens of microseconds, so the
middle ground gives up roughly 2% of the scan's cost to keep template
awareness. The numbers are indicative, not a promise, and a faster or slower
filesystem will move them. Run benchmarks/last_modified.pl with your own
tree size to measure all three variants.
Use an explicit epoch or a coderef when template changes do not need to move
Last-Modified. This removes the scan from ordinary responses, but not
from server-side cache lookups: "buffer_is_cached" still scans, so that an
entry built from an old template is thrown away. Use
"template_mtime_cache" when you need template awareness but not a scan on
every request: it costs one cache lookup per request, covers both uses, and
bounds how long a template edit can be missed by its duration.
template_mtime_cache
An optional cache object understanding get() and set(), such as a
CHI instance, used to remember the result of the template mtime scan.
Defaults to undef, which scans the renderer's template paths on every
request. A per-worker cache avoids the scan without sharing anything:
use CHI;
plugin 'Buffer' => {
template_mtime_cache => CHI->new(driver => 'Memory', datastore => {}),
};
A shared cache, such as CHI's File or Memcached driver, also avoids the scan for every worker. Entries are keyed by the application class, the application script and its mtime, and the renderer's template paths, and are stored with "template_mtime_cache_duration". A cache failure is logged at debug level and the scan runs as usual, so a broken cache cannot stop a response.
For a large tree this is the middle ground: it avoids the per-request scan described under "Last-Modified performance" while still noticing template changes, at the cost of up to one duration of staleness.
The cached value is only refreshed when the entry expires. A template edit
therefore is not noticed until then, and neither Last-Modified nor the
cache invalidation described under "last_modified" sees it immediately.
Keep "template_mtime_cache_duration" short if templates change often.
This option and "template_mtime_cache_duration" are read when the plugin is
registered; they cannot be overridden through the buffer stash.
template_mtime_cache_duration
How long a template mtime is kept, defaults to 10 seconds. It is passed to
the cache's set(), so for CHI it may be a number of seconds or a
human-readable duration.
min_compress_size
Do not compress bodies smaller than this many bytes, defaults to 32. Must be a non-negative integer. Compression is also skipped if it would not make the body smaller.
encodings
An arrayref of the content codings to offer, in server preference order.
Defaults to [qw(br zstd gzip)]. br is only offered if
IO::Compress::Brotli is installed and zstd only if Compress::Zstd
is installed; gzip is always available. x-gzip is accepted as a synonym
for gzip. Clients pick with the usual quality values, and identity is
used when nothing acceptable is offered.
compressible_types
An arrayref of media types to compress, defaults to text/*,
application/json, application/javascript, application/xml,
application/xhtml+xml, image/svg+xml and a few close relatives.
Entries may be strings (matched exactly, case-insensitively) or regular
expressions.
excluded_types
An arrayref of media types that are never buffered, defaults to
text/event-stream. Entries may be strings or regular expressions.
cache
An optional cache object understanding get(), set() and remove(),
such as a CHI instance. Defaults to undef, which disables the
server-side cache. See "buffer_is_cached" for the rules about what is
cached and how keys are made.
cache_duration
How long entries are kept in the cache, defaults to '10 minutes'. It is
passed to the cache's set(), so for CHI it may be a number of seconds
or a human-readable duration.
cache_key
A string, or a coderef called with the controller, that overrides the generated cache key. Useful when several URLs should share one entry, or when the response depends on something the default key does not cover. An empty string, or a coderef returning nothing, falls back to the generated key.
cache_single_flight
Only let one request at a time build a missing entry, defaults to 1. When a request misses the cache while another request in the same worker is already building the same key, the second request waits for the first one to finish and is then served the finished entry, without running the action. This matters for asynchronous actions: a synchronous action runs to completion without the event loop doing anything else, so it cannot overlap with another request in the same worker.
If the first request fails, produces a response that cannot be cached (for
example one that sets a cookie), or takes longer than "cache_lock_timeout",
the waiting request is answered with 503 Service Unavailable and
Retry-After: 1 rather than running the action itself. Set to 0 to let
every concurrent miss run the action independently.
By default the in-flight table is per worker process, so with N hypnotoad workers a cold entry can still be built up to N times. Set "cache_lock" or "cache_lock_dir" to elect a single builder across processes instead.
cache_lock
An optional object that elects one process to build a missing entry, for workers that cannot share a directory. It must implement:
-
acquire
my ($guard, $error) = $lock->acquire($key);Return a truthy guard if the lock was acquired,
(undef, undef)if another process holds it, and(undef, $error)if the backend failed. On an error the plugin logs the message and continues with a per-worker flight, so a cache miss is still built. An exception thrown byacquireis treated as an error.The call is synchronous and runs before the action is allowed to continue. A backend must therefore be quick and must not depend on the event loop; an asynchronous Redis client is not suitable.
-
release
$lock->release($guard);Release a guard returned by
acquire. The guard is opaque to the plugin. Releasing a lock that has already expired is allowed. If release fails, the lock should expire on its own; the backend can return(0, $error)so the failure is logged. A backend that returns nothing is treated as having released the lock.
The cache itself must still be shared between the workers. The built-in
backends are Mojolicious::Plugin::Buffer::Lock::File, selected with
"cache_lock_dir", and Mojolicious::Plugin::Buffer::Lock::Redis.
cache_lock cannot be combined with "cache_lock_dir", and is read when
the plugin is registered rather than per response.
cache_lock_timeout
How long a request may wait for another request, in this worker or another
one, to build the same entry. Defaults to 10 seconds. It also bounds how
long a key is considered to be in flight when the request building it never
finishes. Fractional seconds are allowed, so 0.5 is valid; zero and
negative values are rejected.
cache_lock_dir
A directory in which to keep lock files used to coalesce cache misses across
worker processes, defaults to undef, which limits single-flight to one worker.
This selects the built-in Mojolicious::Plugin::Buffer::Lock::File backend
and is a shorthand for cache_lock => Mojolicious::Plugin::Buffer::Lock::File->new(dir => $dir). The
plugin creates the directory if necessary. It cannot be combined with
"cache_lock".
The workers must share this directory and the cache itself. A per-worker
cache such as CHI's Memory driver cannot be used, because a request in one
worker cannot see the entry built in another; disable any per-worker L1
cache in front of a shared cache for the same reason. Locks are non-blocking
flocks on one small, empty file per cache key, so they are released
automatically if a worker dies. The files are not removed, although they are
tiny and can be deleted while the application is stopped. Use a private
directory.
This works for hypnotoad workers on the same host, or on a shared
filesystem that implements flock reliably. For workers on different
hosts, use "cache_lock" with
Mojolicious::Plugin::Buffer::Lock::Redis instead. It is read when the
plugin is registered, not per response.
vary_by
An arrayref of request header names that become part of the generated cache
key. Defaults to ['cookie'], which keeps one client's session from being
served to another. Set it to [] for URL-only caching of pages that are
identical for every visitor, or add e.g. 'accept-language' when the
response varies by language. Header names are case-insensitive. Headers
listed here may also appear in the response's Vary header; a response
that varies on anything else is not stored.
messages
A hash reference of message overrides, keyed by message key, used for every
error, log line and client-facing text the plugin produces, including the
body of the 503 sent to a request that gave up waiting. See
Mojolicious::Plugin::Buffer::I18N for the keys, the placeholder syntax
and plural and gender variants:
plugin 'Buffer' => {
messages => {
retry_later => "Die Seite wird gerade erstellt, bitte versuchen Sie es gleich noch einmal.\n",
},
};
It is passed to the lock backend built from "cache_lock_dir". It is read when the plugin is registered, not per response.
METHODS
Mojolicious::Plugin::Buffer inherits all methods from
Mojolicious::Plugin and implements the following new ones. It also adds
the "buffer_is_cached" helper to the application.
register
$plugin->register(Mojolicious->new, {optimise_content => 1});
Purpose
Validate the options, install the after_dispatch hook that reworks every
response, and add the "buffer_is_cached" helper. It is normally called for
you by $app->plugin('Buffer', ...).
Arguments
-
$app(positional, required)The Mojolicious application.
-
\%options(positional, optional)The options described under "OPTIONS", as a hash reference or a flat list.
Returns
The plugin object, so calls can be chained.
Side effects
- Turns off "compress" in Mojolicious::Renderer, because this plugin does the compression; see "Interaction with the renderer".
- Adds an
after_dispatchhook and abuffer_is_cachedhelper. - Creates "cache_lock_dir" if it is given and missing.
- Loads the optional compression modules named by "encodings", and logs at debug level when "optimise_content" is on without HTML::Packer.
Example
use Mojolicious::Lite;
use CHI;
plugin 'Buffer' => {
optimise_content => 1,
cache => CHI->new(driver => 'File', root_dir => '/var/cache/myapp'),
cache_lock_dir => '/var/lib/myapp/buffer-locks',
vary_by => [], # pages are the same for everyone
};
get '/' => sub {
my $c = shift;
return if $c->buffer_is_cached;
$c->render(text => 'Hello');
};
app->start;
API SPECIFICATION
Input
# Params::Get::get_params(undef, @_) after $app, then one
# Params::Validate::Strict::validate_strict call per option
{
app => { type => 'object', can => [qw(hook helper renderer log)] },
compress_content => { type => 'scalar', optional => 1 },
optimise_content => { type => 'integer', min => 0, max => 2, optional => 1 },
rewrite_links => { type => 'scalar', optional => 1 },
generate_etag => { type => 'scalar', optional => 1 },
generate_304 => { type => 'scalar', optional => 1 },
trust_last_modified => { type => 'scalar', optional => 1 },
last_modified => [ { type => 'scalar' }, { type => 'coderef' } ],
template_mtime_cache => { type => 'object', can => [qw(get set)], optional => 1 },
template_mtime_cache_duration => { type => 'string', min => 1, optional => 1 },
min_compress_size => { type => 'integer', min => 0 },
encodings => { type => 'arrayref' }, # of br|zstd|gzip|x-gzip
compressible_types => { type => 'arrayref' }, # of strings or qr//
excluded_types => { type => 'arrayref' }, # of strings or qr//
cache => { type => 'object', can => [qw(get set remove)], optional => 1 },
cache_duration => { type => 'string', min => 1, optional => 1 },
cache_key => [ { type => 'string' }, { type => 'coderef' } ],
cache_single_flight => { type => 'scalar', optional => 1 },
cache_lock_timeout => { type => 'number', optional => 1 }, # > 0
cache_lock => { type => 'object', can => [qw(acquire release)], optional => 1 },
cache_lock_dir => { type => 'string', min => 1, optional => 1 },
vary_by => { type => 'arrayref' }, # of non-empty strings
messages => { type => 'hashref', optional => 1 },
}
Output
# Return::Set schema
{ type => 'object', isa => 'Mojolicious::Plugin::Buffer' }
MESSAGES
Every message croaks, from the caller's point of view. The key is the Mojolicious::Plugin::Buffer::I18N key that can be overridden.
When several options are wrong, only one is reported: the first one when
the option names are sorted in ASCII order (for example cache comes
before encodings, and generate_304 before generate_etag). An
unknown option name is reported in the same way, at its place in that
order. Fix it and register again to see the next problem. The order is
fixed, so the same mistake always gives the same message. The checks
that look at more than one option (cache_lock together with
cache_lock_dir, and unknown encodings names) come after all of
these.
Key Message (after "Mojolicious::Plugin::Buffer: ") Resolution
------------------------------ -------------------------------------------------- ---------------------------------
options_not_hash options must be a hash reference Pass {...} or a key/value list
unknown_option unknown option 'NAME' Check the spelling in OPTIONS
option_flag NAME must be a true or false value Pass 0 or 1, not a reference
option_optimise_content optimise_content must be 0, 1 or 2 Use one of the documented levels
option_last_modified last_modified must be 0, 1, an epoch, a date Pass a scalar or a coderef
string or a code reference
option_min_compress_size min_compress_size must be a non-negative integer Pass e.g. 32
option_encodings encodings must be an array reference Pass [qw(br gzip)]
unknown_encoding unknown encoding 'NAME' Use br, zstd, gzip or x-gzip
option_compressible_types compressible_types must be an array reference Pass [qr{^text/}, ...]
option_excluded_types excluded_types must be an array reference Pass [qr{^text/event-stream$}]
option_vary_by vary_by must be an array reference of header names Pass e.g. ['cookie']
option_cache_key cache_key must be a string or a code reference Pass a string or sub
option_cache cache must be an object understanding get(), set() Pass a CHI object
and remove()
option_cache_duration cache_duration must be a duration Pass e.g. '10 minutes'
option_template_mtime_cache template_mtime_cache must be an object Pass a CHI object
understanding get() and set()
option_template_mtime_cache_duration
template_mtime_cache_duration must be a duration Pass e.g. 10
option_cache_lock_timeout cache_lock_timeout must be a positive number of Pass e.g. 10 or 0.5
seconds
option_cache_lock cache_lock must be an object with acquire() and See cache_lock
release() methods
option_cache_lock_dir cache_lock_dir must be a directory path Pass a non-empty string
lock_options_combined cache_lock and cache_lock_dir cannot be combined Use one of the two
option_messages messages must be a hash reference Pass {key => 'text'}
lock_dir_create_failed cannot create cache_lock_dir 'DIR': OS ERROR See Lock::File
lock_dir_not_writable cache_lock_dir 'DIR' is not a writable directory See Lock::File
A failure to validate $app itself is reported by Params::Validate::Strict
and is not translated.
PSEUDOCODE
accept the options as a hash reference or a key/value list
check the application object
adopt the messages option first, so later errors can be reworded
start from the defaults
for each option given, in sorted order:
croak if it is unknown
croak with its own message if its value does not fit its schema
croak if both cache_lock and cache_lock_dir were given
build the lock backend: the given object, or a file backend for the dir
canonicalise encodings, croak on unknown ones, drop unavailable ones
log once if optimisation is on without HTML::Packer
turn off the renderer's own compression
hook after_dispatch to rework every response
add the buffer_is_cached helper
return the plugin
buffer_is_cached
get '/expensive' => sub {
my $c = shift;
return if $c->buffer_is_cached;
# ... expensive work ...
$c->render(template => 'expensive');
};
Purpose
A helper, added to the application by "register", that lets an action skip its work when a finished copy of the page is already cached, or when another request is already building it.
Look for a cached response and, if there is one, replay its status, headers
and body, add X-Cache: HIT, finish the response and return true. The
action only has to return; no rendering is needed on a hit. On a miss it
adds X-Cache: MISS, returns false, and the response is stored when it is
complete.
Only 200 responses to GET and HEAD requests are cached. Requests
with an Authorization header or a Cache-Control header containing
no-store, no-cache, private or max-age=0 bypass the cache, as do
requests made while the NO_CACHE or NO_STORE environment variable is
set. Responses that set a cookie, that are marked no-store or
private, or that vary on something not listed in "vary_by", are not
stored. Entries created before the application script or any template file
or directory was last modified are treated as misses and removed.
The stored body is the optimised but uncompressed entity, so every hit is
compressed for the requesting client, gets its own ETag and can still be
answered with 304 Not Modified. The cache key is a digest of the
application class, host, path, query string and the values of the headers
listed in "vary_by"; use "cache_key" to replace it. Query argument order
is significant.
Concurrent misses for the same key are coalesced within a worker, or across workers when "cache_lock" or "cache_lock_dir" is set: while one request is building an entry, later requests wait for it and replay the result instead of running the action again. The waiting requests are held asynchronously, so this works for actions that themselves use the event loop. See "cache_single_flight" for the failure and timeout behaviour.
Calling the helper more than once is harmless, but only the first call can
hit. To disable the cache for one response, use
$c->stash(buffer => {cache => undef}) before calling it.
Arguments
-
$c(implicit)The controller the helper is called on.
Returns
True when the response has been, or will be, sent by the plugin: a cache hit, or a request now waiting for another one to build the entry. False when the action must do its work: a miss, a bypassed request, no cache, or a cache that failed.
Side effects
- On a hit: sets the status, headers and body and finishes the response.
- On a miss: sets
X-Cache: MISS, and the response is stored in the cache when it is complete. - When waiting: calls
render_later; the response is finished later from the event loop, with the entry or with503 Service Unavailable. - May take a shared lock through "cache_lock", released when the response is complete or after "cache_lock_timeout".
Example
use Mojolicious::Lite;
use CHI;
plugin 'Buffer' => {cache => CHI->new(driver => 'Memory', global => 1)};
get '/report' => sub {
my $c = shift;
return if $c->buffer_is_cached;
# Only reached on a miss
$c->render(text => expensive_report());
};
API SPECIFICATION
Input
{ c => { type => 'object', isa => 'Mojolicious::Controller' } }
Output
# Return::Set schema
{ type => 'boolean' }
MESSAGES
The helper never croaks. It logs:
Key Level Meaning
---------------------- ----- -------------------------------------------------
log_cache_hit debug The response was replayed from the cache
log_cache_get_failed error The cache's get() died; the action runs normally
log_stale_entry debug The entry predates the application or a template
log_building debug This request will build the entry
log_waiting debug This request waits for another one in the worker
log_waiting_worker debug This request waits for another worker
log_lock_failed error The lock backend failed; a local flight is used
log_gave_up debug The wait timed out; a 503 was sent
log_finish_waiter_failed error Replaying to a waiting request failed
PSEUDOCODE
if the plugin is off for this response, return false
if this request was already replayed or is already waiting, return true
if this request is already building an entry, return false
if there is no cache or the request may not use it, return false
mark the request so the finished response is stored
work out the key and look the entry up
if the cache failed, return false
if there is no fresh entry:
set X-Cache: MISS
if single-flight is off, return false
if this worker is already building the key, wait for it, return true
if a lock backend says another worker is building it, poll, return true
start a flight for the key (holding the lock if one was taken)
return false
replay the entry and return true
i18n
my $text = $plugin->i18n('retry_later');
Purpose
Look a message up in the catalog, applying the "messages" option. It is the method from Mojolicious::Plugin::Buffer::I18N, imported so that the plugin and its backends share one implementation; that module documents it in full.
Arguments
$key, a message key, and an optional hash of placeholder values.
Returns
The text of the message, or the key itself if it is unknown.
Side effects
None.
Example
my $plugin = $app->plugin('Buffer', messages => {log_cache_hit => 'HIT %{key}'});
say $plugin->i18n('log_cache_hit', {key => 'buffer:abc'}); # HIT buffer:abc
API SPECIFICATION
Input
{
key => { type => 'string', min => 1 },
args => { type => 'hashref', optional => 1 },
}
Output
# Return::Set schema
{ type => 'string' }
MESSAGES
See "i18n" in Mojolicious::Plugin::Buffer::I18N.
DEPLOYMENT
Hypnotoad
hypnotoad preloads the application before forking the workers, so the plugin is registered once and every worker inherits the same cache object and lock backend. The cache storage, and the storage used by the lock backend, must be shared and writable by the user that runs the workers. The example below uses CHI, which must be installed in the application's environment.
A complete application:
package MyApp;
use Mojo::Base 'Mojolicious';
use CHI;
sub startup {
my $self = shift;
$self->config(hypnotoad => {
listen => ['http://*:8080'],
workers => 4,
pid_file => '/var/lib/myapp/hypnotoad.pid',
});
$self->plugin('Buffer' => {
optimise_content => 1,
cache => CHI->new(
driver => 'File',
root_dir => '/var/cache/myapp/buffer',
namespace => 'pages',
),
cache_lock_dir => '/var/lib/myapp/buffer-locks',
cache_duration => '10 minutes',
});
$self->routes->get('/expensive' => sub {
my $c = shift;
return if $c->buffer_is_cached;
# ... expensive work ...
$c->render(template => 'expensive');
});
}
1;
A runnable copy of this example, with its paths under examples/hypnotoad,
is included as examples/hypnotoad.
script/my_app is the usual launcher:
#!/usr/bin/env perl
use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/../lib";
use MyApp;
MyApp->new->start;
Create the directories as the deployment user and start the server:
install -d -o www-data -g www-data -m 0750 /var/cache/myapp/buffer
install -d -o www-data -g www-data -m 0750 /var/lib/myapp /var/lib/myapp/buffer-locks
hypnotoad script/my_app
Replace www-data with the unprivileged user that runs the workers; do not
run hypnotoad as root. The plugin creates cache_lock_dir if necessary,
but pre-creating it as the deployment user sets its ownership and
permissions. The cache directory must be writable by that user too. Use
hypnotoad -f script/my_app in the foreground while testing, and
hypnotoad -s script/my_app to stop it.
Cache and lock requirements
For workers on the same host, CHI's File driver and the built-in file
lock backend work. Do not use Memory, and do not put a per-worker L1
cache in front of the shared cache: a waiting worker would not see the entry
built by another worker and would eventually receive 503 Service Unavailable. A shared filesystem with reliable flock can also be used
on several hosts.
Multiple hosts
Workers on different hosts need a shared cache and a lock backend that does
not rely on flock. This example uses CHI's Memcached driver for the cache
and Mojolicious::Plugin::Buffer::Lock::Redis for the lock, with the
Redis client:
use CHI;
use Mojolicious::Plugin::Buffer::Lock::Redis;
use Redis;
my $redis = sub {
Redis->new(
server => 'redis://redis.internal:6379',
cnx_timeout => 1,
read_timeout => 1,
write_timeout => 1,
);
};
$app->plugin('Buffer' => {
cache => CHI->new(
driver => 'Memcached',
servers => ['cache1.internal:11211', 'cache2.internal:11211'],
namespace => 'pages',
),
cache_lock => Mojolicious::Plugin::Buffer::Lock::Redis->new(
redis => $redis,
namespace => 'myapp:buffer:',
ttl => 60,
),
cache_duration => '10 minutes',
});
The ttl must be longer than "cache_lock_timeout" and longer than the
slowest cached action. If it expires while the builder is still working,
another worker can start building the same entry. The Redis client is
called synchronously, so set short connect, read and write timeouts. The
code reference is called once per worker, after the fork; a connection
created in startup would be shared by every worker and must not be used.
CHI's Memcached driver needs Cache::Memcached::Fast.
Only cache pages that are identical for every visitor. The default key
includes the Cookie header, not the contents of the session, so a page
that depends on the session needs "cache_key" or a suitable "vary_by"
list. Entries are discarded when the application script or a template
changes, and otherwise expire after "cache_duration".
NOTES
Testing
The Redis lock backend has an optional live integration test. It skips when
MPB_REDIS_URL (or REDIS_SERVER) is not set, or when neither Redis nor
Redis::Fast is installed. Redis::Fast is used when it is the only client
available. CI runs the test against a live Redis service with each client,
once with Redis and once with only Redis::Fast available (Redis
hidden with Test::Without::Module), and each run proves from the test
output which client it used, so both paths include the three-process crash
check described below rather than merely being wired up. When either
variable is set, the test expects that server to be reachable; the value may
be a redis:// URL or a bare host:port, so REDIS_SERVER already in
the environment needs no rewriting:
MPB_REDIS_URL=redis://127.0.0.1:6379 prove -Ilib t/76-live-redis.t
REDIS_SERVER=127.0.0.1:6379 prove -Ilib t/76-live-redis.t
A server named by either variable must answer. The variable says a live Redis is there for this test, so an unreachable one fails the file, naming the variable and the connection error, rather than skipping and looking like a checkout with no Redis configured. Unset both to skip the test.
That includes a three-process check of the crash path: a worker takes the
lock and is killed, another process stays blocked until the TTL expires,
takes the lock over, and a third process is blocked until that owner
releases it. The crash test uses a two second lock TTL and waits for it to
run out rather than sleeping a fixed margin; set MPB_REDIS_TTL to widen
that further on a heavily loaded machine. The normal test suite does not
require a Redis server.
The private routines are guarded by Sub::Private in enforce mode and
Sub::Protected. Both stand aside while HARNESS_ACTIVE is set, so the
white-box tests can reach them under prove; t/16-encapsulation.t turns
that bypass off to check the guards themselves.
Why a plugin?
CGI::Buffer replaces STDOUT with an in-memory buffer and does its work
in an END block, and FCGI::Buffer buffers each FastCGI request and does
its work in DESTROY. Neither trick is needed in Mojolicious: the response
already exists as an object before it is written, so the plugin can simply
hook after_dispatch and rewrite it. That gives the same effect without a
source filter, without reading $_, and without depending on a specific
server or environment.
Interaction with the renderer
Mojolicious::Renderer compresses rendered output with gzip by default
(compress is on and min_compress_size is 860 bytes). That happens
before "after_dispatch" in Mojolicious is emitted, which would leave this
plugin with nothing to optimise and no ETag to generate on any page
larger than 860 bytes. Registering this plugin therefore calls
$app->renderer->compress(0), and the plugin does the compression
instead - with gzip, Brotli or Zstandard as negotiated, and with the header
work the renderer does not do. If you enable $app->renderer->compress
after loading the plugin, those responses will be skipped by the plugin.
What is not implemented
save_to, which rewrites dynamic pages into static ones, is a site
generation feature rather than a response filter and is not part of this
plugin. The server-side cache is available as the opt-in
"buffer_is_cached" helper described above.
COMMON PITFALLS
The buffer stash: what each value means
buffer => 0andbuffer => ''turn the plugin off for this response.buffer => undefdoes not turn it off. undef means "nothing set here", so the plugin options are used.buffer => {}keeps the plugin on and changes nothing.
The buffer stash is merged one level deep only
The hash you put in the stash is laid over the plugin options one key at a time. A key you give replaces the whole plugin value for that key; nothing inside it is merged. So lists are replaced, not added to:
plugin 'Buffer' => {encodings => [qw(br gzip)]};
# This response may ONLY use br. If Brotli is not installed, it is
# not compressed at all, and an error is logged.
$c->stash(buffer => {encodings => ['br']});
Values in the stash are not checked the way plugin() options are, and
they are not filtered by which compression modules are installed.
undef in options
-
In the
bufferstash, an undef value is a real value.{compress_content => undef}turns compression off for that response, and{cache => undef}turns the cache off. -
In a group declaration (a route default or
buffer_NAMEcontroller method), undef means "not declared", so the plugin option is used. -
In
plugin()options, undef is accepted only where it means "use the default" or "off":optimise_content and the true/false off flags (compress_content, rewrite_links, generate_etag, generate_304, trust_last_modified, cache_single_flight) last_modified no Last-Modified header cache, cache_lock, cache_lock_dir, not used template_mtime_cache cache_key the generated key cache_duration, cache_lock_timeout, their default values template_mtime_cache_duration messages the built-in English textsFor
encodings,compressible_types,excluded_types,vary_byandmin_compress_size, undef is refused when the plugin is registered. This matters most forvary_by: an undef there would have silently removed theCookieheader from the cache key. -
In
messages, undef does not restore the built-in text.messages => {retry_later => undef}makes visitors see the wordretry_later. Leave the key out instead. See "COMMON PITFALLS" in Mojolicious::Plugin::Buffer::I18N.
Using the server-side cache safely
- Check access before "buffer_is_cached". A cached page is sent straight away and the rest of the action does not run. Any login or country check that comes later in the action is skipped on a hit.
- Personal pages need a personal key.
vary_by => []makes the key the same for every visitor. A page that shows a name, a basket or anything else from the session is then shown to other visitors. Keep the default['cookie'], or set "cache_key". - The cache must be shared by all workers. With several workers,
a per-worker cache such as CHI's
Memorydriver means a waiting request never sees the entry another worker built; after "cache_lock_timeout" it gets503 Service Unavailable. - Waiting requests get 503, not a second build. If the request
that is building a page fails, makes something that cannot be stored (for
example, it sets a cookie), or is slower than "cache_lock_timeout", the
requests waiting for it get
503withRetry-After: 1. - A Redis lock TTL must be longer than the slowest action, or a second worker starts building the same page.
- Template edits can be missed for a while when "template_mtime_cache" is set: until its entry expires.
Other pitfalls
- Do not turn the renderer's compression back on. The plugin turns
off
$app->renderer->compress. If you turn it on again, the renderer compresses first, and the plugin then skips those responses: no optimisation and noETag. - Do not stream through the plugin. Responses written with
$c->writeare not buffered and are left alone. $c->stash(buffer => ...)must be set before the action ends. The plugin reads it inafter_dispatch. To turn the cache off for one request, set{cache => undef}before calling "buffer_is_cached".
ENCODING
This section says which text may contain non-ASCII characters, such as accented letters, other scripts and emoji.
-
Response bodies
Any encoding works. Pages rendered by Mojolicious are already UTF-8 bytes when the plugin sees them. A body that is still a Perl character string is encoded to UTF-8 first. Bodies in another character set, such as ISO-8859-1 or Shift_JIS, are passed through unchanged; the plugin does not convert them.
-
HTML optimisation
Safe for every UTF-8 character, including emoji. The optimiser only changes ASCII white space (space, tab, carriage return, line feed, form feed) and ASCII markup. A non-breaking space (U+00A0) is kept.
-
The
ETagMade from the bytes of the body, so any content is fine.
-
Cache keys
A custom "cache_key" may contain any characters, including emoji. Keys are encoded to UTF-8 before they are hashed for the lock backends. The generated key is an ASCII MD5 digest. The cache itself receives a custom key as it is; CHI's File and Memory drivers accept any characters, but check this for other cache objects.
-
Request headers in "vary_by"
Header names are ASCII. Header values are used as raw bytes, so any value is fine.
-
The
503text (retry_later) and other messagesMay contain any characters. The
503body is sent as UTF-8 withContent-Type: text/plain; charset=UTF-8. Log lines go through Mojo::Log, which writes them as UTF-8. -
Error messages
The plugin's
croakmessages are Perl character strings. If you translate them into a non-ASCII language and print them toSTDERR, giveSTDERRan encoding layer, or Perl warns "Wide character". -
File paths (
cache_lock_dir)Paths go to the operating system as bytes. ASCII paths are always safe. For a non-ASCII path, pass bytes that are already encoded the way your file system expects, usually UTF-8.
-
Option names, encoding names and media types
These are ASCII words.
LIMITATIONS
- Only in-memory responses are processed. Static files
(
Mojo::Asset::File) and streaming responses are left alone. - The whole response is held in memory, more than once while it is being optimised and compressed. There is no size limit; a very large generated body costs several times its size in a worker.
- Responses that already have a
Content-Encoding, for example from$app->renderer->compress, are not compressed again. - Gzip, Brotli, Zstandard and identity representations have different
ETags, because the tag identifies the selected representation. A client that switches between content codings will therefore download the page once for each coding. - Because the body is buffered, the plugin must not be used for
responses that are streamed with
$c->write(server-sent events, long-polling and so on).text/event-streamis excluded by default. - The HTML optimiser is a set of regular expressions inherited from
CGI::Buffer, not an HTML parser. It protects the content of
<pre>,<textarea>,<script>and<style>, but white space made significant by CSS (white-space: preon another element) is still collapsed, and some rules (for example merging adjacent<center>blocks) change the document slightly. Useoptimise_content => 0on pages where every byte matters, or disable it per response with$c->stash(buffer => {optimise_content => 0}). Link rewriting only recognises double-quoted attributes. - The server-side cache is opt-in: a page is only stored if its action
calls "buffer_is_cached". An action that forgets to call it is never
cached, and an action that calls it for a personalised page must supply a
"cache_key" that distinguishes users (the default key only includes the
Cookieheader, not the session contents). - The default key includes the
Cookieheader, so each session gets its own set of entries and anonymous visitors share them. Sites that set an analytics cookie on every visit will get little sharing; usevary_by => []for genuinely public pages, or "cache_key". - Single-flight is per worker by default. Each
hypnotoadworker keeps its own in-flight table, so a cold entry can be built once per worker; set "cache_lock" or "cache_lock_dir" to coalesce across workers, with a shared cache. A request waiting on an in-flight response is answered with503if that response fails, cannot be cached, or exceeds "cache_lock_timeout", rather than running the action a second time. A waiter in another worker polls the cache every 100 milliseconds, so it can be answered up to that much later than the builder finishes. - Entries are invalidated when the application script or a template path changes, but not when data or configuration changes. The TTL is the backstop for those.
- Options in the
bufferstash and group declarations are not validated the wayplugin()options are. A badcache_lock_timeoutthere falls back to the default; other bad values are used as given. - The lock backend protocol returns two-element lists rather than references. That is the documented interface third-party backends already implement, so it was kept.
- Every private routine is wrapped by Sub::Private or
Sub::Protected, which costs a
callercheck on each call. The plugin makes a few hundred such calls per response, which is small next to MD5, compression and template rendering, but it is not free. The modules also add Readonly, Params::Validate::Strict and their dependencies to what was a Mojolicious-only plugin, anduse autodie qw(:all)requires IPC::System::Simple even though the plugin never runs an external command. - The
503text is chosen per application through "messages"; it is not negotiated fromAccept-Language.
SEE ALSO
CGI::Buffer, FCGI::Buffer, CHI, HTML::Packer, Mojolicious::Plugin, Mojolicious::Plugin::Buffer::Lock::File, Mojolicious::Plugin::Buffer::Lock::Redis, Mojolicious::Plugin::Buffer::I18N
FORMAL SPECIFICATION
This section describes the plugin in the Z notation, for readers who want
an exact model. You do not need it to use the plugin. now is the
current time and mtime is the newest modification time of the
application script and its templates. Schema maps each option name to
its Params::Validate::Strict rule.
[OPTION, VALUE, APP, KEY, Controller, Entry, Guard, Response]
Known == dom Schema
Valid == { o : Known; v : VALUE | v ∈ Schema(o) }
Flight == [ waiters : ℙ Controller ; deadline : ℝ ; guard : Guard ∪ {⊥} ]
┌─ Plugin ────────────────────────────────────
│ options : OPTION ⇸ VALUE
│ inflight : KEY ⇸ Flight
│ polling : Controller ⇸ (KEY × ℝ)
│ lock : LockBackend ∪ {⊥}
├─────────────────────────────────────────────
│ dom options = Known
│ ∀ o : dom options • (o, options o) ∈ Valid
│ ∀ k : dom inflight • (inflight k).deadline ≤ now + timeout
└─────────────────────────────────────────────
┌─ Cache ─────────────────────────────────────
│ store : KEY ⇸ Entry
│ created : KEY ⇸ ℝ
├─────────────────────────────────────────────
│ dom created = dom store
└─────────────────────────────────────────────
fresh : ℙ KEY
fresh = { k : dom store | created k ≥ mtime }
register
┌─ Register ──────────────────────────────────
│ ΔPlugin
│ app? : APP ; conf? : OPTION ⇸ VALUE ; result! : Plugin
├─────────────────────────────────────────────
│ dom conf? ⊆ Known
│ ∀ o : dom conf? • (o, conf? o) ∈ Valid
│ ∀ o : dom conf? • conf? o = ⊥ ⇒ undefAllowed(o)
│ ¬(cache_lock ∈ dom conf? ∧ cache_lock_dir ∈ dom conf?)
│ ran (conf? encodings) ⊆ {br, zstd, gzip, x-gzip}
│ options′ = Defaults ⊕ conf?
│ inflight′ = ∅ ∧ polling′ = ∅
│ compress(renderer app?)′ = false
│ after_dispatch ∈ hooks(app?)′ ∧ buffer_is_cached ∈ helpers(app?)′
│ result! = θPlugin′
└─────────────────────────────────────────────
-- Any violated predicate: croak, and app? is unchanged
buffer_is_cached
cacheable(c) holds when the plugin is on for this response, a cache is
configured, the method is GET or HEAD, and the request carries no
Authorization header, no no-store, no-cache, private or
max-age=0 directive, and NO_CACHE and NO_STORE are not set.
busy(k) holds when the lock backend says another process holds k.
┌─ BufferIsCached ────────────────────────────
│ ΔPlugin ; ΔCache
│ c? : Controller ; result! : 𝔹 ; response! : Response
├─────────────────────────────────────────────
│ let k == key(c?) •
│ ¬cacheable(c?) ⇒ result! = false
│ cacheable(c?) ∧ k ∈ fresh ⇒
│ response! = replay(store k) ∧ result! = true
│ cacheable(c?) ∧ k ∉ fresh ⇒ store′ = {k} ⩤ store ∧ (
│ ¬singleFlight ⇒ result! = false
│ singleFlight ∧ k ∈ dom inflight ⇒
│ (inflight′ k).waiters = (inflight k).waiters ∪ {c?}
│ ∧ result! = true
│ singleFlight ∧ k ∉ dom inflight ∧ busy(k) ⇒
│ polling′ = polling ⊕ {c? ↦ (k, now + timeout)}
│ ∧ result! = true
│ singleFlight ∧ k ∉ dom inflight ∧ ¬busy(k) ⇒
│ k ∈ dom inflight′ ∧ (inflight′ k).waiters = ∅
│ ∧ result! = false )
└─────────────────────────────────────────────
The building request finishes (the after_dispatch hook), or its time
runs out. e? is the stored entry, or ⊥ when the response could not be
stored or the build timed out.
┌─ FinishFlight ──────────────────────────────
│ ΔPlugin
│ k? : KEY ; e? : Entry ∪ {⊥}
├─────────────────────────────────────────────
│ k? ∈ dom inflight
│ ∀ w : (inflight k?).waiters •
│ (e? ≠ ⊥ ⇒ response(w)′ = replay(e?))
│ ∧ (e? = ⊥ ⇒ response(w)′ = 503)
│ inflight′ = {k?} ⩤ inflight
│ (inflight k?).guard ≠ ⊥ ⇒ released((inflight k?).guard)
└─────────────────────────────────────────────
ExpireFlight == [ FinishFlight | now ≥ (inflight k?).deadline ∧ e? = ⊥ ]
Every 0.1 seconds, each request waiting for another worker looks again:
┌─ PollTick ──────────────────────────────────
│ ΔPlugin
│ c? : dom polling
├─────────────────────────────────────────────
│ let (k, d) == polling c? •
│ k ∈ fresh ⇒ response(c?)′ = replay(store k)
│ ∧ polling′ = {c?} ⩤ polling
│ k ∉ fresh ∧ now ≥ d ⇒ response(c?)′ = 503
│ ∧ polling′ = {c?} ⩤ polling
│ k ∉ fresh ∧ now < d ∧ busy(k) ⇒ polling′ = polling
│ k ∉ fresh ∧ now < d ∧ ¬busy(k) ⇒
│ response(c?)′ = 503 ∧ polling′ = {c?} ⩤ polling
└─────────────────────────────────────────────
-- In the last case the lock is taken and the cache read once more
-- before the 503, so an entry stored at that moment is still replayed
i18n
The same operation as "i18n" in Mojolicious::Plugin::Buffer::I18N, where
overrides is the "messages" option.
STATE DIAGRAM
There are three pictures. The numbers on the arrows are explained below
each picture. In each explanation, the text in [ ] is the condition and
the text after / is what happens.
A. One page in the server-side cache
This is the life of one cache key, seen by all workers together.
+---------------------+
| EMPTY |<-----------------------+
| nothing is stored | |
+---------------------+ |
| ^ ^ |
(1)| (3)| (4)| |
v | | |
+---------------------+ (5) |
| BUILDING |-----------+ |
| one request builds; | | |
| others wait | v |
+---------------------+ +--------------+ |
| | CACHED | |
(2)+----------------------->| fresh entry | |
+--------------+ |
| ^ | |
(6)| |(7) |(8) |
v | v |
+--------------+ |
| STALE |----+
| too old | (9)
+--------------+
(1) a request calls buffer_is_cached [no fresh entry, single-flight
on, nobody else building]
/ X-Cache: MISS; this request becomes the builder; it takes the
shared lock if one is configured; a timer of
cache_lock_timeout starts
(2) the builder's response is finished [200, may be stored]
/ the uncompressed page is stored with cache_duration; every
waiting request is answered from it; the lock is released
(3) the builder's response is finished [error, or not storable:
it sets a cookie, is marked private or no-store, or varies on a
header not in vary_by]
/ every waiting request gets 503 with Retry-After: 1; the lock
is released
(4) cache_lock_timeout passes before the builder finishes
/ waiting requests get 503; the lock is released. If the
builder finishes later and its page may be stored, it is still
stored: that is arrow (5).
(5) a late builder finishes [200, may be stored]
/ the page is stored
(6) the application script or a template file is changed
/ nothing happens yet; the entry is now older than the code
(7) a request reads the entry [it is fresh]
/ the stored page is replayed with X-Cache: HIT; it is
compressed for this client and gets its own ETag
(8) cache_duration passes
/ the cache forgets the entry
(9) a request reads the entry [it is older than the code]
/ the entry is removed; the request goes on as at (1)
While BUILDING, a second request for the same key:
- in the same worker: waits for (2), (3) or (4);
- in another worker, with a shared lock: looks in the cache every
0.1 seconds until the page appears, the lock is free, or
cache_lock_timeout passes;
- with cache_single_flight => 0: does not wait, and builds the page
too.
B. One request that calls buffer_is_cached
buffer_is_cached
|
v
+--------------+ (1) +--------+ (5) +-----------------+
| RUN ACTION |<------| DECIDE |------>| BUILDER |
| not cached | +--------+ | the action runs;|
+--------------+ | | | | see picture C |
(2)| | |(3) +-----------------+
v | v
REPLAYED | +----------------+ (8)
| | WAITING_REMOTE |------> REPLAYED
| | polls the cache| (9)
| | 10 times a sec |------> 503
| +----------------+
| | ^
(4)| +--(10)--+
v
+----------------+ (6)
| WAITING_LOCAL |------> REPLAYED
| same worker | (7)
+----------------+------> 503
(1) [the plugin is off, no cache, method not GET or HEAD, an
Authorization header, Cache-Control no-store, no-cache, private
or max-age=0, NO_CACHE or NO_STORE set, or the cache's get() died]
/ returns false: the action runs normally
(2) [a fresh entry exists]
/ status, headers and body are replayed; X-Cache: HIT; the
response is finished; returns true
(3) [no entry; another worker holds the shared lock]
/ render_later; X-Cache: MISS; returns true
(4) [no entry; a request in this worker is building it]
/ render_later; X-Cache: MISS; returns true
(5) [no entry; nobody is building it, or single-flight is off]
/ X-Cache: MISS; returns false. If the lock backend failed, the
error is logged and this worker builds without a shared lock.
(6) the builder in this worker stored the page
/ the page is replayed with X-Cache: HIT
(7) the builder failed, made something not storable, or
cache_lock_timeout passed
/ 503 with Retry-After: 1 and the retry_later text
(8) a poll finds the page in the cache
/ the page is replayed
(9) a poll finds [cache_lock_timeout has passed], or [the lock is free
but there is still no page], or [the lock backend failed]
/ 503 with Retry-After: 1
(10) a poll finds [no page yet, and the lock is still held]
/ wait another 0.1 seconds
Calling buffer_is_cached again in the same request returns the same
answer and changes nothing.
C. What the after_dispatch hook does with every response
response finished by the action
|
v
+-----------------+ (1)
| 1. CHECK |-----------------------------------> sent as it is
+-----------------+
|
v
+-----------------+ [HTML and optimise_content on, and the result
| 2. OPTIMISE | is smaller] / white space removed, links
+-----------------+ shortened. Skipped for a replayed entry.
|
v
+-----------------+ [compress_content on and type in
| 3. COMPRESS | compressible_types] / Vary: Accept-Encoding;
+-----------------+ then [client accepts a coding, body >=
| min_compress_size, result smaller]
| / body compressed, Content-Encoding set
|
v
+-----------------+ [buffer_is_cached ran, it was not a hit, the
| 4. STORE | status is 200, the response may be stored]
+-----------------+ / the uncompressed page goes into the cache
|
v
+-----------------+ (2)
| 5. VALIDATE |-----------------------------------> 304, no body
+-----------------+
|
v
+-----------------+
| 6. SEND | the compressed or plain body; Content-Length
+-----------------+
|
v
if this request was the builder: answer every waiting request and
release the shared lock (arrows (2) and (3) of picture A). This
also happens when a step above dies.
(1) [the stash says buffer => 0, or the body is empty, streamed or a
file, or the status is 1xx, 204, 206 or 304, or a
Content-Encoding is already set, or the media type is unknown or
in excluded_types]
(2) [GET or HEAD, status 200, generate_304 on, and If-None-Match
matches the ETag; or there is no ETag (or trust_last_modified is
on) and If-Modified-Since is not older than Last-Modified]
/ 304 Not Modified is sent with no body
AUTHOR
Nigel Horne, <njh at nigelhorne.com>
LICENCE AND COPYRIGHT
Copyright 2026 Nigel Horne.
Usage is subject to the GPL2 licence terms. If you use it, please let me know.