feat: add memory cache to boost performance (#2620)

* feat: add memory cache to boost performance

* fix: tests

* fix: logging

* fix: missing commented stuff
This commit is contained in:
Jérémy M
2023-11-21 18:29:31 +01:00
committed by GitHub
parent 74e0122294
commit dd125ddfcc
27 changed files with 458 additions and 17 deletions

View File

@@ -41,6 +41,7 @@
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.3.2",
"@nestjs/core": "^9.0.0",
"@nestjs/event-emitter": "^2.0.3",
"@nestjs/graphql": "^12.0.8",
"@nestjs/jwt": "^10.0.3",
"@nestjs/passport": "^9.0.3",

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER, ContextIdFactory, ModuleRef } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { YogaDriver, YogaDriverConfig } from '@graphql-yoga/nestjs';
import GraphQLJSON from 'graphql-type-json';
@@ -9,6 +10,8 @@ import { GraphQLError, GraphQLSchema } from 'graphql';
import { ExtractJwt } from 'passport-jwt';
import { TokenExpiredError, JsonWebTokenError, verify } from 'jsonwebtoken';
import { WorkspaceFactory } from 'src/workspace/workspace.factory';
import { AppService } from './app.service';
import { CoreModule } from './core/core.module';
@@ -20,7 +23,6 @@ import {
JwtAuthStrategy,
JwtPayload,
} from './core/auth/strategies/jwt.auth.strategy';
import { WorkspaceFactory } from './workspace/workspace.factory';
import { ExceptionFilter } from './filters/exception.filter';
@Module({
@@ -102,6 +104,7 @@ import { ExceptionFilter } from './filters/exception.filter';
resolvers: { JSON: GraphQLJSON },
plugins: [],
}),
EventEmitterModule.forRoot(),
HealthModule,
IntegrationsModule,
CoreModule,

View File

