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:
Marco Beretta 2026-06-22 17:52:54 +02:00
parent 84ab681adf
commit da5b1e222b
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
3 changed files with 82 additions and 23 deletions

View file

@ -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);

View file

@ -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(

View file

@ -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