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

refactor: implement ttl cache

This commit is contained in:
Gao Sun 2023-03-17 00:56:12 +08:00
parent 1256711bcc
commit e0fad2dccd
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
26 changed files with 202 additions and 54 deletions

View file

@ -47,7 +47,7 @@
"@logto/core-kit": "workspace:*",
"@logto/schemas": "workspace:*",
"@logto/shared": "workspace:*",
"@silverhand/essentials": "^2.4.1",
"@silverhand/essentials": "2.4.1",
"chalk": "^5.0.0",
"decamelize": "^6.0.0",
"dotenv": "^16.0.0",

View file

@ -28,7 +28,7 @@
"@logto/core-kit": "workspace:*",
"@logto/schemas": "workspace:*",
"@logto/shared": "workspace:*",
"@silverhand/essentials": "^2.4.1",
"@silverhand/essentials": "2.4.1",
"@withtyped/postgres": "^0.8.1",
"@withtyped/server": "^0.8.1",
"accepts": "^1.3.8",

View file

@ -35,7 +35,7 @@
"@parcel/transformer-svg-react": "2.8.3",
"@silverhand/eslint-config": "2.0.1",
"@silverhand/eslint-config-react": "2.0.1",
"@silverhand/essentials": "^2.4.1",
"@silverhand/essentials": "2.4.1",
"@silverhand/ts-config": "2.0.3",
"@silverhand/ts-config-react": "2.0.3",
"@tsconfig/docusaurus": "^1.0.5",

View file

@ -35,7 +35,7 @@
"@logto/phrases-ui": "workspace:*",
"@logto/schemas": "workspace:*",
"@logto/shared": "workspace:*",
"@silverhand/essentials": "^2.4.1",
"@silverhand/essentials": "2.4.1",
"aws-sdk": "^2.1329.0",
"chalk": "^5.0.0",
"clean-deep": "^3.4.0",
@ -51,7 +51,6 @@
"iconv-lite": "0.6.3",
"jose": "^4.11.0",
"js-yaml": "^4.1.0",
"keyv": "^4.5.2",
"koa": "^2.13.1",
"koa-body": "^5.0.0",
"koa-compose": "^4.1.0",

View file

