Fix messages sync for outdated gmail history id (#9583)

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2025-07-23 12:20:30 +07:00
committed by GitHub
parent 343f54c906
commit ee115c89ae
2 changed files with 478 additions and 5 deletions
@@ -182,4 +182,317 @@ describe('SyncManager', () => {
expect(result).toEqual(mockResponse)
})
})
/* eslint-disable @typescript-eslint/unbound-method */
describe('syncNewMessages', () => {
it('should perform full sync when no stored history ID exists', async () => {
// Arrange
const spyGetHistory = jest.spyOn((syncManager as any).stateManager, 'getHistory').mockResolvedValue(null)
const spyFullSync = jest.spyOn(syncManager, 'fullSync').mockResolvedValue(undefined)
// Act
await syncManager.syncNewMessages(userId, userEmail)
// Assert
expect(spyGetHistory).toHaveBeenCalledWith(userId)
expect(mockCtx.info).toHaveBeenCalledWith('No stored history ID found, performing full sync instead', { userId })
expect(spyFullSync).toHaveBeenCalledWith(userId, userEmail)
})
it('should throw error when userEmail is undefined', async () => {
// Act & Assert
await expect(syncManager.syncNewMessages(userId)).rejects.toThrow('Cannot sync without user email')
})
it('should sync new messages and update history ID when newer messages found', async () => {
// Arrange
const storedHistoryId = '12345'
const newHistoryId = '12350'
const spyGetHistory = jest.spyOn((syncManager as any).stateManager, 'getHistory').mockResolvedValue({
historyId: storedHistoryId,
userId,
workspace
})
const spySetHistoryId = jest.spyOn((syncManager as any).stateManager, 'setHistoryId').mockResolvedValue(undefined)
const mockMessages = [{ id: 'msg1' }, { id: 'msg2' }]
mockGmail.messages.list.mockResolvedValue({
data: {
messages: mockMessages,
nextPageToken: null
}
})
mockGmail.messages.get
.mockResolvedValueOnce({
data: {
id: 'msg1',
historyId: newHistoryId
}
})
.mockResolvedValueOnce({
data: {
id: 'msg2',
historyId: '12348'
}
})
// Act
await syncManager.syncNewMessages(userId, userEmail)
// Assert
expect(spyGetHistory).toHaveBeenCalledWith(userId)
expect(mockGmail.messages.list).toHaveBeenCalledWith({
userId: 'me',
pageToken: undefined
})
expect(mockGmail.messages.get).toHaveBeenCalledTimes(2)
expect(mockMessageManager.saveMessage).toHaveBeenCalledTimes(2)
expect(spySetHistoryId).toHaveBeenCalledWith(userId, newHistoryId)
expect(mockCtx.info).toHaveBeenCalledWith('Updated history ID after new messages sync', {
userId,
oldHistoryId: storedHistoryId,
newHistoryId,
messagesProcessed: 2
})
})
it('should stop syncing when encountering message with older history ID', async () => {
// Arrange
const storedHistoryId = '12345'
const olderHistoryId = '12340'
const spyGetHistory = jest.spyOn((syncManager as any).stateManager, 'getHistory').mockResolvedValue({
historyId: storedHistoryId,
userId,
workspace
})
const mockMessages = [{ id: 'msg1' }, { id: 'msg2' }]
mockGmail.messages.list.mockResolvedValue({
data: {
messages: mockMessages,
nextPageToken: null
}
})
mockGmail.messages.get.mockResolvedValueOnce({
data: {
id: 'msg1',
historyId: olderHistoryId
}
})
// Act
await syncManager.syncNewMessages(userId, userEmail)
// Assert
expect(spyGetHistory).toHaveBeenCalledWith(userId)
expect(mockGmail.messages.get).toHaveBeenCalledTimes(1)
expect(mockMessageManager.saveMessage).not.toHaveBeenCalled()
expect(mockCtx.info).toHaveBeenCalledWith('Reached message with history ID <= stored history ID, stopping sync', {
userId,
messageHistoryId: olderHistoryId,
storedHistoryId
})
expect(mockCtx.info).toHaveBeenCalledWith('No history ID update needed after new messages sync', {
userId,
storedHistoryId,
maxHistoryId: undefined,
messagesProcessed: 0
})
})
it('should save messages without history ID', async () => {
// Arrange
const storedHistoryId = '12345'
const spyGetHistory = jest.spyOn((syncManager as any).stateManager, 'getHistory').mockResolvedValue({
historyId: storedHistoryId,
userId,
workspace
})
const mockMessages = [{ id: 'msg1' }]
mockGmail.messages.list.mockResolvedValue({
data: {
messages: mockMessages,
nextPageToken: null
}
})
mockGmail.messages.get.mockResolvedValue({
data: {
id: 'msg1',
historyId: null // No history ID
}
})
// Act
await syncManager.syncNewMessages(userId, userEmail)
// Assert
expect(spyGetHistory).toHaveBeenCalledWith(userId)
expect(mockMessageManager.saveMessage).toHaveBeenCalledTimes(1)
expect(mockCtx.info).toHaveBeenCalledWith('No history ID update needed after new messages sync', {
userId,
storedHistoryId,
maxHistoryId: undefined,
messagesProcessed: 1
})
})
it('should handle pagination correctly', async () => {
// Arrange
const storedHistoryId = '12345'
const newHistoryId = '12350'
const spyGetHistory = jest.spyOn((syncManager as any).stateManager, 'getHistory').mockResolvedValue({
historyId: storedHistoryId,
userId,
workspace
})
const spySetHistoryId = jest.spyOn((syncManager as any).stateManager, 'setHistoryId').mockResolvedValue(undefined)
// First page
mockGmail.messages.list
.mockResolvedValueOnce({
data: {
messages: [{ id: 'msg1' }],
nextPageToken: 'token123'
}
})
.mockResolvedValueOnce({
data: {
messages: [{ id: 'msg2' }],
nextPageToken: null
}
})
mockGmail.messages.get
.mockResolvedValueOnce({
data: {
id: 'msg1',
historyId: newHistoryId
}
})
.mockResolvedValueOnce({
data: {
id: 'msg2',
historyId: '12348'
}
})
// Act
await syncManager.syncNewMessages(userId, userEmail)
// Assert
expect(spyGetHistory).toHaveBeenCalledWith(userId)
expect(mockGmail.messages.list).toHaveBeenCalledTimes(2)
// The first call gets the modified query object with pageToken from the second call
// This is due to query object being reused and modified
expect(mockGmail.messages.list).toHaveBeenCalledWith({
userId: 'me',
pageToken: 'token123' // This will be the final state of the query object
})
expect(mockMessageManager.saveMessage).toHaveBeenCalledTimes(2)
expect(spySetHistoryId).toHaveBeenCalledWith(userId, newHistoryId)
})
it('should handle errors during message processing', async () => {
// Arrange
const storedHistoryId = '12345'
const spyGetHistory = jest.spyOn((syncManager as any).stateManager, 'getHistory').mockResolvedValue({
historyId: storedHistoryId,
userId,
workspace
})
const mockMessages = [{ id: 'msg1' }]
mockGmail.messages.list.mockResolvedValue({
data: {
messages: mockMessages,
nextPageToken: null
}
})
const error = new Error('Message fetch failed')
mockGmail.messages.get.mockRejectedValue(error)
// Act
await syncManager.syncNewMessages(userId, userEmail)
// Assert
expect(spyGetHistory).toHaveBeenCalledWith(userId)
expect(mockCtx.error).toHaveBeenCalledWith('Sync new messages error', {
workspace,
userId,
messageId: 'msg1',
err: error
})
})
it('should return early when isClosing is true', async () => {
// Arrange
const storedHistoryId = '12345'
const spyGetHistory = jest.spyOn((syncManager as any).stateManager, 'getHistory').mockResolvedValue({
historyId: storedHistoryId,
userId,
workspace
})
const mockMessages = [{ id: 'msg1' }]
mockGmail.messages.list.mockResolvedValue({
data: {
messages: mockMessages,
nextPageToken: null
}
})
// Set isClosing to true
syncManager.close()
// Act
await syncManager.syncNewMessages(userId, userEmail)
// Assert
expect(spyGetHistory).toHaveBeenCalledWith(userId)
expect(mockGmail.messages.get).not.toHaveBeenCalled()
expect(mockMessageManager.saveMessage).not.toHaveBeenCalled()
})
it('should handle overall sync error', async () => {
// Arrange
const storedHistoryId = '12345'
const spyGetHistory = jest.spyOn((syncManager as any).stateManager, 'getHistory').mockResolvedValue({
historyId: storedHistoryId,
userId,
workspace
})
const error = new Error('Sync failed')
mockGmail.messages.list.mockRejectedValue(error)
// Act
await syncManager.syncNewMessages(userId, userEmail)
// Assert
expect(spyGetHistory).toHaveBeenCalledWith(userId)
expect(mockCtx.error).toHaveBeenCalledWith('New messages sync error', {
workspace,
userId,
err: error
})
})
})
})
+165 -5
View File
@@ -51,6 +51,7 @@ export class SyncManager {
}
let pageToken: string | undefined
let histories: GaxiosResponse<gmail_v1.Schema$ListHistoryResponse>
let maxHistoryId: string | undefined
while (true) {
try {
await this.rateLimiter.take(2)
@@ -61,9 +62,13 @@ export class SyncManager {
pageToken
})
} catch (err: any) {
this.ctx.error('Part sync get history error', { workspaceUuid: this.workspace, userId, error: err.message })
await this.stateManager.clearHistory(userId)
void this.sync(userId, options, userEmail)
this.ctx.error('Part sync get history error', {
workspaceUuid: this.workspace,
userId,
error: err.message,
historyId
})
void this.syncNewMessages(userId, userEmail)
return
}
const nextPageToken = histories.data.nextPageToken
@@ -89,10 +94,31 @@ export class SyncManager {
}
}
if (history.id != null) {
await this.stateManager.setHistoryId(userId, history.id)
if (maxHistoryId == null || BigInt(maxHistoryId) < BigInt(history.id)) {
maxHistoryId = history.id
}
}
}
if (nextPageToken == null) {
// Set the maximum historyId found during the sync, but only if it's greater than current
if (maxHistoryId != null) {
const currentHistory = await this.stateManager.getHistory(userId)
const currentHistoryId = currentHistory?.historyId
if (currentHistoryId == null || BigInt(maxHistoryId) > BigInt(currentHistoryId)) {
await this.stateManager.setHistoryId(userId, maxHistoryId)
this.ctx.info('Updated history ID', {
userId,
oldHistoryId: currentHistoryId,
newHistoryId: maxHistoryId
})
} else {
this.ctx.info('Skipping history ID update', {
userId,
currentHistoryId,
maxHistoryId
})
}
}
return
} else {
pageToken = nextPageToken
@@ -148,7 +174,7 @@ export class SyncManager {
await this.messageManager.saveMessage(message, userEmail)
if (historyId != null && q === undefined) {
if (currentHistoryId == null || Number(currentHistoryId) < Number(historyId)) {
if (currentHistoryId == null || BigInt(currentHistoryId) < BigInt(historyId)) {
currentHistoryId = historyId
}
}
@@ -190,6 +216,140 @@ export class SyncManager {
})
}
async syncNewMessages (userId: PersonId, userEmail?: string): Promise<void> {
this.ctx.info('Start sync new messages', { workspaceUuid: this.workspace, userId, userEmail })
if (userEmail === undefined) {
throw new Error('Cannot sync without user email')
}
// Get stored history ID to compare against
const storedHistory = await this.stateManager.getHistory(userId)
const storedHistoryId = storedHistory?.historyId
if (storedHistoryId == null) {
this.ctx.info('No stored history ID found, performing full sync instead', { userId })
await this.fullSync(userId, userEmail)
return
}
this.ctx.info('Syncing new messages', {
workspaceUuid: this.workspace,
userId,
storedHistoryId
})
let pageToken: string | undefined
let totalProcessedMessages = 0
let maxHistoryId: string | undefined
let continueOuterLoop = true
const query: gmail_v1.Params$Resource$Users$Messages$List = {
userId: 'me'
}
try {
// Process messages until we find one with historyId > storedHistoryId
while (continueOuterLoop) {
query.pageToken = pageToken
await this.rateLimiter.take(5)
const messages = await this.gmail.messages.list(query)
const ids = messages.data.messages?.map((p) => p.id).filter((id) => id != null) ?? []
this.ctx.info('Processing new messages page', {
workspace: this.workspace,
userId,
messagesInPage: ids.length,
totalProcessed: totalProcessedMessages,
pageToken: query.pageToken
})
for (const id of ids) {
if (this.isClosing) return
if (id == null) continue
try {
const message = await this.getMessage(id)
const messageHistoryId = message.data.historyId
// If message has a history ID, check if it's newer than stored
if (messageHistoryId != null) {
if (BigInt(messageHistoryId) > BigInt(storedHistoryId)) {
await this.messageManager.saveMessage(message, userEmail)
totalProcessedMessages++
// Track the maximum history ID found
if (maxHistoryId == null || BigInt(messageHistoryId) > BigInt(maxHistoryId)) {
maxHistoryId = messageHistoryId
}
} else {
// This message is older or equal to stored history, stop processing
this.ctx.info('Reached message with history ID <= stored history ID, stopping sync', {
userId,
messageHistoryId,
storedHistoryId
})
continueOuterLoop = false
break
}
} else {
// Message without history ID, save it anyway
await this.messageManager.saveMessage(message, userEmail)
totalProcessedMessages++
}
} catch (err: any) {
if (this.isClosing) return
this.ctx.error('Sync new messages error', {
workspace: this.workspace,
userId,
messageId: id,
err
})
}
}
// If we should stop processing or no more pages, stop
if (!continueOuterLoop || messages.data.nextPageToken == null) {
this.ctx.info('Completed new messages sync', {
workspace: this.workspace,
userId,
totalMessages: totalProcessedMessages
})
break
}
pageToken = messages.data.nextPageToken
}
// Update stored history ID with the maximum found, only if we found newer messages
if (maxHistoryId != null && BigInt(maxHistoryId) > BigInt(storedHistoryId)) {
await this.stateManager.setHistoryId(userId, maxHistoryId)
this.ctx.info('Updated history ID after new messages sync', {
userId,
oldHistoryId: storedHistoryId,
newHistoryId: maxHistoryId,
messagesProcessed: totalProcessedMessages
})
} else {
this.ctx.info('No history ID update needed after new messages sync', {
userId,
storedHistoryId,
maxHistoryId,
messagesProcessed: totalProcessedMessages
})
}
this.ctx.info('New messages sync finished', {
workspaceUuid: this.workspace,
userId,
userEmail,
totalMessages: totalProcessedMessages
})
} catch (err) {
if (this.isClosing) return
this.ctx.error('New messages sync error', { workspace: this.workspace, userId, err })
}
}
async sync (userId: PersonId, options: SyncOptions, userEmail?: string): Promise<void> {
const mutexKey = `${this.workspace}:${userId}`
const releaseLock = await this.syncMutex.lock(mutexKey)