Terminplaner: Mailkontingent je Funktion, Vorrang-Reserve und kleinere Haltungsgrenze
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
978722023f
commit
c35711caad
7 files changed
+330
-42
No files matched your search
@@ -386,6 +386,11 @@ export const config = {
|
||||
holdMinutes: 30,
|
||||
zelleMin: 5,
|
||||
mailsProStunde: 200,
|
||||
// Mail-Fairness (outbox.js): hoechstens so viele Mails je Funktion und Stunde (gleitendes Fenster, unter mailsProStunde)
|
||||
mailsProFunktion: 40,
|
||||
// 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,
|
||||
// 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,
|
||||
|
||||
@@ -41,7 +41,7 @@ const ANFRAGEN_JE_SITE = 600
|
||||
/** Buchungsversuche (jedes POST auf /buchungen, auch Honeypot): je IP und 10 Minuten; gehaltene Termine: je siteKey und Stunde. */
|
||||
const BUCHUNGEN_JE_IP = 10
|
||||
const BUCHUNGEN_FENSTER_MS = 10 * MINUTE_MS
|
||||
const HALTUNGEN_JE_SITE = 60
|
||||
const HALTUNGEN_JE_SITE = 25
|
||||
/** Widerrufserklaerungen: je IP und Stunde. */
|
||||
const WIDERRUFE_JE_IP = 10
|
||||
/** G01 (Bestaetigungsmail, auch "zu viele"): je Adresse und Funktion und Tag. */
|
||||
@@ -182,7 +182,10 @@ export function darfBuchungsversuch(ip, now) {
|
||||
return zaehle(`ip:buchung:${ipSchluessel(ip)}`, BUCHUNGEN_JE_IP, BUCHUNGEN_FENSTER_MS, now)
|
||||
}
|
||||
|
||||
/** Darf diese Funktion (siteKey) jetzt einen weiteren Termin halten? Hoechstens 60 je Stunde. */
|
||||
/**
|
||||
* Darf diese Funktion (siteKey) jetzt einen weiteren Termin halten? Hoechstens 25 je Stunde: so loest eine Funktion hoechstens 25 G01
|
||||
* je Stunde aus, unter dem Mailkontingent je Funktion (config.termine.mailsProFunktion, outbox.js).
|
||||
*/
|
||||
export function darfHaltung(siteKey, now) {
|
||||
return zaehle(`haltung-site:${siteKey}`, HALTUNGEN_JE_SITE, STUNDE_MS, now)
|
||||
}
|
||||
|
||||
@@ -24,9 +24,16 @@ import { pruefeNow, pruefeText, zeitMs } from './util.js'
|
||||
* den vorhandenen Index idx_status_next (status, naechsterVersuch); ein eigenes Fehlerdatum gibt es im Schema nicht.
|
||||
*
|
||||
* Stundenbudget (config.termine.mailsProStunde) und Anmeldepause sind Zustand dieses Prozesses: gleitendes Fenster der
|
||||
* Sendezeitpunkte bzw. ein Zeitpunkt. Ein Platz wird vor dem Senden belegt (gleichzeitige Versuche koennen das Budget
|
||||
* nicht ueberziehen) und bei einem Fehlschlag wieder freigegeben. `now` ist immer ein Parameter (feste Uhr in Tests);
|
||||
* ohne Angabe gilt bei einreihen und sendeEintrag die Wanduhr.
|
||||
* belegten Plaetze { t, f, n } (Zeit, Funktion, nachrangig) bzw. ein Zeitpunkt. Ein Platz wird vor dem Senden belegt
|
||||
* (gleichzeitige Versuche koennen das Budget nicht ueberziehen) und bei einem Fehlschlag wieder freigegeben. `now` ist
|
||||
* immer ein Parameter (feste Uhr in Tests); ohne Angabe gilt bei einreihen und sendeEintrag die Wanduhr.
|
||||
*
|
||||
* Fairness (pruefePlatz, in dieser Reihenfolge; der Eintrag bleibt dann unveraendert `wartet`, kein Versuch):
|
||||
* pause Anmeldepause laeuft
|
||||
* budget Plaetze im Fenster >= mailsProStunde
|
||||
* 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
|
||||
*/
|
||||
|
||||
const col = () => config.collections.termineOutbox
|
||||
@@ -47,35 +54,91 @@ const ID_BLOCK = 50
|
||||
|
||||
const iso = (wertMs) => new Date(wertMs).toISOString()
|
||||
|
||||
/** Mails, die vor allen anderen rausgehen duerfen (Bestaetigung, Termin, Eingangsbestaetigung des Widerrufs). */
|
||||
export const MAIL_VORRANG = Object.freeze(['G01', 'G02', 'G08'])
|
||||
/** Mails, die zuerst warten (Erinnerung, Hinweise an den Betrieb): sie nehmen nie die ganze Stunde. */
|
||||
export const MAIL_NACHRANGIG = Object.freeze(['G05', 'K20', 'K21'])
|
||||
|
||||
/** 'vorrang' | 'normal' | 'nachrangig' nach der Art (Vorlagen-ID) der Mail. */
|
||||
export function mailKlasse(art) {
|
||||
if (MAIL_VORRANG.includes(art)) return 'vorrang'
|
||||
if (MAIL_NACHRANGIG.includes(art)) return 'nachrangig'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
// Zustand des Prozesses
|
||||
let pauseBisMs = 0
|
||||
/** Zeitpunkte (ms) der Sendeversuche im letzten Stundenfenster (belegte Plaetze). */
|
||||
let sendeZeiten = []
|
||||
/** Belegte Plaetze im letzten Stundenfenster: { t: Sendezeit (ms), f: featureId, n: nachrangig }. */
|
||||
let plaetze = []
|
||||
/** Laufende sendeEintrag-Aufrufe (fuer warteAufVersand). */
|
||||
const laufend = new Set()
|
||||
/** Eintraege, die schon rausgingen, deren Ergebnis aber nicht gespeichert werden konnte: id => Versandzeitpunkt (ms). */
|
||||
const unverbucht = new Map()
|
||||
|
||||
/** Anzahl freier Plaetze im Stundenbudget zum Zeitpunkt nowMs; raeumt abgelaufene Plaetze aus dem Fenster. */
|
||||
function freieBudgetPlaetze(nowMs) {
|
||||
const grenze = nowMs - STUNDE_MS
|
||||
sendeZeiten = sendeZeiten.filter((zeit) => zeit > grenze)
|
||||
return Math.max(0, config.termine.mailsProStunde - sendeZeiten.length)
|
||||
/** Die Plaetze, die zum Zeitpunkt nowMs noch im Stundenfenster stehen (nur lesen; ein Platz laeuft 60 Minuten nach dem Versand ab). */
|
||||
const imFenster = (nowMs) => plaetze.filter((platz) => platz.t > nowMs - STUNDE_MS)
|
||||
|
||||
/** 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))
|
||||
|
||||
/** 'pause' | 'budget' | null: die Bremsen, die fuer jede Mail gelten (nur lesen). */
|
||||
function gesamtBremse(nowMs) {
|
||||
if (pauseBisMs > nowMs) return 'pause'
|
||||
if (imFenster(nowMs).length >= config.termine.mailsProStunde) return 'budget'
|
||||
return null
|
||||
}
|
||||
|
||||
/** Pause oder ausgeschoepftes Budget (nur lesen). */
|
||||
const gebremst = (nowMs) => pauseBisMs > nowMs || freieBudgetPlaetze(nowMs) === 0
|
||||
|
||||
/** Belegt einen Platz im Budget (synchron, ohne Zwischenschritt); false bei Pause oder leerem Budget. */
|
||||
function belegePlatz(nowMs) {
|
||||
if (gebremst(nowMs)) return false
|
||||
sendeZeiten.push(nowMs)
|
||||
return true
|
||||
/**
|
||||
* Darf eine Mail dieser Funktion und Art zum Zeitpunkt nowMs raus? null = ja, sonst der erste Grund (siehe Kopfkommentar):
|
||||
* 'pause' | 'budget' | 'reserve' | 'kontingent'. Nur lesen: belegt keinen Platz.
|
||||
* @param {number} nowMs
|
||||
* @param {string} featureId
|
||||
* @param {string} art Vorlagen-ID der Mail ('G01' ... 'K21')
|
||||
* @returns {null|'pause'|'budget'|'reserve'|'kontingent'}
|
||||
*/
|
||||
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 (fenster.filter((platz) => platz.f === featureId).length >= config.termine.mailsProFunktion) return 'kontingent'
|
||||
return null
|
||||
}
|
||||
|
||||
function gibPlatzFrei(nowMs) {
|
||||
const stelle = sendeZeiten.indexOf(nowMs)
|
||||
if (stelle >= 0) sendeZeiten.splice(stelle, 1)
|
||||
/** Belegt einen Platz (synchron, ohne Zwischenschritt); der Platz oder null, wenn pruefePlatz einen Grund nennt. */
|
||||
function belegePlatz(nowMs, featureId, art) {
|
||||
if (pruefePlatz(nowMs, featureId, art) !== null) return null
|
||||
const platz = { t: nowMs, f: featureId, n: mailKlasse(art) === 'nachrangig' }
|
||||
plaetze = [...imFenster(nowMs), platz]
|
||||
return platz
|
||||
}
|
||||
|
||||
function gibPlatzFrei(platz) {
|
||||
plaetze = plaetze.filter((belegt) => belegt !== platz)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand des Mailbudgets zum Zeitpunkt now (fuer die Betriebspruefung und die Statusroute): nur Zahlen und Funktions-IDs,
|
||||
* nie eine Adresse. `spitzen` sind die bis zu drei Funktionen mit den meisten Mails im Fenster, die groesste zuerst.
|
||||
* @param {Date} now
|
||||
* @returns {{ stunde: { benutzt: number, max: number }, nachrangig: { benutzt: number, max: number }, jeFunktionMax: number,
|
||||
* spitzen: Array<{ featureId: string, benutzt: number }> }}
|
||||
*/
|
||||
export function mailBudget(now) {
|
||||
const nowMs = pruefeNow(now, 'mailBudget')
|
||||
const fenster = imFenster(nowMs)
|
||||
const jeFunktion = new Map()
|
||||
for (const platz of fenster) jeFunktion.set(platz.f, (jeFunktion.get(platz.f) ?? 0) + 1)
|
||||
const spitzen = [...jeFunktion]
|
||||
.map(([featureId, benutzt]) => ({ featureId, benutzt }))
|
||||
.sort((a, b) => b.benutzt - a.benutzt || (a.featureId < b.featureId ? -1 : 1))
|
||||
.slice(0, 3)
|
||||
return {
|
||||
stunde: { benutzt: fenster.length, max: config.termine.mailsProStunde },
|
||||
nachrangig: { benutzt: fenster.filter((platz) => platz.n).length, max: nachrangigMax() },
|
||||
jeFunktionMax: config.termine.mailsProFunktion,
|
||||
spitzen,
|
||||
}
|
||||
}
|
||||
|
||||
/** 'dauerhaft' | 'anmeldung' | 'vorlaeufig' (siehe Kopfkommentar). */
|
||||
@@ -145,12 +208,13 @@ export async function einreihen(
|
||||
|
||||
/**
|
||||
* Einziger Sendepfad. Macht hoechstens einen Sendeversuch fuer den Eintrag, wenn er `wartet`, faellig ist, keine Pause
|
||||
* laeuft und das Stundenbudget reicht.
|
||||
* laeuft und Stundenbudget, Vorrang-Reserve und Kontingent der Funktion reichen (pruefePlatz).
|
||||
* @param {string} id
|
||||
* @param {Date} [now]
|
||||
* @returns {Promise<'gesendet'|'wartet'|'fehler'|'uebersprungen'>} gesendet = dieser Aufruf hat die Mail gesendet;
|
||||
* wartet = Eintrag bleibt in der Warteschlange (Pause, Budget, noch nicht faellig, vorlaeufiger Fehler); fehler = dieser
|
||||
* Aufruf hat ihn endgueltig scheitern lassen; uebersprungen = Eintrag fehlt oder ist schon gesendet/fehlgeschlagen
|
||||
* wartet = Eintrag bleibt in der Warteschlange (Pause, Budget, Reserve, Kontingent, noch nicht faellig, vorlaeufiger
|
||||
* Fehler); fehler = dieser Aufruf hat ihn endgueltig scheitern lassen; uebersprungen = Eintrag fehlt oder ist schon
|
||||
* gesendet/fehlgeschlagen
|
||||
*/
|
||||
export function sendeEintrag(id, now = new Date()) {
|
||||
const lauf = sende(id, now).finally(() => laufend.delete(lauf))
|
||||
@@ -161,7 +225,7 @@ export function sendeEintrag(id, now = new Date()) {
|
||||
async function sende(id, now) {
|
||||
const nowMs = pruefeNow(now, 'sendeEintrag')
|
||||
pruefeText(id, 'id')
|
||||
if (gebremst(nowMs)) return 'wartet'
|
||||
if (gesamtBremse(nowMs)) return 'wartet'
|
||||
|
||||
return withLocks([`termine-mail:${id}`], async () => {
|
||||
let doc
|
||||
@@ -180,8 +244,10 @@ async function sende(id, now) {
|
||||
return 'gesendet'
|
||||
}
|
||||
if (new Date(doc.naechsterVersuch).getTime() > nowMs) return 'wartet'
|
||||
// Der Platz wird ohne Zwischenschritt vor dem Senden belegt: gleichzeitige Versuche koennen das Budget nicht ueberziehen
|
||||
if (!belegePlatz(nowMs)) return 'wartet'
|
||||
// Der Platz wird ohne Zwischenschritt vor dem Senden belegt: gleichzeitige Versuche koennen Budget und Kontingent nicht
|
||||
// ueberziehen. Ein gebremster Eintrag bleibt unveraendert `wartet` (kein Versuch).
|
||||
const platz = belegePlatz(nowMs, doc.featureId, doc.art)
|
||||
if (!platz) return 'wartet'
|
||||
|
||||
try {
|
||||
await sendGuestMail({
|
||||
@@ -195,7 +261,7 @@ async function sende(id, now) {
|
||||
icsMethod: doc.icsMethod || 'REQUEST',
|
||||
})
|
||||
} catch (err) {
|
||||
gibPlatzFrei(nowMs)
|
||||
gibPlatzFrei(platz)
|
||||
return speichereFehlschlag(doc, err, nowMs)
|
||||
}
|
||||
await speichereGesendet(doc, nowMs)
|
||||
@@ -252,7 +318,8 @@ async function speichereFehlschlag(doc, err, nowMs) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutenjob: sendet alle faelligen wartenden Eintraege, aelteste zuerst, bis Pause oder Stundenbudget bremsen.
|
||||
* 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.
|
||||
* @param {Date} now
|
||||
* @returns {Promise<{ faellig: number, gesendet: number }>}
|
||||
@@ -264,7 +331,10 @@ export async function verarbeite(now) {
|
||||
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) {
|
||||
if (gebremst(nowMs)) break
|
||||
// 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) {
|
||||
@@ -395,6 +465,6 @@ export function pausiertBis() {
|
||||
/** Nur fuer Tests: Pause, Budgetfenster und nicht gespeicherte Versände zuruecksetzen. */
|
||||
export function resetOutboxForTests() {
|
||||
pauseBisMs = 0
|
||||
sendeZeiten = []
|
||||
plaetze = []
|
||||
unverbucht.clear()
|
||||
}
|
||||
Reference in new issue
Block a user