From 247208e5f585515743cbc4aa463145993cca54e1 Mon Sep 17 00:00:00 2001 From: Felix Heilmeyer Date: Sun, 1 May 2022 17:44:35 +0200 Subject: [PATCH] feat: make ioredis configurable via environment variables (#3365) * feat: expose ioredis client options * run linter * refactor redis client init into class extension * explicitly handle constructor errors * rename singletons --- .env.sample | 10 ++++++- docker-compose.yml | 2 ++ server/redis.ts | 52 +++++++++++++++++++++++++++++------ server/services/websockets.ts | 13 +++++---- server/utils/queue.ts | 7 ++--- server/utils/updates.ts | 6 ++-- 6 files changed, 68 insertions(+), 22 deletions(-) diff --git a/.env.sample b/.env.sample index 439c66d9f..64d5228f8 100644 --- a/.env.sample +++ b/.env.sample @@ -16,7 +16,15 @@ DATABASE_CONNECTION_POOL_MIN= DATABASE_CONNECTION_POOL_MAX= # Uncomment this to disable SSL for connecting to Postgres # PGSSLMODE=disable -REDIS_URL=redis://localhost:6379 + +# For redis you can either specify an ioredis compatible url like this +REDIS_URL=redis://localhost:6479 +# or alternatively, if you would like to provide addtional connection options, +# use a base64 encoded JSON connection option object. Refer to the ioredis documentation +# for a list of available options. +# Example: Use Redis Sentinel for high availability +# {"sentinels":[{"host":"sentinel-0","port":26379},{"host":"sentinel-1","port":26379}],"name":"mymaster"} +# REDIS_URL=ioredis://eyJzZW50aW5lbHMiOlt7Imhvc3QiOiJzZW50aW5lbC0wIiwicG9ydCI6MjYzNzl9LHsiaG9zdCI6InNlbnRpbmVsLTEiLCJwb3J0IjoyNjM3OX1dLCJuYW1lIjoibXltYXN0ZXIifQ== # URL should point to the fully qualified, publicly accessible URL. If using a # proxy the port in URL and PORT may be different. diff --git a/docker-compose.yml b/docker-compose.yml index e769e9309..f2c62cb8a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,7 @@ services: image: redis ports: - "127.0.0.1:6479:6379" + user: "redis:redis" postgres: image: postgres ports: @@ -12,6 +13,7 @@ services: POSTGRES_USER: user POSTGRES_PASSWORD: pass POSTGRES_DB: outline + user: "postgres:postgres" s3: image: lphoward/fake-s3 ports: diff --git a/server/redis.ts b/server/redis.ts index 084a539be..35fc0edf6 100644 --- a/server/redis.ts +++ b/server/redis.ts @@ -1,7 +1,8 @@ import Redis from "ioredis"; +import { defaults } from "lodash"; import Logger from "./logging/logger"; -const options = { +const defaultOptions = { maxRetriesPerRequest: 20, retryStrategy(times: number) { @@ -18,12 +19,45 @@ const options = { } : undefined, }; -const client = new Redis(process.env.REDIS_URL, options); -const subscriber = new Redis(process.env.REDIS_URL, options); -// More than the default of 10 listeners is expected for the amount of queues -// we're running. Increase the max here to prevent a warning in the console: -// https://github.com/OptimalBits/bull/issues/1192 -client.setMaxListeners(100); -subscriber.setMaxListeners(100); -export { client, subscriber }; +export default class RedisAdapter extends Redis { + constructor(url: string | undefined) { + if (!(url || "").startsWith("ioredis://")) { + super(process.env.REDIS_URL, defaultOptions); + } else { + let customOptions = {}; + try { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const decodedString = Buffer.from(url!.slice(10), "base64").toString(); + customOptions = JSON.parse(decodedString); + } catch (error) { + throw new Error(`Failed to decode redis adapter options: ${error}`); + } + + try { + const mergedOptions = defaults(defaultOptions, customOptions); + super(mergedOptions); + } catch (error) { + throw new Error(`Failed to initialize redis client: ${error}`); + } + } + + // More than the default of 10 listeners is expected for the amount of queues + // we're running. Increase the max here to prevent a warning in the console: + // https://github.com/OptimalBits/bull/issues/1192 + this.setMaxListeners(100); + } + + private static _client: RedisAdapter; + private static _subscriber: RedisAdapter; + + public static get defaultClient(): RedisAdapter { + return this._client || (this._client = new this(process.env.REDIS_URL)); + } + + public static get defaultSubscriber(): RedisAdapter { + return ( + this._subscriber || (this._subscriber = new this(process.env.REDIS_URL)) + ); + } +} diff --git a/server/services/websockets.ts b/server/services/websockets.ts index 52228de85..5839a68ea 100644 --- a/server/services/websockets.ts +++ b/server/services/websockets.ts @@ -11,7 +11,7 @@ import { can } from "@server/policies"; import { getUserForJWT } from "@server/utils/jwt"; import { websocketQueue } from "../queues"; import WebsocketsProcessor from "../queues/processors/WebsocketsProcessor"; -import { client, subscriber } from "../redis"; +import Redis from "../redis"; export default function init(app: Koa, server: http.Server) { const path = "/realtime"; @@ -49,8 +49,8 @@ export default function init(app: Koa, server: http.Server) { io.adapter( socketRedisAdapter({ - pubClient: client, - subClient: subscriber, + pubClient: Redis.defaultClient, + subClient: Redis.defaultSubscriber, }) ); @@ -92,7 +92,7 @@ export default function init(app: Koa, server: http.Server) { // store the mapping between socket id and user id in redis // so that it is accessible across multiple server nodes - await client.hset(socket.id, "userId", user.id); + await Redis.defaultClient.hset(socket.id, "userId", user.id); return callback(null, true); } catch (err) { return callback(err, false); @@ -173,7 +173,10 @@ export default function init(app: Koa, server: http.Server) { const userIds = new Map(); for (const socketId of sockets) { - const userId = await client.hget(socketId, "userId"); + const userId = await Redis.defaultClient.hget( + socketId, + "userId" + ); userIds.set(userId, userId); } diff --git a/server/utils/queue.ts b/server/utils/queue.ts index c8870156a..09f3f3d67 100644 --- a/server/utils/queue.ts +++ b/server/utils/queue.ts @@ -1,8 +1,7 @@ import Queue from "bull"; -import Redis from "ioredis"; import { snakeCase } from "lodash"; import Metrics from "@server/logging/metrics"; -import { client, subscriber } from "../redis"; +import Redis from "../redis"; export function createQueue( name: string, @@ -13,10 +12,10 @@ export function createQueue( createClient(type) { switch (type) { case "client": - return client; + return Redis.defaultClient; case "subscriber": - return subscriber; + return Redis.defaultSubscriber; default: return new Redis(process.env.REDIS_URL); diff --git a/server/utils/updates.ts b/server/utils/updates.ts index 837a8a605..ee31781ac 100644 --- a/server/utils/updates.ts +++ b/server/utils/updates.ts @@ -6,7 +6,7 @@ import Document from "@server/models/Document"; import Team from "@server/models/Team"; import User from "@server/models/User"; import packageInfo from "../../package.json"; -import { client } from "../redis"; +import Redis from "../redis"; const UPDATES_URL = "https://updates.getoutline.com"; const UPDATES_KEY = "UPDATES_KEY"; @@ -40,7 +40,7 @@ export async function checkUpdates() { documentCount, }, }); - await client.del(UPDATES_KEY); + await Redis.defaultClient.del(UPDATES_KEY); try { const response = await fetch(UPDATES_URL, { @@ -54,7 +54,7 @@ export async function checkUpdates() { const data = await response.json(); if (data.severity) { - await client.set( + await Redis.defaultClient.set( UPDATES_KEY, JSON.stringify({ severity: data.severity,