Linux maintainer tooling and workflows
 help / color / mirror / Atom feed
From: Christian Brauner <brauner@kernel.org>
To: "Kernel.org Tools" <tools@kernel.org>
Cc: Konstantin Ryabitsev <konstantin@linuxfoundation.org>,
	 "Christian Brauner (Amutable)" <brauner@kernel.org>
Subject: [PATCH RFC 02/11] review: track message counts for all revisions of a series
Date: Sat, 18 Jul 2026 00:37:38 +0200	[thread overview]
Message-ID: <20260718-work-b4-multiver-rows-v1-2-3c539d2a3095@kernel.org> (raw)
In-Reply-To: <20260718-work-b4-multiver-rows-v1-0-3c539d2a3095@kernel.org>

The series table only carries message counts for the tracked revision,
so new mail landing on an older version's thread is invisible.  Give
the revisions catalog its own message_count/seen_message_count/
last_update_check/last_activity_at columns (schema v11, backfilled
from series rows including archived upgrade leftovers), stitch reads
through a LEFT JOIN so live series counts win while archived rows
never shadow the catalog, and add update_revision_message_counts() --
an incremental per-revision poller (newest-first, zero writes when
quiet, rethreaded revisions summed across their per-patch threads,
thread mbox cached as a git blob).

refresh_message_count() and sync_seen_from_unseen_count() now fall
back to the catalog row when the (change_id, revision) pair is not
actively tracked, so the thread viewer's badge sync works for any
revision.

Assisted-by: LLM
Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review/tracking.py | 566 ++++++++++++++++++++++++++++++++++++++++------
 1 file changed, 493 insertions(+), 73 deletions(-)

diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index 0d10dff..c176c46 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -26,7 +26,7 @@ logger = b4.logger
 REVIEW_METADATA_DIR = 'b4-review'
 REVIEW_METADATA_FILE = 'metadata.json'
 
-SCHEMA_VERSION = 10
+SCHEMA_VERSION = 11
 
 SERIES_PATCHES_DDL = """
 CREATE TABLE IF NOT EXISTS series_patches (
@@ -38,12 +38,7 @@ CREATE TABLE IF NOT EXISTS series_patches (
     PRIMARY KEY (change_id, revision, position)
 )"""
 
