Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/backend/src/modules/apps/AppInformationService.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ class AppInformationService extends BaseService {
value: appUid,
rawIcon: false,
}),
AppRedisCacheSpace.objectKey({
lookup: 'uid',
value: appUid,
}),
]),
AppRedisCacheSpace.invalidateAppStats(appUid),
]);
Expand Down
46 changes: 41 additions & 5 deletions src/backend/src/modules/apps/AppRedisCacheSpace.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 }) => (
Expand All @@ -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);
Expand All @@ -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));
}
Expand All @@ -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) => {
Expand Down
77 changes: 57 additions & 20 deletions src/backend/src/om/entitystorage/AppES.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
Expand Down Expand Up @@ -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],
);
Expand Down Expand Up @@ -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
Expand All @@ -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');
Expand All @@ -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,
Expand All @@ -501,7 +538,7 @@ class AppES extends BaseES {
});

const [
fileAssociationRows,
filetypeAssociations,
stats,
createdFromOrigin,
privateAccess,
Expand All @@ -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);
Expand Down
Loading