Move stuff around.
[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.  e.g.
32
33     with LockFile('./foo.lock'):
34         # do a bunch of stuff... if the process dies we have a signal
35         # handler to do cleanup.  Other code (in this process or another)
36         # that tries to take the same lockfile will block.  There is also
37         # some logic for detecting stale locks.
38
39     """
40     def __init__(
41             self,
42             lockfile_path: str,
43             *,
44             do_signal_cleanup: bool = True,
45             expiration_timestamp: Optional[float] = None,
46             override_command: Optional[str] = None,
47     ) -> None:
48         self.is_locked = False
49         self.lockfile = lockfile_path
50         self.override_command = override_command
51         if do_signal_cleanup:
52             signal.signal(signal.SIGINT, self._signal)
53             signal.signal(signal.SIGTERM, self._signal)
54         self.expiration_timestamp = expiration_timestamp
55
56     def locked(self):
57         return self.is_locked
58
59     def available(self):
60         return not os.path.exists(self.lockfile)
61
62     def try_acquire_lock_once(self) -> bool:
63         logger.debug(f"Trying to acquire {self.lockfile}.")
64         try:
65             # Attempt to create the lockfile.  These flags cause
66             # os.open to raise an OSError if the file already
67             # exists.
68             fd = os.open(self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
69             with os.fdopen(fd, "a") as f:
70                 contents = self._get_lockfile_contents()
71                 logger.debug(contents)
72                 f.write(contents)
73             logger.debug(f'Success; I own {self.lockfile}.')
74             self.is_locked = True
75             return True
76         except OSError:
77             pass
78         logger.debug(f'Failed; I could not acquire {self.lockfile}.')
79         return False
80
81     def acquire_with_retries(
82             self,
83             *,
84             initial_delay: float = 1.0,
85             backoff_factor: float = 2.0,
86             max_attempts = 5
87     ) -> bool:
88
89         @decorator_utils.retry_if_false(tries = max_attempts,
90                                         delay_sec = initial_delay,
91                                         backoff = backoff_factor)
92         def _try_acquire_lock_with_retries() -> bool:
93             success = self.try_acquire_lock_once()
94             if not success and os.path.exists(self.lockfile):
95                 self._detect_stale_lockfile()
96             return success
97
98         if os.path.exists(self.lockfile):
99             self._detect_stale_lockfile()
100         return _try_acquire_lock_with_retries()
101
102     def release(self):
103         try:
104             os.unlink(self.lockfile)
105         except Exception as e:
106             logger.exception(e)
107         self.is_locked = False
108
109     def __enter__(self):
110         if self.acquire_with_retries():
111             return self
112         msg = f"Couldn't acquire {self.lockfile}; giving up."
113         logger.warning(msg)
114         raise LockFileException(msg)
115
116     def __exit__(self, type, value, traceback):
117         self.release()
118
119     def __del__(self):
120         if self.is_locked:
121             self.release()
122
123     def _signal(self, *args):
124         if self.is_locked:
125             self.release()
126
127     def _get_lockfile_contents(self) -> str:
128         if self.override_command:
129             cmd = self.override_command
130         else:
131             cmd = ' '.join(sys.argv)
132         print(cmd)
133         contents = LockFileContents(
134             pid = os.getpid(),
135             commandline = cmd,
136             expiration_timestamp = self.expiration_timestamp,
137         )
138         return json.dumps(contents.__dict__)
139
140     def _detect_stale_lockfile(self) -> None:
141         try:
142             with open(self.lockfile, 'r') as rf:
143                 lines = rf.readlines()
144                 if len(lines) == 1:
145                     line = lines[0]
146                     line_dict = json.loads(line)
147                     contents = LockFileContents(**line_dict)
148                     logger.debug(f'Blocking lock contents="{contents}"')
149
150                     # Does the PID exist still?
151                     try:
152                         os.kill(contents.pid, 0)
153                     except OSError:
154                         logger.debug('The pid seems stale; killing the lock.')
155                         self.release()
156
157                     # Has the lock expiration expired?
158                     if contents.expiration_timestamp is not None:
159                         now = datetime.datetime.now().timestamp()
160                         if now > contents.expiration_datetime:
161                             logger.debug('The expiration time has passed; ' +
162                                          'killing the lock')
163                             self.release()
164         except Exception:
165             pass