NAME
App::karr::Lock - Lock management via Git refs
VERSION
version 0.500
SYNOPSIS
my $lock = App::karr::Lock->new(git => $git, ttl => 300);
my ($ok, $msg) = $lock->acquire(12, 'agent@example.com');
DESCRIPTION
App::karr::Lock manages lightweight per-task locks stored in Git refs. It is used by commands such as karr pick to avoid concurrent agents selecting the same task at the same time.
The lock is an optimisation, not the thing that makes karr pick exclusive. Its holder identity is the clone's user.email, which every agent on one machine shares, so it cannot separate them from each other at all; what actually binds a pick is the compare-and-swap on the task card itself ("save_task_cas" in App::karr::BoardStore). What the lock buys is that agents do not all pile onto the same candidate and lose the same race.
Expiry
A lock has a TTL, because an agent that dies between acquire and release otherwise leaves a ref that no future run will ever clear -- and that task then stays unpickable forever, with no way out from inside karr (#45). Age is the committer time of the commit the lock ref points at, so it needs no payload of its own and travels with the ref.
A lock past its TTL may be taken over. The takeover is itself a compare-and-swap against the OID whose age was judged, so a holder that refreshes its lock in between wins and is never silently evicted. The TTL is deliberately not claim_timeout: see App::karr::Cmd::Pick.
Locks are local, and live outside the board
Lock refs live under refs/karr-local/, which nothing pushes, fetches, prunes or snapshots. A lock says "this process, in this clone, is mid-pick right now", and that sentence has no meaning anywhere else: a clone that receives one cannot tell whether the holder is still alive, and has no way to find out.
They used to live at refs/karr/tasks/N/lock, inside the namespace karr pushes. Any sync that fired while a lock was held published it, other clones pulled it, and it then blocked their picks until somebody ran karr unlock -- a lock that outlived the process holding it and the machine it ran on (#93). It also turned every board backup into a snapshot of somebody's momentary lock. Moving the refs out is what makes that impossible, rather than making it depend on the timing of when a lock happens to be released.
Locks left in the old place by a karr older than this one -- or pulled from a remote that still has them -- are not acted on: they cannot say anything about this process, and a pick's exclusivity does not rest on them anyway. They are not ignored either. locks reports them, marked legacy, and break_lock clears them, so karr unlock is the way out of the mess the old layout left behind.
new
my $lock = App::karr::Lock->new( git => $git, task_id => 12, ttl => 300 );
my $lock = App::karr::Lock->new( dir => '.' ); # builds its own Git
Takes git (an App::karr::Git instance), or dir to build one via App::karr::Git->new(dir => $dir) when no git is given. task_id and ttl are both optional -- see "task_id" and "ttl".
task_id
The task this lock instance was constructed for. Every method that names a lock ("ref_name", "legacy_ref_name", "get", "acquire", "release", "break_lock") takes an explicit $task_id and falls back to this only when none is given, so one App::karr::Lock can be reused across tasks by always passing $task_id explicitly -- as karr pick does, trying one candidate after another with a single lock object -- or dedicated to one task by setting this instead.
git
The App::karr::Git instance the lock reads and writes refs through. Set from the git argument to "new", or built there from dir when not given.
ttl
Seconds a lock may be held before "expired" considers it stale and "acquire" is allowed to take it over. Falls back to 300 (the DEFAULT_TTL constant) when not given at "new" -- but direct construction is the exception: karr pick builds its lock with the board's own lock_timeout config value instead (see "LOCK EXPIRY" in App::karr::Cmd::Pick), so that is what governs expiry in practice. A ttl of 0 or a negative number disables expiry outright: "expired" always answers false and no lock built with it is ever taken over.
ref_name
my $ref = $lock->ref_name(12); # 'refs/karr-local/tasks/12/lock'
my $ref = $lock->ref_name; # uses $lock->task_id
The current-layout ref name for a task's lock, under refs/karr-local/ -- outside every namespace karr pushes, fetches, prunes or snapshots (see "Locks are local, and live outside the board"). $task_id defaults to "task_id" when omitted.
legacy_ref_name
my $ref = $lock->legacy_ref_name(12); # 'refs/karr/tasks/12/lock'
The pre-#93 ref name for a task's lock, inside the board namespace karr pushes. Nothing in this module writes here any more; it exists so "locks" can find locks a pre-#93 karr, or a board that synced one in before the fix, left behind, and so "break_lock" can clear them. See "Locks are local, and live outside the board".
get
my $holder = $lock->get(12); # e.g. 'agent@example.com', or undef
The identity currently holding the lock on $task_id (defaulting to "task_id"), or undef if it is not held. Reads only the current-layout ref; a stray "legacy_ref_name" lock is not reported here, see "locks".
acquire
my ( $ok, $msg ) = $lock->acquire( 12, 'agent@example.com' );
Tries to take the lock on $task_id (defaulting to "task_id") for $email. Always returns a two-element list for its ordinary outcomes, rather than throwing:
(1, "acquired")-- taken, nobody held it.(1, "acquired (broke stale lock held by $prior)")-- taken over from a holder whose lock had passed its "ttl"; see "expired".(0, "locked by $current")-- held by somebody else and not expired. This is a final answer, not contention: the caller should treat it as "somebody else has this one" and try a different task, not retry.
The lock is not what makes a pick exclusive by itself -- see "DESCRIPTION" -- so losing the race here means trying a different task, not that a concurrent pick is unsafe.
Acquisition is a single compare-and-swap per attempt, retried automatically against Git ref contention ("retry_contended" in App::karr::Git). That retry loop, not this method, is what throws: if the ref stays contended across every retry -- many agents writing the board at once -- the die from "retry_contended" in App::karr::Git propagates uncaught. That is a distinct failure from "locked by somebody else" above and is not expected in ordinary use.
expired
my $stale = $lock->expired($oid);
Whether the lock commit $oid points at is older than "ttl". Takes the commit OID a lock ref currently resolves to, not a $task_id -- taken from "locks", or from the OID "acquire" reads before deciding whether to steal. Guarding a takeover against the exact OID whose age was judged is what keeps a holder that refreshes its lock mid-check from being evicted; see "acquire".
Returns false (never expired) when "ttl" is 0 or negative, and also when $oid's commit time cannot be read at all -- a missing timestamp is not evidence the holder is dead, and refusing to steal is the safe direction; karr unlock remains the way out.
locks
my @held = $lock->locks;
# [ { task_id => 12, owner => 'a@x', held_since => 1712345678,
# age => 40, expired => 0, legacy => 0 }, ... ]
Every lock currently held, across both "ref_name" and "legacy_ref_name" namespaces, sorted by task id and then current-before-legacy. Each entry is a hashref with task_id, owner, held_since (epoch seconds, or undef if unreadable), age (seconds, or undef to match), an expired flag (see "expired"), and a legacy flag marking a lock found at "legacy_ref_name" rather than "ref_name". Reporting only -- nothing here acts on what it finds; that is "break_lock".
break_lock
my ( $ok, $owner ) = $lock->break_lock(12);
Clears the lock on $task_id (defaulting to "task_id") unconditionally -- regardless of who holds it or whether "expired" says it is stale -- at both "ref_name" and "legacy_ref_name". Returns (1, $owner) naming whoever held it (the current-layout holder if both were set), or (0, "not locked") if neither ref existed. This is the escape hatch "release" deliberately is not: karr unlock is built on this, not on "release", because the whole problem it solves is a holder that is never coming back to release anything.
(1, $owner) means the lock is really gone. When a lock ref exists and refuses to be removed, this dies with the karr: could not delete ... message from "delete_ref" in App::karr::Git instead of reporting a break that did not happen (#119) -- an escape hatch that lies leaves the card locked for every other agent with nobody left to look at it.
release
my ( $ok, $msg ) = $lock->release( 12, 'agent@example.com' );
Gives back the lock on $task_id (defaulting to "task_id") held by $email. Like "acquire", returns a two-element list for its ordinary outcomes and only lets "retry_contended" in App::karr::Git's exhaustion die through:
(1, "released")-- released, or already gone (nothing held, already broken, or taken over after expiring). Not an error: release is normally the tail end of a pick that already finished its work.(0, "locked by $current")-- held by a different identity, left untouched.
The delete is itself a compare-and-swap against the holder read moments before, so a lock that was broken and re-taken between the read and the delete is not dropped out from under its new holder (#94).
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/Getty/karr/issues.
IRC
Join #langertha on irc.perl.org or message Getty directly.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHOR
Torsten Raudssus <getty@cpan.org>
COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.