initial commit
This commit is contained in:
@@ -0,0 +1,917 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID} from '~/BrandedTypes';
|
||||
import {
|
||||
BatchBuilder,
|
||||
buildPatchFromData,
|
||||
Db,
|
||||
deleteOneOrMany,
|
||||
executeConditional,
|
||||
executeVersionedUpdate,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
upsertOne,
|
||||
} from '~/database/Cassandra';
|
||||
import type {ChannelMessageBucketRow, ChannelStateRow, MessageRow} from '~/database/CassandraTypes';
|
||||
import {MESSAGE_COLUMNS} from '~/database/CassandraTypes';
|
||||
import {Logger} from '~/Logger';
|
||||
import {Message} from '~/Models';
|
||||
import {
|
||||
AttachmentLookup,
|
||||
ChannelEmptyBuckets,
|
||||
ChannelMessageBuckets,
|
||||
ChannelPins,
|
||||
ChannelState,
|
||||
Messages,
|
||||
MessagesByAuthor,
|
||||
} from '~/Tables';
|
||||
import * as BucketUtils from '~/utils/BucketUtils';
|
||||
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
|
||||
import type {ListMessagesOptions} from '../IMessageRepository';
|
||||
import {BucketScanDirection, scanBucketsWithIndex} from './BucketScanEngine';
|
||||
|
||||
const logger = Logger.child({module: 'MessageDataRepository'});
|
||||
|
||||
const DEFAULT_MESSAGE_LIMIT = 50;
|
||||
const DEFAULT_BUCKET_INDEX_PAGE_SIZE = 200;
|
||||
const DEFAULT_CAS_RETRIES = 8;
|
||||
const LEGACY_BUCKETS_TO_CHECK = [0];
|
||||
|
||||
const FETCH_MESSAGE_BY_CHANNEL_BUCKET_AND_MESSAGE_ID = Messages.select({
|
||||
where: [Messages.where.eq('channel_id'), Messages.where.eq('bucket'), Messages.where.eq('message_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_CHANNEL_STATE = ChannelState.select({
|
||||
where: ChannelState.where.eq('channel_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class MessageDataRepository {
|
||||
async listMessages(
|
||||
channelId: ChannelID,
|
||||
beforeMessageId?: MessageID,
|
||||
limit: number = DEFAULT_MESSAGE_LIMIT,
|
||||
afterMessageId?: MessageID,
|
||||
options?: ListMessagesOptions,
|
||||
): Promise<Array<Message>> {
|
||||
if (limit <= 0) return [];
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
before: beforeMessageId?.toString() ?? null,
|
||||
after: afterMessageId?.toString() ?? null,
|
||||
limit,
|
||||
},
|
||||
'listMessages start',
|
||||
);
|
||||
|
||||
if (beforeMessageId && afterMessageId) {
|
||||
return this.listMessagesBetween(channelId, afterMessageId, beforeMessageId, limit, options);
|
||||
}
|
||||
if (beforeMessageId) {
|
||||
return this.listMessagesBefore(channelId, beforeMessageId, limit, options);
|
||||
}
|
||||
if (afterMessageId) {
|
||||
return this.listMessagesAfter(channelId, afterMessageId, limit, options);
|
||||
}
|
||||
return this.listMessagesLatest(channelId, limit);
|
||||
}
|
||||
|
||||
private makeFetchMessagesBefore(limit: number) {
|
||||
return Messages.select({
|
||||
where: [
|
||||
Messages.where.eq('channel_id'),
|
||||
Messages.where.eq('bucket'),
|
||||
Messages.where.lt('message_id', 'before_message_id'),
|
||||
],
|
||||
orderBy: {col: 'message_id', direction: 'DESC'},
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
private makeFetchMessagesAfterDesc(limit: number) {
|
||||
return Messages.select({
|
||||
where: [
|
||||
Messages.where.eq('channel_id'),
|
||||
Messages.where.eq('bucket'),
|
||||
Messages.where.gt('message_id', 'after_message_id'),
|
||||
],
|
||||
orderBy: {col: 'message_id', direction: 'DESC'},
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
private makeFetchMessagesBetween(limit: number) {
|
||||
return Messages.select({
|
||||
where: [
|
||||
Messages.where.eq('channel_id'),
|
||||
Messages.where.eq('bucket'),
|
||||
Messages.where.gt('message_id', 'after_message_id'),
|
||||
Messages.where.lt('message_id', 'before_message_id'),
|
||||
],
|
||||
orderBy: {col: 'message_id', direction: 'DESC'},
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
private makeFetchMessagesLatestDesc(limit: number) {
|
||||
return Messages.select({
|
||||
where: [Messages.where.eq('channel_id'), Messages.where.eq('bucket')],
|
||||
orderBy: {col: 'message_id', direction: 'DESC'},
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
private makeFetchMessagesAfterAsc(limit: number) {
|
||||
return Messages.select({
|
||||
where: [
|
||||
Messages.where.eq('channel_id'),
|
||||
Messages.where.eq('bucket'),
|
||||
Messages.where.gt('message_id', 'after_message_id'),
|
||||
],
|
||||
orderBy: {col: 'message_id', direction: 'ASC'},
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
private makeFetchMessagesOldestAsc(limit: number) {
|
||||
return Messages.select({
|
||||
where: [Messages.where.eq('channel_id'), Messages.where.eq('bucket')],
|
||||
orderBy: {col: 'message_id', direction: 'ASC'},
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
private async listMessagesLatest(channelId: ChannelID, limit: number): Promise<Array<Message>> {
|
||||
const state = await this.getChannelState(channelId);
|
||||
|
||||
const nowId = SnowflakeUtils.getSnowflake();
|
||||
const maxBucket = BucketUtils.makeBucket(nowId);
|
||||
const minBucket = state?.created_bucket ?? BucketUtils.makeBucket(channelId);
|
||||
|
||||
return this.scanBucketsDescForMessages(channelId, {
|
||||
limit,
|
||||
minBucket,
|
||||
maxBucket,
|
||||
});
|
||||
}
|
||||
|
||||
private async listMessagesBefore(
|
||||
channelId: ChannelID,
|
||||
before: MessageID,
|
||||
limit: number,
|
||||
options?: ListMessagesOptions,
|
||||
): Promise<Array<Message>> {
|
||||
const state = await this.getChannelState(channelId);
|
||||
|
||||
const maxBucket = BucketUtils.makeBucket(before);
|
||||
const minBucket = state?.created_bucket ?? BucketUtils.makeBucket(channelId);
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
before: before.toString(),
|
||||
limit,
|
||||
maxBucket,
|
||||
minBucket,
|
||||
stateCreatedBucket: state?.created_bucket ?? null,
|
||||
restrictToBeforeBucket: options?.restrictToBeforeBucket ?? null,
|
||||
},
|
||||
'listMessagesBefore: computed bucket range',
|
||||
);
|
||||
|
||||
return this.scanBucketsDescForMessages(channelId, {
|
||||
limit,
|
||||
minBucket,
|
||||
maxBucket,
|
||||
before,
|
||||
restrictToBeforeBucket: options?.restrictToBeforeBucket,
|
||||
});
|
||||
}
|
||||
|
||||
private async listMessagesAfter(
|
||||
channelId: ChannelID,
|
||||
after: MessageID,
|
||||
limit: number,
|
||||
options?: ListMessagesOptions,
|
||||
): Promise<Array<Message>> {
|
||||
const state = await this.getChannelState(channelId);
|
||||
|
||||
const afterBucket = BucketUtils.makeBucket(after);
|
||||
const createdMin = state?.created_bucket ?? BucketUtils.makeBucket(channelId);
|
||||
const minBucket = Math.max(afterBucket, createdMin);
|
||||
|
||||
const nowBucket = BucketUtils.makeBucket(SnowflakeUtils.getSnowflake());
|
||||
const maxBucket = Math.max(nowBucket, minBucket);
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
action: 'listMessagesAfter',
|
||||
after: after.toString(),
|
||||
minBucket,
|
||||
maxBucket,
|
||||
limit,
|
||||
immediateAfter: options?.immediateAfter ?? false,
|
||||
},
|
||||
'listMessagesAfter parameters',
|
||||
);
|
||||
|
||||
if (options?.immediateAfter) {
|
||||
const asc = await this.scanBucketsAscForMessages(channelId, {
|
||||
limit,
|
||||
minBucket,
|
||||
maxBucket,
|
||||
after,
|
||||
});
|
||||
return asc.reverse();
|
||||
}
|
||||
|
||||
return this.scanBucketsDescForMessages(channelId, {
|
||||
limit,
|
||||
minBucket,
|
||||
maxBucket,
|
||||
after,
|
||||
});
|
||||
}
|
||||
|
||||
private async listMessagesBetween(
|
||||
channelId: ChannelID,
|
||||
after: MessageID,
|
||||
before: MessageID,
|
||||
limit: number,
|
||||
options?: ListMessagesOptions,
|
||||
): Promise<Array<Message>> {
|
||||
const state = await this.getChannelState(channelId);
|
||||
|
||||
const afterBucket = BucketUtils.makeBucket(after);
|
||||
const beforeBucket = BucketUtils.makeBucket(before);
|
||||
|
||||
const high = Math.max(afterBucket, beforeBucket);
|
||||
const low = Math.min(afterBucket, beforeBucket);
|
||||
|
||||
const createdMin = state?.created_bucket ?? BucketUtils.makeBucket(channelId);
|
||||
const minBucket = Math.max(low, createdMin);
|
||||
const maxBucket = high;
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
action: 'listMessagesBetween',
|
||||
after: after.toString(),
|
||||
before: before.toString(),
|
||||
minBucket,
|
||||
maxBucket,
|
||||
limit,
|
||||
},
|
||||
'listMessagesBetween parameters',
|
||||
);
|
||||
|
||||
return this.scanBucketsDescForMessages(channelId, {
|
||||
limit,
|
||||
minBucket,
|
||||
maxBucket,
|
||||
after,
|
||||
before,
|
||||
restrictToBeforeBucket: options?.restrictToBeforeBucket,
|
||||
});
|
||||
}
|
||||
|
||||
private async scanBucketsDescForMessages(
|
||||
channelId: ChannelID,
|
||||
opts: {
|
||||
limit: number;
|
||||
minBucket: number;
|
||||
maxBucket: number;
|
||||
before?: MessageID;
|
||||
after?: MessageID;
|
||||
restrictToBeforeBucket?: boolean;
|
||||
},
|
||||
): Promise<Array<Message>> {
|
||||
const beforeBucket = opts.before ? BucketUtils.makeBucket(opts.before) : null;
|
||||
const afterBucket = opts.after ? BucketUtils.makeBucket(opts.after) : null;
|
||||
|
||||
const stopAfterBucket =
|
||||
opts.restrictToBeforeBucket === true && opts.before && !opts.after && beforeBucket !== null
|
||||
? beforeBucket
|
||||
: undefined;
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
beforeBucket,
|
||||
afterBucket,
|
||||
restrictToBeforeBucket: opts.restrictToBeforeBucket ?? null,
|
||||
stopAfterBucket: stopAfterBucket ?? null,
|
||||
},
|
||||
'scanBucketsDescForMessages: starting scan',
|
||||
);
|
||||
|
||||
const {rows: out} = await scanBucketsWithIndex<MessageRow>(
|
||||
{
|
||||
listBucketsFromIndex: async (query) =>
|
||||
this.listBucketsDescFromIndex(channelId, {
|
||||
minBucket: query.minBucket,
|
||||
maxBucket: query.maxBucket,
|
||||
limit: query.limit,
|
||||
}),
|
||||
fetchRowsForBucket: async (bucket, limit) =>
|
||||
this.fetchRowsForBucket(channelId, bucket, limit, {
|
||||
before: opts.before,
|
||||
after: opts.after,
|
||||
beforeBucket,
|
||||
afterBucket,
|
||||
}),
|
||||
getRowId: (row) => row.message_id,
|
||||
onEmptyUnboundedBucket: async (bucket) => this.markBucketEmpty(channelId, bucket),
|
||||
onBucketHasRows: async (bucket) => this.touchBucketWithMessages(channelId, bucket),
|
||||
},
|
||||
{
|
||||
limit: opts.limit,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
direction: BucketScanDirection.Desc,
|
||||
indexPageSize: DEFAULT_BUCKET_INDEX_PAGE_SIZE,
|
||||
stopAfterBucket,
|
||||
},
|
||||
);
|
||||
|
||||
if (out.length === 0) return [];
|
||||
|
||||
let maxId: MessageID = out[0].message_id;
|
||||
let maxBucketForId = out[0].bucket;
|
||||
|
||||
for (const row of out) {
|
||||
if (row.message_id > maxId) {
|
||||
maxId = row.message_id;
|
||||
maxBucketForId = row.bucket;
|
||||
}
|
||||
}
|
||||
|
||||
await this.touchChannelHasMessages(channelId);
|
||||
await this.advanceChannelStateLastMessageIfNewer(channelId, maxId, maxBucketForId);
|
||||
|
||||
return this.repairAndMapMessages(channelId, out);
|
||||
}
|
||||
|
||||
private async scanBucketsAscForMessages(
|
||||
channelId: ChannelID,
|
||||
opts: {
|
||||
limit: number;
|
||||
minBucket: number;
|
||||
maxBucket: number;
|
||||
after: MessageID;
|
||||
},
|
||||
): Promise<Array<Message>> {
|
||||
const afterBucket = BucketUtils.makeBucket(opts.after);
|
||||
|
||||
const {rows: out} = await scanBucketsWithIndex<MessageRow>(
|
||||
{
|
||||
listBucketsFromIndex: async (query) =>
|
||||
this.listBucketsAscFromIndex(channelId, {
|
||||
minBucket: query.minBucket,
|
||||
maxBucket: query.maxBucket,
|
||||
limit: query.limit,
|
||||
}),
|
||||
fetchRowsForBucket: async (bucket, limit) =>
|
||||
this.fetchRowsForBucketAsc(channelId, bucket, limit, {
|
||||
after: opts.after,
|
||||
afterBucket,
|
||||
}),
|
||||
getRowId: (row) => row.message_id,
|
||||
onEmptyUnboundedBucket: async (bucket) => this.markBucketEmpty(channelId, bucket),
|
||||
onBucketHasRows: async (bucket) => this.touchBucketWithMessages(channelId, bucket),
|
||||
},
|
||||
{
|
||||
limit: opts.limit,
|
||||
minBucket: opts.minBucket,
|
||||
maxBucket: opts.maxBucket,
|
||||
direction: BucketScanDirection.Asc,
|
||||
indexPageSize: DEFAULT_BUCKET_INDEX_PAGE_SIZE,
|
||||
},
|
||||
);
|
||||
|
||||
if (out.length === 0) return [];
|
||||
|
||||
let maxId: MessageID = out[0].message_id;
|
||||
let maxBucketForId = out[0].bucket;
|
||||
|
||||
for (const row of out) {
|
||||
if (row.message_id > maxId) {
|
||||
maxId = row.message_id;
|
||||
maxBucketForId = row.bucket;
|
||||
}
|
||||
}
|
||||
|
||||
await this.touchChannelHasMessages(channelId);
|
||||
await this.advanceChannelStateLastMessageIfNewer(channelId, maxId, maxBucketForId);
|
||||
|
||||
return this.repairAndMapMessages(channelId, out);
|
||||
}
|
||||
|
||||
private async fetchRowsForBucketAsc(
|
||||
channelId: ChannelID,
|
||||
bucket: number,
|
||||
limit: number,
|
||||
meta: {
|
||||
after: MessageID;
|
||||
afterBucket: number;
|
||||
},
|
||||
): Promise<{rows: Array<MessageRow>; unbounded: boolean}> {
|
||||
logger.debug(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
bucket,
|
||||
limit,
|
||||
meta: {after: meta.after.toString(), afterBucket: meta.afterBucket},
|
||||
},
|
||||
'fetchRowsForBucketAsc parameters',
|
||||
);
|
||||
|
||||
if (bucket === meta.afterBucket) {
|
||||
const q = this.makeFetchMessagesAfterAsc(limit);
|
||||
const rows = await fetchMany<MessageRow>(
|
||||
q.bind({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
after_message_id: meta.after,
|
||||
}),
|
||||
);
|
||||
return {rows, unbounded: false};
|
||||
}
|
||||
|
||||
const q = this.makeFetchMessagesOldestAsc(limit);
|
||||
const rows = await fetchMany<MessageRow>(q.bind({channel_id: channelId, bucket}));
|
||||
return {rows, unbounded: true};
|
||||
}
|
||||
|
||||
private async fetchRowsForBucket(
|
||||
channelId: ChannelID,
|
||||
bucket: number,
|
||||
limit: number,
|
||||
meta: {
|
||||
before?: MessageID;
|
||||
after?: MessageID;
|
||||
beforeBucket: number | null;
|
||||
afterBucket: number | null;
|
||||
},
|
||||
): Promise<{rows: Array<MessageRow>; unbounded: boolean}> {
|
||||
logger.debug(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
bucket,
|
||||
limit,
|
||||
meta: {
|
||||
before: meta.before?.toString() ?? null,
|
||||
after: meta.after?.toString() ?? null,
|
||||
beforeBucket: meta.beforeBucket,
|
||||
afterBucket: meta.afterBucket,
|
||||
},
|
||||
},
|
||||
'fetchRowsForBucket parameters',
|
||||
);
|
||||
|
||||
if (meta.before && meta.after && meta.beforeBucket === bucket && meta.afterBucket === bucket) {
|
||||
const q = this.makeFetchMessagesBetween(limit);
|
||||
const rows = await fetchMany<MessageRow>(
|
||||
q.bind({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
after_message_id: meta.after,
|
||||
before_message_id: meta.before,
|
||||
}),
|
||||
);
|
||||
return {rows, unbounded: false};
|
||||
}
|
||||
|
||||
if (meta.before && meta.beforeBucket === bucket) {
|
||||
const q = this.makeFetchMessagesBefore(limit);
|
||||
const rows = await fetchMany<MessageRow>(
|
||||
q.bind({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
before_message_id: meta.before,
|
||||
}),
|
||||
);
|
||||
return {rows, unbounded: false};
|
||||
}
|
||||
|
||||
if (meta.after && meta.afterBucket === bucket) {
|
||||
const q = this.makeFetchMessagesAfterDesc(limit);
|
||||
const rows = await fetchMany<MessageRow>(
|
||||
q.bind({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
after_message_id: meta.after,
|
||||
}),
|
||||
);
|
||||
return {rows, unbounded: false};
|
||||
}
|
||||
|
||||
const q = this.makeFetchMessagesLatestDesc(limit);
|
||||
const rows = await fetchMany<MessageRow>(q.bind({channel_id: channelId, bucket}));
|
||||
return {rows, unbounded: true};
|
||||
}
|
||||
|
||||
private async touchBucketWithMessages(channelId: ChannelID, bucket: number): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
batch.addPrepared(
|
||||
ChannelMessageBuckets.upsertAll({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
updated_at: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
batch.addPrepared(
|
||||
ChannelEmptyBuckets.deleteByPk({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
}),
|
||||
);
|
||||
|
||||
await batch.execute(true);
|
||||
}
|
||||
|
||||
private async markBucketEmpty(channelId: ChannelID, bucket: number): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
batch.addPrepared(
|
||||
ChannelMessageBuckets.deleteByPk({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
}),
|
||||
);
|
||||
|
||||
batch.addPrepared(
|
||||
ChannelEmptyBuckets.upsertAll({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
updated_at: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
await batch.execute(true);
|
||||
}
|
||||
|
||||
private async touchChannelHasMessages(channelId: ChannelID): Promise<void> {
|
||||
await upsertOne(
|
||||
ChannelState.patchByPk(
|
||||
{channel_id: channelId},
|
||||
{
|
||||
has_messages: Db.set(true),
|
||||
updated_at: Db.set(new Date()),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async advanceChannelStateLastMessageIfNewer(
|
||||
channelId: ChannelID,
|
||||
newLastMessageId: MessageID,
|
||||
newLastMessageBucket: number,
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < DEFAULT_CAS_RETRIES; i++) {
|
||||
const state = await this.getChannelState(channelId);
|
||||
const prev = state?.last_message_id ?? null;
|
||||
|
||||
if (prev !== null && newLastMessageId <= prev) return;
|
||||
|
||||
const q = ChannelState.patchByPkIf(
|
||||
{channel_id: channelId},
|
||||
{
|
||||
has_messages: Db.set(true),
|
||||
last_message_id: Db.set(newLastMessageId),
|
||||
last_message_bucket: Db.set(newLastMessageBucket),
|
||||
updated_at: Db.set(new Date()),
|
||||
},
|
||||
{col: 'last_message_id', expectedParam: 'prev_last_message_id', expectedValue: prev},
|
||||
);
|
||||
|
||||
const res = await executeConditional(q);
|
||||
if (res.applied) return;
|
||||
}
|
||||
|
||||
Logger.warn(
|
||||
{channelId: channelId.toString(), messageId: newLastMessageId.toString()},
|
||||
'Failed to advance ChannelState.last_message_id after retries',
|
||||
);
|
||||
}
|
||||
|
||||
private async getChannelState(channelId: ChannelID): Promise<ChannelStateRow | null> {
|
||||
return fetchOne<ChannelStateRow>(FETCH_CHANNEL_STATE.bind({channel_id: channelId}));
|
||||
}
|
||||
|
||||
private async listBucketsDescFromIndex(
|
||||
channelId: ChannelID,
|
||||
opts: {minBucket?: number; maxBucket?: number; limit: number},
|
||||
): Promise<Array<number>> {
|
||||
const where = [ChannelMessageBuckets.where.eq('channel_id')];
|
||||
|
||||
if (typeof opts.minBucket === 'number') where.push(ChannelMessageBuckets.where.gte('bucket', 'min_bucket'));
|
||||
if (typeof opts.maxBucket === 'number') where.push(ChannelMessageBuckets.where.lte('bucket', 'max_bucket'));
|
||||
|
||||
const q = ChannelMessageBuckets.select({
|
||||
columns: ['bucket'],
|
||||
where,
|
||||
orderBy: {col: 'bucket', direction: 'DESC'},
|
||||
limit: opts.limit,
|
||||
});
|
||||
|
||||
const params = {
|
||||
channel_id: channelId,
|
||||
...(typeof opts.minBucket === 'number' ? {min_bucket: opts.minBucket} : {}),
|
||||
...(typeof opts.maxBucket === 'number' ? {max_bucket: opts.maxBucket} : {}),
|
||||
};
|
||||
|
||||
const rows = await fetchMany<Pick<ChannelMessageBucketRow, 'bucket'>>(q.bind(params));
|
||||
const buckets = rows.map((r) => r.bucket);
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
minBucket: opts.minBucket ?? null,
|
||||
maxBucket: opts.maxBucket ?? null,
|
||||
limit: opts.limit,
|
||||
bucketsFound: buckets,
|
||||
},
|
||||
'listBucketsDescFromIndex: query result',
|
||||
);
|
||||
|
||||
return buckets;
|
||||
}
|
||||
|
||||
private async listBucketsAscFromIndex(
|
||||
channelId: ChannelID,
|
||||
opts: {minBucket?: number; maxBucket?: number; limit: number},
|
||||
): Promise<Array<number>> {
|
||||
const where = [ChannelMessageBuckets.where.eq('channel_id')];
|
||||
|
||||
if (typeof opts.minBucket === 'number') where.push(ChannelMessageBuckets.where.gte('bucket', 'min_bucket'));
|
||||
if (typeof opts.maxBucket === 'number') where.push(ChannelMessageBuckets.where.lte('bucket', 'max_bucket'));
|
||||
|
||||
const q = ChannelMessageBuckets.select({
|
||||
columns: ['bucket'],
|
||||
where,
|
||||
orderBy: {col: 'bucket', direction: 'ASC'},
|
||||
limit: opts.limit,
|
||||
});
|
||||
|
||||
const params = {
|
||||
channel_id: channelId,
|
||||
...(typeof opts.minBucket === 'number' ? {min_bucket: opts.minBucket} : {}),
|
||||
...(typeof opts.maxBucket === 'number' ? {max_bucket: opts.maxBucket} : {}),
|
||||
};
|
||||
|
||||
const rows = await fetchMany<Pick<ChannelMessageBucketRow, 'bucket'>>(q.bind(params));
|
||||
return rows.map((r) => r.bucket);
|
||||
}
|
||||
|
||||
async getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null> {
|
||||
const bucket = BucketUtils.makeBucket(messageId);
|
||||
|
||||
const message = await fetchOne<MessageRow>(
|
||||
FETCH_MESSAGE_BY_CHANNEL_BUCKET_AND_MESSAGE_ID.bind({
|
||||
channel_id: channelId,
|
||||
bucket,
|
||||
message_id: messageId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (message) return new Message(message);
|
||||
|
||||
const repairedMessage = await this.attemptBucketReadRepair(channelId, messageId, bucket);
|
||||
return repairedMessage;
|
||||
}
|
||||
|
||||
async upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise<Message> {
|
||||
const expectedBucket = BucketUtils.makeBucket(data.message_id);
|
||||
if (data.bucket !== expectedBucket) {
|
||||
throw new Error(
|
||||
`Invalid message bucket for ${data.message_id.toString()}: expected ${expectedBucket}, received ${data.bucket}`,
|
||||
);
|
||||
}
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
batch.addPrepared(
|
||||
ChannelEmptyBuckets.deleteByPk({
|
||||
channel_id: data.channel_id,
|
||||
bucket: data.bucket,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await executeVersionedUpdate<MessageRow, 'channel_id' | 'bucket' | 'message_id'>(
|
||||
async () => {
|
||||
if (oldData !== undefined) return oldData;
|
||||
const pk = {
|
||||
channel_id: data.channel_id,
|
||||
bucket: data.bucket,
|
||||
message_id: data.message_id,
|
||||
};
|
||||
const existingMessage = await fetchOne<MessageRow>(FETCH_MESSAGE_BY_CHANNEL_BUCKET_AND_MESSAGE_ID.bind(pk));
|
||||
return existingMessage ?? null;
|
||||
},
|
||||
(current) => ({
|
||||
pk: {
|
||||
channel_id: data.channel_id,
|
||||
bucket: data.bucket,
|
||||
message_id: data.message_id,
|
||||
},
|
||||
patch: buildPatchFromData(data, current, MESSAGE_COLUMNS, ['channel_id', 'bucket', 'message_id']),
|
||||
}),
|
||||
Messages,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
|
||||
if (!result.applied) {
|
||||
throw new Error(`Failed to upsert message ${data.message_id} after LWT retries`);
|
||||
}
|
||||
|
||||
const finalVersion = result.finalVersion ?? 1;
|
||||
|
||||
if (data.author_id) {
|
||||
batch.addPrepared(
|
||||
MessagesByAuthor.upsertAll({
|
||||
author_id: data.author_id,
|
||||
channel_id: data.channel_id,
|
||||
message_id: data.message_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.pinned_timestamp) {
|
||||
batch.addPrepared(
|
||||
ChannelPins.upsertAll({
|
||||
channel_id: data.channel_id,
|
||||
message_id: data.message_id,
|
||||
pinned_timestamp: data.pinned_timestamp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (oldData?.pinned_timestamp && !data.pinned_timestamp) {
|
||||
batch.addPrepared(
|
||||
ChannelPins.deleteByPk({
|
||||
channel_id: data.channel_id,
|
||||
message_id: data.message_id,
|
||||
pinned_timestamp: oldData.pinned_timestamp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (oldData?.attachments) {
|
||||
for (const attachment of oldData.attachments) {
|
||||
batch.addPrepared(
|
||||
AttachmentLookup.deleteByPk({
|
||||
channel_id: data.channel_id,
|
||||
attachment_id: attachment.attachment_id,
|
||||
filename: attachment.filename,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.attachments) {
|
||||
for (const attachment of data.attachments) {
|
||||
batch.addPrepared(
|
||||
AttachmentLookup.upsertAll({
|
||||
channel_id: data.channel_id,
|
||||
attachment_id: attachment.attachment_id,
|
||||
filename: attachment.filename,
|
||||
message_id: data.message_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
batch.addPrepared(
|
||||
ChannelMessageBuckets.upsertAll({
|
||||
channel_id: data.channel_id,
|
||||
bucket: data.bucket,
|
||||
updated_at: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
const createdBucket = BucketUtils.makeBucket(data.channel_id);
|
||||
|
||||
batch.addPrepared(
|
||||
ChannelState.patchByPk(
|
||||
{channel_id: data.channel_id},
|
||||
{
|
||||
created_bucket: Db.set(createdBucket),
|
||||
has_messages: Db.set(true),
|
||||
updated_at: Db.set(new Date()),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await batch.execute();
|
||||
|
||||
await this.advanceChannelStateLastMessageIfNewer(data.channel_id, data.message_id, data.bucket);
|
||||
|
||||
return new Message({...data, version: finalVersion});
|
||||
}
|
||||
|
||||
private async attemptBucketReadRepair(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
expectedBucket: number,
|
||||
): Promise<Message | null> {
|
||||
for (const legacyBucket of LEGACY_BUCKETS_TO_CHECK) {
|
||||
if (legacyBucket === expectedBucket) continue;
|
||||
|
||||
const legacyRow = await fetchOne<MessageRow>(
|
||||
FETCH_MESSAGE_BY_CHANNEL_BUCKET_AND_MESSAGE_ID.bind({
|
||||
channel_id: channelId,
|
||||
bucket: legacyBucket,
|
||||
message_id: messageId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!legacyRow) continue;
|
||||
|
||||
Logger.warn(
|
||||
{channelId: channelId.toString(), messageId: messageId.toString(), legacyBucket, expectedBucket},
|
||||
'Repairing message bucket mismatch',
|
||||
);
|
||||
|
||||
const repairedRow: MessageRow = {
|
||||
...legacyRow,
|
||||
bucket: expectedBucket,
|
||||
};
|
||||
|
||||
const repairedMessage = await this.upsertMessage(repairedRow, legacyRow);
|
||||
|
||||
await deleteOneOrMany(
|
||||
Messages.deleteByPk({
|
||||
channel_id: channelId,
|
||||
bucket: legacyBucket,
|
||||
message_id: messageId,
|
||||
}),
|
||||
);
|
||||
|
||||
return repairedMessage;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async repairAndMapMessages(channelId: ChannelID, messages: Array<MessageRow>): Promise<Array<Message>> {
|
||||
if (messages.length === 0) return [];
|
||||
|
||||
const repaired: Array<Message> = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const expectedBucket = BucketUtils.makeBucket(message.message_id);
|
||||
|
||||
if (message.bucket === expectedBucket) {
|
||||
repaired.push(new Message(message));
|
||||
continue;
|
||||
}
|
||||
|
||||
const repairedMessage = await this.attemptBucketReadRepair(channelId, message.message_id, expectedBucket);
|
||||
|
||||
if (repairedMessage) {
|
||||
repaired.push(repairedMessage);
|
||||
continue;
|
||||
}
|
||||
|
||||
Logger.warn(
|
||||
{
|
||||
channelId: channelId.toString(),
|
||||
messageId: message.message_id.toString(),
|
||||
legacyBucket: message.bucket,
|
||||
expectedBucket,
|
||||
},
|
||||
'Failed to repair message bucket mismatch during listMessages; returning legacy row',
|
||||
);
|
||||
|
||||
repaired.push(new Message(message));
|
||||
}
|
||||
|
||||
return repaired;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user