feat(260907-efh): automatische Fingerabdruck-Nachrechnung beim Start

Nach der Formelaenderung (v1 -> v2) traegt jede Bestandszeile einen Hash
der alten Formel und wuerde nie wieder auf einen neuen treffen. Die
Produktionsdatenbank wird von Hand nicht angefasst, deshalb rechnet ein
neuer Dienst beim Start alle veralteten Zeilen automatisch nach — an der
Versionsmarke aus tender-fingerprint.ts erkannt, gleiche Bauform wie die
LDAP-Bind-Passwort-Nachverschluesselung.

- TenderFingerprintBackfillService: OnApplicationBootstrap, seitenweise
  (500 Zeilen), pro Seite eine Transaktion, Fehler werden geloggt und
  geschluckt statt geworfen
- In tenders.module.ts VOR TenderSchedulerService eingetragen, damit die
  Nachrechnung vor der Cron-Registrierung laeuft
- Vier Tests: leere DB, alter+leerer Hash werden beide erfasst, zweiter
  Lauf schreibt nichts mehr, Datenbankfehler wirft den Haken nicht

Gemessen an der lokalen Datenbank (16.255 Zeilen): Fingerabdruck-Gruppen
mit mehr als einer Zeile vorher=0, nachher=26, veraltete Zeilen
danach=0, davon Gruppen mit widersprechenden Wert-Groessenordnungen=2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq
This commit is contained in:
2026-09-07 10:41:58 +02:00
parent 0645cac618
commit ee3b28e505
3 changed files with 245 additions and 0 deletions
@@ -0,0 +1,135 @@
import { describe, expect, it, vi } from 'vitest';
import { TenderFingerprintBackfillService } from './tender-fingerprint-backfill.service';
import { tenderFingerprint } from './tender-fingerprint';
/**
* TenderFingerprintBackfillService.spec — WINDOWS.md #11 Task 3. Hand-
* rolled fake PrismaService (Map), matching the project convention
* (tender-dedup.service.spec.ts, ldap-config.service.spec.ts). No real DB
* I/O — the real-DB recompute is proven separately by the plan's verify
* step against the local database.
*/
function isOutdated(fingerprint: string | null): boolean {
return fingerprint === null || !fingerprint.startsWith('v2:');
}
function makeFakePrisma(seedRows: any[]) {
const tenders = new Map<string, any>(seedRows.map((r) => [r.id, { ...r }]));
const findManyOutdated = vi.fn(async ({ select, take }: any) => {
const rows = [...tenders.values()].filter((t) => isOutdated(t.fingerprint));
const page = rows.slice(0, take);
return page.map((row) => {
const out: any = {};
for (const k of Object.keys(select)) out[k] = row[k];
return out;
});
});
const update = vi.fn(async ({ where, data }: any) => {
const existing = tenders.get(where.id);
const updated = { ...existing, ...data };
tenders.set(where.id, updated);
return updated;
});
const transaction = vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops));
const prisma = {
tender: { findMany: findManyOutdated, update },
$transaction: transaction,
__store: { tenders },
};
return prisma;
}
describe('TenderFingerprintBackfillService', () => {
it('eine Datenbank ohne Zeilen loest keine Schreiboperation aus und der Haken wirft nicht', async () => {
const prisma = makeFakePrisma([]);
const service = new TenderFingerprintBackfillService(prisma as any);
await expect(service.onApplicationBootstrap()).resolves.not.toThrow();
expect(prisma.tender.update).not.toHaveBeenCalled();
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('Zeilen mit altem Hash und Zeilen mit leerem Hash werden beide erfasst und tragen danach die neue Marke', async () => {
const prisma = makeFakePrisma([
{
id: 't-old',
buyerName: 'Stadt Altstadt',
title: 'Sanierung Rathaus',
deadlineAt: null,
fingerprint: 'oldhashvalue',
},
{
id: 't-null',
buyerName: 'Stadt Neustadt',
title: 'Neubau Schule',
deadlineAt: null,
fingerprint: null,
},
{
id: 't-current',
buyerName: 'Stadt Gegenwart',
title: 'Bereits aktuell',
deadlineAt: null,
fingerprint: 'v2:already-current',
},
]);
const service = new TenderFingerprintBackfillService(prisma as any);
const updated = await service.backfillOutdated();
expect(updated).toBe(2);
const rowOld = prisma.__store.tenders.get('t-old');
const rowNull = prisma.__store.tenders.get('t-null');
const rowCurrent = prisma.__store.tenders.get('t-current');
expect(rowOld.fingerprint).toBe(
tenderFingerprint({ buyerName: 'Stadt Altstadt', title: 'Sanierung Rathaus', deadlineAt: null }),
);
expect(rowNull.fingerprint).toBe(
tenderFingerprint({ buyerName: 'Stadt Neustadt', title: 'Neubau Schule', deadlineAt: null }),
);
// Unangetastet — trug bereits die neue Marke.
expect(rowCurrent.fingerprint).toBe('v2:already-current');
});
it('ein zweiter Lauf direkt danach schreibt nichts mehr', async () => {
const prisma = makeFakePrisma([
{
id: 't-old',
buyerName: 'Stadt Altstadt',
title: 'Sanierung Rathaus',
deadlineAt: null,
fingerprint: 'oldhashvalue',
},
]);
const service = new TenderFingerprintBackfillService(prisma as any);
const first = await service.backfillOutdated();
expect(first).toBe(1);
prisma.tender.update.mockClear();
const second = await service.backfillOutdated();
expect(second).toBe(0);
expect(prisma.tender.update).not.toHaveBeenCalled();
});
it('wirft die Datenbankabfrage, wirft der Haken trotzdem nicht', async () => {
const prisma = {
tender: {
findMany: vi.fn().mockRejectedValue(new Error('DB weg')),
update: vi.fn(),
},
$transaction: vi.fn(),
};
const service = new TenderFingerprintBackfillService(prisma as any);
await expect(service.onApplicationBootstrap()).resolves.not.toThrow();
expect(prisma.tender.update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,103 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { FINGERPRINT_VERSION_PREFIX, tenderFingerprint } from './tender-fingerprint';
const PAGE_SIZE = 500;
/**
* Grosszuegige Obergrenze an Durchlaeufen als Reissleine, nicht als
* funktionale Schranke: bei PAGE_SIZE=500 deckt das weit mehr als das
* gemessene Bestandsvolumen (16.255 Zeilen / 500 ~= 33 Seiten) ab. Greift
* nur, wenn ein unerwarteter Zustand (z.B. eine Zeile, die nie aus der
* Veraltet-Bedingung herausfaellt) sonst in eine Endlosschleife fuehren
* wuerde.
*/
const MAX_PAGES = 10_000;
/**
* TenderFingerprintBackfillService — automatische Nachrechnung veralteter
* Fingerabdruecke beim Start (WINDOWS.md #11, Task 3 der v2-Formeländerung
* in tender-fingerprint.ts).
*
* Warum ueberhaupt automatisch: nach der Formelaenderung traegt jede
* Bestandszeile einen Hash der alten Formel und wuerde nie wieder auf einen
* neuen treffen. Die Produktionsdatenbank wird von Hand nicht angefasst,
* und ein Bootstrap-Schritt, den eine frische Installation still
* ueberspringt, ist in diesem Projekt bereits einmal als Fehlerbild
* aufgetreten (Poll-Cron, Projektnotiz "Tender-Cron Bootstrap"). Die
* Nachrechnung muss also von allein laufen, auf Bestand wie auf frischer
* Installation dasselbe Ergebnis liefern — und darf nicht bei jedem Start
* alle Zeilen anfassen. Genau dafuer ist FINGERPRINT_VERSION_PREFIX da.
*
* Gleiche Bauform wie die Nachverschluesselung der LDAP-Bind-Passwoerter in
* ldap-config.service.ts (dort das vorbildhafte Beispiel): implementiert
* OnApplicationBootstrap, injiziert PrismaService, und ein misslungener
* Lauf wird geloggt und geschluckt statt geworfen — ein fehlgeschlagener
* Nachlauf darf den Start des Dienstes nicht verhindern.
*/
@Injectable()
export class TenderFingerprintBackfillService implements OnApplicationBootstrap {
private readonly logger = new Logger(TenderFingerprintBackfillService.name);
constructor(private readonly prisma: PrismaService) {}
async onApplicationBootstrap(): Promise<void> {
try {
const updated = await this.backfillOutdated();
if (updated > 0) {
this.logger.log(
`Fingerabdruck-Nachrechnung: ${updated} Zeile(n) auf die neue Formel gebracht`,
);
}
} catch (err) {
this.logger.error(
`Fingerabdruck-Nachrechnung fehlgeschlagen: ${(err as Error).message}`,
);
}
}
/**
* Rechnet alle Zeilen mit veraltetem oder fehlendem Fingerabdruck neu und
* gibt die Zahl der geaenderten Zeilen zurueck. Seitenweise (500 Zeilen),
* jede Seite gebuendelt in einer eigenen Transaktion geschrieben.
*
* Die Seite braucht keinen Versatz (kein `skip`): eine geschriebene Zeile
* faellt durch die neue Marke aus der Veraltet-Bedingung heraus, die
* naechste Abfrage liefert automatisch die naechsten Zeilen. Bricht ab,
* sobald eine Seite leer zurueckkommt.
*/
async backfillOutdated(): Promise<number> {
let totalUpdated = 0;
for (let page = 0; page < MAX_PAGES; page++) {
const rows = await this.prisma.tender.findMany({
where: {
// Beide Faelle gehoeren in eine ODER-Verknuepfung: die
// "beginnt nicht mit"-Bedingung wird fuer NULL in SQL nicht wahr,
// leere Zeilen wuerden sonst durchrutschen.
OR: [
{ fingerprint: null },
{ NOT: { fingerprint: { startsWith: FINGERPRINT_VERSION_PREFIX } } },
],
},
select: { id: true, buyerName: true, title: true, deadlineAt: true },
take: PAGE_SIZE,
});
if (rows.length === 0) break;
await this.prisma.$transaction(
rows.map((row) =>
this.prisma.tender.update({
where: { id: row.id },
data: { fingerprint: tenderFingerprint(row) },
}),
),
);
totalUpdated += rows.length;
}
return totalUpdated;
}
}
+7
View File
@@ -12,6 +12,7 @@ import { RssAdapter } from './adapters/rss.adapter';
import { SourceRegistry } from './source-registry'; import { SourceRegistry } from './source-registry';
import { seedServiceBundRssFeed, seedTendersModule } from './tenders.seed'; import { seedServiceBundRssFeed, seedTendersModule } from './tenders.seed';
import { TenderDedupService } from './tender-dedup.service'; import { TenderDedupService } from './tender-dedup.service';
import { TenderFingerprintBackfillService } from './tender-fingerprint-backfill.service';
import { TenderDigestScheduler } from './tender-digest.scheduler'; import { TenderDigestScheduler } from './tender-digest.scheduler';
import { TenderEmailConfigService } from './tender-email-config.service'; import { TenderEmailConfigService } from './tender-email-config.service';
import { TenderIngestionService } from './tender-ingestion.service'; import { TenderIngestionService } from './tender-ingestion.service';
@@ -135,6 +136,12 @@ import { TendersController } from './tenders.controller';
TenderNormalizerService, TenderNormalizerService,
TenderDedupService, TenderDedupService,
TenderIngestionService, TenderIngestionService,
// WINDOWS.md #11, Task 3: MUSS vor TenderSchedulerService stehen — Nest
// ruft die OnApplicationBootstrap-Haken eines Moduls in der Reihenfolge
// der Anbieterliste auf, und die Fingerabdruck-Nachrechnung soll fertig
// sein, bevor der Poll-Cron zu laufen beginnt. Beim Umsortieren dieser
// Liste bitte diese Reihenfolge erhalten.
TenderFingerprintBackfillService,
TenderSchedulerService, TenderSchedulerService,
TenderTriageService, TenderTriageService,
TenderSavedSearchService, TenderSavedSearchService,