fix: pins and cascading delete

This commit is contained in:
Namekuji 2023-08-14 02:49:59 -04:00
parent 37215ae0fa
commit c9dd562145
No known key found for this signature in database
GPG key ID: 1D62332C07FBA532
9 changed files with 109 additions and 60 deletions

View file

@ -9,8 +9,8 @@ DROP MATERIALIZED VIEW IF EXISTS global_timeline;
DROP MATERIALIZED VIEW IF EXISTS note_by_renote_id_and_user_id; DROP MATERIALIZED VIEW IF EXISTS note_by_renote_id_and_user_id;
DROP MATERIALIZED VIEW IF EXISTS note_by_renote_id; DROP MATERIALIZED VIEW IF EXISTS note_by_renote_id;
DROP MATERIALIZED VIEW IF EXISTS note_by_user_id; DROP MATERIALIZED VIEW IF EXISTS note_by_user_id;
DROP MATERIALIZED VIEW IF EXISTS note_by_id;
DROP INDEX IF EXISTS note_by_reply_id; DROP INDEX IF EXISTS note_by_reply_id;
DROP INDEX IF EXISTS note_by_id;
DROP INDEX IF EXISTS note_by_uri; DROP INDEX IF EXISTS note_by_uri;
DROP INDEX IF EXISTS note_by_url; DROP INDEX IF EXISTS note_by_url;
DROP TABLE IF EXISTS note; DROP TABLE IF EXISTS note;

View file

@ -74,9 +74,19 @@ CREATE TABLE IF NOT EXISTS note ( -- Store all posts
CREATE INDEX IF NOT EXISTS note_by_uri ON note ("uri"); CREATE INDEX IF NOT EXISTS note_by_uri ON note ("uri");
CREATE INDEX IF NOT EXISTS note_by_url ON note ("url"); CREATE INDEX IF NOT EXISTS note_by_url ON note ("url");
CREATE INDEX IF NOT EXISTS note_by_id ON note ("id");
CREATE INDEX IF NOT EXISTS note_by_reply_id ON note ("replyId"); CREATE INDEX IF NOT EXISTS note_by_reply_id ON note ("replyId");
CREATE MATERIALIZED VIEW IF NOT EXISTS note_by_id AS
SELECT * FROM note
WHERE "id" IS NOT NULL
AND "createdAt" IS NOT NULL
AND "createdAtDate" IS NOT NULL
AND "userId" IS NOT NULL
AND "userHost" IS NOT NULL
AND "visibility" IS NOT NULL
PRIMARY KEY ("id", "createdAt", "createdAtDate", "userId", "userHost", "visibility")
WITH CLUSTERING ORDER BY ("createdAt" DESC);
CREATE MATERIALIZED VIEW IF NOT EXISTS note_by_user_id AS CREATE MATERIALIZED VIEW IF NOT EXISTS note_by_user_id AS
SELECT * FROM note SELECT * FROM note
WHERE "userId" IS NOT NULL WHERE "userId" IS NOT NULL

View file

@ -45,12 +45,13 @@ export const scyllaQueries = {
byDate: `SELECT * FROM note WHERE "createdAtDate" = ?`, byDate: `SELECT * FROM note WHERE "createdAtDate" = ?`,
byUri: `SELECT * FROM note WHERE "uri" = ?`, byUri: `SELECT * FROM note WHERE "uri" = ?`,
byUrl: `SELECT * FROM note WHERE "url" = ?`, byUrl: `SELECT * FROM note WHERE "url" = ?`,
byId: `SELECT * FROM note WHERE "id" = ?`, byId: `SELECT * FROM note_by_id WHERE "id" = ?`,
byIds: `SELECT * FROM note_by_id WHERE "id" IN ?`,
byUserId: `SELECT * FROM note_by_user_id WHERE "userId" = ?`, byUserId: `SELECT * FROM note_by_user_id WHERE "userId" = ?`,
byRenoteId: `SELECT * FROM note_by_renote_id WHERE "renoteId" = ?`, byRenoteId: `SELECT * FROM note_by_renote_id WHERE "renoteId" = ?`,
byReplyId: `SELECT * FROM note WHERE "replyId" = ?` byReplyId: `SELECT * FROM note WHERE "replyId" = ?`
}, },
delete: `DELETE FROM note WHERE "createdAtDate" = ? AND "createdAt" = ? AND "userId" = ? AND "userHost" = ? AND "visibility" = ?`, delete: `DELETE FROM note WHERE ("createdAtDate", "createdAt", "userId", "userHost", "visibility") IN ?`,
update: { update: {
renoteCount: `UPDATE note SET renoteCount: `UPDATE note SET
"renoteCount" = ?, "renoteCount" = ?,
@ -113,7 +114,7 @@ export const scyllaQueries = {
byUserAndDate: `SELECT * FROM home_timeline WHERE "feedUserId" = ? AND "createdAtDate" = ?`, byUserAndDate: `SELECT * FROM home_timeline WHERE "feedUserId" = ? AND "createdAtDate" = ?`,
byId: `SELECT * FROM home_timeline WHERE "id" = ?`, byId: `SELECT * FROM home_timeline WHERE "id" = ?`,
}, },
delete: `DELETE FROM home_timeline WHERE "feedUserId" = ? AND "createdAtDate" = ? AND "createdAt" = ? AND "userId" = ?`, delete: `DELETE FROM home_timeline WHERE ("feedUserId", "createdAtDate", "createdAt", "userId") IN ?`,
update: { update: {
renoteCount: `UPDATE home_timeline SET renoteCount: `UPDATE home_timeline SET
"renoteCount" = ?, "renoteCount" = ?,

View file

@ -40,7 +40,7 @@ import { Signin } from "@/models/entities/signin.js";
import { AuthSession } from "@/models/entities/auth-session.js"; import { AuthSession } from "@/models/entities/auth-session.js";
import { FollowRequest } from "@/models/entities/follow-request.js"; import { FollowRequest } from "@/models/entities/follow-request.js";
import { Emoji } from "@/models/entities/emoji.js"; import { Emoji } from "@/models/entities/emoji.js";
import { UserNotePining } from "@/models/entities/user-note-pining.js"; import { UserNotePining, UserNotePiningScylla } from "@/models/entities/user-note-pining.js";
import { Poll } from "@/models/entities/poll.js"; import { Poll } from "@/models/entities/poll.js";
import { UserKeypair } from "@/models/entities/user-keypair.js"; import { UserKeypair } from "@/models/entities/user-keypair.js";
import { UserPublickey } from "@/models/entities/user-publickey.js"; import { UserPublickey } from "@/models/entities/user-publickey.js";
@ -74,7 +74,6 @@ import { UserIp } from "@/models/entities/user-ip.js";
import { NoteEdit } from "@/models/entities/note-edit.js"; import { NoteEdit } from "@/models/entities/note-edit.js";
import { entities as charts } from "@/services/chart/entities.js"; import { entities as charts } from "@/services/chart/entities.js";
import { envOption } from "../env.js";
import { dbLogger } from "./logger.js"; import { dbLogger } from "./logger.js";
import { redisClient } from "./redis.js"; import { redisClient } from "./redis.js";
import { nativeInitDatabase } from "native-utils/built/index.js"; import { nativeInitDatabase } from "native-utils/built/index.js";
@ -131,7 +130,7 @@ export const entities = [
UserGroup, UserGroup,
UserGroupJoining, UserGroupJoining,
UserGroupInvitation, UserGroupInvitation,
UserNotePining, config.scylla ? UserNotePiningScylla : UserNotePining,
UserSecurityKey, UserSecurityKey,
UsedUsername, UsedUsername,
AttestationChallenge, AttestationChallenge,

View file

@ -10,9 +10,8 @@ import { Note } from "./note.js";
import { User } from "./user.js"; import { User } from "./user.js";
import { id } from "../id.js"; import { id } from "../id.js";
@Entity()
@Index(["userId", "noteId"], { unique: true }) @Index(["userId", "noteId"], { unique: true })
export class UserNotePining { class UserNotePiningBase {
@PrimaryColumn(id()) @PrimaryColumn(id())
public id: string; public id: string;
@ -33,10 +32,16 @@ export class UserNotePining {
@Column(id()) @Column(id())
public noteId: Note["id"]; public noteId: Note["id"];
}
@Entity()
export class UserNotePining extends UserNotePiningBase {
@ManyToOne((type) => Note, { @ManyToOne((type) => Note, {
onDelete: "CASCADE", onDelete: "CASCADE",
}) })
@JoinColumn() @JoinColumn()
public note: Note | null; public note: Note | null;
} }
@Entity({ name: "user_note_pining" })
export class UserNotePiningScylla extends UserNotePiningBase {}

View file

@ -17,7 +17,7 @@ import { NoteRepository } from "./repositories/note.js";
import { DriveFileRepository } from "./repositories/drive-file.js"; import { DriveFileRepository } from "./repositories/drive-file.js";
import { DriveFolderRepository } from "./repositories/drive-folder.js"; import { DriveFolderRepository } from "./repositories/drive-folder.js";
import { AccessToken } from "./entities/access-token.js"; import { AccessToken } from "./entities/access-token.js";
import { UserNotePining } from "./entities/user-note-pining.js"; import { UserNotePining, UserNotePiningScylla } from "./entities/user-note-pining.js";
import { SigninRepository } from "./repositories/signin.js"; import { SigninRepository } from "./repositories/signin.js";
import { MessagingMessageRepository } from "./repositories/messaging-message.js"; import { MessagingMessageRepository } from "./repositories/messaging-message.js";
import { UserListRepository } from "./repositories/user-list.js"; import { UserListRepository } from "./repositories/user-list.js";
@ -67,6 +67,7 @@ import { InstanceRepository } from "./repositories/instance.js";
import { Webhook } from "./entities/webhook.js"; import { Webhook } from "./entities/webhook.js";
import { UserIp } from "./entities/user-ip.js"; import { UserIp } from "./entities/user-ip.js";
import { NoteEdit } from "./entities/note-edit.js"; import { NoteEdit } from "./entities/note-edit.js";
import config from "@/config/index.js";
export const Announcements = db.getRepository(Announcement); export const Announcements = db.getRepository(Announcement);
export const AnnouncementReads = db.getRepository(AnnouncementRead); export const AnnouncementReads = db.getRepository(AnnouncementRead);
@ -92,7 +93,7 @@ export const UserListJoinings = db.getRepository(UserListJoining);
export const UserGroups = UserGroupRepository; export const UserGroups = UserGroupRepository;
export const UserGroupJoinings = db.getRepository(UserGroupJoining); export const UserGroupJoinings = db.getRepository(UserGroupJoining);
export const UserGroupInvitations = UserGroupInvitationRepository; export const UserGroupInvitations = UserGroupInvitationRepository;
export const UserNotePinings = db.getRepository(UserNotePining); export const UserNotePinings = db.getRepository(config.scylla ? UserNotePiningScylla : UserNotePining);
export const UserIps = db.getRepository(UserIp); export const UserIps = db.getRepository(UserIp);
export const UsedUsernames = db.getRepository(UsedUsername); export const UsedUsernames = db.getRepository(UsedUsername);
export const Followings = FollowingRepository; export const Followings = FollowingRepository;

View file

@ -38,6 +38,9 @@ import {
} from "../index.js"; } from "../index.js";
import type { Instance } from "../entities/instance.js"; import type { Instance } from "../entities/instance.js";
import { userDenormalizedCache } from "@/services/user-cache.js"; import { userDenormalizedCache } from "@/services/user-cache.js";
import { parseScyllaNote, prepared, scyllaClient } from "@/db/scylla.js";
import type { UserNotePining } from "@/models/entities/user-note-pining.js";
import type { Note } from "@/models/entities/note.js";
const userInstanceCache = new Cache<Instance | null>( const userInstanceCache = new Cache<Instance | null>(
"userInstance", "userInstance",
@ -404,13 +407,29 @@ export const UserRepository = db.getRepository(User).extend({
meId && !isMe && opts.detail meId && !isMe && opts.detail
? await this.getRelation(meId, user.id) ? await this.getRelation(meId, user.id)
: null; : null;
const pins = opts.detail
? await UserNotePinings.createQueryBuilder("pin") let pinnedNoteIds: UserNotePining["noteId"][] = [];
.where("pin.userId = :userId", { userId: user.id }) let pinnedNotes: Note[] = [];
.innerJoinAndSelect("pin.note", "note") if (opts.detail) {
.orderBy("pin.id", "DESC") pinnedNoteIds = await UserNotePinings.find({
.getMany() select: ["noteId"],
: []; where: {
userId: user.id,
},
}).then((notes) => notes.map(({ noteId }) => noteId));
if (scyllaClient) {
const result = await scyllaClient.execute(
prepared.note.select.byIds,
[pinnedNoteIds],
{ prepare: true },
);
pinnedNotes = result.rows.map(parseScyllaNote);
} else {
pinnedNotes = await Notes.findBy({ id: In(pinnedNoteIds) });
}
}
const profile = opts.detail const profile = opts.detail
? await UserProfiles.findOneByOrFail({ userId: user.id }) ? await UserProfiles.findOneByOrFail({ userId: user.id })
: null; : null;
@ -506,14 +525,11 @@ export const UserRepository = db.getRepository(User).extend({
followersCount: followersCount || 0, followersCount: followersCount || 0,
followingCount: followingCount || 0, followingCount: followingCount || 0,
notesCount: user.notesCount, notesCount: user.notesCount,
pinnedNoteIds: pins.map((pin) => pin.noteId), pinnedNoteIds,
pinnedNotes: Notes.packMany( pinnedNotes: Notes.packMany(pinnedNotes, me, {
pins.map((pin) => pin.note!), detail: true,
me, scyllaNote: !!scyllaClient,
{ }),
detail: true,
},
),
pinnedPageId: profile!.pinnedPageId, pinnedPageId: profile!.pinnedPageId,
pinnedPage: profile!.pinnedPageId pinnedPage: profile!.pinnedPageId
? Pages.pack(profile!.pinnedPageId, me) ? Pages.pack(profile!.pinnedPageId, me)

View file

@ -4,6 +4,7 @@ import define from "../../define.js";
import { getNote } from "../../common/getters.js"; import { getNote } from "../../common/getters.js";
import { ApiError } from "../../error.js"; import { ApiError } from "../../error.js";
import { SECOND, HOUR } from "@/const.js"; import { SECOND, HOUR } from "@/const.js";
import { userByIdCache } from "@/services/user-cache.js";
export const meta = { export const meta = {
tags: ["notes"], tags: ["notes"],
@ -52,6 +53,10 @@ export default define(meta, paramDef, async (ps, user) => {
throw new ApiError(meta.errors.accessDenied); throw new ApiError(meta.errors.accessDenied);
} }
// この操作を行うのが投稿者とは限らない(例えばモデレーター)ため await deleteNote(
await deleteNote(await Users.findOneByOrFail({ id: note.userId }), note); await userByIdCache.fetch(note.userId, () =>
Users.findOneByOrFail({ id: note.userId }),
),
note,
);
}); });

View file

@ -28,6 +28,7 @@ import {
prepared, prepared,
scyllaClient, scyllaClient,
} from "@/db/scylla.js"; } from "@/db/scylla.js";
import { LocalFollowersCache } from "@/misc/cache.js";
/** /**
* Delete a post * Delete a post
@ -143,6 +144,9 @@ export default async function (
} }
} }
const cascadingNotes =
scyllaClient || !quiet ? await findCascadingNotes(note) : [];
if (!quiet) { if (!quiet) {
publishNoteStream(note.id, "deleted", { publishNoteStream(note.id, "deleted", {
deletedAt: deletedAt, deletedAt: deletedAt,
@ -192,10 +196,10 @@ export default async function (
} }
// also deliever delete activity to cascaded notes // also deliever delete activity to cascaded notes
const cascadingNotes = (await findCascadingNotes(note)).filter( const cascadingLocalNotes = cascadingNotes.filter(
(note) => !note.localOnly, (note) => note.userHost === null && !note.localOnly,
); // filter out local-only notes ); // filter out local-only notes
for (const cascadingNote of cascadingNotes) { for (const cascadingNote of cascadingLocalNotes) {
if (!cascadingNote.user) continue; if (!cascadingNote.user) continue;
if (!Users.isLocalUser(cascadingNote.user)) continue; if (!Users.isLocalUser(cascadingNote.user)) continue;
const content = renderActivity( const content = renderActivity(
@ -221,34 +225,42 @@ export default async function (
} }
if (scyllaClient) { if (scyllaClient) {
const date = new Date(note.createdAt.getTime()); const notesToDelete = [note, ...cascadingNotes];
await scyllaClient.execute(
prepared.note.delete,
[date, date, note.userId, note.userHost ?? "local", note.visibility],
{
prepare: true,
},
);
const homeTimelines = await scyllaClient const noteDeleteParams = notesToDelete.map((n) => {
.execute(prepared.homeTimeline.select.byId, [note.id], { prepare: true }) const date = new Date(n.createdAt.getTime());
.then((result) => result.rows.map(parseHomeTimeline)); return [date, date, n.userId, n.userHost ?? "local", n.visibility];
for (const timeline of homeTimelines) { });
// No need to wait await scyllaClient.execute(prepared.note.delete, noteDeleteParams, {
scyllaClient.execute(prepared.homeTimeline.delete, [ prepare: true,
timeline.feedUserId, });
timeline.createdAtDate,
timeline.createdAt, const noteUserIds = new Set(notesToDelete.map((n) => n.userId));
timeline.userId, const followers: string[] = [];
]); for (const id of noteUserIds) {
const list = await LocalFollowersCache.init(id).then((cache) =>
cache.getAll(),
);
followers.push(...list);
} }
const localFollowers = new Set(followers);
const homeDeleteParams = notesToDelete.map((n) => {
const tuples: [string, Date, Date, string][] = [];
for (const feedUserId of localFollowers) {
tuples.push([feedUserId, n.createdAt, n.createdAt, n.userId]);
}
return tuples;
});
await scyllaClient.execute(prepared.homeTimeline.delete, homeDeleteParams, {
prepare: true,
});
} else {
await Notes.delete({
id: note.id,
userId: user.id,
});
} }
await Notes.delete({
id: note.id,
userId: user.id,
});
if (meilisearch) { if (meilisearch) {
await meilisearch.deleteNotes(note.id); await meilisearch.deleteNotes(note.id);
} }
@ -293,14 +305,14 @@ async function findCascadingNotes(note: Note) {
notes = await query.getMany(); notes = await query.getMany();
} }
for (const reply of notes) { for (const note of notes) {
cascadingNotes.push(reply); cascadingNotes.push(note);
await recursive(reply.id); await recursive(note.id);
} }
}; };
await recursive(note.id); await recursive(note.id);
return cascadingNotes.filter((note) => note.userHost === null); // filter out non-local users return cascadingNotes;
} }
async function getMentionedRemoteUsers(note: Note) { async function getMentionedRemoteUsers(note: Note) {