Terminplaner: Minutenjob in Verfall und Mailversand getrennt geschuetzt

fuehreAus nimmt { teil } und sperrt je job:teil; minutenJob startet Verfall
und Mailversand (Warteschlange plus Betriebspruefung) gemeinsam mit
Promise.all. Haengt ein Teil, laeuft der andere im naechsten Takt weiter
(verfallUebersprungen bzw. mailUebersprungen); uebersprungen gilt nur, wenn
beide Teile uebersprungen werden. Die Ueberlappungswarnung nennt den Teil,
das Fehlerlog behaelt minutenJob.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 11:41:00 +00:00
1 parent aaed273b62
commit e2f50407a0
2 files changed
+190 -40

No files matched your search

+58 -21
View File
@@ -27,8 +27,10 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js'
* Hintergrund-Jobs des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 4 und 11). server/index.js
* ruft sie mit new Date() auf; `now` ist hier immer ein Parameter.
*
* minutenJob jede Minute abgelaufene Haltungen => verfallen (Zellen frei); Mail-Warteschlange senden; zuletzt die
* Betriebspruefung (Budget, Warteschlange, Anmeldepause => Log und Team-Mail, betrieb.js)
* minutenJob jede Minute zwei Teile, gemeinsam gestartet (Promise.all) und je Teil gegen Ueberlappung geschuetzt:
* Verfall: abgelaufene Haltungen => verfallen (Zellen frei);
* Mailversand: Mail-Warteschlange senden, zuletzt die Betriebspruefung (Budget, Warteschlange,
* Anmeldepause => Log und Team-Mail, betrieb.js)
* fuenfMinutenJob alle 5 Minuten Erinnerungen (G05); bestaetigt 1 Stunde nach Ende => erledigt; Anfragen mit Beginn in
* der Vergangenheit => abgelehnt (vom System, Zellen frei, keine Mail)
* taeglicherJob taeglich Loeschfristen (11), festgefahrene und alte Mails, abgeschaltete Funktionen, verwaiste Zellen;
@@ -38,7 +40,10 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js'
* Gemeinsame Regeln:
* - Modus off: jeder Job kehrt sofort zurueck, ohne Abfrage, mit { aus: true }.
* - Keine ueberlappenden Laeufe: laeuft ein Job noch, wird der naechste Takt uebersprungen ({ uebersprungen: true }, einmal im
* Log je Ueberlappung).
* Log je Ueberlappung). Die Sperre gilt je `job:teil` (fuehreAus mit { teil }): haengt beim Minutenjob der Verfall, versendet der
* naechste Takt trotzdem die Warteschlange und umgekehrt; uebersprungen gilt der Takt erst, wenn beide Teile uebersprungen sind
* (sonst steht verfallUebersprungen bzw. mailUebersprungen im Ergebnis). Die Warnung nennt den Teil (`minutenJob (Verfall)`), das
* Fehlerlog behaelt `minutenJob` als Jobnamen.
* - Ein Schritt eines Jobs scheitert nie den ganzen Job: der Fehler steht im Log (Text des Fehlers, bei einer Buchung mit
* Buchungsnummer, nie mit Gastdaten), `fehler` im Ergebnis zaehlt ihn, die uebrigen Schritte laufen. Eine fehlende Collection
* (404 collection_not_found, Einrichtung unvollstaendig) meldet ein Job hoechstens einmal je Stunde.
@@ -100,23 +105,26 @@ async function schritt(lauf, was, fn, ersatz) {
/**
* Rahmen jedes Jobs: Schalter, Ueberlappung, Laufdaten. => Ergebnis von arbeit(lauf) | { aus: true } | { uebersprungen: true }
* `teil` (Name eines Teiljobs, z. B. „Verfall“) gibt dem Teil eine eigene Ueberlappungssperre `job:teil` und nennt ihn in der
* Warnung (`job (teil)`); `lauf.job` bleibt der Jobname (Fehlerlog, Warnung bei fehlender Collection).
*/
async function fuehreAus(job, now, arbeit) {
async function fuehreAus(job, now, arbeit, { teil = null } = {}) {
const nowMs = pruefeNow(now, job)
if (termineAus()) return { aus: true }
if (laufend.has(job)) {
if (!ueberlappGemeldet.has(job)) {
ueberlappGemeldet.add(job)
console.warn(`[termine-jobs] ${job}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`)
const sperre = teil === null ? job : `${job}:${teil}`
if (laufend.has(sperre)) {
if (!ueberlappGemeldet.has(sperre)) {
ueberlappGemeldet.add(sperre)
console.warn(`[termine-jobs] ${teil === null ? job : `${job} (${teil})`}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`)
}
return { uebersprungen: true }
}
laufend.add(job)
laufend.add(sperre)
try {
return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0 })
} finally {
laufend.delete(job)
ueberlappGemeldet.delete(job)
laufend.delete(sperre)
ueberlappGemeldet.delete(sperre)
}
}
@@ -214,19 +222,48 @@ async function verfalleHaltungen(lauf, now) {
}
/**
* Jede Minute: abgelaufene Haltungen verfallen lassen, Warteschlange senden, zuletzt den Betrieb pruefen (Vorfaelle stehen im Log und
* gehen als T20 ans Team; die Pruefung wirft nie und aendert das Ergebnis des Laufs nicht).
* Jede Minute, in zwei unabhaengig geschuetzten Teilen, die gemeinsam starten (Promise.all):
* - Verfall: abgelaufene Haltungen verfallen lassen;
* - Mailversand: Warteschlange senden, zuletzt den Betrieb pruefen (Vorfaelle stehen im Log und gehen als T20 ans Team; die Pruefung
* wirft nie und aendert das Ergebnis des Laufs nicht).
* Haengt ein Teil, wird nur dieser im naechsten Takt uebersprungen (verfallUebersprungen bzw. mailUebersprungen im Ergebnis), der
* andere laeuft weiter. { uebersprungen: true } kommt erst, wenn beide Teile uebersprungen werden.
* @param {Date} now
* @returns {Promise<{ verfallen: number, faellig: number, gesendet: number, fehler: number } | { aus: true } | { uebersprungen: true }>}
* @returns {Promise<{ verfallen: number, faellig: number, gesendet: number, fehler: number, verfallUebersprungen?: true, mailUebersprungen?: true } | { aus: true } | { uebersprungen: true }>}
*/
export async function minutenJob(now) {
return fuehreAus('minutenJob', now, async (lauf) => {
const verfallen = await schritt(lauf, 'Verfall', () => verfalleHaltungen(lauf, now), 0)
const { faellig, gesendet } = await schritt(lauf, 'Warteschlange', () => verarbeite(now), { faellig: 0, gesendet: 0 })
// Zuletzt: die Pruefung sieht den Stand nach Verfall und Versand (ein eben gesendeter Eintrag staut nichts mehr)
await schritt(lauf, 'Betriebsprüfung', () => pruefeBetrieb(now), [])
return { verfallen, faellig, gesendet, fehler: lauf.fehler }
})
const [verfall, mail] = await Promise.all([
fuehreAus(
'minutenJob',
now,
async (lauf) => {
const verfallen = await schritt(lauf, 'Verfall', () => verfalleHaltungen(lauf, now), 0)
return { verfallen, fehler: lauf.fehler }
},
{ teil: 'Verfall' },
),
fuehreAus(
'minutenJob',
now,
async (lauf) => {
const { faellig, gesendet } = await schritt(lauf, 'Warteschlange', () => verarbeite(now), { faellig: 0, gesendet: 0 })
// Zuletzt: die Pruefung sieht den Stand nach dem Versand (ein eben gesendeter Eintrag staut nichts mehr)
await schritt(lauf, 'Betriebsprüfung', () => pruefeBetrieb(now), [])
return { faellig, gesendet, fehler: lauf.fehler }
},
{ teil: 'Mailversand' },
),
])
if (verfall.aus || mail.aus) return { aus: true }
if (verfall.uebersprungen && mail.uebersprungen) return { uebersprungen: true }
return {
verfallen: verfall.verfallen ?? 0,
faellig: mail.faellig ?? 0,
gesendet: mail.gesendet ?? 0,
fehler: (verfall.fehler ?? 0) + (mail.fehler ?? 0),
...(verfall.uebersprungen ? { verfallUebersprungen: true } : {}),
...(mail.uebersprungen ? { mailUebersprungen: true } : {}),
}
}
// ---------------------------------------------------------------- Fuenf-Minuten-Job
+132 -19
View File
@@ -264,6 +264,54 @@ async function mitEinwurf(passt, dazwischen, fn) {
}
const listeVonBuchungen = (muster) => (url, methode) =>
methode === 'GET' && /\/collections\/termineBookings\/documents(\?|$)/.test(url) && muster.test(url)
const listeVonOutbox = (url, methode) => methode === 'GET' && /\/collections\/termineOutbox\/documents(\?|$)/.test(url)
/**
* Haengende Schritte: haelt je Muster (url, methode) => boolean die erste Anfrage an, auf die es zutrifft, bis freigeben() alle
* loslaesst. warteBisRuhig() wartet, bis keine andere Anfrage mehr laeuft und 50 ms lang keine neue kam (die uebrigen Schritte
* des Takts sind dann durch). ende() stellt fetch wieder her.
*/
function mitHaengern(...muster) {
const echt = globalThis.fetch
const tore = muster.map(() => {
const tor = { gehalten: false }
tor.warte = new Promise((resolve) => (tor.los = resolve))
return tor
})
let offen = 0
let anfragen = 0
globalThis.fetch = async (url, optionen) => {
anfragen += 1
const text = decodeURIComponent(String(url))
const methode = optionen?.method ?? 'GET'
const i = muster.findIndex((passt, k) => !tore[k].gehalten && passt(text, methode))
if (i >= 0) {
tore[i].gehalten = true
await tore[i].warte
return echt(url, optionen)
}
offen += 1
try {
return await echt(url, optionen)
} finally {
offen -= 1
}
}
return {
freigeben: () => tore.forEach((tor) => tor.los()),
gehalten: () => tore.every((tor) => tor.gehalten),
async warteBisRuhig() {
for (let ruhig = 0; ruhig < 5; ) {
const stand = anfragen
await new Promise((resolve) => setTimeout(resolve, 10))
ruhig = offen === 0 && stand === anfragen ? ruhig + 1 : 0
}
},
ende: () => {
globalThis.fetch = echt
},
}
}
/** Alle Lese-Anfragen an die Collection scheitern wie in Appwrite mit 404 collection_not_found. */
async function mitFehlenderCollection(collection, fn) {
@@ -1974,46 +2022,111 @@ test('fehlende Collection der Warteschlange: der Minutenjob verfällt trotzdem H
assert.equal(warnLog().length, 1)
})
test('überlappende Läufe: ein langsamer Lauf wird vom nächsten Takt übersprungen (einmal im Log), danach geht es normal weiter', async () => {
test('überlappende Läufe: hängen beide Teile des Minutenjobs, werden die nächsten Takte übersprungen (je Teil einmal im Log), danach geht es normal weiter', async () => {
await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, -MIN)) })
const echt = globalThis.fetch
let freigeben
const tor = new Promise((resolve) => (freigeben = resolve))
let erste = true
globalThis.fetch = async (url, optionen) => {
if (erste) {
erste = false
await tor
}
return echt(url, optionen)
}
const haenger = mitHaengern(listeVonBuchungen(/heldUntil/), listeVonOutbox)
try {
const langsam = minutenJob(JETZT)
await haenger.warteBisRuhig()
assert.ok(haenger.gehalten(), 'Verfall und Mailversand hängen')
assert.deepEqual(await minutenJob(plus(JETZT, MIN)), { uebersprungen: true })
assert.deepEqual(await minutenJob(plus(JETZT, 2 * MIN)), { uebersprungen: true })
assert.equal(warnLog().filter((zeile) => /minutenJob/.test(zeile)).length, 1, 'nur einmal gemeldet')
assert.equal(warnLog().filter((zeile) => /minutenJob \(Verfall\)/.test(zeile)).length, 1, 'Verfall nur einmal gemeldet')
assert.equal(warnLog().filter((zeile) => /minutenJob \(Mailversand\)/.test(zeile)).length, 1, 'Mailversand nur einmal gemeldet')
assert.equal(warnLog().filter((zeile) => /minutenJob/.test(zeile)).length, 2, 'sonst keine Warnung')
// andere Jobs sind davon nicht betroffen
assert.equal((await fuenfMinutenJob(JETZT)).uebersprungen, undefined)
freigeben()
haenger.freigeben()
assert.equal((await langsam).verfallen, 1)
} finally {
globalThis.fetch = echt
haenger.freigeben()
haenger.ende()
}
assert.equal((await minutenJob(plus(JETZT, 3 * MIN))).uebersprungen, undefined, 'danach läuft der Job wieder')
assert.deepEqual(await minutenJob(plus(JETZT, 3 * MIN)), { verfallen: 0, faellig: 0, gesendet: 0, fehler: 0 }, 'danach laufen beide Teile wieder')
})
test('ein Job, der mit einem Fehler endet, gibt die Überlappungssperre frei', async () => {
test('überlappende Läufe: hängt der Verfall, versendet der nächste Takt trotzdem die Warteschlange (Überlappung einmal im Log)', async () => {
await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, -MIN)) })
legeEintrag('o_erst')
const haenger = mitHaengern(listeVonBuchungen(/heldUntil/))
try {
const langsam = minutenJob(JETZT)
await haenger.warteBisRuhig()
assert.ok(haenger.gehalten(), 'der Verfall hängt')
// beide Teile starten gemeinsam: die Warteschlange ist abgearbeitet, obwohl der Verfall des ersten Takts noch hängt
assert.equal(aw.docs('termineOutbox').get('o_erst').status, 'gesendet')
assert.equal(status('b_vorbei'), 'gehalten')
legeEintrag('o_zweit')
const zweiter = await minutenJob(plus(JETZT, MIN))
assert.deepEqual(zweiter, { verfallen: 0, faellig: 1, gesendet: 1, fehler: 0, verfallUebersprungen: true })
assert.equal(aw.docs('termineOutbox').get('o_zweit').status, 'gesendet')
assert.equal(sink.sent.length, 2)
const dritter = await minutenJob(plus(JETZT, 2 * MIN))
assert.equal(dritter.verfallUebersprungen, true)
assert.equal(dritter.uebersprungen, undefined, 'nur ein Teil übersprungen: der Takt gilt nicht als übersprungen')
assert.equal(warnLog().filter((zeile) => /minutenJob \(Verfall\)/.test(zeile)).length, 1, 'die Überlappung steht einmal im Log')
assert.equal(warnLog().filter((zeile) => /minutenJob/.test(zeile)).length, 1, 'der Mailversand wurde nicht übersprungen')
haenger.freigeben()
assert.deepEqual(await langsam, { verfallen: 1, faellig: 1, gesendet: 1, fehler: 0 })
} finally {
haenger.freigeben()
haenger.ende()
}
assert.equal(status('b_vorbei'), 'verfallen')
assert.deepEqual(await minutenJob(plus(JETZT, 3 * MIN)), { verfallen: 0, faellig: 0, gesendet: 0, fehler: 0 })
})
test('überlappende Läufe: hängt der Mailversand, verfällt im nächsten Takt eine abgelaufene Haltung trotzdem (Überlappung einmal im Log)', async () => {
const haenger = mitHaengern(listeVonOutbox)
try {
const langsam = minutenJob(JETZT)
await haenger.warteBisRuhig()
assert.ok(haenger.gehalten(), 'der Mailversand hängt')
await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, MIN)) })
assert.ok(claims('b_vorbei').length > 0)
const zweiter = await minutenJob(plus(JETZT, 2 * MIN))
assert.deepEqual(zweiter, { verfallen: 1, faellig: 0, gesendet: 0, fehler: 0, mailUebersprungen: true })
assert.equal(status('b_vorbei'), 'verfallen')
assert.equal(claims('b_vorbei').length, 0, 'Zellen frei')
const dritter = await minutenJob(plus(JETZT, 3 * MIN))
assert.equal(dritter.mailUebersprungen, true)
assert.equal(dritter.uebersprungen, undefined)
assert.equal(warnLog().filter((zeile) => /minutenJob \(Mailversand\)/.test(zeile)).length, 1, 'die Überlappung steht einmal im Log')
assert.equal(warnLog().filter((zeile) => /minutenJob/.test(zeile)).length, 1, 'der Verfall wurde nicht übersprungen')
haenger.freigeben()
assert.deepEqual(await langsam, { verfallen: 0, faellig: 0, gesendet: 0, fehler: 0 })
} finally {
haenger.freigeben()
haenger.ende()
}
assert.deepEqual(await minutenJob(plus(JETZT, 4 * MIN)), { verfallen: 0, faellig: 0, gesendet: 0, fehler: 0 })
})
test('ein Job, der mit einem Fehler endet, gibt die Überlappungssperren frei', async () => {
const echt = globalThis.fetch
globalThis.fetch = async () => {
throw new TypeError('fetch failed')
}
try {
const erg = await minutenJob(JETZT)
assert.equal(erg.fehler, 2, 'Verfall und Warteschlange scheitern, der Job wirft nicht')
assert.equal(erg.fehler, 2, 'Verfall und Warteschlange scheitern (je Teil ein Fehler), der Job wirft nicht')
assert.deepEqual(
fehlerLog()
.map((zeile) => /^\[termine-jobs\] minutenJob: (Verfall|Warteschlange):/.exec(zeile)?.[1])
.filter(Boolean)
.sort(),
['Verfall', 'Warteschlange'],
'das Fehlerlog behält minutenJob als Jobnamen',
)
} finally {
globalThis.fetch = echt
}
assert.equal((await minutenJob(JETZT)).uebersprungen, undefined)
assert.deepEqual(await minutenJob(JETZT), { verfallen: 0, faellig: 0, gesendet: 0, fehler: 0 }, 'beide Sperren sind frei')
assert.equal(warnLog().filter((zeile) => /nicht fertig/.test(zeile)).length, 0)
})
// ---------------------------------------------------------------- Einbindung