Various changes.
[python_utils.git] / lockfile.py
1 #!/usr/bin/env python3
2
3 from dataclasses import dataclass
4 import datetime
5 import json
6 import logging
7 import os
8 import signal
9 import sys
10 from typing import Optional
11
12 import decorator_utils
13
14
15 logger = logging.getLogger(__name__)
16
17
18 class LockFileException(Exception):
19     pass
20
21
22 @dataclass
23 class LockFileContents:
24     pid: int
25     commandline: str
26     expiration_timestamp: float
27
28
29 class LockFile(object):
30     """A file locking mechanism that has context-manager support so you
31     can use it in a with statement.
32     """
33
34     def __init__(
35             self,
36             lockfile_path: str,
37             *,
38             do_signal_cleanup: bool = True,
39             expiration_timestamp: Optional[float] = None,
40             override_command: Optional[str] = None,
41     ) -> None:
42         self.is_locked = False
43         self.lockfile = lockfile_path
44         self.override_command = override_command
45         if do_signal_cleanup:
46             signal.signal(signal.SIGINT, self._signal)
47             signal.signal(signal.SIGTERM, self._signal)
48         self.expiration_timestamp = expiration_timestamp
49
50     def locked(self):
51         return self.is_locked
52
53     def available(self):
54         return not os.path.exists(self.lockfile)
55
56     def try_acquire_lock_once(self) -> bool:
57         logger.debug(f"Trying to acquire {self.lockfile}.")
58         try:
59             # Attempt to create the lockfile.  These flags cause
60             # os.open to raise an OSError if the file already
61             # exists.
62             fd = os.open(self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
63             with os.fdopen(fd, "a") as f:
64                 contents = self._get_lockfile_contents()
65                 logger.debug(contents)
66                 f.write(contents)
67             logger.debug(f'Success; I own {self.lockfile}.')
68             self.is_locked = True
69             return True
70         except OSError:
71             pass
72         logger.debug(f'Failed; I could not acquire {self.lockfile}.')
73         return False
74
75     def acquire_with_retries(
76             self,
77             *,
78             initial_delay: float = 1.0,
79             backoff_factor: float = 2.0,
80             max_attempts = 5
81     ) -> bool:
82
83         @decorator_utils.retry_if_false(tries = max_attempts,
84                                         delay_sec = initial_delay,
85                                         backoff = backoff_factor)
86         def _try_acquire_lock_with_retries() -> bool:
87             success = self.try_acquire_lock_once()
88             if not success and os.path.exists(self.lockfile):
89                 self._detect_stale_lockfile()
90             return success
91
92         if os.path.exists(self.lockfile):
93             self._detect_stale_lockfile()
94         return _try_acquire_lock_with_retries()
95
96     def release(self):
97         try:
98             os.unlink(self.lockfile)
99         except Exception as e:
100             logger.exception(e)
101         self.is_locked = False
102
103     def __enter__(self):
104         if self.acquire_with_retries():
105             return self
106         msg = f"Couldn't acquire {self.lockfile}; giving up."
107         logger.warning(msg)
108         raise LockFileException(msg)
109
110     def __exit__(self, type, value, traceback):
111         self.release()
112
113     def __del__(self):
114         if self.is_locked:
115             self.release()
116
117     def _signal(self, *args):
118         if self.is_locked:
119             self.release()
120
121     def _get_lockfile_contents(self) -> str:
122         if self.override_command:
123             cmd = self.override_command
124         else:
125             cmd = ' '.join(sys.argv)
126         print(cmd)
127         contents = LockFileContents(
128             pid = os.getpid(),
129             commandline = cmd,
130             expiration_timestamp = self.expiration_timestamp,
131         )
132         return json.dumps(contents.__dict__)
133
134     def _detect_stale_lockfile(self) -> None:
135         try:
136             with open(self.lockfile, 'r') as rf:
137                 lines = rf.readlines()
138                 if len(lines) == 1:
139                     line = lines[0]
140                     line_dict = json.loads(line)
141                     contents = LockFileContents(**line_dict)
142                     logger.debug(f'Blocking lock contents="{contents}"')
143
144                     # Does the PID exist still?
145                     try:
146                         os.kill(contents.pid, 0)
147                     except OSError:
148                         logger.debug('The pid seems stale; killing the lock.')
149                         self.release()
150
151                     # Has the lock expiration expired?
152                     if contents.expiration_timestamp is not None:
153                         now = datetime.datetime.now().timestamp()
154                         if now > contents.expiration_datetime:
155                             logger.debug('The expiration time has passed; ' +
156                                          'killing the lock')
157                             self.release()
158         except Exception:
159             pass