Skip to content

Fix GRDBResultSet spinning on a stale row when a query cursor errors mid-iteration#4667

Merged
kean merged 4 commits into
trunkfrom
fix-grdb-resultset-stale-row-on-cursor-error
Jul 7, 2026
Merged

Fix GRDBResultSet spinning on a stale row when a query cursor errors mid-iteration#4667
kean merged 4 commits into
trunkfrom
fix-grdb-resultset-stale-row-on-cursor-error

Conversation

@kean

@kean kean commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

GRDBResultSet.next() used try? row = rowCursor.next(), which swallowed a mid-iteration cursor error (e.g. SQLITE_BUSY/SQLITE_IOERR) without resetting the cached row. The previous row stayed non-nil, so next() kept returning true and while resultSet.next() callers (End of Year, Auto Add, and other queries) could re-process the last row and spin unbounded.

This resets row to nil on a cursor error so the failure surfaces as end-of-results, and logs the error so the resulting partial read is diagnosable instead of silent. Adds GRDBResultSetTests covering the throwing-cursor case, loop termination, and normal exhaustion.

To test

This is an internal data-layer robustness fix; it's exercised via unit tests:

  1. Run PocketCastsDataModelTests/GRDBResultSetTests — all three tests pass.
  2. Revert the change in GRDBResultSet.next() to try? row = rowCursor.next() and re-run: testWhileLoopTerminatesWhenCursorThrows hits the infinite-loop guard (101 iterations) and testNextReturnsFalseWhenCursorThrowsMidIteration fails, confirming the tests catch the bug.
  3. Confirm the full PocketCastsDataModelTests suite has no new failures.

Checklist

  • I have considered if this change warrants user-facing release notes and have added them to CHANGELOG.md if necessary.
  • I have considered adding unit tests for my changes.
  • I have updated (or requested that someone edit) the Event Horizon schema to reflect any new or changed analytics.

@kean kean requested a review from a team as a code owner July 1, 2026 22:03
@kean kean requested review from Copilot and removed request for a team July 1, 2026 22:03
@kean kean added this to the 8.16 milestone Jul 1, 2026
@kean kean requested a review from SergioEstevao July 1, 2026 22:03
@kean kean added the [Type] Fix label Jul 1, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes an edge-case in the DataModel’s GRDB cursor wrapper where a mid-iteration cursor error could leave a stale cached row and cause while resultSet.next() loops to spin indefinitely, potentially hanging features that iterate database results.

Changes:

  • Update GRDBResultSet.next() to stop swallowing cursor errors while leaving the previous row cached; it now clears the cached row and logs the cursor error.
  • Add GRDBResultSetTests regression coverage for a throwing cursor, loop termination, and normal cursor exhaustion.
  • Add a release-note entry to CHANGELOG.md describing the potential hang fix.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
Modules/Tests/PocketCastsDataModelTests/GRDBResultSetTests.swift Adds regression tests that reproduce the stale-row infinite loop and verify correct termination behavior.
Modules/Sources/PocketCastsDataModel/Private/DB/GRDB/GRDBResultSet.swift Fixes next() error handling by clearing stale state and logging the error so callers don’t spin.
CHANGELOG.md Documents the fix as a potential hang prevention in transient DB error cases.

Comment on lines +24 to +28
// Reset `row` before stepping so a cursor error (e.g. SQLITE_BUSY /
// SQLITE_IOERR) surfaces as end-of-results instead of leaving the
// previous row in place, which would make `while next()` loops spin.
do {
row = try rowCursor.next()
Comment on lines +39 to +45
// No ORDER BY: a sequential table scan returns rows in rowid
// (insertion) order and evaluates `explodeOnTwo` lazily per step, so
// row 1 is emitted before the function throws while stepping onto
// row 2. An ORDER BY on the computed column would instead force a
// full up-front sort pass and throw before the first row.
let cursor = try Row.fetchCursor(db, sql: "SELECT explodeOnTwo(value) AS value FROM items")
try body(GRDBResultSet(rowCursor: cursor))
@kean kean marked this pull request as draft July 1, 2026 22:14
kean and others added 2 commits July 2, 2026 14:16
`next()` used `try? row = rowCursor.next()`, which swallowed a mid-iteration
cursor error (SQLITE_BUSY/IOERR) without resetting `row`. The previously
fetched row stayed non-nil, so `next()` kept returning true and
`while resultSet.next()` callers (EndOfYear/AutoAdd) spun forever on the last
row. Reset `row` to nil on error so a cursor failure surfaces as
end-of-results.

Adds GRDBResultSetTests covering the throwing-cursor case, loop termination,
and normal exhaustion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kean kean force-pushed the fix-grdb-resultset-stale-row-on-cursor-error branch from 5af7e6a to bf4e1a1 Compare July 2, 2026 18:26
`bookmarkCount()` discarded next()'s result and read column 0
unconditionally. On a cursor error (SQLITE_BUSY/IOERR) next() returns
false with a nil row, so the read would crash on the implicitly
unwrapped Row. Guard the read and fall back to 0, matching the
defer { close() } pattern used elsewhere in this file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kean kean marked this pull request as ready for review July 2, 2026 18:32
count = resultSet.long(forColumnIndex: 0)
resultSet.close()
defer { resultSet.close() }
if resultSet.next() {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the only place where row values are read unconditionally (without checking if next succeeded)

@SergioEstevao SergioEstevao left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch :shipit:

@kean kean enabled auto-merge July 7, 2026 15:43
@dangermattic

Copy link
Copy Markdown
Collaborator
1 Warning
⚠️ This PR is assigned to the milestone 8.16. The due date for this milestone has already passed.
Please assign it to a milestone with a later deadline or check whether the release for this milestone has already been finished.

Generated by 🚫 Danger

@kean kean merged commit d5eeccd into trunk Jul 7, 2026
4 checks passed
@kean kean deleted the fix-grdb-resultset-stale-row-on-cursor-error branch July 7, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants