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:
@@ -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
@@ -137,7 +137,25 @@ model AppSettings {
|
||||
allowRegistration Boolean @default(false)
|
||||
/// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys.
|
||||
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.
|
||||
@@ -207,6 +225,12 @@ model Project {
|
||||
|
||||
lastSyncedAt DateTime?
|
||||
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
|
||||
/// Einrichten des ersten Kontos uebernommen werden koennen.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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