Skip to content

Commit 755c017

Browse files
author
Dylan Huang
committed
Add singleton lock functionality for process management
- Introduced a new module `singleton_lock.py` that implements file-based singleton lock management to ensure only one instance of a process can run at a time. - Added functions for acquiring, releasing, and checking the status of locks, along with mechanisms for handling stale locks. - Implemented tests in `test_singleton_lock.py` and `test_singleton_lock_multiprocessing.py` to validate the lock behavior under various scenarios, including concurrent access and cleanup of stale locks.
1 parent 5ca5d65 commit 755c017

3 files changed

Lines changed: 1090 additions & 0 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""
2+
Singleton Lock Management
3+
4+
This module provides file-based singleton lock functionality for ensuring only one
5+
instance of a process can run at a time across the system.
6+
7+
The lock mechanism uses two files:
8+
- A PID file that contains the process ID of the lock holder
9+
- A lock file that serves as a marker for the lock
10+
11+
This approach provides atomic lock acquisition and proper cleanup of stale locks
12+
from terminated processes.
13+
"""
14+
15+
import os
16+
from pathlib import Path
17+
from typing import Optional, Tuple
18+
19+
20+
def get_lock_file_paths(base_dir: Path, lock_name: str) -> Tuple[Path, Path]:
21+
"""
22+
Get the lock file paths for a given lock name.
23+
24+
Args:
25+
base_dir: Base directory where lock files should be stored
26+
lock_name: Name identifier for the lock (e.g., "watcher", "server")
27+
28+
Returns:
29+
Tuple of (lock_file_path, pid_file_path)
30+
"""
31+
lock_file_path = base_dir / f"{lock_name}.lock"
32+
pid_file_path = base_dir / f"{lock_name}.pid"
33+
return lock_file_path, pid_file_path
34+
35+
36+
def acquire_singleton_lock(base_dir: Path, lock_name: str) -> Optional[int]:
37+
"""
38+
Try to acquire the singleton lock. Returns the PID of the current holder if failed.
39+
40+
Args:
41+
base_dir: Base directory where lock files should be stored
42+
lock_name: Name identifier for the lock
43+
44+
Returns:
45+
None if lock acquired successfully, otherwise the PID of the current holder
46+
"""
47+
lock_file_path, pid_file_path = get_lock_file_paths(base_dir, lock_name)
48+
49+
# First, check if PID file exists and contains a running process
50+
if pid_file_path.exists():
51+
try:
52+
with open(pid_file_path, "r") as pid_file:
53+
content = pid_file.read().strip()
54+
if content.isdigit():
55+
pid = int(content)
56+
# Check if the process is still running
57+
try:
58+
os.kill(pid, 0)
59+
# Process is running, we can't acquire the lock
60+
return pid
61+
except OSError:
62+
# Process is not running, clean up stale files
63+
pass
64+
except (IOError, OSError):
65+
pass
66+
67+
# Try to create the PID file atomically
68+
temp_pid_file = None
69+
try:
70+
# Use atomic file creation
71+
temp_pid_file = pid_file_path.with_suffix(".tmp")
72+
with open(temp_pid_file, "w") as temp_file:
73+
temp_file.write(str(os.getpid()))
74+
temp_file.flush()
75+
os.fsync(temp_file.fileno())
76+
77+
# Atomically move the temp file to the final location
78+
temp_pid_file.rename(pid_file_path)
79+
80+
# Create the lock file to indicate we have the lock
81+
with open(lock_file_path, "w") as lock_file:
82+
lock_file.write(str(os.getpid()))
83+
lock_file.flush()
84+
os.fsync(lock_file.fileno())
85+
86+
return None # Successfully acquired lock
87+
88+
except (IOError, OSError) as e:
89+
# Failed to acquire lock
90+
try:
91+
if temp_pid_file and temp_pid_file.exists():
92+
temp_pid_file.unlink()
93+
except (IOError, OSError):
94+
pass
95+
96+
# Check if someone else got the lock
97+
if pid_file_path.exists():
98+
try:
99+
with open(pid_file_path, "r") as pid_file:
100+
content = pid_file.read().strip()
101+
if content.isdigit():
102+
return int(content)
103+
except (IOError, OSError):
104+
pass
105+
106+
return 999999 # Dummy PID to indicate lock is held
107+
108+
109+
def release_singleton_lock(base_dir: Path, lock_name: str) -> None:
110+
"""
111+
Release the singleton lock.
112+
113+
Args:
114+
base_dir: Base directory where lock files are stored
115+
lock_name: Name identifier for the lock
116+
"""
117+
lock_file_path, pid_file_path = get_lock_file_paths(base_dir, lock_name)
118+
try:
119+
if pid_file_path.exists():
120+
pid_file_path.unlink()
121+
if lock_file_path.exists():
122+
lock_file_path.unlink()
123+
except (IOError, OSError):
124+
pass
125+
126+
127+
def is_process_running(pid: int) -> bool:
128+
"""
129+
Check if a process is still running.
130+
131+
Args:
132+
pid: Process ID to check
133+
134+
Returns:
135+
True if the process is running, False otherwise
136+
"""
137+
try:
138+
os.kill(pid, 0)
139+
return True
140+
except OSError:
141+
return False
142+
143+
144+
def is_lock_held(base_dir: Path, lock_name: str) -> bool:
145+
"""
146+
Check if a lock is currently held by a running process.
147+
148+
Args:
149+
base_dir: Base directory where lock files are stored
150+
lock_name: Name identifier for the lock
151+
152+
Returns:
153+
True if the lock is held by a running process, False otherwise
154+
"""
155+
_, pid_file_path = get_lock_file_paths(base_dir, lock_name)
156+
157+
try:
158+
if pid_file_path.exists():
159+
with open(pid_file_path, "r") as pid_file:
160+
content = pid_file.read().strip()
161+
if content.isdigit():
162+
pid = int(content)
163+
if is_process_running(pid):
164+
return True
165+
except (IOError, OSError):
166+
pass
167+
168+
return False
169+
170+
171+
def get_lock_holder_pid(base_dir: Path, lock_name: str) -> Optional[int]:
172+
"""
173+
Get the PID of the process currently holding the lock.
174+
175+
Args:
176+
base_dir: Base directory where lock files are stored
177+
lock_name: Name identifier for the lock
178+
179+
Returns:
180+
PID of the lock holder if the lock is held by a running process, None otherwise
181+
"""
182+
_, pid_file_path = get_lock_file_paths(base_dir, lock_name)
183+
try:
184+
if pid_file_path.exists():
185+
with open(pid_file_path, "r") as pid_file:
186+
content = pid_file.read().strip()
187+
if content.isdigit():
188+
pid = int(content)
189+
if is_process_running(pid):
190+
return pid
191+
except (IOError, OSError):
192+
pass
193+
return None
194+
195+
196+
def cleanup_stale_lock(base_dir: Path, lock_name: str) -> bool:
197+
"""
198+
Clean up a stale lock (lock files exist but process is not running).
199+
200+
Args:
201+
base_dir: Base directory where lock files are stored
202+
lock_name: Name identifier for the lock
203+
204+
Returns:
205+
True if stale lock was cleaned up, False if no cleanup was needed
206+
"""
207+
lock_file_path, pid_file_path = get_lock_file_paths(base_dir, lock_name)
208+
209+
# Check if PID file exists but process is not running
210+
if pid_file_path.exists():
211+
try:
212+
with open(pid_file_path, "r") as pid_file:
213+
content = pid_file.read().strip()
214+
if content.isdigit():
215+
pid = int(content)
216+
if not is_process_running(pid):
217+
# Process is not running, clean up stale files
218+
release_singleton_lock(base_dir, lock_name)
219+
return True
220+
except (IOError, OSError):
221+
# If we can't read the PID file, clean it up
222+
release_singleton_lock(base_dir, lock_name)
223+
return True
224+
225+
return False

0 commit comments

Comments
 (0)