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:
2026-07-25 23:37:54 +02:00
co-authored by Claude Opus 5
parent b865cf49bd
commit ed2637877e
5 changed files with 236 additions and 2 deletions
@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "AppSettings" ADD COLUMN "autoSyncActiveOnly" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "autoSyncBatchSize" INTEGER NOT NULL DEFAULT 25,
ADD COLUMN "autoSyncEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "autoSyncIntervalMinutes" INTEGER NOT NULL DEFAULT 60,
ADD COLUMN "lastAutoSyncAt" TIMESTAMP(3),
ADD COLUMN "lastAutoSyncReport" JSONB;
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "autoSync" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "syncFailureCount" INTEGER NOT NULL DEFAULT 0;
+25 -1
View File
@@ -137,7 +137,25 @@ model AppSettings {
allowRegistration Boolean @default(false) allowRegistration Boolean @default(false)
/// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys. /// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys.
rpName String @default("Nebula") rpName String @default("Nebula")
updatedAt DateTime @updatedAt
// ── Automatischer Abgleich mit den Git-Systemen ────────────
/// Standardmaessig aus: der Abgleich verbraucht fremde API-Kontingente,
/// das soll eine bewusste Entscheidung sein.
autoSyncEnabled Boolean @default(false)
/// Abstand zwischen zwei Durchlaeufen in Minuten.
autoSyncIntervalMinutes Int @default(60)
/// Fertige und archivierte Projekte auslassen – dort aendert sich selten
/// noch etwas.
autoSyncActiveOnly Boolean @default(true)
/// Hoechstzahl Projekte je Durchlauf, damit ein grosses Board nicht auf
/// einen Schlag in ein Rate-Limit laeuft.
autoSyncBatchSize Int @default(25)
lastAutoSyncAt DateTime?
/// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms }
lastAutoSyncReport Json?
updatedAt DateTime @updatedAt
} }
/// Eine Verbindung zu einem Git-System inkl. verschluesseltem Token. /// Eine Verbindung zu einem Git-System inkl. verschluesseltem Token.
@@ -207,6 +225,12 @@ model Project {
lastSyncedAt DateTime? lastSyncedAt DateTime?
syncError String? syncError String?
/// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei
/// einem Repo, dessen Token staendig Fehler wirft.
autoSync Boolean @default(true)
/// Aufeinanderfolgende Fehlversuche. Steuert das Zurueckhalten: nach
/// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert.
syncFailureCount Int @default(0)
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim /// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
/// Einrichten des ersten Kontos uebernommen werden koennen. /// Einrichten des ersten Kontos uebernommen werden koennen.
+22
View File
@@ -0,0 +1,22 @@
/**
* Wird von Next einmal beim Start des Servers ausgefuehrt.
*
* Hier haengt der Zeitgeber fuer den automatischen Abgleich.
*
* Wichtig ist die Form der Abfrage: Next uebersetzt diese Datei auch fuer den
* Edge-Runtime (weil es eine Middleware gibt). Dort gibt es weder node:crypto
* noch node:net, die der Abgleich mitbringt. Der Import muss deshalb *in* der
* positiven Bedingung stehen – dann ersetzt der Bundler NEXT_RUNTIME beim
* Bauen und entfernt den ganzen Block aus dem Edge-Bundle. Mit einem frueh
* abbrechenden "if (… !== 'nodejs') return" bliebe der Import stehen und der
* Build schluege fehl.
*/
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { describeConfig } = await import("@/lib/config");
console.log(`[nebula] Konfiguration: ${describeConfig()}`);
const { startAutoSyncScheduler } = await import("@/lib/sync/scheduler");
startAutoSyncScheduler();
}
}
+7 -1
View File
@@ -63,7 +63,12 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
await prisma.project.update({ await prisma.project.update({
where: { id: project.id }, 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 }; return { ok: false, message };
} }
@@ -127,6 +132,7 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
data: { data: {
lastSyncedAt: new Date(), lastSyncedAt: new Date(),
syncError: null, syncError: null,
syncFailureCount: 0,
defaultBranch: data.defaultBranch, defaultBranch: data.defaultBranch,
repoUrl: data.htmlUrl ?? undefined, repoUrl: data.htmlUrl ?? undefined,
}, },
+171
View File
@@ -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);
}