Riduce falsi positivi del watchdog
This commit is contained in:
@@ -312,9 +312,15 @@ Valori iniziali consigliati:
|
|||||||
stability:
|
stability:
|
||||||
min_age_seconds: 60
|
min_age_seconds: 60
|
||||||
unchanged_check_interval_seconds: 5
|
unchanged_check_interval_seconds: 5
|
||||||
unchanged_checks_required: 2
|
unchanged_checks_required: 2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Riduzione falsi positivi
|
||||||
|
|
||||||
|
Quando si monitora una directory molto ampia, ad esempio un'intera unita' `D:\`, la semplice navigazione con Explorer o altri programmi puo' generare eventi `modified` non rilevanti. Per ridurre questi falsi positivi, il servizio deve mantenere uno snapshot locale di dimensione e timestamp di modifica dei file monitorati.
|
||||||
|
|
||||||
|
All'avvio del servizio viene popolata una baseline degli snapshot per i file gia' presenti, senza inserirli nella coda di backup. Un evento `modified` viene registrato solo se dimensione o timestamp di scrittura risultano diversi dallo snapshot conosciuto.
|
||||||
|
|
||||||
## Configurazione
|
## Configurazione
|
||||||
|
|
||||||
Il file di configurazione dovra' definire almeno:
|
Il file di configurazione dovra' definire almeno:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class ChangeEvent:
|
|||||||
event_type: str
|
event_type: str
|
||||||
old_path: str | None = None
|
old_path: str | None = None
|
||||||
size: int | None = None
|
size: int | None = None
|
||||||
|
mtime_ns: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class ChangeRegistry:
|
class ChangeRegistry:
|
||||||
@@ -26,19 +27,66 @@ class ChangeRegistry:
|
|||||||
with closing(self._connect()) as connection:
|
with closing(self._connect()) as connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO changes(path, event_type, old_path, size, status, created_at, updated_at)
|
INSERT INTO changes(path, event_type, old_path, size, mtime_ns, status, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, 'pending', ?, ?)
|
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)
|
||||||
ON CONFLICT(path) DO UPDATE SET
|
ON CONFLICT(path) DO UPDATE SET
|
||||||
event_type = excluded.event_type,
|
event_type = excluded.event_type,
|
||||||
old_path = excluded.old_path,
|
old_path = excluded.old_path,
|
||||||
size = excluded.size,
|
size = excluded.size,
|
||||||
|
mtime_ns = excluded.mtime_ns,
|
||||||
status = 'pending',
|
status = 'pending',
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
""",
|
""",
|
||||||
(event.path, event.event_type, event.old_path, event.size, timestamp, timestamp),
|
(
|
||||||
|
event.path,
|
||||||
|
event.event_type,
|
||||||
|
event.old_path,
|
||||||
|
event.size,
|
||||||
|
event.mtime_ns,
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
if event.event_type != "deleted":
|
||||||
|
self.update_snapshot(event.path, event.size, event.mtime_ns, connection)
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
|
||||||
|
def has_snapshot_changed(self, path: str, size: int | None, mtime_ns: int | None) -> bool:
|
||||||
|
with closing(self._connect()) as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT size, mtime_ns FROM file_snapshots WHERE path = ?",
|
||||||
|
(path,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return True
|
||||||
|
return row[0] != size or row[1] != mtime_ns
|
||||||
|
|
||||||
|
def update_snapshot(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
size: int | None,
|
||||||
|
mtime_ns: int | None,
|
||||||
|
connection: sqlite3.Connection | None = None,
|
||||||
|
) -> None:
|
||||||
|
timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
|
if connection is None:
|
||||||
|
with closing(self._connect()) as local_connection:
|
||||||
|
self.update_snapshot(path, size, mtime_ns, local_connection)
|
||||||
|
local_connection.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO file_snapshots(path, size, mtime_ns, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(path) DO UPDATE SET
|
||||||
|
size = excluded.size,
|
||||||
|
mtime_ns = excluded.mtime_ns,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(path, size, mtime_ns, timestamp),
|
||||||
|
)
|
||||||
|
|
||||||
def pending_count(self) -> int:
|
def pending_count(self) -> int:
|
||||||
with closing(self._connect()) as connection:
|
with closing(self._connect()) as connection:
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
@@ -50,14 +98,14 @@ class ChangeRegistry:
|
|||||||
with closing(self._connect()) as connection:
|
with closing(self._connect()) as connection:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"""
|
"""
|
||||||
SELECT path, event_type, old_path, size
|
SELECT path, event_type, old_path, size, mtime_ns
|
||||||
FROM changes
|
FROM changes
|
||||||
WHERE status = 'pending'
|
WHERE status = 'pending'
|
||||||
ORDER BY updated_at ASC
|
ORDER BY updated_at ASC
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [
|
return [
|
||||||
ChangeEvent(path=row[0], event_type=row[1], old_path=row[2], size=row[3])
|
ChangeEvent(path=row[0], event_type=row[1], old_path=row[2], size=row[3], mtime_ns=row[4])
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -90,12 +138,24 @@ class ChangeRegistry:
|
|||||||
event_type TEXT NOT NULL,
|
event_type TEXT NOT NULL,
|
||||||
old_path TEXT,
|
old_path TEXT,
|
||||||
size INTEGER,
|
size INTEGER,
|
||||||
|
mtime_ns INTEGER,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
_ensure_column(connection, "changes", "mtime_ns", "INTEGER")
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS file_snapshots (
|
||||||
|
path TEXT NOT NULL PRIMARY KEY,
|
||||||
|
size INTEGER,
|
||||||
|
mtime_ns INTEGER,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_changes_status ON changes(status)"
|
"CREATE INDEX IF NOT EXISTS idx_changes_status ON changes(status)"
|
||||||
)
|
)
|
||||||
@@ -103,3 +163,12 @@ class ChangeRegistry:
|
|||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
def _connect(self) -> sqlite3.Connection:
|
||||||
return sqlite3.connect(self.db_path)
|
return sqlite3.connect(self.db_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_column(connection: sqlite3.Connection, table: str, column: str, definition: str) -> None:
|
||||||
|
columns = {
|
||||||
|
row[1]
|
||||||
|
for row in connection.execute(f"PRAGMA table_info({table})").fetchall()
|
||||||
|
}
|
||||||
|
if column not in columns:
|
||||||
|
connection.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class WatchdogRuntime:
|
|||||||
watched = 0
|
watched = 0
|
||||||
for include_dir in self.config.watch.include_dirs:
|
for include_dir in self.config.watch.include_dirs:
|
||||||
if include_dir.exists():
|
if include_dir.exists():
|
||||||
|
self._prime_snapshots(include_dir)
|
||||||
observer.schedule(Handler(), str(include_dir), recursive=True)
|
observer.schedule(Handler(), str(include_dir), recursive=True)
|
||||||
self.logger.info("Monitoring directory: %s", include_dir)
|
self.logger.info("Monitoring directory: %s", include_dir)
|
||||||
watched += 1
|
watched += 1
|
||||||
@@ -70,12 +71,21 @@ class WatchdogRuntime:
|
|||||||
if not self.filter.should_track(target_path, is_directory=is_directory):
|
if not self.filter.should_track(target_path, is_directory=is_directory):
|
||||||
return
|
return
|
||||||
|
|
||||||
size = _safe_size(target_path)
|
size, mtime_ns = _safe_snapshot(target_path)
|
||||||
|
if event_type == "modified" and not self.registry.has_snapshot_changed(
|
||||||
|
str(target_path),
|
||||||
|
size,
|
||||||
|
mtime_ns,
|
||||||
|
):
|
||||||
|
self.logger.info("Ignored metadata-only modified event: %s", target_path)
|
||||||
|
return
|
||||||
|
|
||||||
change = ChangeEvent(
|
change = ChangeEvent(
|
||||||
path=str(target_path),
|
path=str(target_path),
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
old_path=str(src_path) if dest_path_value else None,
|
old_path=str(src_path) if dest_path_value else None,
|
||||||
size=size,
|
size=size,
|
||||||
|
mtime_ns=mtime_ns,
|
||||||
)
|
)
|
||||||
self.registry.record(change)
|
self.registry.record(change)
|
||||||
self.logger.info("Recorded %s: %s", event_type, target_path)
|
self.logger.info("Recorded %s: %s", event_type, target_path)
|
||||||
@@ -84,9 +94,23 @@ class WatchdogRuntime:
|
|||||||
while not self.stop_event.wait(60):
|
while not self.stop_event.wait(60):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _prime_snapshots(self, root: Path) -> None:
|
||||||
|
self.logger.info("Priming file snapshots: %s", root)
|
||||||
|
count = 0
|
||||||
|
for path in root.rglob("*"):
|
||||||
|
if self.stop_event.is_set():
|
||||||
|
break
|
||||||
|
if not self.filter.should_track(path, is_directory=path.is_dir()):
|
||||||
|
continue
|
||||||
|
size, mtime_ns = _safe_snapshot(path)
|
||||||
|
self.registry.update_snapshot(str(path), size, mtime_ns)
|
||||||
|
count += 1
|
||||||
|
self.logger.info("Primed %s file snapshot(s): %s", count, root)
|
||||||
|
|
||||||
def _safe_size(path: Path) -> int | None:
|
|
||||||
|
def _safe_snapshot(path: Path) -> tuple[int | None, int | None]:
|
||||||
try:
|
try:
|
||||||
return path.stat().st_size
|
stat = path.stat()
|
||||||
|
return stat.st_size, stat.st_mtime_ns
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None, None
|
||||||
|
|||||||
@@ -24,3 +24,27 @@ def test_mark_ignored_removes_from_pending_queue(tmp_path: Path) -> None:
|
|||||||
registry.mark_ignored(path)
|
registry.mark_ignored(path)
|
||||||
|
|
||||||
assert registry.pending_count() == 0
|
assert registry.pending_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_change_detection(tmp_path: Path) -> None:
|
||||||
|
registry = ChangeRegistry(tmp_path / "changes.sqlite")
|
||||||
|
path = str(tmp_path / "image.jpg")
|
||||||
|
|
||||||
|
assert registry.has_snapshot_changed(path, 100, 123)
|
||||||
|
|
||||||
|
registry.update_snapshot(path, 100, 123)
|
||||||
|
|
||||||
|
assert not registry.has_snapshot_changed(path, 100, 123)
|
||||||
|
assert registry.has_snapshot_changed(path, 101, 123)
|
||||||
|
assert registry.has_snapshot_changed(path, 100, 124)
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_stores_snapshot_and_mtime(tmp_path: Path) -> None:
|
||||||
|
registry = ChangeRegistry(tmp_path / "changes.sqlite")
|
||||||
|
path = str(tmp_path / "image.jpg")
|
||||||
|
|
||||||
|
registry.record(ChangeEvent(path=path, event_type="modified", size=100, mtime_ns=123))
|
||||||
|
|
||||||
|
pending = registry.list_pending()
|
||||||
|
assert pending == [ChangeEvent(path=path, event_type="modified", size=100, mtime_ns=123)]
|
||||||
|
assert not registry.has_snapshot_changed(path, 100, 123)
|
||||||
|
|||||||
Reference in New Issue
Block a user