2
\$\begingroup\$

I was reading about the implementation of distributed locks where we need to verify the lease using a fencing token as per this article - https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

I tried to implement a library in Golang which can run as a standalone Golang application. Clients open a TCP connection with this Golang application and issue commands to create a distributed lock and get a fencing token, verify lease on a lock and release a lock. Now, the code for accepting concurrent TCP connections is still in progress, I wanted to check whether the following logic for implementing a simple distributed lock using atomic counters for fencing token would work in a simple scenario?

It works when I try to test it using concurrent calls, but wanted to make sure there are no bugs.

package distributedlock

import (
    "errors"
    "fmt"
    "math"
    "runtime"
    "sync"
    "sync/atomic"
    "time"

    "github.com/pratyush-prateek/distributed-lock-go/utils"
)

const LockState_LOCKED = 1 << 1
const LockState_UNLOCKED = 1 << 2
const FencingToken_MAX = math.MaxUint32

type DistributedLock struct {
    State               uint32
    CurrentFencingToken uint32
    Name                string
}

type DistributedLocker struct {
    lockerGuard *sync.RWMutex
    Locks       map[string]DistributedLock
}

type Locker interface {
    CreateLock(name string, clientId string) error
    AcquireLock(name string, timeInSeconds int, clientId string) (uint32, error)
    VerifyLease(name string, leaseToken uint32, clientId string) (bool, error)
    ReleaseLock(name string, leaseToken uint32, clientId string) (bool, error)
}

// Create a distributed lock
func (distributedLocker *DistributedLocker) CreateLock(name string, clientId string) error {
    err := utils.VerifyStringEmpty(clientId, "clientId")

    if err != nil {
        return err
    }

    distributedLocker.lockerGuard.Lock()
    defer distributedLocker.lockerGuard.Unlock()

    if _, exists := distributedLocker.Locks[name]; exists {
        fmt.Printf("lock %v already created by client %v\n", name, clientId)
        return nil
    }

    distributedLocker.Locks[name] = DistributedLock{
        State:               uint32(LockState_UNLOCKED),
        CurrentFencingToken: uint32(0),
        Name:                name,
    }
    fmt.Printf("new lock %v created by client %v\n", name, clientId)
    return nil
}

// Acquire a distributed lock
func (distributedLocker *DistributedLocker) AcquireLock(name string, timeInSeconds int, clientId string) (uint32, error) {
    err := utils.VerifyStringEmpty(clientId, "clientId")

    if err != nil {
        return uint32(0), err
    }

    if timeInSeconds <= 0 {
        return uint32(0), errors.New("non zero value of lock expiry time is expected")
    }

    distributedLocker.lockerGuard.RLock()
    lock, exists := distributedLocker.Locks[name]
    distributedLocker.lockerGuard.RUnlock()

    if !exists {
        return uint32(0), fmt.Errorf("lock %v does not exists", name)
    } else {
        // Try to change lock state
        if atomic.CompareAndSwapUint32(&lock.State, LockState_UNLOCKED, LockState_LOCKED) {
            // Get a fencing token and set a timer in another goroutine to expire the lock
            updatedToken := atomic.AddUint32(&lock.CurrentFencingToken, 1) // safe to do, as token can only be updated by changing lock state
            go func() {
                timer := time.NewTimer(time.Second * time.Duration(timeInSeconds))
                <-timer.C // wait for timer to expire
                if atomic.CompareAndSwapUint32(&lock.State, LockState_LOCKED, LockState_UNLOCKED) {
                    fmt.Printf("Lock on %v by client %v expired after %v seconds\n", name, clientId, timeInSeconds)
                } else {
                    // Lock has already been released
                    fmt.Printf("Lock on %v already released\n", name)
                }

                runtime.Goexit() // For safety
            }()

            fmt.Printf("Lock %v successfully acquired by client %v", name, clientId)
            return updatedToken, nil
        } else {
            // Else state is locked, failed to acquire the lock
            return uint32(0), fmt.Errorf("failed to acquire lock %v by client %v", name, clientId)
        }
    }
}

// Verify lease on a distributed lock, if it has expired or not
// For clients where their is some sort of pause between acquiring lock and accessing the critical section
// clients should verify the lease through fencing token
func (distributedLocker *DistributedLocker) VerifyLease(name string, leaseToken uint32, clientId string) (bool, error) {
    distributedLocker.lockerGuard.RLock()
    lock, exists := distributedLocker.Locks[name]
    distributedLocker.lockerGuard.RUnlock()

    if !exists {
        return false, fmt.Errorf("lock %v does not exists", name)
    } else {
        if atomic.LoadUint32(&lock.State) == LockState_LOCKED {
            if atomic.LoadUint32(&lock.CurrentFencingToken) == leaseToken {
                fmt.Printf("Lease on lock %v successfully verified by client %v", lock.Name, clientId)
                return true, nil
            } else {
                return false, fmt.Errorf("lease on lock %v has expired", lock.Name)
            }
        } else {
            // Lock has been expired
            return false, fmt.Errorf("lock %v has already been unlocked and lease on this lock has expired", lock.Name)
        }
    }
}

