Merge remote-tracking branch 'origin/main' into fix/376-exit-cancel

# Conflicts:
#	utils/parallel.py
This commit is contained in:
Vito Sansevero
2026-10-28 02:24:48 -07:00
61 changed files with 3230 additions and 857 deletions
+13 -4
View File
@@ -1636,7 +1636,14 @@ class AdminRoutesMixin:
if self._review_file_linked(media_file, roots):
outcome = "unchanged" # already linked: not a review case
else:
self._record_scanned_review(media_file, roots, review)
# The records name the item versions that count; the
# saver anchor (#390) is checked on the captured bytes.
self._record_scanned_review(
media_file,
roots,
review,
scanned=(records.get("canonical"), None),
)
elif untraced and outcome == "unresolved":
# Untraced by the default profile: the inspection profile may
# still offer a choice (queued for review, never linked).
@@ -1697,9 +1704,9 @@ class AdminRoutesMixin:
``"ambiguous"`` (nothing linked it) is a Needs review case. An
ambiguous result with nothing to choose (``reviewable`` is False: fewer
than two candidates carry a prompt) that nothing links returns
``"unresolved"`` instead, and ``media_file`` goes to ``untraced``;
its parsed records go to ``records["canonical"]`` when ``records`` is
a dict.
``"unresolved"`` instead, and ``media_file`` goes to ``untraced``.
The parsed records of either ambiguous file go to
``records["canonical"]`` when ``records`` is a dict.
"""
if not metadata:
# No prompt metadata (--disable-metadata, bare savers): only the
@@ -1729,6 +1736,8 @@ class AdminRoutesMixin:
records["canonical"] = canonical
elif resolution["state"] == "ambiguous" and ambiguous is not None:
ambiguous.append(media_file)
if records is not None:
records["canonical"] = canonical
if resolution["state"] == "resolved":
result = ingest_resolved_pair(
self.db, str(media_file), canonical, resolution
+73 -3
View File
@@ -483,13 +483,59 @@ class BackupRestoreRoutesMixin:
self.logger.info(f"Removed {len(removed)} old safety backups")
async def _publish(self, model, staged):
"""Publish ``staged``; on a page-copy failure prune and re-raise."""
"""Publish ``staged``; on a page-copy failure prune and re-raise.
A cancel (server stop) waits for the publishing thread (#360): the
caller's cleanup and its registry admission must outlive it. When the
copy then succeeded the library was replaced, so it is adopted
(``_after_publish``, itself run to its end) before the cancel goes
on; a copy that failed is handled as usual, then the cancel goes on.
"""
future = self._in_executor_task(model.publish_staged, staged)
cancelled = await _outlive_cancel(future)
try:
return await self._run_in_executor(model.publish_staged, staged)
backup_path = future.result()
except sqlite3.OperationalError as exc:
if getattr(exc, "_prompt_manager_restore_backup_created", False) is True:
await self._prune_after(model.db_path, failed=True)
if cancelled:
raise asyncio.CancelledError from exc
raise
except Exception as exc:
if cancelled:
raise asyncio.CancelledError from exc
raise
if cancelled:
self.logger.info("The restore finished while the server was stopping")
# A second cancel (the loop's own shutdown) must not cut it short.
adopted = asyncio.ensure_future(self._after_publish(model.db_path))
await _outlive_cancel(adopted)
if not adopted.cancelled() and adopted.exception() is not None:
self.logger.error(
"Could not adopt the restored library",
exc_info=adopted.exception(),
)
raise asyncio.CancelledError
return backup_path
async def _stage_to_end(self, model, temp_path):
"""Stage ``temp_path``; a cancel waits for the worker (#360).
The upload stays until the staging thread is done with it, and a
stage it finished after the cancel is removed before the cancel goes
on.
"""
future = self._in_executor_task(model.stage_restore, temp_path)
if await _outlive_cancel(future):
if not future.cancelled() and future.exception() is None:
await self._discard_held([_Orphan(future.result())])
raise asyncio.CancelledError
return future.result()
def _in_executor_task(self, func, *args):
"""``_run_in_executor(func, *args)`` as a task ``_outlive_cancel`` can
wait for (shielded, the executor call is never cancelled)."""
return asyncio.ensure_future(self._run_in_executor(func, *args))
async def _after_publish(self, db_path):
"""Everything after the copy replaced the library: best effort, logged.
@@ -609,7 +655,7 @@ class BackupRestoreRoutesMixin:
{"success": False, "error": REQUESTS_BUSY}, status=409
)
model = self.db.model
staged = await self._run_in_executor(model.stage_restore, temp_path)
staged = await self._stage_to_end(model, temp_path)
try:
backup_path = await self._publish(model, staged)
finally:
@@ -927,6 +973,30 @@ class BackupRestoreRoutesMixin:
job.update(progress=100, status_line=line)
async def _outlive_cancel(future):
"""Await ``future`` (an executor call's task) to its end, even through
cancels (#360).
A cancelled task cannot stop the worker thread, so the files it uses and
the registry admission it runs under must outlive it: the cancel is held
until the worker returned. Returns whether the task was cancelled
meanwhile; the caller reads the outcome from ``future`` and re-raises
``CancelledError``. A future cancelled itself (an executor shut down) is
not a cancel of the task: ``future.result()`` raises it. Never catches
``GeneratorExit``: a closed loop still closes the coroutine.
"""
cancelled = False
while not future.done():
try:
await asyncio.shield(future)
except asyncio.CancelledError:
if not future.cancelled():
cancelled = True
except Exception:
pass # the caller reads it from the future
return cancelled
class _Orphan:
"""A stage with no store entry, in the shape ``_discard_held`` takes."""
+40 -2
View File
@@ -11,6 +11,11 @@ import stat
from aiohttp import web
try:
from ...utils.logging_config import ClearRefused, parse_confirmed
except ImportError: # loaded as the top-level ``py`` package (tests)
from utils.logging_config import ClearRefused, parse_confirmed
# Keys PromptManagerLogger.update_config() understands. Anything else is
# rejected so a client cannot inject arbitrary entries into the logging config.
LOG_CONFIG_KEYS = frozenset(
@@ -263,10 +268,43 @@ class LoggingRoutesMixin:
)
async def truncate_logs(self, request):
"""Truncate all log files."""
"""Clear all logs: what ``{"expected": [...]}`` lists, or all (#389).
``expected`` holds the ``{name, identity}`` rows of ``/logs/files`` the
confirmation showed. Without a body (the legacy admin page, whose
confirmation lists nothing) every rotated copy present now is deleted.
A malformed list is 400; a clear that would empty or delete what was
not confirmed changes nothing and is 409 with the reason's ``code``.
"""
try:
args = ()
if request.can_read_body:
data, error_response = await self._read_json_body(request)
if error_response is not None:
return error_response
if "expected" in data:
try:
parse_confirmed(data["expected"])
except ValueError:
return web.json_response(
{
"success": False,
"error": "expected must list name and identity"
" rows from /logs/files",
},
status=400,
)
args = (data["expected"],)
logger_manager = self._get_logger_manager()
results = await self._run_in_executor(logger_manager.truncate_logs)
try:
results = await self._run_in_executor(
logger_manager.truncate_logs, *args
)
except ClearRefused as refused:
return web.json_response(
{"success": False, "error": str(refused), "code": refused.code},
status=409,
)
return web.json_response(
{
+11 -1
View File
@@ -677,7 +677,14 @@ def _verify_and_read(store, capture, item, root, need_metadata: bool):
facts = store.verify(root, relative)
captured = None
if need_metadata:
captured = captured_review(store, capture, item_profile(item["evidence"]))
# The file's own name, so an inspection item resolves anchored on its
# saver as it was recorded (#390).
captured = captured_review(
store,
capture,
item_profile(item["evidence"]),
relative.rsplit("/", 1)[-1],
)
return relative, facts, captured
@@ -723,6 +730,9 @@ def _candidate_still_offered(resolution: dict, choice: str) -> bool:
that rewords the top-level reason or reorders candidates leaves a
pending item applicable.
"""
if isinstance(resolution, dict) and resolution.get("anchor") == "saver":
# Only the saver anchor resolves the bytes (#390): its pair alone.
resolution = {"state": "ambiguous", "candidates": [resolution]}
if not isinstance(resolution, dict) or resolution.get("state") != "ambiguous":
return False
evidence, _count, limited = review_evidence(resolution)
+3 -2
View File
@@ -88,7 +88,8 @@ class ReviewRecordingMixin:
def _record_scanned_review(self, media_file, roots, review=None, scanned=None):
"""Record one ambiguous, unlinked scanned file (logged and counted).
``scanned`` is the scan's ``(metadata, inspection_resolution)``.
``scanned`` is the scan's ``(metadata, inspection_resolution)``; the
resolution is None when the scan did not run the inspection profile.
"""
try:
outcome = self._record_review_sync(
@@ -199,7 +200,7 @@ class ReviewRecordingMixin:
key = _memory_key(locator)
if state is not None and memory.get(key) == state:
return None
resolution = review_capture.inspection_choice(canonical)
resolution = review_capture.inspection_choice(canonical, media_file.name)
if resolution is not None:
memory.pop(key, None)
return canonical, resolution
+23 -11
View File
@@ -88,9 +88,9 @@ from .thumbnail_encode import shutdown_encoders, writer_settings
from .thumbnail_jobs import ThumbnailOptions
try:
from ...utils.parallel import map_parallel
from ...utils.parallel import map_parallel, note_refusal, refused_at_shutdown
except ImportError: # tests import the package top-level
from utils.parallel import map_parallel
from utils.parallel import map_parallel, note_refusal, refused_at_shutdown
try:
from ...utils.exit_signal import ExitRequested
@@ -415,7 +415,8 @@ class AutoThumbnailer:
def enqueue(self, src, dst, is_video):
"""Queue *dst*'s preview for the worker; False when already queued,
full or closed. Never blocks on a write."""
full, closed or its worker is refused at interpreter exit. Never
blocks on a write."""
key = _dst_key(dst)
with self._changed:
if self._closed or key in self._queued:
@@ -425,30 +426,41 @@ class AutoThumbnailer:
self._queued.add(key)
self._queue.append((key, Path(src), Path(dst), bool(is_video)))
self._busy = True
started = True
if self._start_worker and not self._worker_running:
self._start_worker_locked()
started = self._start_worker_locked()
if not started:
# Refused at interpreter exit: no worker will take it (#375).
self._queue.pop()
self._queued.discard(key)
self._changed.notify_all()
return True
return started
def _start_worker_locked(self):
"""Start the worker (lock held). A start that fails leaves the file
queued and the next enqueue tries again."""
"""Start the worker (lock held); False only when the interpreter is
exiting (#375: logged once at DEBUG, the caller drops the file).
Another start failure leaves the file queued and the next enqueue
tries again."""
worker = threading.Thread(target=self._run, name=WORKER_NAME, daemon=True)
worker.pm_preview_token = _MODULE_TOKEN
worker.pm_preview_close = self.close
self._worker_running = True
try:
worker.start()
except RuntimeError:
except RuntimeError as error:
self._worker_running = False
self._busy = False
if refused_at_shutdown(error):
note_refusal(self._logger, "The automatic preview worker")
return False
self._logger.warning(
"The automatic preview worker could not start; the next output "
"tries again",
exc_info=True,
)
return
return True
self._worker = worker
return True
def _run(self):
try:
@@ -797,8 +809,8 @@ class AutoThumbnailMixin:
Returns ``queued``, ``fresh`` (its preview is current) or ``skipped``
(not a media file under a current root, a link, a preview itself,
already queued, or after the app's cleanup). Never writes on the
caller's thread.
already queued, after the app's cleanup, or when its worker is refused
at interpreter exit). Never writes on the caller's thread.
"""
if self._auto_thumbnails_shut:
return "skipped"