Skip to content

Commit 6d533f9

Browse files
fengluodbdingben.db@bytedance.com
andauthored
feat(fs): expose isLocked in stat() to surface path-lock state (#1940)
Adds an `isLocked` boolean to the dict returned by `VikingFS.stat()` so callers (and the `/api/v1/fs/stat` endpoint, which transparently passes the dict through) can tell whether a resource is currently held by a path lock without having to attempt a write and observe `ResourceBusyError`. The lookup reuses the same conflict-detection semantics as the acquire flow: a path is reported as locked when it has a valid `.path.ovlock` or when any ancestor directory holds a SUBTREE lock; stale locks are ignored because the next acquirer would reclaim them anyway. To make the check available to higher layers, a public `PathLock.is_locked()` helper is introduced and surfaced through `LockManager.is_path_locked()`; both are best-effort and degrade to `False` when the LockManager is unavailable, keeping `stat()` resilient. Co-authored-by: dingben.db@bytedance.com <dingben.db@bytedance.com@bytedance.com>
1 parent fafb3e9 commit 6d533f9

4 files changed

Lines changed: 122 additions & 2 deletions

File tree

openviking/storage/transaction/lock_manager.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,20 @@ def get_handle(self, handle_id: str) -> Optional[LockHandle]:
197197
return None
198198
return current
199199

200+
def is_path_locked(self, path: str, ignore_stale: bool = True) -> bool:
201+
"""Check whether *path* is currently locked.
202+
203+
Semantics align with conflict detection in the acquire flow: the path
204+
is considered locked if it (or any ancestor) holds a lock. By default,
205+
stale locks are ignored because they will be reclaimed on the next
206+
acquire attempt.
207+
"""
208+
try:
209+
return self._path_lock.is_locked(path, ignore_stale=ignore_stale)
210+
except Exception as e:
211+
logger.warning(f"is_path_locked failed for {path}: {e}")
212+
return False
213+
200214
async def refresh_lock(self, handle: LockHandle) -> None:
201215
current = self._reconcile_handle(handle)
202216
if current is None:

openviking/storage/transaction/path_lock.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,42 @@ def is_lock_owned_by(self, lock_path: str, owner_id: str) -> bool:
115115
current_owner_id, _ = self._read_owner_and_type(lock_path)
116116
return current_owner_id == owner_id
117117

118+
def is_locked(self, path: str, ignore_stale: bool = True) -> bool:
119+
"""Check whether *path* is currently locked.
120+
121+
Detection rules (aligned with conflict checks in the acquire flow):
122+
- The path itself has a valid .path.ovlock; or
123+
- Any ancestor directory holds a SUBTREE lock.
124+
125+
Args:
126+
path: Path to check (already converted to AGFS internal path).
127+
ignore_stale: Whether to ignore expired (stale) locks. Defaults
128+
to True to stay consistent with the acquire flow: stale
129+
locks will be cleaned up by the next acquirer, so they are
130+
not considered as held here.
131+
"""
132+
# 1. Lock on the path itself
133+
own_lock_path = self._get_lock_path(path)
134+
token = self._read_token(own_lock_path)
135+
if token is not None:
136+
if not (ignore_stale and self.is_lock_stale(own_lock_path, self._lock_expire)):
137+
return True
138+
139+
# 2. Ancestor SUBTREE locks
140+
parent = self._get_parent_path(path)
141+
while parent:
142+
ancestor_lock = self._get_lock_path(parent)
143+
ancestor_token = self._read_token(ancestor_lock)
144+
if ancestor_token is not None:
145+
_, _, lock_type = _parse_fencing_token(ancestor_token)
146+
if lock_type == LOCK_TYPE_SUBTREE and not (
147+
ignore_stale and self.is_lock_stale(ancestor_lock, self._lock_expire)
148+
):
149+
return True
150+
parent = self._get_parent_path(parent)
151+
152+
return False
153+
118154
def collect_lost_owner_locks(self, owner: LockOwner) -> list[str]:
119155
lost_paths: list[str] = []
120156
for lock_path in list(owner.locks):

openviking/storage/viking_fs.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -945,11 +945,29 @@ async def stat(self, uri: str, ctx: Optional[RequestContext] = None) -> Dict[str
945945
"""
946946
File/directory information.
947947
948-
example: {'name': 'resources', 'size': 128, 'mode': 2147484141, 'modTime': '2026-02-10T21:26:02.934376379+08:00', 'isDir': True, 'meta': {'Name': 'localfs', 'Type': 'local', 'Content': {'local_path': '...'}}}
948+
example: {'name': 'resources', 'size': 128, 'mode': 2147484141, 'modTime': '2026-02-10T21:26:02.934376379+08:00', 'isDir': True, 'isLocked': False, 'meta': {'Name': 'localfs', 'Type': 'local', 'Content': {'local_path': '...'}}}
949+
950+
Extra field:
951+
isLocked (bool): Whether the path is currently held by a path lock
952+
(either the path itself or any ancestor directory). Returns
953+
False when the LockManager is not initialized or the lookup
954+
fails.
949955
"""
950956
self._ensure_access(uri, ctx)
951957
path = self._uri_to_path(uri, ctx=ctx)
952-
return self.agfs.stat(path)
958+
result = self.agfs.stat(path)
959+
if isinstance(result, dict):
960+
result["isLocked"] = self._is_path_locked(path)
961+
return result
962+
963+
def _is_path_locked(self, path: str) -> bool:
964+
"""Best-effort path-lock lookup; returns False when LockManager is absent."""
965+
try:
966+
from openviking.storage.transaction import get_lock_manager
967+
968+
return get_lock_manager().is_path_locked(path)
969+
except Exception:
970+
return False
953971

954972
async def exists(self, uri: str, ctx: Optional[RequestContext] = None) -> bool:
955973
"""Check if a URI exists.

tests/transaction/test_path_lock.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,58 @@ def test_is_lock_stale_recent_token(self):
7373
assert lock.is_lock_stale("/test/.path.ovlock", expire_seconds=300.0) is False
7474

7575

76+
class TestPathLockIsLocked:
77+
def _agfs_with_locks(self, locks: dict[str, bytes]) -> MagicMock:
78+
"""Build an AGFS mock that only returns content for paths in *locks*."""
79+
agfs = MagicMock()
80+
81+
def _read(p):
82+
if p in locks:
83+
return locks[p]
84+
raise Exception("not found")
85+
86+
agfs.read.side_effect = _read
87+
return agfs
88+
89+
def test_is_locked_no_lock(self):
90+
agfs = self._agfs_with_locks({})
91+
lock = PathLock(agfs)
92+
assert lock.is_locked("/local/u/foo") is False
93+
94+
def test_is_locked_self_point_lock(self):
95+
token = _make_fencing_token("tx-1", LOCK_TYPE_POINT)
96+
agfs = self._agfs_with_locks({"/local/u/foo/.path.ovlock": token.encode("utf-8")})
97+
lock = PathLock(agfs)
98+
assert lock.is_locked("/local/u/foo") is True
99+
100+
def test_is_locked_ancestor_subtree_lock(self):
101+
token = _make_fencing_token("tx-1", LOCK_TYPE_SUBTREE)
102+
agfs = self._agfs_with_locks({"/local/u/.path.ovlock": token.encode("utf-8")})
103+
lock = PathLock(agfs)
104+
assert lock.is_locked("/local/u/foo/bar") is True
105+
106+
def test_is_locked_ancestor_point_lock_does_not_propagate(self):
107+
"""An ancestor POINT lock must not affect descendants -- POINT locks only the path itself."""
108+
token = _make_fencing_token("tx-1", LOCK_TYPE_POINT)
109+
agfs = self._agfs_with_locks({"/local/u/.path.ovlock": token.encode("utf-8")})
110+
lock = PathLock(agfs)
111+
assert lock.is_locked("/local/u/foo/bar") is False
112+
113+
def test_is_locked_ignores_stale_by_default(self):
114+
old_ts = time.time_ns() - int(600 * 1e9) # 600s ago
115+
stale = f"tx-dead:{old_ts}:{LOCK_TYPE_POINT}".encode("utf-8")
116+
agfs = self._agfs_with_locks({"/local/u/foo/.path.ovlock": stale})
117+
lock = PathLock(agfs, lock_expire=300.0)
118+
assert lock.is_locked("/local/u/foo") is False
119+
120+
def test_is_locked_can_include_stale(self):
121+
old_ts = time.time_ns() - int(600 * 1e9)
122+
stale = f"tx-dead:{old_ts}:{LOCK_TYPE_POINT}".encode("utf-8")
123+
agfs = self._agfs_with_locks({"/local/u/foo/.path.ovlock": stale})
124+
lock = PathLock(agfs, lock_expire=300.0)
125+
assert lock.is_locked("/local/u/foo", ignore_stale=False) is True
126+
127+
76128
class TestPathLockOwnership:
77129
async def test_refresh_reports_refreshed_lost_and_failed_paths(self):
78130
owned_path = "/locks/owned/.path.ovlock"

0 commit comments

Comments
 (0)