Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ border-radius: 128px;
a CBR conversion even when a library scan lands in the middle of one.
- Editing a comic's tags twice in a row no longer fails the second edit with
a "no such file" error when renaming is on.
- A watched library on a network share or removable volume that goes missing
no longer has all its comics deleted. Polling already refused to scan in
that state; watching now refuses to act on it too.
- Comics are never deleted from the database while their files are still on
disk, so a misread filesystem event can no longer take a comic's bookmarks
and read progress with it.
Expand Down
31 changes: 31 additions & 0 deletions codex/librarian/fs/mounted.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""
Recognize a library root that isn't really there.

A dropped network share, an ejected volume, or a docker bind mount that
didn't come up presents as an empty (or missing) directory rather than an
error. Every comic in the library then looks deleted at once, and acting
on that removes the rows and cascades their bookmarks away — for files
that are perfectly fine and will be back as soon as the mount is.

The delete-phase existence check cannot help here: while the mount is
gone the files genuinely are unreachable. The only defense is to notice
the shape of the failure and refuse to act, which is what both scanners
do with this.
"""

from pathlib import Path

#: Docker bind mounts of a missing host path can be seeded with this file
#: so an unmounted volume is distinguishable from an empty library.
DOCKER_UNMOUNTED_FN = "DOCKER_UNMOUNTED_VOLUME"


def unmounted_reason(root: Path) -> str:
"""Return why this library root looks unmounted, or "" if it looks fine."""
if not root.is_dir():
return "is not there"
if (root / DOCKER_UNMOUNTED_FN).exists():
return "looks like an unmounted docker volume"
if not any(root.iterdir()):
return "is empty. Suspect unmounted"
return ""
20 changes: 4 additions & 16 deletions codex/librarian/fs/poller/poller.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from humanize import naturaldelta

from codex.librarian.fs.import_task import build_import_task
from codex.librarian.fs.mounted import unmounted_reason
from codex.librarian.fs.poller.snapshot import DatabaseSnapshot, DiskSnapshot
from codex.librarian.fs.poller.snapshot_diff import SnapshotDiff
from codex.librarian.fs.poller.status import FSPollStatus
Expand All @@ -19,7 +20,6 @@
from codex.models import Library
from codex.views.const import EPOCH_START

DOCKER_UNMOUNTED_FN = "DOCKER_UNMOUNTED_VOLUME"
_DIR_NOT_FOUND_TIMEOUT = 15 * 60
_LIBRARY_ONLY = (
"path",
Expand Down Expand Up @@ -67,32 +67,20 @@ def stop(self) -> None:
# Timeout computation #
#######################

def _get_poll_timeout(self, library: Library) -> float | None: # noqa: PLR0911
def _get_poll_timeout(self, library: Library) -> float | None:
"""
Compute seconds until this library's next scheduled poll.

Returns None to wait forever (manual poll only).
"""
watch_path = Path(library.path)
unmounted_marker = watch_path / DOCKER_UNMOUNTED_FN

if not library.poll:
self.log.info(f"Library {library.path} waiting for manual poll.")
return None

if not watch_path.is_dir():
self.log.warning(f"Library {library.path} not found. Not polling.")
return _DIR_NOT_FOUND_TIMEOUT

if unmounted_marker.exists():
warning = f"Library {library.path} looks like an unmounted docker volume. Not polling."
self.log.warning(warning)
return _DIR_NOT_FOUND_TIMEOUT

if not tuple(watch_path.iterdir()):
self.log.warning(
f"{library.path} is empty. Suspect unmounted. Not polling."
)
if reason := unmounted_reason(watch_path):
self.log.warning(f"Library {library.path} {reason}. Not polling.")
return _DIR_NOT_FOUND_TIMEOUT

if library.update_in_progress:
Expand Down
38 changes: 36 additions & 2 deletions codex/librarian/fs/watcher/watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from codex.librarian.fs.filters import is_ignored_path, match_comic
from codex.librarian.fs.import_task import build_import_task
from codex.librarian.fs.mounted import unmounted_reason
from codex.librarian.fs.watcher.events import process_changes
from codex.librarian.fs.watcher.status import FSWatcherRestartStatus
from codex.librarian.threads import NamedThread
Expand Down Expand Up @@ -127,8 +128,41 @@ def _process_changes(self, changes: set[tuple[Change, str]]) -> None:

for library_pk, events in events_by_library.items():
task = build_import_task(library_pk, events)
if task is not None:
self.librarian_queue.put(task)
if task is None:
continue
if self._is_a_vanished_library(task):
continue
self.librarian_queue.put(task)

def _is_a_vanished_library(self, task) -> bool:
"""
Whether this task's deletes are really an unmounted library.

A dropped share or volume presents every comic in the library as
deleted at once. The poller refuses to scan a library in that
state; the watcher already holds the events, so it has to refuse
to act on them. Only deletes are worth checking — an add or a
modify against a missing mount can't do damage.
"""
if not (task.files_deleted or task.dirs_deleted or task.covers_deleted):
return False
root = self._library_root(task.library_id)
if root is None:
return False
reason = unmounted_reason(root)
if not reason:
return False
self.log.warning(
f"Library {root} {reason}. Ignoring the deletes it just reported."
)
return True

def _library_root(self, library_pk: int) -> Path | None:
"""Return a watched library's root path."""
for path, pk in self._library_paths.items():
if pk == library_pk:
return Path(path)
return None

def _get_extant_paths(self, paths: list[str]) -> list[str]:
extant_paths = []
Expand Down
172 changes: 172 additions & 0 deletions tests/test_watcher_unmount_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"""
A library that isn't really there must not have its comics deleted.

A dropped network share, an ejected volume, or a docker bind mount that
didn't come up presents as an empty or missing directory, so every comic
under it looks deleted at once. The poller refuses to scan in that state;
the watcher already holds the events, so it has to refuse to act on them.
The delete-phase existence check cannot help — while the mount is gone
the files really are unreachable.
"""

from __future__ import annotations

import shutil
from pathlib import Path
from typing import Any, Final, override

from django.test import TestCase
from loguru import logger
from watchfiles import Change

from codex.librarian.fs.mounted import DOCKER_UNMOUNTED_FN, unmounted_reason
from codex.librarian.fs.watcher.watcher import LibraryWatcherThread
from codex.librarian.scribe.importer.tasks import ImportTask

_ROOT: Final = Path("/tmp/codex.tests.unmount") # noqa: S108
_LIBRARY_PK: Final = 1


def _double(stub: object) -> Any:
"""Pass a test double through a concretely-typed seam."""
return stub


class _ListQueue:
"""Records what the watcher queues."""

def __init__(self, items: list) -> None:
self.items = items

def put(self, item) -> None:
self.items.append(item)


def _watcher() -> LibraryWatcherThread:
"""Build a watcher without its threading machinery."""
watcher = LibraryWatcherThread.__new__(LibraryWatcherThread)
watcher.log = _double(logger)
watcher._library_paths = {str(_ROOT): _LIBRARY_PK} # noqa: SLF001
return watcher


def _delete_task() -> ImportTask:
return ImportTask(
library_id=_LIBRARY_PK,
files_deleted=frozenset({str(_ROOT / "a.cbz")}),
)


class UnmountedReasonTests(TestCase):
"""The shared check both scanners consult."""

@override
def setUp(self) -> None:
shutil.rmtree(_ROOT, ignore_errors=True)
_ROOT.mkdir(parents=True)

@override
def tearDown(self) -> None:
shutil.rmtree(_ROOT, ignore_errors=True)

def test_a_populated_directory_looks_mounted(self) -> None:
(_ROOT / "a.cbz").write_text("comic")

assert not unmounted_reason(_ROOT)

def test_a_missing_directory_is_flagged(self) -> None:
shutil.rmtree(_ROOT)

assert "not there" in unmounted_reason(_ROOT)

def test_an_empty_directory_is_flagged(self) -> None:
assert "empty" in unmounted_reason(_ROOT)

def test_the_docker_marker_is_flagged(self) -> None:
(_ROOT / DOCKER_UNMOUNTED_FN).write_text("")

assert "docker" in unmounted_reason(_ROOT)


class WatcherUnmountGuardTests(TestCase):
"""Deletes from a vanished library never reach the queue."""

@override
def setUp(self) -> None:
shutil.rmtree(_ROOT, ignore_errors=True)
_ROOT.mkdir(parents=True)

@override
def tearDown(self) -> None:
shutil.rmtree(_ROOT, ignore_errors=True)

def test_deletes_are_dropped_when_the_library_is_empty(self) -> None:
"""An empty root means the mount is gone, not that every comic is."""
assert _watcher()._is_a_vanished_library(_delete_task()) # noqa: SLF001

def test_deletes_are_dropped_when_the_root_is_missing(self) -> None:
shutil.rmtree(_ROOT)

assert _watcher()._is_a_vanished_library(_delete_task()) # noqa: SLF001

def test_real_deletes_still_pass(self) -> None:
"""A library with other comics still in it is really deleting one."""
(_ROOT / "b.cbz").write_text("comic")

assert not _watcher()._is_a_vanished_library(_delete_task()) # noqa: SLF001

def test_a_task_without_deletes_is_never_blocked(self) -> None:
"""Adds and modifies can't destroy anything, so they are not checked."""
task = ImportTask(
library_id=_LIBRARY_PK,
files_modified=frozenset({str(_ROOT / "a.cbz")}),
)

assert not _watcher()._is_a_vanished_library(task) # noqa: SLF001

def test_an_unknown_library_is_not_blocked(self) -> None:
"""Without a root to check, the guard stays out of the way."""
task = ImportTask(
library_id=999, files_deleted=frozenset({str(_ROOT / "a.cbz")})
)

assert not _watcher()._is_a_vanished_library(task) # noqa: SLF001


class WatcherProcessChangesTests(TestCase):
"""The guard is wired into the path that queues the work."""

@override
def setUp(self) -> None:
shutil.rmtree(_ROOT, ignore_errors=True)
_ROOT.mkdir(parents=True)

@override
def tearDown(self) -> None:
shutil.rmtree(_ROOT, ignore_errors=True)

@staticmethod
def _run(queue: list) -> None:
watcher = _watcher()
watcher.librarian_queue = _double(_ListQueue(queue))
watcher._process_changes( # noqa: SLF001
{(Change.deleted, str(_ROOT / "a.cbz"))}
)

def test_an_empty_library_queues_nothing(self) -> None:
"""The whole library looking deleted never reaches the importer."""
queued: list = []

self._run(queued)

assert not queued

def test_a_populated_library_queues_the_delete(self) -> None:
"""A real delete is still reported."""
(_ROOT / "b.cbz").write_text("comic")
queued: list = []

self._run(queued)

assert len(queued) == 1
assert queued[0].files_deleted == {str(_ROOT / "a.cbz")}