insert reactions to scylla

This commit is contained in:
Namekuji 2023-07-24 07:52:35 -04:00
parent 03da4eb57a
commit ee6795ac9d
No known key found for this signature in database
GPG key ID: 1D62332C07FBA532
6 changed files with 113 additions and 48 deletions

View file

@ -1,3 +1,5 @@
DROP MATERIALIZED VIEW IF EXISTS reaction_by_user_id;
DROP INDEX IF EXISTS reaction_by_id;
DROP TABLE IF EXISTS reaction; DROP TABLE IF EXISTS reaction;
DROP MATERIALIZED VIEW IF EXISTS note_by_user_id; DROP MATERIALIZED VIEW IF EXISTS note_by_user_id;
DROP INDEX IF EXISTS note_by_id; DROP INDEX IF EXISTS note_by_id;

View file

@ -57,7 +57,7 @@ CREATE TABLE IF NOT EXISTS note ( -- Models timeline
"reactionEmojis" map<text, frozen<emoji>>, "reactionEmojis" map<text, frozen<emoji>>,
"noteEdit" set<frozen<note_edit_history>>, -- Edit History "noteEdit" set<frozen<note_edit_history>>, -- Edit History
"updatedAt" timestamp, "updatedAt" timestamp,
PRIMARY KEY ("createdAtDate", "createdAt") PRIMARY KEY ("createdAtDate", "createdAt", "id")
) WITH CLUSTERING ORDER BY ("createdAt" DESC); ) WITH CLUSTERING ORDER BY ("createdAt" DESC);
CREATE INDEX IF NOT EXISTS note_by_id ON note (id); CREATE INDEX IF NOT EXISTS note_by_id ON note (id);
@ -69,13 +69,26 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS note_by_userid AS
WHERE "userId" IS NOT NULL WHERE "userId" IS NOT NULL
AND "createdAt" IS NOT NULL AND "createdAt" IS NOT NULL
AND "createdAtDate" IS NOT NULL AND "createdAtDate" IS NOT NULL
PRIMARY KEY ("userId", "createdAt", "createdAtDate") AND "id" IS NOT NULL
PRIMARY KEY ("userId", "createdAt", "createdAtDate", "id")
WITH CLUSTERING ORDER BY ("createdAt" DESC); WITH CLUSTERING ORDER BY ("createdAt" DESC);
CREATE TABLE IF NOT EXISTS reaction ( CREATE TABLE IF NOT EXISTS reaction (
"id" text,
"noteId" ascii, "noteId" ascii,
"createdAt" timestamp,
"userId" ascii, "userId" ascii,
"reaction" frozen<emoji>, "reaction" text,
PRIMARY KEY ("noteId", "createdAt", "userId") "emoji" frozen<emoji>,
) WITH CLUSTERING ORDER BY ("createdAt" DESC); "createdAt" timestamp,
PRIMARY KEY ("noteId", "userId")
);
CREATE INDEX IF NOT EXISTS reaction_by_id ON reaction (id);
CREATE MATERIALIZED VIEW IF NOT EXISTS reaction_by_userid AS
SELECT * FROM reaction
WHERE "userId" IS NOT NULL
AND "createdAt" IS NOT NULL
AND "noteId" IS NOT NULL
PRIMARY KEY ("userId", "createdAt", "noteId")
WITH CLUSTERING ORDER BY ("createdAt" DESC);

View file

@ -53,18 +53,21 @@ export const prepared = {
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
select: { select: {
byDate: `SELECT * FROM note WHERE "createdAtDate" = ? AND "createdAt" < ?`, byDate: `SELECT * FROM note WHERE "createdAtDate" = ? AND "createdAt" < ?`,
byId: "SELECT * FROM note WHERE id IN ?", byId: `SELECT * FROM note WHERE "id" IN ?`,
byUri: "SELECT * FROM note WHERE uri = ?", byUri: `SELECT * FROM note WHERE "uri" = ?`,
byUrl: "SELECT * FROM note WHERE url = ?", byUrl: `SELECT * FROM note WHERE "url" = ?`,
byUserId: `SELECT * FROM note_by_userid WHERE "userId" = ? AND "createdAt" < ?`, byUserId: `SELECT * FROM note_by_userid WHERE "userId" = ? AND "createdAt" < ?`,
}, },
delete: "DELETE FROM note WHERE id IN ?", delete: `DELETE FROM note WHERE "createdAtDate" = ? AND "createdAt" = ?`,
update: { update: {
renoteCount: `UPDATE note SET "renoteCount" = ?, "score" = ? WHERE "id" = ? IF EXISTS`, renoteCount: `UPDATE note SET "renoteCount" = ?, "score" = ? WHERE "createdAtDate" = ? AND "createdAt" = ? IF EXISTS`,
} reactions: `UPDATE note SET "reactions" = ?, "score" = ? WHERE "createdAtDate" = ? AND "createdAt" = ? IF EXISTS`,
},
}, },
reaction: { reaction: {
insert: `INSERT INTO reaction ("noteId", "createdAt", "userId", "reaction") VALUES (?, ?, ?, ?)`, insert: `INSERT INTO reaction
("id", "noteId", "userId", "reaction", "emoji", "createdAt")
VALUES (?, ?, ?, ?, ?, ?)`,
}, },
}; };

View file

@ -9,7 +9,7 @@ import config from "@/config/index.js";
import { query } from "@/prelude/url.js"; import { query } from "@/prelude/url.js";
import { redisClient } from "@/db/redis.js"; import { redisClient } from "@/db/redis.js";
const cache = new Cache<Emoji | null>("populateEmojis", 60 * 60 * 12); export const EmojiCache = new Cache<Emoji | null>("populateEmojis", 60 * 60 * 12);
/** /**
* *
@ -72,11 +72,11 @@ export async function populateEmoji(
})) || null; })) || null;
const cacheKey = `${name} ${host}`; const cacheKey = `${name} ${host}`;
let emoji = await cache.fetch(cacheKey, queryOrNull); let emoji = await EmojiCache.fetch(cacheKey, queryOrNull);
if (emoji && !(emoji.width && emoji.height)) { if (emoji && !(emoji.width && emoji.height)) {
emoji = await queryOrNull(); emoji = await queryOrNull();
await cache.set(cacheKey, emoji); await EmojiCache.set(cacheKey, emoji);
} }
if (emoji == null) return null; if (emoji == null) return null;
@ -151,7 +151,7 @@ export async function prefetchEmojis(
emojis: { name: string; host: string | null }[], emojis: { name: string; host: string | null }[],
): Promise<void> { ): Promise<void> {
const notCachedEmojis = emojis.filter( const notCachedEmojis = emojis.filter(
async (emoji) => !(await cache.get(`${emoji.name} ${emoji.host}`)), async (emoji) => !(await EmojiCache.get(`${emoji.name} ${emoji.host}`)),
); );
const emojisQuery: any[] = []; const emojisQuery: any[] = [];
const hosts = new Set(notCachedEmojis.map((e) => e.host)); const hosts = new Set(notCachedEmojis.map((e) => e.host));
@ -172,7 +172,7 @@ export async function prefetchEmojis(
: []; : [];
const trans = redisClient.multi(); const trans = redisClient.multi();
for (const emoji of _emojis) { for (const emoji of _emojis) {
cache.set(`${emoji.name} ${emoji.host}`, emoji, trans); EmojiCache.set(`${emoji.name} ${emoji.host}`, emoji, trans);
} }
await trans.exec(); await trans.exec();
} }

View file

@ -3,6 +3,8 @@ import { fetchMeta } from "./fetch-meta.js";
import { Emojis } from "@/models/index.js"; import { Emojis } from "@/models/index.js";
import { toPunyNullable } from "./convert-host.js"; import { toPunyNullable } from "./convert-host.js";
import { IsNull } from "typeorm"; import { IsNull } from "typeorm";
import { EmojiCache } from "@/misc/populate-emojis.js";
import type { Emoji } from "@/models/entities/emoji.js";
const legacies = new Map([ const legacies = new Map([
["like", "👍"], ["like", "👍"],
@ -18,9 +20,24 @@ const legacies = new Map([
["star", "⭐"], ["star", "⭐"],
]); ]);
export async function getFallbackReaction() { async function getFallbackReaction() {
const meta = await fetchMeta(); const meta = await fetchMeta();
return meta.defaultReaction; const name = meta.defaultReaction;
const match = emojiRegex.exec(name);
if (match) {
const unicode = match[0];
return { name: unicode, emoji: null };
}
const emoji = await EmojiCache.fetch(`${name} ${null}`, () =>
Emojis.findOneBy({
name,
host: IsNull(),
}),
);
return { name, emoji };
} }
export function convertLegacyReactions(reactions: Record<string, number>) { export function convertLegacyReactions(reactions: Record<string, number>) {
@ -38,7 +55,7 @@ export function convertLegacyReactions(reactions: Record<string, number>) {
decodedReactions.set(reaction, decodedReaction); decodedReactions.set(reaction, decodedReaction);
} }
let emoji = legacies.get(decodedReaction.reaction); const emoji = legacies.get(decodedReaction.reaction);
if (emoji) { if (emoji) {
_reactions.set(emoji, (_reactions.get(emoji) || 0) + reactions[reaction]); _reactions.set(emoji, (_reactions.get(emoji) || 0) + reactions[reaction]);
} else { } else {
@ -61,31 +78,36 @@ export function convertLegacyReactions(reactions: Record<string, number>) {
export async function toDbReaction( export async function toDbReaction(
reaction?: string | null, reaction?: string | null,
reacterHost?: string | null, reacterHost?: string | null,
): Promise<string> { ): Promise<{ name: string; emoji: Emoji | null }> {
if (!reaction) return await getFallbackReaction(); if (!reaction) return await getFallbackReaction();
reacterHost = toPunyNullable(reacterHost); const _reacterHost = toPunyNullable(reacterHost);
// Convert string-type reactions to unicode // Convert string-type reactions to unicode
const emoji = legacies.get(reaction) || (reaction === "♥️" ? "❤️" : null); const emoji = legacies.get(reaction) || (reaction === "♥️" ? "❤️" : null);
if (emoji) return emoji; if (emoji) return { name: emoji, emoji: null };
// Allow unicode reactions // Allow unicode reactions
const match = emojiRegex.exec(reaction); const match = emojiRegex.exec(reaction);
if (match) { if (match) {
const unicode = match[0]; const unicode = match[0];
return unicode; return { name: unicode, emoji: null };
} }
const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/); const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/);
if (custom) { if (custom) {
const name = custom[1]; const name = custom[1];
const emoji = await Emojis.findOneBy({ const emoji = await EmojiCache.fetch(`${name} ${_reacterHost}`, () =>
host: reacterHost || IsNull(), Emojis.findOneBy({
name, name,
}); host: _reacterHost || IsNull(),
}),
);
if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; if (emoji) {
const emojiName = _reacterHost ? `:${name}@${_reacterHost}:` : `:${name}:`;
return { name: emojiName, emoji };
}
} }
return await getFallbackReaction(); return await getFallbackReaction();
@ -93,7 +115,7 @@ export async function toDbReaction(
type DecodedReaction = { type DecodedReaction = {
/** /**
* (Unicode Emoji or ':name@hostname' or ':name@.') * (Unicode Emoji or ':name@hostname' or ':name@.:')
*/ */
reaction: string; reaction: string;

View file

@ -21,6 +21,7 @@ import deleteReaction from "./delete.js";
import { isDuplicateKeyValueError } from "@/misc/is-duplicate-key-value-error.js"; import { isDuplicateKeyValueError } from "@/misc/is-duplicate-key-value-error.js";
import type { NoteReaction } from "@/models/entities/note-reaction.js"; import type { NoteReaction } from "@/models/entities/note-reaction.js";
import { IdentifiableError } from "@/misc/identifiable-error.js"; import { IdentifiableError } from "@/misc/identifiable-error.js";
import { prepared, scyllaClient } from "@/db/scylla.js";
export default async ( export default async (
user: { id: User["id"]; host: User["host"] }, user: { id: User["id"]; host: User["host"] },
@ -46,20 +47,33 @@ export default async (
); );
} }
// TODO: cache // Emoji data will be cached in toDbReaction.
reaction = await toDbReaction(reaction, user.host); const { name: _reaction, emoji: emojiData } = await toDbReaction(
reaction,
user.host,
);
const record: NoteReaction = { const record: NoteReaction = {
id: genId(), id: genId(),
createdAt: new Date(), createdAt: new Date(),
noteId: note.id, noteId: note.id,
userId: user.id, userId: user.id,
reaction, reaction: _reaction,
}; };
// Create reaction // Create reaction
try { try {
await NoteReactions.insert(record); if (scyllaClient) {
// INSERT to ScyllaDB is upsert, and the primary key of reaction table is ("noteId", "userId").
// Thus, a reaction by the same user will be replaced if exists.
await scyllaClient.execute(
prepared.reaction.insert,
[record.id, record.noteId, record.userId, _reaction, emojiData, record.createdAt],
{ prepare: true },
);
} else {
await NoteReactions.insert(record);
}
} catch (e) { } catch (e) {
if (isDuplicateKeyValueError(e)) { if (isDuplicateKeyValueError(e)) {
const exists = await NoteReactions.findOneByOrFail({ const exists = await NoteReactions.findOneByOrFail({
@ -67,7 +81,7 @@ export default async (
userId: user.id, userId: user.id,
}); });
if (exists.reaction !== reaction) { if (exists.reaction !== _reaction) {
// 別のリアクションがすでにされていたら置き換える // 別のリアクションがすでにされていたら置き換える
await deleteReaction(user, note); await deleteReaction(user, note);
await NoteReactions.insert(record); await NoteReactions.insert(record);
@ -81,20 +95,31 @@ export default async (
} }
// Increment reactions count // Increment reactions count
const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; if (scyllaClient) {
await Notes.createQueryBuilder() const current = Math.max(note.reactions[_reaction] ?? 0, 0);
.update() note.reactions[_reaction] = current + 1;
.set({ const date = new Date(note.createdAt.getTime());
reactions: () => sql, await scyllaClient.execute(
score: () => '"score" + 1', prepared.note.update.reactions,
}) [note.reactions, (note.score ?? 0) + 1, date, date],
.where("id = :id", { id: note.id }) { prepare: true },
.execute(); );
} else {
const sql = `jsonb_set("reactions", '{${_reaction}}', (COALESCE("reactions"->>'${_reaction}', '0')::int + 1)::text::jsonb)`;
await Notes.createQueryBuilder()
.update()
.set({
reactions: () => sql,
score: () => '"score" + 1',
})
.where("id = :id", { id: note.id })
.execute();
}
perUserReactionsChart.update(user, note); perUserReactionsChart.update(user, note);
// カスタム絵文字リアクションだったら絵文字情報も送る // カスタム絵文字リアクションだったら絵文字情報も送る
const decodedReaction = decodeReaction(reaction); const decodedReaction = decodeReaction(_reaction);
const emoji = await Emojis.findOne({ const emoji = await Emojis.findOne({
where: { where: {
@ -124,7 +149,7 @@ export default async (
notifierId: user.id, notifierId: user.id,
note: note, note: note,
noteId: note.id, noteId: note.id,
reaction: reaction, reaction: _reaction,
}); });
} }
@ -138,7 +163,7 @@ export default async (
notifierId: user.id, notifierId: user.id,
note: note, note: note,
noteId: note.id, noteId: note.id,
reaction: reaction, reaction: _reaction,
}); });
} }
}); });