From 21b1e4e719f4e2df32ff4da4969c41166c80643e Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 31 Aug 2026 01:04:43 +0200 Subject: [PATCH] perf(ready): stop sending read state twice per session (#2223) --- fluxer_api/src/api/openapi/openapi.json | 6 +-- .../ReadStateAckResponsePayload.test.ts | 44 +++++++++++++++++++ .../api/read_state/ReadStateRequestService.ts | 3 +- .../api/read_state/ReadStateResponseMapper.ts | 13 ------ fluxer_api/src/api/rpc/RpcService.ts | 5 +-- .../rpc/tests/RpcSessionReadStates.test.ts | 44 +++++++++++++++++++ .../domains/channel/ChannelRequestSchemas.ts | 3 -- .../domains/read_state/ReadStateProtoCodec.ts | 38 ++++------------ packages/schema/src/domains/rpc/RpcSchemas.ts | 1 - 9 files changed, 100 insertions(+), 57 deletions(-) create mode 100644 fluxer_api/src/api/read_state/ReadStateAckResponsePayload.test.ts create mode 100644 fluxer_api/src/api/rpc/tests/RpcSessionReadStates.test.ts diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index dd48c40ad..437b7a57f 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -32708,13 +32708,9 @@ "type": "array", "items": {"$ref": "#/components/schemas/ReadStateResponse"}, "description": "Authoritative read states after applying the acknowledgement" - }, - "read_state_proto": { - "type": "string", - "description": "Authoritative read states after applying the acknowledgement, encoded as a base64 protobuf bundle" } }, - "required": ["read_states", "read_state_proto"] + "required": ["read_states"] }, "ReadStateResponse": { "type": "object", diff --git a/fluxer_api/src/api/read_state/ReadStateAckResponsePayload.test.ts b/fluxer_api/src/api/read_state/ReadStateAckResponsePayload.test.ts new file mode 100644 index 000000000..0102547c1 --- /dev/null +++ b/fluxer_api/src/api/read_state/ReadStateAckResponsePayload.test.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {afterEach, beforeEach, describe, expect, test} from 'vitest'; +import {createTestAccount} from '../auth/tests/AuthTestUtils'; +import {createChannel, createGuild} from '../guild/tests/GuildTestUtils'; +import {sendMessage} from '../message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '../test/ApiTestHarness'; +import {HTTP_STATUS} from '../test/TestConstants'; +import {createBuilder} from '../test/TestRequestBuilder'; + +interface AckResponse { + read_states: Array<{ + id: string; + mention_count: number; + last_message_id: string | null; + version: string; + }>; +} + +describe('POST /read-states/ack response payload', () => { + let harness: ApiTestHarness; + beforeEach(async () => { + harness = await createApiTestHarness(); + }); + afterEach(async () => { + await harness?.shutdown(); + }); + test('returns the authoritative read states without a protobuf bundle', async () => { + const account = await createTestAccount(harness); + const guild = await createGuild(harness, account.token, 'Read State Guild'); + const channel = await createChannel(harness, account.token, guild.id, 'read-state-channel'); + const message = await sendMessage(harness, account.token, channel.id, 'hello'); + const response = await createBuilder(harness, account.token) + .post('/read-states/ack') + .body({read_states: [{channel_id: channel.id, message_id: message.id, manual: true, mention_count: 0}]}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(response.read_states).toHaveLength(1); + expect(response.read_states[0]?.id).toBe(channel.id); + expect(response.read_states[0]?.last_message_id).toBe(message.id); + expect(typeof response.read_states[0]?.version).toBe('string'); + expect(Object.hasOwn(response, 'read_state_proto')).toBe(false); + }); +}); diff --git a/fluxer_api/src/api/read_state/ReadStateRequestService.ts b/fluxer_api/src/api/read_state/ReadStateRequestService.ts index bbab85038..5c60b2f35 100644 --- a/fluxer_api/src/api/read_state/ReadStateRequestService.ts +++ b/fluxer_api/src/api/read_state/ReadStateRequestService.ts @@ -7,7 +7,7 @@ import type { } from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas'; import type {UserID} from '../BrandedTypes'; import {createChannelID, createMessageID} from '../BrandedTypes'; -import {encodeReadStatesResponseProto, mapReadStateResponse} from './ReadStateResponseMapper'; +import {mapReadStateResponse} from './ReadStateResponseMapper'; import type {ReadStateService} from './ReadStateService'; interface ReadStateAckBulkParams { @@ -45,7 +45,6 @@ export class ReadStateRequestService { }); return { read_states: readStates.map(mapReadStateResponse), - read_state_proto: encodeReadStatesResponseProto(readStates), }; } } diff --git a/fluxer_api/src/api/read_state/ReadStateResponseMapper.ts b/fluxer_api/src/api/read_state/ReadStateResponseMapper.ts index 2455bf495..2eabb515f 100644 --- a/fluxer_api/src/api/read_state/ReadStateResponseMapper.ts +++ b/fluxer_api/src/api/read_state/ReadStateResponseMapper.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {ReadStateResponse} from '@fluxer/schema/src/domains/gateway/GatewaySchemas'; -import {encodeReadStateProtoNative} from '@fluxer/schema/src/domains/read_state/ReadStateProtoCodec'; import type {ReadState} from '../models/ReadState'; export function mapReadStateResponse(readState: ReadState): ReadStateResponse { @@ -13,15 +12,3 @@ export function mapReadStateResponse(readState: ReadState): ReadStateResponse { version: readState.version.toString(), }; } - -export function encodeReadStatesResponseProto(readStates: ReadonlyArray): string { - return encodeReadStateProtoNative( - readStates.map((readState) => ({ - channelId: readState.channelId, - mentionCount: readState.mentionCount, - lastMessageId: readState.lastMessageId, - lastPinTimestamp: readState.lastPinTimestamp, - version: readState.version, - })), - ); -} diff --git a/fluxer_api/src/api/rpc/RpcService.ts b/fluxer_api/src/api/rpc/RpcService.ts index 615cca4c4..e19332ec4 100644 --- a/fluxer_api/src/api/rpc/RpcService.ts +++ b/fluxer_api/src/api/rpc/RpcService.ts @@ -85,7 +85,7 @@ import {UserSettings} from '../models/UserSettings'; import type {WebAuthnCredential} from '../models/WebAuthnCredential'; import type {BotAuthService} from '../oauth/BotAuthService'; import {sendApnsPush} from '../push/ApnsPushService'; -import {encodeReadStatesResponseProto, mapReadStateResponse} from '../read_state/ReadStateResponseMapper'; +import {mapReadStateResponse} from '../read_state/ReadStateResponseMapper'; import type {ReadStateService} from '../read_state/ReadStateService'; import type {IUserRepository} from '../user/IUserRepository'; import {PaymentRepository} from '../user/repositories/PaymentRepository'; @@ -1169,9 +1169,6 @@ export class RpcService { read_states: timeRpcStepSync(responseBuildSteps, 'map_read_states', () => userData.readStates.map(mapReadStateResponse), ), - read_state_proto: timeRpcStepSync(responseBuildSteps, 'encode_read_state_proto', () => - encodeReadStatesResponseProto(userData.readStates), - ), guilds, private_channels: privateChannels, relationships, diff --git a/fluxer_api/src/api/rpc/tests/RpcSessionReadStates.test.ts b/fluxer_api/src/api/rpc/tests/RpcSessionReadStates.test.ts new file mode 100644 index 000000000..633c138dc --- /dev/null +++ b/fluxer_api/src/api/rpc/tests/RpcSessionReadStates.test.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {afterEach, beforeEach, describe, expect, test} from 'vitest'; +import {createTestAccount} from '../../auth/tests/AuthTestUtils'; +import {createChannel, createGuild} from '../../guild/tests/GuildTestUtils'; +import {sendMessage} from '../../message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; +import {createBuilder} from '../../test/TestRequestBuilder'; + +interface RpcSessionResponse { + type: 'session'; + data: { + read_states: Array<{id: string; last_message_id: string | null}>; + }; +} + +describe('RpcService session read states', () => { + let harness: ApiTestHarness; + beforeEach(async () => { + harness = await createApiTestHarness(); + }); + afterEach(async () => { + await harness?.shutdown(); + }); + test('sends read states as JSON only', async () => { + const account = await createTestAccount(harness); + const guild = await createGuild(harness, account.token, 'Read State Guild'); + const channel = await createChannel(harness, account.token, guild.id, 'read-state-channel'); + const message = await sendMessage(harness, account.token, channel.id, 'hello'); + await createBuilder(harness, account.token) + .post('/read-states/ack') + .body({read_states: [{channel_id: channel.id, message_id: message.id, manual: true, mention_count: 0}]}) + .expect(HTTP_STATUS.OK) + .execute(); + const response = await createBuilder(harness, '') + .post('/test/rpc-session-init') + .body({type: 'session', token: account.token, version: 1, ip: '127.0.0.1'}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(response.data.read_states.some((readState) => readState.id === channel.id)).toBe(true); + expect(Object.hasOwn(response.data, 'read_state_proto')).toBe(false); + }); +}); diff --git a/packages/schema/src/domains/channel/ChannelRequestSchemas.ts b/packages/schema/src/domains/channel/ChannelRequestSchemas.ts index bd02384f7..9fbca447d 100644 --- a/packages/schema/src/domains/channel/ChannelRequestSchemas.ts +++ b/packages/schema/src/domains/channel/ChannelRequestSchemas.ts @@ -249,9 +249,6 @@ export type ReadStateAckRequest = z.infer; export const ReadStateAckResponse = z.object({ read_states: z.array(ReadStateResponse).describe('Authoritative read states after applying the acknowledgement'), - read_state_proto: z - .string() - .describe('Authoritative read states after applying the acknowledgement, encoded as a base64 protobuf bundle'), }); export type ReadStateAckResponse = z.infer; diff --git a/packages/schema/src/domains/read_state/ReadStateProtoCodec.ts b/packages/schema/src/domains/read_state/ReadStateProtoCodec.ts index c6e4c607e..27b96f34b 100644 --- a/packages/schema/src/domains/read_state/ReadStateProtoCodec.ts +++ b/packages/schema/src/domains/read_state/ReadStateProtoCodec.ts @@ -13,14 +13,6 @@ interface ReadStateProtoEntryInput { version?: string | null; } -interface ReadStateProtoNativeEntryInput { - channelId: bigint | string; - mentionCount?: number | null; - lastMessageId?: bigint | string | null; - lastPinTimestamp?: Date | string | null; - version?: bigint | string | null; -} - interface ReadStateProtoEntry { id: string; mention_count: number; @@ -34,24 +26,12 @@ const MAX_UINT32 = 0xffffffff; const MAX_UINT64 = 0xffffffffffffffffn; export function encodeReadStateProto(readStates: ReadonlyArray): string { - return encodeReadStateProtoNative( - readStates.map((readState) => ({ - channelId: readState.id, - lastMessageId: readState.last_message_id, - mentionCount: readState.mention_count, - lastPinTimestamp: readState.last_pin_timestamp, - version: readState.version, - })), - ); -} - -export function encodeReadStateProtoNative(readStates: ReadonlyArray): string { const bundle = create(ReadStateBundleSchema, { readStates: readStates.map((readState) => ({ - channelId: parseUint64(readState.channelId, 'id'), - lastMessageId: optionalUint64(readState.lastMessageId, 'last_message_id'), - mentionCount: normalizeMentionCount(readState.mentionCount), - lastPinTimestamp: optionalTimestamp(readState.lastPinTimestamp), + channelId: parseUint64(readState.id, 'id'), + lastMessageId: optionalUint64(readState.last_message_id, 'last_message_id'), + mentionCount: normalizeMentionCount(readState.mention_count), + lastPinTimestamp: optionalTimestamp(readState.last_pin_timestamp), version: optionalUint64(readState.version, 'version'), })), }); @@ -101,13 +81,13 @@ class ReadStateProtoEncodeError extends Error { } } -function optionalUint64(value: bigint | string | null | undefined, field: string): bigint | undefined { +function optionalUint64(value: string | null | undefined, field: string): bigint | undefined { return value == null ? undefined : parseUint64(value, field); } -function parseUint64(value: bigint | string, field: string): bigint { +function parseUint64(value: string, field: string): bigint { try { - const parsed = typeof value === 'bigint' ? value : BigInt(value); + const parsed = BigInt(value); if (parsed < 0n || parsed > MAX_UINT64) { throw new Error('out of uint64 range'); } @@ -119,9 +99,9 @@ function parseUint64(value: bigint | string, field: string): bigint { } } -function optionalTimestamp(value: Date | string | null | undefined) { +function optionalTimestamp(value: string | null | undefined) { if (value == null) return undefined; - const date = value instanceof Date ? value : new Date(value); + const date = new Date(value); if (Number.isNaN(date.getTime())) { throw new ReadStateProtoEncodeError('last_pin_timestamp: invalid timestamp'); } diff --git a/packages/schema/src/domains/rpc/RpcSchemas.ts b/packages/schema/src/domains/rpc/RpcSchemas.ts index d8518bd09..e19311bec 100644 --- a/packages/schema/src/domains/rpc/RpcSchemas.ts +++ b/packages/schema/src/domains/rpc/RpcSchemas.ts @@ -288,7 +288,6 @@ export const RpcResponseSessionData = z.object({ user_guild_settings: z.array(UserGuildSettingsResponse).describe('Per-guild settings for the user'), notes: z.record(SnowflakeStringType, z.string()).describe('User notes keyed by user ID'), read_states: z.array(ReadStateResponse).describe('Read state for each channel'), - read_state_proto: z.string().describe('Read state for each channel, encoded as a base64 protobuf bundle'), private_channels: z.array(ChannelResponse).describe('List of DM and group DM channels'), relationships: z.array(RelationshipResponse).describe('User relationships (friends, blocked, etc.)'), favorite_memes: z.array(FavoriteMemeResponse).describe('List of user favorite memes'),