Skip to content

Go client

monolock-go is the official Go client. One lock is one TCP connection. Do blocks until this process owns the named lock. Do then runs your function for exactly the time in which the server confirms ownership. When the function returns, Do releases the lock.

Terminal window
go get github.com/monolock-dev/monolock-go
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 client cancels the function’s context on a heartbeat confirmation timeout, an EOF, a connection error, a server shutdown, or a cancellation of Do’s own context. When the context is cancelled, the work that the lock guarded must stop. Call Do again to go into the queue for a new turn. There is no silent re-acquisition. A new turn is a new position at the back of the first-in, first-out (FIFO) queue.

Do makes exactly one attempt. A dial failure, a connection loss in the queue, or a server rejection returns as an error, and a new attempt is the decision of the caller. But Do blocks on the queue itself. A satisfactory connection in the WAITING state waits for the full time that the context permits.

The client releases the lock each time the function returns — or panics. There is no release call that you can forget. For the server, the closure of the connection is the release. Thus the server immediately promotes the next waiter and does not wait for the end of the lease.

Field Default Meaning
Address — host:port of the server, required
Dialer &net.Dialer{} each type that has DialContext: a *tls.Dialer connects to a server that operates with -tls-cert (its client certificate is the client’s identity for the access-control list (ACL) and the audit log of the server); a *net.Dialer adjusts dial timeouts, keep-alives, or the source address

The client calculates all other values from the lease that you give to Do. The client sends a heartbeat each lease/4, minus a safety margin of two times the smoothed round trip. The client does not send heartbeats more frequently than each 100ms. Silence that is longer than lease * 0.8 makes the client stop its claim. This rule is applicable in the ACQUIRE handshake and also while the client holds the lock. The client always stops before the server stops. See the heartbeat rules.

Do returns exactly the error that occurred. Errors are possible before the function runs. Do returns invalid input as the validation errors of the protocol (protocol.ErrEmptyName, protocol.ErrNameTooLong, protocol.ErrInvalidLease). Do returns a dial error or a connection error without a change. Do returns a server rejection as a *ServerError that contains the wire code and the reason. A ServerError unwraps to the canonical protocol error. Thus errors.Is(err, protocol.ErrShuttingDown) operates correctly. ServerError.Temporary tells you if a new attempt can help. It obeys the code ranges. Codes below 0x10 are server conditions (for example, a shutdown). Codes from 0x10 and above are client errors, and the same bytes cause the same error again.

DoRetry is Do with an included retry loop. Transient acquisition failures are a lost dial, a lost connection, or a temporary rejection. DoRetry tries these again with exponential backoff and jitter, from 100ms with a doubling to 5s, until the context is cancelled. Invalid input and permanent rejections return immediately. Each attempt is a new position at the back of the FIFO queue. After the function has run, DoRetry returns its result and does not run the function a second time. The question of a repetition of incomplete work is a property of the work, not of the lock.

After the function runs, Do returns the error of the function. Possibly the client lost the lock while the function ran. Then Do joins the cause into the result: ErrHeartbeatTimeout, ErrConnectionClosed, ErrProtocolViolation, or a *ServerError. This occurs also when the function returned nil, because the last actions of the function possibly ran without the lock. A graceful stop through Do’s own context is not a loss and joins nothing.

Give a *tls.Dialer to connect to a TLS-enabled server. With mutual TLS (mTLS), the client certificate is also the identity of the client:

c := monolock.New(monolock.Config{
Address: "locks.internal:7070",
Dialer: &tls.Dialer{Config: &tls.Config{
RootCAs: serverCAPool,
Certificates: []tls.Certificate{clientCert},
}},
})