@ -1,4 +1,4 @@
import Keyv from 'keyv';
import { TtlCache } from '@logto/shared';
import type { AnyAsyncFunction } from 'p-memoize';
import pMemoize from 'p-memoize';
@ -10,14 +10,14 @@ export type WellKnownCacheKey = (typeof cacheKeys)[number];
const buildKey = (tenantId: string, key: WellKnownCacheKey) => `${tenantId}:${key}` as const;
class WellKnownCache {
// Not sure if we need guard value for `.has()` and `.get()`,
// trust cache value for now.
#keyv = new Keyv({ ttl: 180_000 /* 3 minutes */ });
#cache = new TtlCache<string, unknown>(180_000 /* 3 minutes */);
/**
* Use for centralized well-known data caching.
*
* WARN: You should store only well-known (public) data since it's a central cache.
* WARN:
* - You should store only well-known (public) data since it's a central cache.
* - The cache does not guard types.
*/
use<FunctionToMemoize extends AnyAsyncFunction>(
tenantId: string,
@ -26,20 +26,24 @@ class WellKnownCache {
) {
return pMemoize(run, {
cacheKey: () => buildKey(tenantId, key),
cache: this.#keyv,
// Trust cache value type
// eslint-disable-next-line no-restricted-syntax
cache: this.#cache as TtlCache<string, Awaited<ReturnType<FunctionToMemoize>>>,
});
}
async invalidate(tenantId: string, keys: readonly WellKnownCacheKey[]) {
return this.#keyv.delete(keys.map((key) => buildKey(tenantId, key)));
invalidate(tenantId: string, keys: readonly WellKnownCacheKey[]) {
for (const key of keys) {
this.#cache.delete(buildKey(tenantId, key));
}
}
async invalidateAll(tenantId: string) {
return this.invalidate(tenantId, cacheKeys);
invalidateAll(tenantId: string) {
this.invalidate(tenantId, cacheKeys);
}
async set(tenantId: string, key: WellKnownCacheKey, value: unknown) {
return this.#keyv.set(buildKey(tenantId, key), value);
set(tenantId: string, key: WellKnownCacheKey, value: unknown) {
this.#cache.set(buildKey(tenantId, key), value);
}
}

View file

@ -49,8 +49,8 @@ const { getPhrases } = createPhraseLibrary(
tenantId
);
afterEach(async () => {
await wellKnownCache.invalidateAll(tenantId);
afterEach(() => {
wellKnownCache.invalidateAll(tenantId);
jest.clearAllMocks();
});

View file

@ -146,7 +146,7 @@ export default function initOidc(
},
},
interactions: {
url: async (ctx, interaction) => {
url: (ctx, interaction) => {
const isDemoApp = interaction.params.client_id === demoAppApplicationId;
const appendParameters = (path: string) => {
@ -158,7 +158,7 @@ export default function initOidc(
case 'login': {
// Always fetch the latest sign-in experience config for demo app (live preview)
if (isDemoApp) {
await wellKnownCache.invalidate(tenantId, ['sie', 'sie-full']);
wellKnownCache.invalidate(tenantId, ['sie', 'sie-full']);
}
const isSignUp =

View file

@ -195,7 +195,7 @@ export default async function submitInteraction(
// Normally we don't need to manually invalidate TTL cache.
// This is for better OSS onboarding experience.
await wellKnownCache.invalidate(tenantId, ['sie', 'sie-full']);
wellKnownCache.invalidate(tenantId, ['sie', 'sie-full']);
}
await assignInteractionResults(ctx, provider, { login: { accountId: id } });

View file

@ -17,7 +17,7 @@ export default function koaInteractionSie<StateT, ContextT, ResponseT>(
): MiddlewareType<StateT, WithInteractionSieContext<ContextT>, ResponseT> {
return async (ctx, next) => {
if (noCache(ctx.headers)) {
await wellKnownCache.invalidate(tenantId, ['sie']);
wellKnownCache.invalidate(tenantId, ['sie']);
}
const signInExperience = await getSignInExperience();

View file

@ -41,8 +41,8 @@ const phraseRequest = createRequester({
tenantContext,
});
afterEach(async () => {
await wellKnownCache.invalidateAll(tenantContext.id);
afterEach(() => {
wellKnownCache.invalidateAll(tenantContext.id);
jest.clearAllMocks();
});

View file

@ -59,8 +59,8 @@ const phraseRequest = createRequester({
tenantContext,
});
afterEach(async () => {
await wellKnownCache.invalidateAll(tenantContext.id);
afterEach(() => {
wellKnownCache.invalidateAll(tenantContext.id);
jest.clearAllMocks();
});

View file

@ -55,8 +55,8 @@ const tenantContext = new MockTenant(
);
describe('GET /.well-known/sign-in-exp', () => {
afterEach(async () => {
await wellKnownCache.invalidateAll(tenantContext.id);
afterEach(() => {
wellKnownCache.invalidateAll(tenantContext.id);
jest.clearAllMocks();
});

View file

@ -40,7 +40,7 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(
koaGuard({ response: guardFullSignInExperience, status: 200 }),
async (ctx, next) => {
if (noCache(ctx.headers)) {
await wellKnownCache.invalidate(tenantId, ['sie', 'sie-full']);
wellKnownCache.invalidate(tenantId, ['sie', 'sie-full']);
}
ctx.body = await getFullSignInExperience();
@ -60,7 +60,7 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(
}),
async (ctx, next) => {
if (noCache(ctx.headers)) {
await wellKnownCache.invalidate(tenantId, ['sie', 'phrases-lng-tags', 'phrases']);
wellKnownCache.invalidate(tenantId, ['sie', 'phrases-lng-tags', 'phrases']);
}
const {

View file

@ -28,7 +28,7 @@
"@logto/schemas": "workspace:*",
"@peculiar/webcrypto": "^1.3.3",
"@silverhand/eslint-config": "2.0.1",
"@silverhand/essentials": "^2.4.1",
"@silverhand/essentials": "2.4.1",
"@silverhand/ts-config": "2.0.3",
"@types/expect-puppeteer": "^5.0.3",
"@types/jest": "^29.4.0",

View file

@ -1,7 +1,8 @@
import type { SignInExperience } from '@logto/schemas';
import { adminTenantApi } from '#src/api/api.js';
import { adminTenantApi, authedAdminApi } from '#src/api/api.js';
import { api } from '#src/api/index.js';
import { generateUserId } from '#src/utils.js';
describe('.well-known api', () => {
it('get /.well-known/sign-in-exp for console', async () => {
@ -33,4 +34,19 @@ describe('.well-known api', () => {
// Should support sign-in and register
expect(response).toMatchObject({ signInMode: 'SignInAndRegister' });
});
it('should use cached version if no-cache header is not present', async () => {
const response1 = await api.get('.well-known/sign-in-exp').json<SignInExperience>();
const randomId = generateUserId();
const customContent = { foo: randomId };
await authedAdminApi.patch('sign-in-exp', { json: { customContent } }).json<SignInExperience>();
const response2 = await api
.get('.well-known/sign-in-exp', { headers: { 'cache-control': '' } })
.json<SignInExperience>();
expect(response2.customContent.foo).not.toBe(randomId);
expect(response2).toStrictEqual(response1);
});
});

View file

@ -34,7 +34,7 @@
},
"dependencies": {
"@logto/language-kit": "workspace:*",
"@silverhand/essentials": "^2.4.1",
"@silverhand/essentials": "2.4.1",
"zod": "^3.20.2"
},
"devDependencies": {

View file

@ -34,7 +34,7 @@
},
"dependencies": {
"@logto/language-kit": "workspace:*",
"@silverhand/essentials": "^2.4.1",
"@silverhand/essentials": "2.4.1",
"zod": "^3.20.2"
},
"devDependencies": {

View file

@ -41,7 +41,7 @@
},
"devDependencies": {
"@silverhand/eslint-config": "2.0.1",
"@silverhand/essentials": "^2.4.1",
"@silverhand/essentials": "2.4.1",
"@silverhand/ts-config": "2.0.3",
"@types/inquirer": "^9.0.0",
"@types/jest": "^29.4.0",

View file

@ -56,7 +56,7 @@
"dependencies": {
"@logto/core-kit": "workspace:*",
"@logto/schemas": "workspace:*",
"@silverhand/essentials": "^2.4.1",
"@silverhand/essentials": "2.4.1",
"chalk": "^5.0.0",
"find-up": "^6.3.0",
"nanoid": "^4.0.0",

View file

@ -1,3 +1,4 @@
export * from './function.js';
export * from './object.js';
export { default as findPackage } from './find-package.js';
export * from './ttl-cache.js';

View file

@ -0,0 +1,84 @@
import { TtlCache } from './ttl-cache.js';
const { jest } = import.meta;
describe('TtlCache', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should return cached value after a long time if ttl is not set', () => {
jest.setSystemTime(0);
const cache = new TtlCache();
const someObject = Object.freeze({ foo: 'bar', baz: 123 });
cache.set('foo', someObject);
jest.setSystemTime(100_000_000);
expect(cache.get('foo')).toBe(someObject);
expect(cache.has('foo')).toBe(true);
});
it('should return cached value and honor ttl', () => {
jest.setSystemTime(0);
const cache = new TtlCache(100);
const someObject = Object.freeze({ foo: 'bar', baz: 123 });
cache.set(123, someObject);
cache.set('foo', someObject, 99);
jest.setSystemTime(100);
expect(cache.get(123)).toBe(someObject);
expect(cache.has(123)).toBe(true);
expect(cache.get('123')).toBe(undefined);
expect(cache.has('123')).toBe(false);
expect(cache.get('foo')).toBe(undefined);
expect(cache.has('foo')).toBe(false);
jest.setSystemTime(101);
expect(cache.get(123)).toBe(undefined);
expect(cache.has(123)).toBe(false);
});
it('should be able to delete value before ttl', () => {
const cache = new TtlCache(100);
const someObject = Object.freeze({ foo: 'bar', baz: 123 });
cache.set('foo', someObject);
cache.delete('foo');
cache.delete('bar');
expect(cache.get('foo')).toBe(undefined);
expect(cache.has('foo')).toBe(false);
});
it('should be able to clear all values', () => {
const cache = new TtlCache(100);
const someObject = Object.freeze({ foo: 'bar', baz: 123 });
cache.set('foo', someObject);
cache.set('bar', someObject);
cache.set(123, 456);
cache.clear();
expect(cache.get('foo')).toBe(undefined);
expect(cache.has('foo')).toBe(false);
expect(cache.get('bar')).toBe(undefined);
expect(cache.has('bar')).toBe(false);
expect(cache.get(123)).toBe(undefined);
expect(cache.has(123)).toBe(false);
});
it('should throw undefined when value is undefined', () => {
const cache = new TtlCache();
expect(() => {
cache.set(1, undefined);
}).toThrow(TypeError);
});
});

View file

@ -0,0 +1,46 @@
export class TtlCache<Key, Value> {
data = new Map<Key, Value>();
expiration = new Map<Key, number>();
constructor(public readonly ttl = Number.POSITIVE_INFINITY) {}
#purge(key: Key) {
const expiration = this.expiration.get(key);
if (expiration !== undefined && expiration < Date.now()) {
this.delete(key);
}
}
set(key: Key, value: Value, ttl = this.ttl) {
if (value === undefined) {
throw new TypeError('Value cannot be undefined');
}
this.expiration.set(key, Date.now() + ttl);
this.data.set(key, value);
}
get(key: Key): Value | undefined {
this.#purge(key);
return this.data.get(key);
}
has(key: Key) {
this.#purge(key);
return this.data.has(key);
}
delete(key: Key) {
this.expiration.delete(key);
return this.data.delete(key);
}
clear() {
this.expiration.clear();
this.data.clear();
}
}

View file

@ -33,7 +33,7 @@
},
"dependencies": {
"@logto/language-kit": "workspace:*",
"@silverhand/essentials": "^2.4.1"
"@silverhand/essentials": "2.4.1"
},
"optionalDependencies": {
"zod": "^3.20.2"

View file

@ -50,7 +50,7 @@
"@jest/types": "^29.0.3",
"@silverhand/eslint-config": "2.0.1",
"@silverhand/eslint-config-react": "2.0.1",
"@silverhand/essentials": "^2.4.1",
"@silverhand/essentials": "2.4.1",
"@silverhand/ts-config": "2.0.3",
"@types/color": "^3.0.3",
"@types/jest": "^29.4.0",

View file

@ -33,7 +33,7 @@
"@react-spring/web": "^9.6.1",
"@silverhand/eslint-config": "2.0.1",
"@silverhand/eslint-config-react": "2.0.1",
"@silverhand/essentials": "^2.4.1",
"@silverhand/essentials": "2.4.1",
"@silverhand/jest-config": "1.2.2",
"@silverhand/ts-config": "2.0.3",
"@silverhand/ts-config-react": "2.0.3",

View file

@ -32,7 +32,7 @@ importers:
'@logto/schemas': workspace:*
'@logto/shared': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': ^2.4.1
'@silverhand/essentials': 2.4.1
'@silverhand/ts-config': 2.0.3
'@types/inquirer': ^9.0.0
'@types/jest': ^29.4.0
@ -116,7 +116,7 @@ importers:
'@logto/schemas': workspace:*
'@logto/shared': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': ^2.4.1
'@silverhand/essentials': 2.4.1
'@silverhand/jest-config': ^2.0.1
'@silverhand/ts-config': 2.0.3
'@types/accepts': ^1.3.5
@ -196,7 +196,7 @@ importers:
'@parcel/transformer-svg-react': 2.8.3
'@silverhand/eslint-config': 2.0.1
'@silverhand/eslint-config-react': 2.0.1
'@silverhand/essentials': ^2.4.1
'@silverhand/essentials': 2.4.1
'@silverhand/ts-config': 2.0.3
'@silverhand/ts-config-react': 2.0.3
'@tsconfig/docusaurus': ^1.0.5
@ -346,7 +346,7 @@ importers:
'@logto/schemas': workspace:*
'@logto/shared': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': ^2.4.1
'@silverhand/essentials': 2.4.1
'@silverhand/ts-config': 2.0.3
'@types/debug': ^4.1.7
'@types/etag': ^1.8.1
@ -385,7 +385,6 @@ importers:
jest-matcher-specific-error: ^1.0.0
jose: ^4.11.0
js-yaml: ^4.1.0
keyv: ^4.5.2
koa: ^2.13.1
koa-body: ^5.0.0
koa-compose: ^4.1.0
@ -444,7 +443,6 @@ importers:
iconv-lite: 0.6.3
jose: 4.11.0
js-yaml: 4.1.0
keyv: 4.5.2
koa: 2.13.4
koa-body: 5.0.0
koa-compose: 4.1.0
@ -577,7 +575,7 @@ importers:
'@logto/schemas': workspace:*
'@peculiar/webcrypto': ^1.3.3
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': ^2.4.1
'@silverhand/essentials': 2.4.1
'@silverhand/ts-config': 2.0.3
'@types/expect-puppeteer': ^5.0.3
'@types/jest': ^29.4.0
@ -629,7 +627,7 @@ importers:
specifiers:
'@logto/language-kit': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': ^2.4.1
'@silverhand/essentials': 2.4.1
'@silverhand/ts-config': 2.0.3
eslint: ^8.34.0
lint-staged: ^13.0.0
@ -652,7 +650,7 @@ importers:
specifiers:
'@logto/language-kit': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': ^2.4.1
'@silverhand/essentials': 2.4.1
'@silverhand/ts-config': 2.0.3
buffer: ^5.7.1
eslint: ^8.34.0
@ -681,7 +679,7 @@ importers:
'@logto/phrases': workspace:*
'@logto/phrases-ui': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': ^2.4.1
'@silverhand/essentials': 2.4.1
'@silverhand/ts-config': 2.0.3
'@types/inquirer': ^9.0.0
'@types/jest': ^29.4.0
@ -734,7 +732,7 @@ importers:
'@logto/core-kit': workspace:*
'@logto/schemas': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': ^2.4.1
'@silverhand/essentials': 2.4.1
'@silverhand/ts-config': 2.0.3
'@types/jest': ^29.4.0
'@types/node': ^18.11.18
@ -771,7 +769,7 @@ importers:
specifiers:
'@logto/language-kit': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/essentials': ^2.4.1
'@silverhand/essentials': 2.4.1
'@silverhand/ts-config': 2.0.3
'@types/node': ^18.11.18
eslint: ^8.34.0
@ -801,7 +799,7 @@ importers:
'@logto/language-kit': workspace:*
'@silverhand/eslint-config': 2.0.1
'@silverhand/eslint-config-react': 2.0.1
'@silverhand/essentials': ^2.4.1
'@silverhand/essentials': 2.4.1
'@silverhand/ts-config': 2.0.3
'@types/color': ^3.0.3
'@types/jest': ^29.4.0
@ -890,7 +888,7 @@ importers:
'@react-spring/web': ^9.6.1
'@silverhand/eslint-config': 2.0.1
'@silverhand/eslint-config-react': 2.0.1
'@silverhand/essentials': ^2.4.1
'@silverhand/essentials': 2.4.1
'@silverhand/jest-config': 1.2.2
'@silverhand/ts-config': 2.0.3
'@silverhand/ts-config-react': 2.0.3