cacheprovider: write files atomically
Make `Cache.set()` safe to use together with xdist.
This commit is contained in:
parent
98021838fd
commit
2989caa17c
|
@ -0,0 +1,3 @@
|
||||||
|
Fixed file write in `Cache.set()` to be atomic.
|
||||||
|
|
||||||
|
Non-atomic write could cause issues when `Cache.set()` called from different pytest-xdist workers for the same key. Now it should be safe to use in combination with pytest-xdist.
|
|
@ -8,6 +8,7 @@ import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import final
|
from typing import final
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
@ -75,6 +76,10 @@ class Cache:
|
||||||
self._cachedir = cachedir
|
self._cachedir = cachedir
|
||||||
self._config = config
|
self._config = config
|
||||||
|
|
||||||
|
# Note: there's no way to get the current umask atomically, eek.
|
||||||
|
self._umask = os.umask(0o022)
|
||||||
|
os.umask(self._umask)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def for_config(cls, config: Config, *, _ispytest: bool = False) -> "Cache":
|
def for_config(cls, config: Config, *, _ispytest: bool = False) -> "Cache":
|
||||||
"""Create the Cache instance for a Config.
|
"""Create the Cache instance for a Config.
|
||||||
|
@ -124,6 +129,44 @@ class Cache:
|
||||||
stacklevel=3,
|
stacklevel=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _write_atomic(self, path: Path, content: str) -> None:
|
||||||
|
tmpfile = tempfile.NamedTemporaryFile(
|
||||||
|
delete=False, dir=self._cachedir, mode="w", encoding="UTF-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
with tmpfile:
|
||||||
|
tmpfile.write(content)
|
||||||
|
|
||||||
|
# Reset permissions to the default, see #12308.
|
||||||
|
os.chmod(tmpfile.name, 0o666 - self._umask)
|
||||||
|
|
||||||
|
# On Windows, replace() might fail with ERROR_ACCESS_DENIED (5) if
|
||||||
|
# the target file is open by another process.
|
||||||
|
# Retry with exponential backoff in this case.
|
||||||
|
retry_delay = 1 / (2**10)
|
||||||
|
deadline = time.perf_counter() + 5
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
os.replace(tmpfile.name, path)
|
||||||
|
# Note: trying to remove tmpfile.name after successful replace()
|
||||||
|
# can cause a race condition, so it can't be done in 'finally:'
|
||||||
|
return
|
||||||
|
|
||||||
|
except OSError as ex:
|
||||||
|
if getattr(ex, "winerror", None) != 5 or time.perf_counter() > deadline:
|
||||||
|
os.remove(tmpfile.name)
|
||||||
|
raise
|
||||||
|
|
||||||
|
except BaseException:
|
||||||
|
os.remove(tmpfile.name)
|
||||||
|
raise
|
||||||
|
|
||||||
|
time.sleep(max(0, min(retry_delay, deadline - time.perf_counter())))
|
||||||
|
|
||||||
|
if retry_delay < 0.25:
|
||||||
|
retry_delay *= 2
|
||||||
|
|
||||||
def _mkdir(self, path: Path) -> None:
|
def _mkdir(self, path: Path) -> None:
|
||||||
self._ensure_cache_dir_and_supporting_files()
|
self._ensure_cache_dir_and_supporting_files()
|
||||||
path.mkdir(exist_ok=True, parents=True)
|
path.mkdir(exist_ok=True, parents=True)
|
||||||
|
@ -192,15 +235,12 @@ class Cache:
|
||||||
return
|
return
|
||||||
data = json.dumps(value, ensure_ascii=False, indent=2)
|
data = json.dumps(value, ensure_ascii=False, indent=2)
|
||||||
try:
|
try:
|
||||||
f = path.open("w", encoding="UTF-8")
|
self._write_atomic(path, data)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
self.warn(
|
self.warn(
|
||||||
f"cache could not write path {path}: {exc}",
|
f"cache could not write path {path}: {exc}",
|
||||||
_ispytest=True,
|
_ispytest=True,
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
with f:
|
|
||||||
f.write(data)
|
|
||||||
|
|
||||||
def _ensure_cache_dir_and_supporting_files(self) -> None:
|
def _ensure_cache_dir_and_supporting_files(self) -> None:
|
||||||
"""Create the cache dir and its supporting files."""
|
"""Create the cache dir and its supporting files."""
|
||||||
|
@ -215,10 +255,7 @@ class Cache:
|
||||||
path = Path(newpath)
|
path = Path(newpath)
|
||||||
|
|
||||||
# Reset permissions to the default, see #12308.
|
# Reset permissions to the default, see #12308.
|
||||||
# Note: there's no way to get the current umask atomically, eek.
|
path.chmod(0o777 - self._umask)
|
||||||
umask = os.umask(0o022)
|
|
||||||
os.umask(umask)
|
|
||||||
path.chmod(0o777 - umask)
|
|
||||||
|
|
||||||
with open(path.joinpath("README.md"), "xt", encoding="UTF-8") as f:
|
with open(path.joinpath("README.md"), "xt", encoding="UTF-8") as f:
|
||||||
f.write(README_CONTENT)
|
f.write(README_CONTENT)
|
||||||
|
|
|
@ -3,11 +3,14 @@ from enum import Enum
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
from _pytest.compat import assert_never
|
from _pytest.compat import assert_never
|
||||||
from _pytest.config import ExitCode
|
from _pytest.config import ExitCode
|
||||||
|
@ -1360,3 +1363,103 @@ def test_cachedir_tag(pytester: Pytester) -> None:
|
||||||
def test_clioption_with_cacheshow_and_help(pytester: Pytester) -> None:
|
def test_clioption_with_cacheshow_and_help(pytester: Pytester) -> None:
|
||||||
result = pytester.runpytest("--cache-show", "--help")
|
result = pytester.runpytest("--cache-show", "--help")
|
||||||
assert result.ret == 0
|
assert result.ret == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_permissions(pytester: Pytester) -> None:
|
||||||
|
ini_path = pytester.makeini("[pytest]")
|
||||||
|
config = pytester.parseconfigure()
|
||||||
|
assert config.cache is not None
|
||||||
|
|
||||||
|
config.cache.set("test/key", "value")
|
||||||
|
key_path = config.cache._getvaluepath("test/key")
|
||||||
|
|
||||||
|
assert key_path.stat().st_mode == ini_path.stat().st_mode
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_fail_permissions(pytester: Pytester) -> None:
|
||||||
|
pytester.makeini("[pytest]")
|
||||||
|
config = pytester.parseconfigure()
|
||||||
|
assert config.cache is not None
|
||||||
|
|
||||||
|
config.cache.set("test/key", "initial")
|
||||||
|
key_path = config.cache._getvaluepath("test/key")
|
||||||
|
|
||||||
|
old_key_file_mode = key_path.stat().st_mode
|
||||||
|
old_key_dir_mode = key_path.parent.stat().st_mode
|
||||||
|
|
||||||
|
try:
|
||||||
|
key_path.chmod(0)
|
||||||
|
key_path.parent.chmod(0)
|
||||||
|
|
||||||
|
with pytest.warns(pytest.PytestCacheWarning) as warns:
|
||||||
|
config.cache.set("test/key", "value")
|
||||||
|
|
||||||
|
assert len(warns) == 1
|
||||||
|
assert str(warns[0].message).startswith("cache could not write path")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
key_path.parent.chmod(old_key_dir_mode)
|
||||||
|
key_path.chmod(old_key_file_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_write(pytester: Pytester, monkeypatch: MonkeyPatch) -> None:
|
||||||
|
pytester.makeini("[pytest]")
|
||||||
|
config = pytester.parseconfigure()
|
||||||
|
assert config.cache is not None
|
||||||
|
|
||||||
|
config.cache.set("test/key", "initial")
|
||||||
|
key_path = config.cache._getvaluepath("test/key")
|
||||||
|
|
||||||
|
code_file = pytester.makepyfile(
|
||||||
|
f"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
f = open({str(key_path)!r}, mode="rb")
|
||||||
|
print("opened", flush=True)
|
||||||
|
|
||||||
|
line = sys.stdin.readline()
|
||||||
|
if line != "close\\n":
|
||||||
|
raise ValueError(f"Expected 'close', got {{line!r}}")
|
||||||
|
|
||||||
|
f.close()
|
||||||
|
print("done", flush=True)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
old_replace = os.replace
|
||||||
|
replace_attempts = 0
|
||||||
|
|
||||||
|
def mock_replace(
|
||||||
|
src: "Union[str, os.PathLike[str]]",
|
||||||
|
dst: "Union[str, os.PathLike[str]]",
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
old_replace(src, dst)
|
||||||
|
finally:
|
||||||
|
if dst == key_path:
|
||||||
|
nonlocal replace_attempts
|
||||||
|
replace_attempts += 1
|
||||||
|
if replace_attempts == 1:
|
||||||
|
print("close", file=child.stdin, flush=True)
|
||||||
|
|
||||||
|
child = pytester.popen(
|
||||||
|
[sys.executable, code_file],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=sys.stderr,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with child:
|
||||||
|
assert child.stdout.readline() == "opened\n"
|
||||||
|
monkeypatch.setattr("os.replace", mock_replace)
|
||||||
|
config.cache.set("test/key", "value")
|
||||||
|
assert child.stdout.read() == "done\n"
|
||||||
|
|
||||||
|
assert child.returncode == 0
|
||||||
|
assert config.cache.get("test/key", None) == "value"
|
||||||
|
|
||||||
|
if sys.platform.startswith("win"):
|
||||||
|
assert replace_attempts > 1
|
||||||
|
else:
|
||||||
|
assert replace_attempts == 1
|
||||||
|
|
Loading…
Reference in New Issue