lock

package
v0.26.21 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 25, 2026 License: MIT Imports: 3 Imported by: 0

README

lock (core)

The mutual-exclusion domain contract: Locker (Acquire / TryAcquire), the Lease it returns (Fence / Extend / Release), and the Deadliner sibling a lease implements only when it CAN expire.

var locker corelock.Locker // built in internal/service/app/lock

lease, err := locker.Acquire(ctx, "rebuild:index")
defer lease.Release(ctx)

if d, ok := lease.(corelock.Deadliner); ok {
    // this lease can be taken from you at d.Deadline(); renew or fence
}

Locker is frozen at two methods and Lease at three (ADR 0039) — new capabilities arrive as siblings. Neither takes a TTL: a lease lifetime is configured where the locker is built, and a non-positive one is refused there rather than read as "already expired" (ADR 0031).

Release releases only a lock this holder still holds. A lease that was taken over after lapsing releases nothing and reports LOCK_NOT_HELD, because unlocking a section another holder is inside is worse than leaking the lock.

Fence() is a per-name, strictly increasing token — but the SDK can only issue it. Unless the protected resource compares it and refuses the lower one, mutual exclusion is not guaranteed against a stalled holder.

The three interfaces are generated from design/app/lock.yaml into design_gen.go (ADR 0163): a port changes in the design, then kit gen.

Concrete lockers: internal/service/app/lock. Public facade: pkg/v1/app/lock. ADR 0052. See CLAUDE.md.

Documentation

Overview

Package lock — ranges 0.2.21.* (ADR 0052 core/app/lock block) and 0.3.51.* (ADR 0052 service/app/lock block, declared here since ADR 0160).

Package lock — declares the sentinel *errs.Error outcomes of the domain: the port's, and those the concrete lockers emit. Each var's name equals its errs.Define Reason in SCREAMING_SNAKE form.

Package lock declares the SDK's mutual-exclusion DOMAIN: the Locker port that hands out named exclusive leases, the Lease a holder gets back, and the sibling interface through which a lease says whether it can expire at all. A core sibling admitted by ADR 0052.

A lock that lies is worse than no lock

Every decision in this package follows from one observation: a caller who believes they hold a critical section behaves differently from a caller who knows they do not. A lock that reports success it cannot back removes the second behaviour and leaves nothing in its place, so the failure is silent, unreproducible, and discovered as data corruption rather than as an error. ADR 0031's refuse-rather-than-default rule is therefore not a style choice here: a lease TTL of zero is refused AT CONSTRUCTION rather than read as "expires immediately", because a lock that expires the instant it is granted is exactly the shape of a lock that lies.

Expiring a lease does not stop its old holder

This is the trap the domain is built around, and it is worth stating in full. Suppose A holds a lease with a five-second TTL, and A's goroutine stalls — a long GC pause, a blocked syscall, a descheduled thread. Five seconds pass. B acquires the same lock, correctly, because the lease has expired. A resumes. A has no idea anything happened: nothing interrupted it, nothing told it, and its next write lands inside a section B believes it owns alone.

No lock service anywhere fixes this by itself, because the lock is not holding A — A's own belief is. Three things follow, and this domain decides all three rather than leaving them emergent:

  1. OWNERSHIP IS TOKEN-CHECKED. A Lease identifies its holder, and Lease.Release releases only a lock this holder still holds. A lease that was taken over releases NOTHING and reports LockNotHeld — because the alternative, "release whatever is under that name", is A politely unlocking B's critical section.
  2. A LEASE CAN BE RENEWED. Lease.Extend pushes the deadline out for work that outlives its TTL. Without it, a TTL is a bet that the work finishes in time, and the losing side of that bet is silent. A FAILED Extend is not a warning: it is the holder being told it has already lost the lock, and the only correct response is to stop.
  3. A FENCING TOKEN IS ISSUED. Lease.Fence returns a uint64 that strictly increases with every acquisition of that name. Handed to the protected resource and compared there, it turns the scenario above from silent corruption into a rejected write: A carries fence 7, B carries 8, the resource has seen 8, so A's write is refused.

What this domain does NOT guarantee

Issuing a fencing token is not the same as enforcing one. The SDK can only hand the number to the caller; the RESOURCE has to compare it and refuse the lower one, and most resources — a file, an HTTP endpoint, a table without a version column — cannot. Where the fence is not checked, mutual exclusion is not guaranteed against a stalled holder: a long GC pause, a SIGSTOP, or a descheduled thread can put two holders inside one section, and neither will observe it.

That is a limitation, stated here rather than buried, because a reader who assumes otherwise will build on a guarantee that does not exist. If the protected resource cannot check a fence, do not rely on a lease TTL for correctness — use a backend whose lock does not expire at all (internal/service/app/lock's file store is one; see its CLAUDE.md) and accept a hung holder blocking its waiters, which is a liveness failure you can see instead of a safety failure you cannot.

There is no registry

Like proc (ADR 0016), resilience (ADR 0026), net (ADR 0029), scheduler (ADR 0041), token (ADR 0042), session (ADR 0045), validation (ADR 0046) and cache (ADR 0049), this domain has no name-to-implementation registry.

The reason is specific to locking and it is not taste. The two backends differ in the one property a caller must know — whether a lease can be taken away from a live holder — and resolving one of them from a configuration string would let a typo swap a lock that never expires for a lock that does, silently, with no failure at the moment of the swap. Every call would still succeed. The difference would surface only as the rarest class of production bug there is.

Distributed locks are out of scope

A lock over Redis, etcd, ZooKeeper or Consul is a connector to a third-party system and belongs under third-party/, not here. This domain covers exactly two scopes: one process (goroutines) and one machine (processes sharing a filesystem). It never implies a third.

Index

Constants

View Source
const CodeLockBackendFailed errs.Code = 0x00_02_15_03 // 0.2.21.3

CodeLockBackendFailed identifies a locker that could not answer at all — the medium, the filesystem, the process it depends on.

A lock simply being HELD is not this: TryAcquire reports that as (nil, false, nil), because a caller that offered to give up immediately has had its offer accepted and nothing went wrong.

View Source
const CodeLockDirectoryUnsafe errs.Code = 0x00_03_33_02 // 0.3.51.2

CodeLockDirectoryUnsafe identifies a lock directory that is world-writable without the sticky bit.

The exposure is not the counter — it is the inode. Anyone able to unlink the lock file can replace it with a fresh one, after which a new process flocks the NEW inode while the old holder still flocks the old one, and the two hold "the same" lock simultaneously. The sticky bit is what makes a shared directory such as /tmp safe, so it is accepted; its absence is not.

View Source
const CodeLockFenceCorrupt errs.Code = 0x00_03_33_01 // 0.3.51.1

CodeLockFenceCorrupt identifies a fence ledger no further token can be issued from: its contents are not a decimal counter (`condition=unparseable`) or they are the one counter with no successor (`condition=exhausted`).

It is refused rather than repaired. "Repairing" it means restarting the counter, and a restarted fencing token is not a fencing token at all: the numbers it issues have already been seen by the protected resource, so the resource accepts a stale holder's write believing it to be current. A lock that cannot prove monotonicity says so instead of issuing numbers that look fine.

The two conditions share a code because they share that remedy exactly: stop, and have a human look at the ledger. Splitting them would ask every caller to learn a second code for the same instruction.

View Source
const CodeLockFileReplaced errs.Code = 0x00_03_33_05 // 0.3.51.5

CodeLockFileReplaced identifies a held lock whose FILE is no longer the one its name leads to: the entry was unlinked, or replaced by a different file, while this holder still had it open and locked.

The exclusion is gone at that moment, silently. The holder's flock still covers an inode nothing names, so the next process to acquire creates a fresh file, locks THAT, reads a fence ledger that starts again at zero, and is told it holds the lock — while the first holder is still inside the section believing the same thing. Measured: two holders, two inodes, both reporting fence 1.

It is separate from CodeLockDirectoryUnsafe because it is a different question asked at a different time. That code refuses a directory at construction on its mode; this one reports a lock that WAS taken and is no longer exclusive, in a directory whose mode the rule accepts — 0777|sticky, which is what /tmp is, lets the entry's own owner unlink it.

It is reported and never repaired. Re-acquiring would hand the caller a second lease over the new file while the old one is still locked, which is the split-brain spelled deliberately.

View Source
const CodeLockKeepaliveLost errs.Code = 0x00_03_33_03 // 0.3.51.3

CodeLockKeepaliveLost identifies a background renewal that failed.

It marks the point at which the caller stopped holding the lock while still running inside the section it protects. It is carried as a context CAUSE rather than a return value, because the whole purpose of a keepalive is to tell work that is already in progress — and the only channel that reaches in-progress work is its context.

View Source
const CodeLockMisconfigured errs.Code = 0x00_02_15_01 // 0.2.21.1

CodeLockMisconfigured identifies a locker refused AT CONSTRUCTION because its configuration cannot be honoured — most importantly a lease TTL that is not positive.

A zero TTL is refused rather than read as "expires immediately" or as "never expires". Both readings are defensible and they are opposites, so whichever the SDK picked would be wrong for half its callers, and wrong SILENTLY: a lock granted with an already-elapsed lease reports success on every call and excludes nobody. This is ADR 0031's refuse half at its sharpest.

View Source
const CodeLockNameRejected errs.Code = 0x00_02_15_04 // 0.2.21.4

CodeLockNameRejected identifies a lock name a locker refuses: the empty string, or one longer than the backend can represent.

An empty name is refused rather than accepted as a legitimate key, because it is what an uninitialised variable produces: accepting it would silently serialise every unrelated caller in the program through one lock, which looks like a mysterious performance collapse and never like a bug.

View Source
const CodeLockNotHeld errs.Code = 0x00_02_15_02 // 0.2.21.2

CodeLockNotHeld identifies an Extend or a Release from a holder that no longer owns the lock — its lease expired and someone else took it.

It is never a cosmetic outcome. On Extend it means the caller is inside a section another holder also believes it owns. On Release it means the call released nothing, deliberately: releasing under a name that now belongs to someone else would unlock THEIR critical section.

View Source
const CodeLockPathRedirected errs.Code = 0x00_03_33_04 // 0.3.51.4

CodeLockPathRedirected identifies a lock path that is not a file but an indirection to one: a symbolic link on Unix, a reparse point on Windows.

The lock filename is the SHA-256 of the lock name, which makes it derived from no caller-supplied string, and in the same stroke PREDICTABLE — and predictable is what the attack needs. An indirection planted there sends the flock and the fencing ledger to a file the attacker chose, so the victim's lock and the attacker's own lock cover different inodes while both report success: two processes inside one section, neither blocked, nothing logged. It also hands the attacker the fencing token, since the ledger the victim increments is the attacker's file.

It is NOT the core's LOCK_BACKEND_FAILED. Nothing failed — a deliberate substitution succeeded, and reading it as a medium fault invites the one response that is wrong here, a retry. It is its own code for the same reason CodeLockDirectoryUnsafe is: the remedy is a human looking at the directory.

Variables

View Source
var (
	// LockMisconfigured is returned by a locker CONSTRUCTOR whose
	// configuration it cannot honour.
	//
	// The load-bearing case is a non-positive lease TTL, and it is refused
	// rather than defaulted for a reason that does not apply to most policies:
	// the two natural readings of a zero TTL — "already expired" and "never
	// expires" — are exact opposites, and a locker built on the first one
	// still answers every call successfully while excluding nobody at all.
	// Nothing downstream can tell that apart from a lock nobody contends.
	//
	// Refusing at CONSTRUCTION rather than at the first Acquire is the other
	// half: it puts the failure where the program is wired, in the process's
	// first second, instead of in the middle of the first contended section.
	LockMisconfigured = errs.Static(CodeLockMisconfigured, "LOCK_MISCONFIGURED",
		"The lock cannot be built from the given configuration",
		"core/app/lock: a locker constructor received a TTL, a directory or a poll interval it cannot honour; the fields name the option and the value",
		0,
		78)

	// LockNotHeld is returned by Extend or Release when the caller's lease has
	// been taken over by another holder.
	//
	// From Extend it is the single most important error this domain can
	// produce: it is the SDK telling a caller that it is executing inside a
	// critical section someone else also believes they own. It must abort the
	// protected work, not log and continue — the lock cannot be re-taken
	// "quickly" to fix it, because the other holder is already inside.
	//
	// From Release it means the call deliberately released NOTHING. The
	// alternative — unlocking whatever currently sits under that name — would
	// have this caller end another caller's critical section on its way out.
	LockNotHeld = errs.Static(CodeLockNotHeld, "LOCK_NOT_HELD",
		"The lease is no longer held by this caller",
		"core/app/lock: Extend or Release ran against a lease whose lock has since been acquired by another holder; the fields name the lock and the fencing tokens involved",
		0,
		0)

	// LockBackendFailed is returned when the locker itself could not serve the
	// operation. A lock being HELD is not this — TryAcquire reports that as
	// (nil, false, nil).
	LockBackendFailed = errs.Static(CodeLockBackendFailed, "LOCK_BACKEND_FAILED",
		"The lock backend could not serve the operation",
		"core/app/lock: a locker failed for a reason of its own rather than reporting the lock as held; the fields name the operation and the lock",
		0,
		0)

	// LockNameRejected is returned for a name the locker refuses.
	//
	// The empty string is the case that matters, and it is refused because it
	// is what an uninitialised variable holds. Accepting it would funnel every
	// caller that forgot to set a name through one shared lock — correct-looking,
	// silent, and indistinguishable from contention nobody can explain.
	LockNameRejected = errs.Static(CodeLockNameRejected, "LOCK_NAME_REJECTED",
		"The lock name is not usable as given",
		"core/app/lock: Acquire or TryAcquire received an empty name or one the backend cannot represent; the fields name the offending part",
		0,
		65)

	// LockFenceCorrupt is returned when the on-disk fence ledger cannot yield
	// the next token: it does not read as a decimal counter, or it reads as
	// the one counter that has no successor.
	//
	// Nothing is repaired and no lease is granted. Restarting the counter
	// would hand out numbers the protected resource has already accepted,
	// which turns the one mechanism that survives a stalled holder into a
	// mechanism that endorses one. A counter allowed to wrap does the same
	// thing, silently and in order.
	LockFenceCorrupt = errs.Static(CodeLockFenceCorrupt, "LOCK_FENCE_CORRUPT",
		"The lock's fencing ledger is not readable",
		"service/app/lock: the lock file's contents are not a decimal fencing counter, or are the one counter that cannot be advanced without wrapping; the fields name the path, the offending bytes' length and which of the two conditions it is",
		0,
		65)

	// LockDirectoryUnsafe is returned by NewFileLocker for a directory that is
	// world-writable without the sticky bit.
	//
	// Such a directory lets any account unlink the lock file and create a new
	// one in its place. The next process flocks a different inode from the one
	// the current holder is flocking, both are told they hold the lock, and
	// neither can observe the other — the exact failure this domain exists to
	// prevent, reachable without any race at all.
	LockDirectoryUnsafe = errs.Static(CodeLockDirectoryUnsafe, "LOCK_DIRECTORY_UNSAFE",
		"The lock directory's permissions do not protect the lock files",
		"service/app/lock: the directory is world-writable and not sticky, so its lock files can be replaced by any account; the fields name the path and the mode",
		0,
		78)

	// LockKeepaliveLost is the context CAUSE published by the Keepalive of
	// internal/service/app/lock when a renewal fails.
	//
	// It is deliberately not a return value. A keepalive exists to inform work
	// that has ALREADY STARTED, and the only channel that reaches such work is
	// its context — so the loss arrives as a cancellation the protected
	// operation is already selecting on, and `context.Cause` says why.
	LockKeepaliveLost = errs.Static(CodeLockKeepaliveLost, "LOCK_KEEPALIVE_LOST",
		"The lease could not be renewed and is no longer held",
		"service/app/lock: a background Extend failed, so the derived context was cancelled with this cause; the fields name the lock and the fencing token that was lost",
		0,
		0)

	// LockPathRedirected is returned by the file locker when the lock path is
	// an indirection rather than a file: a symbolic link on Unix, a reparse
	// point on Windows.
	//
	// The substitution merges two locks into one or splits one into two, with
	// no error on any path: the victim's lock lands on the attacker's target,
	// the attacker's own lock lands on the real name, and both processes are
	// told they hold it. The fencing token goes with it — the ledger the
	// victim increments is the attacker's file, so the number handed to the
	// protected resource is a number the attacker chose.
	//
	// The directory rule does not reach this. [LockDirectoryUnsafe] refuses a
	// directory in which an existing entry can be UNLINKED by anyone; this
	// attack creates an entry at a name nobody has taken yet, which the sticky
	// bit permits and 0777|sticky — what /tmp is — explicitly accepts.
	LockPathRedirected = errs.Static(CodeLockPathRedirected, "LOCK_PATH_REDIRECTED",
		"The lock path is a link rather than a file",
		"service/app/lock: a symbolic link or reparse point occupies the lock file's path, so the lock and its fencing ledger would land on a file chosen by whoever planted it; the fields name the path, the kind of indirection and what the kernel reported",
		0,
		78)

	// LockFileReplaced is returned when the file a lease holds is no longer
	// the file its name leads to.
	//
	// It is the one exposure of the file locker that is DETECTED rather than
	// prevented, and the doc comment on internal/service/app/lock's identity.go
	// says so at length. In a world-writable sticky directory the account that
	// created the lock file owns that entry, so it may unlink it — including
	// while another process holds the lock. No flag on the open stops that:
	// the descriptor outlives the name by design, on every kernel.
	//
	// What ships instead is that the holder finds out. Acquire refuses a lock
	// whose file was swapped between the open and the flock, and Extend
	// refuses once the swap has happened — so a Keepalive cancels the
	// protected work's context with this cause rather than letting it run on
	// inside a section it no longer owns.
	LockFileReplaced = errs.Static(CodeLockFileReplaced, "LOCK_FILE_REPLACED",
		"The lock file is no longer the file its name leads to",
		"service/app/lock: the lock file this lease holds was unlinked or replaced while it was held, so the next acquisition locks a different inode and a fresh fencing ledger; the fields name the path, the lock, the fencing token and which of the two conditions was observed",
		0,
		78)
)

Functions

This section is empty.

Types

type Deadliner

type Deadliner interface {
	// Deadline returns the instant at which this lease stops being held
	// unless it is renewed first. It moves forward on every successful
	// [Lease.Extend].
	Deadline() time.Time
}

Deadliner is the sibling interface (ADR 0039) implemented by a Lease that CAN expire. Reach it by type assertion:

if d, ok := lease.(lock.Deadliner); ok {
    // this lease has a deadline, and can be taken from us at it
}

The assertion is the answer to the only question that changes how a caller must be written. A lease that implements Deadliner can be taken away from a live holder, so its work must either finish before the deadline, renew, or carry the fence through to the resource. A lease that does NOT implement it cannot be taken away at all, so none of that is needed — and a caller can tell which world it is in without naming an implementation.

This is deliberately not a Deadline method on Lease returning a zero time for "never". A zero time.Time is a value someone will compare against time.Now and lose to; an absent interface is not.

type Lease

type Lease interface {
	// Fence returns this acquisition's fencing token: a uint64 that strictly
	// increases with every successful acquisition of the same name, whoever
	// acquires it.
	//
	// It is the only mechanism in this domain that survives a stalled holder,
	// and it only works if the PROTECTED RESOURCE compares it and refuses the
	// lower one. Handing it to a resource that ignores it buys nothing; see
	// the package comment for what is and is not guaranteed as a result.
	//
	// Fence never returns 0 for a live lease. Zero is the "no acquisition"
	// value, so a caller that forgets to plumb the token through cannot have
	// it silently compare equal to a legitimate one.
	Fence() uint64
	// Extend renews the lease for another full lifetime, starting now.
	//
	// It returns [LockNotHeld] when this holder no longer owns the lock —
	// which is not a warning to log and continue past. It means another holder
	// is, or soon will be, inside the section this caller believes it owns.
	// The only correct response is to stop the protected work.
	//
	// On an implementation whose leases cannot expire, Extend re-asserts
	// ownership and succeeds; it is never a silent no-op that hides a lost
	// lock, because there is no way for such a lock to be lost while held.
	Extend(ctx context.Context) error
	// Release gives the lock up. It releases ONLY a lock this holder still
	// holds: a lease that was taken over after expiring releases nothing and
	// reports [LockNotHeld], because unlocking a section another holder is
	// inside would be worse than leaking the lock.
	//
	// Releasing a lease this holder already released is not an error — the
	// caller's intent is satisfied — so `defer lease.Release(ctx)` next to an
	// explicit release is safe.
	Release(ctx context.Context) error
}

Lease is a held lock. It is what Locker.Acquire returns and the ONLY handle through which the lock can be renewed or released.

The method set is FROZEN at three under ADR 0039, for the same reason Locker is: pkg/v1 aliases it, Go interfaces are structural, and a fourth method would break every downstream double at compile time. Deadliner is the sibling that ships.

A Lease is safe for concurrent use, but it is not a value to pass around casually: it carries the holder's identity, and whoever holds it can release the lock.

type Locker

type Locker interface {
	// Acquire blocks until the named lock is held by this caller or ctx ends.
	// On success it returns a [Lease] that MUST eventually be released.
	//
	// A cancelled or expired ctx returns the context's own error, not an SDK
	// sentinel: the caller supplied the deadline and already knows what it
	// means. An empty name is refused with [LockNameRejected].
	Acquire(ctx context.Context, name string) (Lease, error)
	// TryAcquire attempts the acquisition once and never waits for another
	// holder. It reports (lease, true, nil) on success and (nil, false, nil)
	// when the lock is held elsewhere.
	//
	// "Held elsewhere" is NOT an error: a caller that offered to give up
	// immediately has had its offer accepted, and nothing went wrong. An error
	// return means the backend could not answer the question at all.
	TryAcquire(ctx context.Context, name string) (Lease, bool, error)
}

Locker hands out named, exclusive leases. Implementations MUST be safe for concurrent use by multiple goroutines.

The method set is deliberately two operations wide and is FROZEN: pkg/v1 aliases this interface, so under ADR 0039 a third method would break every downstream implementer at compile time with no deprecation window. New capabilities arrive as SIBLING interfaces discovered by type assertion — Deadliner on the lease is the one that ships, and the model is codec's Appender (ADR 0037).

There is no TTL parameter on either method, and its absence is the design. A lease lifetime belongs to the WORK being protected, which is known where a Locker is wired, not at the call site — and a per-call duration would create exactly one place where a zero can be typed by accident, in a hot path, under time pressure, and be read as a lock that has already expired. The TTL lives in the implementation's configuration and is refused there if it is not positive (ADR 0031, ADR 0052).

IFACE-PLUGIN: the concrete lockers stay unexported behind their constructors in internal/service/app/lock.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL