aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/tests/test_servecache.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_servecache.py')
-rw-r--r--tests/test_servecache.py182
1 files changed, 182 insertions, 0 deletions
diff --git a/tests/test_servecache.py b/tests/test_servecache.py
new file mode 100644
index 0000000..6f45825
--- /dev/null
+++ b/tests/test_servecache.py
@@ -0,0 +1,182 @@
+# map/tests/test_servecache.py
+import os
+import tempfile
+import time
+import unittest
+from pathlib import Path
+from unittest import mock
+
+import numpy as np
+
+import servecache as SC
+
+
+class ServeCacheTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = Path(tempfile.mkdtemp())
+ self.arrays = {"a": np.arange(10, dtype=np.float32), "ids": np.array([3, 1, 2], np.uint64),
+ "m": np.array([True, False]), "xyz": np.random.default_rng(1).normal(size=(5, 3))}
+
+ def test_round_trip_keeps_values_and_dtypes(self):
+ d = SC.cache_dir(self.tmp, "abc")
+ self.assertEqual(d.name, f"serve-abc-v{SC.VERSION}")
+ SC.write(d, self.arrays, {"note": "x"})
+ arrays, meta = SC.read(d)
+ self.assertEqual(meta["note"], "x")
+ for k, v in self.arrays.items():
+ self.assertIsInstance(arrays[k], np.memmap)
+ self.assertEqual(arrays[k].dtype, v.dtype)
+ np.testing.assert_array_equal(arrays[k], v)
+
+ def test_read_loads_more_arrays_than_the_soft_file_limit(self):
+ import resource
+ soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
+ if hard != resource.RLIM_INFINITY and hard < 400:
+ self.skipTest("hard open-file limit too low")
+ d = SC.cache_dir(self.tmp, "many")
+ SC.write(d, {f"f{i}": np.arange(3) + i for i in range(300)}, {})
+ got = None
+ try:
+ resource.setrlimit(resource.RLIMIT_NOFILE, (128, hard)) # each memory map keeps a file open
+ got = SC.read(d)
+ self.assertIsNotNone(got, "a complete cache must not read as damaged")
+ self.assertEqual(int(got[0]["f299"][2]), 301)
+ finally:
+ del got
+ resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard))
+
+ def test_read_ignores_a_folder_without_done(self):
+ d = SC.cache_dir(self.tmp, "abc")
+ SC.write(d, self.arrays, {})
+ (d / "done").unlink()
+ self.assertIsNone(SC.read(d))
+ self.assertIsNone(SC.read(self.tmp / "missing"))
+
+ def test_failed_write_leaves_no_final_folder(self):
+ d = SC.cache_dir(self.tmp, "abc")
+ with mock.patch.object(SC, "save_npy", side_effect=OSError("disk full")):
+ with self.assertRaises(OSError):
+ SC.write(d, self.arrays, {})
+ self.assertFalse(d.exists())
+ self.assertEqual(list(self.tmp.iterdir()), [], "the temporary folder is removed too")
+
+ def test_load_or_build_builds_once(self):
+ d = SC.cache_dir(self.tmp, "abc")
+ calls = []
+ def build():
+ calls.append(1)
+ return self.arrays, {"n": 1}
+ a1, _ = SC.load_or_build(d, build)
+ a2, meta = SC.load_or_build(d, build)
+ self.assertEqual(len(calls), 1)
+ self.assertEqual(meta["n"], 1)
+ self.assertIsInstance(a1["a"], np.memmap, "even the first load hands back the written maps")
+ np.testing.assert_array_equal(a2["xyz"], self.arrays["xyz"])
+
+ def test_unwritable_falls_back_to_ram(self):
+ d = SC.cache_dir(self.tmp, "abc")
+ logs = []
+ with mock.patch.object(SC, "write", side_effect=OSError("read-only file system")):
+ arrays, _ = SC.load_or_build(d, lambda: (self.arrays, {}), log=logs.append)
+ self.assertIs(arrays["a"], self.arrays["a"])
+ self.assertEqual(len(logs), 1)
+ self.assertIn("read-only file system", logs[0])
+
+ def test_prune_removes_only_unkept_serve_folders(self):
+ keep = SC.cache_dir(self.tmp, "new")
+ old = SC.cache_dir(self.tmp, "old")
+ for d in (keep, old):
+ SC.write(d, self.arrays, {})
+ area = self.tmp / "0123abcd"
+ area.mkdir()
+ (area / "cells.npz").write_bytes(b"x")
+ tiles = self.tmp / "v4-abc"
+ tiles.mkdir()
+ gone = SC.prune(self.tmp, [keep])
+ self.assertEqual(gone, [old])
+ self.assertTrue(keep.exists() and area.exists() and tiles.exists())
+
+ def test_prune_keeps_young_temporary_folders_and_removes_old_ones(self):
+ young = self.tmp / f"serve-x-v{SC.VERSION}.tmp-1"
+ stale = self.tmp / f"serve-y-v{SC.VERSION}.tmp-2"
+ young.mkdir()
+ stale.mkdir()
+ t = time.time() - 7200
+ os.utime(stale, (t, t))
+ SC.prune(self.tmp, [])
+ self.assertTrue(young.exists(), "may belong to a server still writing it")
+ self.assertFalse(stale.exists())
+
+ def test_prune_removes_a_young_temporary_folder_of_a_process_that_is_gone(self):
+ import subprocess
+ pr = subprocess.Popen(["true"])
+ pr.wait()
+ orphan = self.tmp / f"serve-z-v{SC.VERSION}.tmp-{pr.pid}"
+ orphan.mkdir()
+ SC.prune(self.tmp, [])
+ self.assertFalse(orphan.exists(), "its writer died: nobody finishes it")
+
+ def test_prune_skips_a_folder_another_process_removed_meanwhile(self):
+ gone = self.tmp / f"serve-q-v{SC.VERSION}.tmp-1"
+ with mock.patch.object(Path, "iterdir", lambda _: iter([gone])), \
+ mock.patch.object(Path, "is_dir", lambda _: True):
+ self.assertEqual(SC.prune(self.tmp, []), [])
+
+ def test_another_server_finishing_first_is_no_failure(self):
+ import errno
+ d = SC.cache_dir(self.tmp, "race")
+ real = os.rename
+
+ def other_first(src, dst):
+ with mock.patch.object(SC.os, "rename", real):
+ SC.write(Path(dst), self.arrays, {})
+ raise OSError(errno.ENOTEMPTY, "Directory not empty")
+ logs = []
+ with mock.patch.object(SC.os, "rename", other_first):
+ arrays, _ = SC.load_or_build(d, lambda: (self.arrays, {}), log=logs.append)
+ self.assertEqual(logs, [], "no RAM fallback")
+ self.assertIsInstance(arrays["a"], np.memmap)
+ self.assertFalse([p for p in self.tmp.iterdir() if ".tmp-" in p.name])
+
+ def test_a_damaged_cache_is_rebuilt(self):
+ for damage in ("empty", "truncated"):
+ d = SC.cache_dir(self.tmp, damage)
+ SC.write(d, self.arrays, {})
+ f = d / "xyz.npy"
+ f.write_bytes(b"" if damage == "empty" else f.read_bytes()[:200]) # e.g. power lost after the rename
+ logs = []
+ arrays, _ = SC.load_or_build(d, lambda: (self.arrays, {}), log=logs.append)
+ self.assertIsInstance(arrays["xyz"], np.memmap, damage)
+ np.testing.assert_array_equal(arrays["xyz"], self.arrays["xyz"])
+ self.assertIsNotNone(SC.read(d), f"{damage}: healed for the next start")
+
+
+if __name__ == "__main__":
+ unittest.main()
+
+
+class SaveNpyTest(unittest.TestCase):
+ def test_same_bytes_as_np_save(self):
+ rng = np.random.default_rng(0)
+ arrays = [rng.normal(size=3_000_000), rng.random((1000, 3)), np.zeros(0), np.array(3.5),
+ rng.integers(0, 9, 50_000).astype(np.int8), rng.random(10_000) < 0.5,
+ np.arange(12, dtype=np.uint64).reshape(3, 4)[:, ::2], np.asfortranarray(rng.random((300, 7)))]
+ with tempfile.TemporaryDirectory() as t:
+ for i, a in enumerate(arrays):
+ with self.subTest(i=i, dtype=str(a.dtype), shape=a.shape):
+ np.save(Path(t) / "want.npy", np.ascontiguousarray(a), allow_pickle=False)
+ SC.save_npy(Path(t) / "got.npy", a)
+ self.assertEqual((Path(t) / "got.npy").read_bytes(), (Path(t) / "want.npy").read_bytes())
+
+ def test_written_in_plain_writes_not_tofile(self):
+ """numpy's tofile() path preallocates the file; btrfs then stores it uncompressed."""
+ from numpy.lib import format as F
+ seen = []
+ real = F.write_array
+ def spy(fp, *a, **k):
+ seen.append(F.isfileobj(fp))
+ return real(fp, *a, **k)
+ with tempfile.TemporaryDirectory() as t, mock.patch.object(F, "write_array", spy):
+ SC.save_npy(Path(t) / "a.npy", np.ones(1000))
+ self.assertTrue(np.array_equal(np.load(Path(t) / "a.npy"), np.ones(1000)))
+ self.assertEqual(seen, [False])