Add app object Redis cache and use in AppES

This commit is contained in:
jelveh
2026-04-21 18:52:17 -07:00
parent 6b3196ed0c
commit bdfa12b566
2 changed files with 97 additions and 24 deletions
@@ -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) => {
+56 -19
View File
@@ -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);