Skip to content

fix(db): keep reading progress steady for sessions that moved nothing, and concurrent replays - #50

Merged
AshDevFr merged 2 commits into
mainfrom
fix/position-less-session-keeps-progress
Oct 2, 2026
Merged

AshDevFr merged 2 commits into
mainfrom
fix/position-less-session-keeps-progress

Conversation

@AshDevFr

@AshDevFr AshDevFr commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Two bugs in POST /api/v1/reading-sessions, reported from a production session against 2.6.2.

A session that moved nothing rewrote reading progress

An iPad left open on a book for under a second queued a session with time spent and no position, and uploaded it the next morning. The server re-projected the progress row from it:

  • updated_at jumped to the upload time, sending the book from 9th to 1st in Keep Reading with its page unchanged.
  • The same shape on a finished book cleared completed and put it back in Keep Reading.
  • On an unread book it created a row at page 0, adding the book to Keep Reading.

The fold now builds the row only from sessions that move the reader: a page, percentage or locator, or a completion. started_at and updated_at come from those sessions too. Time-only sessions are still stored and still count in reading statistics, which read the session log directly.

The fold's progress output is now three-way: a row, no row (after a reset), or leave the row alone. The third case cannot fall back to deleting, because importing a reading-progress export writes read_progress directly, and a time-only session must not erase a row the log does not explain.

Every consumer of read_progress.updated_at (shelf ordering, series last read, Komga readDate / lastModified, OPDS2, plugin sync staleness) reads it as "when the position or state last changed"; none relied on time-only sessions bumping it. Legacy surfaces (v1 progress routes, Komga, KOReader, OPDS) always send a page, so they are unaffected.

Rows already bumped by this bug are not repaired by a migration: a global refold would clobber imported progress. They correct themselves on the book's next session that moves the reader.

A concurrent replay of the same session id returned 500

The same queue was sent twice at once. The replay check reads before it writes, so neither request saw the other's uncommitted row, and on PostgreSQL the second insert failed with duplicate key value violates unique constraint "reading_sessions_pkey" (the production log line).

  • The insert now uses ON CONFLICT (id) DO NOTHING and reports a skipped row as a duplicate, which is what a sequential replay already gets. It uses exec rather than exec_with_returning, since only exec reports a do-nothing insert as a conflict.
  • On SQLite the losing transaction is refused the write lock before it reaches the insert. record_session retries the whole transaction on SQLite lock contention (bounded, with backoff, logged at warn), so the replay re-reads and comes back as a duplicate.

Tests

  • Fold unit tests for time-only sessions: position, completion and started_at untouched, a pass of only time-only sessions leaves the row alone, a reset stays a reset, a positioned session still wins. The updated_at test now states the new rule.
  • API tests on in-progress, finished, unread and directly imported books, all of which fail on main, plus one pinning the boundary where a measured session absorbs position writes and does move the row.
  • A repository test that holds the first transaction open and replays the id, run on both SQLite and PostgreSQL. Before the fix it failed with the production error on PostgreSQL and database is locked on SQLite; with the retry disabled the SQLite case fails again.
  • Full suite: SQLite 5500 passed, PostgreSQL 5541 passed.

…gress

A reading session reporting only time spent (no page, percentage or
locator) still re-projected the whole progress row. A reader left open
on a book for a second therefore bumped updated_at to the upload time
and sent the book to the top of Keep Reading, un-finished a completed
book, and put an unread book on the in-progress shelf at page 0.

The fold now builds the row only from sessions that move the reader: a
position, or a completion. started_at and updated_at are taken over
those sessions too, so a stray open neither backdates a pass nor
reorders the shelves. Time-only sessions are still stored and still
count towards reading statistics, which read the log directly.

The fold's progress output becomes a three-way projection: a row, no
row (after a reset), or leave the row as it is. The last case cannot
fall back to deleting, because importing a reading-progress export
writes the row directly, and a time-only session must not erase a row
the log does not explain.
A client that sent its session queue twice at once got a 500 on the
second request. The replay check reads before it writes, so it cannot
see a sibling request's uncommitted row: both requests passed it, and
on PostgreSQL the second insert then hit the reading_sessions primary
key.

The insert now uses ON CONFLICT (id) DO NOTHING and reports a skipped
row as a duplicate, which is what a sequential replay already gets.
It goes through exec rather than exec_with_returning, because only exec
reports an insert that did nothing as a conflict.

SQLite never reaches the insert: the losing transaction is refused the
write lock instead. record_session now retries the whole transaction on
SQLite lock contention, a bounded number of times with backoff, so the
replay re-reads, finds the committed row and comes back as a duplicate.
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

API contract changes

Compared against main. These are changes a client generated from the
previous document would notice. Not a failure: breaking changes are a
release-time decision, and make release-prepare checks the bump against
them when the version is chosen.

Report
No breaking changes

@AshDevFr
AshDevFr merged commit e0bb0f5 into main Oct 2, 2026
24 checks passed
@AshDevFr
AshDevFr deleted the fix/position-less-session-keeps-progress branch October 2, 2026 22:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant