mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-10 08:13:46 +00:00
fix: preserve earlier message and file expiries under forced retention
Two leftover paths still extended data that was already scheduled to expire sooner when forcing ephemeral retention: - forceConversationMessagesTemporary (and the by-tag message backfill) rewrote every gap-matched message to the forced deadline, overwriting carried-over `all`-mode messages whose own per-message TTL was already sooner. Use a $min aggregation update so each message keeps its earlier deadline while still being marked temporary and capped if later. - File uploads under ephemeral retention skipped the conversation lookup and got a fresh TTL, so a new attachment to a chat with a preserved earlier expiry could outlive the TTL-deleted conversation. Cap ephemeral file expiry to the minimum of the fresh window and the active parent conversation expiry, mirroring the message and shared-link paths.
This commit is contained in:
parent
acd150d4af
commit
75110c70e5
4 changed files with 110 additions and 6 deletions
|
|
@ -52,14 +52,41 @@ describe('retention helpers', () => {
|
|||
expect(dependencies.getConvo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns expiry when retentionMode is EPHEMERAL', async () => {
|
||||
it('returns a fresh expiry when retentionMode is EPHEMERAL and the conversation has no earlier deadline', async () => {
|
||||
dependencies.getConvo.mockResolvedValue(null);
|
||||
|
||||
const result = await getRetentionExpiry(
|
||||
request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }),
|
||||
dependencies,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ expiredAt: expirationDate });
|
||||
expect(dependencies.getConvo).toHaveBeenCalledWith('user-1', 'convo-1');
|
||||
});
|
||||
|
||||
it('caps an ephemeral file to a parent conversation that expires sooner', async () => {
|
||||
const soonerExpiry = new Date('2029-01-01T00:00:00.000Z');
|
||||
dependencies.getConvo.mockResolvedValue({ expiredAt: soonerExpiry });
|
||||
|
||||
const result = await getRetentionExpiry(
|
||||
request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }),
|
||||
dependencies,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ expiredAt: soonerExpiry });
|
||||
});
|
||||
|
||||
it('keeps the fresh window when an ephemeral parent expires later', async () => {
|
||||
dependencies.getConvo.mockResolvedValue({
|
||||
expiredAt: new Date('2031-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const result = await getRetentionExpiry(
|
||||
request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }),
|
||||
dependencies,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ expiredAt: expirationDate });
|
||||
expect(dependencies.getConvo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a fresh expiry when the conversation has an active expiration', async () => {
|
||||
|
|
@ -322,7 +349,7 @@ describe('retention helpers', () => {
|
|||
);
|
||||
|
||||
expect(result).toEqual({ expiredAt: expirationDate });
|
||||
expect(dependencies.getConvo).not.toHaveBeenCalled();
|
||||
expect(dependencies.getConvo).toHaveBeenCalledWith('user-1', 'convo-1');
|
||||
expect(dependencies.createExpirationDate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -99,11 +99,50 @@ const getRetentionCacheKey = (req: RetentionRequest): string =>
|
|||
String(req.body?.isTemporary ?? ''),
|
||||
].join('|');
|
||||
|
||||
/**
|
||||
* Resolves a forced (ephemeral) file deadline. The file is always retained, but capped to the
|
||||
* minimum of a freshly created window and the parent conversation's active expiry, so a new
|
||||
* attachment cannot linger in files/storage after the conversation and messages are TTL-deleted.
|
||||
*/
|
||||
async function computeForcedRetentionExpiry(
|
||||
req: RetentionRequest | null | undefined,
|
||||
dependencies: RetentionDependencies,
|
||||
): Promise<RetentionExpiry> {
|
||||
const fresh = createRetentionExpiry(req, dependencies);
|
||||
const conversationId = req?.body?.conversationId;
|
||||
const userId = req?.user?.id;
|
||||
if (!conversationId || !userId) {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
try {
|
||||
const convo = await dependencies.getConvo(userId, conversationId);
|
||||
const conversationExpiredAt = getConversationExpirationDate(convo);
|
||||
if (conversationExpiredAt == null) {
|
||||
return fresh;
|
||||
}
|
||||
if (!isActiveExpirationDate(conversationExpiredAt)) {
|
||||
return { expiredAt: conversationExpiredAt };
|
||||
}
|
||||
if (fresh.expiredAt != null && conversationExpiredAt < fresh.expiredAt) {
|
||||
return { expiredAt: conversationExpiredAt };
|
||||
}
|
||||
return fresh;
|
||||
} catch (err) {
|
||||
dependencies.logger?.error('[getRetentionExpiry] Error checking conversation retention:', err);
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
|
||||
async function computeRetentionExpiry(
|
||||
req: RetentionRequest | null | undefined,
|
||||
dependencies: RetentionDependencies,
|
||||
): Promise<RetentionExpiry> {
|
||||
if (isAllDataRetention(req?.config?.interfaceConfig?.retentionMode)) {
|
||||
const retentionMode = req?.config?.interfaceConfig?.retentionMode;
|
||||
if (retentionMode === RetentionMode.EPHEMERAL) {
|
||||
return computeForcedRetentionExpiry(req, dependencies);
|
||||
}
|
||||
if (isAllDataRetention(retentionMode)) {
|
||||
return createRetentionExpiry(req, dependencies);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -837,6 +837,39 @@ describe('Message Operations', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('preserves a carried-over message that already expires sooner than the forced window', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const soonerExpiry = new Date(Date.now() + 30 * 60 * 1000);
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Permanent chat with a sooner-expiring message',
|
||||
isTemporary: false,
|
||||
});
|
||||
const soonerMessageId = uuidv4();
|
||||
await Message.create({
|
||||
messageId: soonerMessageId,
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
text: 'all-mode message with its own sooner TTL',
|
||||
isTemporary: false,
|
||||
expiredAt: soonerExpiry,
|
||||
});
|
||||
|
||||
await saveMessage(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ messageId: uuidv4(), conversationId, text: 'follow-up', user: 'user123' },
|
||||
);
|
||||
|
||||
const sooner = await Message.findOne({ messageId: soonerMessageId }).lean();
|
||||
expect(sooner?.isTemporary).toBe(true);
|
||||
expect(sooner?.expiredAt?.getTime()).toBe(soonerExpiry.getTime());
|
||||
});
|
||||
|
||||
it('re-caps an already temporary parent and its messages to a shorter ephemeral window', async () => {
|
||||
const conversationId = uuidv4();
|
||||
const longerExpiry = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
|
||||
|
|
|
|||
|
|
@ -94,6 +94,11 @@ export const capForcedRetentionExpiry = (
|
|||
* whose `expiredAt` is later than a newly shortened window — that would otherwise outlive
|
||||
* the converted conversation. The gap filter pulls all of them onto the ephemeral schedule
|
||||
* and stays a no-op once a conversation already conforms.
|
||||
*
|
||||
* Each message keeps its own earlier deadline: a carried-over message whose per-message TTL
|
||||
* already expires sooner than the forced window is marked temporary but its `expiredAt` is
|
||||
* left untouched (`$min`), so converting the conversation never extends data that was already
|
||||
* scheduled to expire sooner.
|
||||
*/
|
||||
export const forceConversationMessagesTemporary = async (
|
||||
Message: Model<IMessage>,
|
||||
|
|
@ -103,7 +108,7 @@ export const forceConversationMessagesTemporary = async (
|
|||
): Promise<void> => {
|
||||
await Message.updateMany(
|
||||
{ conversationId, user: userId, ...forcedRetentionGapFilter<IMessage>(expiredAt) },
|
||||
{ $set: { isTemporary: true, expiredAt } },
|
||||
[{ $set: { isTemporary: true, expiredAt: { $min: ['$expiredAt', expiredAt] } } }],
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -245,7 +250,7 @@ export const cascadeForcedRetentionByTag = async (
|
|||
conversationId: { $in: conversationIds },
|
||||
...forcedRetentionGapFilter<IMessage>(expiredAt),
|
||||
},
|
||||
{ $set: { isTemporary: true, expiredAt } },
|
||||
[{ $set: { isTemporary: true, expiredAt: { $min: ['$expiredAt', expiredAt] } } }],
|
||||
);
|
||||
await SharedLink.updateMany(
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue