All of lore.kernel.org
 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 v2 19/25] review: add backward discovery of older series revisions
Date: Wed, 12 Aug 2026 23:47:00 +0200	[thread overview]
Message-ID: <20260812-work-b4-multiver-rows-v2-19-305d53cd723a@kernel.org> (raw)
In-Reply-To: <20260812-work-b4-multiver-rows-v2-0-305d53cd723a@kernel.org>

Auto-discovery only looks forward, so versions posted before a series was
tracked never enter the revisions catalog unless they shared the seed
thread.

Add discover_older_revisions().  It fetches the tracked thread and runs
the same get_extra_series() machinery b4 am/mbox uses to pull other
revisions, with an explicit wantvers covering every previous version,
since the backward search otherwise fetches only latest-1.  Newly
recorded revisions are polled immediately so they arrive with counts and
cached thread blobs.

The update sweep runs the same backward search once per series, latched
in a dedicated back_searched column.  Neither the catalog nor revision
provenance can record that a search ran and found nothing: the v11
backfill gives every series row a catalog entry, and offline tracking and
manual linking both stamp a source with no search behind them.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 src/b4/review/_review.py  |  45 +++++---
 src/b4/review/tracking.py | 263 +++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 293 insertions(+), 15 deletions(-)

diff --git a/src/b4/review/_review.py b/src/b4/review/_review.py
index 68b5a7a0..2ed7c340 100644
--- a/src/b4/review/_review.py
+++ b/src/b4/review/_review.py
@@ -2497,22 +2497,38 @@ def update_series_tracking(
     if b4.can_network:
         try:
             _conn = b4.review.tracking.get_db(identifier)
-            _known = set(
-                r['revision']
-                for r in b4.review.tracking.get_revisions(_conn, change_id)
-            )
-            _conn.close()
+            try:
+                _rows = b4.review.tracking.get_revisions(_conn, change_id)
+                _searched = b4.review.tracking.is_back_searched(_conn, change_id)
+            finally:
+                _conn.close()
         except Exception:
-            _known = set()
+            _rows = []
+            _searched = False
+        _known = {r['revision'] for r in _rows}
 
         msgs = b4.mbox.get_extra_series(msgs, direction=1, nocache=True)
-        # Discount the tracked revision's own entry.  The v11 backfill gives
-        # every series row one, so a plain "is the catalog empty?" test is
-        # never true again and this one-shot search stopped running at all.
-        if current_rev > 1 and not (_known - {current_rev}):
+        # One-shot, latched on changes.back_searched.  The catalog alone
+        # cannot carry the latch: the v11 backfill gives every series row
+        # an entry, so "is the catalog empty?" is never true again, and
+        # discounting just the tracked revision re-ran this uncached
+        # subject+sender search on every sweep, forever, for any series
+        # whose older versions cannot be found.  Revision provenance
+        # cannot carry it either: offline tracking and manual linking
+        # both stamp a source with no search behind it, and a manual
+        # stamp can never be overwritten to record a later real search.
+        if current_rev > 1 and not _searched and not (_known - {current_rev}):
             msgs = b4.mbox.get_extra_series(
                 msgs, direction=-1, wantvers=list(range(1, current_rev)), nocache=True
             )
+            try:
+                _conn = b4.review.tracking.get_db(identifier)
+                try:
+                    b4.review.tracking.set_back_searched(_conn, change_id)
+                finally:
+                    _conn.close()
+            except Exception as ex:
+                logger.debug('Could not latch the backward search: %s', ex)
 
     lmbx = b4.LoreMailbox()
     for msg in msgs:
@@ -2562,7 +2578,13 @@ def update_series_tracking(
     try:
         conn = b4.review.tracking.get_db(identifier)
         new_revs = b4.review.tracking._record_discovered_revisions(
-            conn, change_id, lmbx, str(linkmask)
+            conn,
+            change_id,
+            lmbx,
+            str(linkmask),
+            # Offline the lmbx is just the cached thread with no search
+            # behind it; 'heuristic' keeps that provenance honest.
+            source='discovered' if b4.can_network else 'heuristic',
         )
         result['new_revisions'] = len(new_revs)
         try:
@@ -2684,7 +2706,6 @@ def update_series_tracking(
         if not save_tracking_ref(topdir, branch, cover_text, tracking):
             result['error'] = 'Error saving tracking data'
             return result
-
     elif topdir and has_review_branch:
         # The block above mirrors the catalog as part of its save, but it
         # only runs for four statuses.  Every other one still holds
diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index 6662cd9e..843808c4 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -922,8 +922,19 @@ def cmd_track(cmdargs: argparse.Namespace) -> None:
         # Already tracked: fold any newly discovered revisions into the
         # existing series instead of creating a duplicate (bug 70fe607).
         new_revs = _record_discovered_revisions(
-            conn, existing_change_id, lmbx, linkmask, rethreaded_revs=rethreaded_revs
+            conn,
+            existing_change_id,
+            lmbx,
+            linkmask,
+            rethreaded_revs=rethreaded_revs,
+            # Offline the lmbx is just the fetched thread with no search
+            # behind it; 'heuristic' keeps that provenance honest.
+            source='discovered' if b4.can_network else 'heuristic',
         )
+        if b4.can_network and wanted_ver > 1:
+            # The backward search above covered the older versions; the
+            # update sweep's one-shot pass need not repeat it.
+            set_back_searched(conn, existing_change_id)
         srow = conn.execute(
             'SELECT status, revision FROM series WHERE change_id = ?',
             (existing_change_id,),
@@ -990,8 +1001,19 @@ def cmd_track(cmdargs: argparse.Namespace) -> None:
     )
     add_series_patches(conn, change_id, revision, lser)
     _record_discovered_revisions(
-        conn, change_id, lmbx, linkmask, rethreaded_revs=rethreaded_revs
+        conn,
+        change_id,
+        lmbx,
+        linkmask,
+        rethreaded_revs=rethreaded_revs,
+        # Offline the lmbx is just the fetched thread with no search
+        # behind it; 'heuristic' keeps that provenance honest.
+        source='discovered' if b4.can_network else 'heuristic',
     )
+    if b4.can_network and wanted_ver > 1:
+        # The backward search above covered the older versions; the update
+        # sweep's one-shot pass need not repeat it.
+        set_back_searched(conn, change_id)
     conn.close()
     # Mirror the catalog onto the review branch when one already exists (a
     # fresh track usually has none yet — this is then a harmless no-op).
@@ -1205,7 +1227,10 @@ def get_all_tracked_series(identifier: str) -> list[dict[str, Any]]:
 
 # Provenance ranking for a revision's `source`.  A higher rank wins: a manual
 # link must override, and never be downgraded by, automated discovery.
-_SOURCE_RANK = {'heuristic': 0, 'auto-consume': 0, 'manual': 1}
+# 'discovered' sits in between: unlike 'heuristic' (a row seeded from a series
+# row by backfill) it proves a discovery pass actually processed the series,
+# which is what the update sweep's one-shot backward search latches on.
+_SOURCE_RANK = {'heuristic': 0, 'auto-consume': 0, 'discovered': 1, 'manual': 2}
 
 
 def _source_rank(source: Optional[str]) -> int:
@@ -1588,6 +1613,25 @@ def _store_catalog_synced(
     _set_change_state(conn, change_id, catalog_synced=f'{branch_sha}:{catalog_sha}')
 
 
+def set_back_searched(conn: sqlite3.Connection, change_id: str) -> None:
+    """Latch "the backward revision search ran" for a change_id.
+
+    A dedicated stamp rather than an inference from revision provenance:
+    offline tracking and manual linking both write sources with no search
+    behind them, and a search that finds nothing writes no revision rows
+    at all.  Set once the search has run, found something or not.
+    """
+    _set_change_state(conn, change_id, back_searched=1)
+
+
+def is_back_searched(conn: sqlite3.Connection, change_id: str) -> bool:
+    """Whether the one-shot backward revision search already ran."""
+    row = conn.execute(
+        'SELECT back_searched FROM changes WHERE change_id = ?', (change_id,)
+    ).fetchone()
+    return bool(row and row[0])
+
+
 def sync_revisions_catalog_to_branch(
     topdir: Optional[str], identifier: str, change_id: str
 ) -> bool:
@@ -1994,12 +2038,33 @@ def _raw_revision_ref(
     return '', '', False
 
 
+def _revision_posted_at(lmbx: 'b4.LoreMailbox', revision: int) -> Optional[str]:
+    """When a discovered revision was posted, from its own Date: header.
+
+    The backward search records versions posted long before the series was
+    tracked, so dating them "now" would sort the oldest version after the
+    newest and report a year-old posting as found today.
+    """
+    msg = lmbx.covers.get(revision)
+    if msg is None:
+        for p in getattr(lmbx.series.get(revision), 'patches', None) or []:
+            if p is not None:
+                msg = p
+                break
+    date = getattr(msg, 'date', None)
+    if not isinstance(date, datetime.datetime):
+        return None
+    return date.astimezone(datetime.timezone.utc).isoformat()
+
+
 def _record_discovered_revisions(
     conn: sqlite3.Connection,
     change_id: str,
     lmbx: 'b4.LoreMailbox',
     linkmask: str,
     rethreaded_revs: Optional[Set[int]] = None,
+    source: str = 'discovered',
+    skip_revs: Optional[Set[int]] = None,
 ) -> List[int]:
     """Record every revision discovered in *lmbx* under *change_id*.
 
@@ -2014,12 +2079,30 @@ def _record_discovered_revisions(
     their member patch message-ids are recorded — fetching the recorded
     message-id alone would not reconstitute the series, so retrieval must read
     the stored patches instead.
+
+    *source* defaults to ``'discovered'``, the rank for versions a live
+    lore search turned up.  A caller recording from a cached thread with
+    no search behind it passes ``'heuristic'``, so a later real discovery
+    or manual link still outranks those rows.  Whether the one-shot
+    backward search has run is deliberately not inferred from here -- see
+    :func:`set_back_searched`.
+
+    Revisions in *skip_revs* are left out, for a caller that has decided
+    some of what it found is not its to record -- see
+    :func:`discover_older_revisions` and the strays it reports rather than
+    absorbs.  A parameter rather than the caller deleting entries from
+    *lmbx*: that mailbox is the caller's fetch, not a scratch buffer, and
+    a reader of it after this call should still see what came back.
     """
     if rethreaded_revs is None:
         rethreaded_revs = set()
+    if skip_revs is None:
+        skip_revs = set()
     known = {r['revision'] for r in get_revisions(conn, change_id)}
     new_revs: List[int] = []
     for v in sorted(lmbx.series.keys()):
+        if v in skip_revs:
+            continue
         v_ser = lmbx.series[v]
         is_rt = v in rethreaded_revs
         v_msgid, v_subject, from_cover = _raw_revision_ref(lmbx, v, rethreaded=is_rt)
@@ -2034,8 +2117,10 @@ def _record_discovered_revisions(
             v_subject,
             v_link,
             fingerprint=getattr(v_ser, 'fingerprint', None),
+            source=source,
             is_rethreaded=is_rt,
             subject_from_cover=from_cover,
+            found_at=_revision_posted_at(lmbx, v),
         )
         if is_rt:
             add_series_patches(conn, change_id, v, v_ser)
@@ -4241,6 +4326,178 @@ def _is_archived_only(conn: sqlite3.Connection, change_id: str) -> bool:
     return not (row[1] or 0)
 
 
+def discover_older_revisions(
+    identifier: str,
+    series: Dict[str, Any],
+    linkmask: str,
+    topdir: Optional[str] = None,
+    cancel_cb: Optional[Callable[[], bool]] = None,
+) -> Dict[str, Any]:
+    """Search lore for revisions older than the tracked one and record them.
+
+    Auto-discovery only looks forward, so versions posted before a series
+    was tracked never enter the catalog unless they happened to share the
+    seed thread.  This fetches the tracked revision's thread, runs the
+    backward lore search (change-id query when the cover carries one,
+    subject+sender otherwise, capped roughly a year back), records every
+    previously unknown revision, and immediately polls the series so the
+    new revisions get counts and cached thread blobs.
+
+    A rethreaded series is reassembled from its member patches before the
+    search runs.  Its recorded message-id is one patch's, so seeding from
+    that patch's thread alone hands :func:`b4.mbox.get_extra_series` a
+    mailbox with no cover in it -- and a patch numbered above 1 is skipped
+    outright there, leaving no base message and no query issued at all.
+
+    The search returns whole revisions, not per-patch threads, so an older
+    version that was posted with broken threading is recorded as a plain
+    revision: viewing it shows the thread of the message-id that was
+    matched rather than the reassembled series.  Rethreading a version
+    still has to go through a manual link.
+
+    A version that is already tracked as its own stray series is reported
+    rather than recorded -- see the ``conflicts`` key below.
+
+    Returns ``{'found': n, 'revisions': [..], 'conflicts': [..],
+    'error': str-or-None}`` where *found* counts genuinely new catalog
+    entries, *revisions* lists their version numbers, and *conflicts*
+    lists versions skipped because another series already tracks them.
+    """
+
+    def nothing(error: Optional[str] = None) -> Dict[str, Any]:
+        return {'found': 0, 'revisions': [], 'conflicts': [], 'error': error}
+
+    change_id = series.get('change_id', '')
+    message_id = series.get('message_id', '')
+    if not change_id or not message_id:
+        return nothing('no message-id for this series')
+    tracked_rev = int(series.get('revision') or 1)
+    if tracked_rev <= 1:
+        return nothing()
+    if not b4.can_network:
+        return nothing('offline')
+
+    # Same seed the per-revision poller uses, so a rethreaded series arrives
+    # reassembled from its member patches rather than as one patch's thread.
+    # Falls back to the plain fetch whenever the patch list cannot supply a
+    # series, which is what a non-rethreaded revision always does.
+    seed_conn = get_db(identifier)
+    try:
+        msgs = _fetch_revision_thread_msgs(
+            identifier,
+            seed_conn,
+            change_id,
+            {
+                'revision': tracked_rev,
+                'message_id': message_id,
+                'is_rethreaded': bool(series.get('is_rethreaded')),
+            },
+        )
+    finally:
+        seed_conn.close()
+    if not msgs:
+        return nothing(f'could not fetch thread for {message_id}')
+    # Same machinery b4 am/mbox uses to pull other revisions of a series,
+    # except we ask for every previous version at once — without an
+    # explicit wantvers the backward search only fetches latest-1.
+    wantvers = list(range(1, tracked_rev))
+    try:
+        msgs = b4.mbox.get_extra_series(
+            msgs, direction=-1, wantvers=wantvers, nocache=True
+        )
+    except liblore.OperationCancelledError:
+        raise
+    except Exception as ex:
+        return nothing(str(ex))
+
+    lmbx = b4.LoreMailbox()
+    for msg in msgs:
+        lmbx.add_message(msg)
+    if not lmbx.series:
+        return nothing()
+
+    conn = get_db(identifier)
+    try:
+        # The backward search just ran; the update sweep's one-shot pass
+        # need not repeat it.
+        set_back_searched(conn, change_id)
+        # Absorbing a stray removes that revision from the series that owns
+        # it, which a search action should not do unprompted: skip and
+        # report, [l] absorbs.
+        #
+        # Only for versions this change_id does not already have.  A series
+        # whose catalog already covers what the search turned up has nothing
+        # to link: [l] would absorb a posting it already holds, and since
+        # add_revision() is first-wins on message_id it would not even change
+        # the row -- it would only delete the series that owned the other
+        # copy.  A change_id synthesized per posting (the author sends no
+        # change-id trailer, so date+slug+fingerprint differ every version)
+        # puts every one of its versions in that state at once, which is how
+        # this came to report four conflicts on a series that had already
+        # catalogued all of them.
+        own = {r['revision'] for r in get_revisions(conn, change_id)}
+        conflicts = set()
+        for v in sorted(lmbx.series.keys()):
+            if v in own:
+                continue
+            if v == tracked_rev:
+                # The seed thread is the tracked revision's, so lmbx always
+                # holds it, and a stray duplicate of it matches here like any
+                # other.  Reporting it would send the maintainer to [l] to
+                # link the version they are already on.  Same reason
+                # new_revs below drops it.
+                #
+                # Only that one revision, not everything from it up: a newer
+                # version can ride along in the seed thread (an author
+                # posting vN+1 as a reply), and skipping the check for it
+                # would record a posting another series already owns without
+                # anyone being told.
+                continue
+            v_msgid, _, _ = _raw_revision_ref(lmbx, v)
+            fingerprint = getattr(lmbx.series[v], 'fingerprint', None)
+            if find_stray_revision(conn, change_id, v_msgid, fingerprint) is not None:
+                conflicts.add(v)
+        recorded = _record_discovered_revisions(
+            conn, change_id, lmbx, linkmask, skip_revs=conflicts
+        )
+    finally:
+        conn.close()
+
+    # The seed thread is the tracked revision's, so lmbx always holds it
+    # too, and it counts as new whenever the catalog had no row for it.
+    # Recording that row is worth having; reporting the version the
+    # maintainer is already on as an older one found is not.
+    new_revs = [v for v in recorded if v < tracked_rev]
+
+    if recorded:
+        # Mirror the new entries into the review branch's tracking commit,
+        # as every other _record_discovered_revisions caller does, so they
+        # travel with the branch on push.
+        if topdir:
+            sync_revisions_catalog_to_branch(topdir, identifier, change_id)
+    # Named outright so other revisions awaiting a first fetch cannot spend
+    # the budget on these -- but still inside it, or one [o] press on a v20
+    # series answers with 19 round-trips.
+    if new_revs:
+        try:
+            update_revision_message_counts(
+                identifier,
+                [series],
+                topdir=topdir,
+                only_revisions=set(new_revs),
+                max_revisions_per_series=REVISION_POLL_LIMIT,
+                cancel_cb=cancel_cb,
+            )
+        except liblore.OperationCancelledError:
+            pass
+    return {
+        'conflicts': sorted(conflicts),
+        'found': len(new_revs),
+        'revisions': sorted(new_revs),
+        'error': None,
+    }
+
+
 def mark_all_messages_seen(
     conn: sqlite3.Connection, change_id: str, revision: int
 ) -> None:

-- 
2.53.0


  parent reply	other threads:[~2026-08-12 21:47 UTC|newest]

Thread overview: 26+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-08-12 21:46 [PATCH RFC v2 00/25] review: track and browse every version of a tracked series Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 01/25] review-tui: fix rethreaded series thread viewing Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 02/25] review: do not clear fields a re-adding caller does not know Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 03/25] review-tui: keep the rethread flag on an upgraded series row Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 04/25] review: guard the tracking-commit amend on the worktree, not the checkout Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 05/25] review-tui: recompute an evicted A·R·T cache entry Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 06/25] review: test the prerequisite fixes Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 07/25] review: serialize schema migrations against a concurrent opener Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 08/25] review: test the migration serialization Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 09/25] review: track message counts for all revisions of a series Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 10/25] review: give per-change_id state its own table Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 11/25] review: test per-revision message tracking Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 12/25] review-tui: poll every revision on u/U updates Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 13/25] review: test the per-revision poll sweep Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 14/25] review-tui: resolve the tracked revision in revision lists Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 15/25] review-tui: fall back when a cached thread blob has no series Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 16/25] review-tui: test revision resolution and the range-diff fallback Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 17/25] review: skip the catalog mirror when nothing moved Christian Brauner
2026-08-12 21:46 ` [PATCH RFC v2 18/25] review: match a stray posting by message-id Christian Brauner
2026-08-12 21:47 ` Christian Brauner [this message]
2026-08-12 21:47 ` [PATCH RFC v2 20/25] review-tui: add a "Find older revisions" action Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 21/25] review: test the catalog mirror, stray matching and backward discovery Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 22/25] review-tui: extract the Msgs column renderer from TrackedSeriesItem Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 23/25] review-tui: give the unseen badge a column of its own Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 24/25] review-tui: expand tracked series into per-version rows Christian Brauner
2026-08-12 21:47 ` [PATCH RFC v2 25/25] review-tui: test per-version tracker rows 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=20260812-work-b4-multiver-rows-v2-19-305d53cd723a@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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.