monolock

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 10, 2026 License: MIT Imports: 11 Imported by: 0

README

monolock-go

CI latest release license

Go client for monolock, a lightweight TCP server for named locks — a distributed mutex without the distributed system.

One lock is one TCP connection. Do blocks until this process owns the named lock, runs your function for exactly as long as the server keeps confirming ownership, and releases the lock on the way out.

Install

go get github.com/monolock-dev/monolock-go

Use

import monolock "github.com/monolock-dev/monolock-go"

c := monolock.New(monolock.Config{Address: "127.0.0.1:7070"})

// Blocks until this process owns "nightly-import", runs the function, and
// releases the lock when it returns. The lease is this client's
// failure-detection window: how long the lock may sit behind a dead holder
// before moving on.
err := c.Do(ctx, "nightly-import", 2*time.Second,
    func(ctx context.Context, token uint64) error {
        // token is the fencing token of this grant. Hand it to the resource
        // the lock guards with every write — a conditional update, a CAS —
        // and have the resource reject anything with a smaller token than
        // the largest it has seen. That fences out a previous holder that
        // woke up after losing the lock.
        //
        // ctx is cancelled the moment ownership stops being confirmed.
        for {
            select {
            case <-ctx.Done():
                return nil // the lock is gone; Do reports why
            case job := <-jobs:
                if err := process(ctx, job, token); err != nil {
                    return err
                }
            }
        }
    })

The function's context is cancelled on heartbeat confirmation timeout, EOF, connection error, server shutdown, or cancellation of Do's own context. Once it is cancelled the work it guarded must stop; call Do again to queue up for a new turn — there is no silent re-acquisition, and a new turn means a new position at the back of the FIFO queue.

Cancelling Do's own context is a graceful stop, not a loss. While still queueing it aborts at once; once the function is running it only asks the function to stop — the lock stays held, heartbeats and all, until the function winds down and returns, so a routine shutdown never hands the lock over while the work is still finishing. The safety net for a holder that cannot wind down is the lease itself: kill the process and the lock moves on within it.

Do makes exactly one attempt: a dial failure, a connection dropped while still queueing, or a server rejection comes back as an error, and retrying is the caller's decision. What Do does block on is the queue itself — a healthy connection in the WAITING state waits as long as the context allows.

The lock is released whenever the function returns — or panics — with no release call to forget. Closing the connection is the release as far as the server is concerned, so the next waiter is promoted at once instead of waiting out the lease.

Configuration

Field Default Meaning
Address host:port of the server, required
Dialer &net.Dialer{} anything with DialContext: a *tls.Dialer reaches a server running with -tls-cert (its client certificate is the client's identity for the server's ACL and audit log), a *net.Dialer tunes dial timeouts, keep-alives or the source address

Everything else is derived from the lease passed to Do: heartbeats go out every lease/4 minus a safety margin of twice the smoothed round trip, never more often than every 100ms, and silence longer than lease * 0.8 — whether during the ACQUIRE handshake or while holding the lock — makes the client give up, always before the server would. See the protocol notes.

Errors

Do returns exactly what happened. Before the function ever runs: invalid input as the protocol's own validation errors (protocol.ErrEmptyName, protocol.ErrNameTooLong, protocol.ErrInvalidLease), a dial or connection error as is, and a server rejection as a *ServerError carrying the wire code and reason. A ServerError unwraps to the canonical protocol error, so errors.Is(err, protocol.ErrShuttingDown) works. ServerError.Temporary reports whether a new attempt may help — codes below 0x10 are server conditions (e.g. shutting down), codes from 0x10 up are client errors the same bytes would hit again.

DoRetry is Do with the retry loop built in: transient acquisition failures — a dropped dial or connection, a temporary rejection — are retried with exponential backoff and jitter, from 100ms doubling to 5s, until the context is cancelled, while invalid input and permanent rejections return at once. Every attempt is a fresh position at the back of the FIFO queue. Once the function has run, its outcome is returned with no second run: whether half-done work may be repeated is a property of the work, not of the lock.

Once the function runs, Do returns its error. If the lock was lost while the function was still running, the cause — ErrHeartbeatTimeout, ErrConnectionClosed, ErrProtocolViolation, or a *ServerError — is joined in even when the function returned nil, because its last actions may have run without the lock. A graceful stop through Do's own context is not a loss and joins nothing.

Tests

go test -race ./...

Unit tests speak the wire protocol through hand written peers and need nothing but Go. The end-to-end tests in integration_test.go run against a real server in a container; they need Docker and are skipped without it, or with -short. Without MONOLOCK_TEST_IMAGE they pull ghcr.io/monolock-dev/monolock:latest; to test against a locally built server:

docker build -t monolock:dev ../monolock
MONOLOCK_TEST_IMAGE=monolock:dev go test -race ./...

Documentation

Overview

Package monolock is the Go client for a monolock server.

One lock is one TCP connection. Do blocks until this process owns the named lock, runs the given function for exactly as long as ownership is confirmed by the server, and releases the lock on the way out. When the function's context is cancelled the work it guards must stop; a new Do call queues up for a fresh turn.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrHeartbeatTimeout means no WAITING or ACQUIRED arrived before the
	// client side confirmation deadline. The lock must be treated as lost.
	ErrHeartbeatTimeout = errors.New("monolock: heartbeat confirmation timeout")

	// ErrProtocolViolation means the server sent something illegal for the
	// current state, e.g. WAITING to a session that already owns the lock.
	ErrProtocolViolation = errors.New("monolock: protocol violation")

	// ErrConnectionClosed means the server closed the connection.
	ErrConnectionClosed = errors.New("monolock: connection closed")
)

