deploy #1

Merged
smueller merged 30 commits from deploy into main 2026-07-24 08:41:09 +00:00
6 changed files with 88 additions and 43 deletions
Showing only changes of commit 6a223892dd - Show all commits

View File

@@ -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 } });

View File

@@ -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<void> {
await guildBackupQueue.add(
await getGuildBackupQueue().add(
'restoreGuildBackup',
{ backupId, guildId, requestedById },
{

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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') {