// Release a distributed lock, requires a lease token
// Clients cannot release an expired lock, or a lock with old lease token
func (distributedLocker *DistributedLocker) ReleaseLock(name string, leaseToken uint32, clientId string) (bool, error) {
    distributedLocker.lockerGuard.RLock()
    lock, exists := distributedLocker.Locks[name]
    distributedLocker.lockerGuard.RUnlock()

    if !exists {
        return false, fmt.Errorf("lock %v does not exists", name)
    } else {
        // Check lock state
        if atomic.LoadUint32(&lock.State) == LockState_LOCKED {
            // If locked, then only verify the lease and unlock using CAS
            if atomic.LoadUint32(&lock.CurrentFencingToken) == leaseToken {
                if atomic.CompareAndSwapUint32(&lock.State, LockState_LOCKED, LockState_UNLOCKED) {
                    // Lock successfully released
                    fmt.Printf("lock %v successfully released by client %v after lease verification", name, clientId)
                    return true, nil
                } else {
                    // Lease is verified but CAS failed, means post lease verification, someone else released the lock
                    // Or it expired in between
                    return false, fmt.Errorf("lease on lock %v verified by client %v but the lock expired", name, clientId)
                }
            } else {
                return false, fmt.Errorf("lease on lock %v has expired", lock.Name)
            }
        } else {
            // If unlocked, means lock has been expired due to timer, or unlocked previously by user
            return false, fmt.Errorf("the lock on %v has expired or the lock has already been released", name)
        }
    }
}
\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

odd bit masks

const LockState_LOCKED = 1 << 1
const LockState_UNLOCKED = 1 << 2

I don't understand what's going on here. Why not use a simple bool? As written, this representation seems to suggests there are four states, including "unknown" and "invalid -- both locked an unlocked". We could do CAS on 0 and 1 as easily as we could on the OP constants. The // Else state is locked comment would be entirely truthful if we had a bool, but the OP representation admits of alternative states.

nit: Counting to four billion is tedious, but doesn't necessarily take a super long time these days. Consider using 64 bits for FencingToken_MAX.

unique ID

This seems to leave "assigning a clientId" as an exercise for the reader, or the app developer, and I saw no guidance in the Review Context.

type Locker interface {
    CreateLock(name string, clientId string) ...

Consider insisting that clients identify with a GUID, so app developers have a strong hint about what should be happening here.

Later, it's unclear what VerifyStringEmpty() is doing -- perhaps rolling a new ID? The library makes up IDs, and not the calling app?

invariants

Any distributed protocol seeks to preserve certain stated invariants. You didn't state any. This frustrates attempts to reason about the protocol, and to file bug reports.

In addition to writing them down in English, it is useful to have the code execute assertions about what we guarantee shall be true.

local increment

            // Get a fencing token and set a timer in another goroutine to expire the lock
            updatedToken := atomic.AddUint32(&lock.CurrentFencingToken, 1) // safe to do, as token can only be updated by changing lock state

This is not well motivated by the Review Context. We see no "obtain ACKs from a quorum" attempt being made here, nor mention of a leader election.

Thank you for citing a reference. It described a 5-node protocol that attempted to correctly achieve distributed consensus. In the OP code a server process correctly obtains a local mutex, but are we to understand there's another 4 nodes that that clients might interact with?

Clients open a TCP connection ... to create a distributed lock

In the OP code I'm seeing the (single server) local, but not the distributed aspect.

obscure entry point

Thank you for this helpful comment.

                <-timer.C  // wait for timer to expire

But why not use the more self-explanatory .After()?

nit: typo, "there" -- // For clients where their is ...

design flaw: VerifyLease

For reasons eloquently described in the cited reference, clients cannot know about "some sort of pause", and so cannot effectively operate within lease time by issuing verification checks. It's unclear how this function could be of use to application clients.

Perhaps the comments should have described how a server persistence layer might use such a check before serializing input from a client? The clients should verify the lease remark seems out of step with that.

The Kafka developers have written some helpful posts on exactly once semantics in a distributed setting. Spoiler: Servers need to persist the fencing token along with client's input data.

automated test suite

There isn't one.

The cited reference suggested that you might wish to write Jepsen clients which exercise this codebase. And there are other tools, such as the popular Spin model checker.


Sorry, I didn't find a convincing proof of correctness here, no QED. It's admittedly hard to achieve that. Part of the trouble may stem from the concept-of-operations, the setup and operational environment. Better documentation could help with that. Automated tests can be a valuable adjunct to English documentation.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.