From bdfa12b5660cb72d8e8bdc882df3add527a85998 Mon Sep 17 00:00:00 2001 From: jelveh Date: Tue, 21 Apr 2026 18:52:17 -0700 Subject: [PATCH 1/2] Add app object Redis cache and use in AppES --- .../src/modules/apps/AppRedisCacheSpace.js | 46 ++++++++++-- src/backend/src/om/entitystorage/AppES.js | 75 ++++++++++++++----- 2 files changed, 97 insertions(+), 24 deletions(-) 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..69abe2ef5d 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', ]; @@ -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); From b886dde3d68a9fdde7c1a2a73e05a3219066bb2c Mon Sep 17 00:00:00 2001 From: jelveh Date: Tue, 21 Apr 2026 19:32:19 -0700 Subject: [PATCH 2/2] Await DB write and add UID cache key Make the DB update in AppES awaitable so the write completes before proceeding (avoids race conditions). Also add invalidation of the Redis object key for the app UID in AppInformationService to ensure cached entries keyed by uid are cleared after updates. --- src/backend/src/modules/apps/AppInformationService.js | 4 ++++ src/backend/src/om/entitystorage/AppES.js | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) 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/om/entitystorage/AppES.js b/src/backend/src/om/entitystorage/AppES.js index 69abe2ef5d..4287d32af9 100644 --- a/src/backend/src/om/entitystorage/AppES.js +++ b/src/backend/src/om/entitystorage/AppES.js @@ -300,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], );