Client-side conditions: states the client diagnoses on its own, with no ERROR code on the wire. Everything the server itself reports arrives as a *ServerError instead, and Acquire's input validation returns the protocol's own errors (protocol.ErrEmptyName, protocol.ErrNameTooLong, protocol.ErrInvalidLease).

Functions

This section is empty.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client dials a monolock server. It is nothing but configuration: safe for concurrent use, holding several different locks at once, one connection each, with nothing to close. Each lock lives exactly as long as its Do call.

func New

func New(cfg Config) *Client

New returns a client for cfg. It does not connect; connections are made per Do call, so a bad Address surfaces as a dial error there.

func (*Client) Do

func (c *Client) Do(ctx context.Context, lockName string, lease time.Duration, fn func(ctx context.Context, token uint64) error) error

Do claims lockName, blocks until this client owns it, runs fn while the server keeps confirming ownership, and releases the lock on the way out — also when fn panics. Closing the connection is the release, so the next waiter is promoted at once.

fn receives the lock context and the fencing token of the grant. The context is cancelled on heartbeat confirmation timeout, EOF, connection error, server shutdown, or cancellation of ctx; fn must stop the work it guards the moment that happens. The token grows with every grant the server hands out: pass it along with every operation against the resource the lock guards and have the resource reject anything carrying a smaller token than the largest it has seen — that fences out a previous holder that woke up after losing the lock.

lease is this connection's failure-detection window: the server drops the session once it stays silent for that long, so it bounds how long the lock may sit behind a dead holder. Pick the smallest value that survives the network pauses between you and the server. It must be positive and fit the protocol's uint32 millisecond field.

Cancelling ctx is a graceful stop, not a loss. While still queueing, Do aborts and returns ctx.Err() at once. Once fn is running, the cancellation only cancels fn's context — the lock stays held, heartbeats and all, until fn winds down and returns, so a routine shutdown never hands the lock over while the work is still finishing; Do then releases and returns fn's error alone. The safety net for a holder that cannot wind down is the lease itself: kill the process and the lock moves on within it.

Do makes exactly one attempt. Invalid input comes back as the protocol's own validation errors (protocol.ErrEmptyName, protocol.ErrNameTooLong, protocol.ErrInvalidLease); a dial failure, a connection dropped while waiting in the queue, or a server rejection is returned as is, and a *ServerError reports through Temporary whether a new attempt may help. Retrying — and pacing the retries — is the caller's decision; every attempt is a fresh claim and therefore a fresh position at the back of the FIFO queue.

Once fn runs, Do returns fn's error. If the lock was lost while fn was still running, the cause (ErrHeartbeatTimeout, ErrConnectionClosed, a *ServerError, ...) is joined in even when fn returned nil, because fn's last actions may have run without the lock.

func (*Client) DoRetry

func (c *Client) DoRetry(ctx context.Context, lockName string, lease time.Duration, fn func(ctx context.Context, token uint64) error) error

DoRetry is Do with the acquisition retried. A failure to get the lock that may pass on a new attempt — a dropped dial or connection, the server shutting down, any rejection Temporary says yes to — is retried with exponential backoff and jitter, from 100ms doubling to 5s, until ctx is cancelled. Invalid input and permanent rejections return at once. Every attempt is a fresh claim and therefore a fresh position at the back of the FIFO queue.

Once fn has run, its outcome is returned exactly as Do would, with no second run: whether half-done work may be repeated is a property of the work, not of the lock, so re-running fn stays the caller's decision.

type Config

type Config struct {
	// Address is the "host:port" of the monolock server. Required.
	Address string
	// Dialer establishes connections, one per Do, and is the place to
	// configure how they are made: a *tls.Dialer is how a server running
	// with -tls-cert is reached (its client certificate is this client's
	// identity for the server's ACL and audit log), a *net.Dialer tunes
	// dial timeouts, keep-alives or the source address. Nil means a plain
	// &net.Dialer{}, whose dial is bounded only by the Do context.
	Dialer ContextDialer
}

Config configures a Client.

type ContextDialer

type ContextDialer interface {
	DialContext(ctx context.Context, network, address string) (net.Conn, error)
}

ContextDialer establishes the client's connections; *net.Dialer and *tls.Dialer both satisfy it.

type ServerError

type ServerError struct {
	Code   uint8
	Reason string
}

ServerError is an ERROR message received from the server. Reason is the human-readable text the server sent, possibly empty. Branch on Code, or match the canonical protocol errors: errors.Is(err, protocol.ErrShuttingDown).

func (*ServerError) Error

func (e *ServerError) Error() string

func (*ServerError) Temporary

func (e *ServerError) Temporary() bool

Temporary reports whether a new Acquire may succeed. The classification is the protocol's own: for a known code it is the canonical error's answer, and a code this client does not know yet is read by the same range rule — below 0x10 a server condition worth retrying, from 0x10 up a client error the same bytes would hit again.

func (*ServerError) Unwrap

func (e *ServerError) Unwrap() error

Unwrap ties the received code back to its canonical protocol error, so errors.Is(err, protocol.ErrShuttingDown) works. A code this version of the protocol does not know unwraps to nothing.

Jump to

Keyboard shortcuts

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