Repository navigation
[feat] support the kv local and global locks - #186
Conversation
Each KV call is safe on its own, but a sequence such as kv_batch_get -> compute -> kv_put is not: concurrent writers lose updates. kv_local_lock / async_kv_local_lock let threads and asyncio tasks of one process serialize such sequences. Semantics: advisory exclusive mutex per (partition_id, key); one key or a list (deduplicated, acquired in sorted order); timeout is a total deadline and raises TimeoutError; nesting raises RuntimeError; sync kv_local_lock inside a running event loop raises RuntimeError. Waiters are served in FIFO order with direct handoff, so threads and tasks on any loop wake each other, and a timed-out or cancelled waiter never keeps the key. The lock is process-local only; it does not exclude other processes or Ray actors. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
…l lock kv_local_locked(fn, keys, partition_id, *, lock_timeout=None, **kwargs) and its async counterpart hold kv_local_lock on keys while calling fn(keys, partition_id, **kwargs), so keys and partition are written once. fn may be any TQ KV call or a user helper with the same leading arguments, such as a get-compute-put step. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
…ose() close() always killed the controller, and processes that attach via init() without a conf also hold a controller handle, so close() in any worker tore TransferQueue down for the whole cluster. init() now records ownership when it actually creates the controller actor. In other processes close() only closes the client and drops the handles, so they can attach again later. Storage cleanup was already owner-only. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
kv_global_lock / async_kv_global_lock hold an advisory exclusive lock per (partition_id, key) across the Ray cluster. tq.init() creates controller.num_lock_shards (default 8) named TransferQueueLockManager actors next to the controller, and each key goes to one of them by crc32 of (partition_id, key). Within a shard all keys of a request are granted at once; a request spanning shards visits them in ascending order, so overlapping requests cannot deadlock. Leases are renewed in batches by one daemon thread per process. The yielded lease exposes per-key fencing tokens and check(), which raises LockLostError once the lease may have passed to someone else. Callers that give up while waiting withdraw their acquire by token, so a grant never goes to a caller that left, and a release wakes only the waiters of the freed keys. kv_lock_list() shows holders and waiters. Only the owning process kills the lock actors in close(); other processes release their leases and leave them running. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
kv_global_locked(fn, keys, partition_id, *, lock_timeout=None, lease_s=30, **kwargs) and its async counterpart mirror kv_local_locked: they hold kv_global_lock on keys while calling fn(keys, partition_id, **kwargs), and reject a function of the wrong kind with TypeError. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
1e971fc to
12fa4e4
Compare
0oshowero0
left a comment
There was a problem hiding this comment.
[AI Review] Summary
Reviewed for simplicity, maintainability, interface clarity and reliability. All 40 tests added by this PR pass locally (Ray 2.56, Python 3.11, SimpleStorage). Items 1–8 are inline comments; repro snippets are attached where a finding was reproduced.
- Must fix: 1) a cross-shard wait silently loses the earlier shards' locks; 2)
kv_local_lockfails in any process that attached viatq.init(). - Suggested before merge: 3) fencing tokens; 4) cross-shard hold-and-wait; 5) fairness; 6) the
*_lockedwrappers; 7) make the lock actors opt-in; 8) module boundaries.
What works well: the process-local queue handles the hard edges carefully (a grant racing a timeout, waiters on closed loops, cancelled waiters passing the key on); withdraw-by-token, crc32 routing and per-key wakeups are solid; ascending shard order does make cross-shard requests deadlock-free; and the close-ownership fix is correct.
Minor
_holdingis copied into asyncio tasks created inside anasync_kv_local_lockblock and staysTrueafter the block exits, so such a task later gets "Nested kv_local_lock" even for an unrelated key (reproduced). (kv_lock.pyL54, L200)- On first use the async paths block the event loop:
_shard_count()callsray.getand_lock_manager()callsray.get_actor(kv_lock.pyL294–319, reached fromasync_kv_global_lockvia_new_lease). - The sync acquire calls
ray.getwithout a client-side timeout (kv_lock.pyL428), so if a lock actor is alive but unresponsive the caller'stimeoutis not honored. - With
max_concurrency=10_000(lock_manager.pyL30), once that many acquires wait on one actor,releaseandrenew_manyqueue behind them and holders lose their leases. _num_shardsis cached per process (kv_lock.pyL246). If the owner re-inits with a differentnum_lock_shards, processes route the same key to different actors and exclusion breaks silently._expireonly runs insideacquire(lock_manager.pyL78) while every renewal pushes a heap entry (L125), so the heap keeps growing while locks are renewed but no new acquires arrive._withdrawnis also rebuilt on everyreleasethat frees nothing (L132)._reset_after_fork(kv_lock.pyL539–546) targets forked children, which cannot use Ray safely anyway (seeStreamingDataset._create_client), so the lock path it resets is not usable there.GlobalLeaseis whatkv_global_lockyields, but it is not exported fromtransfer_queue.
Related, outside this PR
- The description says each KV call is safe on its own, but concurrent tag updates on one key already lose updates on
main:kv_put(tag=...)reads the current tags, merges on the client, and the controller'sset_custom_metareplaces the per-sample dict. Two threads writing different tag names to the same 300 keys left every key with only one of the two tags (sequential calls keep both). This matters if retry logic keeps an attempt id in a tag; merging per key in the controller, in a separate PR, would fix it.
Repro for 17
keys = [f"k{i}" for i in range(300)]
for key in keys:
tq.kv_put(key, P, fields={"x": torch.tensor([0])})
barriers = [threading.Barrier(2) for _ in keys]
def writer(tag_name):
for key, barrier in zip(keys, barriers):
barrier.wait()
tq.kv_put(key, P, tag={tag_name: 1})
threads = [threading.Thread(target=writer, args=(name,)) for name in ("a", "b")]
for t in threads:
t.start()
for t in threads:
t.join()
tags = tq.kv_list(P)[P]
print(sum(not {"a", "b"} <= set(tags[k]) for k in keys)) # 300| def _acquire_lease(lease: GlobalLease, deadline: float | None) -> None: | ||
| """Acquire every manager route for a sync lock and start lease renewal.""" | ||
| for shard in lease.shards: | ||
| sent = time.monotonic() | ||
| try: | ||
| reply = ray.get(_send_acquire(lease, shard, deadline)) | ||
| except RayActorError as e: | ||
| raise RuntimeError(_MANAGER_GONE) from e | ||
| _take_grant(lease, shard, reply, sent) | ||
| _start_lease(lease) |
There was a problem hiding this comment.
[AI Review] 1 · Must fix: a cross-shard wait silently loses the earlier shards' locks
_start_lease runs only after every shard has been granted, so while this loop waits on a later shard, the leases already granted on earlier shards are not renewed. If that wait exceeds lease_s (30 s by default: a hot key, a long critical section, or a crashed holder whose lease has to run out), the earlier keys expire on their actor and can be granted to someone else. kv_global_lock then yields without checking and kv_global_locked calls fn without checking, so the body writes without exclusion and LockLostError only surfaces after the write. With 8 shards, two keys land on different shards 7/8 of the time.
Reproduced with lease_s=1: inside fn, kv_lock_list showed the earlier key held by another process, and LockLostError was raised only after fn returned.
Suggestion: renew each shard as soon as it is granted (the renewer then has to renew only granted shards; registering the whole lease up front would make ungranted shards report it lost), call lease.check() before yielding, and add a test that waits on a later shard for longer than lease_s.
Repro
P = "repro"
shard_of = lambda k: kvl._shard((P, k), kvl._shard_count()) # kvl = transfer_queue.kv_lock
keys = sorted((f"k{i}" for i in range(50)), key=shard_of)
early, late = keys[0], keys[-1] # shard 0 and shard 7
@ray.remote
class Holder:
def __init__(self):
tq.init()
def hold(self, key, secs):
with tq.kv_global_lock(key, P, timeout=30):
time.sleep(secs)
def grab_after(self, key, delay, hold_s):
time.sleep(delay)
with tq.kv_global_lock(key, P, timeout=30):
time.sleep(hold_s)
a, b = Holder.remote(), Holder.remote()
a.hold.remote(late, 4) # the later shard stays busy for 4 s
time.sleep(0.5)
b.grab_after.remote(early, 2.0, 5) # another client asks for the earlier key at ~2.5 s
def fn(keys, partition_id):
holders = {h["key"]: h["holder"]["pid"] for h in tq.kv_lock_list(P)["holders"]}
print("early key held by", holders.get(early), "| my pid", os.getpid())
tq.kv_global_locked(fn, [early, late], P, lease_s=1, lock_timeout=30)
# early key held by 67541 | my pid 67283
# then: LockLostError: kv_global_lock lease on [...] was lostThere was a problem hiding this comment.
Fixed in 7bc0ee0. Each shard's lease is now registered with the renewer as soon as that shard grants, and the renewer renews only the shards that have granted. If a shard is granted after a renewal batch was snapshotted, that batch does not extend the local deadline. kv_global_lock calls lease.check() before yielding, so a lease lost while later shards were awaited raises LockLostError on entry instead of after the write.
test_waiting_on_a_later_shard_keeps_the_earlier_keys_held holds the later shard for 3 s against a 2 s lease and checks that the earlier key stays ours and the block is entered. It fails without the fix (the earlier key drops out of kv_lock_list).
| def _process_family_id() -> str: | ||
| """Return a process-family id inherited by child processes through the environment.""" | ||
| family_id = os.environ.get(_PROCESS_FAMILY_ENV) | ||
| if family_id is None: | ||
| family_id = f"{os.getpid()}_{uuid.uuid4().hex}" | ||
| os.environ[_PROCESS_FAMILY_ENV] = family_id | ||
| return family_id | ||
|
|
||
|
|
||
| def _process_lock_manager_name() -> str: | ||
| """Return the Ray actor name shared by one process and its descendants.""" | ||
| return f"TransferQueueProcessLockManager_{_process_family_id()}" |
There was a problem hiding this comment.
[AI Review] 2 · Must fix: kv_local_lock fails in every process that attached via tq.init()
The process-family actor is created only in the owner's init() (interface.py L222–225). In a Ray actor or task that calls tq.init() and attaches to the existing controller, _process_family_id() mints a new id, the actor lookup fails, and kv_local_lock raises RuntimeError: TransferQueue lock manager not found; call tq.init() first even though tq.init() was called. It also writes that orphan id into os.environ, so any child process spawned later inherits it. Forked children such as DataLoader workers cannot use Ray safely either (see StreamingDataset._create_client), so in practice the process family covers only the owner plus children that run ray.init themselves.
Suggestion: go back to the purely in-process lock from the first commit, scoped to the threads and tasks of one process, and point cross-process users to kv_global_lock. That also removes the per-acquire actor round trip (0.56 ms p50 uncontended, measured locally), one resident actor, the renewer dependency, and the LockLostError the local lock can currently raise on exit without giving the caller a lease to check.
Repro
@ray.remote
def local_lock_in_attached_worker():
tq.init() # attaches to the controller created by the driver
with tq.kv_local_lock("k", "p", timeout=5):
return "acquired"
ray.get(local_lock_in_attached_worker.remote())
# RuntimeError: TransferQueue lock manager not found; call tq.init() firstThere was a problem hiding this comment.
Fixed in 0dcbcb0: kv_local_lock is back to the purely in-process lock (a FIFO queue per key under a threading.Lock, with direct handoff), with no actor, no round trip and no LockLostError. Cross-process exclusion is kv_global_lock's job, as the module docstring now says. The SimpleStorage read-modify-write test now runs under the default config, where no lock actors exist at all.
| # Min-heap of (expires_at, token). Release and renewal leave stale entries behind, which | ||
| # _expire skips, so neither has to search the heap. | ||
| self._expiries: list[tuple[float, str]] = [] | ||
| self._fences: dict[tuple[str, str], int] = {} # never reset, so a key's fence only grows |
There was a problem hiding this comment.
[AI Review] 3 · Fencing tokens: enforce them on the write path, or drop them
Fencing is the only defense against a holder that stalls past its lease and keeps writing (for example, a slow rollout attempt that has already been retried), but nothing checks lease.fence today, so it provides no protection yet. Meanwhile _fences keeps an entry for every (partition_id, key) ever locked, even after kv_clear, which grows without bound in long jobs keyed by sample uid. Either have the KV write path reject stale fences, or remove the field until it does.
There was a problem hiding this comment.
Dropped in a8b52ac. Nothing enforced the fence, and _fences grew with every key ever locked. Callers who need protection against a stalled holder can call lease.check() right before writing; real fencing would need the KV write path to check it, which is a separate change.
| background thread renews. Keys on several actors are taken one actor at a time, each | ||
| all at once, so keys already granted stay held while a later actor's keys are awaited. |
There was a problem hiding this comment.
[AI Review] 4 · Spell out what cross-shard hold-and-wait means for callers
This docstring notes that granted keys stay held, but the impact is larger than it reads:
- Whether a request is all-or-nothing depends on hashing the caller cannot see:
[a, b]on one shard never holdsawhile waiting forb; on two shards it does. Changingnum_lock_shardschanges this. - Cross-shard is the common case: with 8 shards, 2 keys span two shards 7/8 of the time, and a 16-key batch touches about 7 shards, taking 7 sequential round trips while holding the earlier ones.
- Holding while waiting chains: H held
z(shard 7) for 3 s, X asked for[a, z](aon shard 0), and Y, which only neededa, waited 2.41 s. Withaandzon the same shard, Y waited 0 s.
Suggestion: state these consequences in the kv_global_lock / async_kv_global_lock docstrings, and advise locking the smallest key set and keeping critical sections short.
There was a problem hiding this comment.
[AI Review] 4 (follow-up) Since each shard only sees its own keys, three more effects:
- A request re-queues at every later shard: time already waited on earlier shards gives it no priority there, so its total wait adds up shard by shard. Granting in arrival order (5, follow-up) does not change this.
- If it times out on a later shard, the keys it held on earlier shards blocked others for nothing.
kv_lock_listshows it as the holder of the earlier keys with a growingheld_s, which reads like a long critical section; consider marking holders that are still acquiring.
There was a problem hiding this comment.
Done in d36d05e. The kv_global_lock docstring now states that:
- a request holds the keys already granted while it waits for a later actor's;
- the hash decides which keys share an actor, so whether a request is all-or-nothing is not up to the caller (with 8 shards, two keys span two actors 7 times in 8);
- a request queues afresh at each later actor, so its waits add up;
- the keys it holds meanwhile block others even if it then times out.
It advises locking the fewest keys possible and keeping the block short. kv_lock_list also notes that a holder may still be waiting for its keys on other actors.
Related: 7bc0ee0 (renew from the first grant) fixes the case where such a wait outlasted the earlier shards' leases.
| # name -> wakeups of its waiters, as an insertion-ordered dict so they wake oldest first | ||
| self._wakeups: dict[tuple[str, str], dict[asyncio.Event, None]] = {} |
There was a problem hiding this comment.
[AI Review] 5 · No fairness guarantee on the actor side
A release sets every waiter of the freed keys and whoever re-checks first wins; nothing reserves the key for the oldest waiter, and a newly arriving acquire can take it too. So "wake oldest first" orders wakeups, not grants. Locally, with two threads cycling on each of k1 and k2 (holds of 10–30 ms), a caller asking for [k1, k2] waited 0.3–1.5 s while each key was granted to others 20–80 times; FIFO would bound that at one or two holds (about 20–40 ms). Multi-key waiters suffer most, since all their keys must be free at the moment they re-check. Please document that grants are not fair; if hot keys are an expected workload, granting in arrival order would fix it (sketch in the reply below).
There was a problem hiding this comment.
[AI Review] 5 (follow-up) · Cost of waking every waiter, and granting in arrival order
Measured by running the lock-manager class directly under asyncio (Python 3.11), one hot key:
| waiters on the key | actor CPU per release | release → winner's reply handed to Ray |
|---|---|---|
| 1 | 0.22 ms | 0.30 ms |
| 1,000 | 7.1 ms | 8.5 ms |
| 10,000 | 101 ms | 108 ms |
Every woken waiter re-checks and suspends again (a new task and timer, about 3–10 µs each), and the winner's result reaches Ray only after all losers have done so, because its task-done callback queues behind them on the loop. This matches the 136 locks/s with 1,000 waiters in the description. It never takes more than one core per lock actor, but while it runs every other key on that shard, renewals included, waits. With 2–3 contenders per key (OPD, a retried rollout) the cost is negligible.
If hot keys are expected, a sketch that keeps all-or-nothing per shard:
- Keep waiters in arrival order. On every release, expiry or withdrawal, walk them oldest first and grant each one whose keys are neither held nor wanted by an older waiter that is still waiting; wake only the ones granted. The walk is set checks only, so a release no longer resumes every waiter.
- A new acquire follows the same rule: granted at once only if it conflicts with no holder and no waiter, otherwise it joins the tail.
This stays deadlock-free: a waiter waits only on a holder that is running or waiting on a later shard, or on an older waiter on the same shard, so wait chains cannot cycle (given no await while enqueueing and one shard count everywhere). The parts that need care are:
- a timeout racing a grant: record the grant before waking, and have the woken side check it, as
_settledoes in the in-process queue; - a grant to a waiter that has left:
release(token)must also dequeue it and pass its keys on; a crashed client blocks the queue until its lease expires; - an older waiter leaving without a release (timeout, withdrawal) can unblock younger ones, so the walk must run then too;
- a holder that dies without releasing: something must wake at its lease expiry to run the walk.
The trade-off: a free key can sit idle while an older waiter that wants it waits for its other keys, and cross-shard order is unchanged, since each later shard sees the request as a newcomer.
Suggestion: confirm the expected per-key contention for OPD and rollout retries first. If hot keys are not expected, documenting "not fair" is enough and 1 remains the priority.
There was a problem hiding this comment.
Fixed in 191327a with your sketch. Each actor keeps a FIFO queue per key and grants a request only when all its keys are free and it heads every one of their queues. A newcomer joins the tail unless it conflicts with no holder and no waiter. Release, expiry and withdrawal hand keys straight to the next eligible waiter and wake only that one. Expiry runs on a timer instead of waiter polling.
On one 18-core machine (4 client actors, one hot key), 12, 100 and 1000 waiters went from 1,277–1,395, 943–944 and 253–256 locks/s to 1,400–1,408, 1,411–1,441 and 1,429–1,450 locks/s. The e2e tests cover a single-key request not passing an older multi-key one, a withdrawing waiter letting the next one through, and expired leases passing the key down the queue.
Since 420b822, a timed-out call keeps its queue position until the client withdraws it, so long waits no longer pin actor slots (item 12 in the summary).
| def kv_global_locked( | ||
| fn, keys: str | list[str], partition_id: str, *, lock_timeout: float | None = None, lease_s: float = 30, **kwargs | ||
| ): | ||
| """Call ``fn(keys, partition_id, **kwargs)`` under ``kv_global_lock(keys, partition_id)``. | ||
|
|
||
| Same calling convention as ``kv_local_locked``. If the lease was lost by the time ``fn`` | ||
| returns, its result is discarded and ``LockLostError`` is raised. | ||
| """ | ||
| if inspect.iscoroutinefunction(fn): | ||
| raise TypeError("kv_global_locked would release the lock before the coroutine runs; use async_kv_global_locked") | ||
| with kv_global_lock(keys, partition_id, timeout=lock_timeout, lease_s=lease_s): | ||
| return fn(keys, partition_id, **kwargs) |
There was a problem hiding this comment.
[AI Review] 6 · Consider dropping the four *_locked wrappers
They save repeating keys and partition_id, but add an implicit fn(keys, partition_id, **kwargs) convention, let lock_timeout / lease_s collide with fn's own keyword arguments, and leave the caller no point to call lease.check() before writing (which makes 1 worse). with tq.kv_global_lock(...) as lease: is already one line, and dropping them shrinks the new public surface from ten names to six. The same applies to kv_local_locked / async_kv_local_locked (L216–238).
There was a problem hiding this comment.
Dropped in d8909fe. The public lock API is now kv_local_lock, async_kv_local_lock, kv_global_lock, async_kv_global_lock, kv_lock_list, plus the LockLostError and GlobalLease types.
| # Created here rather than on first lock: non-detached actors die with their creator. | ||
| global_lock_managers = [ | ||
| TransferQueueLockManager.options( # type: ignore[attr-defined] | ||
| name=f"TransferQueueLockManager_{i}", namespace="transfer_queue", get_if_exists=True | ||
| ).remote(num_lock_shards) | ||
| for i in range(num_lock_shards) | ||
| ] | ||
| process_lock_manager = TransferQueueLockManager.options( # type: ignore[attr-defined] | ||
| name=_process_lock_manager_name(), namespace="transfer_queue", get_if_exists=True | ||
| ).remote(1) | ||
| _TQ_LOCK_MANAGERS = [*global_lock_managers, process_lock_manager] |
There was a problem hiding this comment.
[AI Review] 7 · Make the lock actors opt-in
Every owner init() now starts num_lock_shards + 1 resident actor processes whether or not locks are used; locally that was 9 processes and about 877 MiB RSS. Suggest a config switch so users enable the lock feature explicitly (for example disabled by default: no lock actors are created and the global lock APIs raise a clear error, while an in-process kv_local_lock as proposed in 2 keeps working). Since the lock actors are not part of the controller, a top-level lock: section in config.yaml holding the switch and the shard count would read better than controller.num_lock_shards.
There was a problem hiding this comment.
Done in 510a48b. There is a new top-level section:
lock:
enabled: false # true starts the lock actors
num_shards: 8It replaces controller.num_lock_shards. With the default, tq.init() creates no lock actors, kv_local_lock works as before, and kv_global_lock raises RuntimeError naming lock.enabled: true as the fix. lock.num_shards is validated before the controller is created, so a bad value leaves nothing behind.
|
|
||
| from transfer_queue.client import TransferQueueClient | ||
| from transfer_queue.controller import TransferQueueController | ||
| from transfer_queue.kv_lock import _detach_lock_managers, _process_lock_manager_name |
There was a problem hiding this comment.
[AI Review] 8 · Keep the lock-actor lifecycle in one module
interface.py imports private helpers from kv_lock (_detach_lock_managers, _process_lock_manager_name), the actor name TransferQueueLockManager_{i} is spelled in both files, and the actors are created here but looked up in kv_lock.py. Moving creation into kv_lock (one function that init() calls, next to the existing detach) keeps naming, creation, lookup and teardown in one place.
There was a problem hiding this comment.
Done in 510a48b. kv_lock.py now owns the actor name (_manager_name), creation (_start_lock_managers, called by the tq.init() that created the controller), lookup, and teardown (_close_lock_managers, which releases this process's leases and kills the actors it created). interface.py no longer imports TransferQueueLockManager or keeps lock-actor handles; it calls _check_lock_conf, _start_lock_managers and _close_lock_managers.
The process-family scope made every local lock an actor round trip and left processes that attach to an existing TransferQueue, including Ray workers, without a lock actor. Cross-process exclusion is what kv_global_lock is for, so kv_local_lock goes back to a pure in-process mutex with no Ray calls. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
A release used to wake every waiter on the freed keys and let whichever ran first take them. A request arriving in that window could barge ahead of older waiters, and a multi-key request could wait forever while single-key requests kept one of its keys busy. Each lock actor now keeps a FIFO queue per key and grants a request only when all its keys are free and it heads each of their queues. Release, expiry and withdrawal hand the keys straight to the next eligible waiter and wake only it, and a timer expires leases on time now that waiters no longer poll. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Nothing on the KV write path checks them, so they gave no protection against a stalled holder, while the per-key counters on the lock actors grew with every key ever locked. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
A with-block over kv_local_lock / kv_global_lock is one line longer and keeps the public lock API to the two context managers per scope. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
The lock actors cost num_shards resident processes per TransferQueue, so tq.init() starts them only with lock.enabled: true; the shard count moves from controller.num_lock_shards to lock.num_shards. Their naming, creation, lookup and teardown now all live in kv_lock.py. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
A process that cached lock.num_shards before TransferQueue restarted with another value would send keys to the wrong lock actor, where nothing excludes the processes that sent them to the right one. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Renewal used to start only once every shard was granted, so waiting on a later shard for longer than lease_s let an earlier shard's lease expire and pass its keys on while the caller still meant to enter. Check the lease before entering, too. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
An acquire used to wait inside the lock actor until granted, so enough waiters could take every concurrency slot and starve release and renew_many. Each call now waits at most one second and a follow-up wait call keeps the queue position. Every reply is awaited with a bound, so an unresponsive actor cannot outlast the caller's timeout either. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
The first async_kv_global_lock in a process asked shard 0 for the shard count with ray.get and looked each actor up with ray.get_actor, blocking the running loop. All lookups now happen at once in a worker thread. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
A task copies its creator's context, so a task created inside an async_kv_local_lock block kept seeing the lock as held and later failed with "Nested kv_local_lock" even for unrelated keys. The context now holds a cell that exit clears for every copy. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Every release that withdrew nothing rebuilt the whole tombstone dict. All tombstones share one TTL, so expired ones are always the oldest and can be dropped from the front in amortized constant time. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
…ck callers Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
|
Thanks for the thorough review. Items 1–8 are answered in their threads; here are the minor items from the summary. Each fix is its own commit on the branch.
Also removed after review: the |
…nt queues afresh A grant that expired between two polls left the token unknown to the manager, and wait() then returned None at once, so the client spun until its timeout. Re-polls now repeat acquire with the same token, which keeps its place in the queues or queues it again; wait() is removed. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
… key off A caller that died while queued was still granted the key, which then sat idle for that caller's whole lease, once per dead waiter in line. The manager now tracks each waiter's calls in flight and last call, and hand-off drops one that has not polled for 5 s; a merely stalled caller queues again on its next call. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
…e lease One lock actor serves a few thousand calls a second. With 1 s polls, 8,000 idle waiters on a shard cut uncontended lock cycles there from 1,629/s to 1/s, renewals included; with 5 s polls it keeps ~1,100/s. The window is also capped at lease_s / 3, so a renewal that waits for a slot behind every waiter still gets one well within its lease. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
0oshowero0
left a comment
There was a problem hiding this comment.
[AI Review] Round 2
Re-checked at d36d05e. Items 1–16 are fixed as described and hold up locally: the 41 tests pass, and the earlier repros now behave as intended. For example, the multi-key request in 5 is granted in 29–46 ms instead of 0.3–1.5 s, and a hot-key handoff takes under 0.1 ms whether 1 or 10,000 waiters queue (it was 0.3 ms and 108 ms). Thanks for the thorough follow-up.
Three new items, all from the long-poll in 420b822: 18 (must fix), 19 (suggested), 20 (minor).
| except asyncio.TimeoutError: | ||
| pass | ||
| lease = self._leases.get(token) | ||
| return None if lease is None else lease["expires_at"] - time.monotonic() |
There was a problem hiding this comment.
[AI Review] 18 · Must fix: a grant the actor has forgotten makes the client spin
wait returns None both while the token is still queued and once the actor no longer knows it, and _take_grant treats every None as "still queued" (kv_lock.py L311–315). If a grant lands between two polls and the client does not poll again within lease_s (a stalled process, or calls backed up on the actor), the unrenewed lease expires and the actor drops the token. From then on every wait returns None at once, so the client loops on RPCs until its deadline, or forever with timeout=None, while the key sits free.
Locally, a client paused for 3.5 s with lease_s=1 made 9,421 wait calls in about 4.5 s after resuming, then raised TimeoutError at 8.1 s; another client took the free key in 2 ms. Suggest returning a distinct result for an unknown token, and raising LockLostError (or re-queueing) on the client.
Repro
# Child process: ray.init(address="auto"), kv_lock._send_acquire wrapped to count calls.
with tq.kv_global_lock("spin", "spin_p", timeout=8, lease_s=1):
...
# -> TimeoutError after 8.1s, calls={'acquire': 1, 'wait': 9421}
# Driver: hold "spin", start the child, wait until kv_lock_list shows it queued, then
os.kill(child.pid, signal.SIGSTOP) # the client stalls
time.sleep(1.5) # its in-flight wait call times out on the actor
held.__exit__(None, None, None) # the actor hands "spin" to the stalled client...
time.sleep(2.0) # ...whose 1 s lease runs out unrenewed
os.kill(child.pid, signal.SIGCONT)There was a problem hiding this comment.
Fixed in 5d2b183, by re-queueing rather than raising. wait is gone: every re-poll repeats acquire with the same token. While the token is queued, that keeps its place; once granted, it returns the time left on the lease; if the actor no longer knows the token (its grant expired before the caller saw it), it queues it afresh, or grants it at once if the keys are free. Each call blocks on the actor for up to one poll window, so the client can no longer loop on instant replies. I chose re-queueing over LockLostError because the caller never entered the block, so it has not lost anything it wrote under.
test_a_grant_that_expires_while_the_caller_stalls_is_taken_again stalls the client past its lease between two polls (it times out on the old code), and test_a_grant_that_expires_before_the_caller_sees_it_queues_afresh checks that the re-poll queues behind the current holder instead of returning at once.
| async def wait(self, token: str, timeout: float) -> float | None: | ||
| """Return the seconds left on ``token``'s lease once granted, else None after ``timeout``. | ||
|
|
||
| A waiter that times out keeps its place in the queues; only ``release`` withdraws it. |
There was a problem hiding this comment.
[AI Review] 19 · A waiter that dies while queued stalls the queue for a full lease
Since only release withdraws a waiter, a client killed while queued (a retried rollout worker, for example) keeps its place. When it reaches the head it is granted, and the key moves on only when that lease expires. Locally, with lease_s=5, the next waiter got the key 5.0 s after it was released; with the default that is 30 s.
Waiters now poll every second, which gives the actor a free heartbeat: dropping a waiter that has not polled for a few windows (from the _expire timer, for example) would bound this to a few seconds. With 18 in place, a live but slow client whose entry was dropped would then re-queue or raise instead of spinning.
There was a problem hiding this comment.
Fixed in 7110613, using the poll as a heartbeat as you suggested. For each waiter the actor tracks how many of its calls are in flight and when the last one ended. A queued client sends its next acquire as soon as one returns, so a waiter with no call in flight for 5 s is treated as dead: hand-off drops it and moves on to the next waiter instead of granting it a lease that would only expire. The check runs at hand-off rather than from the _expire timer, since that is the only point where a dead waiter costs anything, and it needs no extra timer. A client that was only stalled re-queues at the back on its next call, through the idempotent acquire from 18.
One case remains: a waiter that died only a few seconds before reaching the head can still be granted, and the key then idles for that caller's lease. test_a_waiter_that_stopped_polling_is_skipped covers the dead-waiter case.
| _owned: list = [] # the lock actors this process's tq.init() created, killed by its tq.close() | ||
| # Longest one call waits inside a lock actor; waiting longer takes another call that keeps | ||
| # the queue position. Bounding each call keeps waiters from pinning the actor's concurrency slots. | ||
| _POLL_S = 1.0 |
There was a problem hiding this comment.
[AI Review] 20 · Minor: polling cost of long waits
Each waiter now makes one call per second while it waits. With 1,000 waiters on a held key, the lock actor used about 32% of a core (0% when idle), so roughly 3,000 long waiters would saturate one shard. This should not matter at the contention OPD or rollout retries are expected to see. If long waits are expected, a longer window would cut the cost: renewals only need to land within lease_s, so a few seconds is still safe, at the price of a slower heartbeat for 19.
There was a problem hiding this comment.
Addressed in fce1c2b: the poll window is now 5 s, capped at lease_s / 3 and at the remaining timeout, so a long waiter costs about a fifth of what it did. One actor serves about 4,700 acquire calls/s. With 1 s polls, 8,000 idle waiters on one shard cut an unrelated client's locks there from 1,629/s to 1/s; with 5 s polls it keeps about 1,100/s. The lease_s / 3 cap keeps renewals safe: even if waiters take every actor slot, a renewal waits at most one window for a slot, the same margin as the renewer's period.
ef84850 makes lock.max_concurrency configurable (default 10000) and documents the capacity in the kv_global_lock docstring. About num_shards * max_concurrency requests can wait at once in total, while all waiters on one hot key share one actor and are bounded by max_concurrency alone. Past that, calls queue in Ray, so renewal and release are delayed by at most one poll window; they are never stuck and no lock is lost.
The full per-lock call count (one acquire + one release per shard, plus polls and batched renewals) and uncontended overhead are in the queue-design comparison: #186 (comment)
…s under its lock close() marked this process's leases lost but left them registered, so the renewer kept calling dead actors until each block exited; it now empties the registry and wakes the renewer. A grant's deadline is set under the same lock the renewer holds when it moves it. The docstring notes that an unresponsive actor can stretch timeout by about 1 s. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
|
Follow-up after re-reading the long-poll change critically. Four commits fix what I found; the PR description is updated to match.
Local verification: pre-commit, license and docstring checks pass. The full suite passes (709 passed) apart from the Yuanrong tests that need |
…t the limits lock.max_concurrency (default 10000) replaces the hard-coded value and is validated like lock.num_shards. The kv_global_lock docstring states how many requests can wait at once, which setting raises the total or a hot key's share, and what happens past the limit. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Per-key FIFO queues vs. one FIFO per shard + scanI compared the current Conclusion: keep the per-key queues. FIFO+scan never wins: it ties (within run-to-run noise) when few waiters queue on a shard and loses once many do. With a Designs compared
Cost analysisN = waiters on the shard, k = keys per request, q = length of one key's queue (q ≤ N; q = N for a single hot key), H = entries in the expiry heap (live leases plus stale entries left by renewals; about 4 per lease with renewal every
Notes:
MeasurementsPure-Python cost of one release, including hand-off and the resulting grant, with N = 50,000 waiters queued (no Ray):
fifo costs about 0.25–0.35 µs per queued waiter per release; perkey stays at or below 4.2 µs in every scenario from N = 10 to N = 50,000. End-to-end through Ray, one lock shard, client actors driving the real
At N = 100 on one hot key the three land at 1118 / 1102 / 1336 locks/s. There the in-actor hand-off costs differ by at most 24 µs per release (0.3 µs between perkey and fifo_ee), a few percent of the time per lock at those rates, so that spread is Ray run-to-run variation, not the data structure. Uncontended overhead of the current lock (p50 / p99):
Ray calls counted inside the actors match the analysis: exactly 1 Fairness check: after each of 20,000 random acquire / release / withdraw operations, perkey, fifo and fifo_ee had granted the same set of tokens (2,010 grants compared). In a starvation test, a waiter for Code size (lock manager only; the client side is shared): perkey 209 lines, fifo 214 lines, fifo_ee fifo + a 30-line override. Environment and methodApple M5 Pro (18 cores), 48 GiB RAM, macOS (Darwin 25.6.0), Python 3.13.13, Ray 2.57.0. All processes ran on one local Ray cluster, so there is no cross-machine network latency. A multi-node cluster adds network latency to every Ray call, which leaves the in-actor lock logic an even smaller share of the total. The benchmark is a local harness, not part of this PR. fifo is a full copy of |
|
Thanks for round 2. 18, 19 and 20 are answered in their threads (5d2b183, 7110613, fce1c2b + ef84850). Two updates to my earlier reply on items 11 and 12, which describe a 1 s window and a
|
0oshowero0
left a comment
There was a problem hiding this comment.
[AI Review] Round 3
Re-checked at ef84850: 18, 19 and 20 hold up locally (48 tests pass). After an unseen grant expires, a stalled client re-queues with 3 calls and takes the key at 4.0 s instead of spinning; a waiter dead for 8 s is skipped at hand-off (0.0 s instead of 5.0 s; the dead-for-1 s case remains, as you noted); 1,000 long waiters cost the actor 10% of a core instead of 32%. The per-key vs FIFO+scan comparison is convincing, and keeping the per-key queues makes sense.
One new item, 21, inline.
| Past the limit, calls queue in Ray: a waiting call frees its slot within its poll window | ||
| (5 s, or ``lease_s / 3`` if shorter), so renewal and release are delayed, never stuck. |
There was a problem hiding this comment.
[AI Review] 21 · Past the concurrency limit, holders can lose their leases
This docstring (and the reply on 12) says that past the limit renewal and release are delayed by at most one poll window. Locally they got stuck instead. With lock.num_shards: 1, lock.max_concurrency: 4, one holder (lease_s=15) holding for 40 s and 16 waiters, none of the holder's renewals got through: its lease was lost at 25 s and exit raised LockLostError. After it released, none of the 16 waiters got the key before their 100 s timeouts. The same run with max_concurrency: 10000 keeps the lease.
Two causes:
- Ray serves queued calls in arrival order, so a renewal or release waits behind every acquire queued ahead of it, roughly (calls queued ahead / slots) windows rather than one. With the client patched never to re-send (below), the holder still lost its lease at 25 s.
- When a reply is late (
window + _REPLY_SLACK_S), the client sends a newacquirewhile the old one stays queued and later takes a slot, so the backlog keeps growing. With that patch, 7 of the 16 waiters got the key instead of 0.
From reading the code (not measured separately): a call still queued in Ray does not count in calls, so past the limit a live waiter can look abandoned at hand-off and lose its place.
By the same arithmetic, the defaults (30 s lease, 5 s window) need roughly 7× max_concurrency waiters on one actor before a lease is lost, so 10,000 is far away. But max_concurrency is now configurable, and a hot key puts every waiter on one actor. Suggestions:
- Don't stack calls: while the previous call is pending, keep waiting on it instead of sending another.
- Keep slots free for renewal and release: have the actor cap parked acquires a little below
max_concurrencyand reply at once past the cap, with the client backing off one window before it polls again. Ray concurrency groups would also isolate them, but in an async actor each group runs on its own thread (checked locally), which breaks the single-loop assumption the lock state relies on. - If neither is done, the docstring should say that past the limit a holder may lose its lease.
Repro
tq.init(OmegaConf.create({..., "lock": {"enabled": True, "num_shards": 1, "max_concurrency": 4}}))
def holder():
with tq.kv_global_lock("sat", P, timeout=10, lease_s=15) as lease:
time.sleep(40) # lease.lost turns True at ~25 s; exit raises LockLostError
def waiter():
with tq.kv_global_lock("sat", P, timeout=100, lease_s=15):
pass # all 16 raise TimeoutError
# Start the holder, then 16 waiter threads.
# No-resend patch: keep waiting on the pending call while it is not ready.
def send_once(lease, shard, window):
ref = pending.get((lease.token, shard))
if ref is not None and not ray.wait([ref], timeout=0)[0]:
return ref
ref = pending[(lease.token, shard)] = original_send_acquire(lease, shard, window)
return refThere was a problem hiding this comment.
Confirmed. Our reply on 12 and the docstring were wrong: past the limit, renewal and release were not "delayed by at most one poll window, never stuck". Ray serves an actor's queued calls in arrival order, so once waiting acquires took every slot, a renewal waited behind all of them.
Reproduced at ef84850 with your setup (num_shards: 1, max_concurrency: 4, one holder with lease_s=15 holding for 40 s, 16 waiters with 100 s timeouts):
| Run | Holder | Waiters that got the key |
|---|---|---|
| ef84850 | no renewal got through; its deadline passed at 15.1 s and exit raised LockLostError |
0/16, all timed out |
| ef84850 + your no-resend patch | the same, lost at 15.1 s | 6/16 |
ef84850, max_concurrency: 10000 |
kept its lease | 16/16, at 40.1 s |
b256abe, max_concurrency: 4 |
kept its lease, clean exit | 16/16, between 40.1 s and 60.1 s |
Two things are worse than the comment says:
- In the no-resend run, a waiter entered its block at 25.0 s while the original holder stayed in its block until 40 s, so two holders were in the critical section at once. The first holder's
check()would have raised from 15 s on, but a body that does not call it keeps writing. - The threshold under the defaults is lower than 7×. Each stuck waiter added a call every
window + slack(6 s), while an actor finishesmax_concurrencyparked calls per 5 s window. So the backlog grows without bound once one actor has more than about 1.2 ×max_concurrencywaiters, roughly 12,000 at the default.
Your third point holds too. In an actor-level probe with max_concurrency: 2, a waiter whose next call sat in Ray's queue for more than 5 s was skipped at hand-off as abandoned, and two later waiters got the key before it.
Fixed in three commits, along the lines of your first two suggestions:
- efe17ea corrects the docstring and the comments that claimed "never stuck".
- 5810475 keeps one acquire in flight per shard: a reply later than its window plus slack is awaited again instead of re-sent, so calls no longer pile up in the actor.
test_a_late_reply_is_awaited_instead_of_sent_again(sync and async) counts 2 sends on the old code and 1 now. - b256abe reserves slots.
lock.max_concurrencynow caps the acquire calls an actor lets wait, andtq.init()gives the actor_RESERVED_SLOTS(16) more for calls that never wait: renew, release,list_locksand acquires turned away. Past the cap,acquirekeeps the caller's queue position but returns at once, and the client sleeps out the rest of its window before polling again, so it does not spin. Since such a waiter has no call in flight for up to a window,_ABANDONED_Sgoes from 5 s to 10 s. Waiting calls now stay within their slots, so Ray's queue stays short and a live waiter no longer looks abandoned because its call is stuck there.test_renewal_and_release_run_while_waiters_fill_the_actorfills both waiting slots of an actor sized formax_concurrency: 2; on the old code the extra acquire and the renewal time out, and now both return within 1 s.
I left concurrency groups out, for the single-loop reason you gave. The cost of the cap is that a waiter turned away by a full actor sees its grant up to one window late (in the repro above, the last waiters entered in 5 s steps). The docstring, the config.yaml comment and the PR description now say this.
|
Test size and run time At ef84850 the tests for this PR are 1,064 lines (43 test functions, 48 cases with parameters), against 747 lines of lock code (
The four files take 126 s locally (Ray 2.56, Python 3.11). Most of that is cluster setup and sleeps: the SimpleStorage read-modify-write test in Several cases check the same behavior. For example, the single-key read-modify-write and the two-shard opposite-order test both assert an exact counter, the single-shard timeout test is a subset of the multi-shard one, and the client-level and actor-level tests for an unseen expired grant cover the same fix. Please cut these tests down as far as you can. A small set that exercises the main lock paths is enough; there is no need to keep a dedicated test for every edge case or every fix made during review. |
Drop cases another test already covers: the single-key read-modify-write (the two-shard opposite-order test asserts the same counter), the single-shard timeout (a subset of the multi-shard one), the client-level unseen-grant test (the actor-level one checks the same fix), the thread/task wake test (the mixed read-modify-write test hangs without it), the SimpleStorage local-lock test, the single-shard re-init test, and test_close_ownership_e2e.py, whose checks move into the non-owner and owner close tests. Pin the shard hash instead of comparing subprocesses, and shorten the later-shard renewal wait. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
…s can be lost Ray serves queued calls in arrival order, so once waiting acquires take every slot of a lock actor, renewal and release wait behind all of them, not for one poll window. Say that holders can lose their leases there and drop the comments that claimed otherwise. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
When a reply came later than its window plus slack, the caller sent a new acquire while the old one stayed queued in the actor and later took a slot, so a busy actor's backlog kept growing and waiters starved even after the holder released. Wait on the pending call again instead, and send the next one only after it returns. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Waiting acquires could take every slot of a lock actor, and Ray serves queued calls in arrival order, so renewals waited behind them and holders lost their leases. lock.max_concurrency now caps the acquire calls an actor lets wait; tq.init() gives it _RESERVED_SLOTS more for calls that never wait. Past the cap an acquire keeps its queue place and returns at once, and the caller sleeps out the rest of its window before polling again. Such a caller has no call in flight for up to a window, so a waiter now counts as abandoned after 10 s instead of 5 s. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
|
Thanks for the test size review. The tests are cut down to the core cases in 175b6af and 9261e6a.
What remains:
Removed: The dead-waiter test no longer sleeps past the 10 s abandonment threshold: it backdates the waiter's last call through |
Ray may run two calls to an async actor out of order, so the second waiter was sometimes queued first and granted on the holder's release, leaving the first to wait out a 30 s lease. Queue them in turn, as the arrival-order test does. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Keep ten end-to-end tests for kv_global_lock (exclusion without deadlock, multi-shard timeout cleanup, queue order, unseen expired grants, dead waiters, reserved slots, late replies, killed holders, lease renewal and loss, and the owner's close) and three for kv_local_lock (exclusion across threads and loops, FIFO order, and timeout cleanup). Drop the rest, including tests/test_interface_close.py. The dead-waiter test now backdates the waiter's last call instead of sleeping past the threshold. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
An attached process's tq.close() releases its leases, which withdraws a thread still waiting at a later shard. That thread treated the early reply as a full actor, sent the same token again and queued afresh, then waited for a lease that was already lost. The wait loops now stop with LockLostError once the lease is lost, which also ends a wait whose earlier shard's lease ran out. release tombstones a withdrawn waiter too, so a call Ray runs after the release cannot queue it again. The lease keeps the handle each acquire went to, so its release still goes out after close() dropped this process's handles. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
…ck lease The renewer waited on each shard's renewal in turn, with a budget of 3 * period, which equals the shortest lease_s. One shard that did not answer used the whole budget, so every lease, even one living only on healthy shards, ran out. It now sends all renewals and collects them with one ray.wait bounded by the period. A shard that does not answer in time is not judged: its leases keep their deadline and run out only when it passes. Only an explicit refusal, or a dead lock actor, marks a lease lost. The deadline and lost updates now happen under _registry, as _take_grant's do, so the renewer cannot overwrite a shorter deadline that _take_grant just set for a newly granted shard. That race has no deterministic test. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
…with Each acquire carried the process's current cached shard count rather than the one the lease's keys were hashed with. A waiter whose process ran tq.close() and tq.init() against a TransferQueue with another lock.num_shards re-read the count before its next call, so the actor's check passed while the keys went to the wrong shard. GlobalLease now records the count its keys were hashed over, and every acquire sends that. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
…and names Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
…nment variable Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
…k.num_shards Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
…TING_ACQUIRES and document the lock Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
b4b7b71 to
ece5c46
Compare
… nodes Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
…SHARDS Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
…hard grant tq.close() only marked leases already in the renewer registry as lost, so a waiter still queued for its first shard kept going and could take the keys afterwards. Count close() calls and have lease.check() fail once that count moves; the existing attached-close e2e now holds the waiter's first shard. Signed-off-by: 0oshowero0 <o0shower0o@outlook.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Adds advisory locks for TransferQueue KV keys, so read-modify-write sequences such as
kv_batch_get→ compute →kv_putdon't lose updates when several threads or Ray workers touch the same key.kv_local_lock: a process-local mutex per(partition_id, key), shared by all threads and asyncio tasks of one process. It needs no configuration and makes no Ray calls.kv_global_lock: a cluster-wide mutex per(partition_id, key), held under an auto-renewed lease byTransferQueueLockManagerRay actors. It is opt-in: setting the environment variableTQ_ENABLE_KV_GLOBAL_LOCK=1(parsed like other TQ boolean flags:1/true/yes/y/on) before the owningtq.init()starts the actors, and keys are sharded acrossTQ_KV_GLOBAL_LOCK_NUM_SHARDSof them (default 8), spread evenly over the cluster's nodes.close()now tears TransferQueue down only in the process whoseinit()created it. Before this change,close()in any attached worker killed the controller for the whole cluster. The global lock relies on the same ownership rule for its actors.Both locks are advisory: KV calls never check them, so every writer that needs mutual exclusion has to take the lock.
Usage
Async context managers:
async with tq.async_kv_local_lock(...)andasync with tq.async_kv_global_lock(...).kv_global_lockyields atq.GlobalLease, whose public surface ischeck()(call it right before writes) and the read-onlynames(the locked(partition_id, key)pairs); its other state is private.Configuration
There is no
config.yamlsection; two environment variables, read only by thetq.init()that creates TransferQueue (set them before that call), configure the lock. Attaching processes and Ray workers find the lock actors by name and need not set them.TQ_ENABLE_KV_GLOBAL_LOCK1/true/yes/y/onstarts the lock actors; while off,kv_global_lockraises aRuntimeErrortelling the user to set itTQ_KV_GLOBAL_LOCK_NUM_SHARDS8TQ_KV_GLOBAL_LOCK_MAX_WAITING_ACQUIRES10000The number of lock actors (shards) is
TQ_KV_GLOBAL_LOCK_NUM_SHARDS, 8 by default, for every backend; an earlier revision of this PR tied the count to the storage unit count, which is no longer the case. Both integer variables must be decimal integers >= 1; anything else (0,-1,abc,true,2.5) raises aValueErrornaming the variable beforetq.init()creates any actor.tq.init()pins them round-robin to the alive Ray nodes, sorted by node ID, with hardNodeAffinitySchedulingStrategythrough the helper that already places SimpleStorage units: with fewer nodes than shards, per-node counts differ by at most 1; with more, each shard gets its own node. README.md documents the lock under the KV API.Capacity limits:
TQ_KV_GLOBAL_LOCK_MAX_WAITING_ACQUIRESacquire calls wait at once.tq.init()sets the actor's Raymax_concurrencyto that plus 16 slots (_RESERVED_SLOTS) for calls that never wait: renew, release,list_locksand acquires turned away. Parked acquires therefore cannot occupy the slots renewal and release need. The reserved slots give no priority, though: they are shared with turned-away acquires andlist_locks, and calls beyond all slots queue in Ray's FIFO, behind turned-away acquires.lease_s / 3if shorter). Such a request may see its grant up to one window late.shards × TQ_KV_GLOBAL_LOCK_MAX_WAITING_ACQUIRESrequests can wait without that delay (80,000 by default). All waiters on one key land on one actor, so a hot key is bounded byTQ_KV_GLOBAL_LOCK_MAX_WAITING_ACQUIRESalone.Rules
keysis a key or a list of keys in one partition; duplicates are ignored and an empty list raisesValueError.timeout=Nonewaits forever; otherwiseTimeoutErroris raised and nothing stays held. For the global lock, the wait can overruntimeoutby up to 1 s (the time allowed for the last reply), and the first call in a process also looks up the lock actors, whichtimeoutdoes not bound.RuntimeError; pass all keys to one call instead. Tasks created inside an async block cannot take the same kind of lock while the block lasts.RuntimeError.RuntimeErroron a thread with a running event loop; use the async versions there.Design
Local lock
(partition_id, key)→ FIFO waiter queue, guarded by athreading.Lock. No Ray calls.Global lock
TQ_ENABLE_KV_GLOBAL_LOCKset, the owninginit()createsTransferQueueLockManager_0..N-1, N beingTQ_KV_GLOBAL_LOCK_NUM_SHARDS(async,num_cpus=0), placed round-robin over the alive nodes. Their naming, creation, lookup and teardown all live inkv_lock.py.crc32(partition_id + key) % N. Python'shash()is salted per process, so it can't be used here.tq.init(). The async path does this lookup in a worker thread, so it never blocks the event loop.ValueError. A process that attached before TransferQueue restarted gets the "actor is gone" error first, because its cached handles point to the dead actors. The check covers the narrower case of a process whose cached N predates a restart with anotherTQ_KV_GLOBAL_LOCK_NUM_SHARDSbut which finds the new actors by name, such as a child forked after its parent's first lock, or a waiter whose own process runsclose()andinit()mid-wait.lease_s / 3; the client then repeatsacquirewith the same token, which keeps the queue position. Each reply is awaited with a bound, so an unresponsive actor cannot outlast the caller's timeout by more than 1 s; a reply later than that is awaited again, not re-sent, so calls never pile up in the actor. Parked acquires never take the reserved slots, but renew and release share them with no priority (see Capacity limits).acquirequeues the token again instead of reporting "not granted" forever.lease_s / 3, with one batched call per shard. It sends all shards' calls at once and waits at most one period for their replies, so a shard that does not answer delays no other shard's renewals; its own leases keep their deadline and run out once it passes. Renewal starts as soon as the first shard grants, so waiting on a later shard cannot let an earlier shard's lease run out.check()raises, waiting for later shards stops withLockLostError, and exiting the block raises unless the body already raised. The lease's state is private, socheck()is the only way to ask.check()only narrows the window. KV calls take no fencing token, so a write that outlasts the remaining lease can still land after another holder took the keys.KeyboardInterrupt, task cancellation) sendsrelease(token). This withdraws a request that is still queued or in flight, so a caller that has left is never left holding the lock; a grant made just before the release arrives is freed by it, and a withdrawn token cannot queue again even if one of its calls runs after the release. Exit sends the release without waiting, sokv_lock_listmay briefly still list a holder that just left.close()kills all lock actors.close()releases its own leases and leaves the actors running. A thread of that process still waiting for keys on a later shard stops withLockLostErrorrather than queuing again.RuntimeErrorand holders getLockLostError.close()stops this process's renewer right away, even while blocks are still open.Close ownership
init()records ownership only when it actually creates the controller. In other processes,close()closes the client and drops the handles, so they can attach again later. Storage cleanup was already owner-only.Performance
All numbers below were measured on earlier revisions of this PR and have not been re-measured on the current one. None includes the last two changes (one acquire in flight per shard and the reserved slots), and the 2-node cluster numbers also predate the FIFO queue, long-polling and the abandoned-waiter skip.
Measured on 2 nodes (about 375 CPUs each) with Ray 2.51.1. Client actors were spread across both nodes; each lock is an acquire plus a release round trip.
Uncontended single-key locks, distinct keys per client (locks/s, with p50 latency):
Tests
The suite keeps only the core cases: 16 tests in two files.
tests/test_kv_lock.pycovers the local lock: exclusion across threads and asyncio tasks on several event loops (an exact read-modify-write counter), FIFO order, and a timeout that is one deadline for all keys and releases the keys already taken. It also checks, against 3 fake nodes, that the default 8 lock actors are pinned 3/3/2 andTQ_KV_GLOBAL_LOCK_NUM_SHARDS=3gives one per node, all with hard node affinity, and that invalid shard or waiting-limit values raise.tests/e2e/test_kv_global_lock_e2e.pycovers the actor-backed global lock:[a, b]and[b, a]on two shards, without deadlock;close()stops its thread that waits at a later shard withLockLostError, and leaves the lock actors and controller running;close()removes every TransferQueue actor, fails waiters, and stops the renewer while a block is still open.