NAME
Mojolicious::Plugin::Buffer::Lock::File - flock-based lock backend
VERSION
Version 0.001.0
SYNOPSIS
The usual way: give the plugin a directory and let it build this backend.
use Mojolicious::Lite;
use CHI;
plugin 'Buffer' => {
cache => CHI->new(driver => 'File', root_dir => '/var/cache/myapp'),
cache_lock_dir => '/var/lib/myapp/buffer-locks',
};
Build the backend yourself, for example to reword its messages, and pass it as cache_lock:
use Mojolicious::Plugin::Buffer::Lock::File;
my $lock = Mojolicious::Plugin::Buffer::Lock::File->new(
dir => '/var/lib/myapp/buffer-locks',
messages => {lock_failed => 'lock %{path} is broken: %{error}'},
);
plugin 'Buffer' => {cache => $cache, cache_lock => $lock};
Use it on its own, for example so that only one copy of a cron job rebuilds a report at a time:
use Mojolicious::Plugin::Buffer::Lock::File;
my $lock = Mojolicious::Plugin::Buffer::Lock::File->new(dir => '/run/myapp');
# Always take BOTH return values (see COMMON PITFALLS)
my ($guard, $error) = $lock->acquire('nightly-report');
die "$error\n" if defined $error;
exit 0 unless $guard; # another copy is already running
build_report();
$lock->release($guard);
Spread the lock files over sub-directories by overriding the protected _path method in a subclass:
package MyApp::Lock;
use Mojo::Base 'Mojolicious::Plugin::Buffer::Lock::File';
use File::Path ();
use File::Spec ();
use Mojo::Util qw(encode md5_sum);
sub _path {
my ($self, $key) = @_;
my $digest = md5_sum(encode('UTF-8', $key));
my $sub = File::Spec->catdir($self->dir, substr($digest, 0, 2));
File::Path::make_path($sub);
return File::Spec->catfile($sub, "$digest.lock");
}
DESCRIPTION
This is the built-in lock backend for Mojolicious::Plugin::Buffer. The plugin uses it when you set cache_lock_dir.
What problem does it solve?
A web server often runs several copies of your program at the same time. Each copy is called a worker. When a page is not in the cache yet, every worker that gets a request for it would build the same page at the same time. That wastes work. A lock lets exactly one worker build the page. The other workers wait, then read the finished page from the cache.
How it works, in plain words
- 1. Each cache key gets its own small, empty file in "dir". The file name is the MD5 digest of the key, so any key makes a safe file name.
- 2. To take the lock, the backend opens that file and asks the operating system for an exclusive lock on it (
flock). It does not wait: if another open file handle already holds the lock, the answer is "busy" at once. - 3. The lock lasts as long as the file stays open. The open file lives inside the guard that "acquire" returns.
- 4. "release" closes the file, which frees the lock. If the process crashes or is killed, the operating system closes the file and frees the lock too. So a dead worker can never block a key for ever.
All workers must use the same directory. This works for workers on one machine. For workers on different machines, use Mojolicious::Plugin::Buffer::Lock::Redis.
Words used in this document
- key
-
A text string that names one cache entry, for example
buffer:0123abcd. - guard
-
The value "acquire" returns when you get the lock. Keep it, and give it back to "release". Do not look inside it.
- flock
-
The operating system's file-locking call. Only one open file handle can hold an exclusive
flockon a file at a time.
ATTRIBUTES
Attributes are set with "new". Each one also has an accessor method with the same name, made by Mojo::Base.
dir
The directory that holds the lock files. It is required. "new" creates it (and any missing parent directories) and checks that it is writable.
API SPECIFICATION
Input
# $lock->dir reads the value. $lock->dir($path) changes it WITHOUT
# creating or checking the directory; see COMMON PITFALLS.
{ dir => { type => 'string', min => 1, optional => 1 } }
Output
# Return::Set schema: the path when reading, the object when writing
{ type => 'string' }
messages
An optional hash of message overrides, keyed by message key. See Mojolicious::Plugin::Buffer::I18N for the keys and the text format. The plugin passes its own messages option here when it builds this backend from cache_lock_dir.
API SPECIFICATION
Input
{ messages => { type => 'hashref', optional => 1 } }
Output
# Return::Set schema: the hash (or undef) when reading, the object
# when writing
{ type => 'hashref', optional => 1 }
METHODS
new
my $lock = Mojolicious::Plugin::Buffer::Lock::File->new(dir => $dir);
Purpose
Create the backend, creating the lock directory if necessary.
Arguments
dir(named, required)-
A directory path, as a non-empty string.
messages(named, optional)-
A hash of message overrides.
Returns
The new object.
Side effects
Creates dir and any missing parents.
Example
my $lock = Mojolicious::Plugin::Buffer::Lock::File->new(
dir => '/var/lib/myapp/buffer-locks',
);
API SPECIFICATION
Input
{
dir => { type => 'string', min => 1 },
messages => { type => 'hashref', optional => 1 },
}
Output
# Return::Set schema
{ type => 'object', isa => 'Mojolicious::Plugin::Buffer::Lock::File' }
MESSAGES
Key Message Resolution
---------------------- ---------------------------------------------- ---------------------------------
option_cache_lock_dir cache_lock_dir must be a directory path Pass a non-empty string
lock_dir_create_failed cannot create cache_lock_dir 'DIR': OS ERROR Fix the parent's permissions, or
the path (e.g. a file is in the
way)
lock_dir_not_writable cache_lock_dir 'DIR' is not a writable Change the owner or mode of DIR
directory
All three croak. The OS error is Perl's $! text and follows the process's LC_MESSAGES only inside use locale.
acquire
my ($guard, $error) = $lock->acquire($key);
Purpose
Try, without waiting, to become the only process building $key.
Arguments
$key(positional, required)-
The cache key: any defined string, in any language, emoji included. Only its MD5 digest reaches the file system.
Returns
A two-element list, as the lock protocol in "cache_lock" in Mojolicious::Plugin::Buffer requires: ($guard, undef) when the lock was taken, (undef, undef) when another process holds it, and (undef, $error) on failure. The guard is opaque. Call it in list context; see "COMMON PITFALLS".
Side effects
Creates the lock file if it does not exist and keeps it open while the guard lives.
Example
my ($guard, $error) = $lock->acquire('buffer:0123abcd');
if ($guard) {
# ... build the entry ...
$lock->release($guard);
} elsif (defined $error) {
warn $error;
} else {
# somebody else is building it
}
API SPECIFICATION
Input
{ key => { type => 'string', position => 0 } }
Output
# Return::Set schema (list of two)
{
type => 'arrayref',
schema => [
{ type => 'hashref', optional => 1 }, # guard
{ type => 'string', optional => 1 }, # error
],
}
MESSAGES
Key Message Resolution
------------------ ----------------------------- ---------------------------------
lock_key_undefined cache key is not defined Pass a defined key
lock_open_failed cannot open PATH: OS ERROR Check the directory still exists
and is writable
lock_failed cannot lock PATH: OS ERROR The filesystem does not support
flock; use another backend
These are returned, not thrown.
PSEUDOCODE
reject an undefined key
open (creating) DIR/md5(key).lock
on failure return the OS error
try a non-blocking exclusive flock
if it succeeded, return a guard holding the handle
close the handle
if flock said "would block", return (undef, undef)
otherwise return the OS error
release
my ($ok, $error) = $lock->release($guard);
Purpose
Give up a lock taken by "acquire".
Arguments
$guard(positional, required)-
A guard returned by "acquire". A false value, or a guard that was already released, is accepted and ignored.
Returns
(1, undef) on success, (0, $error) if unlocking or closing failed. The lock is gone either way, because the handle is closed.
Side effects
Unlocks and closes the lock file handle and removes it from the guard.
Example
my ($ok, $error) = $lock->release($guard);
warn $error unless $ok;
API SPECIFICATION
Input
{ guard => { type => 'hashref', optional => 1, position => 0 } }
Output
# Return::Set schema (list of two)
{
type => 'arrayref',
schema => [
{ type => 'boolean' }, # released
{ type => 'string', optional => 1 }, # error
],
}
MESSAGES
Key Message Resolution
------------- ----------------------------- -------------------------
unlock_failed cannot unlock PATH: OS ERROR Usually harmless: closing
the handle drops the lock
COMMON PITFALLS
Always take both return values. "acquire" returns a list of two values. In scalar context Perl gives you only the last one, which is the error slot:
my $guard = $lock->acquire($key); # WRONG: $guard is undef my ($guard, $error) = $lock->acquire($key); # rightThe wrong form is worse than it looks. The real guard is thrown away at once, which closes the file, so the lock you just took is released before the next line runs.
Keep the guard while you work. The lock lives inside the guard. If the guard variable goes out of scope, the file is closed and the lock is freed, even though you never called "release".
The lock is not re-entrant. If your own process already holds the lock for a key, a second
acquirefor the same key returns "busy"(undef, undef). This is true even for the same object, because each call opens a new file handle.Forking while you hold a lock shares it. A child process made with
forkinherits the open file, so the lock stays held until the parent and the child have closed it. Release locks before you fork.Do not change
dirafternew. "new" creates and checks the directory. Setting it later with$lock->dir($path)skips both checks, so a typing mistake only shows up as an error from every "acquire".Lock files are never deleted. There is one empty file per key that was ever built. Deleting a file while another process has it open would let two builders run, so only clean the directory while the application is stopped.
Network file systems.
flockis unreliable on some network file systems, especially older NFS. Use Mojolicious::Plugin::Buffer::Lock::Redis when workers are on different machines.undef values.
acquire(undef)does not die; it returns(undef, 'cache key is not defined'). The empty string is a valid key.release(undef)and releasing the same guard twice both return(1, undef)and do nothing.new(dir => undef)croaks.messages => undefmeans "no overrides".
ENCODING
- Keys
-
Any Perl string is safe, including accented letters, any other non-ASCII text and emoji. The key is encoded as UTF-8 bytes before it is hashed, so the same characters always name the same lock file, however Perl stores them internally. Only the MD5 digest reaches the file system. A subclass that overrides
_pathmust do the same, becausemd5_sumdies on a character above U+00FF. - dir
-
A directory name goes to the operating system as bytes. An ASCII path is always safe. For a non-ASCII path, pass a byte string that is already encoded the way your file system expects, usually UTF-8:
use Encode qw(encode); my $lock = Mojolicious::Plugin::Buffer::Lock::File->new( dir => encode('UTF-8', "/srv/caf\x{e9}/locks"), ); - messages
-
Override texts may hold any characters, including emoji. The messages this module returns are Perl character strings. Print them to a handle with an encoding layer, such as
binmode(STDERR, ':encoding(UTF-8)'), or Perl warns "Wide character". Mojo::Log encodes to UTF-8 by itself. - Operating-system errors
-
The text after
OS ERRORin a message is Perl's$!. It is English unless the program runs underuse locale.
LIMITATIONS
Lock files are never deleted. Removing one while another process has it open would let a third process lock a new file of the same name, and two builders would run. They are empty, so the cost is one inode per key ever built.
flockis only advisory and is unreliable on some network filesystems (older NFS in particular). Use the Redis backend there.The protocol returns a two-element list rather than a reference, because that is the documented interface third-party backends implement.
SEE ALSO
Mojolicious::Plugin::Buffer, Mojolicious::Plugin::Buffer::Lock::Redis, Mojolicious::Plugin::Buffer::I18N.
AUTHOR
Nigel Horne, <njh at bandsman.co.uk>
FORMAL SPECIFICATION
This section describes the backend in the Z notation, for readers who want an exact model. You do not need it to use the module. KEY is the set of cache keys, PATH the set of file names and PID the set of processes. held records which process holds the lock for each key.
[KEY, PATH, PID, TEXT, Guard]
file : KEY → PATH
∀ k : KEY • file k = dir ⁀ "/" ⁀ md5(utf8(k)) ⁀ ".lock"
┌─ Locks ─────────────────────────────────────
│ dir : PATH
│ held : KEY ⇸ PID
├─────────────────────────────────────────────
│ dir ≠ ∅
└─────────────────────────────────────────────
┌─ ProcessExit ───────────────────────────────
│ ΔLocks
│ p? : PID
├─────────────────────────────────────────────
│ held′ = held ⩥ {p?}
└─────────────────────────────────────────────
new
┌─ New ───────────────────────────────────────
│ Locks′
│ dir? : PATH ; fs, fs′ : PATH ⇸ Node ; result! : Locks
├─────────────────────────────────────────────
│ dir? ≠ ∅
│ dir? ∈ dom fs′ ∧ isDirectory(fs′ dir?) ∧ writable(fs′ dir?)
│ fs ⊆ fs′
│ dir′ = dir? ∧ held′ = ∅ ∧ result! = θLocks′
└─────────────────────────────────────────────
-- Otherwise: croak, and fs′ may hold the parents created so far
acquire
ok means the lock file could be opened and the file system supports flock. The lock is not re-entrant: when held key? = me the result is "busy", the same as for any other holder.
┌─ Acquire ───────────────────────────────────
│ ΔLocks
│ key? : KEY ; me : PID
│ guard! : Guard ∪ {⊥} ; error! : TEXT ∪ {⊥}
├─────────────────────────────────────────────
│ ok ∧ key? ¬in; dom held ⇒
│ held′ = held ⊕ {key? ↦ me} ∧ guard! ≠ ⊥ ∧ error! = ⊥
│ ok ∧ key? ∈ dom held ⇒
│ held′ = held ∧ guard! = ⊥ ∧ error! = ⊥
│ ¬ok ⇒
│ held′ = held ∧ guard! = ⊥ ∧ error! ≠ ⊥
└─────────────────────────────────────────────
release
┌─ Release ───────────────────────────────────
│ ΔLocks
│ guard? : Guard ∪ {⊥} ; ok! : 𝔹 ; error! : TEXT ∪ {⊥}
├─────────────────────────────────────────────
│ guard? ≠ ⊥ ⇒ held′ = {key(guard?)} ⩤ held
│ guard? = ⊥ ⇒ held′ = held ∧ ok! = true
│ ok! = true ⇔ error! = ⊥
└─────────────────────────────────────────────
-- The lock is gone even when ok! = false, because the handle is closed
STATE DIAGRAM
Each cache key's lock is in one of two states. The backend object itself has no other state: after "new" it is ready, and it stays ready. The numbers on the arrows are explained below the picture.
+-----------+ (1) +-----------+
| FREE | --------------------------> | HELD |
| (key k) | <-------------------------- | (key k) |
+-----------+ (2), (3) or (4) +-----------+
| ^ | ^
+-----+ (5), (7) +-----+ (6), (8)
(1) acquire(k) [the file opens and flock is granted]
/ returns (guard, undef); the file stays open inside the guard
(2) release(guard)
/ unlock and close the file; returns (1, undef), or (0, error)
if unlocking failed - the lock is gone either way
(3) the guard goes out of scope
/ Perl closes the file, which frees the lock
(4) the holding process exits or is killed
/ the operating system closes the file, which frees the lock
(5) acquire(k) [the file cannot be opened, or flock is not supported]
/ returns (undef, error); nothing changes
(6) acquire(k) [flock would block], from ANY process, the holder too
/ returns (undef, undef); nothing changes
(7) release(undef), or release of a guard already released
/ returns (1, undef); nothing changes
(8) fork()
/ the child shares the open file, so the key stays HELD until
both processes have closed it
The plugin's own use of these states is drawn in "STATE DIAGRAM" in Mojolicious::Plugin::Buffer.
LICENSE AND COPYRIGHT
Copyright 2026 Nigel Horne.
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version.