insert reactions to scylla
This commit is contained in:
parent
03da4eb57a
commit
ee6795ac9d
6 changed files with 113 additions and 48 deletions
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 (?, ?, ?, ?, ?, ?)`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue