0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): add subscription cache class

refactor the well-known cache class and implement a new subscription cache
This commit is contained in:
simeng-li 2024-11-26 17:59:42 +08:00
parent 971c75ad5e
commit e8bfb028ce
No known key found for this signature in database
GPG key ID: 14EA7BB1541E8075
7 changed files with 288 additions and 166 deletions

View file

@ -0,0 +1,177 @@
import { trySafe, type Optional } from '@silverhand/essentials';
import { type ZodType } from 'zod';
import { type CacheStore } from './types.js';
import { cacheConsole } from './utils.js';
type CacheKeyOf<CacheMapT extends Record<string, unknown>> = Extract<keyof CacheMapT, string>;
/**
* The array tuple to determine how cache will be built.
*
* - If only `Type` is given, the cache key should be resolved as `${valueof Type}:#`.
* - If both parameters are given, the cache key will be built dynamically by executing
* the second element (which is a function) by passing current calling arguments:
* `${valueof Type}:${valueof CacheKey(...args)}`.
*
* @template Args The function arguments for the cache key builder to resolve.
* @template Type The {@link WellKnownCacheType cache type}.
*/
type CacheKeyConfig<
Args extends unknown[],
Type extends string,
CacheKey = (...args: Args) => string,
> = [Type] | [Type, CacheKey];
export abstract class BaseCache<CacheMapT extends Record<string, unknown>> {
static defaultKey = '#';
/**
* For logging and debugging purposes only.
* This name will be used in the log messages.
*/
abstract name: string;
/**
* @param tenantId The tenant ID this cache is intended for.
* @param cacheStore The storage to use as the cache.
*/
constructor(
public tenantId: string,
protected cacheStore: CacheStore
) {}
/**
* Get value from the inner cache store for the given type and key.
* Note: Redis connection and format errors will be silently caught and result an `undefined` return.
*/
async get<Type extends CacheKeyOf<CacheMapT>>(
type: Type,
key: string
): Promise<Optional<CacheMapT[Type]>> {
return trySafe(async () => {
const data = await this.cacheStore.get(this.cacheKey(type, key));
return this.getValueGuard(type).parse(JSON.parse(data ?? ''));
});
}
/**
* Set value to the inner cache store for the given type and key.
* The given value will be stringify without format validation before storing into the cache.
*
* @param expire The expire time in seconds. If not given, use the default expire time 30 * 60 seconds.
*/
async set<Type extends CacheKeyOf<CacheMapT>>(
type: Type,
key: string,
value: Readonly<CacheMapT[Type]>,
expire?: number
) {
return this.cacheStore.set(this.cacheKey(type, key), JSON.stringify(value), expire);
}
/** Delete value from the inner cache store for the given type and key. */
async delete(type: CacheKeyOf<CacheMapT>, key: string) {
return this.cacheStore.delete(this.cacheKey(type, key));
}
/**
* Create a wrapper of the given function, which invalidates a set of keys in cache
* after the function runs successfully.
*
* @param run The function to wrap.
* @param types An array of {@link CacheKeyConfig}.
*/
mutate<Args extends unknown[], Return>(
run: (...args: Args) => Promise<Return>,
...types: Array<CacheKeyConfig<Args, CacheKeyOf<CacheMapT>>>
) {
// Intended. We're going to use `this` cache inside another closure.
// eslint-disable-next-line @typescript-eslint/no-this-alias, unicorn/no-this-assignment
const kvCache = this;
const mutated = async function (this: unknown, ...args: Args): Promise<Return> {
const value = await run.apply(this, args);
// We don't leverage `finally` here since we want to ensure cache deleting
// only happens when the original function executed successfully
void Promise.all(
types.map(async ([type, cacheKey]) =>
trySafe(kvCache.delete(type, cacheKey?.(...args) ?? BaseCache.defaultKey))
)
);
return value;
};
return mutated;
}
/**
* [Memoize](https://en.wikipedia.org/wiki/Memoization) a function and cache the result. The function execution
* will be also cached, which means there will be only one execution at a time.
*
* @param run The function to memoize.
* @param config The object to determine how cache key will be built. See {@link CacheKeyConfig} for details.
* @param getExpiresIn A function to determine how long the cache will be expired. The function will be called
* with the resolved value from the original function. The return value should be the expire time in seconds.
*/
memoize<
Type extends CacheKeyOf<CacheMapT>,
Args extends unknown[],
Value extends Readonly<CacheMapT[Type]>,
>(
run: (...args: Args) => Promise<Value>,
[type, cacheKey]: CacheKeyConfig<Args, Type>,
getExpiresIn?: (value: Value) => number
) {
const promiseCache = new Map<unknown, Promise<Readonly<CacheMapT[Type]>>>();
// Intended. We're going to use `this` cache inside another closure.
// eslint-disable-next-line @typescript-eslint/no-this-alias, unicorn/no-this-assignment
const kvCache = this;
const memoized = async function (
this: unknown,
...args: Args
): Promise<Readonly<CacheMapT[Type]>> {
const promiseKey = cacheKey?.(...args) ?? BaseCache.defaultKey;
const cachedPromise = promiseCache.get(promiseKey);
if (cachedPromise) {
return cachedPromise;
}
const promise = (async () => {
try {
// Wrap with `trySafe()` here to ignore Redis errors
const cachedValue = await trySafe(kvCache.get(type, promiseKey));
if (cachedValue) {
cacheConsole.info(`${kvCache.name} cache hit for', type, promiseKey`);
return cachedValue;
}
const value = await run.apply(this, args);
await trySafe(kvCache.set(type, promiseKey, value, getExpiresIn?.(value)));
return value;
} finally {
promiseCache.delete(promiseKey);
}
})();
promiseCache.set(promiseKey, promise);
return promise;
};
return memoized;
}
abstract getValueGuard<Type extends CacheKeyOf<CacheMapT>>(type: Type): ZodType<CacheMapT[Type]>;
protected cacheKey(type: CacheKeyOf<CacheMapT>, key: string) {
return `${this.tenantId}:${type}:${key}`;
}
}

