import type { Prisma } from "@prisma/client"; import { getAppSettings } from "@/lib/auth/settings"; import { prisma } from "@/lib/db"; import { syncProject } from "@/lib/sync"; /** * Automatischer Abgleich mit den Git-Systemen. * * Laeuft im Serverprozess selbst, angestossen von instrumentation.ts. Fuer * eine Einzelinstanz ist das die einfachste tragfaehige Loesung: kein * zusaetzlicher Container, kein externer Cron, keine zweite Konfiguration. * Wer lieber von aussen taktet, ruft stattdessen POST /api/admin/sync/run * auf und laesst den Automatismus aus. * * Ruecksicht auf fremde Kontingente: * - je Durchlauf nur eine begrenzte Zahl Projekte * - die am laengsten nicht abgeglichenen zuerst * - nach Fehlschlaegen wird zurueckgehalten, mit wachsendem Abstand * - kurze Pause zwischen den Projekten */ const TICK_MS = 60_000; /** Kleine Verschnaufpause zwischen zwei Projekten. */ const PAUSE_MS = 400; /** Hoechstens so lange wird nach Fehlschlaegen zurueckgehalten. */ const MAX_BACKOFF_STEPS = 4; export interface AutoSyncReport { at: string; trigger: "zeitplan" | "manuell"; candidates: number; synced: number; failed: number; /** Wegen Zurueckhaltung nach Fehlschlaegen uebersprungen. */ skipped: number; durationMs: number; } // Ueber globalThis, damit der Hot-Reload im Entwicklungsbetrieb nicht bei // jedem Neuladen einen weiteren Zeitgeber startet. const globalForScheduler = globalThis as unknown as { nebulaAutoSyncTimer?: NodeJS.Timeout; nebulaAutoSyncRunning?: boolean; }; export function startAutoSyncScheduler() { if (globalForScheduler.nebulaAutoSyncTimer) return; const timer = setInterval(() => { void tick(); }, TICK_MS); // Der Zeitgeber darf den Prozess nicht am Beenden hindern. timer.unref?.(); globalForScheduler.nebulaAutoSyncTimer = timer; // Kurz nach dem Start einmal nachsehen, statt eine volle Minute zu warten. const initial = setTimeout(() => void tick(), 20_000); initial.unref?.(); console.log("[nebula] Zeitgeber fuer den automatischen Abgleich laeuft."); } /** Prueft, ob ein Durchlauf faellig ist, und startet ihn gegebenenfalls. */ async function tick() { try { const settings = await getAppSettings(); if (!settings.autoSyncEnabled) return; const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000; const last = settings.lastAutoSyncAt?.getTime() ?? 0; if (Date.now() - last < intervalMs) return; await runAutoSyncPass("zeitplan"); } catch (error) { console.error("[nebula] Automatischer Abgleich fehlgeschlagen:", error); } } /** * Ein Durchlauf. Laeuft nie zweimal gleichzeitig – ein zweiter Aufruf * waehrend eines laufenden Durchlaufs bekommt null zurueck. */ export async function runAutoSyncPass( trigger: AutoSyncReport["trigger"], ): Promise { if (globalForScheduler.nebulaAutoSyncRunning) return null; globalForScheduler.nebulaAutoSyncRunning = true; const startedAt = Date.now(); let synced = 0; let failed = 0; let skipped = 0; try { const settings = await getAppSettings(); const batchSize = Math.max(1, Math.min(200, settings.autoSyncBatchSize)); const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000; const where: Prisma.ProjectWhereInput = { autoSync: true, // Ohne Repository gibt es nichts abzugleichen. OR: [ { AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] }, { repoUrl: { not: null } }, ], ...(settings.autoSyncActiveOnly ? { status: { notIn: ["DONE", "ARCHIVED"] } } : {}), }; // Grosszuegig holen: ein Teil faellt gleich der Zurueckhaltung zum Opfer. const candidates = await prisma.project.findMany({ where, orderBy: [{ lastSyncedAt: { sort: "asc", nulls: "first" } }], take: batchSize * 4, select: { id: true, name: true, lastSyncedAt: true, syncFailureCount: true }, }); const due = candidates.filter((project) => { if (project.syncFailureCount === 0) return true; if (!project.lastSyncedAt) return true; // Nach jedem Fehlschlag doppelt so lange warten, gedeckelt. const steps = Math.min(project.syncFailureCount, MAX_BACKOFF_STEPS); const wait = intervalMs * 2 ** steps; const ready = Date.now() - project.lastSyncedAt.getTime() >= wait; if (!ready) skipped += 1; return ready; }); const batch = due.slice(0, batchSize); for (const project of batch) { const result = await syncProject(project.id); if (result.ok) synced += 1; else failed += 1; if (PAUSE_MS > 0) await new Promise((resolve) => setTimeout(resolve, PAUSE_MS)); } const report: AutoSyncReport = { at: new Date().toISOString(), trigger, candidates: candidates.length, synced, failed, skipped, durationMs: Date.now() - startedAt, }; await prisma.appSettings.update({ where: { id: "singleton" }, data: { lastAutoSyncAt: new Date(), lastAutoSyncReport: report as unknown as Prisma.InputJsonValue, }, }); console.log( `[nebula] Abgleich (${trigger}): ${synced} aktualisiert, ${failed} fehlgeschlagen, ` + `${skipped} zurueckgestellt, ${report.durationMs} ms`, ); return report; } finally { globalForScheduler.nebulaAutoSyncRunning = false; } } export function isAutoSyncRunning(): boolean { return Boolean(globalForScheduler.nebulaAutoSyncRunning); }