Add a new field `consecutiveFailures` to the Inbox model to track consecutive errors. Update the inbox guard logic to mark mailboxes as error after exceeding a failure threshold. Implement retry logic in email and IMAP services to handle transient connection issues. Adjust job processing to reclaim stale send jobs and improve overall reliability of email dispatching. Update routes to initialize `consecutiveFailures` on inbox creation and reset it when the inbox status changes to active.
134 lines
3.2 KiB
TypeScript
134 lines
3.2 KiB
TypeScript
import { SendJobStatus, prisma } from '../lib/prisma';
|
|
import { logger } from '../lib/logger';
|
|
|
|
export async function logJobRun(
|
|
jobName: string,
|
|
success: boolean,
|
|
message?: string,
|
|
): Promise<void> {
|
|
await prisma.jobRunLog.create({
|
|
data: { jobName, success, message },
|
|
});
|
|
}
|
|
|
|
export async function getJobStatuses(): Promise<
|
|
Array<{ jobName: string; lastRunAt: string | null; success: boolean | null; message: string | null }>
|
|
> {
|
|
const jobNames = [
|
|
'campaign-dispatcher',
|
|
'rescuer',
|
|
'campaign-rollover',
|
|
'stats-rollup',
|
|
];
|
|
|
|
const results = [];
|
|
for (const jobName of jobNames) {
|
|
const last = await prisma.jobRunLog.findFirst({
|
|
where: { jobName },
|
|
orderBy: { ranAt: 'desc' },
|
|
});
|
|
results.push({
|
|
jobName,
|
|
lastRunAt: last?.ranAt.toISOString() ?? null,
|
|
success: last?.success ?? null,
|
|
message: last?.message ?? null,
|
|
});
|
|
}
|
|
|
|
const pendingSends = await prisma.sendJob.count({
|
|
where: { status: SendJobStatus.pending },
|
|
});
|
|
|
|
return results.map((row) =>
|
|
row.jobName === 'campaign-dispatcher'
|
|
? { ...row, message: row.message ?? `${pendingSends} queued sends` }
|
|
: row,
|
|
);
|
|
}
|
|
|
|
export async function enqueueSendJob(
|
|
campaignId: string,
|
|
scheduledAt: Date = new Date(),
|
|
): Promise<string | null> {
|
|
const dayKey = scheduledAt.toISOString().slice(0, 10);
|
|
const idempotencyKey = `${campaignId}:${dayKey}:${scheduledAt.getTime()}`;
|
|
|
|
const existing = await prisma.sendJob.findUnique({
|
|
where: { idempotencyKey },
|
|
});
|
|
if (existing) {
|
|
return existing.id;
|
|
}
|
|
|
|
const job = await prisma.sendJob.create({
|
|
data: {
|
|
campaignId,
|
|
scheduledAt,
|
|
idempotencyKey,
|
|
status: SendJobStatus.pending,
|
|
},
|
|
});
|
|
|
|
logger.debug({ campaignId, jobId: job.id }, 'Send job enqueued');
|
|
return job.id;
|
|
}
|
|
|
|
const STALE_SEND_JOB_MS = 60 * 60 * 1000;
|
|
|
|
export async function reclaimStaleSendJobs(staleMs = STALE_SEND_JOB_MS): Promise<number> {
|
|
const cutoff = new Date(Date.now() - staleMs);
|
|
const result = await prisma.sendJob.updateMany({
|
|
where: {
|
|
status: SendJobStatus.processing,
|
|
updatedAt: { lt: cutoff },
|
|
},
|
|
data: {
|
|
status: SendJobStatus.failed,
|
|
errorMessage: 'Stale processing job reclaimed',
|
|
processedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
if (result.count > 0) {
|
|
logger.warn({ count: result.count }, 'Reclaimed stale send jobs stuck in processing');
|
|
}
|
|
|
|
return result.count;
|
|
}
|
|
|
|
export async function claimDueSendJob(campaignId: string) {
|
|
const now = new Date();
|
|
const job = await prisma.sendJob.findFirst({
|
|
where: {
|
|
campaignId,
|
|
status: SendJobStatus.pending,
|
|
scheduledAt: { lte: now },
|
|
},
|
|
orderBy: { scheduledAt: 'asc' },
|
|
});
|
|
|
|
if (!job) return null;
|
|
|
|
const updated = await prisma.sendJob.updateMany({
|
|
where: { id: job.id, status: SendJobStatus.pending },
|
|
data: { status: SendJobStatus.processing },
|
|
});
|
|
|
|
if (updated.count === 0) return null;
|
|
return job;
|
|
}
|
|
|
|
export async function completeSendJob(
|
|
jobId: string,
|
|
success: boolean,
|
|
errorMessage?: string,
|
|
): Promise<void> {
|
|
await prisma.sendJob.update({
|
|
where: { id: jobId },
|
|
data: {
|
|
status: success ? SendJobStatus.completed : SendJobStatus.failed,
|
|
processedAt: new Date(),
|
|
errorMessage,
|
|
},
|
|
});
|
|
}
|