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 { GiveawayCreateDashboard, GiveawayDashboard } from '@nexumi/shared';
import type { Giveaway } from '@prisma/client'; import type { Giveaway } from '@prisma/client';
import { prisma } from '../prisma'; import { prisma } from '../prisma';
import { addJobAndAwait, giveawayQueue, giveawayQueueEvents } from '../queues'; import { addJobAndAwait, getGiveawayQueue, getGiveawayQueueEvents } from '../queues';
export class GiveawayCreateTimeoutError extends Error { export class GiveawayCreateTimeoutError extends Error {
constructor() { constructor() {
@@ -60,8 +60,8 @@ export async function createGiveawayDashboard(
} }
const { confirmed, result } = await addJobAndAwait<{ id: string }>( const { confirmed, result } = await addJobAndAwait<{ id: string }>(
giveawayQueue, getGiveawayQueue(),
giveawayQueueEvents, getGiveawayQueueEvents(),
'giveawayCreate', 'giveawayCreate',
{ {
guildId, guildId,
@@ -112,12 +112,12 @@ export async function endGiveawayDashboard(guildId: string, giveawayId: string):
return null; return null;
} }
const existingJob = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`); const existingJob = await getGiveawayQueue().getJob(`giveaway-end-${giveawayId}`);
await existingJob?.remove(); await existingJob?.remove();
await addJobAndAwait( await addJobAndAwait(
giveawayQueue, getGiveawayQueue(),
giveawayQueueEvents, getGiveawayQueueEvents(),
'giveawayEnd', 'giveawayEnd',
{ giveawayId }, { giveawayId },
{ jobId: `giveaway-end-${giveawayId}`, removeOnComplete: true, removeOnFail: 50, delay: 0 } { jobId: `giveaway-end-${giveawayId}`, removeOnComplete: true, removeOnFail: 50, delay: 0 }
@@ -133,7 +133,7 @@ export async function deleteGiveawayDashboard(guildId: string, giveawayId: strin
return false; return false;
} }
const job = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`); const job = await getGiveawayQueue().getJob(`giveaway-end-${giveawayId}`);
await job?.remove(); await job?.remove();
await prisma.giveaway.delete({ where: { id: giveawayId } }); 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 { buildGuildBackupPayloadViaRest } from '../guild-backup';
import { prisma } from '../prisma'; import { prisma } from '../prisma';
import { assertPremiumLimit, PremiumLimitError } from '../premium'; import { assertPremiumLimit, PremiumLimitError } from '../premium';
import { guildBackupQueue, guildBackupQueueName } from '../queues'; import { getGuildBackupQueue, guildBackupQueueName } from '../queues';
export class BackupPremiumError extends Error { export class BackupPremiumError extends Error {
constructor(public readonly code: 'premium_limit' | 'premium_feature') { constructor(public readonly code: 'premium_limit' | 'premium_feature') {
@@ -77,7 +77,7 @@ export async function enqueueGuildBackupRestore(
guildId: string, guildId: string,
requestedById: string requestedById: string
): Promise<void> { ): Promise<void> {
await guildBackupQueue.add( await getGuildBackupQueue().add(
'restoreGuildBackup', 'restoreGuildBackup',
{ backupId, guildId, requestedById }, { backupId, guildId, requestedById },
{ {

View File

@@ -1,7 +1,7 @@
import type { ScheduledMessageDashboard, ScheduledMessageDashboardCreate } from '@nexumi/shared'; import type { ScheduledMessageDashboard, ScheduledMessageDashboardCreate } from '@nexumi/shared';
import type { ScheduledMessage } from '@prisma/client'; import type { ScheduledMessage } from '@prisma/client';
import { prisma } from '../prisma'; import { prisma } from '../prisma';
import { scheduleQueue } from '../queues'; import { getScheduleQueue } from '../queues';
export class SchedulerValidationError extends Error { export class SchedulerValidationError extends Error {
constructor(public readonly code: string) { constructor(public readonly code: string) {
@@ -92,7 +92,7 @@ export async function createScheduledMessageDashboard(
jobOptions.delay = Math.max(0, runAt.getTime() - Date.now()); 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({ const updated = await prisma.scheduledMessage.update({
where: { id: schedule.id }, where: { id: schedule.id },
data: { jobId: String(job.id) } data: { jobId: String(job.id) }
@@ -108,7 +108,7 @@ export async function deleteScheduledMessageDashboard(guildId: string, scheduleI
} }
if (existing.jobId) { if (existing.jobId) {
const job = await scheduleQueue.getJob(existing.jobId); const job = await getScheduleQueue().getJob(existing.jobId);
await job?.remove(); await job?.remove();
} }

View File

@@ -7,7 +7,7 @@ import type {
} from '@nexumi/shared'; } from '@nexumi/shared';
import type { Suggestion } from '@prisma/client'; import type { Suggestion } from '@prisma/client';
import { prisma } from '../prisma'; import { prisma } from '../prisma';
import { addJobAndAwait, suggestionsQueue, suggestionsQueueEvents } from '../queues'; import { addJobAndAwait, getSuggestionsQueue, getSuggestionsQueueEvents } from '../queues';
export class SuggestionActionTimeoutError extends Error { export class SuggestionActionTimeoutError extends Error {
constructor() { constructor() {
@@ -105,8 +105,8 @@ export async function applySuggestionAction(
} }
const { confirmed } = await addJobAndAwait<{ id: string }>( const { confirmed } = await addJobAndAwait<{ id: string }>(
suggestionsQueue, getSuggestionsQueue(),
suggestionsQueueEvents, getSuggestionsQueueEvents(),
'suggestionStatusUpdate', 'suggestionStatusUpdate',
{ {
suggestionId, suggestionId,

View File

@@ -8,6 +8,11 @@ import { redis } from './redis';
* drawing winners, restoring a guild backup) is enqueued here and picked up * 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 * by the bot's existing workers - the WebUI never talks to Discord directly
* for these actions. * 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 giveawayQueueName = 'giveaways';
export const scheduleQueueName = 'schedules'; export const scheduleQueueName = 'schedules';
@@ -24,43 +29,68 @@ const globalForQueues = globalThis as unknown as {
__nexumiSuggestionsQueueEvents?: QueueEvents; __nexumiSuggestionsQueueEvents?: QueueEvents;
}; };
export const giveawayQueue: Queue = function queueConnection() {
globalForQueues.__nexumiGiveawayQueue ?? new Queue(giveawayQueueName, { connection: redis }); return redis;
}
export const scheduleQueue: Queue = /** QueueEvents needs its own connection; duplicate inherits lazyConnect. */
globalForQueues.__nexumiScheduleQueue ?? new Queue(scheduleQueueName, { connection: redis }); function queueEventsConnection() {
return redis.duplicate();
}
export const guildBackupQueue: Queue = export function getGiveawayQueue(): Queue {
globalForQueues.__nexumiGuildBackupQueue ?? new Queue(guildBackupQueueName, { connection: redis }); globalForQueues.__nexumiGiveawayQueue ??= new Queue(giveawayQueueName, {
connection: queueConnection()
});
return globalForQueues.__nexumiGiveawayQueue;
}
export const suggestionsQueue: Queue = export function getScheduleQueue(): Queue {
globalForQueues.__nexumiSuggestionsQueue ?? new Queue(suggestionsQueueName, { connection: redis }); 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 * 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 * 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 * is immediately visible).
* cached on the global object the same way as the queues themselves.
*/ */
export const giveawayQueueEvents: QueueEvents = export function getGiveawayQueueEvents(): QueueEvents {
globalForQueues.__nexumiGiveawayQueueEvents ?? globalForQueues.__nexumiGiveawayQueueEvents ??= new QueueEvents(giveawayQueueName, {
new QueueEvents(giveawayQueueName, { connection: redis.duplicate() }); connection: queueEventsConnection()
});
return globalForQueues.__nexumiGiveawayQueueEvents;
}
export const guildBackupQueueEvents: QueueEvents = export function getGuildBackupQueueEvents(): QueueEvents {
globalForQueues.__nexumiGuildBackupQueueEvents ?? globalForQueues.__nexumiGuildBackupQueueEvents ??= new QueueEvents(guildBackupQueueName, {
new QueueEvents(guildBackupQueueName, { connection: redis.duplicate() }); connection: queueEventsConnection()
});
return globalForQueues.__nexumiGuildBackupQueueEvents;
}
export const suggestionsQueueEvents: QueueEvents = export function getSuggestionsQueueEvents(): QueueEvents {
globalForQueues.__nexumiSuggestionsQueueEvents ?? globalForQueues.__nexumiSuggestionsQueueEvents ??= new QueueEvents(suggestionsQueueName, {
new QueueEvents(suggestionsQueueName, { connection: redis.duplicate() }); connection: queueEventsConnection()
});
globalForQueues.__nexumiGiveawayQueue = giveawayQueue; return globalForQueues.__nexumiSuggestionsQueueEvents;
globalForQueues.__nexumiScheduleQueue = scheduleQueue; }
globalForQueues.__nexumiGuildBackupQueue = guildBackupQueue;
globalForQueues.__nexumiSuggestionsQueue = suggestionsQueue;
globalForQueues.__nexumiGiveawayQueueEvents = giveawayQueueEvents;
globalForQueues.__nexumiGuildBackupQueueEvents = guildBackupQueueEvents;
globalForQueues.__nexumiSuggestionsQueueEvents = suggestionsQueueEvents;
const DEFAULT_WAIT_TIMEOUT_MS = 15_000; 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 }; 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 = export const redis: Redis =
globalForRedis.__nexumiRedis ?? globalForRedis.__nexumiRedis ??
new Redis(env.REDIS_URL, { new Redis(env.REDIS_URL, {
@@ -16,7 +21,17 @@ export const redis: Redis =
}); });
redis.on('error', (error) => { 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') { if (env.NODE_ENV !== 'production') {