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";
"AttachmentDragAddItems" = "Add Items";
"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, useCallback } from '../../../lib/teact/teact';
import { memo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { AccountSettings } from '../../../types';
import { AUTODOWNLOAD_FILESIZE_MB_LIMITS } from '../../../config';
import { purgeClearableCache } from '../../../util/cacheApi';
import { pick } from '../../../util/iteratees';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import Checkbox from '../../ui/Checkbox';
import ListItem from '../../ui/ListItem';
import RangeSlider from '../../ui/RangeSlider';
type OwnProps = {
isActive?: boolean;
onReset: () => void;
onReset: NoneToVoidFunction;
};
type StateProps = Pick<AccountSettings, (
@ -34,9 +36,8 @@ type StateProps = Pick<AccountSettings, (
'autoLoadFileMaxSizeMb'
)>;
const SettingsDataStorage: FC<OwnProps & StateProps> = ({
const SettingsDataStorage = ({
isActive,
onReset,
canAutoLoadPhotoFromContacts,
canAutoLoadPhotoInPrivateChats,
canAutoLoadPhotoInGroups,
@ -50,8 +51,9 @@ const SettingsDataStorage: FC<OwnProps & StateProps> = ({
canAutoLoadFileInGroups,
canAutoLoadFileInChannels,
autoLoadFileMaxSizeMb,
}) => {
const { setSettingOption } = getActions();
onReset,
}: OwnProps & StateProps) => {
const { setSettingOption, showNotification } = getActions();
const lang = useLang();
@ -60,16 +62,23 @@ const SettingsDataStorage: FC<OwnProps & StateProps> = ({
onBack: onReset,
});
const renderFileSizeCallback = useCallback((value: number) => {
const renderFileSizeCallback = useLastCallback((value: number) => {
const size = AUTODOWNLOAD_FILESIZE_MB_LIMITS[value];
return lang('AutodownloadSizeLimitUpTo', {
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]);
});
const handlePurge = useLastCallback(() => {
purgeClearableCache();
showNotification({
message: { key: 'SettingsDataClearMediaDone' },
});
});
function renderContentSizeSlider() {
const value = AUTODOWNLOAD_FILESIZE_MB_LIMITS.indexOf(autoLoadFileMaxSizeMb);
@ -157,6 +166,20 @@ const SettingsDataStorage: FC<OwnProps & StateProps> = ({
canAutoLoadFileInGroups,
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>
);
};

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_CACHE_MAX_BYTES = 512 * 1024; // 512 KB
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 AUTODOWNLOAD_FILESIZE_MB_LIMITS = [1, 5, 10, 50, 100, 500];
export const DATA_BROADCAST_CHANNEL_PREFIX = 'tt-global';

View File

@ -624,6 +624,23 @@ export function getMessageMediaHash(
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(
message: MediaContainer, attachment: ApiAttachment,
) {

View File

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

View File

@ -1893,6 +1893,9 @@ export interface LangPair {
'AttachmentMenuDisableSpoiler': undefined;
'AttachmentDragAddItems': undefined;
'AttachmentCaptionPlaceholder': undefined;
'SettingsDataClearMediaCache': undefined;
'SettingsDataClearMediaCacheDescription': undefined;
'SettingsDataClearMediaDone': undefined;
}
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';
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;
@ -20,6 +32,13 @@ export enum Type {
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(
cacheName: string, key: string, type: Type, isHtmlAllowed = false,
) {
@ -30,12 +49,18 @@ export async function fetch(
try {
// To avoid the error "Request scheme 'webdocument' is unsupported"
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);
if (!response) {
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');
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"
const request = new Request(key.replace(/:/g, '_'));
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);
return true;
@ -106,7 +132,7 @@ export async function remove(cacheName: string, key: string) {
return undefined;
}
const cache = await cacheApi.open(`${cacheName}${SUFFIX}`);
const cache = await cacheApi.open(getCacheName(cacheName));
return await cache.delete(key);
} catch (err) {
// eslint-disable-next-line no-console
@ -121,10 +147,56 @@ export async function clear(cacheName: string) {
return undefined;
}
return await cacheApi.delete(`${cacheName}${SUFFIX}`);
return await cacheApi.delete(getCacheName(cacheName));
} catch (err) {
// eslint-disable-next-line no-console
console.warn(err);
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;
}
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) {
const onProgress: ApiOnProgress = (progress: number) => {
progressCallbacks.get(url)?.forEach((callback) => {