Skip to content

Commit

Permalink
refactor(server): redis config (#13538)
Browse files Browse the repository at this point in the history
* refactor(server): redis config

* refactor: cache parsed env data

* chore: add database and redis tests
  • Loading branch information
jrasm91 authored Oct 17, 2024
1 parent 79acbc1 commit 3f66310
Show file tree
Hide file tree
Showing 8 changed files with 314 additions and 169 deletions.
10 changes: 7 additions & 3 deletions server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ClsModule } from 'nestjs-cls';
import { OpenTelemetryModule } from 'nestjs-otel';
import { commands } from 'src/commands';
import { bullConfig, bullQueues, clsConfig, immichAppConfig } from 'src/config';
import { clsConfig, immichAppConfig } from 'src/config';
import { controllers } from 'src/controllers';
import { databaseConfig } from 'src/database.config';
import { entities } from 'src/entities';
Expand All @@ -20,6 +20,7 @@ import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
import { repositories } from 'src/repositories';
import { ConfigRepository } from 'src/repositories/config.repository';
import { services } from 'src/services';
import { DatabaseService } from 'src/services/database.service';
import { otelConfig } from 'src/utils/instrumentation';
Expand All @@ -35,9 +36,12 @@ const middleware = [
{ provide: APP_GUARD, useClass: AuthGuard },
];

const configRepository = new ConfigRepository();
const { bull } = configRepository.getEnv();

const imports = [
BullModule.forRoot(bullConfig),
BullModule.registerQueue(...bullQueues),
BullModule.forRoot(bull.config),
BullModule.registerQueue(...bull.queues),
ClsModule.forRoot(clsConfig),
ConfigModule.forRoot(immichAppConfig),
OpenTelemetryModule.forRoot(otelConfig),
Expand Down
35 changes: 0 additions & 35 deletions server/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { RegisterQueueOptions } from '@nestjs/bullmq';
import { ConfigModuleOptions } from '@nestjs/config';
import { CronExpression } from '@nestjs/schedule';
import { QueueOptions } from 'bullmq';
import { Request, Response } from 'express';
import { RedisOptions } from 'ioredis';
import Joi, { Root } from 'joi';
import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
import { ImmichHeader } from 'src/dtos/auth.dto';
Expand Down Expand Up @@ -363,38 +360,6 @@ export const immichAppConfig: ConfigModuleOptions = {
}),
};

export function parseRedisConfig(): RedisOptions {
const redisUrl = process.env.REDIS_URL;
if (redisUrl && redisUrl.startsWith('ioredis://')) {
try {
const decodedString = Buffer.from(redisUrl.slice(10), 'base64').toString();
return JSON.parse(decodedString);
} catch (error) {
throw new Error(`Failed to decode redis options: ${error}`);
}
}
return {
host: process.env.REDIS_HOSTNAME || 'redis',
port: Number.parseInt(process.env.REDIS_PORT || '6379'),
db: Number.parseInt(process.env.REDIS_DBINDEX || '0'),
username: process.env.REDIS_USERNAME || undefined,
password: process.env.REDIS_PASSWORD || undefined,
path: process.env.REDIS_SOCKET || undefined,
};
}

export const bullConfig: QueueOptions = {
prefix: 'immich_bull',
connection: parseRedisConfig(),
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
};

export const bullQueues: RegisterQueueOptions[] = Object.values(QueueName).map((name) => ({ name }));

