fix: reconcile < target severity and rmtree symlink guard (v0.5.10)
- reconcile.py: re-escalate the < target branch from INFO to WARNING and add 'permanently unmapped' label. Both post-loop branches produce identical permanent mapping loss; v0.5.9 incorrectly treated the timeout case as recoverable. - executor.py: guard shutil.rmtree with 'not os.path.islink(person_dir)' so a race-replaced symlink-to-directory is skipped rather than raising an unhandled OSError that aborts all remaining jobs. Correct comment: rmtree raises OSError, not NotADirectoryError.
This commit is contained in:
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.5.10] - 2026-06-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Reconcile `< target` branch re-escalated to WARNING**: when fewer Frigate files appear than expected after the full backoff window, the affected files are permanently unmapped — identical in consequence to the `> target` (external upload race) case fixed in v0.5.9. The v0.5.9 demotion to `INFO` was incorrect; both post-loop branches now log at `WARNING` and include the "permanently unmapped" label.
|
||||||
|
|
||||||
|
- **`execute_jobs` symlink guard added before `shutil.rmtree`**: `os.path.isdir` follows symlinks and returns `True` for a symlink pointing at a directory. If a race condition replaces `person_dir` with such a symlink, the old guard would pass and `shutil.rmtree` would raise an unhandled `OSError`, aborting all remaining jobs in the batch. The guard is now `os.path.isdir(person_dir) and not os.path.islink(person_dir)`, so a symlink-to-directory is silently skipped. The comment is also corrected: `shutil.rmtree` raises `OSError` (not `NotADirectoryError`) on a top-level symlink.
|
||||||
|
|
||||||
## [0.5.9] - 2026-06-15
|
## [0.5.9] - 2026-06-15
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.5.9"
|
version = "0.5.10"
|
||||||
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition."
|
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition."
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|||||||
+4
-3
@@ -107,9 +107,10 @@ def execute_jobs(jobs: list[dict]) -> None:
|
|||||||
logger.error(str(e))
|
logger.error(str(e))
|
||||||
continue
|
continue
|
||||||
# Face crops are transient (uploaded then discarded); wipe before each run.
|
# Face crops are transient (uploaded then discarded); wipe before each run.
|
||||||
# shutil.rmtree raises NotADirectoryError on a top-level symlink (POSIX),
|
# Exclude symlinks explicitly: os.path.isdir follows them and returns True
|
||||||
# so a race-replaced symlink cannot cause deletion outside output_dir.
|
# for a symlink-to-directory, but shutil.rmtree raises OSError on a
|
||||||
if os.path.isdir(person_dir):
|
# top-level symlink rather than deleting through it.
|
||||||
|
if os.path.isdir(person_dir) and not os.path.islink(person_dir):
|
||||||
shutil.rmtree(person_dir)
|
shutil.rmtree(person_dir)
|
||||||
os.makedirs(person_dir, exist_ok=True)
|
os.makedirs(person_dir, exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -85,9 +85,10 @@ def reconcile_frigate_mappings(
|
|||||||
target,
|
target,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.warning(
|
||||||
"%s: only %s of %s expected Frigate files"
|
"%s: only %s of %s expected Frigate files"
|
||||||
" appeared after reconciliation — mapping skipped",
|
" appeared after reconciliation — mapping skipped;"
|
||||||
|
" these files are permanently unmapped",
|
||||||
person_name,
|
person_name,
|
||||||
len(new_files),
|
len(new_files),
|
||||||
target,
|
target,
|
||||||
|
|||||||
Reference in New Issue
Block a user