Bisher musste jedes Projekt einzeln von Hand synchronisiert werden. Jetzt gibt es einen Zeitgeber im Serverprozess, angestossen von instrumentation.ts. Fuer eine Einzelinstanz ist das die einfachste tragfaehige Loesung: kein zusaetzlicher Container, kein externer Cron, keine zweite Konfiguration. Ruecksicht auf fremde API-Kontingente war der bestimmende Gedanke: - standardmaessig abgeschaltet - der Abgleich verbraucht fremdes Kontingent, das soll eine bewusste Entscheidung sein - je Durchlauf nur eine begrenzte Zahl Projekte, die am laengsten nicht abgeglichenen zuerst; der Rest kommt beim naechsten Mal - nach jedem Fehlschlag verdoppelt sich der Abstand bis zum naechsten Versuch, gedeckelt beim Sechzehnfachen. Ein Repo mit abgelaufenem Token blockiert damit nicht dauerhaft das Kontingent. - kurze Pause zwischen zwei Projekten - zwei Durchlaeufe ueberschneiden sich nie - fertige und archivierte Projekte lassen sich auslassen - einzelne Projekte lassen sich ausnehmen Der Zaehler fuer Fehlschlaege wandert in syncProject: bei Erfolg zurueck auf null, bei Fehlschlag hoch. So stimmt er auch nach einem manuellen Abgleich. sync.ts wird zu sync/index.ts, damit Modul und Ordner nicht nebeneinander stehen. Alle Importpfade bleiben unveraendert. Zur Form der Abfrage in instrumentation.ts: Next uebersetzt die Datei auch fuer den Edge-Runtime. Der Import muss deshalb *in* der positiven Bedingung stehen, sonst bleibt er im Edge-Bundle stehen und der Build scheitert an node:crypto. Mit einem frueh abbrechenden return passiert genau das. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
172 lines
5.4 KiB
TypeScript
172 lines
5.4 KiB
TypeScript
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<AutoSyncReport | null> {
|
||
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);
|
||
}
|