0afcf237e8
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
165 lines
6.7 KiB
TypeScript
165 lines
6.7 KiB
TypeScript
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
|
import { SchedulerRegistry } from '@nestjs/schedule';
|
|
import { RETRY_DELAY_MS } from './nextcloud-alert-rules';
|
|
import { NextcloudReleaseService } from './nextcloud-release.service';
|
|
import {
|
|
CHECK_CONCURRENCY,
|
|
NextcloudStatusService,
|
|
runWithConcurrency,
|
|
} from './nextcloud-status.service';
|
|
|
|
/**
|
|
* CronJob constructor — resolved at runtime via require() because `cron` is
|
|
* a transitive dependency of @nestjs/schedule (not a direct api dep under
|
|
* pnpm strict isolation, so `import { CronJob } from 'cron'` fails
|
|
* type-check). At runtime, cron IS on disk as @nestjs/schedule@6 declares
|
|
* it as a peer dep. Reuses the exact ProxmoxSchedulerService resolution
|
|
* workaround verbatim.
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): void } =
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
require('cron').CronJob as new (
|
|
cronTime: string,
|
|
onTick: () => void,
|
|
) => { start(): void };
|
|
|
|
/** Name des Auftrags in der Registry. */
|
|
export const NEXTCLOUD_JOB_NAME = 'nextcloud-status-poll';
|
|
/** Jede volle Stunde (L-08). */
|
|
export const NEXTCLOUD_CRON = '0 * * * *';
|
|
/** Name des Wiederholungsauftrags (quick-261002-kxc, L-03). */
|
|
export const NEXTCLOUD_RETRY_JOB_NAME = 'nextcloud-status-retry';
|
|
/** Jede Minute: prueft nur Clouds, deren erster Fehlschlag fuenf Minuten zurueckliegt. */
|
|
export const NEXTCLOUD_RETRY_CRON = '* * * * *';
|
|
|
|
/**
|
|
* NextcloudStatusSchedulerService — stuendliche Pruefung aller Clouds
|
|
* (quick-261002-k67, L-08, D-C).
|
|
*
|
|
* Lebenszyklus: `OnApplicationBootstrap`, NICHT `OnModuleInit` — die
|
|
* Reihenfolge der `onModuleInit`-Haken zwischen Modulen ist nicht
|
|
* festgelegt, und die Erfahrung "frische Datenbank ingestiert nichts bis zum
|
|
* zweiten Neustart" (Tender-Cron-Bootstrap) gilt hier genauso.
|
|
*
|
|
* Ein einziger globaler Auftrag statt je einem je Mandant: das Intervall ist
|
|
* fest (stuendlich), es gibt keine Einstellung je Zeile. Der Auftrag wird
|
|
* beim Start OHNE Datenbankzugriff registriert — eine frische Datenbank kann
|
|
* daher nie ohne Auftrag enden, auch wenn beim Start noch keine Cloud
|
|
* eingetragen ist. Jeder Durchlauf liest Kennung und Mandant aller Clouds
|
|
* (ein einziger Systemkontext-Aufruf) und prueft dann jede Cloud an ihren
|
|
* eigenen Mandanten gebunden, mit hoechstens vier gleichzeitig. Ein
|
|
* Ueberlappungsschutz ueberspringt einen Durchlauf, solange der vorige laeuft.
|
|
*/
|
|
@Injectable()
|
|
export class NextcloudStatusSchedulerService implements OnApplicationBootstrap {
|
|
private readonly logger = new Logger(NextcloudStatusSchedulerService.name);
|
|
private running = false;
|
|
private retryRunning = false;
|
|
|
|
constructor(
|
|
private readonly schedulerRegistry: SchedulerRegistry,
|
|
private readonly service: NextcloudStatusService,
|
|
private readonly release: NextcloudReleaseService,
|
|
) {}
|
|
|
|
/**
|
|
* Registriert und startet den Auftrag und stoesst das Aufwaermen der
|
|
* Vergleichsdaten an (ohne zu warten). Ein Fehler wird gefangen und
|
|
* protokolliert, nie weitergeworfen — die Anwendung startet trotzdem.
|
|
*/
|
|
async onApplicationBootstrap(): Promise<void> {
|
|
try {
|
|
const job = new CronJobClass(NEXTCLOUD_CRON, () => {
|
|
this.tick().catch((err) =>
|
|
this.logger.error(`Nextcloud poll tick failed: ${(err as Error).message}`),
|
|
);
|
|
});
|
|
// Cast noetig — dasselbe Muster wie ProxmoxSchedulerService.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
// biome-ignore lint/suspicious/noExplicitAny: Cast wie in ProxmoxSchedulerService
|
|
this.schedulerRegistry.addCronJob(NEXTCLOUD_JOB_NAME, job as any);
|
|
job.start();
|
|
this.logger.log(`Nextcloud-Status cron job registered: ${NEXTCLOUD_CRON}`);
|
|
const retryJob = new CronJobClass(NEXTCLOUD_RETRY_CRON, () => {
|
|
this.retryTick().catch((err) =>
|
|
this.logger.error(`Nextcloud retry tick failed: ${(err as Error).message}`),
|
|
);
|
|
});
|
|
// biome-ignore lint/suspicious/noExplicitAny: Cast wie in ProxmoxSchedulerService
|
|
this.schedulerRegistry.addCronJob(NEXTCLOUD_RETRY_JOB_NAME, retryJob as any);
|
|
retryJob.start();
|
|
this.logger.log(`Nextcloud-Status retry job registered: ${NEXTCLOUD_RETRY_CRON}`);
|
|
void this.release.refresh().catch(() => undefined);
|
|
} catch (err) {
|
|
this.logger.error(`Nextcloud-Status scheduler init failed: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
/** Ein Durchlauf ueber alle Clouds aller Mandanten. */
|
|
async tick(): Promise<void> {
|
|
if (this.running) {
|
|
this.logger.warn('Nextcloud poll tick skipped — previous run still active');
|
|
return;
|
|
}
|
|
this.running = true;
|
|
try {
|
|
await this.checkRows(await this.service.loadAllInstancesForScheduler());
|
|
} finally {
|
|
this.running = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wiederholung: nur Clouds mit genau einem Fehlschlag, der mindestens
|
|
* `RETRY_DELAY_MS` zurueckliegt. Wirft nie; ein laufender Durchlauf haelt
|
|
* den naechsten an.
|
|
*/
|
|
async retryTick(now: Date = new Date()): Promise<void> {
|
|
if (this.retryRunning) {
|
|
this.logger.warn('Nextcloud retry tick skipped — previous run still active');
|
|
return;
|
|
}
|
|
this.retryRunning = true;
|
|
try {
|
|
const rows = await this.service.loadAllInstancesForScheduler({
|
|
retryDueBefore: new Date(now.getTime() - RETRY_DELAY_MS),
|
|
});
|
|
await this.checkRows(rows);
|
|
} catch (err) {
|
|
this.logger.error(`Nextcloud retry tick failed: ${(err as Error).message}`);
|
|
} finally {
|
|
this.retryRunning = false;
|
|
}
|
|
}
|
|
|
|
/** Prueft jede Cloud an ihren eigenen Mandanten gebunden, hoechstens vier gleichzeitig. */
|
|
private async checkRows(rows: { id: string; tenantId: string }[]): Promise<void> {
|
|
// Je Mandant gruppiert, damit jede Pruefung an IHREN Mandanten gebunden bleibt.
|
|
const byTenant = new Map<string, string[]>();
|
|
for (const row of rows) {
|
|
const ids = byTenant.get(row.tenantId) ?? [];
|
|
ids.push(row.id);
|
|
byTenant.set(row.tenantId, ids);
|
|
}
|
|
const work: { tenantId: string; id: string }[] = [];
|
|
for (const [tenantId, ids] of byTenant) {
|
|
for (const id of ids) work.push({ tenantId, id });
|
|
}
|
|
if (work.length === 0) return;
|
|
const startedAt = Date.now();
|
|
await runWithConcurrency(work, CHECK_CONCURRENCY, async ({ tenantId, id }) => {
|
|
try {
|
|
await this.service.checkInstance(tenantId, id);
|
|
} catch (err) {
|
|
this.logger.error(
|
|
`Nextcloud check failed for instance ${id} (tenant ${tenantId}): ${(err as Error).message}`,
|
|
);
|
|
}
|
|
});
|
|
this.logger.log(
|
|
`Nextcloud-Pruefdurchlauf: ${work.length} Cloud(s) in ${Date.now() - startedAt} ms`,
|
|
);
|
|
}
|
|
}
|