From 36b94c3035778b80113d0135146567f42eeba854 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 10:13:26 +0000 Subject: [PATCH] Terminplaner: Mail-Warteschlange begrenzt und nach Vorrang geordnet verarbeite liest nicht mehr alle wartenden Eintraege, sondern je Klasse (vorrang, normal, nachrangig) eine begrenzte Liste (config.termine.mailListe, Standard 100) mit select und orderAsc(naechsterVersuch). Bei Pause oder leerem Gesamtbudget endet der Lauf ohne Abfrage, bei erreichter Reserve entfaellt die nachrangige Klasse. Funktionen mit ausgeschoepftem Kontingent kommen in eine Ausschlussliste (hoechstens 90 Werte); hoechstens vier weitere Runden je Klasse, damit keine Funktion hinter einer vollen verhungert. Co-Authored-By: Claude Sonnet 5.5 --- server/config.js | 2 + server/services/termine/outbox.js | 113 ++++++++++++++---- test/termineOutbox.test.js | 188 ++++++++++++++++++++++++++++-- test/termineSchalter.test.js | 1 + 4 files changed, 275 insertions(+), 29 deletions(-) diff --git a/server/config.js b/server/config.js index e1921d0..2e6096e 100644 --- a/server/config.js +++ b/server/config.js @@ -391,6 +391,8 @@ export const config = { // Anteil der Stunde, der Vorrang- und Normal-Mails vorbehalten bleibt: nachrangige Mails (G05, K20, K21) nehmen hoechstens // max(1, floor(mailsProStunde x (1 - mailsReserve))) Plaetze mailsReserve: 0.25, + // Eintraege je Abfrage der Mail-Warteschlange (verarbeite, Minutenjob): hoechstens so viele fuer eine Klasse und Runde + mailListe: 100, // Hoechstens so viele Buchungen loescht ein Schritt der Aufbewahrungs-Loeschung je Lauf (Tagesjob): je Status, bei erledigt und // nichterschienen zusammen je Funktion; der Rest folgt am naechsten Tag loeschDeckel: 2000, diff --git a/server/services/termine/outbox.js b/server/services/termine/outbox.js index 628dd80..60277b2 100644 --- a/server/services/termine/outbox.js +++ b/server/services/termine/outbox.js @@ -1,5 +1,5 @@ import { config } from '../../config.js' -import { Query, createDocument, deleteDocument, getDocument, listAllDocuments, updateDocument } from '../appwriteAdmin.js' +import { Query, createDocument, deleteDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' import { sendGuestMail } from '../mailer.js' import { renderMail } from '../mailTemplates.js' @@ -34,6 +34,12 @@ import { pruefeNow, pruefeText, zeitMs } from './util.js' * reserve nachrangige Mails (G05, K20, K21) im Fenster >= max(1, floor(mailsProStunde x (1 - mailsReserve))): so bleibt * ein Teil der Stunde den Vorrang-Mails (G01, G02, G08) und den normalen Mails vorbehalten * kontingent Mails dieser Funktion im Fenster >= mailsProFunktion: eine Funktion kann die Stunde nicht allein fuellen + * + * Minutenjob (verarbeite): liest nie alle wartenden Eintraege. Bei Pause oder leerem Gesamtbudget endet er ohne Abfrage. + * Sonst je Klasse (mailKlasse) in der Reihenfolge vorrang, normal, nachrangig eine begrenzte Liste (config.termine.mailListe, + * nur die Felder $id, featureId, art, naechsterVersuch, nie ein Mailtext), die faelligsten zuerst. Eine Funktion mit + * ausgeschoepftem Kontingent kommt in die Ausschlussliste; ist die Seite voll und hat sich etwas bewegt, folgen hoechstens + * vier weitere Runden mit der neuen Ausschlussliste, damit hinter einer vollen Funktion keine andere verhungert. */ const col = () => config.collections.termineOutbox @@ -81,6 +87,9 @@ const imFenster = (nowMs) => plaetze.filter((platz) => platz.t > nowMs - STUNDE_ /** Hoechstens so viele nachrangige Mails (G05, K20, K21) im Stundenfenster; die Toleranz faengt Fliesskommafehler wie 100 x 0.58 ab. */ const nachrangigMax = () => Math.max(1, Math.floor(config.termine.mailsProStunde * (1 - config.termine.mailsReserve) + 1e-9)) +/** Sind die nachrangigen Plaetze (G05, K20, K21) im uebergebenen Fenster aufgebraucht? */ +const reserveErreicht = (fenster) => fenster.filter((platz) => platz.n).length >= nachrangigMax() + /** 'pause' | 'budget' | null: die Bremsen, die fuer jede Mail gelten (nur lesen). */ function gesamtBremse(nowMs) { if (pauseBisMs > nowMs) return 'pause' @@ -100,7 +109,7 @@ export function pruefePlatz(nowMs, featureId, art) { const bremse = gesamtBremse(nowMs) if (bremse) return bremse const fenster = imFenster(nowMs) - if (mailKlasse(art) === 'nachrangig' && fenster.filter((platz) => platz.n).length >= nachrangigMax()) return 'reserve' + if (mailKlasse(art) === 'nachrangig' && reserveErreicht(fenster)) return 'reserve' if (fenster.filter((platz) => platz.f === featureId).length >= config.termine.mailsProFunktion) return 'kontingent' return null } @@ -317,31 +326,93 @@ async function speichereFehlschlag(doc, err, nowMs) { return 'fehler' } +/** Klassen in der Reihenfolge, in der verarbeite sie bedient. */ +const KLASSEN = ['vorrang', 'normal', 'nachrangig'] +/** Hoechstens so viele Abfragen je Klasse und Lauf: die erste und bis zu vier weitere Runden mit neuer Ausschlussliste. */ +const RUNDEN_MAX = 5 +/** Hoechstens so viele Funktionen in der Ausschlussliste einer Abfrage (Appwrite erlaubt hoechstens 100 Werte je Abfrage). */ +const AUSSCHLUSS_MAX = 90 +/** Die Felder der Liste: genug fuer pruefePlatz und sendeEintrag, nie ein Mailtext. */ +const LISTENFELDER = ['$id', 'featureId', 'art', 'naechsterVersuch'] + +/** Filter auf die Art der Mail: die Arten der Klasse (equal) bzw. alle anderen (notEqual, die Klasse normal). */ +function klassenFilter(klasse) { + if (klasse === 'vorrang') return Query.equal('art', [...MAIL_VORRANG]) + if (klasse === 'nachrangig') return Query.equal('art', [...MAIL_NACHRANGIG]) + return Query.notEqual('art', [...MAIL_VORRANG, ...MAIL_NACHRANGIG]) +} + +/** Eine Runde der Klasse: faellige wartende Eintraege, fuer die Funktionen ohne Kontingent nicht mitgelesen werden. */ +function listenAbfrage(klasse, nowMs, blockiert) { + return [ + Query.equal('status', 'wartet'), + Query.lessThanEqual('naechsterVersuch', iso(nowMs)), + klassenFilter(klasse), + ...(blockiert.size > 0 ? [Query.notEqual('featureId', [...blockiert].slice(0, AUSSCHLUSS_MAX))] : []), + Query.orderAsc('naechsterVersuch'), + Query.limit(config.termine.mailListe), + Query.select(LISTENFELDER), + ] +} + /** - * Minutenjob: sendet alle faelligen wartenden Eintraege, aelteste zuerst, bis Pause oder Stundenbudget bremsen; Eintraege, die - * Vorrang-Reserve oder Kontingent ihrer Funktion bremsen, bleiben `wartet` und die uebrigen kommen trotzdem dran. - * Fehler bei einzelnen Eintraegen (z. B. Appwrite) stehen im Log und stoppen den Lauf nicht. + * Eine Klasse bedienen (siehe Kopfkommentar). Zaehler und Ausschlussliste des Laufs stehen in `lauf`. + * @returns {Promise} false, wenn Pause oder Gesamtbudget den ganzen Lauf beenden + */ +async function verarbeiteKlasse(klasse, nowMs, now, lauf) { + for (let runde = 0; runde < RUNDEN_MAX; runde++) { + if (gesamtBremse(nowMs)) return false + if (klasse === 'nachrangig' && reserveErreicht(imFenster(nowMs))) return true + const seite = await listDocuments(col(), listenAbfrage(klasse, nowMs, lauf.blockiert)) + lauf.faellig += seite.length + let bewegt = false + for (const eintrag of seite) { + if (lauf.versucht.has(eintrag.$id)) continue + // Pause und leeres Gesamtbudget beenden den Lauf, die Reserve die nachrangige Klasse; das Kontingent betrifft nur die Funktion + const grund = pruefePlatz(nowMs, eintrag.featureId, eintrag.art) + if (grund === 'pause' || grund === 'budget') return false + if (grund === 'reserve') return true + if (grund === 'kontingent') { + if (!lauf.blockiert.has(eintrag.featureId)) { + lauf.blockiert.add(eintrag.featureId) + bewegt = true + } + continue + } + lauf.versucht.add(eintrag.$id) + try { + if ((await sendeEintrag(eintrag.$id, now)) === 'gesendet') { + lauf.gesendet += 1 + bewegt = true + } + } catch (err) { + console.error(`[termine-outbox] Eintrag ${eintrag.$id}: ${err.message}`) + } + } + // Weiter nur, wenn die Seite voll war (es kann mehr geben) und sich etwas bewegt hat; sonst kaeme dieselbe Seite wieder + if (seite.length < config.termine.mailListe || !bewegt) return true + } + return true +} + +/** + * Minutenjob: sendet faellige wartende Eintraege, Vorrang-Mails (G01, G02, G08) vor normalen vor nachrangigen (G05, K20, K21), + * in jeder Klasse die faelligsten zuerst, bis Pause oder Stundenbudget bremsen. Er liest begrenzte Listen statt aller Eintraege + * (siehe Kopfkommentar); Eintraege, die Vorrang-Reserve oder Kontingent ihrer Funktion bremsen, bleiben `wartet`, und die uebrigen + * kommen trotzdem dran. Fehler bei einzelnen Eintraegen (z. B. Appwrite) stehen im Log und stoppen den Lauf nicht. * @param {Date} now - * @returns {Promise<{ faellig: number, gesendet: number }>} + * @returns {Promise<{ faellig: number, gesendet: number }>} faellig = die gelesenen Eintraege (0 bei Pause oder leerem Budget) */ export async function verarbeite(now) { const nowMs = pruefeNow(now, 'verarbeite') - if (pauseBisMs > nowMs) return { faellig: 0, gesendet: 0 } - const faellig = await listAllDocuments(col(), [Query.equal('status', 'wartet'), Query.lessThanEqual('naechsterVersuch', iso(nowMs))]) - faellig.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt) || (a.$id < b.$id ? -1 : 1)) - let gesendet = 0 - for (const eintrag of faellig) { - // Pause und leeres Gesamtbudget beenden den Lauf; Reserve und Kontingent betreffen nur diesen Eintrag (ohne Lesen und Sperre) - const grund = pruefePlatz(nowMs, eintrag.featureId, eintrag.art) - if (grund === 'pause' || grund === 'budget') break - if (grund) continue - try { - if ((await sendeEintrag(eintrag.$id, now)) === 'gesendet') gesendet += 1 - } catch (err) { - console.error(`[termine-outbox] Eintrag ${eintrag.$id}: ${err.message}`) - } + // Fruehausstieg: ohne Pause und mit freiem Gesamtbudget lohnt sich das Lesen, sonst nicht + if (gesamtBremse(nowMs)) return { faellig: 0, gesendet: 0 } + // blockiert: Funktionen mit ausgeschoepftem Kontingent (gilt fuer alle Klassen); versucht: Eintraege, die dieser Lauf schon angefasst hat + const lauf = { faellig: 0, gesendet: 0, blockiert: new Set(), versucht: new Set() } + for (const klasse of KLASSEN) { + if (!(await verarbeiteKlasse(klasse, nowMs, now, lauf))) break } - return { faellig: faellig.length, gesendet } + return { faellig: lauf.faellig, gesendet: lauf.gesendet } } /** Nur fuer Tests: wartet, bis alle laufenden Sendeversuche (auch die aus einreihen) beendet sind. */ diff --git a/test/termineOutbox.test.js b/test/termineOutbox.test.js index 344bddd..847d06b 100644 --- a/test/termineOutbox.test.js +++ b/test/termineOutbox.test.js @@ -69,6 +69,7 @@ beforeEach(async () => { config.termine.mailsProStunde = 200 config.termine.mailsProFunktion = 40 config.termine.mailsReserve = 0.25 + config.termine.mailListe = 100 tr = fakeTransport() setMailTransportForTests(tr) // Fehler-/Warnlogs der Warteschlange abfangen (Testausgabe bleibt sauber, Logs sind pruefbar) @@ -80,6 +81,7 @@ afterEach(async () => { config.termine.mailsProStunde = 200 config.termine.mailsProFunktion = 40 config.termine.mailsReserve = 0.25 + config.termine.mailListe = 100 config.smtp.from = ABSENDER_VORHER }) @@ -220,7 +222,7 @@ test('sendeEintrag meldet das Ergebnis: gesendet, uebersprungen (unbekannt oder assert.equal(tr.aufrufe, 1) }) -test('verarbeite sendet nur fällige wartet-Einträge, älteste zuerst; gesendet und fehler bleiben unberührt', async () => { +test('verarbeite sendet nur fällige wartet-Einträge, fälligste zuerst (naechsterVersuch, nicht createdAt); gesendet und fehler bleiben unberührt', async () => { const faellig = await legeAn({ bookingId: 'b_faellig', an: 'faellig@example.com', naechsterVersuch: plus(-5 * MIN).toISOString(), createdAt: plus(-9 * MIN).toISOString() }) const aelter = await legeAn({ bookingId: 'b_aelter', an: 'aelter@example.com', naechsterVersuch: plus(-1 * MIN).toISOString(), createdAt: plus(-30 * MIN).toISOString() }) const spaeter = await legeAn({ bookingId: 'b_spaeter', naechsterVersuch: plus(MIN).toISOString() }) @@ -229,7 +231,7 @@ test('verarbeite sendet nur fällige wartet-Einträge, älteste zuerst; gesendet const ergebnis = await verarbeite(JETZT) assert.deepEqual(ergebnis, { faellig: 2, gesendet: 2 }) assert.equal(tr.aufrufe, 2) - assert.deepEqual(tr.senke.sent.map((m) => m.to[0].address), ['aelter@example.com', 'faellig@example.com'], 'älteste zuerst') + assert.deepEqual(tr.senke.sent.map((m) => m.to[0].address), ['faellig@example.com', 'aelter@example.com'], 'fälligste zuerst: naechsterVersuch -5 min vor -1 min, obwohl der andere Eintrag älter ist (createdAt)') assert.equal((await lies(faellig.$id)).status, 'gesendet') assert.equal((await lies(aelter.$id)).status, 'gesendet') assert.equal((await lies(spaeter.$id)).status, 'wartet') @@ -238,7 +240,7 @@ test('verarbeite sendet nur fällige wartet-Einträge, älteste zuerst; gesendet await assert.rejects(() => verarbeite('jetzt'), TypeError) }) -test('verarbeite liest alle Seiten (mehr als 25 fällige Einträge)', async () => { +test('verarbeite liest mit eigenem limit mehr als die 25 der Standardseite (30 fällige Einträge)', async () => { for (let i = 0; i < 30; i++) await legeAn({ bookingId: `b_${i}` }) const ergebnis = await verarbeite(JETZT) assert.deepEqual(ergebnis, { faellig: 30, gesendet: 30 }) @@ -260,7 +262,7 @@ test('Stundenbudget 2: der dritte Eintrag wartet (unverändert, ohne Versuch) un assert.equal(wartend.versuche, 0) assert.ok(wartend.an && wartend.html, 'Inhalt bleibt erhalten') - assert.deepEqual(await verarbeite(plus(59 * MIN)), { faellig: 1, gesendet: 0 }) + assert.deepEqual(await verarbeite(plus(59 * MIN)), { faellig: 0, gesendet: 0 }, 'Frühausstieg: das Budget ist voll, der wartende Eintrag wird nicht einmal gelesen') assert.equal(tr.senke.sent.length, 2, 'innerhalb der Stunde bleibt es bei zwei Mails') assert.deepEqual(await verarbeite(plus(61 * MIN)), { faellig: 1, gesendet: 1 }) assert.equal(tr.senke.sent.length, 3) @@ -337,10 +339,10 @@ test('Kontingent je Funktion: die Funktion A bekommt 3 von 5 Mails, B ist unber assert.equal(alle().filter((d) => d.status === 'wartet').length, 0) }) -test('verarbeite überspringt Einträge einer ausgeschöpften Funktion und bedient die Funktionen dahinter (älteste zuerst)', async () => { +test('verarbeite überspringt Einträge einer ausgeschöpften Funktion und bedient die Funktionen dahinter (fälligste zuerst)', async () => { config.termine.mailsProFunktion = 2 - for (let i = 0; i < 4; i++) await legeAn({ featureId: 'f_A', art: 'G02', bookingId: `b_a${i}`, createdAt: plus(i * 1000).toISOString() }) - for (let i = 0; i < 2; i++) await legeAn({ featureId: 'f_B', art: 'G02', bookingId: `b_b${i}`, createdAt: plus(10000 + i * 1000).toISOString() }) + for (let i = 0; i < 4; i++) await legeAn({ featureId: 'f_A', art: 'G02', bookingId: `b_a${i}`, naechsterVersuch: plus(-60000 + i * 1000).toISOString() }) + for (let i = 0; i < 2; i++) await legeAn({ featureId: 'f_B', art: 'G02', bookingId: `b_b${i}`, naechsterVersuch: plus(-50000 + i * 1000).toISOString() }) assert.deepEqual(await verarbeite(plus(MIN)), { faellig: 6, gesendet: 4 }) const gesendet = alle().filter((d) => d.status === 'gesendet') assert.deepEqual(gesendet.map((d) => d.bookingId).sort(), ['b_a0', 'b_a1', 'b_b0', 'b_b1']) @@ -360,7 +362,7 @@ test('Vorrang-Reserve: nachrangige Mails (G05, K20, K21) nehmen höchstens 6 von await warteAufVersand() assert.equal(tr.senke.sent.length, 7, 'Platz 7 geht an den Vorrang') - assert.deepEqual(await verarbeite(plus(2 * MIN)), { faellig: 4, gesendet: 0 }, 'das siebte G05 wartet, obwohl noch ein Platz frei ist') + assert.deepEqual(await verarbeite(plus(2 * MIN)), { faellig: 0, gesendet: 0 }, 'das siebte G05 wartet, obwohl noch ein Platz frei ist (Reserve erreicht: die nachrangigen werden nicht einmal gelesen)') assert.equal(tr.senke.sent.length, 7) assert.ok(alle().filter((d) => d.status === 'wartet').every((d) => d.versuche === 0 && d.html)) @@ -499,6 +501,176 @@ test('Reserve: mit sehr kleinem Stundenbudget darf mindestens eine nachrangige M assert.equal(mailBudget(JETZT).nachrangig.max, 1, 'floor(3 × 0.1) = 0 ⇒ mindestens 1') }) +// --------------------------------------------------------------------------------------------------------------------- +// Minutenjob: begrenzte, nach Klasse geordnete Liste (verarbeite) +// --------------------------------------------------------------------------------------------------------------------- + +const listenAbfragen = () => aw.listCalls('termineOutbox') +const abfrageKlasse = ({ queries }) => { + const art = queries.find((q) => q.attribute === 'art') + if (art.method === 'notEqual') return 'normal' + return art.values.includes('G01') ? 'vorrang' : 'nachrangig' +} +const abfrageKlassen = () => listenAbfragen().map(abfrageKlasse) +const ausschluss = ({ queries }) => queries.find((q) => q.method === 'notEqual' && q.attribute === 'featureId')?.values ?? null +const begrenzteLeseabfragen = (abfragen) => { + for (const abfrage of abfragen) { + const limit = abfrage.queries.find((q) => q.method === 'limit') + assert.ok(limit && limit.values[0] <= 100, `limit höchstens 100: ${JSON.stringify(abfrage.queries)}`) + assert.ok(!abfrage.queries.some((q) => q.method === 'cursorAfter' || (q.method === 'orderAsc' && q.attribute === '$id')), 'kein $id-Seitenlauf') + assert.deepEqual(abfrage.queries.find((q) => q.method === 'select')?.values, ['$id', 'featureId', 'art', 'naechsterVersuch'], 'nur die vier Felder') + assert.ok(abfrage.queries.some((q) => q.method === 'orderAsc' && q.attribute === 'naechsterVersuch'), 'fälligste zuerst') + assert.ok(!JSON.stringify(abfrage.queries).includes('html'), 'kein Mailtext in der Abfrage') + assert.ok(abfrage.queries.some((q) => q.method === 'equal' && q.attribute === 'status' && q.values[0] === 'wartet')) + assert.ok(abfrage.queries.some((q) => q.method === 'lessThanEqual' && q.attribute === 'naechsterVersuch')) + } +} +/** Alle Einträge löschen und den Prozesszustand zurücksetzen (mehrere Szenarien in einem Test). */ +async function leereWarteschlange() { + for (const id of aw.docs('termineOutbox').keys()) await deleteDocument(col(), id) + resetOutboxForTests() + tr = fakeTransport() + setMailTransportForTests(tr) +} +const empfaenger = () => tr.senke.sent.map((m) => m.to[0].address) + +test('Frühausstieg: bei leerem Gesamtbudget liest verarbeite nichts (keine Listenabfrage an termineOutbox)', async () => { + config.termine.mailsProStunde = 2 + for (const nummer of [1, 2]) await einreihen(eintrag({ bookingId: `b_${nummer}` }), JETZT) + await warteAufVersand() + await legeAn({ art: 'G02', bookingId: 'b_wartet', naechsterVersuch: plus(-MIN).toISOString() }) + aw.resetListCalls() + assert.deepEqual(await verarbeite(plus(MIN)), { faellig: 0, gesendet: 0 }) + assert.deepEqual(await verarbeite(plus(59 * MIN)), { faellig: 0, gesendet: 0 }) + assert.equal(listenAbfragen().length, 0, 'Budget voll: nicht eine Abfrage') + assert.equal(tr.senke.sent.length, 2) + + assert.deepEqual(await verarbeite(plus(61 * MIN)), { faellig: 1, gesendet: 1 }, 'nach einer Stunde wird gelesen und gesendet') + assert.ok(listenAbfragen().length > 0) +}) + +test('Frühausstieg: während der Anmeldepause liest verarbeite nichts, danach läuft es weiter', async () => { + tr.fehler.push(EAUTH) + await einreihen(eintrag(), JETZT) // Anmeldefehler: 15 Minuten Pause, der Eintrag bleibt wartet + await warteAufVersand() + assert.equal(pausiertBis().getTime(), plus(15 * MIN).getTime()) + await legeAn({ art: 'G02', bookingId: 'b_wartet' }) + aw.resetListCalls() + assert.deepEqual(await verarbeite(plus(MIN)), { faellig: 0, gesendet: 0 }) + assert.equal(listenAbfragen().length, 0, 'Pause: nicht eine Abfrage') + assert.deepEqual(await verarbeite(plus(16 * MIN)), { faellig: 2, gesendet: 2 }) +}) + +test('begrenzte Liste: 250 fällige G02 aus 3 Funktionen ⇒ jede Abfrage limit ≤ 100, ohne $id-Seitenlauf, mit select; gesendet wird bis zum Kontingent', async () => { + const funktionen = ['f_A', 'f_B', 'f_C'] + for (let i = 0; i < 250; i++) { + await legeAn({ featureId: funktionen[i % 3], art: 'G02', bookingId: `b_${i}`, naechsterVersuch: plus(-3600000 + i * 1000).toISOString() }) + } + aw.resetListCalls() + const ergebnis = await verarbeite(JETZT) + assert.deepEqual(ergebnis, { faellig: 200, gesendet: 120 }, 'zwei volle Seiten gelesen; je Funktion höchstens 40 (Kontingent)') + begrenzteLeseabfragen(listenAbfragen()) + assert.deepEqual(abfrageKlassen(), ['vorrang', 'vorrang', 'vorrang', 'normal', 'nachrangig']) + for (const f of funktionen) assert.equal(alle().filter((d) => d.featureId === f && d.status === 'gesendet').length, 40) + assert.equal(alle().filter((d) => d.status === 'wartet').length, 130) + assert.ok(alle().filter((d) => d.status === 'wartet').every((d) => d.versuche === 0 && d.html), 'gebremste Einträge bleiben unverändert') + assert.equal(tr.aufrufe, 120) +}) + +test('Reihenfolge: Vorrang vor normal vor nachrangig, innerhalb einer Klasse die fälligste zuerst (Budget 1 bis 4)', async () => { + const lege = async () => { + await legeAn({ art: 'G05', an: 'g05@example.com', bookingId: 'b_g05', naechsterVersuch: plus(-10 * MIN).toISOString(), createdAt: plus(-10 * MIN).toISOString() }) + await legeAn({ art: 'G03', an: 'g03a@example.com', bookingId: 'b_g03a', naechsterVersuch: plus(-3 * MIN).toISOString(), createdAt: plus(-60 * MIN).toISOString() }) + await legeAn({ art: 'G03', an: 'g03b@example.com', bookingId: 'b_g03b', naechsterVersuch: plus(-7 * MIN).toISOString(), createdAt: plus(-MIN).toISOString() }) + await legeAn({ art: 'G01', an: 'g01@example.com', bookingId: 'b_g01', naechsterVersuch: plus(-MIN).toISOString(), createdAt: plus(-30 * MIN).toISOString() }) + } + // gelesen wird je Klasse eine Liste: vorrang 1 Eintrag, normal 2, nachrangig 1; danach ist das Budget voll oder alles gesendet + const szenarien = [ + [1, ['g01@example.com'], 1], + [2, ['g01@example.com', 'g03b@example.com'], 3], + [3, ['g01@example.com', 'g03b@example.com', 'g03a@example.com'], 3], + [4, ['g01@example.com', 'g03b@example.com', 'g03a@example.com', 'g05@example.com'], 4], + ] + for (const [budget, erwartet, gelesen] of szenarien) { + await leereWarteschlange() + config.termine.mailsProStunde = budget + await lege() + const ergebnis = await verarbeite(JETZT) + assert.deepEqual(empfaenger(), erwartet, `Budget ${budget}`) + assert.equal(ergebnis.gesendet, budget) + assert.equal(ergebnis.faellig, gelesen, `Budget ${budget}: gelesen wird nur, bis das Budget voll ist`) + assert.equal(alle().filter((d) => d.status === 'wartet').length, 4 - budget) + } +}) + +test('kein Verhungern: 150 fällige G02 der Funktion A (Kontingent 40) vor einem G02 der Funktion B ⇒ ein Lauf sendet 40 von A und die Mail von B', async () => { + for (let i = 0; i < 150; i++) await legeAn({ featureId: 'f_A', art: 'G02', bookingId: `b_a${i}`, naechsterVersuch: plus(-3600000 + i * 1000).toISOString() }) + const b = await legeAn({ featureId: 'f_B', art: 'G02', bookingId: 'b_b', an: 'b@example.com', naechsterVersuch: plus(-1000).toISOString() }) + aw.resetListCalls() + assert.deepEqual(await verarbeite(JETZT), { faellig: 101, gesendet: 41 }) + assert.equal((await lies(b.$id)).status, 'gesendet') + assert.equal(alle().filter((d) => d.featureId === 'f_A' && d.status === 'gesendet').length, 40) + const abfragen = listenAbfragen() + begrenzteLeseabfragen(abfragen) + const vorrang = abfragen.filter((a) => abfrageKlasse(a) === 'vorrang') + assert.equal(vorrang.length, 2, 'erste Seite voll von A, zweite mit Ausschlussliste') + assert.equal(ausschluss(vorrang[0]), null, 'die erste Abfrage schließt nichts aus') + assert.deepEqual(ausschluss(vorrang[1]), ['f_A']) + for (const klasse of ['vorrang', 'normal', 'nachrangig']) assert.ok(abfragen.filter((a) => abfrageKlasse(a) === klasse).length <= 5) +}) + +test('Runden: höchstens 5 Abfragen je Klasse; mailListe wird bei jedem Aufruf gelesen; der Rest folgt beim nächsten Lauf', async () => { + config.termine.mailListe = 2 + for (let i = 0; i < 12; i++) await legeAn({ featureId: `f_${i}`, art: 'G02', bookingId: `b_${i}`, naechsterVersuch: plus(-3600000 + i * 1000).toISOString() }) + aw.resetListCalls() + assert.deepEqual(await verarbeite(JETZT), { faellig: 10, gesendet: 10 }) + assert.deepEqual(abfrageKlassen(), ['vorrang', 'vorrang', 'vorrang', 'vorrang', 'vorrang', 'normal', 'nachrangig']) + assert.ok(listenAbfragen().every((a) => a.queries.find((q) => q.method === 'limit').values[0] === 2)) + assert.equal(alle().filter((d) => d.status === 'wartet').length, 2) + assert.deepEqual(await verarbeite(JETZT), { faellig: 2, gesendet: 2 }) +}) + +test('Ausschlussliste: höchstens 90 Funktionen je Abfrage, auch wenn mehr blockiert sind', async () => { + config.termine.mailsProFunktion = 1 + for (let i = 0; i < 100; i++) { + for (const teil of ['a', 'b']) { + await legeAn({ featureId: `f_${i}`, art: 'G02', bookingId: `b_${i}${teil}`, naechsterVersuch: plus(-3600000 + (2 * i + (teil === 'a' ? 0 : 1)) * 1000).toISOString() }) + } + } + aw.resetListCalls() + assert.deepEqual(await verarbeite(JETZT), { faellig: 210, gesendet: 100 }) + const abfragen = listenAbfragen() + const vorrang = abfragen.filter((a) => abfrageKlasse(a) === 'vorrang') + assert.deepEqual(vorrang.map((a) => ausschluss(a)?.length ?? 0), [0, 50, 90], 'Runde 3: 100 blockierte Funktionen, 90 davon in der Liste') + for (const abfrage of abfragen) assert.ok((ausschluss(abfrage)?.length ?? 0) <= 90) + assert.ok(abfragen.filter((a) => abfrageKlasse(a) !== 'vorrang').every((a) => ausschluss(a).length === 90), 'blockierte Funktionen gelten für alle Klassen') + assert.equal(alle().filter((d) => d.status === 'gesendet').length, 100) +}) + +test('Reserve erreicht: keine Abfrage der nachrangigen Klasse mehr; Vorrang und normal kommen trotzdem dran', async () => { + config.termine.mailsProStunde = 8 // nachrangig höchstens 6 + for (let i = 1; i <= 6; i++) await einreihen(von('f_A', 'G05', i), JETZT) + await warteAufVersand() + assert.equal(tr.senke.sent.length, 6) + const rest = await legeAn({ featureId: 'f_A', art: 'G05', bookingId: 'b_rest', naechsterVersuch: plus(-MIN).toISOString() }) + await legeAn({ featureId: 'f_A', art: 'G03', bookingId: 'b_normal', naechsterVersuch: plus(-MIN).toISOString() }) + aw.resetListCalls() + assert.deepEqual(await verarbeite(plus(MIN)), { faellig: 1, gesendet: 1 }) + assert.deepEqual(abfrageKlassen(), ['vorrang', 'normal'], 'die nachrangige Klasse wird gar nicht erst gelesen') + assert.equal((await lies(rest.$id)).status, 'wartet') + assert.equal((await lies(rest.$id)).versuche, 0) +}) + +test('Reserve mitten im Lauf: nach der Runde, die sie erreicht, folgt keine weitere nachrangige Abfrage', async () => { + config.termine.mailsProStunde = 8 // nachrangig höchstens 6 + config.termine.mailListe = 3 + for (let i = 0; i < 10; i++) await legeAn({ art: i % 2 ? 'K20' : 'G05', bookingId: `b_${i}`, naechsterVersuch: plus(-3600000 + i * 1000).toISOString() }) + aw.resetListCalls() + assert.deepEqual(await verarbeite(JETZT), { faellig: 6, gesendet: 6 }) + assert.deepEqual(abfrageKlassen(), ['vorrang', 'normal', 'nachrangig', 'nachrangig'], 'zwei Runden à 3; vor der dritten ist die Reserve erreicht') + assert.equal(alle().filter((d) => d.status === 'wartet').length, 4) +}) + // --------------------------------------------------------------------------------------------------------------------- // Fehlerklassen // --------------------------------------------------------------------------------------------------------------------- diff --git a/test/termineSchalter.test.js b/test/termineSchalter.test.js index 42cf850..c0d08ee 100644 --- a/test/termineSchalter.test.js +++ b/test/termineSchalter.test.js @@ -49,6 +49,7 @@ test('Konfiguration: Vorgaben ohne Umgebungsvariablen', async () => { mailsProStunde: 200, mailsProFunktion: 40, mailsReserve: 0.25, + mailListe: 100, loeschDeckel: 2000, loeschDeckelGesamt: 20000, zellenDeckel: 5000,