Locks

A Grackle lock is a named read/write lock that mediates access to a resource. It lives inside a namespace and is referenced by name. A lock can be held in one of two modes: shared (read) by any number of holders at once, or exclusive (write) by a single holder. Clients acquire the lock in one of these modes and release it when done.

Locks do not need to be created upfront. The first successful AcquireLock creates the lock record; once the last holder releases (or expires) the lock is fully unlocked. GetLock on a name that has never been acquired returns an unlocked lock.

Core concepts

Lock state

A lock is always in one of three states:

  • UNLOCKED — no active holders.
  • SHARED_LOCKED — one or more holders, all of them shared (readers).
  • EXCLUSIVE_LOCKED — exactly one holder, holding the lock exclusively (a writer).

A lock used as a plain mutex is simply one that is only ever acquired in exclusive mode.

Holders

Each successful acquire adds a holder record to the lock, carrying lease_id and locked_at. A shared lock can have many holders; an exclusive lock has exactly one. GetLock returns the current holder list so a caller that failed to acquire can see who is holding it.

Hierarchical names

Lock names are paths separated by / (e.g. users/123/profile). Grackle treats them as a hierarchy:

  • An exclusive lock at any path blocks acquires on its ancestors and descendants.
  • A shared lock at any path blocks exclusive acquires on its ancestors and descendants, but allows shared acquires anywhere up or down the path.
  • Sibling paths are independent. Locks on users/123 and users/456 never interfere.

This is useful for guarding tree-shaped resources (filesystems, document trees, partitioned data) without enumerating every leaf. Acquire from root to leaf and release from leaf to root to avoid deadlocks.

If no hierarchy is needed, just use plain lock names without /.

Leases

A lock acquisition is owned by a lease, not by the caller directly. A lease is a short-lived, server-side TTL token created with CreateLockLease. All acquires made with the same lease_id share its expiration. Calling RefreshLockLease extends the TTL of the lease and of every lock holder it owns. RevokeLockLease releases every lock the lease holds, in one shot. If a lease expires without being refreshed, its holders are reaped automatically — there are no stuck locks when a process crashes.

A single lease may hold many locks at once. Leases are listed per namespace and can also be listed by process_id. Lock leases and semaphore leases are independent and not interchangeable.

Process IDs

A process_id is a free-form string the caller assigns to a lease at creation (e.g. "host-123/pid-4567" or any opaque identifier of the work unit). Grackle does not interpret it; it only stores it on the lease so callers can later enumerate leases by process_id to find “what does this worker currently hold?” The acquire/release surface does not take process_id — it takes lease_id.

Metadata

Each lock holder carries an optional metadata map (string → string), attached on AcquireLock and returned with the holder on GetLock / ListLocks. (Metadata lives on the holder, not on the lock itself, since a shared lock can have many holders.) Metadata is opaque to Grackle — see Metadata for the shared semantics and limits.

Contention diagnostics

When an AcquireLock does not acquire (outcome is ACQUIRE_OUTCOME_UNAVAILABLE or ACQUIRE_OUTCOME_TIMED_OUT), the response explains why so a caller does not have to guess which part of the hierarchy is in the way:

  • reason — a ContentionReason enum: CONTENTION_REASON_PEER (the lock itself is held in an incompatible mode), CONTENTION_REASON_ANCESTOR (a lock on an ancestor path blocks it), or CONTENTION_REASON_DESCENDANT (one or more locks on descendant paths block it). It is CONTENTION_REASON_UNSPECIFIED on a successful acquire.
  • blocking_locks — the actual locks standing in the way (the blocking ancestor lock(s) or blocking descendant locks, matching reason), each with its current holders. It is empty for CONTENTION_REASON_PEER, where the conflicting lock is the one being acquired and is already returned in the top-level lock field, so it is not duplicated here.

Both are best-effort, point-in-time hints: they describe the conflict seen on the last attempt and may already be stale by the time you read them, so treat them as diagnostics (logging, error messages, dashboards) and branch your logic on outcome, not on these. blocking_locks is always capped at 50 — a contended descendant subtree may hold more locks than are listed — which keeps the lookup cheap and bounded no matter how large the subtree is.

Lifecycle

Locks do not need to be created upfront. Also, they do not need to be cleaned up. An unlocked lock simply does not occupy any space in the storage.

