From ee5bbcdfc6fe730864e9495cc78365cad93f25a5 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Sun, 16 Aug 2026 15:30:31 +0200 Subject: [PATCH] UI - front end improvement for mark all viewed (less IO) --- changedetectionio/blueprint/ui/__init__.py | 47 +++++++++++---------- changedetectionio/realtime/socket_server.py | 24 +++++++++++ changedetectionio/store/__init__.py | 15 +++++-- 3 files changed, 59 insertions(+), 27 deletions(-) diff --git a/changedetectionio/blueprint/ui/__init__.py b/changedetectionio/blueprint/ui/__init__.py index 98e3a319..524418c3 100644 --- a/changedetectionio/blueprint/ui/__init__.py +++ b/changedetectionio/blueprint/ui/__init__.py @@ -1,5 +1,6 @@ import time import threading +from blinker import signal from flask import Blueprint, request, redirect, url_for, flash, render_template, session, current_app from flask_babel import gettext from loguru import logger @@ -237,32 +238,32 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, worker_pool, list_filters = wl_filters.list_filters_from_args(datastore, request.args) now = int(time.time()) - # Mark watches as viewed - use background thread only for large watch counts - def mark_viewed_impl(): - """Mark watches as viewed - can run synchronously or in background thread.""" - marked_count = 0 - try: - for watch_uuid, watch in datastore.data['watching'].items(): - if not wl_filters.watch_matches_filters(datastore, watch, list_filters): - continue + # Runs SYNCHRONOUSLY, and must stay that way. Re #4021: this used to hand the work to a + # background thread and redirect immediately, so the watch list re-rendered from a + # datastore that was still being marked and showed rows as unviewed until a manual + # refresh. The realtime events that would have corrected it were emitted while the + # browser was mid-navigation with no socket connected, so they went nowhere. + # It is cheap enough to do inline: the per-watch signal is suppressed below (that was + # the actual cost, not the disk write, which measures ~0.05ms per watch). + marked_count = 0 + try: + for watch_uuid, watch in datastore.data['watching'].items(): + if not wl_filters.watch_matches_filters(datastore, watch, list_filters): + continue - datastore.set_last_viewed(watch_uuid, now) - marked_count += 1 + datastore.set_last_viewed(watch_uuid, now, send_signal=False) + marked_count += 1 - logger.info(f"Marking complete: {marked_count} watches marked as viewed") - except Exception as e: - logger.error(f"Error marking as viewed: {e}") + logger.info(f"Marking complete: {marked_count} watches marked as viewed") + except Exception as e: + logger.error(f"Error marking as viewed: {e}") - # For small watch counts (< 10), run synchronously to avoid race conditions in tests - # For larger counts, use background thread to avoid blocking the UI - watch_count = len(datastore.data['watching']) - if watch_count < 10: - # Run synchronously for small watch counts - mark_viewed_impl() - else: - # Start background thread for large watch counts - thread = threading.Thread(target=mark_viewed_impl, daemon=True) - thread.start() + # One summary event instead of one per watch, so other open tabs refresh their counters. + # This page doesn't need it - the redirect below re-renders it from the marked datastore. + if marked_count: + general_stats_update = signal('general_stats_update') + if general_stats_update: + general_stats_update.send() return redirect(url_for('watchlist.index', **wl_filters.filter_query_args(request.args))) diff --git a/changedetectionio/realtime/socket_server.py b/changedetectionio/realtime/socket_server.py index 6d38c75c..8abceb08 100644 --- a/changedetectionio/realtime/socket_server.py +++ b/changedetectionio/realtime/socket_server.py @@ -40,6 +40,30 @@ class SignalHandler: notification_event_signal.connect(self.handle_notification_event, weak=False) logger.info("SignalHandler: Connected to notification_event signal") + # One-shot stats refresh for bulk operations that deliberately skip the per-watch + # signal (see set_last_viewed(send_signal=False)) — n signals would mean n full + # rescans of every watch plus 3n broadcasts. + general_stats_signal = signal('general_stats_update') + general_stats_signal.connect(self.handle_general_stats_update, weak=False) + + + def handle_general_stats_update(self, *args, **kwargs): + """Emit the global counters once, without touching any individual row. + + Sent after a bulk operation. The tab that triggered it is usually reloading anyway; + this is for OTHER open tabs so their unread/error counters don't sit stale. + Note their ROWS still keep a stale 'unviewed' class until the row resync lands — + tracked separately, this only fixes the counters. + """ + try: + errored_count = sum(1 for w in self.datastore.data['watching'].values() if w.get('last_error')) + self.socketio_instance.emit("general_stats_update", { + 'count_errors': errored_count, + 'unread_changes_count': self.datastore.unread_changes_count, + }) + logger.trace("Socket.IO: Emitted one-shot general_stats_update") + except Exception as e: + logger.error(f"Socket.IO error in handle_general_stats_update: {str(e)}") def handle_watch_small_status_update(self, *args, **kwargs): """Small simple status update, for example 'Connecting...'""" diff --git a/changedetectionio/store/__init__.py b/changedetectionio/store/__init__.py index 8d8978d4..16031b09 100644 --- a/changedetectionio/store/__init__.py +++ b/changedetectionio/store/__init__.py @@ -455,14 +455,21 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore): # Watch Management Methods # ============================================================================ - def set_last_viewed(self, uuid, timestamp): + def set_last_viewed(self, uuid, timestamp, send_signal=True): logger.debug(f"Setting watch UUID: {uuid} last viewed to {int(timestamp)}") self.data['watching'][uuid].update({'last_viewed': int(timestamp)}) self.data['watching'][uuid].commit() - watch_check_update = signal('watch_check_update') - if watch_check_update: - watch_check_update.send(watch_uuid=uuid) + # Bulk callers (mark-all-viewed) pass send_signal=False and emit one summary event + # afterwards instead. Each signal fans out to handle_watch_update(), which rescans + # EVERY watch twice (errored_count + unread_changes_count), takes the queue and + # worker-pool locks, and broadcasts 3 socket events — so signalling per watch makes + # a bulk mark O(n^2) with 3n emits, and floods every connected browser with n + # row updates it will immediately re-render anyway. + if send_signal: + watch_check_update = signal('watch_check_update') + if watch_check_update: + watch_check_update.send(watch_uuid=uuid) def remove_password(self): self.__data['settings']['application']['password'] = False