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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 09:44:47 +00:00
1 parent d040a55f89
commit 978722023f
6 files changed
+404 -158

No files matched your search

+2
View File
@@ -391,6 +391,8 @@ export const config = {
loeschDeckel: 2000, loeschDeckel: 2000,
// Alle Schritte der Aufbewahrungs-Loeschung eines Laufs zusammen loeschen hoechstens so viele Buchungen (Gesamtdeckel ueber alle Funktionen) // Alle Schritte der Aufbewahrungs-Loeschung eines Laufs zusammen loeschen hoechstens so viele Buchungen (Gesamtdeckel ueber alle Funktionen)
loeschDeckelGesamt: 20000, 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) // Mindestzeit zwischen Laden und Absenden des Buchungsformulars (schneller = Bot)
formMinSekunden: nonNegativeNumber(process.env.TERMINE_FORM_MIN_SECONDS, 3), formMinSekunden: nonNegativeNumber(process.env.TERMINE_FORM_MIN_SECONDS, 3),
// Kuerzeste Antwortzeit (ms) von POST /buchungen und den Widerrufs-POSTs der oeffentlichen Schnittstelle: verwischt, ob // Kuerzeste Antwortzeit (ms) von POST /buchungen und den Widerrufs-POSTs der oeffentlichen Schnittstelle: verwischt, ob
+9 -4
View File
@@ -47,6 +47,8 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js'
* und nichterschienen zusammen je Funktion), alle Schritte eines Laufs zusammen hoechstens config.termine.loeschDeckelGesamt; * 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 * 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. * 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 const MIN_MS = 60 * 1000
@@ -601,10 +603,13 @@ export async function taeglicherJob(now) {
gedeckelt: 0, gedeckelt: 0,
}) })
const aufgeraeumt = await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0) const aufgeraeumt = await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0)
const zellen = await schritt(lauf, 'Verwaiste Zellen', () => raeumeVerwaisteZellen(now), 0) const zellenErgebnis = await schritt(lauf, 'Verwaiste Zellen', () => raeumeVerwaisteZellen(now), { geloescht: 0, gedeckelt: false })
// gedeckelt: Schritte bzw. Funktionen, die am Deckel endeten (zellen fuellt ein spaeterer Task); getrennt vom Loeschzaehler, damit die Summe im Tagesbericht stimmt if (zellenErgebnis.gedeckelt) {
const gedeckelt = { loeschfristen: loeschfristenGedeckelt, abgeschaltet: abgeschaltetGedeckelt, zellen: 0 } 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`)
return { festgefahren, loeschfristen: loeschung, gedeckelt, abgeschaltet, aufgeraeumt, zellen, fehler: lauf.fehler } }
// 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 }
}) })
} }
+94 -58
View File
@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { config } from '../../config.js' 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 { withLocks } from '../keyedLock.js'
import { istBelegend } from './slots.js' import { istBelegend } from './slots.js'
import { pruefeNow, pruefeText, zeitMs } from './util.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 * 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. * 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 * Ob eine vorhandene Zelle noch gilt, entscheidet zuerst das Zeitfenster, danach ihre Buchung: eine Zelle, deren Ende mehr
* istBelegend in slots.js) oder ist die Haltung abgelaufen, ist die Zelle frei. Solche Zellen loescht belegeZellen erst, * als einen Tag zurueckliegt, ist ohne Buchungsabfrage ungueltig; bei allen anderen ist sie frei, wenn ihre Buchung fehlt,
* wenn sie eine Belegung blockieren (danach genau ein zweiter Versuch), der taegliche Job raeumt den Rest * nicht belegend ist (siehe istBelegend in slots.js) oder die Haltung abgelaufen ist. Solche Zellen loescht belegeZellen
* (raeumeVerwaisteZellen; verwaisteZellen nennt sie nur). * 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 * 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 * 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 * 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, * 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 * 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 * 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 const SPERRE_WARTE_MS = 5000
/** Aufraeumen darf laenger warten als eine Buchung: nach dem Ablauf liefe es ohne Sperre weiter (siehe withLocks). */ /** Aufraeumen darf laenger warten als eine Buchung: nach dem Ablauf liefe es ohne Sperre weiter (siehe withLocks). */
const AUFRAEUM_WARTE_MS = 15000 const AUFRAEUM_WARTE_MS = 15000
/** Zellen je Sperre beim Raeumen (haelt die Sperre kurz) und Wiederholungen, falls Zellen auf weiteren Ressourcen auftauchen. */ /** Wiederholungen beim Freigeben, falls Zellen auf weiteren Ressourcen auftauchen. */
const RAEUM_BLOCK = 100
const RAEUM_RUNDEN = 3 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_BLOCK = 50
const ID_SEITE = 100
const LOESCH_PARALLEL = 10 const LOESCH_PARALLEL = 10
/** Der taegliche Job liest bewusst alle Zellen; mehr als das waere ein Fehler. */ /** Zellen je Seite des Sweeps; zugleich die Zellen, die unter einer Sperre geloescht werden (haelt die Sperre kurz). */
const ALLE_ZELLEN_MAX = 100000 const SWEEP_SEITE = 100
const SWEEP_FELDER = ['$id', 'featureId', 'ressourceId', 'bookingId', 'bis']
const claims = () => config.collections.termineClaims const claims = () => config.collections.termineClaims
const buchungen = () => config.collections.termineBookings const buchungen = () => config.collections.termineBookings
@@ -83,23 +87,35 @@ export function zellenId(featureId, ressourceId, zelle) {
async function leseNachIds(collection, ids) { async function leseNachIds(collection, ids) {
const gefunden = [] const gefunden = []
for (let i = 0; i < ids.length; i += ID_BLOCK) { 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 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) { 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])) const vorhanden = new Map((await leseNachIds(buchungen(), buchungsIds)).map((buchung) => [buchung.$id, buchung]))
return zellen.filter((zelle) => { return [
const buchung = vorhanden.get(zelle.bookingId) ...alt,
return !buchung || !istBelegend(buchung, nowMs) ...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. */ /** Loescht Dokumente (404 = schon weg) und versucht alle. => { geloescht, fehler (der erste andere Fehler | null) } */
async function loesche(collection, ids) { async function loescheAlle(collection, ids) {
let geloescht = 0 let geloescht = 0
let fehler = null let fehler = null
for (let i = 0; i < ids.length; i += LOESCH_PARALLEL) { 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 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 if (fehler) throw fehler
return geloescht return geloescht
} }
@@ -212,59 +234,73 @@ export async function gibZellenFrei(bookingId, { behalte = [], ressourceId = nul
return geloescht 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. * Taeglicher Sweep: loescht Zellen, deren Ende mehr als einen Tag zurueckliegt (bis < jetzt - 1 Tag), unabhaengig von ihrer
* belegt nicht mehr (abgesagt, abgelehnt, verfallen, erledigt, nichterschienen, abgelaufene Haltung). Nur Bericht: * Buchung (die Buchungen werden nicht gelesen). Seiten zu 100 Zellen ueber idx_bis (aelteste zuerst), nach jeder Seite ohne
* zum Loeschen NICHT diese Liste benutzen (sie veraltet), sondern raeumeVerwaisteZellen. * 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) { export async function raeumeVerwaisteZellen(now, { deckel = config.termine.zellenDeckel } = {}) {
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) {
const nowMs = pruefeNow(now, 'raeumeVerwaisteZellen') const nowMs = pruefeNow(now, 'raeumeVerwaisteZellen')
const nachSperre = new Map() if (!Number.isInteger(deckel) || deckel < 0) throw new TypeError(`raeumeVerwaisteZellen: deckel muss eine ganze Zahl ab 0 sein, nicht ${deckel}`)
for (const zelle of await verwaisteAus(await alleZellen(), nowMs)) { const grenze = new Date(nowMs - TAG_MS).toISOString()
const schluessel = sperrSchluessel(zelle.featureId, zelle.ressourceId) const versucht = new Set()
nachSperre.set(schluessel, [...(nachSperre.get(schluessel) ?? []), zelle])
}
let geloescht = 0 let geloescht = 0
let gedeckelt = false
let fehler = null let fehler = null
for (const [schluessel, liste] of nachSperre) { for (;;) {
for (let i = 0; i < liste.length; i += RAEUM_BLOCK) { let seite
const block = new Map(liste.slice(i, i + RAEUM_BLOCK).map((zelle) => [zelle.$id, zelle.bookingId])) 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 { try {
geloescht += await withLocks( const erg = await withLocks(
[schluessel], [schluessel],
async () => { async () => {
const aktuell = (await leseNachIds(claims(), [...block.keys()])).filter((zelle) => block.get(zelle.$id) === zelle.bookingId) const aktuell = (await leseNachIds(claims(), [...gelesen.keys()])).filter(
return loesche(claims(), (await verwaisteAus(aktuell, nowMs)).map((zelle) => zelle.$id)) (zelle) => gelesen.get(zelle.$id) === zelle.bookingId && istZuAlt(zelle, nowMs)
)
return loescheAlle(claims(), aktuell.map((zelle) => zelle.$id))
}, },
{ waitMs: AUFRAEUM_WARTE_MS } { waitMs: AUFRAEUM_WARTE_MS }
) )
geloescht += erg.geloescht
fehler ??= erg.fehler
} catch (err) { } catch (err) {
fehler ??= err fehler ??= err
} }
} }
if (geloescht === vorher) break
} }
if (fehler) throw fehler if (fehler) throw fehler
return geloescht return { geloescht, gedeckelt }
} }
+69 -4
View File
@@ -51,6 +51,7 @@ const ABSENDER_VORHER = config.smtp.from
const MODUS_VORHER = config.termine.mode const MODUS_VORHER = config.termine.mode
const DECKEL_VORHER = config.termine.loeschDeckel const DECKEL_VORHER = config.termine.loeschDeckel
const GESAMT_DECKEL_VORHER = config.termine.loeschDeckelGesamt const GESAMT_DECKEL_VORHER = config.termine.loeschDeckelGesamt
const ZELLEN_DECKEL_VORHER = config.termine.zellenDeckel
const BASIS_VORHER = config.publicBaseUrl const BASIS_VORHER = config.publicBaseUrl
const iso = (wert) => new Date(wert).toISOString() const iso = (wert) => new Date(wert).toISOString()
@@ -134,6 +135,7 @@ afterEach(async () => {
config.termine.mode = MODUS_VORHER config.termine.mode = MODUS_VORHER
config.termine.loeschDeckel = DECKEL_VORHER config.termine.loeschDeckel = DECKEL_VORHER
config.termine.loeschDeckelGesamt = GESAMT_DECKEL_VORHER config.termine.loeschDeckelGesamt = GESAMT_DECKEL_VORHER
config.termine.zellenDeckel = ZELLEN_DECKEL_VORHER
config.smtp.from = ABSENDER_VORHER config.smtp.from = ABSENDER_VORHER
config.publicBaseUrl = BASIS_VORHER config.publicBaseUrl = BASIS_VORHER
assert.doesNotMatch(text, /Mustermann|max@example\.com/i, 'kein Gastname und keine Gastadresse im Log') 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 () => { 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) 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) 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)) }) 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 let erg
const zaehler = await mitScheiternderLoeschung( 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_erledigt', { status: 'erledigt', ende: plus(JETZT, -100 * TAG) })
await legeBuchung('b_zukunft', { start: plus(JETZT, 5 * TAG) }) await legeBuchung('b_zukunft', { start: plus(JETZT, 5 * TAG) })
await legeBuchung('b_abgeschaltet', { featureId: F2, start: plus(JETZT, 5 * TAG) }) await legeBuchung('b_abgeschaltet', { featureId: F2, start: plus(JETZT, 5 * TAG) })
// verwaiste Zelle: Buchung fehlt // 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, 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 })
legeEintrag('o_gesendet', { status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: iso(plus(JETZT, -MIN)) }) legeEintrag('o_gesendet', { status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: iso(plus(JETZT, -MIN)) })
legeEintrag('o_festgefahren', { createdAt: iso(plus(JETZT, -3 * TAG)) }) 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) 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 () => { 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 }) 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 aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 1 }) // 1. GET = Funktionen der Löschfristen, 2. = abgeschaltete
+1
View File
@@ -49,6 +49,7 @@ test('Konfiguration: Vorgaben ohne Umgebungsvariablen', async () => {
mailsProStunde: 200, mailsProStunde: 200,
loeschDeckel: 2000, loeschDeckel: 2000,
loeschDeckelGesamt: 20000, loeschDeckelGesamt: 20000,
zellenDeckel: 5000,
formMinSekunden: 3, formMinSekunden: 3,
minAntwortMs: 300, minAntwortMs: 300,
}) })
+229 -92
View File
@@ -19,7 +19,7 @@ useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js') const { config } = await import('../server/config.js')
const { deleteDocument, listDocuments } = await import('../server/services/appwriteAdmin.js') const { deleteDocument, listDocuments } = await import('../server/services/appwriteAdmin.js')
const { withLocks } = await import('../server/services/keyedLock.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( const { neuesToken, hashToken, verwaltungsToken, buchungsNummer, formToken, pruefeFormToken } = await import(
'../server/services/termine/token.js' '../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) assert.equal(claimsOf('b_X').length, 0)
}) })
test('verwaisteZellen: Buchung fehlt, nicht mehr belegend oder Zelle laenger als einen Tag vorbei', async () => { // ---------------------------------------------------------------- Zellen-Sweep (raeumeVerwaisteZellen) und Zeitfenster
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 mit eigener ID in der Datenbank anlegen (ohne belegeZellen, damit auch ungueltige Altlasten entstehen). */ /** Zellen mit eigener ID in der Datenbank anlegen (ohne belegeZellen, damit auch ungueltige Altlasten entstehen). */
function seedZelle(id, bookingId, bis, extra = {}) { 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) const bald = new Date(JETZT.getTime() + 2 * TAG)
seedBuchung('b_ok') seedBuchung('b_ok')
seedBuchung('b_abgesagt', { status: 'abgesagt' }) seedBuchung('b_abgesagt', { status: 'abgesagt' })
seedBuchung('b_haltung_alt', { status: 'gehalten', heldUntil: iso(new Date(JETZT.getTime() - MIN)) }) 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_ok', 'b_ok', bald)
seedZelle('c_abgesagt', 'b_abgesagt', bald, { ressourceId: 'r_aaaaaaa2' }) 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_fehlt', 'b_gibt_es_nicht', bald, { featureId: 'f_feature02' })
seedZelle('c_alt', 'b_alt', new Date(JETZT.getTime() - TAG - MIN)) // Ende mehr als einen Tag vorbei: weg, egal was die Buchung sagt
assert.equal(await raeumeVerwaisteZellen(JETZT), 4) seedZelle('c_alt_ok', 'b_ok', alt(1000))
assert.deepEqual([...aw.docs('termineClaims').keys()], ['c_ok']) seedZelle('c_alt_abgesagt', 'b_abgesagt', alt(2000), { ressourceId: 'r_aaaaaaa2' })
assert.equal(await raeumeVerwaisteZellen(JETZT), 0) seedZelle('c_alt_haltung', 'b_haltung_alt', alt(3000))
await assert.rejects(raeumeVerwaisteZellen(undefined), TypeError) 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) { 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') seedBuchung('b_ok')
seedZelle('c_ok', 'b_ok', T('23:00')) 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']) 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' }) 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') seedBuchung('b_W')
await mitVerzoegertemLoeschen(10, 400, async () => { await mitVerzoegertemLoeschen(10, 400, async () => {
const lauf = raeumeVerwaisteZellen(JETZT) const lauf = raeumeVerwaisteZellen(JETZT)
await schlafe(20) await schlafe(20)
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_W', start: T('10:00'), blockEnde: T('10:50'), now: JETZT }) 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_W').length, 10, 'W behaelt alle Zellen')
assert.equal(claimsOf('b_X').length, 0) 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) 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') seedBuchung('b_W')
seedZelle('c_x1', 'b_X1', bald) seedZelle('c_x1', 'b_X1', alt(1000)) // unveraendert: weg
seedZelle('c_x2', 'b_X2', bald) seedZelle('c_x2', 'b_X2', alt(2000)) // wird einer neuen Buchung mit kuenftigem Ende
seedZelle('c_x3', 'b_X3', bald) seedZelle('c_x3', 'b_X3', alt(3000)) // ist inzwischen schon weg
seedZelle('c_x4', 'b_X1', bald) 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() const sperre = haltePerSperre()
aw.resetListCalls()
const lauf = raeumeVerwaisteZellen(JETZT) const lauf = raeumeVerwaisteZellen(JETZT)
await schlafe(100) // der Lauf hat die Kandidaten gelesen und wartet auf die Sperre await schlafe(100) // der Lauf hat die Seite gelesen und wartet auf die Sperre
seedBuchung('b_X1', { status: 'bestaetigt' }) // wieder gueltig const zelle = (id) => aw.docs('termineClaims').get(id)
aw.seed('termineClaims', { ...aw.docs('termineClaims').get('c_x2'), bookingId: 'b_W' }) // neu vergeben aw.seed('termineClaims', { ...zelle('c_x2'), bookingId: 'b_W', bis: iso(bald) })
await deleteDocument('termineClaims', 'c_x3') // schon weg await deleteDocument('termineClaims', 'c_x3')
aw.seed('termineClaims', { ...aw.docs('termineClaims').get('c_x4'), bookingId: 'b_neu_ohne_buchung' }) // neu vergeben, Buchung folgt erst noch 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() sperre.freigeben()
await sperre.fertig await sperre.fertig
assert.equal(await lauf, 0) assert.deepEqual(await lauf, { geloescht: 1, gedeckelt: false })
assert.deepEqual([...aw.docs('termineClaims').keys()].sort(), ['c_x1', 'c_x2', 'c_x4']) assert.deepEqual([...aw.docs('termineClaims').keys()].sort(), ['c_x2', 'c_x4', 'c_x5', 'c_x6'])
assert.equal(aw.docs('termineClaims').get('c_x2').bookingId, 'b_W') assert.equal(zelle('c_x2').bookingId, 'b_W')
assert.equal(aw.docs('termineClaims').get('c_x4').bookingId, 'b_neu_ohne_buchung') 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 () => { test('Zellen-Sweep: eine Waise mit kuenftigem Ende bleibt stehen, blockiert aber keine Neubuchung', async () => {
seedZelle('c_a1', 'b_nie1', T('23:00')) seedBuchung('b_abgesagt', { status: 'abgesagt' })
seedZelle('c_b1', 'b_nie2', T('23:00'), { ressourceId: 'r_aaaaaaa2' }) 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 }) aw.failNext({ collection: 'termineClaims', method: 'DELETE', status: 503 })
await assert.rejects(raeumeVerwaisteZellen(JETZT), (err) => err.status === 500) await assert.rejects(raeumeVerwaisteZellen(JETZT), (err) => err.status === 500)
assert.equal(aw.docs('termineClaims').size, 1, 'die andere Ressource ist trotzdem geraeumt') 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 // ---------------------------------------------------------------- Buchungsspeicher