View file

@ -0,0 +1,32 @@
import { SubscriptionRedisCacheKey } from '@logto/schemas';
import { type ZodType } from 'zod';
import { type Subscription, subscriptionCacheGuard } from '#src/utils/subscription/types.js';
import { BaseCache } from './base-cache.js';
type SubscriptionCacheMap = {
[SubscriptionRedisCacheKey.Subscription]: Subscription;
};
type SubscriptionCacheType = keyof SubscriptionCacheMap;
function getValueGuard(type: SubscriptionCacheType): ZodType<SubscriptionCacheMap[typeof type]> {
switch (type) {
case SubscriptionRedisCacheKey.Subscription: {
return subscriptionCacheGuard;
}
}
}
/**
* A local region cache for tenant subscription data.
* We use this cache to reduce the number of requests to the Cloud
* and improve the performance of subscription-related operations.
*
* TODO: Will use the cache for tenant subscription data.
*/
class TenantSubscriptionCache extends BaseCache<SubscriptionCacheMap> {
name = 'Tenant Subscription';
getValueGuard = getValueGuard;
}

View file

@ -1,11 +1,9 @@
import { type SignInExperience, SignInExperiences } from '@logto/schemas'; import { type SignInExperience, SignInExperiences } from '@logto/schemas';
import { type Optional, trySafe } from '@silverhand/essentials';
import { type ZodType, z } from 'zod'; import { type ZodType, z } from 'zod';
import { type ConnectorWellKnown, connectorWellKnownGuard } from '#src/utils/connectors/types.js'; import { type ConnectorWellKnown, connectorWellKnownGuard } from '#src/utils/connectors/types.js';
import { type CacheStore } from './types.js'; import { BaseCache } from './base-cache.js';
import { cacheConsole } from './utils.js';
type WellKnownMap = { type WellKnownMap = {
sie: SignInExperience; sie: SignInExperience;
@ -19,23 +17,6 @@ type WellKnownMap = {
type WellKnownCacheType = keyof WellKnownMap; type WellKnownCacheType = keyof WellKnownMap;
/**
* The array tuple to determine how cache will be built.
*
* - If only `Type` is given, the cache key should be resolved as `${valueof Type}:#`.
* - If both parameters are given, the cache key will be built dynamically by executing
* the second element (which is a function) by passing current calling arguments:
* `${valueof Type}:${valueof CacheKey(...args)}`.
*
* @template Args The function arguments for the cache key builder to resolve.
* @template Type The {@link WellKnownCacheType cache type}.
*/
type CacheKeyConfig<
Args extends unknown[],
Type = WellKnownCacheType,
CacheKey = (...args: Args) => string,
> = [Type] | [Type, CacheKey];
// Cannot use generic type here, but direct type works. // Cannot use generic type here, but direct type works.
// See [this issue](https://github.com/microsoft/TypeScript/issues/27808#issuecomment-1207161877) for details. // See [this issue](https://github.com/microsoft/TypeScript/issues/27808#issuecomment-1207161877) for details.
// WARN: You should carefully check key and return type mapping since the implementation signature doesn't do this. // WARN: You should carefully check key and return type mapping since the implementation signature doesn't do this.
@ -75,147 +56,7 @@ function getValueGuard(type: WellKnownCacheType): ZodType<WellKnownMap[typeof ty
* *
* @see {@link getValueGuard} For how data will be guarded while getting from the cache. * @see {@link getValueGuard} For how data will be guarded while getting from the cache.
*/ */
export class WellKnownCache { export class WellKnownCache extends BaseCache<WellKnownMap> {
static defaultKey = '#'; name = 'Well-known';
getValueGuard = getValueGuard;
/**
* @param tenantId The tenant ID this cache is intended for.
* @param cacheStore The storage to use as the cache.
*/
constructor(
public tenantId: string,
protected cacheStore: CacheStore
) {}
/**
* Get value from the inner cache store for the given type and key.
* Note: Redis connection and format errors will be silently caught and result an `undefined` return.
*/
async get<Type extends WellKnownCacheType>(
type: Type,
key: string
): Promise<Optional<WellKnownMap[Type]>> {
return trySafe(async () => {
const data = await this.cacheStore.get(this.cacheKey(type, key));
return getValueGuard(type).parse(JSON.parse(data ?? ''));
});
}
/**
* Set value to the inner cache store for the given type and key.
* The given value will be stringify without format validation before storing into the cache.
*
* @param expire The expire time in seconds. If not given, use the default expire time 30 * 60 seconds.
*/
async set<Type extends WellKnownCacheType>(
type: Type,
key: string,
value: Readonly<WellKnownMap[Type]>,
expire?: number
) {
return this.cacheStore.set(this.cacheKey(type, key), JSON.stringify(value), expire);
}
/** Delete value from the inner cache store for the given type and key. */
async delete(type: WellKnownCacheType, key: string) {
return this.cacheStore.delete(this.cacheKey(type, key));
}
/**
* Create a wrapper of the given function, which invalidates a set of keys in cache
* after the function runs successfully.
*
* @param run The function to wrap.
* @param types An array of {@link CacheKeyConfig}.
*/
mutate<Args extends unknown[], Return>(
run: (...args: Args) => Promise<Return>,
...types: Array<CacheKeyConfig<Args>>
) {
// Intended. We're going to use `this` cache inside another closure.
// eslint-disable-next-line @typescript-eslint/no-this-alias, unicorn/no-this-assignment
const kvCache = this;
const mutated = async function (this: unknown, ...args: Args): Promise<Return> {
const value = await run.apply(this, args);
// We don't leverage `finally` here since we want to ensure cache deleting
// only happens when the original function executed successfully
void Promise.all(
types.map(async ([type, cacheKey]) =>
trySafe(kvCache.delete(type, cacheKey?.(...args) ?? WellKnownCache.defaultKey))
)
);
return value;
};
return mutated;
}
/**
* [Memoize](https://en.wikipedia.org/wiki/Memoization) a function and cache the result. The function execution
* will be also cached, which means there will be only one execution at a time.
*
* @param run The function to memoize.
* @param config The object to determine how cache key will be built. See {@link CacheKeyConfig} for details.
* @param getExpiresIn A function to determine how long the cache will be expired. The function will be called
* with the resolved value from the original function. The return value should be the expire time in seconds.
*/
memoize<
Type extends WellKnownCacheType,
Args extends unknown[],
Value extends Readonly<WellKnownMap[Type]>,
>(
run: (...args: Args) => Promise<Value>,
[type, cacheKey]: CacheKeyConfig<Args, Type>,
getExpiresIn?: (value: Value) => number
) {
const promiseCache = new Map<unknown, Promise<Readonly<WellKnownMap[Type]>>>();
// Intended. We're going to use `this` cache inside another closure.
// eslint-disable-next-line @typescript-eslint/no-this-alias, unicorn/no-this-assignment
const kvCache = this;
const memoized = async function (
this: unknown,
...args: Args
): Promise<Readonly<WellKnownMap[Type]>> {
const promiseKey = cacheKey?.(...args) ?? WellKnownCache.defaultKey;
const cachedPromise = promiseCache.get(promiseKey);
if (cachedPromise) {
return cachedPromise;
}
const promise = (async () => {
try {
// Wrap with `trySafe()` here to ignore Redis errors
const cachedValue = await trySafe(kvCache.get(type, promiseKey));
if (cachedValue) {
cacheConsole.info('Well-known cache hit for', type, promiseKey);
return cachedValue;
}
const value = await run.apply(this, args);
await trySafe(kvCache.set(type, promiseKey, value, getExpiresIn?.(value)));
return value;
} finally {
promiseCache.delete(promiseKey);
}
})();
promiseCache.set(promiseKey, promise);
return promise;
};
return memoized;
}
protected cacheKey(type: WellKnownCacheType, key: string) {
return `${this.tenantId}:${type}:${key}`;
}
} }

View file

@ -1,10 +1,10 @@
import { type ToZodObject } from '@logto/connector-kit';
import { import {
type VerificationIdentifier, type VerificationIdentifier,
VerificationType, VerificationType,
type User, type User,
verificationIdentifierGuard, verificationIdentifierGuard,
} from '@logto/schemas'; } from '@logto/schemas';
import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js';
import { generateStandardId } from '@logto/shared'; import { generateStandardId } from '@logto/shared';
import { z } from 'zod'; import { z } from 'zod';

View file

@ -1,4 +1,4 @@
import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js'; import { type ToZodObject } from '@logto/connector-kit';
import { z } from 'zod'; import { z } from 'zod';
import { extendedSocialUserInfoGuard, type ExtendedSocialUserInfo } from './saml.js'; import { extendedSocialUserInfoGuard, type ExtendedSocialUserInfo } from './saml.js';

View file

@ -1,6 +1,7 @@
import type router from '@logto/cloud/routes'; import type router from '@logto/cloud/routes';
import { type ToZodObject } from '@logto/connector-kit';
import { type RouterRoutes } from '@withtyped/client'; import { type RouterRoutes } from '@withtyped/client';
import { type z, type ZodType } from 'zod'; import { z, type ZodType } from 'zod';
type GetRoutes = RouterRoutes<typeof router>['get']; type GetRoutes = RouterRoutes<typeof router>['get'];
type PostRoutes = RouterRoutes<typeof router>['post']; type PostRoutes = RouterRoutes<typeof router>['post'];
@ -56,3 +57,64 @@ export const allReportSubscriptionUpdatesUsageKeys = Object.freeze([
'enterpriseSsoLimit', 'enterpriseSsoLimit',
'hooksLimit', 'hooksLimit',
]) satisfies readonly ReportSubscriptionUpdatesUsageKey[]; ]) satisfies readonly ReportSubscriptionUpdatesUsageKey[];
const subscriptionStatusGuard = z.enum([
'incomplete',
'incomplete_expired',
'trialing',
'active',
'past_due',
'canceled',
'unpaid',
'paused',
]);
const upcomingInvoiceGuard = z.object({
subtotal: z.number(),
subtotalExcludingTax: z.number().nullable(),
total: z.number(),
totalExcludingTax: z.number().nullable(),
}) satisfies ToZodObject<Subscription['upcomingInvoice']>;
const logtoSkuQuotaGuard = z.object({
mauLimit: z.number().nullable(),
applicationsLimit: z.number().nullable(),
thirdPartyApplicationsLimit: z.number().nullable(),
scopesPerResourceLimit: z.number().nullable(),
socialConnectorsLimit: z.number().nullable(),
userRolesLimit: z.number().nullable(),
machineToMachineRolesLimit: z.number().nullable(),
scopesPerRoleLimit: z.number().nullable(),
hooksLimit: z.number().nullable(),
auditLogsRetentionDays: z.number().nullable(),
customJwtEnabled: z.boolean(),
subjectTokenEnabled: z.boolean(),
bringYourUiEnabled: z.boolean(),
tokenLimit: z.number().nullable(),
machineToMachineLimit: z.number().nullable(),
resourcesLimit: z.number().nullable(),
enterpriseSsoLimit: z.number().nullable(),
tenantMembersLimit: z.number().nullable(),
mfaEnabled: z.boolean(),
organizationsEnabled: z.boolean(),
organizationsLimit: z.number().nullable(),
idpInitiatedSsoEnabled: z.boolean(),
}) satisfies ToZodObject<SubscriptionQuota>;
/**
* Redis cache guard for the subscription data returned from the Cloud API `/api/tenants/my/subscription`.
* Logto core does not have access to the zod guard of the subscription data in Cloud,
* so we need to manually define the guard here,
* it should be kept in sync with the Cloud API response.
*/
export const subscriptionCacheGuard = z.object({
id: z.string().optional(),
planId: z.string(),
currentPeriodStart: z.string(),
currentPeriodEnd: z.string(),
isEnterprisePlan: z.boolean(),
isAddOnAvailable: z.boolean(),
status: subscriptionStatusGuard,
upcomingInvoice: upcomingInvoiceGuard.nullable().optional(),
quota: logtoSkuQuotaGuard,
}) satisfies ToZodObject<Subscription>;

View file

@ -23,3 +23,13 @@ export enum ReservedPlanId {
*/ */
Pro202411 = 'pro-202411', Pro202411 = 'pro-202411',
} }
/**
* Tenant subscription related Redis cache keys.
*
* We use Redis to cache the tenant subscription data to reduce the number of requests to the Cloud.
* Both @logto/core and @logto/cloud will need to access the cache, so we define the cache keys here as the SSOT.
*/
export enum SubscriptionRedisCacheKey {
Subscription = 'subscription',
}