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:
parent
971c75ad5e
commit
e8bfb028ce
7 changed files with 288 additions and 166 deletions
177
packages/core/src/caches/base-cache.ts
Normal file
177
packages/core/src/caches/base-cache.ts
Normal 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}`;
|
||||
}
|
||||
}
|
32
packages/core/src/caches/tenant-subscription.ts
Normal file
32
packages/core/src/caches/tenant-subscription.ts
Normal 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;
|
||||
}
|
|
@ -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<WellKnownMap[typeof ty
|
|||
*
|
||||
* @see {@link getValueGuard} For how data will be guarded while getting from the cache.
|
||||
*/
|
||||
export class WellKnownCache {
|
||||
static defaultKey = '#';
|
||||
|
||||
/**
|
||||
* @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}`;
|
||||
}
|
||||
export class WellKnownCache extends BaseCache<WellKnownMap> {
|
||||
name = 'Well-known';
|
||||
getValueGuard = getValueGuard;
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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<typeof router>['get'];
|
||||
type PostRoutes = RouterRoutes<typeof router>['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<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>;
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue