{"record":{"id":"12d3b8a7ed17f095","repo":"gastownhall/beads","slug":"get-issue-comments-page-from-s-after-v-q-w","errorCode":null,"errorMessage":"get issue comments page from %s (after %v/%q): %w","messagePattern":"get issue comments page from (.+?) \\(after (.+?)/%q\\): %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/issueops/comments.go","lineNumber":139,"sourceCode":"\n\ttable := \"comments\"\n\tif IsActiveWispInTx(ctx, tx, issueID) {\n\t\ttable = \"wisp_comments\"\n\t}\n\n\thasCursor := !after.CreatedAt.IsZero() || after.ID != \"\"\n\targs := []any{issueID}\n\tif hasCursor {\n\t\t// Bind the cursor time as time.Time, not a formatted string: created_at\n\t\t// is a DATETIME column, so a time.Time value compares correctly on every\n\t\t// backend while an RFC3339 string can mis-compare. Bound twice (the\n\t\t// sargable lower bound and the strict bound), then the id tie-break.\n\t\targs = append(args, after.CreatedAt, after.CreatedAt, after.ID)\n\t}\n\n\trows, err := tx.QueryContext(ctx, CommentsPageQuery(table, hasCursor, limit), args...)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get issue comments page from %s (after %v/%q): %w\", table, after.CreatedAt, after.ID, err)\n\t}\n\tdefer rows.Close()\n\n\tvar comments []*types.Comment\n\tfor rows.Next() {\n\t\tvar c types.Comment\n\t\tif err := rows.Scan(&c.ID, &c.IssueID, &c.Author, &c.Text, &c.CreatedAt); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"get issue comments page: scan: %w\", err)\n\t\t}\n\t\tcomments = append(comments, &c)\n\t}\n\treturn comments, rows.Err()\n}\n\n// GetCommentCountsInTx returns comment counts per issue ID within a transaction.\n// Routes each ID to comments or wisp_comments based on wisp status.\n// Uses batched IN clauses (queryBatchSize) to avoid query-planner spikes.\nfunc GetCommentCountsInTx(ctx context.Context, tx *sql.Tx, issueIDs []string) (map[string]int, error) {","sourceCodeStart":121,"sourceCodeEnd":157,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/issueops/comments.go#L121-L157","documentation":"GetIssueCommentsPageInTx fails when the keyset-paged comments query (with the sargable created_at >= ? AND ((created_at > ?) OR (id > ?)) predicate when a cursor is set) returns a driver error. The message includes the table and the resume cursor (created_at/id) to aid debugging. Like the unpaginated read, this is an infrastructure/query failure, not a missing issue (a missing issue yields an empty page, no error).","triggerScenarios":"Paging a thread when the connection drops or the context is cancelled mid-query; passing a cursor whose CreatedAt is a formatted RFC3339 string rather than a time.Time can also break comparison on some backends; schema changes invalidating the (issue_id, created_at, id) index.","commonSituations":"UI or bot walking a very long thread across many requests, with timeouts between pages; custom drivers with different DATETIME binding semantics; migrations running while pages are fetched.","solutions":["Retry the failed page (keyset paging is idempotent for a given cursor).","Bind the cursor as a time.Time (storage.CommentPageCursor.CreatedAt), never a preformatted string.","Increase the context timeout per page and re-check the schema/index after migrations."],"exampleFix":"// before\nafter := storage.CommentPageCursor{CreatedAt: parsedFromString} // string-backed time\n\n// after\nts, err := time.Parse(time.RFC3339, cursorStr)\nif err != nil { return err }\nafter := storage.CommentPageCursor{CreatedAt: ts.UTC(), ID: cursorID}","handlingStrategy":"retry","validationCode":"if !after.CreatedAt.IsZero() && after.ID == \"\" {\n\t// partial cursor: refetch the last page to rebuild a complete cursor\n}","typeGuard":null,"tryCatchPattern":"page, err := store.GetIssueCommentsPage(ctx, id, after, limit)\nif err != nil {\n\tif isTransientDB(err) {\n\t\tpage, err = store.GetIssueCommentsPage(ctx, id, after, limit) // same cursor = idempotent\n\t}\n\tif err != nil { return err }\n}","preventionTips":["Always build cursors from a previously returned comment's (CreatedAt, ID); never fabricate them from strings.","Keep limit <= 500 (the clamp max) so paging loops terminate predictably.","Retry a failed page with the same cursor — keyset paging cannot duplicate or skip on retry."],"tags":["database","pagination","sql"],"backgroundTag":"database-query-failed","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}