Each lock carries a last_activity_at timestamp — the time of the most recent activity on it, namely an AcquireLock or ReleaseLock. Reads (GetLock, ListLocks) do not change it. This is information-only field.

Example workflow

Process host-123/pid-4567 creates a lease. ttl_seconds is added to “now” server-side. (Assuming that a namespace documents already exists.)

CreateLockLeaseRequest:

{
  "namespace_name": "documents",
  "process_id": "host-123/pid-4567",
  "ttl_seconds": 60,
}

CreateLockLeaseResponse:

{
  "lease": {
    "lease_id": "ls_NfKKeiPbP18NFeU3lLGrRWWgDJRB",
    "process_id": "host-123/pid-4567",
    "created_at": 1695826239671432000,
    "expires_at": 1695826299671432000
  }
}

Then the process tries to acquire a lock under that lease. exclusive: true requests a write lock; exclusive: false requests a read (shared) lock. timeout_seconds tells how long the call should wait if the lock is not immediately compatible. The acquire succeeds if the lock is currently unlocked, already held in a compatible mode, or held by the same lease (re-acquiring your own lock is always allowed and just refreshes locked_at). The response carries an outcome enum: ACQUIRE_OUTCOME_ACQUIRED when the lease now holds the lock, ACQUIRE_OUTCOME_UNAVAILABLE when a non-blocking attempt (timeout_seconds: 0) found it held by someone else and returned without waiting, or ACQUIRE_OUTCOME_TIMED_OUT when the call blocked until timeout_seconds elapsed without ever acquiring. In the non-acquired cases the current state of the lock is returned with no error, along with contention diagnostics (reason and blocking_locks, see Contention diagnostics) that point at what stood in the way.

AcquireLockRequest:

{
  "namespace_name": "documents",
  "lock_name": "users/123/profile",
  "exclusive": true,
  "lease_id": "ls_NfKKeiPbP18NFeU3lLGrRWWgDJRB",
  "timeout_seconds": 60,
}

AcquireLockResponse (acquired):

{
  "lock": {
    "name": "users/123/profile",
    "state": "LOCK_STATE_EXCLUSIVE_LOCKED",
    "locked_at": 1695826239671432000,
    "last_activity_at": 1695826239671432000,
    "lock_holders": [
      {
        "lease_id": "ls_NfKKeiPbP18NFeU3lLGrRWWgDJRB",
        "locked_at": 1695826239671432000
      }
    ]
  },
  "outcome": "ACQUIRE_OUTCOME_ACQUIRED"
}

AcquireLockResponse (already locked by someone else — not an error). The lock itself is the blocker, so reason is CONTENTION_REASON_PEER; the conflicting lock is already in the lock field, so blocking_locks is empty (omitted below):

{
  "lock": {
    "name": "users/123/profile",
    "state": "LOCK_STATE_EXCLUSIVE_LOCKED",
    "locked_at": 1695826200000000000,
    "last_activity_at": 1695826200000000000,
    "lock_holders": [
      {
        "lease_id": "ls_qB7XwYzAaaaaaaaaaaaaaaaaaaaa",
        "locked_at": 1695826200000000000
      }
    ]
  },
  "outcome": "ACQUIRE_OUTCOME_TIMED_OUT",
  "reason": "CONTENTION_REASON_PEER"
}

When the process is done it should release the lock.

ReleaseLockRequest:

{
  "namespace_name": "documents",
  "lock_name": "users/123/profile",
  "lease_id": "ls_NfKKeiPbP18NFeU3lLGrRWWgDJRB"
}

ReleaseLockResponse:

{
  "lock": {
    "name": "users/123/profile",
    "state": "LOCK_STATE_UNLOCKED",
    "locked_at": 0,
    "last_activity_at": 1695826239671432000,
    "lock_holders": []
  }
}

For a shared lock, the same AcquireLock call with exclusive: false succeeds for many leases at once:

AcquireLockResponse:

{
  "lock": {
    "name": "users/123/profile",
    "state": "LOCK_STATE_SHARED_LOCKED",
    "locked_at": 1695826239671432000,
    "last_activity_at": 1695826240120000000,
    "lock_holders": [
      {
        "lease_id": "ls_NfKKeiPbP18NFeU3lLGrRWWgDJRB",
        "locked_at": 1695826239671432000
      },
      {
        "lease_id": "ls_qB7XwYzAaaaaaaaaaaaaaaaaaaaa",
        "locked_at": 1695826240120000000
      }
    ]
  },
  "outcome": "ACQUIRE_OUTCOME_ACQUIRED"
}

ReleaseLock removes this lease from the holders. The lock becomes UNLOCKED only when the last holder is gone. Releasing a lock the lease does not hold is a no-op (returns the lock unchanged, no error). DeleteLock removes the lock record entirely regardless of state — use it to forcibly unlock something stuck.

Working with leases

A process should create and maintain one or multiple leases while it is alive and use these leases to hold locks. On startup the process calls CreateLockLease with some TTL, e.g. 60s. Then periodically on a heartbeat it calls RefreshLockLease to extend TTL and signal that it is still alive. In the meantime this lease can be used to call AcquireLock and ReleaseLock. On shutdown the process calls RevokeLockLease and everything that it held will be released. If the process crashes everything will be released as well when TTL of its lease is over.

Alternatively, a process can create a lease every time it needs to acquire a lock and set TTL to the time it expects to work with that lock. Keep in mind that lease TTL starts counting right after a lease is created, but an acquisition might not happen immediately if the lock is held incompatibly.

Hierarchical Lock Semantics

An exclusive lock at any level in the hierarchy grants exclusive access to that path and all its descendants. No other locks (shared or exclusive) can be acquired on the same path, any descendant paths, or any ancestor paths.

A shared lock allows multiple readers at the same level. Shared locks at a parent level allow shared locks at descendant levels. Shared locks prevent exclusive locks from being acquired on the same path, any descendant paths, or any ancestor paths.

Existing Lock Requested Lock Result Reasoning
a/b (X) a/b/c (S) ❌ BLOCK Parent exclusive lock prevents all descendant locks
a/b (X) a/b/c (X) ❌ BLOCK Parent exclusive lock prevents all descendant locks
a/b (S) a/b/c (S) ✅ ALLOW Parent shared lock allows descendant shared locks
a/b (S) a/b/c (X) ❌ BLOCK Parent shared lock prevents descendant exclusive locks
a/b/c (X) a/b (S) ❌ BLOCK Descendant exclusive lock prevents ancestor locks (intent conflict)
a/b/c (X) a/b (X) ❌ BLOCK Descendant exclusive lock prevents ancestor locks (intent conflict)
a/b/c (S) a/b (S) ✅ ALLOW Descendant shared lock allows ancestor shared locks
a/b/c (S) a/b (X) ❌ BLOCK Descendant shared lock prevents ancestor exclusive locks
a/b (X) a/b (S) ❌ BLOCK Same-level exclusive lock blocks all other locks
a/b (X) a/b (X) ❌ BLOCK Same-level exclusive lock blocks all other locks
a/b (S) a/b (S) ✅ ALLOW Multiple shared locks on same path are allowed
a/b (S) a/b (X) ❌ BLOCK Shared lock prevents exclusive lock on same path
a (X) a/b (S) ❌ BLOCK Parent exclusive lock prevents all descendant locks
a (X) a/b (X) ❌ BLOCK Parent exclusive lock prevents all descendant locks
a (S) a/b (S) ✅ ALLOW Parent shared lock allows descendant shared locks
a (S) a/b (X) ❌ BLOCK Parent shared lock prevents descendant exclusive locks
a/b/c/d (X) a/b (S) ❌ BLOCK Deep descendant exclusive lock prevents ancestor locks
a/b/c/d (X) a/b (X) ❌ BLOCK Deep descendant exclusive lock prevents ancestor locks
a/b/c/d (S) a/b (S) ✅ ALLOW Deep descendant shared lock allows ancestor shared locks
a/b/c/d (S) a/b (X) ❌ BLOCK Deep descendant shared lock prevents ancestor exclusive locks

Paths that are not in the same hierarchy (siblings or unrelated paths) should not interfere with each other:

Existing Lock Requested Lock Result Reasoning
a/b (X) a/c (S) ✅ ALLOW Sibling paths are independent
a/b (X) a/c (X) ✅ ALLOW Sibling paths are independent
a/b (S) a/c (S) ✅ ALLOW Sibling paths are independent
a/b (S) a/c (X) ✅ ALLOW Sibling paths are independent
a/b/c (X) a/d/e (X) ✅ ALLOW Sibling paths are independent

API reference