Terminplaner: veraltete G01 verworfen, Index bookingId

Eine G01, die erst nach holdMinutes + 1 Minute faellig wird, ist mit verfallenem Link
nutzlos: sie wird nicht mehr gesendet, sondern verworfen (kein Transportaufruf, kein
Budgetplatz, Felder geleert, kein Zustellfehler, raeumeAuf loescht sie nach 48 Stunden).
Neuer Index idx_booking auf termineOutbox fuer zustellFehler und verwerfeWartende;
verworfeneG01() zaehlt seit Prozessstart.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 10:21:25 +00:00
1 parent 36b94c3035
commit 43b3caddde
4 files changed
+268 -18

No files matched your search

+63 -13
View File
@@ -19,6 +19,13 @@ import { pruefeNow, pruefeText, zeitMs } from './util.js'
* Fehlschlag `fehler`
* Nach `gesendet` werden an, html, text und ics geleert (personenbezogene Daten).
*
* Veraltete Bestaetigungsmail (G01 mit Link): Wird eine G01 erst nach Ablauf des Halte-Fensters faellig (Warteschlange voll,
* Pause, Wiederholungen), waere ihr Bestaetigungslink schon verfallen. Sie geht dann nicht mehr raus, sondern wird `verworfen`
* (siehe sende): kein Transportaufruf, kein Budgetplatz, an/html/text/ics geleert. Das ist kein Zustellfehler (zustellFehler
* zaehlt nur `fehler`); raeumeAuf loescht `verworfen` wie `fehler` 48 Stunden spaeter. G01 ohne Link (bookingId '-', zu_viele)
* und alle anderen Arten verfallen nie. Ein Eintrag, der schon rausging, aber nicht verbucht werden konnte (`unverbucht`),
* wird nie verworfen und nie doppelt gesendet.
*
* `naechsterVersuch` ist die Faelligkeit des Eintrags: bei `wartet` der naechste Sendeversuch, bei `gesendet` der
* Loeschtermin (Versand + 7 Tage), bei `fehler` ebenfalls (Fehlschlag + 48 Stunden). So findet raeumeAuf alles ueber
* den vorhandenen Index idx_status_next (status, naechsterVersuch); ein eigenes Fehlerdatum gibt es im Schema nicht.
@@ -52,6 +59,10 @@ const WIEDERHOLUNG_MS = [1 * MIN_MS, 5 * MIN_MS, 30 * MIN_MS]
const ANMELDE_PAUSE_MS = 15 * MIN_MS
const GESENDET_BEHALTEN_MS = 7 * TAG_MS
const FEHLER_BEHALTEN_MS = 48 * STUNDE_MS
const VERWORFEN_BEHALTEN_MS = 48 * STUNDE_MS
/** bookingId der Mails ohne Buchung (G01 zu_viele): sie haben keinen Bestaetigungslink und verfallen nie. */
const OHNE_BUCHUNG = '-'
const VERWORFEN_GRUND = 'Verworfen: Bestätigungslink abgelaufen'
/** Laenger als das in `wartet` (ab createdAt) ist ein Eintrag festgefahren (beendeFestgefahrene). */
const WARTEN_MAX_MS = 48 * STUNDE_MS
const ICS_METHODEN = ['REQUEST', 'CANCEL', 'PUBLISH']
@@ -76,6 +87,8 @@ export function mailKlasse(art) {
let pauseBisMs = 0
/** Belegte Plaetze im letzten Stundenfenster: { t: Sendezeit (ms), f: featureId, n: nachrangig }. */
let plaetze = []
/** Veraltete G01, die dieser Prozess verworfen hat (verworfeneG01). */
let verworfenG01 = 0
/** Laufende sendeEintrag-Aufrufe (fuer warteAufVersand). */
const laufend = new Set()
/** Eintraege, die schon rausgingen, deren Ergebnis aber nicht gespeichert werden konnte: id => Versandzeitpunkt (ms). */
@@ -220,10 +233,10 @@ export async function einreihen(
* 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;
* @returns {Promise<'gesendet'|'wartet'|'fehler'|'verworfen'|'uebersprungen'>} gesendet = dieser Aufruf hat die Mail gesendet;
* 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
* Fehler); fehler = dieser Aufruf hat ihn endgueltig scheitern lassen; verworfen = veraltete G01 (Bestaetigungslink
* abgelaufen), nicht gesendet; uebersprungen = Eintrag fehlt oder ist schon gesendet/fehlgeschlagen/verworfen
*/
export function sendeEintrag(id, now = new Date()) {
const lauf = sende(id, now).finally(() => laufend.delete(lauf))
@@ -252,6 +265,11 @@ async function sende(id, now) {
await speichereGesendet(doc, unverbucht.get(id))
return 'gesendet'
}
// Vor dem Budget: eine veraltete G01 braucht keinen Platz und keinen Sendeversuch
if (istVeraltetG01(doc, nowMs)) {
await speichereVerworfen(doc, nowMs)
return 'verworfen'
}
if (new Date(doc.naechsterVersuch).getTime() > 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).
@@ -278,6 +296,30 @@ async function sende(id, now) {
})
}
/**
* Ist der Bestaetigungslink dieser G01 schon verfallen? Das Halte-Fenster (config.termine.holdMinutes, bei jedem Aufruf
* gelesen) plus eine Minute Toleranz ab Einreihen; genau so alt ist noch nicht veraltet. G01 ohne Buchung (zu_viele) hat
* keinen Link, andere Arten verfallen nie.
*/
function istVeraltetG01(doc, nowMs) {
if (doc.art !== 'G01' || doc.bookingId === OHNE_BUCHUNG) return false
return nowMs - zeitMs(doc.createdAt) > (config.termine.holdMinutes + 1) * MIN_MS
}
/** Veraltete G01 verwerfen: Status `verworfen`, personenbezogene Felder leeren, Loeschtermin jetzt + 48 Stunden (raeumeAuf). */
async function speichereVerworfen(doc, nowMs) {
await updateDocument(col(), doc.$id, {
status: 'verworfen',
an: '',
html: '',
text: '',
ics: '',
fehler: VERWORFEN_GRUND,
naechsterVersuch: iso(nowMs + VERWORFEN_BEHALTEN_MS),
})
verworfenG01 += 1
}
/**
* Nach dem Versand: Status, Zeiten setzen und personenbezogene Felder leeren. Scheitert das Speichern (auch beim
* zweiten Versuch), merkt sich der Prozess den Versand, damit der Eintrag nicht noch einmal gesendet wird.
@@ -381,10 +423,10 @@ async function verarbeiteKlasse(klasse, nowMs, now, lauf) {
}
lauf.versucht.add(eintrag.$id)
try {
if ((await sendeEintrag(eintrag.$id, now)) === 'gesendet') {
lauf.gesendet += 1
bewegt = true
}
const ergebnis = await sendeEintrag(eintrag.$id, now)
if (ergebnis === 'gesendet') lauf.gesendet += 1
// auch ein verworfener Eintrag verlaesst die Liste (`wartet`); so kommen die Eintraege dahinter in der naechsten Runde dran
if (ergebnis === 'gesendet' || ergebnis === 'verworfen') bewegt = true
} catch (err) {
console.error(`[termine-outbox] Eintrag ${eintrag.$id}: ${err.message}`)
}
@@ -421,8 +463,10 @@ export async function warteAufVersand() {
}
/**
* Buchungen (aus bookingIds) mit mindestens einer endgueltig gescheiterten Mail (Status fehler). Das Portal zeigt dort
* Buchungen (aus bookingIds) mit mindestens einer endgueltig gescheiterten Mail (Status fehler; verworfene veraltete G01
* zaehlen nicht: der Betrieb sieht fuer einen verfallenen Halte-Eintrag keinen Zustellfehler). Das Portal zeigt dort
* „Bestätigung konnte nicht zugestellt werden“. Die Eintraege verschwinden mit raeumeAuf (48 Stunden nach dem Fehler).
* Fragt ueber den Index idx_booking (bookingId) ab.
* @param {string[]} bookingIds
* @returns {Promise<Set<string>>}
*/
@@ -437,14 +481,14 @@ export async function zustellFehler(bookingIds) {
}
/**
* Taeglicher Job: loescht gesendete Eintraege 7 Tage nach dem Versand und fehlgeschlagene 48 Stunden nach dem Fehler
* (beides steht in naechsterVersuch). Wartende Eintraege bleiben.
* Taeglicher Job: loescht gesendete Eintraege 7 Tage nach dem Versand, fehlgeschlagene 48 Stunden nach dem Fehler und
* verworfene (veraltete G01) 48 Stunden nach dem Verwerfen (alles steht in naechsterVersuch). Wartende Eintraege bleiben.
* @param {Date} now
* @returns {Promise<number>} Anzahl geloeschter Eintraege
*/
export async function raeumeAuf(now) {
const nowMs = pruefeNow(now, 'raeumeAuf')
const alt = await listAllDocuments(col(), [Query.equal('status', ['gesendet', 'fehler']), Query.lessThanEqual('naechsterVersuch', iso(nowMs))])
const alt = await listAllDocuments(col(), [Query.equal('status', ['gesendet', 'fehler', 'verworfen']), Query.lessThanEqual('naechsterVersuch', iso(nowMs))])
let geloescht = 0
for (const eintrag of alt) {
try {
@@ -461,7 +505,7 @@ export async function raeumeAuf(now) {
* Gastdaten loeschen (Portal "Gastdaten löschen"): noch nicht versandte Mails einer Buchung (Status wartet) werden
* geloescht, denn sie halten Adresse und Inhalt. Je Eintrag unter der Sendesperre `termine-mail:<id>` nach neuem Lesen:
* ein Eintrag, der inzwischen gesendet wurde (seine Felder sind dann schon leer), bleibt; ein inzwischen geloeschter
* stoert nicht. Gesendete und fehlgeschlagene Eintraege behandelt raeumeAuf.
* stoert nicht. Gesendete, fehlgeschlagene und verworfene Eintraege behandelt raeumeAuf. Fragt ueber den Index idx_booking ab.
* @param {string} bookingId
* @returns {Promise<number>} Anzahl geloeschter Eintraege
*/
@@ -528,14 +572,20 @@ export async function beendeFestgefahrene(now) {
return beendet
}
/** Wie viele veraltete G01 dieser Prozess seit dem Start verworfen hat (Betriebsmeldung, A10b). */
export function verworfeneG01() {
return verworfenG01
}
/** Ende der letzten Anmeldepause (Date) oder null, wenn es noch keine gab. Eine Pause gilt, solange das Ende nach `now` liegt. */
export function pausiertBis() {
return pauseBisMs ? new Date(pauseBisMs) : null
}
/** Nur fuer Tests: Pause, Budgetfenster und nicht gespeicherte Versände zuruecksetzen. */
/** Nur fuer Tests: Pause, Budgetfenster, nicht gespeicherte Versände und den Zaehler verworfener G01 zuruecksetzen. */
export function resetOutboxForTests() {
pauseBisMs = 0
plaetze = []
unverbucht.clear()
verworfenG01 = 0
}
+2 -1
View File
@@ -114,7 +114,8 @@ export const TERMINE_COLLECTIONS = {
str('fehler', 300),
dt('createdAt'),
],
indexes: [index('idx_status_next', 'status', 'naechsterVersuch')],
// idx_status_next: Minutenjob und Aufraeumen; idx_booking: zustellFehler und verwerfeWartende fragen nach der Buchung
indexes: [index('idx_status_next', 'status', 'naechsterVersuch'), index('idx_booking', 'bookingId')],
},
}