UI - front end improvement for mark all viewed (less IO)

This commit is contained in:
dgtlmoon
2026-08-16 15:30:31 +02:00
parent cf5f02b177
commit ee5bbcdfc6
3 changed files with 59 additions and 27 deletions
+24 -23
View File
@@ -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)))
@@ -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...'"""
+11 -4
View File
@@ -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