105 lines
3.7 KiB
Python
105 lines
3.7 KiB
Python
"""Concurrency tests: store/users racy write scenarios.
|
|
|
|
These tests hammer the same files from many threads to confirm that
|
|
per-target locks, fsync, and unique tempfiles do not corrupt data.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from pathlib import Path
|
|
|
|
from volumen.post import Post
|
|
from volumen.store import Store
|
|
from volumen.users import Users
|
|
|
|
|
|
class TestStoreRace:
|
|
def test_concurrent_saves_keep_all_data(self, tmp_content_dir: Path) -> None:
|
|
"""Many threads save distinct posts in parallel."""
|
|
store = Store(str(tmp_content_dir), default_lang="en")
|
|
|
|
def worker(idx: int) -> str:
|
|
slug = f"post-{idx}"
|
|
post = Post(metadata={"slug": slug, "title": f"Post {idx}"}, body=f"body {idx}")
|
|
store.save(post)
|
|
return slug
|
|
|
|
with ThreadPoolExecutor(max_workers=16) as pool:
|
|
futures = [pool.submit(worker, i) for i in range(50)]
|
|
for f in as_completed(futures):
|
|
assert f.result() is not None
|
|
|
|
posts = store.all()
|
|
slugs = {p.slug for p in posts}
|
|
for i in range(50):
|
|
assert f"post-{i}" in slugs
|
|
|
|
def test_concurrent_saves_to_same_target(self, tmp_content_dir: Path) -> None:
|
|
"""Many threads save to the same target path; no data lost."""
|
|
store = Store(str(tmp_content_dir), default_lang="en")
|
|
|
|
def worker(idx: int) -> None:
|
|
post = Post(
|
|
metadata={"slug": "shared", "title": f"v{idx}"},
|
|
body=f"iteration {idx}",
|
|
)
|
|
store.save(post)
|
|
|
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
list(pool.map(worker, range(20)))
|
|
|
|
post = store.find("shared")
|
|
assert post is not None
|
|
# The file content must end with one of our iterations, no torn write.
|
|
assert post.body.startswith("iteration ")
|
|
|
|
|
|
class TestUsersRace:
|
|
def test_concurrent_adds_keep_all_users(self, users_path: str) -> None:
|
|
users = Users(path=users_path)
|
|
|
|
def worker(idx: int) -> str:
|
|
name = f"user-{idx}"
|
|
users.add(name, "alice-test-password")
|
|
return name
|
|
|
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
futures = [pool.submit(worker, i) for i in range(30)]
|
|
for f in as_completed(futures):
|
|
assert f.result() is not None
|
|
|
|
assert len(users.all) == 30
|
|
|
|
|
|
class TestSnapshotCache:
|
|
def test_snapshot_changes_invalidate_cache(self, tmp_content_dir: Path) -> None:
|
|
store = Store(str(tmp_content_dir), default_lang="en")
|
|
store.all() # prime cache
|
|
|
|
(tmp_content_dir / "extra.md").write_text('+++\ntitle = "Extra"\n+++\n\nNew post.\n')
|
|
# Even without touching mtime directly, stat() picks up the new file.
|
|
posts = store.all()
|
|
slugs = {p.slug for p in posts}
|
|
assert "extra" in slugs
|
|
|
|
def test_size_only_change_invalidates(self, tmp_content_dir: Path) -> None:
|
|
"""The cache key includes size; in-place rewrites invalidate."""
|
|
import os
|
|
|
|
store = Store(str(tmp_content_dir), default_lang="en")
|
|
# Force initial cache.
|
|
store.all()
|
|
# Overwrite an existing file with the same mtime but different size.
|
|
path = tmp_content_dir / "hello.md"
|
|
st = os.stat(path)
|
|
os.utime(path, (st.st_atime, st.st_mtime))
|
|
path.write_text(
|
|
'+++\ntitle = "Hello (resized)"\n+++\n\nNew body that is much longer than before.\n' * 5
|
|
)
|
|
# Touch back to original mtime so only size differs.
|
|
os.utime(path, (st.st_atime, st.st_mtime))
|
|
post = store.find("hello")
|
|
assert post is not None
|
|
assert "resized" in (post.title or "")
|