Skip to content

use UPSERT instead of select for hashtag statistics update - #17795

Merged
syuilo merged 6 commits into
misskey-dev:developfrom
anatawa12:hashtag-update
Jul 25, 2026
Merged

use UPSERT instead of select for hashtag statistics update#17795
syuilo merged 6 commits into
misskey-dev:developfrom
anatawa12:hashtag-update

Conversation

@anatawa12

Copy link
Copy Markdown
Member

What

use UPSERT instead of select for hashtag statistics update to loos lock to FOR NO KEY UPDATE (automatically acquired by db) and shorten the duration of lock by manipulating array on the server.

Why

to FOR NO KEY UPDATE (automatically acquired by db) and shorten the duration of lock by manipulating array on the server.

内部 discussion で上がってきた懸念だったので

Additional info (optional)

データベースの構造を変えない前提で書いたのでこうなりましたが UsersCount 系カラムは GENERATED COLUMN にしたりそもそもカラムを廃止して cardinality(UserIds) を EXPRESSION INDEX にして ORDER BY 対応すると色々考えることが減っていいような気もしてます。

また存在しない時にappendするという処理自体面倒なので TRIGGER 処理にしちゃうとか

Checklist

  • Read the contribution guide
  • Test working in a local environment
  • (If needed) Add story of storybook
  • (If needed) Update CHANGELOG.md
  • (If possible) Add tests

@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Jul 24, 2026
@github-actions github-actions Bot added the packages/backend Server side specific issue/PR label Jul 24, 2026
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@anatawa12, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 38 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8633fd33-a015-4936-b00a-b3052e6cedb8

📥 Commits

Reviewing files that changed from the base of the PR and between 8855f71 and e4f45ce.

📒 Files selected for processing (1)
  • packages/backend/src/core/HashtagService.ts
📝 Walkthrough

Walkthrough

ハッシュタグの6つのユーザーID配列カラムに空配列の既定値を追加し、関連ユーザーの追加・削除処理を条件付きSQLによる更新へ変更しています。

Changes

ハッシュタグ更新

Layer / File(s) Summary
配列カラムの既定値
packages/backend/migration/..., packages/backend/src/models/Hashtag.ts
6つの配列カラムに空配列の既定値を追加し、マイグレーションで既定値の設定・解除を実装しています。
更新対象カラムの選択
packages/backend/src/core/HashtagService.ts
添付・メンションの種別と増減方向に応じて、更新対象の配列・件数カラムと処理を選択します。
条件付き集計更新
packages/backend/src/core/HashtagService.ts
追加時はUPSERT、削除時はUPDATEを使用し、ID配列の存在に応じて配列と件数を更新します。

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant 呼び出し元
  participant HashtagService
  participant hashtag_table
  呼び出し元->>HashtagService: 添付・メンションの増減を指定
  HashtagService->>hashtag_table: 条件付きUPSERTまたはUPDATE
  hashtag_table-->>HashtagService: 配列と件数を更新
Loading

Poem

空の配列を携えて
ハッシュタグは整列し
追加は一度だけ数え
削除は静かに戻ります
SQLの波に乗って進みます

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed ハッシュタグ統計更新のSELECTベース処理をUPSERT化する主変更を簡潔に表しており、内容と一致しています。
Description check ✅ Passed 必須のWhat/Why/Additional info/Checklistが揃っており、変更意図も概ね説明されています。
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 26.53%. Comparing base (c29a3d9) to head (e4f45ce).
⚠️ Report is 169 commits behind head on develop.

Files with missing lines Patch % Lines
packages/backend/src/core/HashtagService.ts 0.00% 24 Missing and 6 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##           develop   #17795       +/-   ##
============================================
+ Coverage    15.00%   26.53%   +11.52%     
============================================
  Files          248     1183      +935     
  Lines        12402    40398    +27996     
  Branches      4224    11192     +6968     
============================================
+ Hits          1861    10718     +8857     
- Misses        8239    23818    +15579     
- Partials      2302     5862     +3560     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

このPRによるapi.jsonの差分
差分はありません。
Get diff files from Workflow Page

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/backend/migration/1784899839024-HashtagTableDefaults.js`:
- Around line 10-15: Backfill existing NULL values in all six hashtag user-ID
array columns to empty arrays before applying their defaults. In the migration’s
up flow, add updates for mentionedUserIds, mentionedLocalUserIds,
mentionedRemoteUserIds, attachedUserIds, attachedLocalUserIds, and
attachedRemoteUserIds, then retain the existing ALTER COLUMN ... SET DEFAULT
'{}' statements.

In `@packages/backend/src/core/HashtagService.ts`:
- Around line 103-159: Extend the backend hashtag tests covering the
`#incrementHashTag` and `#decrementHashTag` flows to verify that re-adding the same
user does not increase user counts or duplicate IDs, and removing a user who is
not recorded leaves the hashtag aggregation unchanged. Reuse the existing
hashtag retrieval/setup helpers and assert both stored user IDs and counts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 901b57bc-938e-4ce3-94be-511a4b2681fa

📥 Commits

Reviewing files that changed from the base of the PR and between d2973ec and 0c34da5.

📒 Files selected for processing (3)
  • packages/backend/migration/1784899839024-HashtagTableDefaults.js
  • packages/backend/src/core/HashtagService.ts
  • packages/backend/src/models/Hashtag.ts

Comment thread packages/backend/migration/1784899839024-HashtagTableDefaults.js
Comment on lines +103 to 159
async #incrementHashTag(
user: { id: MiUser['id']; host: MiUser['host']; },
tag: string,
columns: UpdatingHashtagColumn,
) {
const isLocal = this.userEntityService.isLocalUser(user);
const { totalUserIds, totalUsersCount } = columns;
const localOrRemoteUserIds = isLocal ? columns.localUserIds : columns.remoteUserIds;
const localOrRemoteUserCount = isLocal ? columns.localUsersCount : columns.remoteUsersCount;

await this.db.createQueryRunner('master')
.query(
`INSERT into "hashtag"("id", "name", "${totalUserIds}", "${totalUsersCount}", "${localOrRemoteUserIds}",
"${localOrRemoteUserCount}")
VALUES ($3, $1, ARRAY [$2], 1, ARRAY [$2], 1)
ON CONFLICT ("name")
DO UPDATE SET "${totalUserIds}" = ${appendUserIdIfNotExists(totalUserIds)},
"${totalUsersCount}" = ${incrementCountIfNotExists(totalUserIds, totalUsersCount)},
"${localOrRemoteUserIds}" = ${appendUserIdIfNotExists(localOrRemoteUserIds)},
"${localOrRemoteUserCount}" = ${incrementCountIfNotExists(localOrRemoteUserIds, localOrRemoteUserCount)}`,
[tag, user.id, this.idService.gen()],
);

function appendUserIdIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`): string {
return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN array_append("hashtag"."${userIds}", $2) ELSE "hashtag"."${userIds}" END`;
}

