refactor(sync): replace message reloads with incremental tail sync

This commit is contained in:
weishu
2026-07-28 12:20:53 +08:00
parent 2235b924a7
commit faf70c64dd
28 changed files with 2927 additions and 2077 deletions
+97
View File
@@ -243,6 +243,11 @@ describe('MessageService message pagination', () => {
expect(page.messages.map((message) => message.id)).toEqual([second.id, third.id])
expect(page.page.nextBeforeAt).toBe(2_000)
expect(page.page.nextBeforeSeq).toBe(second.seq)
expect(page.page.direction).toBe('latest')
expect(page.page.epoch).toBe(0)
expect(page.page.reset).toBe(false)
expect(page.page.snapshotHeadAt).toBe(3_000)
expect(page.page.snapshotHeadSeq).toBe(third.seq)
expect(page.page.hasMore).toBe(true)
expect(first.id).toBeDefined()
})
@@ -266,6 +271,7 @@ describe('MessageService message pagination', () => {
expect(older.messages.map((message) => message.id)).toEqual([first.id])
expect(older.page.nextBeforeAt).toBe(1_000)
expect(older.page.nextBeforeSeq).toBe(first.seq)
expect(older.page.direction).toBe('before')
expect(older.page.hasMore).toBe(false)
expect(second.id).toBeDefined()
expect(third.id).toBeDefined()
@@ -305,6 +311,97 @@ describe('MessageService message pagination', () => {
expect(page.page.nextBeforeSeq).toBe(invoked.seq)
expect(page.page.hasMore).toBe(true)
})
it('pages forward to a fixed snapshot head', () => {
const store = makeStore()
const session = makeSession(store, 'page-after')
const first = store.messages.addMessage(session.id, 'first', 'local-first')
const second = store.messages.addMessage(session.id, 'second', 'local-second')
const third = store.messages.addMessage(session.id, 'third', 'local-third')
const fourth = store.messages.addMessage(session.id, 'fourth', 'local-fourth')
store.messages.markMessagesInvoked(session.id, ['local-first'], 1_000)
store.messages.markMessagesInvoked(session.id, ['local-second'], 2_000)
store.messages.markMessagesInvoked(session.id, ['local-third'], 3_000)
store.messages.markMessagesInvoked(session.id, ['local-fourth'], 4_000)
const service = makeService(store)
const firstDelta = service.getMessagesPage(session.id, {
limit: 1,
after: { at: 1_000, seq: first.seq },
epoch: 0
})
expect(firstDelta.messages.map((message) => message.id)).toEqual([second.id])
expect(firstDelta.page).toMatchObject({
direction: 'after',
nextAfterAt: 2_000,
nextAfterSeq: second.seq,
snapshotHeadAt: 4_000,
snapshotHeadSeq: fourth.seq,
hasMore: true,
reset: false
})
const fifth = store.messages.addMessage(session.id, 'fifth', 'local-fifth')
store.messages.markMessagesInvoked(session.id, ['local-fifth'], 5_000)
const secondDelta = service.getMessagesPage(session.id, {
limit: 10,
after: { at: firstDelta.page.nextAfterAt!, seq: firstDelta.page.nextAfterSeq! },
until: { at: firstDelta.page.snapshotHeadAt!, seq: firstDelta.page.snapshotHeadSeq! },
epoch: firstDelta.page.epoch
})
expect(secondDelta.messages.map((message) => message.id)).toEqual([third.id, fourth.id])
expect(secondDelta.page.hasMore).toBe(false)
expect(secondDelta.messages.some((message) => message.id === fifth.id)).toBe(false)
})
it('completes a fixed snapshot when no raw row remains before its head', () => {
const store = makeStore()
const session = makeSession(store, 'page-after-empty-snapshot-gap')
const first = store.messages.addMessage(session.id, 'first', 'local-first')
const latest = store.messages.addMessage(session.id, 'latest', 'local-latest')
store.messages.markMessagesInvoked(session.id, ['local-first'], 1_000)
store.messages.markMessagesInvoked(session.id, ['local-latest'], 4_000)
const response = makeService(store).getMessagesPage(session.id, {
limit: 10,
after: { at: 2_000, seq: first.seq },
until: { at: 3_000, seq: latest.seq },
epoch: 0
})
expect(response.messages).toEqual([])
expect(response.page).toMatchObject({
direction: 'after',
nextAfterAt: 3_000,
nextAfterSeq: latest.seq,
snapshotHeadAt: 3_000,
snapshotHeadSeq: latest.seq,
hasMore: false
})
})
it('returns a reset latest page when the structural epoch changed', () => {
const store = makeStore()
const session = makeSession(store, 'page-after-reset')
const first = store.messages.addMessage(session.id, 'first', 'local-first')
store.messages.markMessagesInvoked(session.id, ['local-first'], 1_000)
const queued = store.messages.addMessage(session.id, 'queued', 'local-queued')
store.messages.cancelQueuedMessage(session.id, queued.id)
const response = makeService(store).getMessagesPage(session.id, {
limit: 10,
after: { at: 1_000, seq: first.seq },
epoch: 0
})
expect(response.page.direction).toBe('latest')
expect(response.page.reset).toBe(true)
expect(response.page.epoch).toBe(1)
expect(response.messages.map((message) => message.id)).toEqual([first.id])
})
})
describe('MessageService.getQueuedState', () => {
+121 -14
View File
@@ -10,13 +10,25 @@ import {
unwrapRoleWrappedRecordEnvelope
} from '@hapi/protocol/messages'
import { isObject } from '@hapi/protocol'
import type { QueuedStateResponse } from '@hapi/protocol/apiTypes'
import type { MessagesResponse, QueuedStateResponse } from '@hapi/protocol/apiTypes'
import type { Server } from 'socket.io'
import { randomUUID } from 'node:crypto'
import type { Store, CancelQueuedMessageResult } from '../store'
import { EventPublisher } from './eventPublisher'
type StoredMessageForDelivery = ReturnType<Store['messages']['getMessages']>[number]
type MessagePosition = { at: number; seq: number }
function messagePosition(message: StoredMessageForDelivery): MessagePosition {
return {
at: message.invokedAt ?? message.createdAt,
seq: message.seq
}
}
function comparePosition(a: MessagePosition, b: MessagePosition): number {
return a.at !== b.at ? a.at - b.at : a.seq - b.seq
}
function isWebVisibleStoredMessage(message: StoredMessageForDelivery): boolean {
return !isRedundantGoalStatusEventContent(message.content)
@@ -139,25 +151,54 @@ export class MessageService {
getMessagesPage(
sessionId: string,
options: { limit: number; before?: { at: number; seq: number } | null }
): {
messages: DecryptedMessage[]
page: {
options: {
limit: number
nextBeforeSeq: number | null
nextBeforeAt: number | null
hasMore: boolean
before?: MessagePosition | null
after?: MessagePosition | null
until?: MessagePosition | null
epoch?: number | null
}
} {
let before = options.before ?? undefined
let pageRows = this.store.messages.getMessagesByPosition(sessionId, options.limit, before)
): MessagesResponse {
const epoch = this.store.messages.getMessageEpoch(sessionId)
if (options.after) {
if (options.epoch !== undefined && options.epoch !== null && options.epoch !== epoch) {
return this.getLatestOrBeforeMessagesPage(sessionId, options.limit, null, epoch, true)
}
return this.getAfterMessagesPage(
sessionId,
options.limit,
options.after,
options.until ?? null,
epoch
)
}
return this.getLatestOrBeforeMessagesPage(
sessionId,
options.limit,
options.before ?? null,
epoch,
false
)
}
private getLatestOrBeforeMessagesPage(
sessionId: string,
limit: number,
requestedBefore: MessagePosition | null,
epoch: number,
reset: boolean
): MessagesResponse {
const direction = requestedBefore ? 'before' as const : 'latest' as const
const snapshotHead = this.store.messages.getNewestMessagePosition(sessionId)
let before = requestedBefore ?? undefined
let pageRows = this.store.messages.getMessagesByPosition(sessionId, limit, requestedBefore ?? undefined)
// Latest-page request (no cursor): also include uninvoked local user messages
// out-of-band, so refresh / secondary clients can still see queued rows even
// when their position key (createdAt) places them outside the latest page.
// The cursor stays anchored to pageRows so out-of-band rows don't affect
// pagination of older pages.
let queuedRows = before === undefined
let queuedRows = requestedBefore === null
? this.store.messages.getUninvokedLocalMessages(sessionId)
: []
@@ -190,7 +231,7 @@ export class MessageService {
while (messages.length === 0 && hasMore && oldestSeq !== null && oldestPositionAt !== null) {
before = { at: oldestPositionAt, seq: oldestSeq }
pageRows = this.store.messages.getMessagesByPosition(sessionId, options.limit, before)
pageRows = this.store.messages.getMessagesByPosition(sessionId, limit, before)
queuedRows = []
byId = new Map<string, typeof pageRows[number]>()
@@ -219,9 +260,75 @@ export class MessageService {
return {
messages,
page: {
limit: options.limit,
direction,
limit,
epoch,
reset,
nextBeforeSeq: oldestSeq,
nextBeforeAt: oldestPositionAt,
nextAfterSeq: null,
nextAfterAt: null,
snapshotHeadSeq: snapshotHead?.seq ?? null,
snapshotHeadAt: snapshotHead?.at ?? null,
hasMore
}
}
}
private getAfterMessagesPage(
sessionId: string,
limit: number,
after: MessagePosition,
requestedUntil: MessagePosition | null,
epoch: number
): MessagesResponse {
const currentHead = this.store.messages.getNewestMessagePosition(sessionId)
const snapshotHead = currentHead && requestedUntil
? (comparePosition(requestedUntil, currentHead) <= 0 ? requestedUntil : currentHead)
: requestedUntil ?? currentHead
if (!snapshotHead || comparePosition(snapshotHead, after) <= 0) {
return {
messages: [],
page: {
direction: 'after',
limit,
epoch,
reset: false,
nextBeforeSeq: null,
nextBeforeAt: null,
nextAfterSeq: after.seq,
nextAfterAt: after.at,
snapshotHeadSeq: snapshotHead?.seq ?? null,
snapshotHeadAt: snapshotHead?.at ?? null,
hasMore: false
}
}
}
const pageRows = this.store.messages.getMessagesAfterPosition(
sessionId,
limit,
after,
snapshotHead
)
const last = pageRows[pageRows.length - 1] ?? null
const nextAfter = last ? messagePosition(last) : snapshotHead
const hasMore = last !== null && comparePosition(nextAfter, snapshotHead) < 0
return {
messages: toVisibleDecryptedMessages(pageRows),
page: {
direction: 'after',
limit,
epoch,
reset: false,
nextBeforeSeq: null,
nextBeforeAt: null,
nextAfterSeq: nextAfter.seq,
nextAfterAt: nextAfter.at,
snapshotHeadSeq: snapshotHead.seq,
snapshotHeadAt: snapshotHead.at,
hasMore
}
}
+7 -9
View File
@@ -8,7 +8,7 @@
*/
import { isKnownFlavor, type LocalResumeTarget, type ResumableSession } from '@hapi/protocol'
import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, QueuedStateResponse, SlashCommandsResponse } from '@hapi/protocol/apiTypes'
import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessagesResponse, QueuedStateResponse, SlashCommandsResponse } from '@hapi/protocol/apiTypes'
import type { AgentFlavor, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types'
import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages'
import type { Server } from 'socket.io'
@@ -323,16 +323,14 @@ export class SyncEngine {
getMessagesPage(
sessionId: string,
options: { limit: number; before?: { at: number; seq: number } | null }
): {
messages: DecryptedMessage[]
page: {
options: {
limit: number
nextBeforeSeq: number | null
nextBeforeAt: number | null
hasMore: boolean
before?: { at: number; seq: number } | null
after?: { at: number; seq: number } | null
until?: { at: number; seq: number } | null
epoch?: number | null
}
} {
): MessagesResponse {
return this.messageService.getMessagesPage(sessionId, options)
}