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