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 10/25] review: give per-change_id state its own table
Date: Wed, 12 Aug 2026 23:46:51 +0200 [thread overview]
Message-ID: <20260812-work-b4-multiver-rows-v2-10-305d53cd723a@kernel.org> (raw)
In-Reply-To: <20260812-work-b4-multiver-rows-v2-0-305d53cd723a@kernel.org>
series.branch_sha records the HEAD of b4/review/<change_id> so a rescan
can skip a branch that has not moved. There is one such branch per
change_id, but the column lives on a table keyed (change_id, revision),
so the fact is stored once per version of the series.
That forces an arbitration nothing should have to make. rescan_branches()
reads it back with ORDER BY revision DESC LIMIT 1, believing the highest
revision's copy, while writing only the row whose revision the branch's
tracking commit happens to name. A change_id with more than one live
row, which rescan_branches() itself can produce, then has a stale copy
sitting where the reader looks.
Give it a table of its own, keyed by change_id alone. The reader stops
choosing, the writer stops picking a row, and delete_series() drops the
entry with the rest of the change. The migration seeds each change_id
from the same row the old reader believed, so nothing is reinterpreted on
the way across. A wrong sha costs one extra tracking-commit read on the
next rescan, since this is a cache key rather than data.
Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
src/b4/review/tracking.py | 141 +++++++++++++++++++++++++++++++-------
src/tests/test_review_tracking.py | 3 +-
2 files changed, 120 insertions(+), 24 deletions(-)
diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index 7d42b160..68701238 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 = 11
+SCHEMA_VERSION = 12
SERIES_PATCHES_DDL = """
CREATE TABLE IF NOT EXISTS series_patches (
@@ -53,7 +53,6 @@ CREATE TABLE IF NOT EXISTS series (
pw_series_id INTEGER,
status TEXT DEFAULT 'new',
fingerprint TEXT,
- branch_sha TEXT,
-- Per-revision read state (message_count, seen_message_count,
-- last_update_check) lives on `revisions` and only there: a series row
-- names which revision it tracks, and that revision's catalog row
@@ -73,6 +72,28 @@ CREATE TABLE IF NOT EXISTS series (
UNIQUE (change_id, revision)
)"""
+# Per-change_id state: one row per change_id, never one per revision.
+#
+# These facts belong to the change, not to a version of it -- the review
+# branch is `b4/review/<change_id>` and there is exactly one, the backward
+# search runs once per change_id -- so a copy on every `series` row left
+# each writer updating them all and each reader arbitrating between them
+# (MAX(back_searched), ORDER BY revision DESC LIMIT 1 for the other two).
+# That is the same "which copy wins" question the per-revision read state
+# moved onto `revisions` to stop asking.
+CHANGES_DDL = """
+CREATE TABLE IF NOT EXISTS changes (
+ change_id TEXT PRIMARY KEY,
+ -- HEAD of b4/review/<change_id> at the last branch rescan.
+ branch_sha TEXT,
+ -- "<branch-sha>:<catalog-sha1>" at the last successful known-revisions
+ -- mirror; see sync_revisions_catalog_to_branch.
+ catalog_synced TEXT,
+ -- 1 once the one-shot backward revision search has run for this
+ -- change_id, found something or not; see set_back_searched().
+ back_searched INTEGER DEFAULT 0
+)"""
+
SCHEMA_SQL = (
"""
CREATE TABLE IF NOT EXISTS schema_version (
@@ -117,6 +138,8 @@ CREATE INDEX IF NOT EXISTS idx_revisions_fingerprint ON revisions(fingerprint);
CREATE INDEX IF NOT EXISTS idx_revisions_message_id ON revisions(message_id);
"""
+ + CHANGES_DDL
+ + ';'
+ SERIES_PATCHES_DDL
+ ';'
)
@@ -164,6 +187,21 @@ def init_db(identifier: str) -> sqlite3.Connection:
return conn
+def _drop_column(conn: sqlite3.Connection, table: str, col: str) -> None:
+ """Drop *col* from *table*, tolerating an sqlite too old to do it.
+
+ DROP COLUMN wants sqlite 3.35 (2021). On anything older the column
+ simply stays, unread by everything above -- dead weight in the row, not
+ a correctness problem, and not worth a twelve-step table rebuild.
+
+ *table* and *col* are literals supplied by this module.
+ """
+ try:
+ conn.execute(f'ALTER TABLE {table} DROP COLUMN {col}')
+ except sqlite3.OperationalError as ex:
+ logger.debug('Could not drop %s.%s: %s', table, col, ex)
+
+
def _migrate_db_if_needed(conn: sqlite3.Connection) -> None:
"""Apply any pending schema migrations in-place.
@@ -339,20 +377,12 @@ def _run_migrations(conn: sqlite3.Connection) -> None:
# column left behind invites the next writer to keep it warm.
# Inside the backfill guard on purpose: dropping a copy that
# was never carried across would just lose it.
- #
- # DROP COLUMN wants sqlite 3.35 (2021). On anything older the
- # columns simply stay, unread by everything below -- dead
- # weight in the row, not a correctness problem, and not worth
- # a twelve-step table rebuild to reclaim.
for col in (
'message_count',
'seen_message_count',
'last_update_check',
):
- try:
- conn.execute(f'ALTER TABLE series DROP COLUMN {col}')
- except sqlite3.OperationalError as ex:
- logger.debug('Could not drop series.%s: %s', col, ex)
+ _drop_column(conn, 'series', col)
# Matching a posting by message-id became a hot lookup at v11 --
# [l], [o] and the conflict check all run it -- and it was a full
# table scan, unlike its fingerprint twin.
@@ -360,6 +390,27 @@ def _run_migrations(conn: sqlite3.Connection) -> None:
'CREATE INDEX IF NOT EXISTS idx_revisions_message_id'
' ON revisions(message_id)'
)
+ if version < 12:
+ # Per-change_id state gets its own table. `branch_sha` describes
+ # the one b4/review/<change_id> branch, so a copy on every series
+ # row meant writing them all and reading back with ORDER BY
+ # revision DESC LIMIT 1 -- an arbitration between copies that only
+ # existed because the fact was stored per revision.
+ conn.execute(CHANGES_DDL)
+ series_cols = {row[1] for row in conn.execute('PRAGMA table_info(series)')}
+ if 'branch_sha' in series_cols:
+ # Highest revision wins, which is the row the old reader picked
+ # with ORDER BY revision DESC LIMIT 1. A wrong sha here costs
+ # one extra tracking-commit read on the next rescan, so there is
+ # nothing to reconcile: it is a cache key, not data.
+ conn.execute(
+ 'INSERT OR IGNORE INTO changes (change_id, branch_sha)'
+ ' SELECT change_id, ('
+ ' SELECT s.branch_sha FROM series s WHERE s.change_id ='
+ ' series.change_id ORDER BY s.revision DESC LIMIT 1)'
+ ' FROM series GROUP BY change_id'
+ )
+ _drop_column(conn, 'series', 'branch_sha')
# Not an UPDATE: `version` is the primary key, so an UPDATE writes
# nothing at all against an empty table -- and the read above maps "no
# row" to version 0, so such a database would re-run the whole ladder
@@ -1453,6 +1504,52 @@ def record_known_revisions(
conn.commit()
+def _set_change_state(conn: sqlite3.Connection, change_id: str, **cols: Any) -> None:
+ """Upsert per-change_id state, creating the `changes` row if needed.
+
+ A change_id acquires its row the first time something needs to
+ remember anything about it; nothing else has to keep the table in
+ step with `series`.
+
+ *cols* keys are column names supplied by the wrappers below, never
+ caller input.
+ """
+ assignments = ', '.join(f'{col} = ?' for col in cols)
+ conn.execute(
+ f'INSERT INTO changes (change_id, {", ".join(cols)})'
+ f' VALUES (?, {", ".join("?" for _ in cols)})'
+ f' ON CONFLICT (change_id) DO UPDATE SET {assignments}',
+ (change_id, *cols.values(), *cols.values()),
+ )
+ conn.commit()
+
+
+def set_branch_sha(conn: sqlite3.Connection, change_id: str, branch_sha: str) -> None:
+ """Record the review branch's HEAD, so a rescan can skip an unmoved branch."""
+ _set_change_state(conn, change_id, branch_sha=branch_sha)
+
+
+def get_branch_sha(conn: sqlite3.Connection, change_id: str) -> Optional[str]:
+ """The review branch HEAD recorded at the last rescan, if any."""
+ row = conn.execute(
+ 'SELECT branch_sha FROM changes WHERE change_id = ?', (change_id,)
+ ).fetchone()
+ return str(row[0]) if row is not None and row[0] else None
+
+
+def forget_change_state(conn: sqlite3.Connection, change_id: str) -> None:
+ """Drop the per-change_id row once nothing tracks that change_id.
+
+ `changes` outlives `series` on its own -- nothing joins them -- so a
+ change_id that stops existing leaves a row behind carrying
+ back_searched=1 and a branch sha. Re-tracking it then skips the
+ one-shot backward search for ever, which is the exact opposite of what
+ the latch is for, and a resurrected branch landing on the recorded sha
+ is skipped by rescan_branches.
+ """
+ conn.execute('DELETE FROM changes WHERE change_id = ?', (change_id,))
+
+
def sync_revisions_catalog_to_branch(
topdir: Optional[str], identifier: str, change_id: str
) -> bool:
@@ -1922,6 +2019,7 @@ def absorb_series_as_revision(
conn.execute(
'DELETE FROM series_patches WHERE change_id = ?', (stray_change_id,)
)
+ forget_change_state(conn, stray_change_id)
conn.commit()
return True
@@ -3923,13 +4021,7 @@ def rescan_branches(
continue
current_sha = sha_out.strip()
- # Check the stored SHA for the most recent revision of this change_id.
- stored = conn.execute(
- 'SELECT branch_sha FROM series WHERE change_id = ?'
- ' ORDER BY revision DESC LIMIT 1',
- (change_id_from_branch,),
- ).fetchone()
- if stored and stored['branch_sha'] == current_sha:
+ if get_branch_sha(conn, change_id_from_branch) == current_sha:
# Branch HEAD unchanged — skip the expensive tracking-commit read.
scanned_change_ids.add(change_id_from_branch)
continue
@@ -4021,11 +4113,7 @@ def rescan_branches(
record_known_revisions(conn, change_id, tracking.get('known-revisions'))
# Persist the new HEAD SHA so future rescans can skip this branch.
- conn.execute(
- 'UPDATE series SET branch_sha = ? WHERE change_id = ? AND revision = ?',
- (current_sha, change_id, revision),
- )
- conn.commit()
+ set_branch_sha(conn, change_id, str(current_sha))
logger.info('Rescanned: %s (status: %s)', change_id, status)
changed += 1
@@ -4072,8 +4160,15 @@ def delete_series(
'DELETE FROM series_patches WHERE change_id = ? AND revision = ?',
(change_id, revision),
)
+ # Only once nothing is left: the other revisions still share the
+ # one branch, and the search latch still describes them.
+ if not conn.execute(
+ 'SELECT COUNT(*) FROM series WHERE change_id = ?', (change_id,)
+ ).fetchone()[0]:
+ forget_change_state(conn, change_id)
else:
conn.execute('DELETE FROM revisions WHERE change_id = ?', (change_id,))
conn.execute('DELETE FROM series WHERE change_id = ?', (change_id,))
conn.execute('DELETE FROM series_patches WHERE change_id = ?', (change_id,))
+ forget_change_state(conn, change_id)
conn.commit()
diff --git a/src/tests/test_review_tracking.py b/src/tests/test_review_tracking.py
index f65bbb4d..8611cd3d 100644
--- a/src/tests/test_review_tracking.py
+++ b/src/tests/test_review_tracking.py
@@ -1646,7 +1646,8 @@ class TestFollowupCounts:
conn = review_tracking.get_db('fc-migration-test')
cursor = conn.execute('PRAGMA table_info(series)')
col_names = {row[1] for row in cursor.fetchall()}
- assert 'branch_sha' in col_names
+ # branch_sha is added by v2 and rehomed to `changes` by v12.
+ assert 'branch_sha' not in col_names
assert 'message_count' in col_names
assert 'seen_message_count' in col_names
assert 'last_update_check' in col_names
--
2.53.0
next prev 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 ` Christian Brauner [this message]
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 ` [PATCH RFC v2 19/25] review: add backward discovery of older series revisions Christian Brauner
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-10-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.