From 6a223892dd582eff32d900cc967090990927a8e5 Mon Sep 17 00:00:00 2001 From: TheOnlyMace <0815cracky@gmail.com> Date: Wed, 22 Jul 2026 21:38:56 +0200 Subject: [PATCH] Refactor queue management to use lazy initialization and improve Redis connection handling - Updated queue and QueueEvents initialization to be lazy, preventing unnecessary Redis connections during the build process. - Refactored queue access methods to ensure consistent connection handling across the application. - Enhanced error handling for Redis connections during production builds to reduce log noise. --- .../webui/src/lib/module-configs/giveaways.ts | 14 ++-- .../src/lib/module-configs/guildbackup.ts | 4 +- .../webui/src/lib/module-configs/scheduler.ts | 6 +- .../src/lib/module-configs/suggestions.ts | 6 +- apps/webui/src/lib/queues.ts | 84 +++++++++++++------ apps/webui/src/lib/redis.ts | 17 +++- 6 files changed, 88 insertions(+), 43 deletions(-) diff --git a/apps/webui/src/lib/module-configs/giveaways.ts b/apps/webui/src/lib/module-configs/giveaways.ts index 8b88678..1a6413d 100644 --- a/apps/webui/src/lib/module-configs/giveaways.ts +++ b/apps/webui/src/lib/module-configs/giveaways.ts @@ -1,7 +1,7 @@ import type { GiveawayCreateDashboard, GiveawayDashboard } from '@nexumi/shared'; import type { Giveaway } from '@prisma/client'; import { prisma } from '../prisma'; -import { addJobAndAwait, giveawayQueue, giveawayQueueEvents } from '../queues'; +import { addJobAndAwait, getGiveawayQueue, getGiveawayQueueEvents } from '../queues'; export class GiveawayCreateTimeoutError extends Error { constructor() { @@ -60,8 +60,8 @@ export async function createGiveawayDashboard( } const { confirmed, result } = await addJobAndAwait<{ id: string }>( - giveawayQueue, - giveawayQueueEvents, + getGiveawayQueue(), + getGiveawayQueueEvents(), 'giveawayCreate', { guildId, @@ -112,12 +112,12 @@ export async function endGiveawayDashboard(guildId: string, giveawayId: string): return null; } - const existingJob = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`); + const existingJob = await getGiveawayQueue().getJob(`giveaway-end-${giveawayId}`); await existingJob?.remove(); await addJobAndAwait( - giveawayQueue, - giveawayQueueEvents, + getGiveawayQueue(), + getGiveawayQueueEvents(), 'giveawayEnd', { giveawayId }, { jobId: `giveaway-end-${giveawayId}`, removeOnComplete: true, removeOnFail: 50, delay: 0 } @@ -133,7 +133,7 @@ export async function deleteGiveawayDashboard(guildId: string, giveawayId: strin return false; } - const job = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`); + const job = await getGiveawayQueue().getJob(`giveaway-end-${giveawayId}`); await job?.remove(); await prisma.giveaway.delete({ where: { id: giveawayId } }); diff --git a/apps/webui/src/lib/module-configs/guildbackup.ts b/apps/webui/src/lib/module-configs/guildbackup.ts index cb4666d..c13a0d2 100644 --- a/apps/webui/src/lib/module-configs/guildbackup.ts +++ b/apps/webui/src/lib/module-configs/guildbackup.ts @@ -3,7 +3,7 @@ import type { GuildBackup } from '@prisma/client'; import { buildGuildBackupPayloadViaRest } from '../guild-backup'; import { prisma } from '../prisma'; import { assertPremiumLimit, PremiumLimitError } from '../premium'; -import { guildBackupQueue, guildBackupQueueName } from '../queues'; +import { getGuildBackupQueue, guildBackupQueueName } from '../queues'; export class BackupPremiumError extends Error { constructor(public readonly code: 'premium_limit' | 'premium_feature') { @@ -77,7 +77,7 @@ export async function enqueueGuildBackupRestore( guildId: string, requestedById: string ): Promise { - await guildBackupQueue.add( + await getGuildBackupQueue().add( 'restoreGuildBackup', { backupId, guildId, requestedById }, { diff --git a/apps/webui/src/lib/module-configs/scheduler.ts b/apps/webui/src/lib/module-configs/scheduler.ts index 6b22d70..4da9a75 100644 --- a/apps/webui/src/lib/module-configs/scheduler.ts +++ b/apps/webui/src/lib/module-configs/scheduler.ts @@ -1,7 +1,7 @@ import type { ScheduledMessageDashboard, ScheduledMessageDashboardCreate } from '@nexumi/shared'; import type { ScheduledMessage } from '@prisma/client'; import { prisma } from '../prisma'; -import { scheduleQueue } from '../queues'; +import { getScheduleQueue } from '../queues'; export class SchedulerValidationError extends Error { constructor(public readonly code: string) { @@ -92,7 +92,7 @@ export async function createScheduledMessageDashboard( jobOptions.delay = Math.max(0, runAt.getTime() - Date.now()); } - const job = await scheduleQueue.add('scheduleSend', { scheduleId: schedule.id }, jobOptions); + const job = await getScheduleQueue().add('scheduleSend', { scheduleId: schedule.id }, jobOptions); const updated = await prisma.scheduledMessage.update({ where: { id: schedule.id }, data: { jobId: String(job.id) } @@ -108,7 +108,7 @@ export async function deleteScheduledMessageDashboard(guildId: string, scheduleI } if (existing.jobId) { - const job = await scheduleQueue.getJob(existing.jobId); + const job = await getScheduleQueue().getJob(existing.jobId); await job?.remove(); } diff --git a/apps/webui/src/lib/module-configs/suggestions.ts b/apps/webui/src/lib/module-configs/suggestions.ts index 1bb0e33..0869d84 100644 --- a/apps/webui/src/lib/module-configs/suggestions.ts +++ b/apps/webui/src/lib/module-configs/suggestions.ts @@ -7,7 +7,7 @@ import type { } from '@nexumi/shared'; import type { Suggestion } from '@prisma/client'; import { prisma } from '../prisma'; -import { addJobAndAwait, suggestionsQueue, suggestionsQueueEvents } from '../queues'; +import { addJobAndAwait, getSuggestionsQueue, getSuggestionsQueueEvents } from '../queues'; export class SuggestionActionTimeoutError extends Error { constructor() { @@ -105,8 +105,8 @@ export async function applySuggestionAction( } const { confirmed } = await addJobAndAwait<{ id: string }>( - suggestionsQueue, - suggestionsQueueEvents, + getSuggestionsQueue(), + getSuggestionsQueueEvents(), 'suggestionStatusUpdate', { suggestionId, diff --git a/apps/webui/src/lib/queues.ts b/apps/webui/src/lib/queues.ts index e25cd28..1d87883 100644 --- a/apps/webui/src/lib/queues.ts +++ b/apps/webui/src/lib/queues.ts @@ -8,6 +8,11 @@ import { redis } from './redis'; * drawing winners, restoring a guild backup) is enqueued here and picked up * by the bot's existing workers - the WebUI never talks to Discord directly * for these actions. + * + * Queues and QueueEvents are created lazily on first use. Eager construction + * at import time would open Redis connections during `next build` (where + * Redis is intentionally unavailable), flooding the build log with + * ECONNREFUSED errors. */ export const giveawayQueueName = 'giveaways'; export const scheduleQueueName = 'schedules'; @@ -24,43 +29,68 @@ const globalForQueues = globalThis as unknown as { __nexumiSuggestionsQueueEvents?: QueueEvents; }; -export const giveawayQueue: Queue = - globalForQueues.__nexumiGiveawayQueue ?? new Queue(giveawayQueueName, { connection: redis }); +function queueConnection() { + return redis; +} -export const scheduleQueue: Queue = - globalForQueues.__nexumiScheduleQueue ?? new Queue(scheduleQueueName, { connection: redis }); +/** QueueEvents needs its own connection; duplicate inherits lazyConnect. */ +function queueEventsConnection() { + return redis.duplicate(); +} -export const guildBackupQueue: Queue = - globalForQueues.__nexumiGuildBackupQueue ?? new Queue(guildBackupQueueName, { connection: redis }); +export function getGiveawayQueue(): Queue { + globalForQueues.__nexumiGiveawayQueue ??= new Queue(giveawayQueueName, { + connection: queueConnection() + }); + return globalForQueues.__nexumiGiveawayQueue; +} -export const suggestionsQueue: Queue = - globalForQueues.__nexumiSuggestionsQueue ?? new Queue(suggestionsQueueName, { connection: redis }); +export function getScheduleQueue(): Queue { + globalForQueues.__nexumiScheduleQueue ??= new Queue(scheduleQueueName, { + connection: queueConnection() + }); + return globalForQueues.__nexumiScheduleQueue; +} + +export function getGuildBackupQueue(): Queue { + globalForQueues.__nexumiGuildBackupQueue ??= new Queue(guildBackupQueueName, { + connection: queueConnection() + }); + return globalForQueues.__nexumiGuildBackupQueue; +} + +export function getSuggestionsQueue(): Queue { + globalForQueues.__nexumiSuggestionsQueue ??= new Queue(suggestionsQueueName, { + connection: queueConnection() + }); + return globalForQueues.__nexumiSuggestionsQueue; +} /** * QueueEvents instances are only needed for jobs where the dashboard needs * to wait for the bot to finish (e.g. ending a giveaway so the winners list - * is immediately visible). They open their own Redis connection, so they are - * cached on the global object the same way as the queues themselves. + * is immediately visible). */ -export const giveawayQueueEvents: QueueEvents = - globalForQueues.__nexumiGiveawayQueueEvents ?? - new QueueEvents(giveawayQueueName, { connection: redis.duplicate() }); +export function getGiveawayQueueEvents(): QueueEvents { + globalForQueues.__nexumiGiveawayQueueEvents ??= new QueueEvents(giveawayQueueName, { + connection: queueEventsConnection() + }); + return globalForQueues.__nexumiGiveawayQueueEvents; +} -export const guildBackupQueueEvents: QueueEvents = - globalForQueues.__nexumiGuildBackupQueueEvents ?? - new QueueEvents(guildBackupQueueName, { connection: redis.duplicate() }); +export function getGuildBackupQueueEvents(): QueueEvents { + globalForQueues.__nexumiGuildBackupQueueEvents ??= new QueueEvents(guildBackupQueueName, { + connection: queueEventsConnection() + }); + return globalForQueues.__nexumiGuildBackupQueueEvents; +} -export const suggestionsQueueEvents: QueueEvents = - globalForQueues.__nexumiSuggestionsQueueEvents ?? - new QueueEvents(suggestionsQueueName, { connection: redis.duplicate() }); - -globalForQueues.__nexumiGiveawayQueue = giveawayQueue; -globalForQueues.__nexumiScheduleQueue = scheduleQueue; -globalForQueues.__nexumiGuildBackupQueue = guildBackupQueue; -globalForQueues.__nexumiSuggestionsQueue = suggestionsQueue; -globalForQueues.__nexumiGiveawayQueueEvents = giveawayQueueEvents; -globalForQueues.__nexumiGuildBackupQueueEvents = guildBackupQueueEvents; -globalForQueues.__nexumiSuggestionsQueueEvents = suggestionsQueueEvents; +export function getSuggestionsQueueEvents(): QueueEvents { + globalForQueues.__nexumiSuggestionsQueueEvents ??= new QueueEvents(suggestionsQueueName, { + connection: queueEventsConnection() + }); + return globalForQueues.__nexumiSuggestionsQueueEvents; +} const DEFAULT_WAIT_TIMEOUT_MS = 15_000; diff --git a/apps/webui/src/lib/redis.ts b/apps/webui/src/lib/redis.ts index f2137ec..be9adbf 100644 --- a/apps/webui/src/lib/redis.ts +++ b/apps/webui/src/lib/redis.ts @@ -3,6 +3,11 @@ import { env } from './env'; const globalForRedis = globalThis as unknown as { __nexumiRedis?: Redis }; +/** Next.js sets this while collecting page data / compiling the production build. */ +function isNextProductionBuild(): boolean { + return process.env.NEXT_PHASE === 'phase-production-build'; +} + export const redis: Redis = globalForRedis.__nexumiRedis ?? new Redis(env.REDIS_URL, { @@ -16,7 +21,17 @@ export const redis: Redis = }); redis.on('error', (error) => { - console.error('[redis]', error.message); + // Redis is intentionally unreachable during `next build` (Docker build + // stage uses a placeholder REDIS_URL). Queues are lazy, but any stray + // connect attempt should stay silent rather than spam AggregateError. + if (isNextProductionBuild()) { + return; + } + const detail = + error instanceof Error + ? error.message || ('code' in error ? String((error as NodeJS.ErrnoException).code) : error.name) + : String(error); + console.error('[redis]', detail); }); if (env.NODE_ENV !== 'production') {