mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-10 08:13:46 +00:00
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:
parent
ec8eec6eaf
commit
fd8292669d
4 changed files with 47 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue