1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-25 16:45:00 +02:00

fix(backend): 同名の新規ハッシュタグが複数同時に作成されたとき、DBで発生した競合を適切にハンドリング (#17640)

* Hashtag Timeline のテストを復活させる

* HashtagService.ts を修正

* fix indent

* refactor

* Update CHANGELOG.md

* fix review
This commit is contained in:
ihoronir
2026-07-02 09:48:26 +09:00
committed by GitHub
parent 174fb434cc
commit 2d67380f2a
3 changed files with 143 additions and 192 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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<void>(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<void>(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<void>(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<void>(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);
});
});
*/
});
});