diff --git a/CHANGELOG.md b/CHANGELOG.md index 02f64274d1..58ba1ffc86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - Enhance: Node.js 22.23.0以降、24.17.0以降、26.4.0以降をサポートするように - Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新 - Fix: `/stats` API のレスポンス型が正しくない問題を修正 +- Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正 ## 2026.6.0 diff --git a/packages/backend/src/core/HashtagService.ts b/packages/backend/src/core/HashtagService.ts index 793bbeecb1..beed786d1b 100644 --- a/packages/backend/src/core/HashtagService.ts +++ b/packages/backend/src/core/HashtagService.ts @@ -5,20 +5,28 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; +import { DataSource } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { MiUser } from '@/models/User.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { IdService } from '@/core/IdService.js'; -import type { MiHashtag } from '@/models/Hashtag.js'; +import { MiHashtag } from '@/models/Hashtag.js'; import type { HashtagsRepository, MiMeta } from '@/models/_.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { FeaturedService } from '@/core/FeaturedService.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import Logger from '../logger.js'; + +const logger = new Logger('hashtag/create'); @Injectable() export class HashtagService { constructor( + @Inject(DI.db) + private db: DataSource, + @Inject(DI.meta) private meta: MiMeta, @@ -60,19 +68,74 @@ export class HashtagService { // TODO: サンプリング this.updateHashtagsRanking(tag, user.id); - const index = await this.hashtagsRepository.findOneBy({ name: tag }); + { + const index = await this.hashtagsRepository.findOneBy({ name: tag }); - if (index == null && !inc) return; + if (index == null && inc) { + try { + if (isUserAttached) { + await this.hashtagsRepository.insert({ + id: this.idService.gen(), + name: tag, + mentionedUserIds: [], + mentionedUsersCount: 0, + mentionedLocalUserIds: [], + mentionedLocalUsersCount: 0, + mentionedRemoteUserIds: [], + mentionedRemoteUsersCount: 0, + attachedUserIds: [user.id], + attachedUsersCount: 1, + attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [], + attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0, + attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [], + attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0, + } as MiHashtag); + } else { + await this.hashtagsRepository.insert({ + id: this.idService.gen(), + name: tag, + mentionedUserIds: [user.id], + mentionedUsersCount: 1, + mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [], + mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0, + mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [], + mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0, + attachedUserIds: [], + attachedUsersCount: 0, + attachedLocalUserIds: [], + attachedLocalUsersCount: 0, + attachedRemoteUserIds: [], + attachedRemoteUsersCount: 0, + } as MiHashtag); + } + return; + } catch (err) { + if (isDuplicateKeyValueError(err)) { + logger.info(`Duplicate insertion detected. Falling back to update. #${tag}`); + } else { + throw err; + } + } + } + } - if (index != null) { - const q = this.hashtagsRepository.createQueryBuilder('tag').update() - .where('name = :name', { name: tag }); + await this.db.transaction(async transactionalEntityManager => { + const transactionalHashtagRepository = transactionalEntityManager + .getRepository(MiHashtag); + + const index = await transactionalHashtagRepository + .createQueryBuilder() + .setLock('pessimistic_write') + .where('name = :name', { name: tag }) + .getOne(); + + if (index == null) return; const set = {} as any; if (isUserAttached) { if (inc) { - // 自分が初めてこのタグを使ったなら + // 自分が初めてこのタグを使ったなら if (!index.attachedUserIds.some(id => id === user.id)) { set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`; set.attachedUsersCount = () => '"attachedUsersCount" + 1'; @@ -117,46 +180,14 @@ export class HashtagService { } if (Object.keys(set).length > 0) { - q.set(set); - q.execute(); + await transactionalHashtagRepository + .createQueryBuilder() + .update() + .where('id = :id', { id: index.id }) + .set(set) + .execute(); } - } else { - if (isUserAttached) { - this.hashtagsRepository.insert({ - id: this.idService.gen(), - name: tag, - mentionedUserIds: [], - mentionedUsersCount: 0, - mentionedLocalUserIds: [], - mentionedLocalUsersCount: 0, - mentionedRemoteUserIds: [], - mentionedRemoteUsersCount: 0, - attachedUserIds: [user.id], - attachedUsersCount: 1, - attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [], - attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0, - attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [], - attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0, - } as MiHashtag); - } else { - this.hashtagsRepository.insert({ - id: this.idService.gen(), - name: tag, - mentionedUserIds: [user.id], - mentionedUsersCount: 1, - mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [], - mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0, - mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [], - mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0, - attachedUserIds: [], - attachedUsersCount: 0, - attachedLocalUserIds: [], - attachedLocalUsersCount: 0, - attachedRemoteUserIds: [], - attachedRemoteUsersCount: 0, - } as MiHashtag); - } - } + }); } @bindThis diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index 18762ac383..a051be6c3c 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -8,9 +8,9 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { describe, beforeAll, test } from 'vitest'; import { WebSocket } from 'ws'; -import { MiFollowing } from '@/models/Following.js'; -import { api, createAppToken, initTestDb, port, post, signup, waitFire } from '../utils.js'; +import { api, connectStream, createAppToken, initTestDb, port, post, signup, waitFire } from '../utils.js'; import type * as misskey from 'misskey-js'; +import { MiFollowing } from '@/models/Following.js'; describe('Streaming', () => { let Followings: any; @@ -744,164 +744,83 @@ describe('Streaming', () => { assert.strictEqual(fired, true); }); - // XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac" - /* describe('Hashtag Timeline', () => { - test('指定したハッシュタグの投稿が流れる', () => new Promise(async done => { - const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { - if (type === 'note') { - assert.deepStrictEqual(body.text, '#foo'); - ws.close(); - done(); - } - }, { - q: [ - ['foo'], - ], - }); + test('指定したハッシュタグの投稿が流れる', async () => { + const fired = await waitFire( + chitose, 'hashtag', + () => api('notes/create', { text: '#foo' }, chitose), + msg => msg.type === 'note' && msg.body.text === '#foo', + { q: [['foo']] }, + ); - post(chitose, { - text: '#foo', - }); - })); + assert.strictEqual(fired, true); + }); - test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise(async done => { - let fooCount = 0; - let barCount = 0; - let fooBarCount = 0; + test('指定したハッシュタグの投稿が流れる (AND)', async () => { + const received: string[] = []; + const ws = await connectStream(chitose, 'hashtag', (msg) => { + if (msg.type === 'note') received.push(msg.body.text); + }, { q: [['foo', 'bar']] }); - const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { - if (type === 'note') { - if (body.text === '#foo') fooCount++; - if (body.text === '#bar') barCount++; - if (body.text === '#foo #bar') fooBarCount++; - } - }, { - q: [ - ['foo', 'bar'], - ], - }); + await Promise.all([ + await api('notes/create', { text: '#foo' }, chitose), + await api('notes/create', { text: '#bar' }, chitose), + await api('notes/create', { text: '#foo #bar' }, chitose), + ]); - post(chitose, { - text: '#foo', - }); + await new Promise(r => setTimeout(r, 1000)); + ws.close(); - post(chitose, { - text: '#bar', - }); + assert.strictEqual(received.includes('#foo'), false); + assert.strictEqual(received.includes('#bar'), false); + assert.strictEqual(received.includes('#foo #bar'), true); + }); - post(chitose, { - text: '#foo #bar', - }); + test('指定したハッシュタグの投稿が流れる (OR)', async () => { + const received: string[] = []; + const ws = await connectStream(chitose, 'hashtag', (msg) => { + if (msg.type === 'note') received.push(msg.body.text); + }, { q: [['foo'], ['bar']] }); - setTimeout(() => { - assert.strictEqual(fooCount, 0); - assert.strictEqual(barCount, 0); - assert.strictEqual(fooBarCount, 1); - ws.close(); - done(); - }, 3000); - })); + await Promise.all([ + await api('notes/create', { text: '#foo' }, chitose), + await api('notes/create', { text: '#bar' }, chitose), + await api('notes/create', { text: '#foo #bar' }, chitose), + await api('notes/create', { text: '#piyo' }, chitose), + ]); - test('指定したハッシュタグの投稿が流れる (OR)', () => new Promise(async done => { - let fooCount = 0; - let barCount = 0; - let fooBarCount = 0; - let piyoCount = 0; + await new Promise(r => setTimeout(r, 1000)); + ws.close(); - const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { - if (type === 'note') { - if (body.text === '#foo') fooCount++; - if (body.text === '#bar') barCount++; - if (body.text === '#foo #bar') fooBarCount++; - if (body.text === '#piyo') piyoCount++; - } - }, { - q: [ - ['foo'], - ['bar'], - ], - }); + assert.strictEqual(received.includes('#foo'), true); + assert.strictEqual(received.includes('#bar'), true); + assert.strictEqual(received.includes('#foo #bar'), true); + assert.strictEqual(received.includes('#piyo'), false); + }); - post(chitose, { - text: '#foo', - }); + test('指定したハッシュタグの投稿が流れる (AND + OR)', async () => { + const received: string[] = []; + const ws = await connectStream(chitose, 'hashtag', (msg) => { + if (msg.type === 'note') received.push(msg.body.text); + }, { q: [['foo', 'bar'], ['piyo']] }); - post(chitose, { - text: '#bar', - }); + await Promise.all([ + api('notes/create', { text: '#foo' }, chitose), + api('notes/create', { text: '#bar' }, chitose), + api('notes/create', { text: '#foo #bar' }, chitose), + api('notes/create', { text: '#piyo' }, chitose), + api('notes/create', { text: '#waaa' }, chitose), + ]); - post(chitose, { - text: '#foo #bar', - }); + await new Promise(r => setTimeout(r, 1000)); + ws.close(); - post(chitose, { - text: '#piyo', - }); - - setTimeout(() => { - assert.strictEqual(fooCount, 1); - assert.strictEqual(barCount, 1); - assert.strictEqual(fooBarCount, 1); - assert.strictEqual(piyoCount, 0); - ws.close(); - done(); - }, 3000); - })); - - test('指定したハッシュタグの投稿が流れる (AND + OR)', () => new Promise(async done => { - let fooCount = 0; - let barCount = 0; - let fooBarCount = 0; - let piyoCount = 0; - let waaaCount = 0; - - const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { - if (type === 'note') { - if (body.text === '#foo') fooCount++; - if (body.text === '#bar') barCount++; - if (body.text === '#foo #bar') fooBarCount++; - if (body.text === '#piyo') piyoCount++; - if (body.text === '#waaa') waaaCount++; - } - }, { - q: [ - ['foo', 'bar'], - ['piyo'], - ], - }); - - post(chitose, { - text: '#foo', - }); - - post(chitose, { - text: '#bar', - }); - - post(chitose, { - text: '#foo #bar', - }); - - post(chitose, { - text: '#piyo', - }); - - post(chitose, { - text: '#waaa', - }); - - setTimeout(() => { - assert.strictEqual(fooCount, 0); - assert.strictEqual(barCount, 0); - assert.strictEqual(fooBarCount, 1); - assert.strictEqual(piyoCount, 1); - assert.strictEqual(waaaCount, 0); - ws.close(); - done(); - }, 3000); - })); + assert.strictEqual(received.includes('#foo'), false); + assert.strictEqual(received.includes('#bar'), false); + assert.strictEqual(received.includes('#foo #bar'), true); + assert.strictEqual(received.includes('#piyo'), true); + assert.strictEqual(received.includes('#waaa'), false); + }); }); - */ }); });