From a529fdeb8073131f8051a34ea19077a4042a9c5a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 03:00:28 +0000 Subject: [PATCH 1/5] Fix N+1 query bottleneck in discussion list (GetPosts) GetPosts previously issued 4 extra sequential DB round trips per post (reply count, last reply, lock status, board name) on top of the page query, i.e. ~60+ round trips for a single 15-post page. Since D1 queries go over the network per call, this dominates latency regardless of indexing. Replaced the per-post loop with a single query using correlated subqueries and joins. --- Source/Process.ts | 91 +++++++++++++++++++++++------------------------ 1 file changed, 45 insertions(+), 46 deletions(-) diff --git a/Source/Process.ts b/Source/Process.ts index 38fc031..ef71178 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -566,16 +566,17 @@ export class Process { "Page": "number", "BoardID": "number" })); + const SearchCondition = {}; + if (Data["ProblemID"] !== 0) { + SearchCondition["problem_id"] = Data["ProblemID"]; + } + if (Data["BoardID"] !== -1) { + SearchCondition["board_id"] = Data["BoardID"]; + } let ResponseData = { Posts: new Array, - PageCount: Data["BoardID"] !== -1 ? (Data["ProblemID"] !== 0 ? Math.ceil(ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("bbs_post", { - board_id: Data["BoardID"], - problem_id: Data["ProblemID"] - }))["TableSize"] / 15) : Math.ceil(ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("bbs_post", { - board_id: Data["BoardID"] - }))["TableSize"] / 15)) : (Data["ProblemID"] !== 0 ? Math.ceil(ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("bbs_post", { - problem_id: Data["ProblemID"] - }))["TableSize"] / 15) : Math.ceil(ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("bbs_post"))["TableSize"] / 15)) + PageCount: Math.ceil(ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("bbs_post", + Object.keys(SearchCondition).length === 0 ? undefined : SearchCondition))["TableSize"] / 15) }; if (ResponseData.PageCount === 0) { return new Result(true, "获得讨论列表成功", ResponseData); @@ -583,48 +584,44 @@ export class Process { if (Data["Page"] < 1 || Data["Page"] > ResponseData.PageCount) { return new Result(false, "参数页数不在范围1~" + ResponseData.PageCount + "内"); } - const SearchCondition = {}; + + let WhereClause = ""; + const BindData: (string | number)[] = []; if (Data["ProblemID"] !== 0) { - SearchCondition["problem_id"] = Data["ProblemID"]; + WhereClause += (WhereClause === "" ? "WHERE " : "AND ") + "p.problem_id = ? "; + BindData.push(Data["ProblemID"]); } if (Data["BoardID"] !== -1) { - SearchCondition["board_id"] = Data["BoardID"]; + WhereClause += (WhereClause === "" ? "WHERE " : "AND ") + "p.board_id = ? "; + BindData.push(Data["BoardID"]); } - const Posts = ThrowErrorIfFailed(await this.XMOJDatabase.Select("bbs_post", [], SearchCondition, { - Order: "post_id", - OrderIncreasing: false, - Limit: 15, - Offset: (Data["Page"] - 1) * 15 - })); - for (const Post of Posts) { + BindData.push(15, (Data["Page"] - 1) * 15); - const ReplyCount: number = ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("bbs_reply", {post_id: Post["post_id"]}))["TableSize"]; - const LastReply = ThrowErrorIfFailed(await this.XMOJDatabase.Select("bbs_reply", ["user_id", "reply_time"], {post_id: Post["post_id"]}, { - Order: "reply_time", - OrderIncreasing: false, - Limit: 1 - })); - if (ReplyCount === 0) { + // Single query with correlated subqueries/joins instead of 4 extra + // round trips per post (was causing an N+1 query bottleneck). + const Posts = (await this.RawDatabase.prepare( + "SELECT p.post_id AS post_id, p.user_id AS user_id, p.problem_id AS problem_id, " + + "p.title AS title, p.post_time AS post_time, p.board_id AS board_id, " + + "b.board_name AS board_name, " + + "(SELECT COUNT(*) FROM bbs_reply r WHERE r.post_id = p.post_id) AS reply_count, " + + "(SELECT r.user_id FROM bbs_reply r WHERE r.post_id = p.post_id ORDER BY r.reply_time DESC LIMIT 1) AS last_reply_user_id, " + + "(SELECT r.reply_time FROM bbs_reply r WHERE r.post_id = p.post_id ORDER BY r.reply_time DESC LIMIT 1) AS last_reply_time, " + + "l.lock_person AS lock_person, l.lock_time AS lock_time " + + "FROM bbs_post p " + + "LEFT JOIN bbs_board b ON b.board_id = p.board_id " + + "LEFT JOIN bbs_lock l ON l.post_id = p.post_id " + + WhereClause + + "ORDER BY p.post_id DESC LIMIT ? OFFSET ?;" + ).bind(...BindData).all())["results"]; + + for (const Post of Posts) { + if (Post["reply_count"] === 0) { await this.XMOJDatabase.Delete("bbs_post", { post_id: Post["post_id"] }); continue; } - const LockData = { - Locked: false, - LockPerson: "", - LockTime: 0 - }; - const Locked = ThrowErrorIfFailed(await this.XMOJDatabase.Select("bbs_lock", [], { - post_id: Post["post_id"] - })); - if (Locked.toString() !== "") { - LockData.Locked = true; - LockData.LockPerson = Locked[0]["lock_person"]; - LockData.LockTime = Locked[0]["lock_time"]; - } - ResponseData.Posts.push({ PostID: Post["post_id"], UserID: Post["user_id"], @@ -632,13 +629,15 @@ export class Process { Title: Post["title"], PostTime: Post["post_time"], BoardID: Post["board_id"], - BoardName: ThrowErrorIfFailed(await this.XMOJDatabase.Select("bbs_board", ["board_name"], { - board_id: Post["board_id"] - }))[0]["board_name"], - ReplyCount: ReplyCount, - LastReplyUserID: LastReply[0]["user_id"], - LastReplyTime: LastReply[0]["reply_time"], - Lock: LockData + BoardName: Post["board_name"], + ReplyCount: Post["reply_count"], + LastReplyUserID: Post["last_reply_user_id"], + LastReplyTime: Post["last_reply_time"], + Lock: { + Locked: Post["lock_person"] !== null, + LockPerson: Post["lock_person"] ?? "", + LockTime: Post["lock_time"] ?? 0 + } }); } return new Result(true, "获得讨论列表成功", ResponseData); From b32bab853569e1b938ff64c1558ef454037915ce Mon Sep 17 00:00:00 2001 From: codefactor-io Date: Mon, 27 Jul 2026 03:01:09 +0000 Subject: [PATCH 2/5] [CodeFactor] Apply fixes to commit a529fde --- Source/Process.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Source/Process.ts b/Source/Process.ts index ef71178..7c22bf5 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -210,7 +210,7 @@ export class Process { return this.DenyBadgeEditList.indexOf(this.Username) !== -1; } public VerifyCaptcha = async (CaptchaToken: string): Promise => { - const ErrorDescriptions: Object = { + const ErrorDescriptions: object = { "missing-input-secret": "密钥为空", "invalid-input-secret": "密钥不正确", "missing-input-response": "验证码令牌为空", @@ -574,7 +574,7 @@ export class Process { SearchCondition["board_id"] = Data["BoardID"]; } let ResponseData = { - Posts: new Array, + Posts: new Array, PageCount: Math.ceil(ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("bbs_post", Object.keys(SearchCondition).length === 0 ? undefined : SearchCondition))["TableSize"] / 15) }; @@ -654,7 +654,7 @@ export class Process { BoardID: 0, BoardName: "", PostTime: 0, - Reply: new Array(), + Reply: new Array(), PageCount: 0, Lock: { Locked: false, @@ -869,7 +869,7 @@ export class Process { GetBBSMentionList: async (Data: object): Promise => { ThrowErrorIfFailed(this.CheckParams(Data, {})); const ResponseData = { - MentionList: new Array() + MentionList: new Array() }; const Mentions = ThrowErrorIfFailed(await this.XMOJDatabase.Select("bbs_mention", ["bbs_mention_id", "post_id", "bbs_mention_time", "reply_id"], { to_user_id: this.Username @@ -895,7 +895,7 @@ export class Process { GetMailMentionList: async (Data: object): Promise => { ThrowErrorIfFailed(this.CheckParams(Data, {})); const ResponseData = { - MentionList: new Array() + MentionList: new Array() }; const Mentions = ThrowErrorIfFailed(await this.XMOJDatabase.Select("short_message_mention", ["mail_mention_id", "from_user_id", "mail_mention_time"], { to_user_id: this.Username @@ -958,7 +958,7 @@ export class Process { GetMailList: async (Data: object): Promise => { ThrowErrorIfFailed(this.CheckParams(Data, {})); const ResponseData = { - MailList: new Array() + MailList: new Array() }; let OtherUsernameList = new Array(); let Mails = ThrowErrorIfFailed(await this.XMOJDatabase.Select("short_message", ["message_from"], {message_to: this.Username}, {}, true)); @@ -987,7 +987,7 @@ export class Process { OrderIncreasing: false, Limit: 1 })); - let LastMessage: Object; + let LastMessage: object; if (LastMessageFrom.toString() === "") { LastMessage = LastMessageTo; @@ -1068,7 +1068,7 @@ export class Process { "OtherUser": "string" })); const ResponseData = { - Mail: new Array() + Mail: new Array() }; let Mails = ThrowErrorIfFailed(await this.XMOJDatabase.Select("short_message", [], { message_from: Data["OtherUser"], @@ -1384,7 +1384,7 @@ export class Process { }, GetBoards: async (Data: object): Promise => { ThrowErrorIfFailed(this.CheckParams(Data, {})); - const Boards: Array = new Array(); + const Boards: Array = new Array(); const BoardsData = ThrowErrorIfFailed(await this.XMOJDatabase.Select("bbs_board", [])); for (const Board of BoardsData) { Boards.push({ From 2c21ef110e51f845e3f64bad6bbd8cf4938c68be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 03:04:28 +0000 Subject: [PATCH 3/5] Remove redundant ternary flagged by static analysis in GetPosts WhereClause is guaranteed empty at the first check (nothing between declaration and use could change it), so CodeFactor correctly flagged it as always-true dead code. --- Source/Process.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Process.ts b/Source/Process.ts index 7c22bf5..20def0a 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -588,7 +588,7 @@ export class Process { let WhereClause = ""; const BindData: (string | number)[] = []; if (Data["ProblemID"] !== 0) { - WhereClause += (WhereClause === "" ? "WHERE " : "AND ") + "p.problem_id = ? "; + WhereClause += "WHERE p.problem_id = ? "; BindData.push(Data["ProblemID"]); } if (Data["BoardID"] !== -1) { From 345786b40c39eb2627d1fe223a05b62f3a1f6f89 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 03:06:20 +0000 Subject: [PATCH 4/5] Keep GetPosts count and page query on the same D1 session The page-count query went through this.XMOJDatabase's own D1DatabaseSession while the page data query used this.RawDatabase's session. With D1 read replication, sequential consistency is only guaranteed within a session, so the two reads could observe different snapshots and corrupt pagination. Both queries now run as raw SQL through this.RawDatabase. Per Codex review on PR #66. --- Source/Process.ts | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/Source/Process.ts b/Source/Process.ts index 20def0a..89412f7 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -566,17 +566,28 @@ export class Process { "Page": "number", "BoardID": "number" })); - const SearchCondition = {}; + let WhereClause = ""; + const FilterBindData: (string | number)[] = []; if (Data["ProblemID"] !== 0) { - SearchCondition["problem_id"] = Data["ProblemID"]; + WhereClause += "WHERE p.problem_id = ? "; + FilterBindData.push(Data["ProblemID"]); } if (Data["BoardID"] !== -1) { - SearchCondition["board_id"] = Data["BoardID"]; + WhereClause += (WhereClause === "" ? "WHERE " : "AND ") + "p.board_id = ? "; + FilterBindData.push(Data["BoardID"]); } + + // Count and page query must share this.RawDatabase's session (rather than + // this.XMOJDatabase's own session) so D1 read replication reads a + // consistent snapshot across both - otherwise the count can observe a + // newer version than the page query, corrupting pagination. + const PostCount = (await this.RawDatabase.prepare( + "SELECT COUNT(*) AS count FROM bbs_post p " + WhereClause + ";" + ).bind(...FilterBindData).all())["results"][0]["count"]; + let ResponseData = { Posts: new Array, - PageCount: Math.ceil(ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("bbs_post", - Object.keys(SearchCondition).length === 0 ? undefined : SearchCondition))["TableSize"] / 15) + PageCount: Math.ceil(PostCount / 15) }; if (ResponseData.PageCount === 0) { return new Result(true, "获得讨论列表成功", ResponseData); @@ -585,17 +596,7 @@ export class Process { return new Result(false, "参数页数不在范围1~" + ResponseData.PageCount + "内"); } - let WhereClause = ""; - const BindData: (string | number)[] = []; - if (Data["ProblemID"] !== 0) { - WhereClause += "WHERE p.problem_id = ? "; - BindData.push(Data["ProblemID"]); - } - if (Data["BoardID"] !== -1) { - WhereClause += (WhereClause === "" ? "WHERE " : "AND ") + "p.board_id = ? "; - BindData.push(Data["BoardID"]); - } - BindData.push(15, (Data["Page"] - 1) * 15); + const BindData: (string | number)[] = [...FilterBindData, 15, (Data["Page"] - 1) * 15]; // Single query with correlated subqueries/joins instead of 4 extra // round trips per post (was causing an N+1 query bottleneck). From a73e46a1b58271b02e19e2fad3664866fe267f6d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 03:12:55 +0000 Subject: [PATCH 5/5] Consolidate last-reply subqueries into one windowed join Replaces two separate correlated subqueries (one for last_reply_user_id, one for last_reply_time) that each scanned bbs_reply per row, with a single LEFT JOIN against a ROW_NUMBER()-based derived table. Per cubic-dev-ai review on PR #66. --- Source/Process.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Source/Process.ts b/Source/Process.ts index 89412f7..d8afb56 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -605,12 +605,18 @@ export class Process { "p.title AS title, p.post_time AS post_time, p.board_id AS board_id, " + "b.board_name AS board_name, " + "(SELECT COUNT(*) FROM bbs_reply r WHERE r.post_id = p.post_id) AS reply_count, " + - "(SELECT r.user_id FROM bbs_reply r WHERE r.post_id = p.post_id ORDER BY r.reply_time DESC LIMIT 1) AS last_reply_user_id, " + - "(SELECT r.reply_time FROM bbs_reply r WHERE r.post_id = p.post_id ORDER BY r.reply_time DESC LIMIT 1) AS last_reply_time, " + + "lr.user_id AS last_reply_user_id, lr.reply_time AS last_reply_time, " + "l.lock_person AS lock_person, l.lock_time AS lock_time " + "FROM bbs_post p " + "LEFT JOIN bbs_board b ON b.board_id = p.board_id " + "LEFT JOIN bbs_lock l ON l.post_id = p.post_id " + + "LEFT JOIN (" + + " SELECT post_id, user_id, reply_time FROM (" + + " SELECT post_id, user_id, reply_time, " + + " ROW_NUMBER() OVER (PARTITION BY post_id ORDER BY reply_time DESC) AS rn " + + " FROM bbs_reply" + + " ) WHERE rn = 1" + + ") lr ON lr.post_id = p.post_id " + WhereClause + "ORDER BY p.post_id DESC LIMIT ? OFFSET ?;" ).bind(...BindData).all())["results"];