if (Object.keys(set).length > 0) {
await transactionalHashtagRepository
.createQueryBuilder()
.update()
.where('id = :id', { id: index.id })
.set(set)
.execute();
}
});
function incrementCountIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string {
return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN "hashtag"."${userCount}" + 1 ELSE "hashtag"."${userCount}" END`;
}
}

async #decrementHashTag(
user: { id: MiUser['id']; host: MiUser['host']; },
tag: string,
columns: UpdatingHashtagColumn,
) {
const isLocal = this.userEntityService.isLocalUser(user);
const { totalUserIds, totalUsersCount } = columns;
const localOrRemoteUserIds = isLocal ? columns.localUserIds : columns.remoteUserIds;
const localOrRemoteUserCount = isLocal ? columns.localUsersCount : columns.remoteUsersCount;

await this.db.createQueryRunner('master')
.query(
`UPDATE "hashtag"
SET "${totalUserIds}" = array_remove("${totalUserIds}", $2),
"${totalUsersCount}" = ${decrementIfExists(totalUserIds, totalUsersCount)},
"${localOrRemoteUserIds}" = array_remove("${localOrRemoteUserIds}", $2),
"${localOrRemoteUserCount}" = ${decrementIfExists(localOrRemoteUserIds, localOrRemoteUserCount)}
WHERE "name" = $1`,
[tag, user.id],
);

function decrementIfExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string {
return `CASE WHEN ("${userIds}" @> ARRAY[$2]) THEN "${userCount}" - 1 ELSE "${userCount}" END`;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# HashtagService の新しいUPSERT/UPDATEロジックに対するテストの有無を確認
rg -n -A3 -B3 'HashtagService' packages/backend/test --type=ts -g '!**/node_modules/**' 2>/dev/null
fd -e ts hashtag packages/backend/test

Repository: misskey-dev/misskey

Length of output: 1584


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 変更箇所と関連テストを確認
ast-grep outline packages/backend/src/core/HashtagService.ts --view expanded || true
echo '---'
ast-grep outline packages/backend/test/unit/entities/UserEntityService.ts --view expanded || true
echo '---'
rg -n "incrementHashTag|decrementHashTag|hashtag" packages/backend/test packages/backend/src/core -g '!**/node_modules/**'

Repository: misskey-dev/misskey

Length of output: 9636


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '820,890p' packages/backend/test/e2e/users.ts
echo '---'
rg -n "updateHashtags\(|updateUsertags\(|hashtags/users|hashtag" packages/backend/test/unit packages/backend/test/e2e -g '!**/node_modules/**'

Repository: misskey-dev/misskey

Length of output: 5451


Hashtag 集計更新の境界ケースをテストで押さえてください
packages/backend/test/e2e/users.ts にはハッシュタグ経由の取得確認はありますが、今回の #incrementHashTag / #decrementHashTag で重要な「同一ユーザーの再追加でカウントが増えない」「存在しないユーザーの削除が no-op になる」ケースまでは見えていません。集計が崩れると影響が大きいので、これらを含むバックエンドテストの追加をお願いします。

🧰 Tools
🪛 ast-grep (0.44.1)

[error] 112-123: Avoid SQL injection
Context: this.db.createQueryRunner('master')
.query(
INSERT into "hashtag"("id", "name", "${totalUserIds}", "${totalUsersCount}", "${localOrRemoteUserIds}", "${localOrRemoteUserCount}") VALUES ($3, $1, ARRAY [$2], 1, ARRAY [$2], 1) ON CONFLICT ("name") DO UPDATE SET "${totalUserIds}" = ${appendUserIdIfNotExists(totalUserIds)}, "${totalUsersCount}" = ${incrementCountIfNotExists(totalUserIds, totalUsersCount)}, "${localOrRemoteUserIds}" = ${appendUserIdIfNotExists(localOrRemoteUserIds)}, "${localOrRemoteUserCount}" = ${incrementCountIfNotExists(localOrRemoteUserIds, localOrRemoteUserCount)},
[tag, user.id, this.idService.gen()],
)
Note: [CWE-89] Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection').

(sql-injection-typescript)


[error] 144-153: Avoid SQL injection
Context: this.db.createQueryRunner('master')
.query(
UPDATE "hashtag" SET "${totalUserIds}" = array_remove("${totalUserIds}", $2), "${totalUsersCount}" = ${decrementIfExists(totalUserIds, totalUsersCount)}, "${localOrRemoteUserIds}" = array_remove("${localOrRemoteUserIds}", $2), "${localOrRemoteUserCount}" = ${decrementIfExists(localOrRemoteUserIds, localOrRemoteUserCount)} WHERE "name" = $1,
[tag, user.id],
)
Note: [CWE-89] Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection').

(sql-injection-typescript)

🪛 OpenGrep (1.25.0)

[ERROR] 113-124: SQL query built via string concatenation or template literal passed to query()/execute(). Use parameterized queries instead.

(coderabbit.sql-injection.raw-query-concat-js)


[ERROR] 145-154: SQL query built via string concatenation or template literal passed to query()/execute(). Use parameterized queries instead.

(coderabbit.sql-injection.raw-query-concat-js)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backend/src/core/HashtagService.ts` around lines 103 - 159, Extend
the backend hashtag tests covering the `#incrementHashTag` and `#decrementHashTag`
flows to verify that re-adding the same user does not increase user counts or
duplicate IDs, and removing a user who is not recorded leaves the hashtag
aggregation unchanged. Reuse the existing hashtag retrieval/setup helpers and
assert both stored user IDs and counts.

Source: Coding guidelines

@syuilo

syuilo commented Jul 25, 2026

Copy link
Copy Markdown
Member

テストが失敗しとるわね
image

@anatawa12

Copy link
Copy Markdown
Member Author

queryrunnerのクリーンアップ忘れがありました。すみません。

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/backend/src/core/HashtagService.ts (1)

145-154: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

QueryRunner を await using で解放してください。

削除側で createQueryRunner('master') したまま query() しており、接続が返却されません。更新が重なると接続プールを圧迫するため、増分側と同様に await using に切り替えてください。

修正案
-		await this.db.createQueryRunner('master')
-			.query(
+		await using runner = this.db.createQueryRunner('master');
+		await runner.query(
 				`UPDATE "hashtag"
 				 SET "${totalUserIds}"           = array_remove("${totalUserIds}", $2),
 				     "${totalUsersCount}"        = ${decrementIfExists(totalUserIds, totalUsersCount)},
 				     "${localOrRemoteUserIds}"   = array_remove("${localOrRemoteUserIds}", $2),
 				     "${localOrRemoteUserCount}" = ${decrementIfExists(localOrRemoteUserIds, localOrRemoteUserCount)}
 				 WHERE "name" = $1`,
 				[tag, user.id],
-			);
+		);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backend/src/core/HashtagService.ts` around lines 145 - 154, Update
the delete-side query execution around createQueryRunner('master') to declare
the QueryRunner with await using, matching the increment-side pattern, so it is
automatically released after query() completes. Keep the existing SQL and
parameters unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/backend/src/core/HashtagService.ts`:
- Around line 145-154: Update the delete-side query execution around
createQueryRunner('master') to declare the QueryRunner with await using,
matching the increment-side pattern, so it is automatically released after
query() completes. Keep the existing SQL and parameters unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 13eac8aa-ffdb-44b8-95c2-28f5c42e4416

📥 Commits

Reviewing files that changed from the base of the PR and between 0c34da5 and 24e479b.

📒 Files selected for processing (1)
  • packages/backend/src/core/HashtagService.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/backend/src/core/HashtagService.ts`:
- Around line 145-154: Await the `runner.query()` call in the hashtag update
flow so the database update completes before the `await using` query runner is
released and any failure propagates to the caller, matching the corresponding
increment path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b9ed7c85-a0ce-414f-b87f-4d2e921d35ee

📥 Commits

Reviewing files that changed from the base of the PR and between 24e479b and 8855f71.

📒 Files selected for processing (1)
  • packages/backend/src/core/HashtagService.ts

Comment thread packages/backend/src/core/HashtagService.ts Outdated
@anatawa12

Copy link
Copy Markdown
Member Author

await usingって22だとダメなのか...

@syuilo
syuilo merged commit 982d490 into misskey-dev:develop Jul 25, 2026
36 of 38 checks passed
@syuilo

syuilo commented Jul 25, 2026

Copy link
Copy Markdown
Member

🙏🏻

@github-project-automation github-project-automation Bot moved this from Todo to Done in [実験中] 管理用 Jul 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

packages/backend Server side specific issue/PR size:L This PR changes 100-499 lines, ignoring generated files.

Projects

Development

Successfully merging this pull request may close these issues.

2 participants