mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-03 04:42:11 +00:00
fix: backfill lagging messages when capping to an already-sooner parent
When a message-only forced save caps to a parent that is already temporary and expires before the freshly computed window, the gated cascade leaves the parent untouched (modifiedCount 0) and skips the message backfill. Older messages with expiredAt null or a later deadline then survive after the parent's TTL deletes the conversation. Extract the cap logic into capForcedRetentionToParent, shared by saveMessage and applyForcedRetention, which now also backfills the conversation's non-conforming messages to the parent's earlier deadline. The hot normal-send path does not cap, so it keeps the gated cascade with no extra per-message work.
This commit is contained in:
parent
84ab681adf
commit
da5b1e222b
3 changed files with 82 additions and 23 deletions
|
|
@ -902,6 +902,39 @@ describe('Message Operations', () => {
|
|||
expect(saved?.expiredAt?.getTime()).toBeLessThanOrEqual(convo?.expiredAt?.getTime() ?? 0);
|
||||
});
|
||||
|
||||
it('backfills lagging messages when the parent already expires sooner', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const parentExpiry = new Date(Date.now() + 60 * 60 * 1000);
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Ephemeral chat expiring soon',
|
||||
isTemporary: true,
|
||||
expiredAt: parentExpiry,
|
||||
});
|
||||
const laggingMessageId = uuidv4();
|
||||
await Message.create({
|
||||
messageId: laggingMessageId,
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
text: 'older message with no expiry',
|
||||
});
|
||||
|
||||
await saveMessage(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
|
||||
{ context: 'branch', capExpiryToConversation: true },
|
||||
);
|
||||
|
||||
const lagging = await Message.findOne({ messageId: laggingMessageId }).lean();
|
||||
expect(lagging?.isTemporary).toBe(true);
|
||||
expect(lagging?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
|
||||
});
|
||||
|
||||
it('does not cap a normal send save so a following conversation refresh stays aligned', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const parentExpiry = new Date(Date.now() + 60 * 60 * 1000);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { RetentionMode, isForcedTemporaryRetention } from 'librechat-data-provider';
|
||||
import type { DeleteResult, FilterQuery, Model } from 'mongoose';
|
||||
import type { AppConfig, IConversation, IMessage } from '~/types';
|
||||
import { cascadeForcedConversationRetention, createFallbackRetentionDate } from '~/utils/retention';
|
||||
import {
|
||||
capForcedRetentionToParent,
|
||||
cascadeForcedConversationRetention,
|
||||
createFallbackRetentionDate,
|
||||
} from '~/utils/retention';
|
||||
import { createTempChatExpirationDate } from '~/utils/tempChatRetention';
|
||||
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
|
||||
import logger from '~/config/winston';
|
||||
|
|
@ -163,17 +167,13 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
metadata?.capExpiryToConversation === true
|
||||
) {
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const parent = await Conversation.findOne(
|
||||
{ conversationId, user: userId },
|
||||
'isTemporary expiredAt',
|
||||
).lean<{ isTemporary?: boolean | null; expiredAt?: Date | null } | null>();
|
||||
if (
|
||||
parent?.isTemporary === true &&
|
||||
parent.expiredAt != null &&
|
||||
parent.expiredAt.getTime() < forcedExpiredAt.getTime()
|
||||
) {
|
||||
update.expiredAt = parent.expiredAt;
|
||||
}
|
||||
update.expiredAt = await capForcedRetentionToParent(
|
||||
Conversation,
|
||||
Message,
|
||||
userId,
|
||||
conversationId,
|
||||
forcedExpiredAt,
|
||||
);
|
||||
}
|
||||
|
||||
const message = await Message.findOneAndUpdate(
|
||||
|
|
@ -386,17 +386,13 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
|
||||
if (metadata?.capExpiryToConversation === true) {
|
||||
const parent = await Conversation.findOne(
|
||||
{ conversationId, user: userId },
|
||||
'isTemporary expiredAt',
|
||||
).lean<{ isTemporary?: boolean | null; expiredAt?: Date | null } | null>();
|
||||
if (
|
||||
parent?.isTemporary === true &&
|
||||
parent.expiredAt != null &&
|
||||
parent.expiredAt.getTime() < forcedExpiredAt.getTime()
|
||||
) {
|
||||
forcedExpiredAt = parent.expiredAt;
|
||||
}
|
||||
forcedExpiredAt = await capForcedRetentionToParent(
|
||||
Conversation,
|
||||
Message,
|
||||
userId,
|
||||
conversationId,
|
||||
forcedExpiredAt,
|
||||
);
|
||||
}
|
||||
|
||||
await Message.updateOne(
|
||||
|
|
|
|||
|
|
@ -91,6 +91,36 @@ export const forceConversationMessagesTemporary = async (
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Caps a message-only forced save to a parent that already expires sooner than the freshly
|
||||
* computed window. Returns the parent's earlier deadline (so the message cannot outlive it)
|
||||
* and backfills the conversation's lagging messages to that deadline — the cascade leaves
|
||||
* an already-conforming parent untouched, so older `expiredAt: null`/later messages would
|
||||
* otherwise survive the parent's TTL. Returns the forced window unchanged when no earlier
|
||||
* parent deadline applies.
|
||||
*/
|
||||
export const capForcedRetentionToParent = async (
|
||||
Conversation: Model<IConversation>,
|
||||
Message: Model<IMessage>,
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
forcedExpiredAt: Date,
|
||||
): Promise<Date> => {
|
||||
const parent = await Conversation.findOne(
|
||||
{ conversationId, user: userId },
|
||||
'isTemporary expiredAt',
|
||||
).lean<{ isTemporary?: boolean | null; expiredAt?: Date | null } | null>();
|
||||
if (
|
||||
parent?.isTemporary === true &&
|
||||
parent.expiredAt instanceof Date &&
|
||||
parent.expiredAt.getTime() < forcedExpiredAt.getTime()
|
||||
) {
|
||||
await forceConversationMessagesTemporary(Message, userId, conversationId, parent.expiredAt);
|
||||
return parent.expiredAt;
|
||||
}
|
||||
return forcedExpiredAt;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts or re-caps a parent conversation to the forced deadline and, when that first
|
||||
* brings the conversation into the forced window, backfills its lagging messages. Shared
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue