chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
This commit is contained in:
159
scripts/campaign-retry-daemon.mjs
Normal file
159
scripts/campaign-retry-daemon.mjs
Normal file
@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const intervalSecondsRaw = process.env.CAMPAIGN_RETRY_DAEMON_INTERVAL_SECONDS?.trim();
|
||||
const timeoutMsRaw = process.env.CAMPAIGN_RETRY_DAEMON_TIMEOUT_MS?.trim();
|
||||
const runOnce = args.has("--once") || args.has("-1");
|
||||
const shouldJitter = !args.has("--no-jitter");
|
||||
|
||||
const intervalMs = normalizePositiveNumber(intervalSecondsRaw, 300) * 1000;
|
||||
const requestTimeoutMs = normalizePositiveNumber(timeoutMsRaw, 30000);
|
||||
|
||||
if (isNaN(intervalMs) || intervalMs <= 0) {
|
||||
console.error("Invalid CAMPAIGN_RETRY_DAEMON_INTERVAL_SECONDS");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const endpoint = resolveCampaignRetryEndpoint();
|
||||
const token = process.env.CAMPAIGN_RETRY_JOB_TOKEN?.trim();
|
||||
|
||||
const payload = {
|
||||
...(process.env.CAMPAIGN_RETRY_TENANT_ID?.trim() ? { tenantId: process.env.CAMPAIGN_RETRY_TENANT_ID.trim() } : {}),
|
||||
...(process.env.CAMPAIGN_RETRY_CAMPAIGN_ID?.trim()
|
||||
? { campaignId: process.env.CAMPAIGN_RETRY_CAMPAIGN_ID.trim() }
|
||||
: {}),
|
||||
...(isPositiveInt(process.env.CAMPAIGN_RETRY_BATCH_SIZE)
|
||||
? { recipientBatchSize: Number(process.env.CAMPAIGN_RETRY_BATCH_SIZE) }
|
||||
: {}),
|
||||
...(isPositiveInt(process.env.CAMPAIGN_RETRY_MAX_CAMPAIGNS)
|
||||
? { maxCampaigns: Number(process.env.CAMPAIGN_RETRY_MAX_CAMPAIGNS) }
|
||||
: {})
|
||||
};
|
||||
|
||||
let isShuttingDown = false;
|
||||
let lastSummary = null;
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
isShuttingDown = true;
|
||||
console.info("\n[daemon] shutdown requested");
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
isShuttingDown = true;
|
||||
console.info("\n[daemon] shutdown requested");
|
||||
});
|
||||
|
||||
(async function main() {
|
||||
if (runOnce) {
|
||||
await runLoopOnce();
|
||||
} else {
|
||||
await runDaemon();
|
||||
}
|
||||
})();
|
||||
|
||||
async function runDaemon() {
|
||||
console.info(`[daemon] starting campaign retry worker, interval=${intervalMs / 1000}s`);
|
||||
while (!isShuttingDown) {
|
||||
await runLoopOnce();
|
||||
if (isShuttingDown) {
|
||||
break;
|
||||
}
|
||||
|
||||
const jitter = shouldJitter ? Math.random() * Math.min(1000, intervalMs * 0.1) : 0;
|
||||
const waitMs = Math.max(1000, intervalMs + jitter);
|
||||
await sleep(waitMs);
|
||||
}
|
||||
|
||||
if (lastSummary) {
|
||||
console.info(`[daemon] last run summary: ${JSON.stringify(lastSummary)}`);
|
||||
}
|
||||
console.info("[daemon] stopped");
|
||||
}
|
||||
|
||||
async function runLoopOnce() {
|
||||
const startedAt = new Date().toISOString();
|
||||
const headers = {
|
||||
"content-type": "application/json"
|
||||
};
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[${startedAt}] campaign retry failed: ${response.status} ${response.statusText}`);
|
||||
console.error(text);
|
||||
lastSummary = { ts: startedAt, status: "failed", code: response.status, message: response.statusText };
|
||||
if (runOnce) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lastSummary = safeParseJson(text) ?? { ts: startedAt, raw: text, status: "ok" };
|
||||
console.log(`[${startedAt}] campaign retry done: ${typeof text === "string" ? text.slice(0, 250) : text}`);
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
lastSummary = { ts: startedAt, status: "error", message };
|
||||
console.error(`[${startedAt}] campaign retry request failed: ${message}`);
|
||||
if (runOnce) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (runOnce) {
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCampaignRetryEndpoint() {
|
||||
const baseUrl = process.env.CAMPAIGN_RETRY_JOB_URL?.trim() || process.env.APP_URL?.trim() || process.env.NEXT_PUBLIC_APP_URL?.trim();
|
||||
|
||||
if (!baseUrl) {
|
||||
console.error("Missing CAMPAIGN_RETRY_JOB_URL / APP_URL / NEXT_PUBLIC_APP_URL");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return baseUrl.endsWith("/api/jobs/campaign-retry")
|
||||
? baseUrl
|
||||
: `${baseUrl.replace(/\/+$/, "")}/api/jobs/campaign-retry`;
|
||||
}
|
||||
|
||||
function normalizePositiveNumber(value, fallback) {
|
||||
const raw = Number(value);
|
||||
if (!Number.isFinite(raw) || raw <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function isPositiveInt(raw) {
|
||||
const value = Number(raw);
|
||||
return Number.isInteger(value) && value > 0;
|
||||
}
|
||||
|
||||
function safeParseJson(raw) {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
Reference in New Issue
Block a user