Files
twenty/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-message.service.ts
T
Félix Malfait f39fccc3c4 fix(messaging): split thread create/update statements and stop clobbering accumulator (#19388)
## Summary

Fixes two bugs in
`MessagingMessageService.saveMessagesWithinTransaction`.

### Bug 1 — `ON CONFLICT DO UPDATE command cannot affect row a second
time`

Reported in production logs from `MessagingMessagesImportService`.
Postgres rejects a single `INSERT … ON CONFLICT DO UPDATE` when the same
conflict target appears twice in the values list, and that's exactly
what was happening to `messageThread`.

Where it came from: #19351 added the message-thread subject refresh
feature and, in doing so, switched the existing thread `insert` to a
bulk `upsert(['id'])` over a list built by concatenating two sources:

```ts
const threadsToUpsert = [
  ...messageThreadsToCreate,        // brand-new thread rows
  ...threadSubjectUpdates entries,  // subject refreshes for existing threads
];
await messageThreadRepository.upsert(threadsToUpsert, ['id'], txManager);
```

Each list is internally unique, but they are **not disjoint**.
`enrichMessageAccumulatorWithMessageThreadToCreate`, when it sees two
messages in the same batch sharing a brand-new thread external id,
copies the first sibling's freshly-minted thread id into the second
sibling's `existingThreadInDB`. The subject-update gate later in the
loop then trusts that field and queues a subject refresh for that id —
which is also already in `messageThreadsToCreate`. Same id, same
statement, two rows → Postgres aborts the transaction and the import
retries forever on the same batch.

**Fix:** stop merging the two lists. Issue creates and subject updates
as two separate statements within the same transaction:

```ts
if (messageThreadsToCreate.length > 0) {
  await messageThreadRepository.insert(messageThreadsToCreate, txManager);
}
if (threadSubjectUpdates.size > 0) {
  await messageThreadRepository.upsert(
    Array.from(threadSubjectUpdates.entries()).map(([id, { subject }]) => ({ id, subject })),
    ['id'],
    txManager,
  );
}
```

This is closer to the pre-#19351 shape (`insert` for new rows) and
side-steps the duplicate-row constraint entirely: each statement is
internally unique (creates use freshly minted UUIDs; updates are keyed
by a `Map<id, …>`), and within the same transaction Postgres happily
applies a subject update to a row inserted by a previous statement.

### Bug 2 —
`enrichMessageAccumulatorWithExistingMessageChannelMessageAssociations`
clobbers the accumulator

Independent latent bug spotted while tracing the flow. The helper did:

```ts
if (existingMessageChannelMessageAssociation) {
  messageAccumulatorMap.set(message.externalId, {
    existingMessageInDB: existingMessage,
    existingMessageChannelMessageAssociationInDB: existingMessageChannelMessageAssociation,
  });
}
```

i.e. it **replaces** the accumulator object, dropping the
`existingThreadInDB` set just before by
`enrichMessageAccumulatorWithExistingMessageThreadIds`.

The branch only fires when re-encountering a message that's already been
fully synced on this channel (matched on `headerMessageId` AND already
has an association row) — i.e. routinely on Gmail/IMAP incremental syncs
whenever the connector re-delivers an existing message (label change,
read/unread, archive, full-resync after error, …).

When it fires, the next enrichment step sees `existingThreadInDB` as
`undefined`, falls into the "create a new thread" branch, mints a fresh
`threadToCreate`, but the main loop never queues a `messageToCreate` or
association for it (because both `existingMessageInDB` and the existing
association are still set). Net effect: **one orphan `messageThread` row
inserted per re-encountered message, with nothing referencing it.**

The existing message in the DB keeps pointing at its real thread, so
this is invisible to users — no thread fragmentation, no UI symptoms, no
error logs. Just slow accumulation of orphan thread rows that no query
joins onto. Probably worth running

```sql
SELECT COUNT(*)
FROM "messageThread" mt
WHERE NOT EXISTS (
  SELECT 1 FROM message m WHERE m."messageThreadId" = mt.id
);
```

on a busy production workspace once this lands to size whether a cleanup
migration is warranted.

**Fix:** mutate the existing accumulator in place instead of replacing
it.

## Test plan

- [x] `oxlint --type-aware` clean on touched file
- [x] `prettier` clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-04-07 13:23:31 +02:00

503 lines
17 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { In } from 'typeorm';
import { v4 } from 'uuid';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
type MessageAccumulator = {
existingMessageInDB?: MessageWorkspaceEntity;
existingThreadInDB?: Pick<MessageThreadWorkspaceEntity, 'id'>;
existingMessageChannelMessageAssociationInDB?: MessageChannelMessageAssociationWorkspaceEntity;
messageToCreate?: Pick<
MessageWorkspaceEntity,
| 'id'
| 'headerMessageId'
| 'subject'
| 'receivedAt'
| 'text'
| 'messageThreadId'
>;
threadToCreate?: Pick<MessageThreadWorkspaceEntity, 'id' | 'subject'>;
messageChannelMessageAssociationToCreate?: Pick<
MessageChannelMessageAssociationWorkspaceEntity,
| 'id'
| 'messageChannelId'
| 'messageId'
| 'messageExternalId'
| 'messageThreadExternalId'
| 'direction'
>;
};
@Injectable()
export class MessagingMessageService {
private readonly logger = new Logger(MessagingMessageService.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
public async saveMessagesWithinTransaction(
messages: MessageWithParticipants[],
messageChannelId: string,
transactionManager: WorkspaceEntityManager,
workspaceId: string,
): Promise<{
createdMessages: Partial<MessageWorkspaceEntity>[];
messageExternalIdsAndIdsMap: Map<string, string>;
messageExternalIdToMessageChannelMessageAssociationIdMap: Map<
string,
string
>;
}> {
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const messageChannelMessageAssociationRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelMessageAssociationWorkspaceEntity>(
workspaceId,
'messageChannelMessageAssociation',
);
const messageRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageWorkspaceEntity>(
workspaceId,
'message',
);
const messageThreadRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageThreadWorkspaceEntity>(
workspaceId,
'messageThread',
);
const messageAccumulatorMap = new Map<string, MessageAccumulator>();
const existingMessagesInDB = await messageRepository.find({
where: {
headerMessageId: In(
messages.map((message) => message.headerMessageId),
),
},
});
const messageChannelMessageAssociationsReferencingMessageThread =
await messageChannelMessageAssociationRepository.find(
{
where: {
messageThreadExternalId: In(
messages.map((message) => message.messageThreadExternalId),
),
messageChannelId,
},
relations: ['message'],
},
transactionManager,
);
const existingMessageChannelMessageAssociations =
await messageChannelMessageAssociationRepository.find({
where: {
messageId: In(existingMessagesInDB.map((message) => message.id)),
messageChannelId,
},
});
await this.enrichMessageAccumulatorWithExistingMessages(
messages,
messageAccumulatorMap,
existingMessagesInDB,
);
await this.enrichMessageAccumulatorWithExistingMessageThreadIds(
messages,
messageAccumulatorMap,
messageChannelMessageAssociationsReferencingMessageThread,
workspaceId,
);
await this.enrichMessageAccumulatorWithExistingMessageChannelMessageAssociations(
messages,
messageAccumulatorMap,
existingMessageChannelMessageAssociations,
);
await this.enrichMessageAccumulatorWithMessageThreadToCreate(
messages,
messageAccumulatorMap,
);
for (const message of messages) {
const messageAccumulator = messageAccumulatorMap.get(
message.externalId,
);
if (!isDefined(messageAccumulator)) {
throw new Error(
`Message accumulator should reference the message, this should never happen`,
);
}
const messageThreadId =
messageAccumulator.threadToCreate?.id ??
messageAccumulator.existingThreadInDB?.id;
if (!isDefined(messageThreadId)) {
throw new Error(
`Message thread id should be defined, either in the threadToCreate or existingThreadInDB`,
);
}
let newOrExistingMessageId: string;
if (!isDefined(messageAccumulator.existingMessageInDB)) {
newOrExistingMessageId = v4();
const messageToCreate = {
id: newOrExistingMessageId,
headerMessageId: message.headerMessageId,
subject: message.subject,
receivedAt: message.receivedAt,
text: message.text,
messageThreadId,
};
messageAccumulator.messageToCreate = messageToCreate;
} else {
newOrExistingMessageId = messageAccumulator.existingMessageInDB.id;
}
if (
!isDefined(
messageAccumulator.existingMessageChannelMessageAssociationInDB,
)
) {
messageAccumulator.messageChannelMessageAssociationToCreate = {
id: v4(),
messageChannelId,
messageId: newOrExistingMessageId,
messageExternalId: message.externalId,
messageThreadExternalId: message.messageThreadExternalId,
direction: message.direction,
};
}
messageAccumulatorMap.set(message.externalId, messageAccumulator);
}
const messageThreadsToCreate = Array.from(
messageAccumulatorMap.values(),
)
.map((accumulator) => accumulator.threadToCreate)
.filter(isDefined);
const threadSubjectUpdates = new Map<
string,
{ subject: string; receivedAt: number }
>();
for (const message of messages) {
const messageAccumulator = messageAccumulatorMap.get(
message.externalId,
);
if (!isDefined(messageAccumulator)) {
continue;
}
if (
isDefined(messageAccumulator.existingThreadInDB) &&
isDefined(messageAccumulator.messageToCreate) &&
isDefined(message.subject)
) {
const threadId = messageAccumulator.existingThreadInDB.id;
const existing = threadSubjectUpdates.get(threadId);
const receivedAt = message.receivedAt?.getTime() ?? 0;
if (!isDefined(existing) || receivedAt > existing.receivedAt) {
threadSubjectUpdates.set(threadId, {
subject: message.subject,
receivedAt,
});
}
}
}
if (messageThreadsToCreate.length > 0) {
await messageThreadRepository.insert(
messageThreadsToCreate,
transactionManager,
);
}
if (threadSubjectUpdates.size > 0) {
await messageThreadRepository.upsert(
Array.from(threadSubjectUpdates.entries()).map(
([id, { subject }]) => ({ id, subject }),
),
['id'],
transactionManager,
);
}
const messagesToCreate = Array.from(messageAccumulatorMap.values())
.map((accumulator) => accumulator.messageToCreate)
.filter(isDefined);
await messageRepository.insert(messagesToCreate, transactionManager);
const messageChannelMessageAssociationsToCreate = Array.from(
messageAccumulatorMap.values(),
)
.map(
(accumulator) =>
accumulator.messageChannelMessageAssociationToCreate,
)
.filter(isDefined);
await messageChannelMessageAssociationRepository.insert(
messageChannelMessageAssociationsToCreate,
transactionManager,
);
const messageExternalIdsAndIdsMap = new Map<string, string>();
const messageExternalIdToMessageChannelMessageAssociationIdMap =
new Map<string, string>();
for (const [
externalId,
accumulator,
] of messageAccumulatorMap.entries()) {
if (isDefined(accumulator.messageToCreate)) {
messageExternalIdsAndIdsMap.set(
externalId,
accumulator.messageToCreate.id,
);
}
if (isDefined(accumulator.existingMessageInDB)) {
messageExternalIdsAndIdsMap.set(
externalId,
accumulator.existingMessageInDB.id,
);
}
const createdAssociationId =
accumulator.messageChannelMessageAssociationToCreate?.id;
const existingAssociationId =
accumulator.existingMessageChannelMessageAssociationInDB?.id;
const associationId = createdAssociationId ?? existingAssociationId;
if (isDefined(associationId)) {
messageExternalIdToMessageChannelMessageAssociationIdMap.set(
externalId,
associationId,
);
}
}
return {
createdMessages: messagesToCreate,
messageExternalIdsAndIdsMap,
messageExternalIdToMessageChannelMessageAssociationIdMap,
};
},
authContext,
);
}
private async enrichMessageAccumulatorWithExistingMessages(
messages: MessageWithParticipants[],
messageAccumulatorMap: Map<string, MessageAccumulator>,
existingMessagesInDB: MessageWorkspaceEntity[],
) {
for (const message of messages) {
const existingMessage = existingMessagesInDB.find(
(existingMessage) =>
existingMessage.headerMessageId === message.headerMessageId,
);
if (!isDefined(existingMessage)) {
messageAccumulatorMap.set(message.externalId, {});
continue;
}
messageAccumulatorMap.set(message.externalId, {
existingMessageInDB: existingMessage,
});
}
}
private async enrichMessageAccumulatorWithExistingMessageThreadIds(
messages: MessageWithParticipants[],
messageAccumulatorMap: Map<string, MessageAccumulator>,
messageChannelMessageAssociationsReferencingMessageThread: Pick<
MessageChannelMessageAssociationWorkspaceEntity,
'messageThreadExternalId' | 'message'
>[],
workspaceId: string,
) {
for (const message of messages) {
const messageAccumulator = messageAccumulatorMap.get(message.externalId);
if (!isDefined(messageAccumulator)) {
throw new Error(
`Message accumulator should reference the message, this should never happen`,
);
}
const messageChannelMessageAssociationReferencingMessageThread =
messageChannelMessageAssociationsReferencingMessageThread.find(
(association) =>
association.messageThreadExternalId ===
message.messageThreadExternalId,
);
const existingThreadIdInDBIfMessageIsExistingInDB =
messageAccumulator.existingMessageInDB?.messageThreadId;
const existingThreadIdInDBIfMessageIsReferencedInMessageChannelMessageAssociation =
messageChannelMessageAssociationReferencingMessageThread?.message
?.messageThreadId;
if (isDefined(existingThreadIdInDBIfMessageIsExistingInDB)) {
messageAccumulator.existingThreadInDB = {
id: existingThreadIdInDBIfMessageIsExistingInDB,
};
}
if (
isDefined(
existingThreadIdInDBIfMessageIsReferencedInMessageChannelMessageAssociation,
)
) {
messageAccumulator.existingThreadInDB = {
id: existingThreadIdInDBIfMessageIsReferencedInMessageChannelMessageAssociation,
};
}
if (
isDefined(existingThreadIdInDBIfMessageIsExistingInDB) &&
isDefined(
existingThreadIdInDBIfMessageIsReferencedInMessageChannelMessageAssociation,
) &&
existingThreadIdInDBIfMessageIsExistingInDB !==
existingThreadIdInDBIfMessageIsReferencedInMessageChannelMessageAssociation
) {
this.logger.warn(
`
WorkspaceId: ${workspaceId} /
Message ExternalId: ${message.externalId} /
Message HeaderId: ${message.headerMessageId} /
Message Thread ExternalId: ${message.messageThreadExternalId} /
Message Thread Id in DB: ${existingThreadIdInDBIfMessageIsExistingInDB} /
Message Thread Id in Message Channel Message Association: ${existingThreadIdInDBIfMessageIsReferencedInMessageChannelMessageAssociation} /
Message Subject: ${message.subject} /
Message Received At: ${message.receivedAt} /
Thread inter channel detected`,
);
}
messageAccumulatorMap.set(message.externalId, messageAccumulator);
}
}
private async enrichMessageAccumulatorWithExistingMessageChannelMessageAssociations(
messages: MessageWithParticipants[],
messageAccumulatorMap: Map<string, MessageAccumulator>,
existingMessageChannelMessageAssociations: MessageChannelMessageAssociationWorkspaceEntity[],
) {
for (const message of messages) {
const messageAccumulator = messageAccumulatorMap.get(message.externalId);
if (!isDefined(messageAccumulator)) {
throw new Error(
`Message accumulator should reference the message, this should never happen`,
);
}
const existingMessage = messageAccumulator.existingMessageInDB;
if (!isDefined(existingMessage)) {
continue;
}
const existingMessageChannelMessageAssociation =
existingMessageChannelMessageAssociations.find(
(association) => association.messageId === existingMessage.id,
);
if (existingMessageChannelMessageAssociation) {
messageAccumulator.existingMessageChannelMessageAssociationInDB =
existingMessageChannelMessageAssociation;
}
}
}
private async enrichMessageAccumulatorWithMessageThreadToCreate(
messages: MessageWithParticipants[],
messageAccumulatorMap: Map<string, MessageAccumulator>,
) {
for (const [index, message] of messages.entries()) {
const messageAccumulator = messageAccumulatorMap.get(message.externalId);
if (!isDefined(messageAccumulator)) {
throw new Error(
`Message accumulator should reference the message, this should never happen`,
);
}
const previousMessageWithSameThreadExternalId = messages.find(
(otherMessage, otherMessageIndex) =>
otherMessage.messageThreadExternalId ===
message.messageThreadExternalId && otherMessageIndex < index,
);
let newOrExistingMessageThreadId: string | undefined;
if (isDefined(messageAccumulator.existingThreadInDB)) {
newOrExistingMessageThreadId = messageAccumulator.existingThreadInDB.id;
}
if (isDefined(previousMessageWithSameThreadExternalId)) {
const previousMessageAccumulator = messageAccumulatorMap.get(
previousMessageWithSameThreadExternalId.externalId,
);
const previousMessageThreadId =
previousMessageAccumulator?.threadToCreate?.id ??
previousMessageAccumulator?.existingThreadInDB?.id;
if (!isDefined(previousMessageThreadId)) {
throw new Error(
`Previous message should have a thread id, either in the messageToCreate or existingMessageInDB`,
);
}
newOrExistingMessageThreadId = previousMessageThreadId;
messageAccumulator.existingThreadInDB = {
id: previousMessageThreadId,
};
}
if (!isDefined(newOrExistingMessageThreadId)) {
newOrExistingMessageThreadId = v4();
messageAccumulator.threadToCreate = {
id: newOrExistingMessageThreadId,
subject: message.subject,
};
}
messageAccumulatorMap.set(message.externalId, messageAccumulator);
}
}
}