Skip to content

Commit

Permalink
refactor(frontend): extract redis client (to use in future health che…
Browse files Browse the repository at this point in the history
…cks)
  • Loading branch information
gregory-j-baker committed Dec 23, 2024
1 parent ed9211d commit 15effbb
Show file tree
Hide file tree
Showing 2 changed files with 95 additions and 77 deletions.
88 changes: 11 additions & 77 deletions frontend/app/.server/express/session.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
import { RedisStore } from 'connect-redis';
import { Duration, Redacted } from 'effect';
import { MemoryStore } from 'express-session';
import Redis from 'ioredis';
import { setInterval } from 'node:timers';

import type { ServerEnvironment } from '~/.server/environment';
import { LogFactory } from '~/.server/logging';
import { getRedisClient } from '~/.server/redis';

const log = LogFactory.getLogger(import.meta.url);

/**
* Creates a memory store for Express sessions.
* This function creates a new MemoryStore instance and sets up a timer to periodically purge expired sessions.
*
* @returns A MemoryStore instance.
* This function initializes a new `MemoryStore` instance and sets
* up an automated task to purge expired sessions every 60 seconds.
*/
export function createMemoryStore(): MemoryStore {
log.info(' initializing new memory session store');
Expand All @@ -27,89 +25,25 @@ export function createMemoryStore(): MemoryStore {

/**
* Creates a Redis store for Express sessions.
*
* This function attempts to connect to a Redis server using the provided environment variables.
* It implements a retry strategy with exponential backoff in case of connection failures.
*
* @param environment
* - The environment object containing configuration values.
* - `env.REDIS_HOST` (string): The hostname or IP address of the Redis server.
* - `env.REDIS_PORT` (number): The port number of the Redis server.
* - `env.REDIS_PASSWORD` (string): The password for the Redis server (optional).
* - `env.SESSION_EXPIRES_SECONDS` (number): The session expiration time in seconds.
*
* @throws {Error} If failed to connect to Redis after the maximum retries.
* This function initializes a new `RedisStore` instance, using the
* Redis client and session configuration from the provided server environment.
*/
export function createRedisStore(environment: ServerEnvironment): RedisStore {
log.info(' initializing new Redis session store');
const { REDIS_CONNECTION_TYPE, REDIS_HOST, REDIS_PORT, SESSION_EXPIRES_SECONDS, SESSION_KEY_PREFIX } = environment;

const redisClient = new Redis(getRedisConfig(environment))
.on('connect', () => log.info('Connected to %s://%s:%s/', REDIS_CONNECTION_TYPE, REDIS_HOST, REDIS_PORT))
.on('error', (error) => log.error('Redis client error: %s', error.message));

return new RedisStore({
client: redisClient,
prefix: SESSION_KEY_PREFIX,
client: getRedisClient(),
prefix: environment.SESSION_KEY_PREFIX,
// The Redis TTL is set to the session expiration
// time, plus 5% to allow for clock drift
ttl: SESSION_EXPIRES_SECONDS * 1.05,
ttl: environment.SESSION_EXPIRES_SECONDS * 1.05,
});
}

function getRedisConfig(environment: ServerEnvironment) {
const {
REDIS_COMMAND_TIMEOUT_SECONDS, //
REDIS_HOST,
REDIS_PASSWORD,
REDIS_PORT,
REDIS_SENTINEL_MASTER_NAME,
REDIS_USERNAME,
} = environment;

const redisPassword = Redacted.value(REDIS_PASSWORD);
const redisCommandTimeout = Duration.toMillis(Duration.seconds(REDIS_COMMAND_TIMEOUT_SECONDS));

const retryStrategy = (times: number): number => {
// exponential backoff starting at 250ms to a maximum of 5s
const retryIn = Math.min(250 * Math.pow(2, times - 1), 5000);
log.error('Could not connect to Redis (attempt #%s); retry in %s ms', times, retryIn);
return retryIn;
};

switch (environment.REDIS_CONNECTION_TYPE) {
case 'standalone': {
log.debug(' configuring Redis client in standalone mode');
return {
host: REDIS_HOST,
port: REDIS_PORT,
username: REDIS_USERNAME,
password: redisPassword,
commandTimeout: redisCommandTimeout,
retryStrategy,
};
}

case 'sentinel': {
log.debug(' configuring Redis client in sentinel mode');

return {
name: REDIS_SENTINEL_MASTER_NAME,
sentinels: [{ host: REDIS_HOST, port: REDIS_PORT }],
username: REDIS_USERNAME,
password: redisPassword,
commandTimeout: redisCommandTimeout,
retryStrategy,
};
}
}
}

/**
* Purges expired sessions from a MemoryStore.
* This function iterates through all sessions in the store and removes those that have expired.
*
* @param memoryStore - The MemoryStore instance to purge sessions from.
* Purges expired sessions from a memory store.
* This function iterates through all sessions stored in the provided `MemoryStore` instance,
* checking their expiration times, and removes any sessions that have expired.
*/
function purgeExpiredSessions(memoryStore: MemoryStore): void {
log.trace('Purging expired sessions');
Expand Down
84 changes: 84 additions & 0 deletions frontend/app/.server/redis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Duration, Redacted } from 'effect';
import Redis from 'ioredis';

import { serverEnvironment } from '~/.server/environment';
import { LogFactory } from '~/.server/logging';

const log = LogFactory.getLogger(import.meta.url);

/**
* A holder for our singleton redis client instance.
*/
const clientHolder: { client?: Redis } = {};

/**
* Retrieves the application's redis client instance.
* If the client does not exist, it initializes a new one.
*/
export function getRedisClient() {
return (clientHolder.client ??= createRedisClient());
}

/**
* Creates a new Redis client and sets up logging for connection and error events.
*/
function createRedisClient(): Redis {
log.info('Creating new redis client');

const { REDIS_CONNECTION_TYPE, REDIS_HOST, REDIS_PORT } = serverEnvironment;

return new Redis(getRedisConfig())
.on('connect', () => log.info('Connected to %s://%s:%s/', REDIS_CONNECTION_TYPE, REDIS_HOST, REDIS_PORT))
.on('error', (error) => log.error('Redis client error: %s', error.message));
}

/**
* Constructs the configuration object for the Redis client based on the server environment.
*/
function getRedisConfig() {
const {
REDIS_COMMAND_TIMEOUT_SECONDS, //
REDIS_HOST,
REDIS_PASSWORD,
REDIS_PORT,
REDIS_SENTINEL_MASTER_NAME,
REDIS_USERNAME,
} = serverEnvironment;

const redisPassword = Redacted.value(REDIS_PASSWORD);
const redisCommandTimeout = Duration.toMillis(Duration.seconds(REDIS_COMMAND_TIMEOUT_SECONDS));

const retryStrategy = (times: number): number => {
// exponential backoff starting at 250ms to a maximum of 5s
const retryIn = Math.min(250 * Math.pow(2, times - 1), 5000);
log.error('Could not connect to Redis (attempt #%s); retry in %s ms', times, retryIn);
return retryIn;
};

switch (serverEnvironment.REDIS_CONNECTION_TYPE) {
case 'standalone': {
log.debug(' configuring Redis client in standalone mode');
return {
host: REDIS_HOST,
port: REDIS_PORT,
username: REDIS_USERNAME,
password: redisPassword,
commandTimeout: redisCommandTimeout,
retryStrategy,
};
}

case 'sentinel': {
log.debug(' configuring Redis client in sentinel mode');

return {
name: REDIS_SENTINEL_MASTER_NAME,
sentinels: [{ host: REDIS_HOST, port: REDIS_PORT }],
username: REDIS_USERNAME,
password: redisPassword,
commandTimeout: redisCommandTimeout,
retryStrategy,
};
}
}
}

0 comments on commit 15effbb

Please sign in to comment.