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

+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