Cache: Autodelete old entries (#6580)

This commit is contained in:
zubiden 2026-01-13 01:14:32 +01:00 committed by Alexander Zinchuk
parent bb56647584
commit d67efa3f99
9 changed files with 161 additions and 17 deletions

View File

@ -2568,3 +2568,6 @@
"AttachmentSendFile_other" = "Send {count} Files"; "AttachmentSendFile_other" = "Send {count} Files";
"AttachmentDragAddItems" = "Add Items"; "AttachmentDragAddItems" = "Add Items";
"AttachmentCaptionPlaceholder" = "Add a caption..."; "AttachmentCaptionPlaceholder" = "Add a caption...";
"SettingsDataClearMediaCache" = "Clear Media Cache";
"SettingsDataClearMediaCacheDescription" = "Deletes locally cached media for this account";
"SettingsDataClearMediaDone" = "Media cache cleared";

View File

@ -1,21 +1,23 @@
import type { FC } from '../../../lib/teact/teact'; import { memo } from '../../../lib/teact/teact';
import { memo, useCallback } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { AccountSettings } from '../../../types'; import type { AccountSettings } from '../../../types';
import { AUTODOWNLOAD_FILESIZE_MB_LIMITS } from '../../../config'; import { AUTODOWNLOAD_FILESIZE_MB_LIMITS } from '../../../config';
import { purgeClearableCache } from '../../../util/cacheApi';
import { pick } from '../../../util/iteratees'; import { pick } from '../../../util/iteratees';
import useHistoryBack from '../../../hooks/useHistoryBack'; import useHistoryBack from '../../../hooks/useHistoryBack';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import Checkbox from '../../ui/Checkbox'; import Checkbox from '../../ui/Checkbox';
import ListItem from '../../ui/ListItem';
import RangeSlider from '../../ui/RangeSlider'; import RangeSlider from '../../ui/RangeSlider';
type OwnProps = { type OwnProps = {
isActive?: boolean; isActive?: boolean;
onReset: () => void; onReset: NoneToVoidFunction;
}; };
type StateProps = Pick<AccountSettings, ( type StateProps = Pick<AccountSettings, (
@ -34,9 +36,8 @@ type StateProps = Pick<AccountSettings, (
'autoLoadFileMaxSizeMb' 'autoLoadFileMaxSizeMb'
)>; )>;
const SettingsDataStorage: FC<OwnProps & StateProps> = ({ const SettingsDataStorage = ({
isActive, isActive,
onReset,
canAutoLoadPhotoFromContacts, canAutoLoadPhotoFromContacts,
canAutoLoadPhotoInPrivateChats, canAutoLoadPhotoInPrivateChats,
canAutoLoadPhotoInGroups, canAutoLoadPhotoInGroups,
@ -50,8 +51,9 @@ const SettingsDataStorage: FC<OwnProps & StateProps> = ({
canAutoLoadFileInGroups, canAutoLoadFileInGroups,
canAutoLoadFileInChannels, canAutoLoadFileInChannels,
autoLoadFileMaxSizeMb, autoLoadFileMaxSizeMb,
}) => { onReset,
const { setSettingOption } = getActions(); }: OwnProps & StateProps) => {
const { setSettingOption, showNotification } = getActions();
const lang = useLang(); const lang = useLang();
@ -60,16 +62,23 @@ const SettingsDataStorage: FC<OwnProps & StateProps> = ({
onBack: onReset, onBack: onReset,
}); });
const renderFileSizeCallback = useCallback((value: number) => { const renderFileSizeCallback = useLastCallback((value: number) => {
const size = AUTODOWNLOAD_FILESIZE_MB_LIMITS[value]; const size = AUTODOWNLOAD_FILESIZE_MB_LIMITS[value];
return lang('AutodownloadSizeLimitUpTo', { return lang('AutodownloadSizeLimitUpTo', {
limit: lang('MediaSizeMB', { size }, { pluralValue: size }), limit: lang('MediaSizeMB', { size }, { pluralValue: size }),
}); });
}, [lang]); });
const handleFileSizeChange = useCallback((value: number) => { const handleFileSizeChange = useLastCallback((value: number) => {
setSettingOption({ autoLoadFileMaxSizeMb: AUTODOWNLOAD_FILESIZE_MB_LIMITS[value] }); setSettingOption({ autoLoadFileMaxSizeMb: AUTODOWNLOAD_FILESIZE_MB_LIMITS[value] });
}, [setSettingOption]); });
const handlePurge = useLastCallback(() => {
purgeClearableCache();
showNotification({
message: { key: 'SettingsDataClearMediaDone' },
});
});
function renderContentSizeSlider() { function renderContentSizeSlider() {
const value = AUTODOWNLOAD_FILESIZE_MB_LIMITS.indexOf(autoLoadFileMaxSizeMb); const value = AUTODOWNLOAD_FILESIZE_MB_LIMITS.indexOf(autoLoadFileMaxSizeMb);
@ -157,6 +166,20 @@ const SettingsDataStorage: FC<OwnProps & StateProps> = ({
canAutoLoadFileInGroups, canAutoLoadFileInGroups,
canAutoLoadFileInChannels, canAutoLoadFileInChannels,
)} )}
<div className="settings-item">
<ListItem
onClick={handlePurge}
icon="delete"
multiline
>
<span className="title">
{lang('SettingsDataClearMediaCache')}
</span>
<span className="subtitle">
{lang('SettingsDataClearMediaCacheDescription')}
</span>
</ListItem>
</div>
</div> </div>
); );
}; };

