Skip to content
Merged

. #17

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
754 changes: 507 additions & 247 deletions extensions/fsv2/src/controllers/FSController.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/backend/src/clients/redis/redisSingleton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import MockRedis from 'ioredis-mock';
const redisStartupRetryMaxDelayMs = 2000;
const redisSlotsRefreshTimeoutMs = 5000;
const redisConnectTimeoutMs = 10000;
const redisMaxRetriesPerRequest = 2;
const redisMaxRetriesPerRequest = 1;
const redisBootRetryRegex = /Cluster(All)?FailedError|None of startup nodes is available/i;

const formatRedisError = (error: unknown): string => {
Expand Down
4 changes: 2 additions & 2 deletions src/backend/src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ export async function get_app (options) {
const cacheApp = async (app) => {
if ( ! app ) return;
AppRedisCacheSpace.setCachedApp(app, {
ttlSeconds: 300,
ttlSeconds: 24 * 60 * 60,
});
};
const isDecoratedAppCacheEntry = (app) => (
Expand Down Expand Up @@ -550,7 +550,7 @@ export const get_apps = spanify('get_apps', async (specifiers, options = {}) =>
const cacheApp = async (app) => {
if ( ! app ) return;
AppRedisCacheSpace.setCachedApp(app, {
ttlSeconds: 300,
ttlSeconds: 24 * 60 * 60,
});
};

Expand Down
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,35 +16,36 @@ export const TOGETHER_COST_MAP = {
'together-image:Qwen/Qwen-Image': 0.0058 * 100_000_000,
'together-image:RunDiffusion/Juggernaut-pro-flux': 0.0049 * 100_000_000,
'together-image:Rundiffusion/Juggernaut-Lightning-Flux': 0.0017 * 100_000_000,
'together-image:black-forest-labs/FLUX.1-Canny-pro': 0.05 * 100_000_000,
'together-image:black-forest-labs/FLUX.1-dev': 0.025 * 100_000_000,
'together-image:black-forest-labs/FLUX.1-dev-lora': 0.025 * 100_000_000,
'together-image:black-forest-labs/FLUX.1-kontext-dev': 0.025 * 100_000_000,
'together-image:black-forest-labs/FLUX.1-kontext-max': 0.08 * 100_000_000,
'together-image:black-forest-labs/FLUX.1-kontext-pro': 0.04 * 100_000_000,
'together-image:black-forest-labs/FLUX.1-krea-dev': 0.025 * 100_000_000,
'together-image:black-forest-labs/FLUX.1-pro': 0.05 * 100_000_000,
'together-image:black-forest-labs/FLUX.1-schnell': 0.0027 * 100_000_000,
'together-image:black-forest-labs/FLUX.1.1-pro': 0.05 * 100_000_000,
'together-image:black-forest-labs/FLUX.1.1-pro': 0.04 * 100_000_000,
'together-image:black-forest-labs/FLUX.2-pro': 0.03 * 100_000_000,
'together-image:black-forest-labs/FLUX.2-flex': 0.03 * 100_000_000,
'together-image:black-forest-labs/FLUX.2-dev': 0.0154 * 100_000_000,
'together-image:black-forest-labs/FLUX.2-max': 0.07 * 100_000_000,
'together-image:google/flash-image-2.5': 0.039 * 100_000_000,
'together-image:google/flash-image-3.1': 0.067 * 100_000_000,
'together-image:google/gemini-3-pro-image': 0.134 * 100_000_000,
'together-image:google/imagen-4.0-fast': 0.02 * 100_000_000,
'together-image:google/imagen-4.0-preview': 0.04 * 100_000_000,
'together-image:google/imagen-4.0-ultra': 0.06 * 100_000_000,
'together-image:ideogram/ideogram-3.0': 0.06 * 100_000_000,
'together-image:openai/gpt-image-1.5': 0.034 * 100_000_000,
'together-image:Qwen/Qwen-Image-2.0': 0.04 * 100_000_000,
'together-image:Qwen/Qwen-Image-2.0-Pro': 0.08 * 100_000_000,
'together-image:Wan-AI/Wan2.6-image': 0.03 * 100_000_000,
'together-image:stabilityai/stable-diffusion-3-medium': 0.0019 * 100_000_000,
'together-image:stabilityai/stable-diffusion-xl-base-1.0': 0.0045 * 100_000_000,
'together-image:stabilityai/stable-diffusion-xl-base-1.0': 0.0019 * 100_000_000,

// Video generation placeholder (per-video pricing). Update with real pricing when available.
'together-video:default': 0,
'together-video:ByteDance/Seedance-1.0-lite': 0.14 * 100_000_000,
'together-video:ByteDance/Seedance-1.0-pro': 0.57 * 100_000_000,
'together-video:Wan-AI/Wan2.2-I2V-A14B': 0.31 * 100_000_000,
'together-video:Wan-AI/Wan2.2-T2V-A14B': 0.66 * 100_000_000,
'together-video:Wan-AI/wan2.7-t2v': 0.10 * 100_000_000,
'together-video:google/veo-2.0': 2.50 * 100_000_000,
'together-video:google/veo-3.0': 1.60 * 100_000_000,
'together-video:google/veo-3.0-audio': 3.20 * 100_000_000,
Expand All @@ -56,10 +57,10 @@ export const TOGETHER_COST_MAP = {
'together-video:kwaivgI/kling-2.1-master': 0.92 * 100_000_000,
'together-video:kwaivgI/kling-2.1-pro': 0.32 * 100_000_000,
'together-video:kwaivgI/kling-2.1-standard': 0.18 * 100_000_000,
'together-video:minimax/hailuo-02': 0.56 * 100_000_000,
'together-video:minimax/hailuo-02': 0.49 * 100_000_000,
'together-video:minimax/video-01-director': 0.28 * 100_000_000,
'together-video:openai/sora-2': 0.80 * 100_000_000,
'together-video:openai/sora-2-pro': 4.00 * 100_000_000,
'together-video:openai/sora-2-pro': 3.00 * 100_000_000,
'together-video:pixverse/pixverse-v5': 0.30 * 100_000_000,
'together-video:vidu/vidu-2.0': 0.28 * 100_000_000,
'together-video:vidu/vidu-q1': 0.22 * 100_000_000,
Expand Down
Loading
Loading