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

+69 -4
View File
@@ -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
+1
View File
@@ -49,6 +49,7 @@ test('Konfiguration: Vorgaben ohne Umgebungsvariablen', async () => {
mailsProStunde: 200,
loeschDeckel: 2000,
loeschDeckelGesamt: 20000,
zellenDeckel: 5000,
formMinSekunden: 3,
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 { 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