distributedlocktest

package
v10.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 15, 2026 License: AGPL-3.0 Imports: 10 Imported by: 0

Documentation

Overview

Package distributedlocktest holds the behavior every distributedlock.Locker and distributedlock.ScopedLocker owes its callers, written once and run against each implementation.

Each provider used to carry its own copy of these cases, and the copies had already drifted apart. Only postgres asserted that two separate Lockers contend over one key, and that a Refresh after a Release is refused. Only memory asserted that Acquire rejects a negative TTL, or raced goroutines for one key — and it counted the winners without checking that the losers were told ErrLockNotAcquired rather than something else. Postgres reached its expiry cases by writing to the handle's expiry field behind a sqlmock, so nothing proved the real backend behaves that way. None of the three asserted that two distinct keys do not contend. On the scoped side, neither implementation asserted an empty key, and each asserted the panic case on the method the other did not.

Nothing was wrong with the providers — the gaps were in what each file happened to think of, and a gap in one file is invisible from the others. A behavior a caller is entitled to expect from the interface belongs here, where an implementation that disagrees fails rather than passes quietly.

Why it is exported

The suite is not internal, unlike routing/backends/internal/conformance, because the in-repo test doubles are the strongest reason to have it. distributedlock/memory stands in for a real lock across this repository and in consumers, and a double is a claim about the real thing: every suite that schedules against it inherits whatever it gets wrong. Running the same cases against the double and against redis and postgres is what keeps that claim honest. A consumer writing a fifth implementation runs Run against it and finds out whether it belongs, rather than discovering in production that the interface only ever said what compiles.

Using it

func TestLocker_Conformance(t *testing.T) {
	t.Parallel()

	distributedlocktest.Run(t, func(tb testing.TB) distributedlock.Locker {
		l, err := NewLocker()
		must.NoError(tb, err)
		tb.Cleanup(func() { must.NoError(tb, l.Close()) })

		return l
	}, distributedlocktest.WithInstanceLocalStore())
}

Each implementation keeps its own test file for what is genuinely its own — pool saturation and advisory-lock ids for postgres, a forged ownership token for redis, container wiring for both.

Declaring a deviation

The Options are how an implementation says where it stops honoring the full contract, and every one of them removes cases. They are deliberately shaped so that silence means the whole contract: a provider that needs one and does not declare it fails, rather than skipping something nobody notices. A declared deviation still runs as a skipped subtest naming the reason, so `go test -v` shows what was not proven instead of hiding it.

Real clocks, generous windows

The suite uses the wall clock. No provider's notion of now can be replaced from out here — postgres' is the server's, redis' is the server's — so the expiry cases acquire with a short TTL and then wait several times that before asserting the lock has lapsed. The windows are picked so that a loaded CI host cannot land between them, not so that the suite is fast.

What it deliberately does not pin

Close. The interface promises that outstanding handles "may become invalid" after it, which is not a behavior a caller can rely on and not one a suite can assert: postgres releases every outstanding advisory lock, redis closes the client and leaves the keys to expire on their own TTL, memory drops the map. Both are conformant with what the interface says, so each provider tests its own answer in its own file, and a caller that needs one of those answers is choosing a provider rather than an interface.

What it is not run against

distributedlock/noop, which arbitrates nothing by design: its Acquire always succeeds, its Release always reports success, and its TTL is never enforced. It is not an implementation that would fail this suite so much as one the suite has nothing to say about — mutual exclusion is the whole contract, and noop's own doc is explicit that it provides none of it. Running it here with enough deviations declared to pass would turn the suite into a shape check.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Run

func Run(t *testing.T, newLocker Factory, opts ...Option)

Run asserts every behavior a distributedlock.Locker owes its callers against the implementation newLocker builds, as one parallel subtest per behavior.

It takes a *testing.T rather than the testing.TB the factory takes because it runs subtests, which TB cannot: a failure has to name the behavior that broke, not just the provider.

func RunScoped

func RunScoped(t *testing.T, newScopedLocker ScopedFactory)

RunScoped asserts every behavior a distributedlock.ScopedLocker owes its callers against the implementation newScopedLocker builds.

The interface has two implementations that reach the same contract by opposite means — postgres waits in the database on a transaction-scoped advisory lock, the generic adapter polls Acquire — so the answers that matter here are the ones a caller writes code against and cannot see the mechanism behind: whether a contended TryWithLock is an error or a false, whether fn ran at all when it was, and whether the lock is free again after fn panics.

Types

type Factory

type Factory func(tb testing.TB) distributedlock.Locker

Factory builds one Locker for one subtest. It must hand back a fresh instance and register whatever teardown that instance needs on tb — the suite never closes what a factory returns, because what Close does is one of the things this package deliberately leaves to each implementation.

A backend whose store outlives the Locker value (a redis server, a postgres cluster) needs no cleaning between subtests: every key the suite touches carries a unique suffix, so one server can serve every subtest, every parallel run, and every rerun without collisions.

type Option

type Option func(*deviations)

Option declares where an implementation stops honoring the full Locker contract. Each one removes cases, so an implementation that declares nothing is held to all of it.

func WithAdvisoryTTL

func WithAdvisoryTTL() Option

WithAdvisoryTTL declares that this Locker's TTL is its own bookkeeping rather than something the store enforces: the holder stops owning the lock when the TTL lapses, but the key is not freed for anybody else until that holder releases it or its session dies.

The postgres provider is this one — advisory locks have no server-side expiry — and it is why the case where a second caller takes over a lapsed key does not run for it. Everything else about expiry still does: a holder past its TTL is told ErrLockNotHeld by Release and by Refresh, which is the half of the promise a client-side expiry can keep.

func WithInstanceLocalStore

func WithInstanceLocalStore() Option

WithInstanceLocalStore declares that this Locker keeps its locks inside the Locker value rather than somewhere a second holder could reach them, so mutual exclusion holds only among callers sharing one instance.

The memory provider is this one. It is a deployment constraint rather than a testing detail — a second replica does not contend with the first, and finds every key free — so declaring it here is the implementation saying out loud what its doc already says in prose.

type ScopedFactory

type ScopedFactory func(tb testing.TB) distributedlock.ScopedLocker

ScopedFactory builds one ScopedLocker for one subtest. As with Factory it hands back a fresh instance and registers whatever teardown that instance needs on tb.

Every case here contends one instance against itself, so a factory over an instance-local Locker needs no deviation declared: there is nothing this interface promises about two of them that the scoped surface can be asked for. Where that promise matters it belongs to the Locker underneath, and Run is where it is asserted.

Jump to

Keyboard shortcuts

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