bb-plane-fork/apps/live/src/redis.ts
Aaryan Khandelwal 64781be7d2
regression: downgrade to tiptap v2 (#7982)
* chore: downgrade to tiptap v2

* fix: revert back to hocuspocus

* fix: collaboration events added

* fix: lock unlock issues

* fix: build errors

* fix: type errors

* fix: graceful shutdown

---------

Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
2025-10-21 18:28:16 +05:30

214 lines
6 KiB
TypeScript

import Redis from "ioredis";
import { logger } from "@plane/logger";
import { env } from "./env";
export class RedisManager {
private static instance: RedisManager;
private redisClient: Redis | null = null;
private isConnected: boolean = false;
private connectionPromise: Promise<void> | null = null;
private constructor() {}
public static getInstance(): RedisManager {
if (!RedisManager.instance) {
RedisManager.instance = new RedisManager();
}
return RedisManager.instance;
}
public async initialize(): Promise<void> {
if (this.redisClient && this.isConnected) {
logger.info("REDIS_MANAGER: client already initialized and connected");
return;
}
if (this.connectionPromise) {
logger.info("REDIS_MANAGER: Redis connection already in progress, waiting...");
await this.connectionPromise;
return;
}
this.connectionPromise = this.connect();
await this.connectionPromise;
}
private getRedisUrl(): string {
const redisUrl = env.REDIS_URL;
const redisHost = env.REDIS_HOST;
const redisPort = env.REDIS_PORT;
if (redisUrl) {
return redisUrl;
}
if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) {
return `redis://${redisHost}:${redisPort}`;
}
return "";
}
private async connect(): Promise<void> {
try {
const redisUrl = this.getRedisUrl();
if (!redisUrl) {
logger.warn("REDIS_MANAGER: No Redis URL provided, Redis functionality will be disabled");
this.isConnected = false;
return;
}
// Configuration optimized for BOTH regular operations AND pub/sub
// HocuspocusRedis uses .duplicate() which inherits these settings
this.redisClient = new Redis(redisUrl, {
lazyConnect: false, // Connect immediately for reliability (duplicates inherit this)
keepAlive: 30000,
connectTimeout: 10000,
maxRetriesPerRequest: 3,
enableOfflineQueue: true, // Keep commands queued during reconnection
retryStrategy: (times: number) => {
// Exponential backoff with max 2 seconds
const delay = Math.min(times * 50, 2000);
logger.info(`REDIS_MANAGER: Reconnection attempt ${times}, delay: ${delay}ms`);
return delay;
},
});
// Set up event listeners
this.redisClient.on("connect", () => {
logger.info("REDIS_MANAGER: Redis client connected");
this.isConnected = true;
});
this.redisClient.on("ready", () => {
logger.info("REDIS_MANAGER: Redis client ready");
this.isConnected = true;
});
this.redisClient.on("error", (error) => {
logger.error("REDIS_MANAGER: Redis client error:", error);
this.isConnected = false;
});
this.redisClient.on("close", () => {
logger.warn("REDIS_MANAGER: Redis client connection closed");
this.isConnected = false;
});
this.redisClient.on("reconnecting", () => {
logger.info("REDIS_MANAGER: Redis client reconnecting...");
this.isConnected = false;
});
await this.redisClient.ping();
logger.info("REDIS_MANAGER: Redis connection test successful");
} catch (error) {
logger.error("REDIS_MANAGER: Failed to initialize Redis client:", error);
this.isConnected = false;
throw error;
} finally {
this.connectionPromise = null;
}
}
public getClient(): Redis | null {
if (!this.redisClient || !this.isConnected) {
logger.warn("REDIS_MANAGER: Redis client not available or not connected");
return null;
}
return this.redisClient;
}
public isClientConnected(): boolean {
return this.isConnected && this.redisClient !== null;
}
public async disconnect(): Promise<void> {
if (this.redisClient) {
try {
await this.redisClient.quit();
logger.info("REDIS_MANAGER: Redis client disconnected gracefully");
} catch (error) {
logger.error("REDIS_MANAGER: Error disconnecting Redis client:", error);
// Force disconnect if quit fails
this.redisClient.disconnect();
} finally {
this.redisClient = null;
this.isConnected = false;
}
}
}
// Convenience methods for common Redis operations
public async set(key: string, value: string, ttl?: number): Promise<boolean> {
const client = this.getClient();
if (!client) return false;
try {
if (ttl) {
await client.setex(key, ttl, value);
} else {
await client.set(key, value);
}
return true;
} catch (error) {
logger.error(`REDIS_MANAGER: Error setting Redis key ${key}:`, error);
return false;
}
}
public async get(key: string): Promise<string | null> {
const client = this.getClient();
if (!client) return null;
try {
return await client.get(key);
} catch (error) {
logger.error(`REDIS_MANAGER: Error getting Redis key ${key}:`, error);
return null;
}
}
public async del(key: string): Promise<boolean> {
const client = this.getClient();
if (!client) return false;
try {
await client.del(key);
return true;
} catch (error) {
logger.error(`REDIS_MANAGER: Error deleting Redis key ${key}:`, error);
return false;
}
}
public async exists(key: string): Promise<boolean> {
const client = this.getClient();
if (!client) return false;
try {
const result = await client.exists(key);
return result === 1;
} catch (error) {
logger.error(`REDIS_MANAGER: Error checking Redis key ${key}:`, error);
return false;
}
}
public async expire(key: string, ttl: number): Promise<boolean> {
const client = this.getClient();
if (!client) return false;
try {
const result = await client.expire(key, ttl);
return result === 1;
} catch (error) {
logger.error(`REDIS_MANAGER: Error setting expiry for Redis key ${key}:`, error);
return false;
}
}
}
// Export a default instance for convenience
export const redisManager = RedisManager.getInstance();