debian-forge/test/mod/test_util_fscache_coherency.py
David Rheinsberg a3e49df619 test/fscache-coherency: add coherency tests
Add an extension to the FsCache tests which verifies cache coherency and
atomicity of the FsCache implementation. Additionally, if available, it
utilizes a cache on NFS storage to test network-support.

Unfortunately, the stress-tests keep triggering kernel-oopses in the NFS
client driver, so they are disabled for now. However, once investigated,
we can re-enable them.

Signed-off-by: David Rheinsberg <david.rheinsberg@gmail.com>
2022-12-20 16:56:32 +01:00

277 lines
9.4 KiB
Python

#
# Coherency tests for the 'osbuild.util.fscache' module.
#
# pylint: disable=protected-access
import contextlib
import errno
import os
import socket
import subprocess
import tempfile
import pytest
from osbuild.util import fscache, linux
from .. import test
def nfsd_available():
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
s.settimeout(1)
return s.connect_ex(("localhost", 2049)) == 0
@contextlib.contextmanager
def mount_nfs(src: str, dst: str):
r = subprocess.run(
[
"mount",
"-t", "nfs",
"-o", "nosharecache,vers=4",
src,
dst,
],
check=False,
encoding="utf-8",
stderr=subprocess.STDOUT,
stdout=subprocess.PIPE,
)
if r.returncode != 0:
code = r.returncode
msg = r.stdout.strip()
raise RuntimeError(f"{msg} (code: {code})")
try:
yield dst
finally:
subprocess.run(
["umount", dst],
check=True,
)
@pytest.fixture(name="nfsmnt")
def nfsmnt_fixture():
tmpmnt = None
try:
tmpmnt = tempfile.mkdtemp(prefix="osbuild-test-", dir="/var/tmp")
with mount_nfs("localhost:/", tmpmnt) as tmpnfs:
with tempfile.TemporaryDirectory(dir=tmpnfs) as tmpdir:
yield tmpdir
finally:
os.rmdir(tmpmnt)
@pytest.fixture(name="nfsmnts")
def nfsmnts_fixture():
tmpmnt = None
try:
tmpmnt = tempfile.mkdtemp(prefix="osbuild-test-", dir="/var/tmp")
os.mkdir(os.path.join(tmpmnt, "a"))
os.mkdir(os.path.join(tmpmnt, "b"))
with mount_nfs("localhost:/", os.path.join(tmpmnt, "a")), \
mount_nfs("localhost:/", os.path.join(tmpmnt, "b")):
with tempfile.TemporaryDirectory(dir=os.path.join(tmpmnt, "a")) as tmpdir:
dirname = os.path.basename(os.path.normpath(tmpdir))
a = os.path.join(tmpmnt, "a", dirname)
b = os.path.join(tmpmnt, "b", dirname)
yield (a, b)
finally:
os.rmdir(os.path.join(tmpmnt, "b"))
os.rmdir(os.path.join(tmpmnt, "a"))
os.rmdir(tmpmnt)
@pytest.mark.skipif(not nfsd_available(), reason="NFSv4 daemon required")
@pytest.mark.skipif(not test.TestBase.can_bind_mount(), reason="Bind-mounting required")
def test_nfs_characteristics(nfsmnts):
#
# Test NFS Characteristic
#
# This mounts a single NFS instance with `nosharedcache` twice. It then
# runs a series of custom tests to verify cache-behavior of NFS and how
# different operations will cause stale caches and thus validate our
# assumptions on how to avoid them.
#
a = nfsmnts[0]
b = nfsmnts[1]
# `stat` does not invalidate caches
#
# Write a fresh file on A, then stat it on B. This will properly re-fetch
# all information since caches on both sides are empty. Then re-write the
# file on A and, again, stat it on B and verify that the metadata was *NOT*
# re-fetched, since NFS caches this information.
# As last step read the file on B and verify that `open()` will properly
# re-fetch all information.
with open(os.path.join(a, "0_foo"), "x", encoding="utf8") as f:
f.write("foo")
assert os.stat(os.path.join(b, "0_foo")).st_size == 3
with open(os.path.join(a, "0_foo"), "w", encoding="utf8") as f:
f.write("foobar")
assert os.stat(os.path.join(b, "0_foo")).st_size == 3
with open(os.path.join(b, "0_foo"), "r", encoding="utf8") as f:
assert f.read() == "foobar"
assert os.stat(os.path.join(b, "0_foo")).st_size == 6
# Lock-acquisition invalidates caches
#
# Create a file on A and commit it to disk. Open it for reading on A, but
# then delete it on B. Verify on B it is gone. On A continue reading the
# file. Stat it on A to verify the caches have not been invalidated. Then
# acquire a read-lock on the open file and try the same again, this time
# the cache should reflect the unlink.
with open(os.path.join(a, "1_foo"), "x", encoding="utf8") as f:
f.write("foo")
with open(os.path.join(a, "1_foo"), "r", encoding="utf8") as f:
os.unlink(os.path.join(b, "1_foo"))
assert not os.access(os.path.join(b, "1_foo"), os.R_OK)
assert f.read() == "foo"
assert os.stat(f.fileno()).st_nlink == 1
assert os.stat(os.path.join(a, "1_foo")).st_ino != 0
assert os.access(os.path.join(a, "1_foo"), os.R_OK)
linux.fcntl_flock(f.fileno(), linux.fcntl.F_RDLCK, wait=True)
# The first STAT after an unlink returns a link-count of 0,
# while every following STAT raises ESTALE. Lets try
# verifying that, but do not depend on it and allow the
# first STAT to raise ESTALE as well.
try:
assert os.stat(f.fileno()).st_nlink == 0
except OSError as e:
assert e.errno == errno.ESTALE
with pytest.raises(OSError):
os.stat(f.fileno())
with pytest.raises(OSError):
os.stat(os.path.join(a, "1_foo"))
assert not os.access(os.path.join(a, "1_foo"), os.R_OK)
# Inode changes on replacement
#
# Create a file, STAT it on A and B and verify they match and the
# caches are active. Then replace the file on A and STAT again. The
# replacement will be visible on A, but the caches on B still yield
# the same old value. However, after opening on B, the file content
# will yield the updated data and so will STAT.
with open(os.path.join(a, "2_foo"), "x", encoding="utf8") as f:
f.write("foo")
st_a0 = os.stat(os.path.join(a, "2_foo"))
st_b0 = os.stat(os.path.join(b, "2_foo"))
assert st_a0.st_ino == st_b0.st_ino
with open(os.path.join(a, "2_bar"), "x", encoding="utf8") as f:
f.write("bar")
os.rename(os.path.join(a, "2_bar"), os.path.join(a, "2_foo"))
st_a1 = os.stat(os.path.join(a, "2_foo"))
st_b1 = os.stat(os.path.join(b, "2_foo"))
assert st_a1.st_ino != st_b1.st_ino
assert st_b0.st_ino == st_b1.st_ino
with open(os.path.join(b, "2_foo"), "r", encoding="utf8") as f:
assert f.read() == "bar"
st_a2 = os.stat(os.path.join(a, "2_foo"))
st_b2 = os.stat(os.path.join(b, "2_foo"))
assert st_a2.st_ino == st_b2.st_ino
def _test_atomics_with(a: str, b: str):
with fscache.FsCache("osbuild-test-appid", a) as cache:
cache.info = cache.info._replace(maximum_size=1024)
# Test _atomic_open() with open+lock race
#
# Create a file `0_foo` and OPEN+LOCK it with _atomic_open(). Use a
# tracer to hook between OPEN and LOCK. First time unlinke the target
# file and recreate it. The second time, replace it instead.
#
# Verify that `_atomic_open()` needs 3 attempts to OPEN+LOCK the file,
# and verify the content is ultimately correct.
def _trace_lock(state: dict):
# Use `open(..., "x")` to force an invalidation of NFS caches.
# Otherwise, VFS would just deny our operations based on outdated
# NFS-caches. This would be coherent, but overly restrictive.
with pytest.raises(OSError):
with open(os.path.join(b, "0_foo"), "x", encoding="utf8") as f:
pass
if state["lock"] == 0:
with open(os.path.join(b, "0_foo"), "r", encoding="utf8") as f:
assert f.read() == "foo"
os.unlink(os.path.join(b, "0_foo"))
with open(os.path.join(b, "0_foo"), "x", encoding="utf8") as f:
f.write("bar")
elif state["lock"] == 1:
with open(os.path.join(b, "0_foo"), "r", encoding="utf8") as f:
assert f.read() == "bar"
with open(os.path.join(b, "0_foo2"), "x", encoding="utf8") as f:
f.write("foobar")
os.rename(os.path.join(b, "0_foo2"), os.path.join(b, "0_foo"))
state["lock"] = state["lock"] + 1
state = {"lock": 0}
cache._tracers = {
"_atomic_open:lock": lambda: _trace_lock(state)
}
with open(os.path.join(a, "0_foo"), "x", encoding="utf8") as f:
f.write("foo")
with cache._atomic_open("0_foo", wait=True, write=False) as fd:
with os.fdopen(fd, "r", closefd=False, encoding="utf8") as f:
assert f.read() == "foobar"
assert state["lock"] == 3
@pytest.mark.skipif(not test.TestBase.can_bind_mount(), reason="Bind-mounting required")
def test_atomics():
#
# Test FsCache Atomics (native)
#
# Verify the behavior of the `_atomic_*()` helpers of FsCache. Use the
# trace-hooks of FsCache to trigger the race-conditions we want to test.
#
with tempfile.TemporaryDirectory(dir="/var/tmp") as tmpdir:
_test_atomics_with(tmpdir, tmpdir)
@pytest.mark.skipif(not nfsd_available(), reason="NFSv4 daemon required")
@pytest.mark.skipif(not test.TestBase.can_bind_mount(), reason="Bind-mounting required")
def test_atomics_nfs(nfsmnts):
#
# Test FsCache Atomics (NFS)
#
# Same as `test_atomics()` but on NFS.
#
with tempfile.TemporaryDirectory(dir=nfsmnts[0]) as tmpdir:
_test_atomics_with(tmpdir, tmpdir)
# Preferably, we would now run the same tests on two distinct
# NFS mounts with no shared caches. Unfortunately, this keeps
# triggering kernel-oopses, so we disable the tests for now:
#_test_atomics_with(nfsmnts[0], nfsmnts[1])