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:
1 parent
4d96565240
commit
36b94c3035
4 files changed
+275
-29
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user