View File

@ -61,7 +61,7 @@ export const MEDIA_PROGRESSIVE_CACHE_DISABLED = false;
export const MEDIA_PROGRESSIVE_CACHE_NAME = 'tt-media-progressive'; export const MEDIA_PROGRESSIVE_CACHE_NAME = 'tt-media-progressive';
export const MEDIA_CACHE_MAX_BYTES = 512 * 1024; // 512 KB export const MEDIA_CACHE_MAX_BYTES = 512 * 1024; // 512 KB
export const CUSTOM_BG_CACHE_NAME = 'tt-custom-bg'; export const CUSTOM_BG_CACHE_NAME = 'tt-custom-bg';
export const LANG_CACHE_NAME = 'tt-lang-packs-v50'; export const LANG_CACHE_NAME = 'tt-lang-packs-v51';
export const ASSET_CACHE_NAME = 'tt-assets'; export const ASSET_CACHE_NAME = 'tt-assets';
export const AUTODOWNLOAD_FILESIZE_MB_LIMITS = [1, 5, 10, 50, 100, 500]; export const AUTODOWNLOAD_FILESIZE_MB_LIMITS = [1, 5, 10, 50, 100, 500];
export const DATA_BROADCAST_CHANNEL_PREFIX = 'tt-global'; export const DATA_BROADCAST_CHANNEL_PREFIX = 'tt-global';

View File

@ -624,6 +624,23 @@ export function getMessageMediaHash(
return undefined; return undefined;
} }
export function getAllMessageMediaHashes(
message: MediaContainer,
statefulMedia: StatefulMediaContent,
) {
const targets: SizeTarget[] = ['micro', 'pictogram', 'inline', 'preview', 'full', 'download'];
const hashes = new Set<string>();
targets.forEach((target) => {
const hash = getMessageMediaHash(message, statefulMedia, target);
if (hash) {
hashes.add(hash);
}
});
return Array.from(hashes);
}
export function canReplaceMessageMedia( export function canReplaceMessageMedia(
message: MediaContainer, attachment: ApiAttachment, message: MediaContainer, attachment: ApiAttachment,
) { ) {

View File

@ -26,7 +26,10 @@ import {
areSortedArraysEqual, excludeSortedArray, omit, omitUndefined, pick, pickTruthy, unique, areSortedArraysEqual, excludeSortedArray, omit, omitUndefined, pick, pickTruthy, unique,
} from '../../util/iteratees'; } from '../../util/iteratees';
import { isLocalMessageId, type MessageKey } from '../../util/keys/messageKey'; import { isLocalMessageId, type MessageKey } from '../../util/keys/messageKey';
import { unload } from '../../util/mediaLoader';
import { import {
getAllMessageMediaHashes,
getMessageStatefulContent,
hasMessageTtl, isMediaLoadableInViewer, mergeIdRanges, orderHistoryIds, orderPinnedIds, hasMessageTtl, isMediaLoadableInViewer, mergeIdRanges, orderHistoryIds, orderPinnedIds,
} from '../helpers'; } from '../helpers';
import { getEmojiOnlyCountForMessage } from '../helpers/getEmojiOnlyCountForMessage'; import { getEmojiOnlyCountForMessage } from '../helpers/getEmojiOnlyCountForMessage';
@ -380,6 +383,11 @@ export function deleteChatMessages<T extends GlobalState>(
messageIds.forEach((messageId) => { messageIds.forEach((messageId) => {
const message = byId[messageId]; const message = byId[messageId];
if (!message) return; if (!message) return;
const statefulContent = getMessageStatefulContent(global, message);
const hashes = getAllMessageMediaHashes(message, statefulContent);
hashes.forEach((hash) => {
unload(hash);
});
if (isMediaLoadableInViewer(message)) { if (isMediaLoadableInViewer(message)) {
mediaIdsToRemove.push(messageId); mediaIdsToRemove.push(messageId);
} }

View File

@ -1893,6 +1893,9 @@ export interface LangPair {
'AttachmentMenuDisableSpoiler': undefined; 'AttachmentMenuDisableSpoiler': undefined;
'AttachmentDragAddItems': undefined; 'AttachmentDragAddItems': undefined;
'AttachmentCaptionPlaceholder': undefined; 'AttachmentCaptionPlaceholder': undefined;
'SettingsDataClearMediaCache': undefined;
'SettingsDataClearMediaCacheDescription': undefined;
'SettingsDataClearMediaDone': undefined;
} }
export interface LangPairWithVariables<V = LangVariable> { export interface LangPairWithVariables<V = LangVariable> {

View File

@ -0,0 +1,10 @@
export async function yieldToMain() {
const scheduler = (globalThis as any).scheduler;
if (scheduler?.yield) {
return scheduler.yield();
}
return new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
}

View File

@ -1,8 +1,20 @@
import { LANG_CACHE_NAME, MEDIA_CACHE_NAME, MEDIA_CACHE_NAME_AVATARS, MEDIA_PROGRESSIVE_CACHE_NAME } from '../config';
import { yieldToMain } from './browser/scheduler';
import { ACCOUNT_SLOT } from './multiaccount'; import { ACCOUNT_SLOT } from './multiaccount';
const cacheApi = self.caches; const cacheApi = self.caches;
const SUFFIX = ACCOUNT_SLOT ? `_${ACCOUNT_SLOT}` : ''; const LAST_ACCESS_HEADER = 'X-Last-Access';
const CACHE_TTL = 5 * 24 * 60 * 60 * 1000; // 5 days
const ACCESS_THROTTLE = 24 * 60 * 60 * 1000; // 1 day
const CLEANUP_INTERVAL = 1 * 60 * 60 * 1000; // 1 hour
const CLEARABLE_CACHE_NAMES = [MEDIA_CACHE_NAME, MEDIA_CACHE_NAME_AVATARS, MEDIA_PROGRESSIVE_CACHE_NAME];
cleanup(CLEARABLE_CACHE_NAMES);
setInterval(() => {
cleanup(CLEARABLE_CACHE_NAMES);
}, CLEANUP_INTERVAL);
let isSupported: boolean | undefined; let isSupported: boolean | undefined;
@ -20,6 +32,13 @@ export enum Type {
ArrayBuffer, ArrayBuffer,
} }
function getCacheName(cacheName: string) {
if (cacheName === LANG_CACHE_NAME) return cacheName;
const suffix = ACCOUNT_SLOT ? `_${ACCOUNT_SLOT}` : '';
return `${cacheName}${suffix}`;
}
export async function fetch( export async function fetch(
cacheName: string, key: string, type: Type, isHtmlAllowed = false, cacheName: string, key: string, type: Type, isHtmlAllowed = false,
) { ) {
@ -30,12 +49,18 @@ export async function fetch(
try { try {
// To avoid the error "Request scheme 'webdocument' is unsupported" // To avoid the error "Request scheme 'webdocument' is unsupported"
const request = new Request(key.replace(/:/g, '_')); const request = new Request(key.replace(/:/g, '_'));
const cache = await cacheApi.open(`${cacheName}${SUFFIX}`); const cache = await cacheApi.open(getCacheName(cacheName));
const response = await cache.match(request); const response = await cache.match(request);
if (!response) { if (!response) {
return undefined; return undefined;
} }
const lastAccess = Number(response.headers.get(LAST_ACCESS_HEADER));
const now = Date.now();
if (!lastAccess || now - lastAccess > ACCESS_THROTTLE) {
updateAccessTime(cache, request, response);
}
const contentType = response.headers.get('Content-Type'); const contentType = response.headers.get('Content-Type');
switch (type) { switch (type) {
@ -89,7 +114,8 @@ export async function save(cacheName: string, key: string, data: AnyLiteral | Bl
// To avoid the error "Request scheme 'webdocument' is unsupported" // To avoid the error "Request scheme 'webdocument' is unsupported"
const request = new Request(key.replace(/:/g, '_')); const request = new Request(key.replace(/:/g, '_'));
const response = new Response(cacheData); const response = new Response(cacheData);
const cache = await cacheApi.open(`${cacheName}${SUFFIX}`); response.headers.set(LAST_ACCESS_HEADER, Date.now().toString());
const cache = await cacheApi.open(getCacheName(cacheName));
await cache.put(request, response); await cache.put(request, response);
return true; return true;
@ -106,7 +132,7 @@ export async function remove(cacheName: string, key: string) {
return undefined; return undefined;
} }
const cache = await cacheApi.open(`${cacheName}${SUFFIX}`); const cache = await cacheApi.open(getCacheName(cacheName));
return await cache.delete(key); return await cache.delete(key);
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -121,10 +147,56 @@ export async function clear(cacheName: string) {
return undefined; return undefined;
} }
return await cacheApi.delete(`${cacheName}${SUFFIX}`); return await cacheApi.delete(getCacheName(cacheName));
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn(err); console.warn(err);
return undefined; return undefined;
} }
} }
export async function cleanup(cacheNames: string[]) {
if (!cacheApi) return;
try {
for (const cacheName of cacheNames) {
const cache = await cacheApi.open(getCacheName(cacheName));
const keys = await cache.keys();
const now = Date.now();
for (const request of keys) {
await yieldToMain();
const response = await cache.match(request);
if (!response) continue;
const lastAccess = Number(response.headers.get(LAST_ACCESS_HEADER));
if (lastAccess && now - lastAccess > CACHE_TTL) {
await cache.delete(request);
}
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.warn(err);
}
}
export function purgeClearableCache() {
CLEARABLE_CACHE_NAMES.forEach((cacheName) => clear(cacheName));
}
async function updateAccessTime(cache: Cache, request: Request, response: Response) {
try {
const headers = new Headers(response.headers);
headers.set(LAST_ACCESS_HEADER, Date.now().toString());
const newResponse = new Response(response.clone().body, {
status: response.status,
statusText: response.statusText,
headers,
});
await cache.put(request, newResponse);
} catch (err) {
// eslint-disable-next-line no-console
console.warn(err);
}
}

View File

@ -182,6 +182,14 @@ async function fetchFromCacheOrRemote(
return prepared; return prepared;
} }
export async function unload(url: string) {
memoryCache.delete(url);
if (!MEDIA_CACHE_DISABLED) {
const cacheName = url.startsWith('avatar') ? MEDIA_CACHE_NAME_AVATARS : MEDIA_CACHE_NAME;
await cacheApi.remove(cacheName, url);
}
}
function makeOnProgress(url: string) { function makeOnProgress(url: string) {
const onProgress: ApiOnProgress = (progress: number) => { const onProgress: ApiOnProgress = (progress: number) => {
progressCallbacks.get(url)?.forEach((callback) => { progressCallbacks.get(url)?.forEach((callback) => {