-SCHEMA_SQL = (
-    """
-CREATE TABLE IF NOT EXISTS schema_version (
-    version INTEGER PRIMARY KEY
-);
-
+SERIES_DDL = """
 CREATE TABLE IF NOT EXISTS series (
     track_id INTEGER PRIMARY KEY,
     change_id TEXT NOT NULL,
@@ -68,7 +63,16 @@ CREATE TABLE IF NOT EXISTS series (
     target_branch TEXT,
     is_rethreaded INTEGER DEFAULT 0,
     UNIQUE (change_id, revision)
+)"""
+
+SCHEMA_SQL = (
+    """
+CREATE TABLE IF NOT EXISTS schema_version (
+    version INTEGER PRIMARY KEY
 );
+"""
+    + SERIES_DDL
+    + """;
 
 CREATE TABLE IF NOT EXISTS revisions (
     change_id   TEXT NOT NULL,
@@ -81,6 +85,10 @@ CREATE TABLE IF NOT EXISTS revisions (
     fingerprint TEXT,
     source      TEXT DEFAULT 'heuristic',
     is_rethreaded INTEGER DEFAULT 0,
+    message_count INT,
+    seen_message_count INT,
+    last_update_check TEXT,
+    last_activity_at TEXT,
     PRIMARY KEY (change_id, revision)
 );
 
@@ -187,6 +195,76 @@ def _migrate_db_if_needed(conn: sqlite3.Connection) -> None:
             conn.execute(
                 'ALTER TABLE revisions ADD COLUMN is_rethreaded INTEGER DEFAULT 0'
             )
+    if version < 11:
+        # Per-revision unread tracking: the series table only covers the
+        # tracked revision, so non-tracked versions get their message
+        # counts and new-mail detection from the revisions catalog.
+        # The stitched per-revision read joins series, so make sure the
+        # table exists (mirrors the v8 treatment of revisions).
+        conn.execute(SERIES_DDL)
+        existing = {row[1] for row in conn.execute('PRAGMA table_info(revisions)')}
+        for coldef in (
+            'message_count INT',
+            'seen_message_count INT',
+            'last_update_check TEXT',
+            'last_activity_at TEXT',
+        ):
+            if coldef.split()[0] not in existing:
+                conn.execute(f'ALTER TABLE revisions ADD COLUMN {coldef}')
+        # Backfill from the series table -- but only when it carries the
+        # full column set (a degenerate/absent series table has nothing
+        # worth backfilling from).
+        series_cols = {row[1] for row in conn.execute('PRAGMA table_info(series)')}
+        needed = {
+            'change_id',
+            'revision',
+            'message_id',
+            'subject',
+            'added_at',
+            'sent_at',
+            'fingerprint',
+            'is_rethreaded',
+            'message_count',
+            'seen_message_count',
+            'last_update_check',
+            'last_activity_at',
+        }
+        if needed <= series_cols:
+            # Every tracked series (archived rows included) needs a catalog
+            # row so its per-revision counts have somewhere to live.
+            conn.execute(
+                'INSERT OR IGNORE INTO revisions'
+                ' (change_id, revision, message_id, subject, found_at,'
+                '  fingerprint, source, is_rethreaded,'
+                '  message_count, seen_message_count, last_update_check,'
+                '  last_activity_at)'
+                ' SELECT change_id, revision, message_id, subject,'
+                "  COALESCE(added_at, sent_at), fingerprint, 'heuristic',"
+                '  COALESCE(is_rethreaded, 0),'
+                '  message_count, seen_message_count, last_update_check,'
+                '  last_activity_at'
+                " FROM series WHERE message_id IS NOT NULL AND message_id != ''"
+            )
+            # Seed counts on pre-existing catalog rows from any matching
+            # series row -- the only historical data available.
+            conn.execute(
+                'UPDATE revisions SET'
+                '  message_count = (SELECT s.message_count FROM series s'
+                '   WHERE s.change_id = revisions.change_id'
+                '   AND s.revision = revisions.revision),'
+                '  seen_message_count = (SELECT s.seen_message_count FROM series s'
+                '   WHERE s.change_id = revisions.change_id'
+                '   AND s.revision = revisions.revision),'
+                '  last_update_check = (SELECT s.last_update_check FROM series s'
+                '   WHERE s.change_id = revisions.change_id'
+                '   AND s.revision = revisions.revision),'
+                '  last_activity_at = (SELECT s.last_activity_at FROM series s'
+                '   WHERE s.change_id = revisions.change_id'
+                '   AND s.revision = revisions.revision)'
+                ' WHERE message_count IS NULL AND EXISTS (SELECT 1 FROM series s'
+                '  WHERE s.change_id = revisions.change_id'
+                '  AND s.revision = revisions.revision)'
+            )
     conn.execute('UPDATE schema_version SET version = ?', (SCHEMA_VERSION,))
     conn.commit()
 
@@ -1035,18 +1113,34 @@ _REVISION_COLS = (
     'fingerprint',
     'source',
     'is_rethreaded',
+    'message_count',
+    'seen_message_count',
+    'last_update_check',
+    'last_activity_at',
 )
 
+# Counts for the revision a series row actively tracks live on `series`;
+# the catalog columns cover every other revision.  COALESCE stitches the
+# two so readers get one consistent per-revision view.  Archived series
+# rows are upgrade leftovers whose stale counts must not shadow the
+# catalog's.
 _REVISION_SELECT = (
-    'SELECT change_id, revision, message_id, subject, link, found_at,'
-    ' thread_blob, fingerprint, source, is_rethreaded FROM revisions'
+    'SELECT r.change_id, r.revision, r.message_id, r.subject, r.link,'
+    ' r.found_at, r.thread_blob, r.fingerprint, r.source, r.is_rethreaded,'
+    ' COALESCE(s.message_count, r.message_count),'
+    ' COALESCE(s.seen_message_count, r.seen_message_count),'
+    ' COALESCE(s.last_update_check, r.last_update_check),'
+    ' COALESCE(s.last_activity_at, r.last_activity_at)'
+    ' FROM revisions r LEFT JOIN series s'
+    ' ON s.change_id = r.change_id AND s.revision = r.revision'
+    " AND s.status != 'archived'"
 )
 
 
 def get_revisions(conn: sqlite3.Connection, change_id: str) -> list[dict[str, Any]]:
     """Return all known revisions for a change_id, ordered ascending."""
     cursor = conn.execute(
-        _REVISION_SELECT + ' WHERE change_id = ? ORDER BY revision ASC',
+        _REVISION_SELECT + ' WHERE r.change_id = ? ORDER BY r.revision ASC',
         (change_id,),
     )
     return [dict(zip(_REVISION_COLS, row)) for row in cursor.fetchall()]
@@ -1064,7 +1158,7 @@ def find_revision_by_fingerprint(
     if not fingerprint:
         return None
     row = conn.execute(
-        _REVISION_SELECT + ' WHERE fingerprint = ? LIMIT 1',
+        _REVISION_SELECT + ' WHERE r.fingerprint = ? LIMIT 1',
         (fingerprint,),
     ).fetchone()
     if row is None:
@@ -1475,26 +1569,34 @@ def get_all_revisions_grouped(
     conn: sqlite3.Connection,
 ) -> dict[str, list[dict[str, Any]]]:
     """Return {change_id: [rev_dicts]} for all change_ids, ordered ascending."""
-    cols = (
-        'change_id',
-        'revision',
-        'message_id',
-        'subject',
-        'link',
-        'found_at',
-        'thread_blob',
-    )
-    cursor = conn.execute(
-        'SELECT change_id, revision, message_id, subject, link, found_at, thread_blob '
-        'FROM revisions ORDER BY change_id, revision ASC'
-    )
+    cursor = conn.execute(_REVISION_SELECT + ' ORDER BY r.change_id, r.revision ASC')
     result: dict[str, list[dict[str, Any]]] = {}
     for row in cursor.fetchall():
-        entry: dict[str, Any] = dict(zip(cols, row))
-        result.setdefault(row[0], []).append(entry)
+        entry: dict[str, Any] = dict(zip(_REVISION_COLS, row))
+        result.setdefault(entry['change_id'], []).append(entry)
     return result
 
 
+def summarize_revision_unseen(
+    revisions: List[Dict[str, Any]], tracked_revision: Optional[int] = None
+) -> int:
+    """Sum unseen messages across catalog revisions, excluding the tracked one.
+
+    Revisions whose thread was never fetched (NULL count) contribute
+    nothing.  The tracked revision is excluded because its unread state
+    is already surfaced through the series row badge.
+    """
+    total = 0
+    for rev in revisions:
+        if tracked_revision is not None and rev.get('revision') == tracked_revision:
+            continue
+        count = rev.get('message_count')
+        if count is None:
+            continue
+        total += max(0, int(count) - int(rev.get('seen_message_count') or 0))
+    return total
+
+
 def update_attestation(
     identifier: str, change_id: str, revision: int, attestation: Optional[str]
 ) -> None:
@@ -1925,6 +2027,45 @@ def _fetch_new_since(
         return None
 
 
+def _write_mbox_blob(topdir: str, msgs: List[Any]) -> Optional[str]:
+    """Serialize msgs to mboxrd and write as a git blob; return the SHA."""
+    import io
+
+    buf = io.BytesIO()
+    b4.save_mboxrd_mbox(msgs, buf)
+    mbox_bytes = buf.getvalue()
+    if not mbox_bytes:
+        return None
+
+    ecode, out = b4.git_run_command(
+        topdir, ['hash-object', '-w', '--stdin'], stdin=mbox_bytes
+    )
+    if ecode != 0:
+        return None
+    return str(out.strip())
+
+
+def _store_revision_thread_blob(
+    conn: sqlite3.Connection,
+    topdir: str,
+    change_id: str,
+    revision: int,
+    msgs: List[Any],
+) -> Optional[str]:
+    """Cache a catalog revision's thread mbox as a git blob.
+
+    Unlike _store_thread_blob this records the SHA in the revisions
+    catalog only -- the review branch tracking ref belongs to the
+    tracked revision.
+    """
+    blob_sha = _write_mbox_blob(topdir, msgs)
+    if blob_sha is None:
+        logger.debug('Could not store thread blob for %s v%d', change_id, revision)
+        return None
+    set_revision_thread_blob(conn, change_id, revision, blob_sha)
+    return blob_sha
+
+
 def _store_thread_blob(topdir: str, change_id: str, msgs: List[Any]) -> Optional[str]:
     """Serialize msgs to mboxrd and write as a git blob; update tracking commit.
 
@@ -1936,24 +2077,12 @@ def _store_thread_blob(topdir: str, change_id: str, msgs: List[Any]) -> Optional
     """
     # Local import first — avoids circular deps AND prevents UnboundLocalError
     # that would occur if `import b4.review` appeared after a `b4.xxx` call.
-    import io
-
     import b4.review as _b4_review
 
-    buf = io.BytesIO()
-    b4.save_mboxrd_mbox(msgs, buf)
-    mbox_bytes = buf.getvalue()
-    if not mbox_bytes:
-        logger.debug('No bytes to store for thread blob for %s', change_id)
-        return None
-
-    ecode, out = b4.git_run_command(
-        topdir, ['hash-object', '-w', '--stdin'], stdin=mbox_bytes
-    )
-    if ecode != 0:
+    blob_sha = _write_mbox_blob(topdir, msgs)
+    if blob_sha is None:
         logger.debug('Could not write thread blob for %s', change_id)
         return None
-    blob_sha = out.strip()
 
     branch_name = f'b4/review/{change_id}'
     if b4.git_branch_exists(topdir, branch_name):
@@ -2423,6 +2552,228 @@ def update_message_counts(
     return {'updated': updated, 'errors': errors}
 
 
+def _fetch_revision_thread_msgs(
+    identifier: str,
+    conn: sqlite3.Connection,
+    change_id: str,
+    rev: Dict[str, Any],
+) -> Optional[List[Any]]:
+    """Fetch the full thread for a catalog revision.
+
+    Rethreaded revisions reassemble from their per-patch message-ids;
+    plain revisions fetch the single thread mbox.  Returns None on
+    failure or when offline.
+    """
+    revision = int(rev['revision'])
+    if rev.get('is_rethreaded'):
+        patches = get_series_patches(conn, change_id, revision)
+        if sum(1 for p in patches if p.get('position', 0) > 0) >= 2:
+            import b4.review as _b4_review
+
+            series_dict = {
+                'message_id': rev.get('message_id', ''),
+                'change_id': change_id,
+                'revision': revision,
+                'is_rethreaded': True,
+            }
+            try:
+                return _b4_review.retrieve_series_messages(series_dict, identifier)
+            except Exception as ex:
+                logger.debug(
+                    'Could not reassemble rethreaded v%d of %s: %s',
+                    revision,
+                    change_id,
+                    ex,
+                )
+                return None
+    message_id = str(rev.get('message_id') or '')
+    if not message_id:
+        return None
+    mbox_bytes = _fetch_thread_mbox_bytes(message_id)
+    if mbox_bytes is None:
+        return None
+    return b4.split_and_dedupe_pi_results(mbox_bytes)
+
+
+def _update_one_revision_count(
+    identifier: str,
+    conn: sqlite3.Connection,
+    topdir: Optional[str],
+    change_id: str,
+    rev: Dict[str, Any],
+    now: str,
+) -> Optional[bool]:
+    """Update message counts for a single non-tracked catalog revision.
+
+    Returns True when the database changed, False when there was nothing
+    new (zero writes, keeping the DB mtime stable), None on fetch failure.
+    """
+    revision = int(rev['revision'])
+
+    if rev.get('message_count') is None or not rev.get('last_update_check'):
+        # ── First fetch: full thread download ────────────────────────────
+        msgs = _fetch_revision_thread_msgs(identifier, conn, change_id, rev)
+        if not msgs:
+            return None
+        count = len(msgs)
+        conn.execute(
+            'UPDATE revisions SET message_count = ?, seen_message_count = ?,'
+            ' last_update_check = ?, last_activity_at = ?'
+            ' WHERE change_id = ? AND revision = ?',
+            (count, count, now, _latest_date_from_msgs(msgs), change_id, revision),
+        )
+        conn.commit()
+        if topdir:
+            _store_revision_thread_blob(conn, topdir, change_id, revision, msgs)
+        return True
+
+    # ── Incremental: query for messages since last check ─────────────────
+    # A rethreaded revision is N separate threads, one per patch — sum the
+    # per-thread queries.  A reply CC'd into several patch threads can be
+    # counted more than once; the next full refresh corrects the total.
+    msgids = [str(rev.get('message_id') or '')]
+    if rev.get('is_rethreaded'):
+        patches = get_series_patches(conn, change_id, revision)
+        patch_ids = [p['message_id'] for p in patches if p.get('position', 0) > 0]
+        if patch_ids:
+            msgids = patch_ids
+
+    new_count = 0
+    new_activity: Optional[str] = None
+    for msgid in msgids:
+        if not msgid:
+            continue
+        result = _fetch_new_since(msgid, str(rev['last_update_check']))
+        if result is None:
+            return None
+        count, activity = result
+        new_count += count
+        if activity and (new_activity is None or activity > new_activity):
+            new_activity = activity
+
+    if new_count == 0:
+        return False
+
+    if topdir:
+        # New mail arrived — refresh the cached blob and take the exact
+        # count from the full refetch rather than trusting the increment.
+        msgs = _fetch_revision_thread_msgs(identifier, conn, change_id, rev)
+        if msgs:
+            conn.execute(
+                'UPDATE revisions SET message_count = ?, last_update_check = ?,'
+                ' last_activity_at = COALESCE(?, last_activity_at)'
+                ' WHERE change_id = ? AND revision = ?',
+                (
+                    len(msgs),
+                    now,
+                    _latest_date_from_msgs(msgs),
+                    change_id,
+                    revision,
+                ),
+            )
+            conn.commit()
+            _store_revision_thread_blob(conn, topdir, change_id, revision, msgs)
+            return True
+
+    conn.execute(
+        'UPDATE revisions SET message_count = message_count + ?,'
+        ' last_update_check = ?,'
+        ' last_activity_at = COALESCE(?, last_activity_at)'
+        ' WHERE change_id = ? AND revision = ?',
+        (new_count, now, new_activity, change_id, revision),
+    )
+    conn.commit()
+    return True
+
+
+def update_revision_message_counts(
+    identifier: str,
+    series_list: List[Dict[str, Any]],
+    topdir: Optional[str] = None,
+    max_revisions_per_series: Optional[int] = None,
+) -> Dict[str, int]:
+    """Fetch and store thread message counts for non-tracked revisions.
+
+    The series machinery owns the tracked revision's counts; this covers
+    every *other* revision in the catalog so new mail landing on an old
+    version's thread is still noticed:
+
+    - **First fetch** (``message_count IS NULL``): downloads the full
+      thread and stores the count, with ``seen_message_count`` initialised
+      to the same value so no badge appears until *new* activity arrives.
+      When *topdir* is given the mbox is cached as a git blob.
+    - **Incremental**: queries for messages newer than
+      ``last_update_check``.  An empty response produces **zero database
+      writes**, keeping the DB mtime stable and suppressing spurious list
+      reloads in the TUI.  New mail bumps the count and refreshes the
+      cached blob.
+
+    Revisions are polled newest-first, optionally capped by
+    *max_revisions_per_series*.
+
+    Returns ``{'updated': n, 'errors': n}`` where *updated* counts
+    revisions whose counts actually changed.
+    """
+    updated = 0
+    errors = 0
+    skip_statuses = frozenset(('archived', 'accepted', 'thanked', 'snoozed'))
+    now = datetime.datetime.now(datetime.timezone.utc).isoformat()
+
+    try:
+        conn = get_db(identifier)
+    except FileNotFoundError:
+        return {'updated': 0, 'errors': 0}
+
+    for series in series_list:
+        if series.get('status') in skip_statuses:
+            continue
+        change_id = series.get('change_id', '')
+        if not change_id:
+            continue
+        tracked_rev = int(series.get('revision') or 1)
+
+        # A catalog row for the tracked revision is not guaranteed (manual
+        # linking can record only newer versions); backfill it so the
+        # revision keeps its counts once it stops being tracked.  Guarded
+        # by an existence check to keep quiet runs write-free.
+        if series.get('message_id'):
+            row = conn.execute(
+                'SELECT 1 FROM revisions WHERE change_id = ? AND revision = ?',
+                (change_id, tracked_rev),
+            ).fetchone()
+            if row is None:
+                add_revision(
+                    conn,
+                    change_id,
+                    tracked_rev,
+                    str(series['message_id']),
+                    subject=series.get('subject'),
+                    fingerprint=series.get('fingerprint'),
+                    is_rethreaded=bool(series.get('is_rethreaded')),
+                )
+
+        polled = 0
+        for rev in reversed(get_revisions(conn, change_id)):
+            if int(rev['revision']) == tracked_rev:
+                continue
+            if (
+                max_revisions_per_series is not None
+                and polled >= max_revisions_per_series
+            ):
+                break
+            polled += 1
+            result = _update_one_revision_count(
+                identifier, conn, topdir, change_id, rev, now
+            )
+            if result is None:
+                errors += 1
+            elif result:
+                updated += 1
+
+    conn.close()
+    return {'updated': updated, 'errors': errors}
+
+
 def mark_all_messages_seen(
     conn: sqlite3.Connection, change_id: str, revision: int
 ) -> None:
@@ -2435,49 +2786,92 @@ def mark_all_messages_seen(
     conn.commit()
 
 
-def sync_seen_from_unseen_count(
-    identifier: str, change_id: str, revision: int, unseen_count: int
-) -> bool:
-    """Sync seen_message_count so the unread badge matches the messages DB.
+def mark_all_revision_messages_seen(
+    conn: sqlite3.Connection, change_id: str, revision: int
+) -> None:
+    """Clear the unread delta on a catalog revision (revisions table)."""
+    conn.execute(
+        'UPDATE revisions SET seen_message_count = message_count'
+        ' WHERE change_id = ? AND revision = ? AND message_count IS NOT NULL',
+        (change_id, revision),
+    )
+    conn.commit()
 
-    Sets ``seen_message_count = message_count - unseen_count``, clamped
-    to [0, message_count].  Only writes when the value actually changes.
 
-    Returns True if the database was updated, False otherwise.
+def _row_guard(table: str) -> str:
+    """WHERE-clause suffix scoping series lookups to non-archived rows.
+
+    Archived series rows are upgrade leftovers; per-revision reads and
+    writes must resolve to the revisions catalog instead of them.
     """
-    try:
-        conn = get_db(identifier)
-    except FileNotFoundError:
-        return False
+    return " AND status != 'archived'" if table == 'series' else ''
+
 
+def _apply_seen_sync(
+    conn: sqlite3.Connection,
+    table: str,
+    change_id: str,
+    revision: int,
+    unseen_count: int,
+) -> Optional[bool]:
+    """Apply a seen-count sync against one table.
+
+    Returns None when no row matches (caller should try the next table),
+    False when nothing changed, True when the database was updated.
+    """
+    guard = _row_guard(table)
     row = conn.execute(
-        'SELECT message_count, seen_message_count FROM series'
-        ' WHERE change_id = ? AND revision = ?',
+        f'SELECT message_count, seen_message_count FROM {table}'
+        f' WHERE change_id = ? AND revision = ?{guard}',
         (change_id, revision),
     ).fetchone()
     if row is None:
-        conn.close()
-        return False
+        return None
 
     fc = row['message_count']
     if fc is None:
-        conn.close()
         return False
 
     new_seen = max(0, min(fc, fc - unseen_count))
     if new_seen == row['seen_message_count']:
-        conn.close()
         return False
 
     conn.execute(
-        'UPDATE series SET seen_message_count = ? WHERE change_id = ? AND revision = ?',
+        f'UPDATE {table} SET seen_message_count = ?'
+        f' WHERE change_id = ? AND revision = ?{guard}',
         (new_seen, change_id, revision),
     )
     conn.commit()
-    conn.close()
     return True
 
 
+def sync_seen_from_unseen_count(
+    identifier: str, change_id: str, revision: int, unseen_count: int
+) -> bool:
+    """Sync seen_message_count so the unread badge matches the messages DB.
+
+    Sets ``seen_message_count = message_count - unseen_count``, clamped
+    to [0, message_count].  Only writes when the value actually changes.
+
+    The series row (the tracked revision) is preferred; when the
+    (change_id, revision) pair is not actively tracked the sync applies
+    to the revisions catalog row instead, so viewing an old version's
+    thread keeps its badge accurate.
+
+    Returns True if the database was updated, False otherwise.
+    """
+    try:
+        conn = get_db(identifier)
+    except FileNotFoundError:
+        return False
+
+    result = _apply_seen_sync(conn, 'series', change_id, revision, unseen_count)
+    if result is None:
+        result = _apply_seen_sync(conn, 'revisions', change_id, revision, unseen_count)
+    conn.close()
+    return bool(result)
+
+
 def refresh_message_count(
     identifier: str, change_id: str, revision: int, total_messages: int
 ) -> bool:
@@ -2496,6 +2890,10 @@ def refresh_message_count(
     Only writes to the database when the count differs from the stored
     value, keeping the DB mtime stable when nothing changed.
 
+    The series row (the tracked revision) is preferred; when the
+    (change_id, revision) pair is not actively tracked the count applies
+    to the revisions catalog row instead.
+
     Returns True if the database was updated, False otherwise.
     """
     now = datetime.datetime.now(datetime.timezone.utc).isoformat()
@@ -2504,29 +2902,52 @@ def refresh_message_count(
     except FileNotFoundError:
         return False
 
+    result = _apply_message_count(
+        conn, 'series', change_id, revision, total_messages, now
+    )
+    if result is None:
+        result = _apply_message_count(
+            conn, 'revisions', change_id, revision, total_messages, now
+        )
+    conn.close()
+    return bool(result)
+
+
+def _apply_message_count(
+    conn: sqlite3.Connection,
+    table: str,
+    change_id: str,
+    revision: int,
+    total_messages: int,
+    now: str,
+) -> Optional[bool]:
+    """Apply a fresh total message count against one table.
+
+    Returns None when no row matches (caller should try the next table),
+    False when nothing changed, True when the database was updated.
+    """
+    guard = _row_guard(table)
     row = conn.execute(
-        'SELECT message_count, seen_message_count FROM series'
-        ' WHERE change_id = ? AND revision = ?',
+        f'SELECT message_count, seen_message_count FROM {table}'
+        f' WHERE change_id = ? AND revision = ?{guard}',
         (change_id, revision),
     ).fetchone()
     if row is None:
-        conn.close()
-        return False
+        return None
 
     count = total_messages
     old_count = row['message_count']
 
     if old_count is not None and count == old_count:
         # Nothing changed — skip the write to keep the DB mtime stable.
-        conn.close()
         return False
 
     if old_count is None:
         # First fetch: initialise both counts equally (no badge).
         conn.execute(
-            'UPDATE series SET message_count = ?, seen_message_count = ?,'
-            '  last_update_check = ?'
-            ' WHERE change_id = ? AND revision = ?',
+            f'UPDATE {table} SET message_count = ?, seen_message_count = ?,'
+            f'  last_update_check = ?'
+            f' WHERE change_id = ? AND revision = ?{guard}',
             (count, count, now, change_id, revision),
         )
     else:
@@ -2535,20 +2956,19 @@ def refresh_message_count(
         seen = row['seen_message_count']
         if seen is not None and seen > count:
             conn.execute(
-                'UPDATE series SET message_count = ?, seen_message_count = ?,'
-                '  last_update_check = ?'
-                ' WHERE change_id = ? AND revision = ?',
+                f'UPDATE {table} SET message_count = ?, seen_message_count = ?,'
+                f'  last_update_check = ?'
+                f' WHERE change_id = ? AND revision = ?{guard}',
                 (count, count, now, change_id, revision),
             )
         else:
             conn.execute(
-                'UPDATE series SET message_count = ?, last_update_check = ?'
-                ' WHERE change_id = ? AND revision = ?',
+                f'UPDATE {table} SET message_count = ?, last_update_check = ?'
+                f' WHERE change_id = ? AND revision = ?{guard}',
                 (count, now, change_id, revision),
             )
 
     conn.commit()
-    conn.close()
     return True
 
 

-- 
2.53.0


  parent reply	other threads:[~2026-07-17 22:38 UTC|newest]

Thread overview: 14+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-07-17 22:37 [PATCH RFC 00/11] review: track and browse every version of a tracked series Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 01/11] review-tui: fix rethreaded series thread viewing Christian Brauner
2026-07-17 22:37 ` Christian Brauner [this message]
2026-07-17 22:37 ` [PATCH RFC 03/11] review: test per-revision message tracking Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 04/11] review-tui: poll every revision on u/U updates Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 05/11] review-tui: guarantee the tracked revision in revision lists Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 06/11] review: add backward discovery of older series revisions Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 07/11] review-tui: add a "Find older revisions" action Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 08/11] review: test backward revision discovery Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 09/11] review-tui: extract the Msgs column renderer from TrackedSeriesItem Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 10/11] review-tui: expand tracked series into per-version rows Christian Brauner
2026-07-17 22:37 ` [PATCH RFC 11/11] review-tui: test per-version tracker rows Christian Brauner
2026-07-27 20:43 ` [PATCH RFC 00/11] review: track and browse every version of a tracked series Konstantin Ryabitsev
2026-07-27 21:27   ` Christian Brauner

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260718-work-b4-multiver-rows-v1-2-3c539d2a3095@kernel.org \
    --to=brauner@kernel.org \
    --cc=konstantin@linuxfoundation.org \
    --cc=tools@kernel.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox