fix: only cap message expiry to the parent on message-only saves

Capping every forced message save to the parent expiry broke the normal send
paths: POST /api/messages/:conversationId and BaseClient.saveMessageToDatabase
call saveConvo right after saveMessage, refreshing the conversation to a fresh
TTL. The message kept the older parent deadline, so the message TTL index could
delete the just-sent message while the conversation stayed visible until the
later deadline.

Gate the cap behind a capExpiryToConversation flag that only the message-only
callers (branch, artifact, abort) set, since those never run saveConvo. Normal
sends leave the message on its fresh deadline, which the following saveConvo
refresh keeps aligned. The conversion/re-cap cascade still runs for every forced
save.
This commit is contained in:
Marco Beretta 2026-06-22 09:39:09 +02:00
parent ec8eec6eaf
commit fd8292669d
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 47 additions and 19 deletions

View file

@ -173,7 +173,7 @@ async function abortMessage(req, res) {
interfaceConfig: req?.config?.interfaceConfig,
},
{ ...responseMessage, user: userId },
{ context: 'api/server/middleware/abortMiddleware.js' },
{ context: 'api/server/middleware/abortMiddleware.js', capExpiryToConversation: true },
);
// Get conversation for title

View file

@ -175,7 +175,7 @@ router.post('/branch', configMiddleware, async (req, res) => {
interfaceConfig: req?.config?.interfaceConfig,
},
newMessage,
{ context: 'POST /api/messages/branch' },
{ context: 'POST /api/messages/branch', capExpiryToConversation: true },
);
if (!savedMessage) {
@ -256,7 +256,7 @@ router.post('/artifact/:messageId', configMiddleware, async (req, res) => {
content: message.content,
user: req.user.id,
},
{ context: 'POST /api/messages/artifact/:messageId' },
{ context: 'POST /api/messages/artifact/:messageId', capExpiryToConversation: true },
);
res.status(200).json({

View file

@ -899,7 +899,7 @@ describe('Message Operations', () => {
}
});
it('caps a message to a sooner parent expiry instead of orphaning it', async () => {
it('caps a message-only save to a sooner parent expiry instead of orphaning it', async () => {
const conversationId = uuidv4();
const parentExpiry = new Date(Date.now() + 60 * 60 * 1000);
await Conversation().create({
@ -917,6 +917,7 @@ describe('Message Operations', () => {
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
{ context: 'branch', capExpiryToConversation: true },
);
expect(saved?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
@ -926,6 +927,29 @@ describe('Message Operations', () => {
expect(saved?.expiredAt?.getTime()).toBeLessThanOrEqual(convo?.expiredAt?.getTime() ?? 0);
});
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);
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Ephemeral chat expiring soon',
isTemporary: true,
expiredAt: parentExpiry,
});
const saved = await saveMessage(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ messageId: uuidv4(), conversationId, text: 'follow-up', user: 'user123' },
);
expect(saved?.expiredAt?.getTime()).toBeGreaterThan(parentExpiry.getTime());
});
it('does not touch the parent conversation outside forced retention', async () => {
const conversationId = uuidv4();
await Conversation().create({

View file

@ -2,7 +2,6 @@ import { RetentionMode, isForcedTemporaryRetention } from 'librechat-data-provid
import type { DeleteResult, FilterQuery, Model, PipelineStage } from 'mongoose';
import type { AppConfig, IConversation, IMessage } from '~/types';
import {
conversationNeedsForcedRetention,
createFallbackRetentionDate,
forceConversationMessagesTemporary,
forcedRetentionGapFilter,
@ -36,7 +35,7 @@ export interface MessageMethods {
saveMessage(
ctx: { userId: string; isTemporary?: boolean; interfaceConfig?: AppConfig['interfaceConfig'] },
params: Partial<IMessage> & { newMessageId?: string },
metadata?: { context?: string },
metadata?: { context?: string; capExpiryToConversation?: boolean },
): Promise<IMessage | null | undefined>;
bulkSaveMessages(
messages: Array<Partial<IMessage>>,
@ -102,7 +101,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
interfaceConfig?: AppConfig['interfaceConfig'];
},
params: Partial<IMessage> & { newMessageId?: string },
metadata?: { context?: string },
metadata?: { context?: string; capExpiryToConversation?: boolean },
) {
if (!userId) {
throw new Error('User not authenticated');
@ -168,19 +167,28 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
const forcedExpiredAt = update.expiredAt;
const isForcedRetention = isForcedTemporaryRetention(interfaceConfig?.retentionMode);
let parentRetention: { isTemporary?: boolean | null; expiredAt?: Date | null } | null = null;
if (isForcedRetention && forcedExpiredAt instanceof Date) {
/**
* Message-only saves (branch/artifact/abort) never run `saveConvo`, so the new
* message must not outlive a parent that already expires sooner. Callers that DO
* refresh the conversation afterward must not opt in, otherwise the message keeps
* the stale deadline and the TTL index deletes it before the refreshed conversation.
*/
if (
isForcedRetention &&
forcedExpiredAt instanceof Date &&
metadata?.capExpiryToConversation === true
) {
const Conversation = mongoose.models.Conversation as Model<IConversation>;
parentRetention = await Conversation.findOne(
const parent = await Conversation.findOne(
{ conversationId, user: userId },
'isTemporary expiredAt',
).lean<{ isTemporary?: boolean | null; expiredAt?: Date | null } | null>();
if (
parentRetention?.isTemporary === true &&
parentRetention.expiredAt != null &&
parentRetention.expiredAt.getTime() < forcedExpiredAt.getTime()
parent?.isTemporary === true &&
parent.expiredAt != null &&
parent.expiredAt.getTime() < forcedExpiredAt.getTime()
) {
update.expiredAt = parentRetention.expiredAt;
update.expiredAt = parent.expiredAt;
}
}
@ -203,11 +211,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
message.isTemporary = false;
}
if (
isForcedRetention &&
forcedExpiredAt instanceof Date &&
conversationNeedsForcedRetention(parentRetention, forcedExpiredAt)
) {
if (isForcedRetention && forcedExpiredAt instanceof Date) {
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const convoResult = await Conversation.updateOne(
{