lock

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Sep 13, 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/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.

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

Documentation

Overview

Package lock — range 0.2.21.* (ADR 0052 core/lock block).

Package lock — declares the sentinel *errs.Error port outcomes. Each var's name equals its errs.Define Reason in SCREAMING_SNAKE form.

Package lock — the lease a holder gets back, and the sibling interface a lease uses to say whether it can expire at all. Kept out of lock.go so the FROZEN Locker port stands alone in its own file.

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/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 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.

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.Define(CodeLockMisconfigured, "LOCK_MISCONFIGURED",
		"The lock cannot be built from the given configuration",
		"core/lock: a locker constructor received a TTL, a directory or a poll interval it cannot honour; the fields name the option and the value",
		errs.WithExitCode(exitConfig))

	// 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.Define(CodeLockNotHeld, "LOCK_NOT_HELD",
		"The lease is no longer held by this caller",
		"core/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")

	// 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.Define(CodeLockBackendFailed, "LOCK_BACKEND_FAILED",
		"The lock backend could not serve the operation",
		"core/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")

	// 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.Define(CodeLockNameRejected, "LOCK_NAME_REJECTED",
		"The lock name is not usable as given",
		"core/lock: Acquire or TryAcquire received an empty name or one the backend cannot represent; the fields name the offending part",
		errs.WithExitCode(exitDataErr))
)

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/lock.

Jump to

Keyboard shortcuts

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