f39fccc3c4
## 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)
503 lines
17 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|