@@ -12,6 +12,7 @@ import { RefreshToken } from 'src/core/refresh-token/refresh-token.entity';
export class TypeORMService implements OnModuleInit, OnModuleDestroy {
private mainDataSource: DataSource;
private dataSources: Map<string, DataSource> = new Map();
private isDatasourceInitializing: Map<string, boolean> = new Map();
constructor(private readonly environmentService: EnvironmentService) {
this.mainDataSource = new DataSource({
@@ -35,23 +36,46 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy {
public async connectToDataSource(
dataSource: DataSourceEntity,
): Promise<DataSource | undefined> {
// Wait for a bit before trying again if another initialization is in progress
while (this.isDatasourceInitializing.get(dataSource.id)) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
if (this.dataSources.has(dataSource.id)) {
return this.dataSources.get(dataSource.id);
}
this.isDatasourceInitializing.set(dataSource.id, true);
try {
const dataSourceInstance = await this.createAndInitializeDataSource(
dataSource,
);
this.dataSources.set(dataSource.id, dataSourceInstance);
return dataSourceInstance;
} finally {
this.isDatasourceInitializing.delete(dataSource.id);
}
}
private async createAndInitializeDataSource(
dataSource: DataSourceEntity,
): Promise<DataSource> {
const schema = dataSource.schema;
const workspaceDataSource = new DataSource({
url: dataSource.url ?? this.environmentService.getPGDatabaseUrl(),
type: 'postgres',
logging: ['query'],
logging: this.environmentService.isDebugMode()
? ['query', 'error']
: ['error'],
schema,
});
await workspaceDataSource.initialize();
this.dataSources.set(dataSource.id, workspaceDataSource);
return workspaceDataSource;
}

View File

@@ -0,0 +1,3 @@
export enum MemoryStorageType {
Local = 'local',
}

View File

@@ -0,0 +1,9 @@
import { Inject } from '@nestjs/common';
import { createMemoryStorageInjectionToken } from 'src/integrations/memory-storage/memory-storage.util';
export const InjectMemoryStorage = (identifier: string) => {
const injectionToken = createMemoryStorageInjectionToken(identifier);
return Inject(injectionToken);
};

View File

@@ -0,0 +1,5 @@
export interface MemoryStorageDriver<T> {
read(params: { key: string }): Promise<T | null>;
write(params: { key: string; data: T }): Promise<void>;
delete(params: { key: string }): Promise<void>;
}

View File

@@ -0,0 +1,57 @@
import { MemoryStorageSerializer } from 'src/integrations/memory-storage/serializers/interfaces/memory-storage-serializer.interface';
import { MemoryStorageDriver } from './interfaces/memory-storage-driver.interface';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface LocalMemoryDriverOptions {}
export class LocalMemoryDriver<T> implements MemoryStorageDriver<T> {
private identifier: string;
private options: LocalMemoryDriverOptions;
private serializer: MemoryStorageSerializer<T>;
private storage: Map<string, string> = new Map();
constructor(
identifier: string,
options: LocalMemoryDriverOptions,
serializer: MemoryStorageSerializer<T>,
) {
this.identifier = identifier;
this.options = options;
this.serializer = serializer;
}
async write(params: { key: string; data: T }): Promise<void> {
const compositeKey = this.generateCompositeKey(params.key);
const serializedData = this.serializer.serialize(params.data);
this.storage.set(compositeKey, serializedData);
}
async read(params: { key: string }): Promise<T | null> {
const compositeKey = this.generateCompositeKey(params.key);
if (!this.storage.has(compositeKey)) {
return null;
}
const data = this.storage.get(compositeKey)!;
const deserializeData = this.serializer.deserialize(data);
return deserializeData;
}
async delete(params: { key: string }): Promise<void> {
const compositeKey = this.generateCompositeKey(params.key);
if (!this.storage.has(compositeKey)) {
return;
}
this.storage.delete(compositeKey);
}
private generateCompositeKey(key: string): string {
return `${this.identifier}:${key}`;
}
}

View File

@@ -0,0 +1 @@
export * from './memory-storage.interface';

View File

@@ -0,0 +1,29 @@
import { FactoryProvider, ModuleMetadata } from '@nestjs/common';
import { MemoryStorageType } from 'src/integrations/environment/interfaces/memory-storage.interface';
import { MemoryStorageSerializer } from 'src/integrations/memory-storage/serializers/interfaces/memory-storage-serializer.interface';
import { LocalMemoryDriverOptions } from 'src/integrations/memory-storage/drivers/local.driver';
export interface LocalMemoryDriverFactoryOptions {
type: MemoryStorageType.Local;
options: LocalMemoryDriverOptions;
}
interface MemoryStorageModuleBaseOptions {
identifier: string;
serializer?: MemoryStorageSerializer<any>;
}
export type MemoryStorageModuleOptions = MemoryStorageModuleBaseOptions &
LocalMemoryDriverFactoryOptions;
export type MemoryStorageModuleAsyncOptions = {
identifier: string;
useFactory: (
...args: any[]
) =>
| Omit<MemoryStorageModuleOptions, 'identifier'>
| Promise<Omit<MemoryStorageModuleOptions, 'identifier'>>;
} & Pick<ModuleMetadata, 'imports'> &
Pick<FactoryProvider, 'inject'>;

View File

@@ -0,0 +1 @@
export const MEMORY_STORAGE_SERVICE = 'MEMORY_STORAGE_SERVICE';

View File

@@ -0,0 +1,73 @@
import { DynamicModule, Global } from '@nestjs/common';
import { MemoryStorageType } from 'src/integrations/environment/interfaces/memory-storage.interface';
import { MemoryStorageDefaultSerializer } from 'src/integrations/memory-storage/serializers/default.serializer';
import { createMemoryStorageInjectionToken } from 'src/integrations/memory-storage/memory-storage.util';
import {
MemoryStorageModuleAsyncOptions,
MemoryStorageModuleOptions,
} from './interfaces';
import { LocalMemoryDriver } from './drivers/local.driver';
@Global()
export class MemoryStorageModule {
static forRoot(options: MemoryStorageModuleOptions): DynamicModule {
// Dynamic injection token to allow multiple instances of the same driver
const injectionToken = createMemoryStorageInjectionToken(
options.identifier,
);
const provider = {
provide: injectionToken,
useValue: this.createStorageDriver(options),
};
return {
module: MemoryStorageModule,
providers: [provider],
exports: [provider],
};
}
static forRootAsync(options: MemoryStorageModuleAsyncOptions): DynamicModule {
// Dynamic injection token to allow multiple instances of the same driver
const injectionToken = createMemoryStorageInjectionToken(
options.identifier,
);
const provider = {
provide: injectionToken,
useFactory: async (...args: any[]) => {
const config = await options.useFactory(...args);
return this.createStorageDriver({
identifier: options.identifier,
...config,
});
},
inject: options.inject || [],
};
return {
module: MemoryStorageModule,
imports: options.imports || [],
providers: [provider],
exports: [provider],
};
}
private static createStorageDriver(options: MemoryStorageModuleOptions) {
switch (options.type) {
case MemoryStorageType.Local:
return new LocalMemoryDriver(
options.identifier,
options.options,
options.serializer ?? new MemoryStorageDefaultSerializer<string>(),
);
// Future case for Redis or other types
default:
throw new Error(`Unsupported storage type: ${options.type}`);
}
}
}

View File

@@ -0,0 +1,19 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MemoryStorageService } from './memory-storage.service';
describe('MemoryStorageService', () => {
let service: MemoryStorageService<any>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [MemoryStorageService],
}).compile();
service = module.get<MemoryStorageService<any>>(MemoryStorageService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,21 @@
import { MemoryStorageDriver } from 'src/integrations/memory-storage/drivers/interfaces/memory-storage-driver.interface';
export class MemoryStorageService<T> implements MemoryStorageDriver<T> {
private driver: MemoryStorageDriver<T>;
constructor(driver: MemoryStorageDriver<T>) {
this.driver = driver;
}
write(params: { key: string; data: T }): Promise<void> {
return this.driver.write(params);
}
read(params: { key: string }): Promise<T | null> {
return this.driver.read(params);
}
delete(params: { key: string }): Promise<void> {
return this.driver.delete(params);
}
}

View File

@@ -0,0 +1,5 @@
import { MEMORY_STORAGE_SERVICE } from 'src/integrations/memory-storage/memory-storage.constants';
export const createMemoryStorageInjectionToken = (identifier: string) => {
return `${MEMORY_STORAGE_SERVICE}_${identifier}`;
};

View File

@@ -0,0 +1,16 @@
import { MemoryStorageSerializer } from 'src/integrations/memory-storage/serializers/interfaces/memory-storage-serializer.interface';
export class MemoryStorageDefaultSerializer<T>
implements MemoryStorageSerializer<T>
{
serialize(item: T): string {
if (typeof item !== 'string') {
throw new Error('DefaultSerializer can only serialize strings');
}
return item;
}
deserialize(data: string): T {
return data as unknown as T;
}
}

View File

@@ -0,0 +1,4 @@
export interface MemoryStorageSerializer<T> {
serialize(item: T): string;
deserialize(data: string): T;
}

View File

@@ -0,0 +1,13 @@
import { MemoryStorageSerializer } from 'src/integrations/memory-storage/serializers/interfaces/memory-storage-serializer.interface';
export class MemoryStorageJsonSerializer<T>
implements MemoryStorageSerializer<T>
{
serialize(item: T): string {
return JSON.stringify(item);
}
deserialize(data: string): T {
return JSON.parse(data) as T;
}
}

View File

@@ -0,0 +1,11 @@
export class WorkspaceMigrationAppliedEvent {
private readonly _workspaceId: string;
constructor(worskapceId: string) {
this._workspaceId = worskapceId;
}
get workspaceId(): string {
return this._workspaceId;
}
}

View File

@@ -0,0 +1,3 @@
export enum WorkspaceMigrationEvents {
MigrationApplied = '@workspace/migration-applied',
}

View File

@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
QueryRunner,
@@ -17,6 +18,8 @@ import {
WorkspaceMigrationColumnCreate,
WorkspaceMigrationColumnRelation,
} from 'src/metadata/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationEvents } from 'src/workspace/workspace-migration-runner/events/workspace-migration-events';
import { WorkspaceMigrationAppliedEvent } from 'src/workspace/workspace-migration-runner/events/workspace-migration-applied.event';
import { customTableDefaultColumns } from './utils/custom-table-default-column.util';
@@ -25,6 +28,7 @@ export class WorkspaceMigrationRunnerService {
constructor(
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly eventEmitter: EventEmitter2,
) {}
/**
@@ -78,6 +82,12 @@ export class WorkspaceMigrationRunnerService {
await queryRunner.release();
// Emit event when migration is applied
this.eventEmitter.emit(
WorkspaceMigrationEvents.MigrationApplied,
new WorkspaceMigrationAppliedEvent(workspaceId),
);
return flattenedPendingMigrations;
}

View File

@@ -142,13 +142,9 @@ export class WorkspaceQueryRunnerService {
)};
`);
const queryFormatted = query
.replace('neq:null', 'is:NOT_NULL')
.replace('eq:null', 'is:NULL');
const results = await workspaceDataSource?.query<PGGraphQLResult>(`
SELECT graphql.resolve($$
${queryFormatted}
${query}
$$);
`);

View File

@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { MemoryStorageType } from 'src/integrations/environment/interfaces/memory-storage.interface';
import { MemoryStorageModule } from 'src/integrations/memory-storage/memory-storage.module';
import { MemoryStorageJsonSerializer } from 'src/integrations/memory-storage/serializers/json.serializer';
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module';
import { WorkspaceSchemaStorageService } from 'src/workspace/workspace-schema-storage/workspace-schema-storage.service';
@Module({
imports: [
ObjectMetadataModule,
MemoryStorageModule.forRoot({
identifier: 'objectMetadataCollection',
type: MemoryStorageType.Local,
options: {},
serializer: new MemoryStorageJsonSerializer<ObjectMetadataEntity[]>(),
}),
MemoryStorageModule.forRoot({
identifier: 'typeDefs',
type: MemoryStorageType.Local,
options: {},
}),
],
providers: [WorkspaceSchemaStorageService],
exports: [WorkspaceSchemaStorageService],
})
export class WorkspaceSchemaStorageModule {}

View File

@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { InjectMemoryStorage } from 'src/integrations/memory-storage/decorators/inject-memory-storage.decorator';
import { MemoryStorageService } from 'src/integrations/memory-storage/memory-storage.service';
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import { WorkspaceMigrationAppliedEvent } from 'src/workspace/workspace-migration-runner/events/workspace-migration-applied.event';
import { WorkspaceMigrationEvents } from 'src/workspace/workspace-migration-runner/events/workspace-migration-events';
@Injectable()
export class WorkspaceSchemaStorageService {
constructor(
@InjectMemoryStorage('objectMetadataCollection')
private readonly objectMetadataMemoryStorageService: MemoryStorageService<
ObjectMetadataEntity[]
>,
@InjectMemoryStorage('typeDefs')
private readonly typeDefsMemoryStorageService: MemoryStorageService<string>,
) {}
setObjectMetadata(
workspaceId: string,
objectMetadata: ObjectMetadataEntity[],
) {
return this.objectMetadataMemoryStorageService.write({
key: workspaceId,
data: objectMetadata,
});
}
getObjectMetadata(
workspaceId: string,
): Promise<ObjectMetadataEntity[] | null> {
return this.objectMetadataMemoryStorageService.read({
key: workspaceId,
});
}
setTypeDefs(workspaceId: string, typeDefs: string): Promise<void> {
return this.typeDefsMemoryStorageService.write({
key: workspaceId,
data: typeDefs,
});
}
getTypeDefs(workspaceId: string): Promise<string | null> {
return this.typeDefsMemoryStorageService.read({
key: workspaceId,
});
}
/**
* Clear the workspace schema storage when new migrations are applied for a specific workspace
*/
@OnEvent(WorkspaceMigrationEvents.MigrationApplied)
handleMigrationAppliedEvent({ workspaceId }: WorkspaceMigrationAppliedEvent) {
this.objectMetadataMemoryStorageService.delete({ key: workspaceId });
this.typeDefsMemoryStorageService.delete({ key: workspaceId });
}
}

View File

@@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service';
import { WorkspaceSchemaStorageService } from 'src/workspace/workspace-schema-storage/workspace-schema-storage.service';
import { WorkspaceFactory } from './workspace.factory';
@@ -31,6 +32,10 @@ describe('WorkspaceFactory', () => {
provide: WorkspaceResolverFactory,
useValue: {},
},
{
provide: WorkspaceSchemaStorageService,
useValue: {},
},
],
}).compile();

View File

@@ -5,6 +5,7 @@ import { makeExecutableSchema } from '@graphql-tools/schema';
import { gql } from 'graphql-tag';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { WorkspaceSchemaStorageService } from 'src/workspace/workspace-schema-storage/workspace-schema-storage.service';
import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service';
import { WorkspaceGraphQLSchemaFactory } from './workspace-schema-builder/workspace-graphql-schema.factory';
@@ -18,6 +19,7 @@ export class WorkspaceFactory {
private readonly objectMetadataService: ObjectMetadataService,
private readonly workspaceGraphQLSchemaFactory: WorkspaceGraphQLSchemaFactory,
private readonly workspaceResolverFactory: WorkspaceResolverFactory,
private readonly workspaceSchemaStorageService: WorkspaceSchemaStorageService,
) {}
async createGraphQLSchema(
@@ -37,15 +39,43 @@ export class WorkspaceFactory {
return new GraphQLSchema({});
}
const objectMetadataCollection =
await this.objectMetadataService.getObjectMetadataFromWorkspaceId(
workspaceId,
);
// Get object metadata from cache
let objectMetadataCollection =
await this.workspaceSchemaStorageService.getObjectMetadata(workspaceId);
const autoGeneratedSchema = await this.workspaceGraphQLSchemaFactory.create(
objectMetadataCollection,
workspaceResolverBuilderMethodNames,
// If object metadata is not cached, get it from the database
if (!objectMetadataCollection) {
objectMetadataCollection =
await this.objectMetadataService.getObjectMetadataFromWorkspaceId(
workspaceId,
);
await this.workspaceSchemaStorageService.setObjectMetadata(
workspaceId,
objectMetadataCollection,
);
}
// Get typeDefs from cache
let typeDefs = await this.workspaceSchemaStorageService.getTypeDefs(
workspaceId,
);
// If typeDefs are not cached, generate them
if (!typeDefs) {
const autoGeneratedSchema =
await this.workspaceGraphQLSchemaFactory.create(
objectMetadataCollection,
workspaceResolverBuilderMethodNames,
);
typeDefs = printSchema(autoGeneratedSchema);
await this.workspaceSchemaStorageService.setTypeDefs(
workspaceId,
typeDefs,
);
}
const autoGeneratedResolvers = await this.workspaceResolverFactory.create(
workspaceId,
objectMetadataCollection,
@@ -53,7 +83,6 @@ export class WorkspaceFactory {
);
// TODO: Cache the generate type definitions
const typeDefs = printSchema(autoGeneratedSchema);
const executableSchema = makeExecutableSchema({
typeDefs: gql`
${typeDefs}

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { MetadataModule } from 'src/metadata/metadata.module';
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
import { WorkspaceSchemaStorageModule } from 'src/workspace/workspace-schema-storage/workspace-schema-storage.module';
import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module';
import { WorkspaceFactory } from './workspace.factory';
@@ -16,6 +17,7 @@ import { WorkspaceResolverBuilderModule } from './workspace-resolver-builder/wor
ObjectMetadataModule,
WorkspaceSchemaBuilderModule,
WorkspaceResolverBuilderModule,
WorkspaceSchemaStorageModule,
],
providers: [WorkspaceFactory],
exports: [WorkspaceFactory],

View File

@@ -1848,6 +1848,13 @@
path-to-regexp "3.2.0"
tslib "2.5.3"
"@nestjs/event-emitter@^2.0.3":
version "2.0.3"
resolved "https://registry.yarnpkg.com/@nestjs/event-emitter/-/event-emitter-2.0.3.tgz#3bfcb7b580f98b2dee3c44c567b45a506767f559"
integrity sha512-Pt7KAERrgK0OjvarSI3wfVhwZ8X1iLq1lXuodyRe+Zx3aLLP7fraFUHirASbFkB6KIQ1Zj+gZ1g8a9eu4GfFhw==
dependencies:
eventemitter2 "6.4.9"
"@nestjs/graphql@^12.0.8":
version "12.0.8"
resolved "https://registry.yarnpkg.com/@nestjs/graphql/-/graphql-12.0.8.tgz#15143b76dfb5fa4dc880d68a1bf2f7159ea077b6"
@@ -5159,6 +5166,11 @@ etag@~1.8.1:
resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz"
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
eventemitter2@6.4.9:
version "6.4.9"
resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.9.tgz#41f2750781b4230ed58827bc119d293471ecb125"
integrity sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==
eventemitter3@^3.1.0:
version "3.1.2"
resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz"