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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 10:13:26 +00:00
1 parent 4d96565240
commit 36b94c3035
4 files changed
+275 -29

No files matched your search

+2
View File
@@ -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,
+92 -21
View File
@@ -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<boolean>} 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. */
+180 -8
View File
@@ -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
// ---------------------------------------------------------------------------------------------------------------------
+1
View File
@@ -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,