feat(sync): automatischer Abgleich nach Zeitplan
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>
This commit is contained in:
@@ -63,7 +63,12 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: { syncError: message, lastSyncedAt: new Date() },
|
||||
data: {
|
||||
syncError: message,
|
||||
lastSyncedAt: new Date(),
|
||||
// Zaehler fuers Zurueckhalten beim automatischen Abgleich.
|
||||
syncFailureCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
return { ok: false, message };
|
||||
}
|
||||
@@ -127,6 +132,7 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
|
||||
data: {
|
||||
lastSyncedAt: new Date(),
|
||||
syncError: null,
|
||||
syncFailureCount: 0,
|
||||
defaultBranch: data.defaultBranch,
|
||||
repoUrl: data.htmlUrl ?? undefined,
|
||||
},
|
||||
@@ -0,0 +1,171 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user