export const clsConfig: ClsModuleOptions = {
middleware: {
mount: true,
Expand Down
10 changes: 10 additions & 0 deletions server/src/interfaces/config.interface.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { RegisterQueueOptions } from '@nestjs/bullmq';
import { QueueOptions } from 'bullmq';
import { RedisOptions } from 'ioredis';
import { ImmichEnvironment, ImmichWorker, LogLevel } from 'src/enum';
import { VectorExtension } from 'src/interfaces/database.interface';

Expand Down Expand Up @@ -57,6 +60,13 @@ export interface EnvData {
};
};

redis: RedisOptions;

bull: {
config: QueueOptions;
queues: RegisterQueueOptions[];
};

storage: {
ignoreMountCheckErrors: boolean;
};
Expand Down
5 changes: 3 additions & 2 deletions server/src/middleware/websocket.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@ import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { Redis } from 'ioredis';
import { ServerOptions } from 'socket.io';
import { parseRedisConfig } from 'src/config';
import { IConfigRepository } from 'src/interfaces/config.interface';

export class WebSocketAdapter extends IoAdapter {
constructor(private app: INestApplicationContext) {
super(app);
}

createIOServer(port: number, options?: ServerOptions): any {
const { redis } = this.app.get<IConfigRepository>(IConfigRepository).getEnv();
const server = super.createIOServer(port, options);
const pubClient = new Redis(parseRedisConfig());
const pubClient = new Redis(redis);
const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient));
return server;
Expand Down
215 changes: 160 additions & 55 deletions server/src/repositories/config.repository.spec.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,181 @@
import { ConfigRepository } from 'src/repositories/config.repository';
import { clearEnvCache, ConfigRepository } from 'src/repositories/config.repository';

const getEnv = () => new ConfigRepository().getEnv();
const getEnv = () => {
clearEnvCache();
return new ConfigRepository().getEnv();
};

const resetEnv = () => {
for (const env of [
'IMMICH_WORKERS_INCLUDE',
'IMMICH_WORKERS_EXCLUDE',

'DB_URL',
'DB_HOSTNAME',
'DB_PORT',
'DB_USERNAME',
'DB_PASSWORD',
'DB_DATABASE_NAME',
'DB_SKIP_MIGRATIONS',
'DB_VECTOR_EXTENSION',

'REDIS_HOSTNAME',
'REDIS_PORT',
'REDIS_DBINDEX',
'REDIS_USERNAME',
'REDIS_PASSWORD',
'REDIS_SOCKET',
'REDIS_URL',

'NO_COLOR',
]) {
delete process.env[env];
}
};

const sentinelConfig = {
sentinels: [
{
host: 'redis-sentinel-node-0',
port: 26_379,
},
{
host: 'redis-sentinel-node-1',
port: 26_379,
},
{
host: 'redis-sentinel-node-2',
port: 26_379,
},
],
name: 'redis-sentinel',
};

describe('getEnv', () => {
beforeEach(() => {
delete process.env.IMMICH_WORKERS_INCLUDE;
delete process.env.IMMICH_WORKERS_EXCLUDE;
delete process.env.NO_COLOR;
resetEnv();
});

it('should return default workers', () => {
const { workers } = getEnv();
expect(workers).toEqual(['api', 'microservices']);
});
describe('database', () => {
it('should use defaults', () => {
const { database } = getEnv();
expect(database).toEqual({
url: undefined,
host: 'database',
port: 5432,
name: 'immich',
username: 'postgres',
password: 'postgres',
skipMigrations: false,
vectorExtension: 'vectors',
});
});

it('should return included workers', () => {
process.env.IMMICH_WORKERS_INCLUDE = 'api';
const { workers } = getEnv();
expect(workers).toEqual(['api']);
it('should allow skipping migrations', () => {
process.env.DB_SKIP_MIGRATIONS = 'true';
const { database } = getEnv();
expect(database).toMatchObject({ skipMigrations: true });
});
});

it('should excluded workers from defaults', () => {
process.env.IMMICH_WORKERS_EXCLUDE = 'api';
const { workers } = getEnv();
expect(workers).toEqual(['microservices']);
});
describe('redis', () => {
it('should use defaults', () => {
const { redis } = getEnv();
expect(redis).toEqual({
host: 'redis',
port: 6379,
db: 0,
username: undefined,
password: undefined,
path: undefined,
});
});

it('should exclude workers from include list', () => {
process.env.IMMICH_WORKERS_INCLUDE = 'api,microservices,randomservice';
process.env.IMMICH_WORKERS_EXCLUDE = 'randomservice,microservices';
const { workers } = getEnv();
expect(workers).toEqual(['api']);
});
it('should parse base64 encoded config, ignore other env', () => {
process.env.REDIS_URL = `ioredis://${Buffer.from(JSON.stringify(sentinelConfig)).toString('base64')}`;
process.env.REDIS_HOSTNAME = 'redis-host';
process.env.REDIS_USERNAME = 'redis-user';
process.env.REDIS_PASSWORD = 'redis-password';
const { redis } = getEnv();
expect(redis).toEqual(sentinelConfig);
});

it('should remove whitespace from included workers before parsing', () => {
process.env.IMMICH_WORKERS_INCLUDE = 'api, microservices';
const { workers } = getEnv();
expect(workers).toEqual(['api', 'microservices']);
it('should reject invalid json', () => {
process.env.REDIS_URL = `ioredis://${Buffer.from('{ "invalid json"').toString('base64')}`;
expect(() => getEnv()).toThrowError('Failed to decode redis options');
});
});

it('should remove whitespace from excluded workers before parsing', () => {
process.env.IMMICH_WORKERS_EXCLUDE = 'api, microservices';
const { workers } = getEnv();
expect(workers).toEqual([]);
});
describe('noColor', () => {
beforeEach(() => {
delete process.env.NO_COLOR;
});

it('should remove whitespace from included and excluded workers before parsing', () => {
process.env.IMMICH_WORKERS_INCLUDE = 'api, microservices, randomservice,randomservice2';
process.env.IMMICH_WORKERS_EXCLUDE = 'randomservice,microservices, randomservice2';
const { workers } = getEnv();
expect(workers).toEqual(['api']);
});
it('should default noColor to false', () => {
const { noColor } = getEnv();
expect(noColor).toBe(false);
});

it('should throw error for invalid workers', () => {
process.env.IMMICH_WORKERS_INCLUDE = 'api,microservices,randomservice';
expect(getEnv).toThrowError('Invalid worker(s) found: api,microservices,randomservice');
});
it('should map NO_COLOR=1 to true', () => {
process.env.NO_COLOR = '1';
const { noColor } = getEnv();
expect(noColor).toBe(true);
});

it('should default noColor to false', () => {
const { noColor } = getEnv();
expect(noColor).toBe(false);
it('should map NO_COLOR=true to true', () => {
process.env.NO_COLOR = 'true';
const { noColor } = getEnv();
expect(noColor).toBe(true);
});
});

it('should map NO_COLOR=1 to true', () => {
process.env.NO_COLOR = '1';
const { noColor } = getEnv();
expect(noColor).toBe(true);
});
describe('workers', () => {
it('should return default workers', () => {
const { workers } = getEnv();
expect(workers).toEqual(['api', 'microservices']);
});

it('should return included workers', () => {
process.env.IMMICH_WORKERS_INCLUDE = 'api';
const { workers } = getEnv();
expect(workers).toEqual(['api']);
});

it('should excluded workers from defaults', () => {
process.env.IMMICH_WORKERS_EXCLUDE = 'api';
const { workers } = getEnv();
expect(workers).toEqual(['microservices']);
});

it('should exclude workers from include list', () => {
process.env.IMMICH_WORKERS_INCLUDE = 'api,microservices,randomservice';
process.env.IMMICH_WORKERS_EXCLUDE = 'randomservice,microservices';
const { workers } = getEnv();
expect(workers).toEqual(['api']);
});

it('should remove whitespace from included workers before parsing', () => {
process.env.IMMICH_WORKERS_INCLUDE = 'api, microservices';
const { workers } = getEnv();
expect(workers).toEqual(['api', 'microservices']);
});

it('should remove whitespace from excluded workers before parsing', () => {
process.env.IMMICH_WORKERS_EXCLUDE = 'api, microservices';
const { workers } = getEnv();
expect(workers).toEqual([]);
});

it('should remove whitespace from included and excluded workers before parsing', () => {
process.env.IMMICH_WORKERS_INCLUDE = 'api, microservices, randomservice,randomservice2';
process.env.IMMICH_WORKERS_EXCLUDE = 'randomservice,microservices, randomservice2';
const { workers } = getEnv();
expect(workers).toEqual(['api']);
});

it('should map NO_COLOR=true to true', () => {
process.env.NO_COLOR = 'true';
const { noColor } = getEnv();
expect(noColor).toBe(true);
it('should throw error for invalid workers', () => {
process.env.IMMICH_WORKERS_INCLUDE = 'api,microservices,randomservice';
expect(getEnv).toThrowError('Invalid worker(s) found: api,microservices,randomservice');
});
});
});
Loading

0 comments on commit 3f66310

Please sign in to comment.