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. */