Skip to content

Commit fac5691

Browse files
committed
Introduce optional file locking for appending to perflogs
Also: - Introduce an `osext.flock()` utility function to handle the underlying locking for the different Python versions. - Use Python syntax for ``true`` and ``false`` in the configuration reference to be consistent with the use of ``None`` instead of the JSON's ``null``.
1 parent 7611a6a commit fac5691

File tree

8 files changed

+214
-80
lines changed

8 files changed

+214
-80
lines changed

docs/config_reference.rst

Lines changed: 70 additions & 46 deletions
Large diffs are not rendered by default.

docs/requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ archspec==0.2.5
22
ClusterShell==1.9.3
33
docutils==0.18.1; python_version < '3.9'
44
docutils==0.21.2; python_version >= '3.9'
5+
filelock==3.4.1; python_version == '3.6'
6+
filelock==3.12.2; python_version == '3.7'
7+
filelock==3.16.1; python_version == '3.8'
8+
filelock==3.18.0; python_version > '3.8'
59
jinja2==3.0.3; python_version == '3.6'
610
jinja2==3.1.6; python_version >= '3.7'
711
jsonschema==3.2.0

reframe/core/logging.py

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# SPDX-License-Identifier: BSD-3-Clause
55

66
import abc
7+
import atexit
78
import itertools
89
import logging
910
import logging.handlers
@@ -149,7 +150,8 @@ class MultiFileHandler(logging.FileHandler):
149150
'''
150151

151152
def __init__(self, prefix, mode='a', encoding=None, fmt=None,
152-
perffmt=None, ignore_keys=None):
153+
perffmt=None, ignore_keys=None, use_locking=False,
154+
lockfile_mode=None):
153155
super().__init__(prefix, mode, encoding, delay=True)
154156

155157
# Reset FileHandler's filename
@@ -164,6 +166,8 @@ def __init__(self, prefix, mode='a', encoding=None, fmt=None,
164166
self.__perffmt = perffmt
165167
self.__attr_patt = re.compile(r'\%\((.*?)\)s(.*?(?=%|$))?')
166168
self.__ignore_keys = set(ignore_keys) if ignore_keys else set()
169+
self.__use_locking = use_locking
170+
self.__lockfile_mode = lockfile_mode
167171

168172
def __generate_header(self, record):
169173
# Generate the header from the record and fmt
@@ -234,13 +238,25 @@ def _emit_header(self, record):
234238

235239
os.rename(self.baseFilename, self.baseFilename + f'.h{hcnt}')
236240
finally:
237-
# Open the file for writing and write the header
238-
fp = open(self.baseFilename,
239-
mode=self.mode, encoding=self.encoding)
240-
if record_header != header:
241-
fp.write(f'{record_header}\n')
241+
if self.__use_locking:
242+
# When using locking, we need to open, append and write to
243+
# the file at once
244+
lockfile = os.path.join(f'{self.baseFilename}.lock')
245+
with osext.flock(lockfile, self.__lockfile_mode):
246+
with open(self.baseFilename, mode=self.mode,
247+
encoding=self.encoding) as fp:
248+
if record_header != header:
249+
fp.write(f'{record_header}\n')
250+
251+
self.__streams[self.baseFilename] = None
252+
else:
253+
# Open the file for writing and write the header
254+
fp = open(self.baseFilename,
255+
mode=self.mode, encoding=self.encoding)
256+
if record_header != header:
257+
fp.write(f'{record_header}\n')
242258

243-
self.__streams[self.baseFilename] = fp
259+
self.__streams[self.baseFilename] = fp
244260

245261
def emit(self, record):
246262
try:
@@ -255,14 +271,23 @@ def emit(self, record):
255271
check_basename = type(record.__rfm_check__).variant_name()
256272
self.baseFilename = os.path.join(dirname, f'{check_basename}.log')
257273
self._emit_header(record)
258-
self.stream = self.__streams[self.baseFilename]
259-
super().emit(record)
274+
if self.__use_locking:
275+
lockfile = os.path.join(dirname, f'.{check_basename}.lock')
276+
with osext.flock(lockfile, self.__lockfile_mode):
277+
with open(self.baseFilename, mode=self.mode,
278+
encoding=self.encoding) as fp:
279+
self.stream = fp
280+
super().emit(record)
281+
else:
282+
self.stream = self.__streams[self.baseFilename]
283+
super().emit(record)
260284

261285
def close(self):
262286
# Close all open streams
263-
for s in self.__streams.values():
264-
self.stream = s
265-
super().close()
287+
for stream in self.__streams.values():
288+
if stream:
289+
self.stream = stream
290+
super().close()
266291

267292

268293
def _format_time_rfc3339(timestamp, datefmt):
@@ -459,9 +484,13 @@ def _create_filelog_handler(site_config, config_prefix):
459484
format = site_config.get(f'{config_prefix}/format')
460485
format_perf = site_config.get(f'{config_prefix}/format_perfvars')
461486
ignore_keys = site_config.get(f'{config_prefix}/ignore_keys')
487+
use_locking = site_config.get(f'{config_prefix}/locking_enable')
488+
lockfile_mode = site_config.get(f'{config_prefix}/locking_file_mode')
462489
return MultiFileHandler(filename_patt, mode='a+' if append else 'w+',
463490
fmt=format, perffmt=format_perf,
464-
ignore_keys=ignore_keys)
491+
ignore_keys=ignore_keys,
492+
use_locking=use_locking,
493+
lockfile_mode=lockfile_mode)
465494

466495

467496
@register_log_handler('syslog')
@@ -806,6 +835,13 @@ def debug(self, message, *args, **kwargs):
806835
def debug2(self, message, *args, **kwargs):
807836
self.log(DEBUG2, message, *args, **kwargs)
808837

838+
def shutdown(self):
839+
'''Shutdown logger by removing all handlers and closing any files.'''
840+
841+
for h in list(self.handlers):
842+
h.close()
843+
self.removeHandler(h)
844+
809845

810846
# This is a cache for warnings that we don't want to repeat
811847
_WARN_ONCE = set()
@@ -1014,6 +1050,8 @@ def configure_logging(site_config):
10141050
_context_logger = null_logger
10151051
return
10161052

1053+
# Shutdown the previously setup loggers and close any files
1054+
shutdown()
10171055
_logger = _create_logger(site_config, 'handlers$', 'handlers')
10181056
_perf_logger = _create_logger(site_config, 'handlers_perflog')
10191057
_context_logger = LoggerAdapter(_logger)
@@ -1066,6 +1104,17 @@ def _fn(*args, **kwargs):
10661104
return _fn
10671105

10681106

1107+
@atexit.register
1108+
def shutdown():
1109+
'''Shutdown logging'''
1110+
1111+
if _logger:
1112+
_logger.shutdown()
1113+
1114+
if _perf_logger:
1115+
_perf_logger.shutdown()
1116+
1117+
10691118
# The following is meant to be used only by the unit tests
10701119

10711120
class logging_sandbox:
@@ -1085,6 +1134,7 @@ def __enter__(self):
10851134
def __exit__(self, exc_type, exc_value, traceback):
10861135
global _logger, _perf_logger, _context_logger
10871136

1137+
shutdown()
10881138
_logger = self._logger
10891139
_perf_logger = self._perf_logger
10901140
_context_logger = self._context_logger

reframe/frontend/reporting/storage.py

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
import os
1010
import re
1111
import sqlite3
12-
import sys
13-
from filelock import FileLock
1412

1513
import reframe.utility.jsonext as jsonext
1614
import reframe.utility.osext as osext
@@ -124,22 +122,8 @@ def _db_connect(self, *args, **kwargs):
124122
return sqlite3.connect(*args, **kwargs)
125123

126124
def _db_lock(self):
127-
prefix = os.path.dirname(self.__db_file)
128-
if sys.version_info >= (3, 7):
129-
kwargs = {'mode': self.__db_file_mode}
130-
else:
131-
# Python 3.6 forces us to use an older filelock version that does
132-
# not support file modes. File modes where introduced in
133-
# filelock 3.10
134-
kwargs = {}
135-
136-
# Create parent directories of the lock file
137-
#
138-
# NOTE: This is not necessary for filelock >= 3.12.3 and Python >= 3.8
139-
# However, we do create it here, in order to support the older Python
140-
# versions.
141-
os.makedirs(prefix, exist_ok=True)
142-
return FileLock(os.path.join(prefix, '.db.lock'), **kwargs)
125+
lockfile = os.path.join(os.path.dirname(self.__db_file), '.db.lock')
126+
return osext.flock(lockfile, self.__db_file_mode)
143127

144128
def _db_create(self):
145129
clsname = type(self).__name__

reframe/schemas/config.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@
8585
"ignore_keys": {
8686
"type": "array",
8787
"items": {"type": "string"}
88-
}
88+
},
89+
"locking_enable": {"type": "boolean"},
90+
"locking_file_mode": {"type": "string"}
8991
},
9092
"required": ["prefix"]
9193
}
@@ -633,6 +635,8 @@
633635
"logging/handlers_perflog/filelog_append": true,
634636
"logging/handlers_perflog/filelog_basedir": "./perflogs",
635637
"logging/handlers_perflog/filelog_ignore_keys": [],
638+
"logging/handlers_perflog/filelog_locking_enable": false,
639+
"logging/handlers_perflog/filelog_locking_file_mode": "644",
636640
"logging/handlers_perflog/graylog_extras": {},
637641
"logging/handlers_perflog/httpjson_extras": {},
638642
"logging/handlers_perflog/httpjson_ignore_keys": [],

reframe/utility/osext.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import collections.abc
1111
import errno
12+
import filelock
1213
import getpass
1314
import grp
1415
import os
@@ -863,6 +864,44 @@ def unique_abs_paths(paths, prune_children=True):
863864
return list(unique_paths - children)
864865

865866

867+
def flock(name, mode=None):
868+
'''Obtain a file lock
869+
870+
:arg name: the name of the lock file
871+
:arg mode: the file mode of the lock file
872+
:returns: a context manager that holds the lock.
873+
Example usage:
874+
875+
.. code-block:: python
876+
877+
with flock('file.lock'):
878+
# do something
879+
880+
.. versionadded:: 4.8.3
881+
882+
'''
883+
884+
if not isinstance(mode, int):
885+
mode = int(mode, base=8)
886+
887+
if mode and sys.version_info >= (3, 7):
888+
kwargs = {'mode': mode}
889+
else:
890+
# Python 3.6 forces us to use an older filelock version that does
891+
# not support file modes. File modes where introduced in
892+
# filelock 3.10
893+
kwargs = {}
894+
895+
# Create parent directories of the lock file
896+
#
897+
# NOTE: This is not necessary for filelock >= 3.12.3 and Python >= 3.8
898+
# However, we do create it here, in order to support the older Python
899+
# versions.
900+
prefix = os.path.dirname(name)
901+
os.makedirs(prefix, exist_ok=True)
902+
return filelock.FileLock(name, **kwargs)
903+
904+
866905
def cray_cdt_version():
867906
'''Return either the Cray Development Toolkit (CDT) version, the Cray
868907
Programming Environment (CPE) version or :class:`None` if the version

unittests/test_logging.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ def handler(logfile, rfc3339formatter):
6969
def logger(handler):
7070
logger = rlog.Logger('reframe')
7171
logger.addHandler(handler)
72-
return logger
72+
yield logger
73+
logger.shutdown()
7374

7475

7576
@pytest.fixture
@@ -545,7 +546,7 @@ def url_scheme(request):
545546

546547
def test_httpjson_handler_no_port(make_exec_ctx, config_file,
547548
url_scheme, logging_sandbox):
548-
ctx = make_exec_ctx(
549+
make_exec_ctx(
549550
config_file({
550551
'level': 'info',
551552
'handlers_perflog': [{

unittests/test_perflogging.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,3 +465,31 @@ def validate(self):
465465

466466
assert len(lines) == 2
467467
assert lines[1] == 'fail|sanity error: no way|None|None\n'
468+
469+
470+
def test_perf_logging_locking(make_runner, make_exec_ctx,
471+
config_perflog, perf_test, tmp_path):
472+
make_exec_ctx(config_perflog(
473+
fmt='',
474+
logging_opts={
475+
'handlers_perflog': [{
476+
'type': 'filelog',
477+
'use_locking': True,
478+
'prefix': '%(check_system)s/%(check_partition)s',
479+
'level': 'info',
480+
'format': (
481+
'%(check_job_completion_time)s,%(version)s,'
482+
'%(check_display_name)s,%(check_system)s,'
483+
'%(check_partition)s,%(check_environ)s,'
484+
'%(check_jobid)s,%(check_result)s,%(check_perfvalues)s'
485+
)
486+
}]
487+
}
488+
))
489+
logging.configure_logging(rt.runtime().site_config)
490+
runner = make_runner()
491+
testcases = executors.generate_testcases([perf_test])
492+
_assert_no_logging_error(runner.runall, testcases)
493+
logfile = tmp_path / 'perflogs' / 'generic' / 'default' / '_MyPerfTest.log'
494+
assert os.path.exists(logfile)
495+
assert _count_lines(logfile) == 2

0 commit comments

Comments
 (0)