From e8bfb028ced8e1fcca8b47fb392b6af203d50772 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Tue, 26 Nov 2024 17:59:42 +0800 Subject: [PATCH] feat(core): add subscription cache class refactor the well-known cache class and implement a new subscription cache --- packages/core/src/caches/base-cache.ts | 177 ++++++++++++++++++ .../core/src/caches/tenant-subscription.ts | 32 ++++ packages/core/src/caches/well-known.ts | 167 +---------------- .../verifications/password-verification.ts | 2 +- packages/core/src/sso/types/session.ts | 2 +- packages/core/src/utils/subscription/types.ts | 64 ++++++- packages/schemas/src/consts/subscriptions.ts | 10 + 7 files changed, 288 insertions(+), 166 deletions(-) create mode 100644 packages/core/src/caches/base-cache.ts create mode 100644 packages/core/src/caches/tenant-subscription.ts diff --git a/packages/core/src/caches/base-cache.ts b/packages/core/src/caches/base-cache.ts new file mode 100644 index 000000000..eab97b208 --- /dev/null +++ b/packages/core/src/caches/base-cache.ts @@ -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> = Extract; + +/** + * 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> { + 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: Type, + key: string + ): Promise> { + 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: Type, + key: string, + value: Readonly, + 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, 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( + run: (...args: Args) => Promise, + ...types: Array>> + ) { + // 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 { + 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, + Args extends unknown[], + Value extends Readonly, + >( + run: (...args: Args) => Promise, + [type, cacheKey]: CacheKeyConfig, + getExpiresIn?: (value: Value) => number + ) { + const promiseCache = new Map>>(); + // 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> { + 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: Type): ZodType; + + protected cacheKey(type: CacheKeyOf, key: string) { + return `${this.tenantId}:${type}:${key}`; + } +} diff --git a/packages/core/src/caches/tenant-subscription.ts b/packages/core/src/caches/tenant-subscription.ts new file mode 100644 index 000000000..debe26e8a --- /dev/null +++ b/packages/core/src/caches/tenant-subscription.ts @@ -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 { + 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 { + name = 'Tenant Subscription'; + getValueGuard = getValueGuard; +} diff --git a/packages/core/src/caches/well-known.ts b/packages/core/src/caches/well-known.ts index c99d4d6a1..c403d2998 100644 --- a/packages/core/src/caches/well-known.ts +++ b/packages/core/src/caches/well-known.ts @@ -1,11 +1,9 @@ import { type SignInExperience, SignInExperiences } from '@logto/schemas'; -import { type Optional, trySafe } from '@silverhand/essentials'; import { type ZodType, z } from 'zod'; import { type ConnectorWellKnown, connectorWellKnownGuard } from '#src/utils/connectors/types.js'; -import { type CacheStore } from './types.js'; -import { cacheConsole } from './utils.js'; +import { BaseCache } from './base-cache.js'; type WellKnownMap = { sie: SignInExperience; @@ -19,23 +17,6 @@ type 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. // 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. @@ -75,147 +56,7 @@ function getValueGuard(type: WellKnownCacheType): ZodType( - type: Type, - key: string - ): Promise> { - 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: Type, - key: string, - value: Readonly, - 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( - run: (...args: Args) => Promise, - ...types: Array> - ) { - // 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 { - 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, - >( - run: (...args: Args) => Promise, - [type, cacheKey]: CacheKeyConfig, - getExpiresIn?: (value: Value) => number - ) { - const promiseCache = new Map>>(); - // 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> { - 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}`; - } +export class WellKnownCache extends BaseCache { + name = 'Well-known'; + getValueGuard = getValueGuard; } diff --git a/packages/core/src/routes/experience/classes/verifications/password-verification.ts b/packages/core/src/routes/experience/classes/verifications/password-verification.ts index 7443cf205..6dffb2c9c 100644 --- a/packages/core/src/routes/experience/classes/verifications/password-verification.ts +++ b/packages/core/src/routes/experience/classes/verifications/password-verification.ts @@ -1,10 +1,10 @@ +import { type ToZodObject } from '@logto/connector-kit'; import { type VerificationIdentifier, VerificationType, type User, verificationIdentifierGuard, } from '@logto/schemas'; -import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js'; import { generateStandardId } from '@logto/shared'; import { z } from 'zod'; diff --git a/packages/core/src/sso/types/session.ts b/packages/core/src/sso/types/session.ts index 6f8aab4c6..ea8ee566d 100644 --- a/packages/core/src/sso/types/session.ts +++ b/packages/core/src/sso/types/session.ts @@ -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 { extendedSocialUserInfoGuard, type ExtendedSocialUserInfo } from './saml.js'; diff --git a/packages/core/src/utils/subscription/types.ts b/packages/core/src/utils/subscription/types.ts index 9b3ee9a6d..69f09d437 100644 --- a/packages/core/src/utils/subscription/types.ts +++ b/packages/core/src/utils/subscription/types.ts @@ -1,6 +1,7 @@ import type router from '@logto/cloud/routes'; +import { type ToZodObject } from '@logto/connector-kit'; import { type RouterRoutes } from '@withtyped/client'; -import { type z, type ZodType } from 'zod'; +import { z, type ZodType } from 'zod'; type GetRoutes = RouterRoutes['get']; type PostRoutes = RouterRoutes['post']; @@ -56,3 +57,64 @@ export const allReportSubscriptionUpdatesUsageKeys = Object.freeze([ 'enterpriseSsoLimit', 'hooksLimit', ]) 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; + +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; + +/** + * 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; diff --git a/packages/schemas/src/consts/subscriptions.ts b/packages/schemas/src/consts/subscriptions.ts index b0dad38a8..e1911be18 100644 --- a/packages/schemas/src/consts/subscriptions.ts +++ b/packages/schemas/src/consts/subscriptions.ts @@ -23,3 +23,13 @@ export enum ReservedPlanId { */ 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', +}