3 """Wrapper that adds exclusive locks, timeouts, timestamp accounting,
4 max frequency, logging, etc... to running cron jobs.
11 from typing import Optional
13 from pyutils import bootstrap, config, exec_utils, stopwatch
14 from pyutils.datetimez import datetime_utils
15 from pyutils.files import file_utils, lockfile
17 logger = logging.getLogger(__name__)
19 cfg = config.add_commandline_args(
20 f'Python Cron Runner ({__file__})',
21 'Wrapper for cron commands with locking, timeouts, and accounting.',
26 metavar='LOCKFILE_PATH',
27 help='Path to the lockfile to use to ensure that two instances of a command do not execute contemporaneously.',
34 help='Maximum time for lock acquisition + command execution. Undecorated for seconds but "3m" or "1h 15m" work too.',
39 metavar='TIMESTAMP_FILE',
41 help='The /timestamp/TIMESTAMP_FILE file tracking the work being done; files\' mtimes will be set to the last successful run of a command for accounting purposes.',
48 help='The maximum frequency with which to do this work; even if the wrapper is invoked more often than this it will not run the command. Requires --timestamp. Undecorated for seconds but "3h" or "1h 15m" work too.',
55 metavar='COMMANDLINE',
56 help='The commandline to run under a lock.',
58 config.overwrite_argparse_epilog(
62 -1000 = some internal error occurred (see exception log).
63 0 = we exited early due to not enough time passage since the last
64 invocation of --command.
65 1000 = we could not obtain the lockfile; someone else owns it.
66 else = if the --command was run successfully, cron.py will exit with
67 the same code that the subcommand exited with.
72 def run_command(timeout: Optional[int], timestamp_file: Optional[str]) -> int:
73 """Run cron command"""
74 cmd = ' '.join(config.config['command'])
75 logger.info('cron cmd = "%s"', cmd)
76 logger.debug('shell environment:')
77 for var in os.environ:
79 logger.debug('%s = %s', var, val)
80 logger.debug('____ (↓↓↓ output from the subprocess appears below here ↓↓↓) ____')
82 with stopwatch.Timer() as t:
83 ret = exec_utils.cmd_exitcode(cmd, timeout)
85 f'____ (↑↑↑ subprocess finished in {t():.2f}s, exit value was {ret} ↑↑↑) ____'
87 if timestamp_file is not None and os.path.exists(timestamp_file):
88 logger.debug('Touching %s', timestamp_file)
89 file_utils.touch_file(timestamp_file)
91 except Exception as e:
93 print('Cron subprocess failed, giving up.', file=sys.stderr)
94 logger.warning('Cron subprocess failed, giving up')
101 if config.config['timestamp']:
102 timestamp_file = f"/timestamps/{config.config['timestamp']}"
103 if not file_utils.does_file_exist(timestamp_file):
105 '--timestamp argument\'s target file (%s) must already exist.',
110 timestamp_file = None
111 if config.config['max_frequency']:
113 'The --max_frequency argument requires the --timestamp argument.'
116 now = datetime.datetime.now()
117 if timestamp_file is not None and os.path.exists(timestamp_file):
118 max_frequency = config.config['max_frequency']
119 if max_frequency is not None:
120 max_delta = datetime_utils.parse_duration(max_frequency)
122 mtime = file_utils.get_file_mtime_as_datetime(timestamp_file)
124 if delta.total_seconds() < max_delta:
126 "It's only been %s since we last ran successfully; bailing out.",
127 datetime_utils.describe_duration_briefly(delta.total_seconds()),
131 timeout = config.config['timeout']
132 if timeout is not None:
133 timeout = datetime_utils.parse_duration(timeout)
135 logger.debug('Timeout is %ss', timeout)
136 lockfile_expiration = datetime.datetime.now().timestamp() + timeout
138 logger.debug('Timeout not specified; no lockfile expiration.')
139 lockfile_expiration = None
141 lockfile_path = config.config['lockfile']
142 if lockfile_path is not None:
143 logger.debug('Attempting to acquire lockfile %s...', lockfile_path)
145 with lockfile.LockFile(
147 do_signal_cleanup=True,
148 override_command=' '.join(config.config['command']),
149 expiration_timestamp=lockfile_expiration,
151 return run_command(timeout, timestamp_file)
152 except lockfile.LockFileException as e:
154 msg = f'Failed to acquire {lockfile_path}, giving up.'
156 print(msg, file=sys.stderr)
159 logger.debug('No lockfile indicated; not locking anything.')
160 return run_command(timeout, timestamp_file)
163 if __name__ == '__main__':
164 # Insist that our logger.whatever('messages') make their way into
165 # syslog with a facility=LOG_CRON, please. Yes, this is hacky.
166 sys.argv.append('--logging_syslog')
167 sys.argv.append('--logging_syslog_facility=CRON')