From 978722023f7449d9f6b58e135c27d58f4c715905 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:44:47 +0000 Subject: [PATCH] Terminplaner: Zellen-Sweep seitenweise ueber idx_bis mit Deckel raeumeVerwaisteZellen(now, { deckel = config.termine.zellenDeckel }) liest Zellen mit bis < jetzt - 1 Tag in Seiten zu 100 ueber idx_bis (aelteste zuerst, ohne Cursor neu gelesen), gruppiert je Ressourcensperre mit Map und loescht unter der Sperre nur, was dort noch dieselbe Buchung hat und noch alt ist. Die Buchungen werden nicht gelesen. Der Lauf endet am Deckel (5000, gedeckelt wird im Tagesjob gezaehlt und im Log gewarnt), bei einer leeren Seite oder einer Seite ohne Erfolg; der erste Fehler wird am Ende geworfen. ungueltigeZellen prueft zuerst das Zeitfenster (auch fuer belegeZellen). verwaisteZellen, alleZellen und ALLE_ZELLEN_MAX entfallen. Co-Authored-By: Claude Sonnet 5.5 --- server/config.js | 2 + server/services/termine/jobs.js | 13 +- server/services/termine/zellen.js | 152 ++++++++------ test/termineJobs.test.js | 73 ++++++- test/termineSchalter.test.js | 1 + test/termineZellen.test.js | 321 +++++++++++++++++++++--------- 6 files changed, 404 insertions(+), 158 deletions(-) diff --git a/server/config.js b/server/config.js index 99502ab..ba54d8e 100644 --- a/server/config.js +++ b/server/config.js @@ -391,6 +391,8 @@ export const config = { loeschDeckel: 2000, // Alle Schritte der Aufbewahrungs-Loeschung eines Laufs zusammen loeschen hoechstens so viele Buchungen (Gesamtdeckel ueber alle Funktionen) loeschDeckelGesamt: 20000, + // Hoechstens so viele alte Belegungszellen (bis mehr als einen Tag vorbei) loescht der Zellen-Sweep des Tagesjobs je Lauf; der Rest folgt am naechsten Tag + zellenDeckel: 5000, // Mindestzeit zwischen Laden und Absenden des Buchungsformulars (schneller = Bot) formMinSekunden: nonNegativeNumber(process.env.TERMINE_FORM_MIN_SECONDS, 3), // Kuerzeste Antwortzeit (ms) von POST /buchungen und den Widerrufs-POSTs der oeffentlichen Schnittstelle: verwischt, ob diff --git a/server/services/termine/jobs.js b/server/services/termine/jobs.js index 6b9b26f..1484160 100644 --- a/server/services/termine/jobs.js +++ b/server/services/termine/jobs.js @@ -47,6 +47,8 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js' * und nichterschienen zusammen je Funktion), alle Schritte eines Laufs zusammen hoechstens config.termine.loeschDeckelGesamt; * der Rest folgt am naechsten Tag und wird gezaehlt (`gedeckelt`), nie still uebersprungen. Ebenso die Loeschung abgeschalteter * Funktionen: je Funktion Seiten zu 100, hoechstens config.termine.loeschDeckel je Lauf, die Einstellung erst nach einer leeren Seite. + * Ebenso der Zellen-Sweep (raeumeVerwaisteZellen): Zellen mit Ende vor mehr als einem Tag, Seiten zu 100 ueber idx_bis, hoechstens + * config.termine.zellenDeckel je Lauf; am Deckel zaehlt `gedeckelt.zellen`. */ const MIN_MS = 60 * 1000 @@ -601,10 +603,13 @@ export async function taeglicherJob(now) { gedeckelt: 0, }) const aufgeraeumt = await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0) - const zellen = await schritt(lauf, 'Verwaiste Zellen', () => raeumeVerwaisteZellen(now), 0) - // gedeckelt: Schritte bzw. Funktionen, die am Deckel endeten (zellen fuellt ein spaeterer Task); getrennt vom Loeschzaehler, damit die Summe im Tagesbericht stimmt - const gedeckelt = { loeschfristen: loeschfristenGedeckelt, abgeschaltet: abgeschaltetGedeckelt, zellen: 0 } - return { festgefahren, loeschfristen: loeschung, gedeckelt, abgeschaltet, aufgeraeumt, zellen, fehler: lauf.fehler } + const zellenErgebnis = await schritt(lauf, 'Verwaiste Zellen', () => raeumeVerwaisteZellen(now), { geloescht: 0, gedeckelt: false }) + if (zellenErgebnis.gedeckelt) { + console.warn(`[termine-jobs] ${lauf.job}: Zellen-Sweep endet am Deckel von ${config.termine.zellenDeckel} je Lauf, der Rest folgt an den nächsten Tagen`) + } + // gedeckelt: Schritte bzw. Funktionen, die am Deckel endeten (zellen: 1, wenn der Zellen-Sweep daran endete); getrennt vom Loeschzaehler, damit die Summe im Tagesbericht stimmt + const gedeckelt = { loeschfristen: loeschfristenGedeckelt, abgeschaltet: abgeschaltetGedeckelt, zellen: zellenErgebnis.gedeckelt ? 1 : 0 } + return { festgefahren, loeschfristen: loeschung, gedeckelt, abgeschaltet, aufgeraeumt, zellen: zellenErgebnis.geloescht, fehler: lauf.fehler } }) } diff --git a/server/services/termine/zellen.js b/server/services/termine/zellen.js index 0d3f592..8ca1e12 100644 --- a/server/services/termine/zellen.js +++ b/server/services/termine/zellen.js @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { config } from '../../config.js' -import { Query, createDocuments, deleteDocument, listAllDocuments } from '../appwriteAdmin.js' +import { Query, createDocuments, deleteDocument, listAllDocuments, listDocuments } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' import { istBelegend } from './slots.js' import { pruefeNow, pruefeText, zeitMs } from './util.js' @@ -13,16 +13,19 @@ import { pruefeNow, pruefeText, zeitMs } from './util.js' * in EINEM Bulk-Create (hoechstens 100 Zellen = 500 Minuten): Appwrite schreibt alles oder nichts, eine vergebene ID * ergibt 409 document_already_exists. Zusaetzlich serialisiert withLocks je Ressource innerhalb des Prozesses. * - * Ob eine vorhandene Zelle noch gilt, entscheidet allein ihre Buchung: fehlt sie, ist sie nicht belegend (siehe - * istBelegend in slots.js) oder ist die Haltung abgelaufen, ist die Zelle frei. Solche Zellen loescht belegeZellen erst, - * wenn sie eine Belegung blockieren (danach genau ein zweiter Versuch), der taegliche Job raeumt den Rest - * (raeumeVerwaisteZellen; verwaisteZellen nennt sie nur). + * Ob eine vorhandene Zelle noch gilt, entscheidet zuerst das Zeitfenster, danach ihre Buchung: eine Zelle, deren Ende mehr + * als einen Tag zurueckliegt, ist ohne Buchungsabfrage ungueltig; bei allen anderen ist sie frei, wenn ihre Buchung fehlt, + * nicht belegend ist (siehe istBelegend in slots.js) oder die Haltung abgelaufen ist. Solche Zellen loescht belegeZellen + * erst, wenn sie eine Belegung blockieren (danach genau ein zweiter Versuch). Der taegliche Job raeumt nur noch, was mehr als + * einen Tag vorbei ist (raeumeVerwaisteZellen, seitenweise ueber idx_bis, mit Deckel); eine Zelle mit kuenftigem Ende und + * fehlender oder nicht mehr belegender Buchung (nur moeglich, wenn gibZellenFrei scheiterte) stoert nicht und faellt + * spaetestens einen Tag nach ihrem Ende in diesen Sweep. * * Geloescht wird nie ausserhalb der Ressourcensperre und nie nach einer veralteten Liste: Zellen haben feste IDs, eine * neue Buchung legt dieselben IDs neu an, sobald die alten als ungueltig gelten. Ein spaeter Loeschauftrag nach einer * frueher gelesenen Liste wuerde die frischen Zellen der neuen Buchung treffen (Doppelbuchung). Darum lesen * gibZellenFrei und raeumeVerwaisteZellen unter der Sperre jeder betroffenen Ressource noch einmal und loeschen nur, - * was dort jetzt noch zur freizugebenden Buchung gehoert bzw. noch verwaist ist. + * was dort jetzt noch zur freizugebenden Buchung gehoert bzw. (Sweep) noch dieselbe Buchung hat und noch alt ist. * * Reihenfolge fuer Aufrufer: die Buchung (Status gehalten mit heldUntil bzw. der Zielstatus) steht VOR ihren Zellen in * der Datenbank. Eine Zelle, deren Buchung noch fehlt, gilt als frei; eine parallele Anfrage koennte sie in der kurzen @@ -36,14 +39,15 @@ const TAG_MS = 24 * 60 * 60 * 1000 const SPERRE_WARTE_MS = 5000 /** Aufraeumen darf laenger warten als eine Buchung: nach dem Ablauf liefe es ohne Sperre weiter (siehe withLocks). */ const AUFRAEUM_WARTE_MS = 15000 -/** Zellen je Sperre beim Raeumen (haelt die Sperre kurz) und Wiederholungen, falls Zellen auf weiteren Ressourcen auftauchen. */ -const RAEUM_BLOCK = 100 +/** Wiederholungen beim Freigeben, falls Zellen auf weiteren Ressourcen auftauchen. */ const RAEUM_RUNDEN = 3 -/** Werte je $id-Abfrage (haelt die Adresse kurz) und gleichzeitige Loeschungen. */ +/** Werte je $id-Abfrage (haelt die Adresse kurz), Seitengroesse dieser Abfragen und gleichzeitige Loeschungen. */ const ID_BLOCK = 50 +const ID_SEITE = 100 const LOESCH_PARALLEL = 10 -/** Der taegliche Job liest bewusst alle Zellen; mehr als das waere ein Fehler. */ -const ALLE_ZELLEN_MAX = 100000 +/** Zellen je Seite des Sweeps; zugleich die Zellen, die unter einer Sperre geloescht werden (haelt die Sperre kurz). */ +const SWEEP_SEITE = 100 +const SWEEP_FELDER = ['$id', 'featureId', 'ressourceId', 'bookingId', 'bis'] const claims = () => config.collections.termineClaims const buchungen = () => config.collections.termineBookings @@ -83,23 +87,35 @@ export function zellenId(featureId, ressourceId, zelle) { async function leseNachIds(collection, ids) { const gefunden = [] for (let i = 0; i < ids.length; i += ID_BLOCK) { - gefunden.push(...(await listAllDocuments(collection, [Query.equal('$id', ids.slice(i, i + ID_BLOCK))]))) + gefunden.push(...(await listAllDocuments(collection, [Query.equal('$id', ids.slice(i, i + ID_BLOCK))], { pageSize: ID_SEITE }))) } return gefunden } -/** Zellen, deren Buchung fehlt oder zum Zeitpunkt nowMs nichts belegt. */ +/** Zeitfenster: das Ende der Zelle liegt zum Zeitpunkt nowMs mehr als einen Tag zurueck (genau ein Tag zaehlt noch nicht). */ +const istZuAlt = (zelle, nowMs) => zeitMs(zelle.bis) < nowMs - TAG_MS + +/** + * Zellen, die zum Zeitpunkt nowMs ungueltig sind. Zuerst das Zeitfenster: eine Zelle, deren Ende mehr als einen Tag zurueckliegt, + * ist ohne Buchungsabfrage ungueltig; nur die uebrigen werden gegen ihre Buchung geprueft (fehlt oder belegt nichts). + */ async function ungueltigeZellen(zellen, nowMs) { - const buchungsIds = [...new Set(zellen.map((zelle) => zelle.bookingId).filter((id) => typeof id === 'string' && id !== ''))] + const alt = [] + const pruefen = [] + for (const zelle of zellen) (istZuAlt(zelle, nowMs) ? alt : pruefen).push(zelle) + const buchungsIds = [...new Set(pruefen.map((zelle) => zelle.bookingId).filter((id) => typeof id === 'string' && id !== ''))] const vorhanden = new Map((await leseNachIds(buchungen(), buchungsIds)).map((buchung) => [buchung.$id, buchung])) - return zellen.filter((zelle) => { - const buchung = vorhanden.get(zelle.bookingId) - return !buchung || !istBelegend(buchung, nowMs) - }) + return [ + ...alt, + ...pruefen.filter((zelle) => { + const buchung = vorhanden.get(zelle.bookingId) + return !buchung || !istBelegend(buchung, nowMs) + }), + ] } -/** Loescht Dokumente (404 = schon weg); versucht alle, wirft danach den ersten anderen Fehler. => Zahl geloeschter. */ -async function loesche(collection, ids) { +/** Loescht Dokumente (404 = schon weg) und versucht alle. => { geloescht, fehler (der erste andere Fehler | null) } */ +async function loescheAlle(collection, ids) { let geloescht = 0 let fehler = null for (let i = 0; i < ids.length; i += LOESCH_PARALLEL) { @@ -109,6 +125,12 @@ async function loesche(collection, ids) { else if (ergebnis.reason?.status !== 404) fehler ??= ergebnis.reason } } + return { geloescht, fehler } +} + +/** Loescht Dokumente (404 = schon weg); versucht alle, wirft danach den ersten anderen Fehler. => Zahl geloeschter. */ +async function loesche(collection, ids) { + const { geloescht, fehler } = await loescheAlle(collection, ids) if (fehler) throw fehler return geloescht } @@ -212,59 +234,73 @@ export async function gibZellenFrei(bookingId, { behalte = [], ressourceId = nul return geloescht } -/** Verwaiste Zellen aus zellen: bis laenger als einen Tag vorbei, oder die Buchung fehlt bzw. belegt nicht mehr. */ -async function verwaisteAus(zellen, nowMs) { - const grenze = nowMs - TAG_MS - const alt = [] - const pruefen = [] - for (const zelle of zellen) (zeitMs(zelle.bis) < grenze ? alt : pruefen).push(zelle) - return [...alt, ...(await ungueltigeZellen(pruefen, nowMs))] -} - -const alleZellen = () => listAllDocuments(claims(), [], { max: ALLE_ZELLEN_MAX }) - /** - * IDs der Zellen, die der taegliche Job loeschen darf: bis laenger als einen Tag vorbei, oder die Buchung fehlt bzw. - * belegt nicht mehr (abgesagt, abgelehnt, verfallen, erledigt, nichterschienen, abgelaufene Haltung). Nur Bericht: - * zum Loeschen NICHT diese Liste benutzen (sie veraltet), sondern raeumeVerwaisteZellen. + * Taeglicher Sweep: loescht Zellen, deren Ende mehr als einen Tag zurueckliegt (bis < jetzt - 1 Tag), unabhaengig von ihrer + * Buchung (die Buchungen werden nicht gelesen). Seiten zu 100 Zellen ueber idx_bis (aelteste zuerst), nach jeder Seite ohne + * Cursor neu gelesen (was geloescht ist, fehlt dann). Je Seite werden die Zellen nach Ressourcensperre gruppiert; unter der + * Sperre jeder Gruppe liest der Sweep die Zellen noch einmal und loescht nur, was dort noch dieselbe Buchung hat und noch alt + * ist (eine Zelle, die inzwischen einer anderen Buchung gehoert oder verlaengert wurde, bleibt). Fehler einer Gruppe stoppen + * die anderen nicht; der erste wird am Ende geworfen. Eine Seite, auf der nichts geloescht wurde, beendet den Lauf, ebenso eine + * Seite aus lauter schon versuchten Zellen (kein Endlosdurchlauf). Bewusst: Zellen mit kuenftigem Ende, deren Buchung fehlt + * oder nichts mehr belegt, findet der Sweep nicht (belegeZellen raeumt sie bei Bedarf); sie fallen spaetestens einen Tag nach bis hinein. + * + * deckel = hoechstens so viele Zellen je Lauf (config.termine.zellenDeckel, bei jedem Aufruf gelesen); der Rest folgt am + * naechsten Tag. gedeckelt = der Lauf endete am Deckel und es steht noch etwas an (0 = nur nachsehen). + * => { geloescht, gedeckelt } */ -export async function verwaisteZellen(now) { - const nowMs = pruefeNow(now, 'verwaisteZellen') - return (await verwaisteAus(await alleZellen(), nowMs)).map((zelle) => zelle.$id) -} - -/** - * Loescht die verwaisten Zellen (Regeln wie verwaisteZellen) selbst: je Ressource in Bloecken zu 100 unter deren - * Sperre, nach erneutem Lesen der Zellen und ihrer Buchungen. Eine Zelle, die inzwischen einer anderen Buchung gehoert - * oder wieder gilt, bleibt. Fehler eines Blocks stoppen die anderen nicht; der erste wird am Ende geworfen. - * => Zahl geloeschter Zellen. - */ -export async function raeumeVerwaisteZellen(now) { +export async function raeumeVerwaisteZellen(now, { deckel = config.termine.zellenDeckel } = {}) { const nowMs = pruefeNow(now, 'raeumeVerwaisteZellen') - const nachSperre = new Map() - for (const zelle of await verwaisteAus(await alleZellen(), nowMs)) { - const schluessel = sperrSchluessel(zelle.featureId, zelle.ressourceId) - nachSperre.set(schluessel, [...(nachSperre.get(schluessel) ?? []), zelle]) - } + if (!Number.isInteger(deckel) || deckel < 0) throw new TypeError(`raeumeVerwaisteZellen: deckel muss eine ganze Zahl ab 0 sein, nicht ${deckel}`) + const grenze = new Date(nowMs - TAG_MS).toISOString() + const versucht = new Set() let geloescht = 0 + let gedeckelt = false let fehler = null - for (const [schluessel, liste] of nachSperre) { - for (let i = 0; i < liste.length; i += RAEUM_BLOCK) { - const block = new Map(liste.slice(i, i + RAEUM_BLOCK).map((zelle) => [zelle.$id, zelle.bookingId])) + for (;;) { + let seite + try { + seite = await listDocuments(claims(), [Query.lessThan('bis', grenze), Query.orderAsc('bis'), Query.limit(SWEEP_SEITE), Query.select(SWEEP_FELDER)]) + } catch (err) { + fehler ??= err + break + } + const neu = seite.filter((zelle) => !versucht.has(zelle.$id)) + if (neu.length === 0) break + if (geloescht >= deckel) { + gedeckelt = true + break + } + + const nachSperre = new Map() + for (const zelle of neu.slice(0, deckel - geloescht)) { + versucht.add(zelle.$id) + const schluessel = sperrSchluessel(zelle.featureId, zelle.ressourceId) + const gruppe = nachSperre.get(schluessel) + if (gruppe) gruppe.push(zelle) + else nachSperre.set(schluessel, [zelle]) + } + const vorher = geloescht + for (const [schluessel, gruppe] of nachSperre) { + const gelesen = new Map(gruppe.map((zelle) => [zelle.$id, zelle.bookingId])) try { - geloescht += await withLocks( + const erg = await withLocks( [schluessel], async () => { - const aktuell = (await leseNachIds(claims(), [...block.keys()])).filter((zelle) => block.get(zelle.$id) === zelle.bookingId) - return loesche(claims(), (await verwaisteAus(aktuell, nowMs)).map((zelle) => zelle.$id)) + const aktuell = (await leseNachIds(claims(), [...gelesen.keys()])).filter( + (zelle) => gelesen.get(zelle.$id) === zelle.bookingId && istZuAlt(zelle, nowMs) + ) + return loescheAlle(claims(), aktuell.map((zelle) => zelle.$id)) }, { waitMs: AUFRAEUM_WARTE_MS } ) + geloescht += erg.geloescht + fehler ??= erg.fehler } catch (err) { fehler ??= err } } + if (geloescht === vorher) break } if (fehler) throw fehler - return geloescht + return { geloescht, gedeckelt } } diff --git a/test/termineJobs.test.js b/test/termineJobs.test.js index f00b34c..987b0c6 100644 --- a/test/termineJobs.test.js +++ b/test/termineJobs.test.js @@ -51,6 +51,7 @@ const ABSENDER_VORHER = config.smtp.from const MODUS_VORHER = config.termine.mode const DECKEL_VORHER = config.termine.loeschDeckel const GESAMT_DECKEL_VORHER = config.termine.loeschDeckelGesamt +const ZELLEN_DECKEL_VORHER = config.termine.zellenDeckel const BASIS_VORHER = config.publicBaseUrl const iso = (wert) => new Date(wert).toISOString() @@ -134,6 +135,7 @@ afterEach(async () => { config.termine.mode = MODUS_VORHER config.termine.loeschDeckel = DECKEL_VORHER config.termine.loeschDeckelGesamt = GESAMT_DECKEL_VORHER + config.termine.zellenDeckel = ZELLEN_DECKEL_VORHER config.smtp.from = ABSENDER_VORHER config.publicBaseUrl = BASIS_VORHER assert.doesNotMatch(text, /Mustermann|max@example\.com/i, 'kein Gastname und keine Gastadresse im Log') @@ -928,9 +930,9 @@ test('wendeLoeschfristenAn: der Deckel gilt je Status; genau so viele fällige w test('wendeLoeschfristenAn: scheitert jedes Löschen, endet der Lauf nach einer Seite je Schritt, zählt die Fehler, lässt die anderen Schritte laufen; der nächste Lauf holt alles nach', async () => { for (const art of ARTEN) for (let i = 0; i < 30; i += 1) await legeLoeschFall(`b_${KUERZEL[art]}${nr(i)}`, art, 1 + i * 1000) await legeLoeschFall('b_ver_jung', 'verfallen', -1) - // andere Schritte des Tagesjobs: eine alte, versandte Mail und eine verwaiste Zelle + // andere Schritte des Tagesjobs: eine alte, versandte Mail und eine verwaiste Zelle (Buchung fehlt, Ende mehr als einen Tag vorbei: nur solche findet der Zellen-Sweep) legeEintrag('o_gesendet', { status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: iso(plus(JETZT, -MIN)) }) - await belegeZellen({ featureId: F1, ressourceId: 'r_geist', bookingId: 'b_geist', start: plus(JETZT, 2 * TAG), blockEnde: plus(JETZT, 2 * TAG + 30 * MIN), now: JETZT }) + await belegeZellen({ featureId: F1, ressourceId: 'r_geist', bookingId: 'b_geist', start: plus(JETZT, -3 * TAG), blockEnde: plus(JETZT, -3 * TAG + 30 * MIN), now: JETZT }) let erg const zaehler = await mitScheiternderLoeschung( @@ -1629,8 +1631,8 @@ test('taeglicherJob: Löschfristen, Warteschlange, festgefahrene Mails, verwaist await legeBuchung('b_erledigt', { status: 'erledigt', ende: plus(JETZT, -100 * TAG) }) await legeBuchung('b_zukunft', { start: plus(JETZT, 5 * TAG) }) await legeBuchung('b_abgeschaltet', { featureId: F2, start: plus(JETZT, 5 * TAG) }) - // verwaiste Zelle: Buchung fehlt - await belegeZellen({ featureId: F1, ressourceId: 'r_geist', bookingId: 'b_geist', start: plus(JETZT, 2 * TAG), blockEnde: plus(JETZT, 2 * TAG + 30 * MIN), now: JETZT }) + // verwaiste Zelle: Buchung fehlt, Ende mehr als einen Tag vorbei (nur solche findet der Zellen-Sweep) + await belegeZellen({ featureId: F1, ressourceId: 'r_geist', bookingId: 'b_geist', start: plus(JETZT, -3 * TAG), blockEnde: plus(JETZT, -3 * TAG + 30 * MIN), now: JETZT }) legeEintrag('o_gesendet', { status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: iso(plus(JETZT, -MIN)) }) legeEintrag('o_festgefahren', { createdAt: iso(plus(JETZT, -3 * TAG)) }) @@ -1688,6 +1690,69 @@ test('taeglicherJob: abgeschaltete Funktion am Deckel ⇒ gedeckelt.abgeschaltet assert.equal(aw.docs('termineConfig').get(F1), undefined) }) +/** Alte, verwaiste Zellen (Buchung fehlt, Ende mehr als einen Tag vorbei), n Stück, die erste ist die älteste. */ +function legeAlteZellen(n, praefix = 'c_alt') { + for (let i = 0; i < n; i += 1) { + const bis = plus(JETZT, -3 * TAG - (n - i) * 1000) + aw.seed('termineClaims', { + $id: `${praefix}${String(i).padStart(3, '0')}`, + featureId: F1, + ressourceId: `r_geist${i % 3}`, + bookingId: `b_geist${i}`, + zelle: iso(plus(bis, -5 * MIN)), + bis: iso(bis), + }) + } +} + +test('taeglicherJob: Zellen-Sweep am Deckel ⇒ gedeckelt.zellen zählt, zellen bleibt die Zahl der gelöschten Zellen, der Rest folgt an den nächsten Tagen', async () => { + legeAlteZellen(600) + config.termine.zellenDeckel = 250 + + let erg = await taeglicherJob(JETZT) + assert.equal(erg.zellen, 250) + assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 1 }) + assert.equal(erg.fehler, 0) + assert.equal(aw.docs('termineClaims').size, 350) + assert.equal(warnLog().filter((zeile) => /Deckel/.test(zeile)).length, 1) + assert.doesNotMatch(warnLog().join('\n'), /\bb_|\bf_|\br_|\bc_/, 'die Warnung nennt keine Kennungen') + const zeile = tagesbericht(erg) + assert.match(zeile, /, 250 Zellen, /) + assert.match(zeile, /Löschläufe am Deckel: 1, Rest folgt an den nächsten Tagen, 0 Fehler$/) + + erg = await taeglicherJob(JETZT) + assert.equal(erg.zellen, 250) + assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 1 }) + assert.equal(aw.docs('termineClaims').size, 100) + + log.warn.mock.resetCalls() + erg = await taeglicherJob(JETZT) + assert.equal(erg.zellen, 100) + assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 0 }) + assert.equal(aw.docs('termineClaims').size, 0) + assert.equal(warnLog().filter((zeile) => /Deckel/.test(zeile)).length, 0) + assert.doesNotMatch(tagesbericht(erg), /Deckel/) +}) + +test('taeglicherJob: Zellen-Sweep mit Standard-Deckel ⇒ zellen ist die Zahl, gedeckelt.zellen 0; nicht lesbare Zellen zählen als Fehler, die Zahlen bleiben Zahlen', async () => { + legeAlteZellen(130) + let erg = await taeglicherJob(JETZT) + assert.equal(erg.zellen, 130) + assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 0 }) + assert.equal(erg.fehler, 0) + + legeAlteZellen(5) + aw.failNext({ collection: 'termineClaims', method: 'GET', status: 500, message: 'Nachbau kaputt' }) // die erste Seite des Sweeps + erg = await taeglicherJob(JETZT) + assert.equal(erg.zellen, 0) + assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 0 }) + assert.equal(erg.fehler, 1) + assert.equal(aw.docs('termineClaims').size, 5) + erg = await taeglicherJob(JETZT) + assert.equal(erg.zellen, 5) + assert.equal(erg.fehler, 0) +}) + test('taeglicherJob: ist die Liste der abgeschalteten Funktionen nicht lesbar, zählt der Schritt nichts, gedeckelt.abgeschaltet bleibt eine Zahl und die übrigen Schritte laufen', async () => { await abgeschaltetMitBuchungen(3, { zellen: false }) aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 1 }) // 1. GET = Funktionen der Löschfristen, 2. = abgeschaltete diff --git a/test/termineSchalter.test.js b/test/termineSchalter.test.js index d6c2ff9..d8f8492 100644 --- a/test/termineSchalter.test.js +++ b/test/termineSchalter.test.js @@ -49,6 +49,7 @@ test('Konfiguration: Vorgaben ohne Umgebungsvariablen', async () => { mailsProStunde: 200, loeschDeckel: 2000, loeschDeckelGesamt: 20000, + zellenDeckel: 5000, formMinSekunden: 3, minAntwortMs: 300, }) diff --git a/test/termineZellen.test.js b/test/termineZellen.test.js index 62dc43f..611cc21 100644 --- a/test/termineZellen.test.js +++ b/test/termineZellen.test.js @@ -19,7 +19,7 @@ useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { deleteDocument, listDocuments } = await import('../server/services/appwriteAdmin.js') const { withLocks } = await import('../server/services/keyedLock.js') -const { zellenFuer, zellenId, belegeZellen, gibZellenFrei, verwaisteZellen, raeumeVerwaisteZellen } = await import('../server/services/termine/zellen.js') +const { zellenFuer, zellenId, belegeZellen, gibZellenFrei, raeumeVerwaisteZellen } = await import('../server/services/termine/zellen.js') const { neuesToken, hashToken, verwaltungsToken, buchungsNummer, formToken, pruefeFormToken } = await import( '../server/services/termine/token.js' ) @@ -597,62 +597,7 @@ test('gibZellenFrei sperrt jede Ressource der Buchung, auch mehrere zugleich', a assert.equal(claimsOf('b_X').length, 0) }) -test('verwaisteZellen: Buchung fehlt, nicht mehr belegend oder Zelle laenger als einen Tag vorbei', async () => { - const claim = (id, bookingId, bis, extra = {}) => - aw.seed('termineClaims', { - $id: id, - featureId: F, - ressourceId: R, - bookingId, - zelle: iso(new Date(new Date(bis).getTime() - 5 * MIN)), - bis: iso(bis), - ...extra, - }) - const bald = new Date(JETZT.getTime() + 2 * TAG) - seedBuchung('b_ok', { status: 'bestaetigt' }) - seedBuchung('b_anfrage', { status: 'angefragt' }) - seedBuchung('b_haltung', { status: 'gehalten', heldUntil: iso(new Date(JETZT.getTime() + MIN)) }) - seedBuchung('b_haltung_alt', { status: 'gehalten', heldUntil: iso(new Date(JETZT.getTime() - MIN)) }) - seedBuchung('b_abgesagt', { status: 'abgesagt' }) - seedBuchung('b_erledigt', { status: 'erledigt' }) - seedBuchung('b_alt', { status: 'bestaetigt' }) - claim('c_ok', 'b_ok', bald) - claim('c_anfrage', 'b_anfrage', bald) - claim('c_haltung', 'b_haltung', bald) - claim('c_haltung_alt', 'b_haltung_alt', bald) - claim('c_abgesagt', 'b_abgesagt', bald) - claim('c_erledigt', 'b_erledigt', bald) - claim('c_fehlt', 'b_gibt_es_nicht', bald) - claim('c_ohne', '', bald) - claim('c_alt', 'b_alt', new Date(JETZT.getTime() - TAG - MIN)) // Buchung laeuft noch, die Zelle ist aber ueber einen Tag vorbei - claim('c_gestern', 'b_ok', new Date(JETZT.getTime() - TAG + MIN)) // noch keinen vollen Tag vorbei, Buchung belegt - const ids = await verwaisteZellen(JETZT) - assert.deepEqual( - [...ids].sort(), - ['c_abgesagt', 'c_alt', 'c_erledigt', 'c_fehlt', 'c_haltung_alt', 'c_ohne'].sort() - ) -}) - -test('verwaisteZellen liest alle Zellen (mehr als eine Seite) und prueft ihre Buchungen in Bloecken', async () => { - // 260 Zellen, je 2 pro Buchung (130 Buchungen, mehr als ein Block); jede zweite Buchung existiert nicht - for (let i = 0; i < 260; i += 1) { - const bookingId = `b_viele${Math.floor(i / 2)}` - if (i % 4 < 2) seedBuchung(bookingId) - aw.seed('termineClaims', { - $id: `c_viele${String(i).padStart(3, '0')}`, - featureId: F, - ressourceId: R, - bookingId, - zelle: iso(new Date(T('10:00').getTime() + i * 5 * MIN)), - bis: iso(T('23:00')), - }) - } - const ids = await verwaisteZellen(JETZT) - const erwartet = [] - for (let i = 0; i < 260; i += 1) if (i % 4 >= 2) erwartet.push(`c_viele${String(i).padStart(3, '0')}`) - assert.equal(erwartet.length, 130) - assert.deepEqual([...ids].sort(), erwartet.sort()) -}) +// ---------------------------------------------------------------- Zellen-Sweep (raeumeVerwaisteZellen) und Zeitfenster /** Zellen mit eigener ID in der Datenbank anlegen (ohne belegeZellen, damit auch ungueltige Altlasten entstehen). */ function seedZelle(id, bookingId, bis, extra = {}) { @@ -667,79 +612,271 @@ function seedZelle(id, bookingId, bis, extra = {}) { }) } -test('raeumeVerwaisteZellen loescht genau die verwaisten Zellen und zaehlt sie', async () => { +/** Ende einer alten Zelle: mehr als einen Tag vor JETZT (n ms weiter zurueck, damit die Sortierung nach bis eindeutig ist). */ +const alt = (n = 0) => new Date(JETZT.getTime() - TAG - 1 - n) +const nr = (i, stellen = 3) => String(i).padStart(stellen, '0') + +/** Alle Anfragen (Methode und Adresse) aufzeichnen, die fn an Appwrite stellt. */ +async function mitAnfragen(fn) { + const echt = globalThis.fetch + const anfragen = [] + globalThis.fetch = async (url, optionen) => { + anfragen.push({ methode: optionen?.method ?? 'GET', url: String(url) }) + return echt(url, optionen) + } + try { + await fn() + } finally { + globalThis.fetch = echt + } + return anfragen +} +const anBuchungen = (anfragen) => anfragen.filter((anfrage) => anfrage.url.includes('/collections/termineBookings/')) + +/** DELETE-Anfragen an termineClaims scheitern, solange scheitert(id) wahr ist (503); alles andere geht durch. */ +async function mitScheiternderZellenLoeschung(scheitert, fn) { + const echt = globalThis.fetch + globalThis.fetch = async (url, optionen) => { + const treffer = optionen?.method === 'DELETE' && String(url).match(/\/collections\/termineClaims\/documents\/([^/?]+)$/) + if (treffer && scheitert(treffer[1])) { + return new Response(JSON.stringify({ message: 'Nachbau kaputt', code: 503, type: 'general_unknown' }), { + status: 503, + headers: { 'content-type': 'application/json' }, + }) + } + return echt(url, optionen) + } + try { + return await fn() + } finally { + globalThis.fetch = echt + } +} + +/** Abfragen des Sweeps (die Seiten ueber bis), nicht die ID-Abfragen unter der Sperre. */ +const seitenAbfragen = () => aw.listCalls('termineClaims').filter((aufruf) => aufruf.queries.some((q) => q.method === 'lessThan')) +const limitVon = (aufruf) => aufruf.queries.find((q) => q.method === 'limit')?.values[0] + +test('Zellen-Sweep: bis genau jetzt - 1 Tag bleibt, eine Millisekunde aelter geht, die Buchungen werden nie abgefragt', async () => { + const grenze = new Date(JETZT.getTime() - TAG) + seedBuchung('b_ok') // bestaetigt: die Buchung belegt noch + seedZelle('c_genau', 'b_ok', grenze) + seedZelle('c_aelter', 'b_ok', new Date(grenze.getTime() - 1)) // ueber einen Tag vorbei: ungueltig, egal was die Buchung sagt + seedZelle('c_fehlt_genau', 'b_gibt_es_nicht', grenze) // Buchung fehlt, aber das Zeitfenster zaehlt: bleibt + seedZelle('c_fehlt_kuenftig', 'b_gibt_es_nicht', new Date(JETZT.getTime() + 2 * TAG)) + let erg + const anfragen = await mitAnfragen(async () => { + erg = await raeumeVerwaisteZellen(JETZT) + }) + assert.deepEqual(erg, { geloescht: 1, gedeckelt: false }) + assert.deepEqual([...aw.docs('termineClaims').keys()].sort(), ['c_fehlt_genau', 'c_fehlt_kuenftig', 'c_genau']) + assert.deepEqual(anBuchungen(anfragen), [], 'keine einzige Anfrage an termineBookings') + + // eine Millisekunde spaeter sind auch die beiden Zellen mit bis == jetzt - 1 Tag ueber einen Tag vorbei + assert.deepEqual(await raeumeVerwaisteZellen(new Date(JETZT.getTime() + 1)), { geloescht: 2, gedeckelt: false }) + assert.deepEqual([...aw.docs('termineClaims').keys()], ['c_fehlt_kuenftig']) + await assert.rejects(raeumeVerwaisteZellen(undefined), TypeError) +}) + +test('Zellen-Sweep loescht genau die alten Zellen (jeder Funktion und Ressource) und zaehlt sie; kuenftige bleiben, auch ohne gueltige Buchung', async () => { const bald = new Date(JETZT.getTime() + 2 * TAG) seedBuchung('b_ok') seedBuchung('b_abgesagt', { status: 'abgesagt' }) seedBuchung('b_haltung_alt', { status: 'gehalten', heldUntil: iso(new Date(JETZT.getTime() - MIN)) }) - seedBuchung('b_alt') + // kuenftiges Ende: bleibt, auch wenn die Buchung fehlt oder nichts belegt (siehe die Waisen-Regel im Kopfkommentar) seedZelle('c_ok', 'b_ok', bald) seedZelle('c_abgesagt', 'b_abgesagt', bald, { ressourceId: 'r_aaaaaaa2' }) - seedZelle('c_haltung_alt', 'b_haltung_alt', bald) seedZelle('c_fehlt', 'b_gibt_es_nicht', bald, { featureId: 'f_feature02' }) - seedZelle('c_alt', 'b_alt', new Date(JETZT.getTime() - TAG - MIN)) - assert.equal(await raeumeVerwaisteZellen(JETZT), 4) - assert.deepEqual([...aw.docs('termineClaims').keys()], ['c_ok']) - assert.equal(await raeumeVerwaisteZellen(JETZT), 0) - await assert.rejects(raeumeVerwaisteZellen(undefined), TypeError) + // Ende mehr als einen Tag vorbei: weg, egal was die Buchung sagt + seedZelle('c_alt_ok', 'b_ok', alt(1000)) + seedZelle('c_alt_abgesagt', 'b_abgesagt', alt(2000), { ressourceId: 'r_aaaaaaa2' }) + seedZelle('c_alt_haltung', 'b_haltung_alt', alt(3000)) + seedZelle('c_alt_fehlt', 'b_gibt_es_nicht', alt(4000), { featureId: 'f_feature02' }) + assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 4, gedeckelt: false }) + assert.deepEqual([...aw.docs('termineClaims').keys()].sort(), ['c_abgesagt', 'c_fehlt', 'c_ok']) + assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 0, gedeckelt: false }) }) -test('raeumeVerwaisteZellen raeumt mehr als einen Block (260 Zellen auf zwei Ressourcen)', async () => { +test('Zellen-Sweep: 260 alte Zellen auf zwei Ressourcen in Seiten zu hoechstens 100 ueber bis (Standard-Deckel liegt darueber)', async () => { + assert.equal(config.termine.zellenDeckel, 5000) for (let i = 0; i < 260; i += 1) { - seedZelle(`c_viele${String(i).padStart(3, '0')}`, `b_nie${i}`, T('23:00'), { ressourceId: i % 2 ? 'r_aaaaaaa2' : R }) + seedZelle(`c_viele${nr(i)}`, `b_nie${i}`, alt(i * 1000), { ressourceId: i % 2 ? 'r_aaaaaaa2' : R }) } seedBuchung('b_ok') seedZelle('c_ok', 'b_ok', T('23:00')) - assert.equal(await raeumeVerwaisteZellen(JETZT), 260) + aw.resetListCalls() + assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 260, gedeckelt: false }) + assert.deepEqual([...aw.docs('termineClaims').keys()], ['c_ok']) + + const abfragen = aw.listCalls('termineClaims') + assert.ok(abfragen.length >= 4, `drei Seiten, eine leere Seite und die ID-Abfragen: ${abfragen.length}`) + assert.ok(abfragen.every((aufruf) => Number.isInteger(limitVon(aufruf)) && limitVon(aufruf) <= 100), abfragen.map(limitVon).join(',')) + const seiten = seitenAbfragen() + assert.equal(seiten.length, 4, '100 + 100 + 60 Zellen, dann eine leere Seite') + const erste = seiten[0].queries + assert.deepEqual(erste.find((q) => q.method === 'lessThan'), { method: 'lessThan', attribute: 'bis', values: [iso(new Date(JETZT.getTime() - TAG))] }) + assert.equal(erste.find((q) => q.method === 'orderAsc')?.attribute, 'bis') + assert.deepEqual(erste.find((q) => q.method === 'select')?.values, ['$id', 'featureId', 'ressourceId', 'bookingId', 'bis']) + assert.equal(erste.find((q) => q.method === 'cursorAfter'), undefined, 'ohne Cursor, die Liste wird neu gelesen') + assert.deepEqual(seiten.map(limitVon), [100, 100, 100, 100]) +}) + +test('Zellen-Sweep: der Deckel zaehlt Zellen genau, der Rest folgt im naechsten Lauf', async () => { + // c_deckel000 ist die aelteste + for (let i = 0; i < 600; i += 1) seedZelle(`c_deckel${nr(i)}`, `b_nie${i}`, alt((600 - i) * 1000), { ressourceId: i % 3 ? 'r_aaaaaaa2' : R }) + const vorher = config.termine.zellenDeckel + config.termine.zellenDeckel = 250 + try { + assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 250, gedeckelt: true }) + assert.equal(aw.docs('termineClaims').size, 350) + assert.ok(!aw.docs('termineClaims').has('c_deckel249') && aw.docs('termineClaims').has('c_deckel250'), 'die aeltesten zuerst') + assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 250, gedeckelt: true }) + assert.equal(aw.docs('termineClaims').size, 100) + assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 100, gedeckelt: false }) + assert.equal(alleClaims(), 0) + assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 0, gedeckelt: false }) + } finally { + config.termine.zellenDeckel = vorher + } +}) + +test('Zellen-Sweep: deckel als Option gilt vor config.termine.zellenDeckel; genau so viele Zellen wie der Deckel sind nicht gedeckelt', async () => { + for (let i = 0; i < 10; i += 1) seedZelle(`c_opt${nr(i)}`, `b_nie${i}`, alt((10 - i) * 1000)) + assert.deepEqual(await raeumeVerwaisteZellen(JETZT, { deckel: 4 }), { geloescht: 4, gedeckelt: true }) + assert.deepEqual(await raeumeVerwaisteZellen(JETZT, { deckel: 0 }), { geloescht: 0, gedeckelt: true }, 'Deckel 0: nur nachsehen, ob etwas ansteht') + assert.equal(alleClaims(), 6) + assert.deepEqual(await raeumeVerwaisteZellen(JETZT, { deckel: 6 }), { geloescht: 6, gedeckelt: false }, 'der Deckel reicht genau') + assert.deepEqual(await raeumeVerwaisteZellen(JETZT, { deckel: 0 }), { geloescht: 0, gedeckelt: false }) + for (const falsch of [-1, 1.5, '5', null, NaN]) { + await assert.rejects(raeumeVerwaisteZellen(JETZT, { deckel: falsch }), TypeError, String(falsch)) + } +}) + +test('Zellen-Sweep: 3000 alte Zellen einer einzigen Ressource (ein Sperrschluessel) werden alle geloescht', async () => { + for (let i = 0; i < 3000; i += 1) seedZelle(`c_eine${nr(i, 4)}`, `b_nie${i % 500}`, alt((3000 - i) * 1000)) + seedBuchung('b_ok') + seedZelle('c_ok', 'b_ok', T('23:00')) + assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 3000, gedeckelt: false }) assert.deepEqual([...aw.docs('termineClaims').keys()], ['c_ok']) }) -test('raeumeVerwaisteZellen: ein verzoegertes DELETE trifft nicht die frisch belegten Zellen einer neuen Buchung', async () => { +test('Zellen-Sweep: ein verzoegertes DELETE trifft nicht die Zellen einer gleichzeitig belegten Zeit derselben Ressource', async () => { seedBuchung('b_X', { status: 'abgesagt' }) - await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_X', start: T('10:00'), blockEnde: T('10:50'), now: JETZT }) + for (let i = 0; i < 10; i += 1) seedZelle(`c_x${nr(i, 2)}`, 'b_X', alt(i * 1000)) seedBuchung('b_W') await mitVerzoegertemLoeschen(10, 400, async () => { const lauf = raeumeVerwaisteZellen(JETZT) await schlafe(20) await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_W', start: T('10:00'), blockEnde: T('10:50'), now: JETZT }) - await lauf + assert.deepEqual(await lauf, { geloescht: 10, gedeckelt: false }) }) assert.equal(claimsOf('b_W').length, 10, 'W behaelt alle Zellen') assert.equal(claimsOf('b_X').length, 0) }) -test('raeumeVerwaisteZellen prueft nach dem Warten auf die Sperre erneut: wieder gueltige oder neu vergebene Zellen bleiben', async () => { +test('Zellen-Sweep prueft nach dem Warten auf die Sperre erneut: nur dieselbe Buchung und noch alt wird geloescht', async () => { const bald = new Date(JETZT.getTime() + 2 * TAG) - seedBuchung('b_X1', { status: 'abgesagt' }) - seedBuchung('b_X2', { status: 'abgesagt' }) - seedBuchung('b_X3', { status: 'abgesagt' }) seedBuchung('b_W') - seedZelle('c_x1', 'b_X1', bald) - seedZelle('c_x2', 'b_X2', bald) - seedZelle('c_x3', 'b_X3', bald) - seedZelle('c_x4', 'b_X1', bald) + seedZelle('c_x1', 'b_X1', alt(1000)) // unveraendert: weg + seedZelle('c_x2', 'b_X2', alt(2000)) // wird einer neuen Buchung mit kuenftigem Ende + seedZelle('c_x3', 'b_X3', alt(3000)) // ist inzwischen schon weg + seedZelle('c_x4', 'b_X1', alt(4000)) // andere Buchung, deren Dokument erst noch folgt + seedZelle('c_x5', 'b_X5', alt(5000)) // gleiche Buchung, aber das Ende liegt jetzt in der Zukunft + seedZelle('c_x6', 'b_X6', alt(6000)) // andere Buchung, das Ende ist noch alt: bleibt bis zum naechsten Lauf const sperre = haltePerSperre() + aw.resetListCalls() const lauf = raeumeVerwaisteZellen(JETZT) - await schlafe(100) // der Lauf hat die Kandidaten gelesen und wartet auf die Sperre - seedBuchung('b_X1', { status: 'bestaetigt' }) // wieder gueltig - aw.seed('termineClaims', { ...aw.docs('termineClaims').get('c_x2'), bookingId: 'b_W' }) // neu vergeben - await deleteDocument('termineClaims', 'c_x3') // schon weg - aw.seed('termineClaims', { ...aw.docs('termineClaims').get('c_x4'), bookingId: 'b_neu_ohne_buchung' }) // neu vergeben, Buchung folgt erst noch + await schlafe(100) // der Lauf hat die Seite gelesen und wartet auf die Sperre + const zelle = (id) => aw.docs('termineClaims').get(id) + aw.seed('termineClaims', { ...zelle('c_x2'), bookingId: 'b_W', bis: iso(bald) }) + await deleteDocument('termineClaims', 'c_x3') + aw.seed('termineClaims', { ...zelle('c_x4'), bookingId: 'b_neu_ohne_buchung', bis: iso(bald) }) + aw.seed('termineClaims', { ...zelle('c_x5'), bis: iso(bald) }) + aw.seed('termineClaims', { ...zelle('c_x6'), bookingId: 'b_W' }) sperre.freigeben() await sperre.fertig - assert.equal(await lauf, 0) - assert.deepEqual([...aw.docs('termineClaims').keys()].sort(), ['c_x1', 'c_x2', 'c_x4']) - assert.equal(aw.docs('termineClaims').get('c_x2').bookingId, 'b_W') - assert.equal(aw.docs('termineClaims').get('c_x4').bookingId, 'b_neu_ohne_buchung') + assert.deepEqual(await lauf, { geloescht: 1, gedeckelt: false }) + assert.deepEqual([...aw.docs('termineClaims').keys()].sort(), ['c_x2', 'c_x4', 'c_x5', 'c_x6']) + assert.equal(zelle('c_x2').bookingId, 'b_W') + assert.equal(zelle('c_x4').bookingId, 'b_neu_ohne_buchung') + assert.equal(seitenAbfragen().length <= 3, true, 'keine Endlosschleife ueber die unveraenderte c_x6') + // im naechsten Lauf gehoert c_x6 der Buchung, die der Sweep liest: weg + assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 1, gedeckelt: false }) + assert.deepEqual([...aw.docs('termineClaims').keys()].sort(), ['c_x2', 'c_x4', 'c_x5']) }) -test('raeumeVerwaisteZellen: ein Fehler in einem Block stoppt die uebrigen nicht und wird am Ende geworfen', async () => { - seedZelle('c_a1', 'b_nie1', T('23:00')) - seedZelle('c_b1', 'b_nie2', T('23:00'), { ressourceId: 'r_aaaaaaa2' }) +test('Zellen-Sweep: eine Waise mit kuenftigem Ende bleibt stehen, blockiert aber keine Neubuchung', async () => { + seedBuchung('b_abgesagt', { status: 'abgesagt' }) + await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_abgesagt', start: T('10:00'), blockEnde: T('10:50'), now: JETZT }) + seedZelle('c_fehlt', 'b_gibt_es_nicht', T('23:00')) + assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 0, gedeckelt: false }) + assert.equal(alleClaims(), 11, 'der Sweep findet Waisen mit kuenftigem Ende nicht mehr') + + seedBuchung('b_neu') + await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu', start: T('10:00'), blockEnde: T('10:50'), now: JETZT }) + assert.equal(claimsOf('b_neu').length, 10, 'belegeZellen raeumt den Blockierer') + assert.equal(claimsOf('b_abgesagt').length, 0) + + // spaetestens einen Tag nach bis faellt alles in den Sweep + assert.deepEqual(await raeumeVerwaisteZellen(new Date(T('23:00').getTime() + TAG + MIN)), { geloescht: 11, gedeckelt: false }) + assert.equal(alleClaims(), 0) +}) + +test('Zellen-Sweep: ein Fehler beim Loeschen stoppt die uebrigen Ressourcen nicht und wird am Ende geworfen', async () => { + seedZelle('c_a1', 'b_nie1', alt(2000)) + seedZelle('c_b1', 'b_nie2', alt(1000), { ressourceId: 'r_aaaaaaa2' }) + aw.resetListCalls() aw.failNext({ collection: 'termineClaims', method: 'DELETE', status: 503 }) await assert.rejects(raeumeVerwaisteZellen(JETZT), (err) => err.status === 500) assert.equal(aw.docs('termineClaims').size, 1, 'die andere Ressource ist trotzdem geraeumt') - assert.equal(await raeumeVerwaisteZellen(JETZT), 1) + assert.ok(seitenAbfragen().length <= 2, `kein Endlosdurchlauf: ${seitenAbfragen().length} Seiten`) + assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 1, gedeckelt: false }) +}) + +test('Zellen-Sweep: scheitert jedes DELETE, endet der Lauf nach einer Seite und wirft den ersten Fehler', async () => { + for (let i = 0; i < 150; i += 1) seedZelle(`c_kaputt${nr(i)}`, `b_nie${i}`, alt((150 - i) * 1000), { ressourceId: i % 2 ? 'r_aaaaaaa2' : R }) + aw.resetListCalls() + await mitScheiternderZellenLoeschung( + () => true, + () => assert.rejects(raeumeVerwaisteZellen(JETZT), (err) => err.status === 500) + ) + assert.equal(alleClaims(), 150) + assert.equal(seitenAbfragen().length, 1, 'eine Seite ohne Erfolg beendet den Lauf') +}) + +test('Zellen-Sweep: eine Zelle, die sich nicht loeschen laesst, haelt den Rest nicht auf; was geloescht ist, zaehlt fuer den naechsten Lauf', async () => { + // c_stur000 ist die aelteste und steht in jeder Liste vorn + for (let i = 0; i < 150; i += 1) seedZelle(`c_stur${nr(i)}`, `b_nie${i}`, alt((150 - i) * 1000)) + aw.resetListCalls() + await mitScheiternderZellenLoeschung( + (id) => id === 'c_stur000', + () => assert.rejects(raeumeVerwaisteZellen(JETZT), (err) => err.status === 500) + ) + assert.deepEqual([...aw.docs('termineClaims').keys()], ['c_stur000']) + assert.ok(seitenAbfragen().length <= 4, `keine Endlosschleife: ${seitenAbfragen().length} Seiten`) + assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 1, gedeckelt: false }) +}) + +test('Blockierer mit Ende vor mehr als einem Tag sind ohne Buchungsabfrage ungueltig (dieselbe Regel wie im Sweep)', async () => { + await belege('b_alt', T('10:00'), T('11:00')) // bestaetigt: belegt + seedBuchung('b_neu') + const spaet = new Date(T('11:00').getTime() + TAG + MIN) + const anfragen = await mitAnfragen(() => + belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu', start: T('10:00'), blockEnde: T('11:00'), now: spaet }) + ) + assert.equal(claimsOf('b_neu').length, 12) + assert.equal(claimsOf('b_alt').length, 0) + assert.deepEqual(anBuchungen(anfragen), [], 'das Zeitfenster entscheidet, die Buchung wird nicht gelesen') + + // genau einen Tag nach bis ist noch nichts ueberfaellig: die Buchung entscheidet und belegt + await belege('b_alt2', T('12:00'), T('13:00')) + seedBuchung('b_neu2') + const knapp = new Date(T('13:00').getTime() + TAG) + const anfragen2 = await mitAnfragen(() => + assert.rejects(belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu2', start: T('12:00'), blockEnde: T('13:00'), now: knapp }), vergeben) + ) + assert.ok(anBuchungen(anfragen2).length > 0) + assert.equal(claimsOf('b_alt2').length, 12) }) // ---------------------------------------------------------------- Buchungsspeicher