fix: cap normal message saves to the preserved parent expiry

saveConvo now preserves a conversation's earlier ephemeral deadline
instead of refreshing it, so the message cap can no longer be opt-in.
A normal send to an existing ephemeral conversation whose parent
already expires sooner saved the new message with a fresh, later TTL;
saveConvo kept the parent at the earlier deadline and the cascade
skipped (parent already conforms), leaving the new message to outlive
the deleted conversation.

Cap every forced-retention message save to the parent's deadline, not
just the opt-in message-only paths. Message-only saves still backfill
the parent's other messages and shares; normal saves only cap the
message itself and let saveConvo and the cascade handle the rest.
This commit is contained in:
Marco Beretta 2026-06-24 17:52:27 +02:00
parent 98dd93d8c1
commit acd150d4af
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
2 changed files with 30 additions and 20 deletions

View file

@ -978,7 +978,7 @@ describe('Message Operations', () => {
expect(lagging?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
});
it('does not cap a normal send save so a following conversation refresh stays aligned', async () => {
it('caps a normal send to a parent expiring sooner so it cannot outlive the conversation', async () => {
const conversationId = uuidv4();
const parentExpiry = new Date(Date.now() + 60 * 60 * 1000);
await Conversation().create({
@ -998,7 +998,11 @@ describe('Message Operations', () => {
{ messageId: uuidv4(), conversationId, text: 'follow-up', user: 'user123' },
);
expect(saved?.expiredAt?.getTime()).toBeGreaterThan(parentExpiry.getTime());
expect(saved?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
const convo = await Conversation().findOne({ conversationId }).lean();
expect(convo?.expiredAt?.getTime()).toBe(parentExpiry.getTime());
expect(saved?.expiredAt?.getTime()).toBeLessThanOrEqual(convo?.expiredAt?.getTime() ?? 0);
});
it('does not touch the parent conversation outside forced retention', async () => {

View file

@ -2,6 +2,7 @@ import { RetentionMode, isForcedTemporaryRetention } from 'librechat-data-provid
import type { DeleteResult, FilterQuery, Model } from 'mongoose';
import type { AppConfig, IConversation, IMessage, ISharedLink } from '~/types';
import {
capForcedRetentionExpiry,
capForcedRetentionToParent,
cascadeForcedConversationRetention,
cascadeForcedRetentionByTag,
@ -162,26 +163,31 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
const forcedExpiredAt = update.expiredAt;
const isForcedRetention = isForcedTemporaryRetention(interfaceConfig?.retentionMode);
/**
* 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.
* Under forced retention the new message must never outlive a parent that already
* expires sooner, since `saveConvo` preserves that earlier deadline rather than
* refreshing it. Message-only saves (branch/artifact/abort) additionally backfill the
* parent's other messages and shares because no `saveConvo` follows to cascade; normal
* saves only cap the message itself and let the conversation cascade handle the rest.
*/
if (
isForcedRetention &&
forcedExpiredAt instanceof Date &&
metadata?.capExpiryToConversation === true
) {
if (isForcedRetention && forcedExpiredAt instanceof Date) {
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
update.expiredAt = await capForcedRetentionToParent(
Conversation,
Message,
SharedLink,
userId,
conversationId,
forcedExpiredAt,
);
if (metadata?.capExpiryToConversation === true) {
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
update.expiredAt = await capForcedRetentionToParent(
Conversation,
Message,
SharedLink,
userId,
conversationId,
forcedExpiredAt,
);
} else {
const parent = await Conversation.findOne(
{ conversationId, user: userId },
'expiredAt',
).lean<{ expiredAt?: Date | null } | null>();
update.expiredAt = capForcedRetentionExpiry(parent?.expiredAt, forcedExpiredAt);
}
}
const message = await Message.findOneAndUpdate(