diff --git a/src/backend/src/modules/apps/AppInformationService.js b/src/backend/src/modules/apps/AppInformationService.js index 05a8fc8ccc..7a18dcc77d 100644 --- a/src/backend/src/modules/apps/AppInformationService.js +++ b/src/backend/src/modules/apps/AppInformationService.js @@ -123,6 +123,10 @@ class AppInformationService extends BaseService { value: appUid, rawIcon: false, }), + AppRedisCacheSpace.objectKey({ + lookup: 'uid', + value: appUid, + }), ]), AppRedisCacheSpace.invalidateAppStats(appUid), ]); diff --git a/src/backend/src/modules/apps/AppRedisCacheSpace.js b/src/backend/src/modules/apps/AppRedisCacheSpace.js index c948d4f9ce..01802304e7 100644 --- a/src/backend/src/modules/apps/AppRedisCacheSpace.js +++ b/src/backend/src/modules/apps/AppRedisCacheSpace.js @@ -21,6 +21,7 @@ import { deleteRedisKeys } from '../../clients/redis/deleteRedisKeys.js'; const appFullNamespace = 'apps'; const appLookupKeys = ['uid', 'name', 'id']; +const appObjectSuffix = 'object'; const safeParseJson = (value, fallback = null) => { if ( value === null || value === undefined ) return fallback; @@ -45,15 +46,29 @@ const appCacheKey = ({ lookup, value }) => ( `${appNamespace()}:${lookup}:${value}` ); +const appObjectNamespace = () => `${appNamespace()}:${appObjectSuffix}`; + +const appObjectCacheKey = ({ lookup, value }) => ( + `${appObjectNamespace()}:${lookup}:${value}` +); + export const AppRedisCacheSpace = { key: appCacheKey, namespace: appNamespace, + objectNamespace: appObjectNamespace, + objectKey: appObjectCacheKey, keysForApp: (app) => { if ( ! app ) return []; return appLookupKeys .filter(lookup => app[lookup] !== undefined && app[lookup] !== null && app[lookup] !== '') .map(lookup => appCacheKey({ lookup, value: app[lookup] })); }, + objectKeysForApp: (app) => { + if ( ! app ) return []; + return appLookupKeys + .filter(lookup => app[lookup] !== undefined && app[lookup] !== null && app[lookup] !== '') + .map(lookup => appObjectCacheKey({ lookup, value: app[lookup] })); + }, uidScanPattern: () => `${appNamespace()}:uid:*`, pendingNamespace: () => 'pending_app', pendingKey: ({ lookup, value }) => ( @@ -77,6 +92,9 @@ export const AppRedisCacheSpace = { getCachedApp: async ({ lookup, value }) => ( safeParseJson(await redisClient.get(appCacheKey({ lookup, value }))) ), + getCachedAppObject: async ({ lookup, value }) => ( + safeParseJson(await redisClient.get(appObjectCacheKey({ lookup, value }))) + ), setCachedApp: async (app, { ttlSeconds } = {}) => { if ( ! app ) return; const serialized = JSON.stringify(app); @@ -86,9 +104,21 @@ export const AppRedisCacheSpace = { await Promise.all(writes); } }, + setCachedAppObject: async (app, { ttlSeconds } = {}) => { + if ( ! app ) return; + const serialized = JSON.stringify(app); + const writes = AppRedisCacheSpace.objectKeysForApp(app) + .map(key => setKey(key, serialized, { ttlSeconds: ttlSeconds || 60 })); + if ( writes.length ) { + await Promise.all(writes); + } + }, invalidateCachedApp: (app, { includeStats = false } = {}) => { if ( ! app ) return; - const keys = [...AppRedisCacheSpace.keysForApp(app)]; + const keys = [ + ...AppRedisCacheSpace.keysForApp(app), + ...AppRedisCacheSpace.objectKeysForApp(app), + ]; if ( includeStats && app.uid ) { keys.push(...AppRedisCacheSpace.statsKeys(app.uid)); } @@ -98,10 +128,16 @@ export const AppRedisCacheSpace = { }, invalidateCachedAppName: async (name) => { if ( ! name ) return; - const keys = [appCacheKey({ - lookup: 'name', - value: name, - })]; + const keys = [ + appCacheKey({ + lookup: 'name', + value: name, + }), + appObjectCacheKey({ + lookup: 'name', + value: name, + }), + ]; return deleteRedisKeys(keys); }, invalidateAppStats: async (uid) => { diff --git a/src/backend/src/om/entitystorage/AppES.js b/src/backend/src/om/entitystorage/AppES.js index 4fa125bb1b..4287d32af9 100644 --- a/src/backend/src/om/entitystorage/AppES.js +++ b/src/backend/src/om/entitystorage/AppES.js @@ -33,6 +33,7 @@ const uuidv4 = require('uuid').v4; const APP_UID_ALIAS_KEY_PREFIX = 'app:canonicalUidAlias'; const APP_UID_ALIAS_REVERSE_KEY_PREFIX = 'app:canonicalUidAliasReverse'; const APP_UID_ALIAS_TTL_SECONDS = 60 * 60 * 24 * 90; +const APP_OBJECT_CACHE_TTL_SECONDS = 24 * 60 * 60; const indexUrlUniquenessExemptionCandidates = [ 'https://dev-center.puter.com/coming-soon', ]; @@ -299,7 +300,7 @@ class AppES extends BaseES { }; await svc_event.emit('app.new-icon', event); if ( typeof event.url === 'string' && event.url ) { - this.db.write( + await this.db.write( 'UPDATE apps SET icon = ? WHERE id = ? LIMIT 1', [event.url, insert_id], ); @@ -446,6 +447,26 @@ class AppES extends BaseES { }); }, + async get_cached_app_object_ (appUid) { + if ( typeof appUid !== 'string' || !appUid ) return null; + return await AppRedisCacheSpace.getCachedAppObject({ + lookup: 'uid', + value: appUid, + }); + }, + + async set_cached_app_object_ (entity) { + if ( ! entity ) return; + + const cacheable = await entity.get_client_safe(); + delete cacheable.stats; + delete cacheable.privateAccess; + + await AppRedisCacheSpace.setCachedAppObject(cacheable, { + ttlSeconds: APP_OBJECT_CACHE_TTL_SECONDS, + }); + }, + /** * Transforms app data before reading by adding associations and handling permissions * @param {Object} entity - App entity to transform @@ -463,6 +484,7 @@ class AppES extends BaseES { const appIndexUrl = await entity.get('index_url'); const appCreatedAt = await entity.get('created_at'); const appIsPrivate = await entity.get('is_private'); + const cachedAppObject = await this.get_cached_app_object_(appUid); const appInformationService = services.get('app-information'); const authService = services.get('auth'); @@ -473,21 +495,36 @@ class AppES extends BaseES { created_at: appCreatedAt, }) : Promise.resolve(undefined); - const fileAssociationsPromise = this.db.read( - 'SELECT type FROM app_filetype_association WHERE app_id = ?', - [entity.private_meta.mysql_id], + const cachedFiletypeAssociations = Array.isArray(cachedAppObject?.filetype_associations) + ? cachedAppObject.filetype_associations + : null; + const hasCachedCreatedFromOrigin = !!( + cachedAppObject && + Object.prototype.hasOwnProperty.call(cachedAppObject, 'created_from_origin') ); - const createdFromOriginPromise = (async () => { - if ( ! authService ) return null; - try { - const origin = origin_from_url(appIndexUrl); - const expectedUid = await authService.app_uid_from_origin(origin); - return expectedUid === appUid ? origin : null; - } catch { - // This happens when index_url is not a valid URL. - return null; - } - })(); + const shouldRefreshCachedAppObject = + !cachedAppObject || + !cachedFiletypeAssociations || + !hasCachedCreatedFromOrigin; + const fileAssociationsPromise = cachedFiletypeAssociations + ? Promise.resolve(cachedFiletypeAssociations) + : this.db.read( + 'SELECT type FROM app_filetype_association WHERE app_id = ?', + [entity.private_meta.mysql_id], + ).then(rows => rows.map(row => row.type)); + const createdFromOriginPromise = hasCachedCreatedFromOrigin + ? Promise.resolve(cachedAppObject.created_from_origin ?? null) + : (async () => { + if ( ! authService ) return null; + try { + const origin = origin_from_url(appIndexUrl); + const expectedUid = await authService.app_uid_from_origin(origin); + return expectedUid === appUid ? origin : null; + } catch { + // This happens when index_url is not a valid URL. + return null; + } + })(); const privateAccessPromise = resolvePrivateLaunchAccess({ app: { uid: appUid, @@ -501,7 +538,7 @@ class AppES extends BaseES { }); const [ - fileAssociationRows, + filetypeAssociations, stats, createdFromOrigin, privateAccess, @@ -511,13 +548,13 @@ class AppES extends BaseES { createdFromOriginPromise, privateAccessPromise, ]); - await entity.set( - 'filetype_associations', - fileAssociationRows.map(row => row.type), - ); + await entity.set('filetype_associations', filetypeAssociations); await entity.set('stats', stats); await entity.set('created_from_origin', createdFromOrigin); await entity.set('privateAccess', privateAccess); + if ( shouldRefreshCachedAppObject ) { + await this.set_cached_app_object_(entity); + } // Migrate b64 icons to the filesystem-backed icon flow without blocking reads. this.queueIconMigration(entity);