Terminplaner: Zellen nur unter der Ressourcensperre freigeben, gemeinsame Hilfsfunktionen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent e281db1b2b
commit 9ad8515d60
6 files changed
+284 -71

No files matched your search

+89 -35
View File
@@ -3,6 +3,7 @@ import { config } from '../../config.js'
import { Query, createDocuments, deleteDocument, listAllDocuments } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { istBelegend } from './slots.js'
import { pruefeNow, pruefeText, zeitMs } from './util.js'
/**
* Belegungszellen gegen Doppelbuchung (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 5).
@@ -15,7 +16,13 @@ import { istBelegend } from './slots.js'
* Ob eine vorhandene Zelle noch gilt, entscheidet allein ihre Buchung: fehlt sie, ist sie nicht belegend (siehe
* istBelegend in slots.js) oder ist die Haltung abgelaufen, ist die Zelle frei. Solche Zellen loescht belegeZellen erst,
* wenn sie eine Belegung blockieren (danach genau ein zweiter Versuch), der taegliche Job raeumt den Rest
* (verwaisteZellen).
* (raeumeVerwaisteZellen; verwaisteZellen nennt sie nur).
*
* Geloescht wird nie ausserhalb der Ressourcensperre und nie nach einer veralteten Liste: Zellen haben feste IDs, eine
* neue Buchung legt dieselben IDs neu an, sobald die alten als ungueltig gelten. Ein spaeter Loeschauftrag nach einer
* frueher gelesenen Liste wuerde die frischen Zellen der neuen Buchung treffen (Doppelbuchung). Darum lesen
* gibZellenFrei und raeumeVerwaisteZellen unter der Sperre jeder betroffenen Ressource noch einmal und loeschen nur,
* was dort jetzt noch zur freizugebenden Buchung gehoert bzw. noch verwaist ist.
*
* Reihenfolge fuer Aufrufer: die Buchung (Status gehalten mit heldUntil bzw. der Zielstatus) steht VOR ihren Zellen in
* der Datenbank. Eine Zelle, deren Buchung noch fehlt, gilt als frei; eine parallele Anfrage koennte sie in der kurzen
@@ -27,6 +34,11 @@ import { istBelegend } from './slots.js'
const MAX_ZELLEN = 100
const TAG_MS = 24 * 60 * 60 * 1000
const SPERRE_WARTE_MS = 5000
/** Aufraeumen darf laenger warten als eine Buchung: nach dem Ablauf liefe es ohne Sperre weiter (siehe withLocks). */
const AUFRAEUM_WARTE_MS = 15000
/** Zellen je Sperre beim Raeumen (haelt die Sperre kurz) und Wiederholungen, falls Zellen auf weiteren Ressourcen auftauchen. */
const RAEUM_BLOCK = 100
const RAEUM_RUNDEN = 3
/** Werte je $id-Abfrage (haelt die Adresse kurz) und gleichzeitige Loeschungen. */
const ID_BLOCK = 50
const LOESCH_PARALLEL = 10
@@ -35,23 +47,7 @@ const ALLE_ZELLEN_MAX = 100000
const claims = () => config.collections.termineClaims
const buchungen = () => config.collections.termineBookings
/** Zeitpunkt als ms: Date, ISO-Text (auch mit +00:00 wie von Appwrite) oder Zahl; sonst NaN. */
function zeitMs(wert) {
if (wert instanceof Date) return wert.getTime()
if (typeof wert === 'string' && wert !== '') return new Date(wert).getTime()
if (typeof wert === 'number') return wert
return Number.NaN
}
function pruefeText(wert, name) {
if (typeof wert !== 'string' || wert === '') throw new TypeError(`${name} fehlt`)
}
function pruefeJetzt(now, name) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError(`${name}: now fehlt`)
return now.getTime()
}
const sperrSchluessel = (featureId, ressourceId) => `termine-res:${featureId}:${ressourceId}`
const slotVergeben = () =>
Object.assign(new Error('Dieser Termin wurde gerade vergeben. Bitte wählen Sie eine andere Zeit.'), {
@@ -155,7 +151,7 @@ export async function belegeZellen({ featureId, ressourceId, bookingId, start, b
pruefeText(ressourceId, 'ressourceId')
pruefeText(bookingId, 'bookingId')
if (ausser !== null) pruefeText(ausser, 'ausser')
const nowMs = pruefeJetzt(now, 'belegeZellen')
const nowMs = pruefeNow(now, 'belegeZellen')
const zellen = zellenFuer(start, blockEnde)
const bis = new Date(zeitMs(blockEnde)).toISOString()
const dokumente = zellen.map((zelle) => ({
@@ -168,7 +164,7 @@ export async function belegeZellen({ featureId, ressourceId, bookingId, start, b
}))
await withLocks(
[`termine-res:${featureId}:${ressourceId}`],
[sperrSchluessel(featureId, ressourceId)],
async () => {
let neu = dokumente
if (ausser !== null) {
@@ -187,30 +183,88 @@ export async function belegeZellen({ featureId, ressourceId, bookingId, start, b
/**
* Loescht die Zellen einer Buchung, ausser denen mit den Zeiten aus behalte (Date oder ISO-Text, z. B. das Ergebnis
* von zellenFuer). Mit ressourceId bleiben nur Zellen dieser Ressource stehen (Verschieben auf eine andere Person zur
* selben Zeit). Liest seitenweise (mehr als 25 Zellen) und loescht alle; => Zahl geloeschter Zellen.
* selben Zeit). Liest seitenweise (mehr als 25 Zellen). Geloescht wird unter der Sperre jeder betroffenen Ressource
* nach erneutem Lesen: nur Zellen, die dort jetzt noch zu bookingId gehoeren (siehe Kopfkommentar). => Zahl geloeschter.
*/
export async function gibZellenFrei(bookingId, { behalte = [], ressourceId = null } = {}) {
pruefeText(bookingId, 'bookingId')
const behalten = new Set([...behalte].map(zeitMs).filter(Number.isFinite))
const zellen = await listAllDocuments(claims(), [Query.equal('bookingId', bookingId)])
const weg = zellen.filter((zelle) => !(behalten.has(zeitMs(zelle.zelle)) && (ressourceId === null || zelle.ressourceId === ressourceId)))
return loesche(claims(), weg.map((zelle) => zelle.$id))
const bleibt = (zelle) => behalten.has(zeitMs(zelle.zelle)) && (ressourceId === null || zelle.ressourceId === ressourceId)
const suche = [Query.equal('bookingId', bookingId)]
let geloescht = 0
for (let runde = 0; runde < RAEUM_RUNDEN; runde += 1) {
const gelesen = await listAllDocuments(claims(), suche) // nur um die Ressourcen zu kennen, geloescht wird nach neuem Lesen
const schluessel = [...new Set(gelesen.map((zelle) => sperrSchluessel(zelle.featureId, zelle.ressourceId)))].sort()
if (schluessel.length === 0) break
const gesperrt = new Set(schluessel)
const vollstaendig = await withLocks(
schluessel,
async () => {
const aktuell = await listAllDocuments(claims(), suche)
const hier = aktuell.filter((zelle) => gesperrt.has(sperrSchluessel(zelle.featureId, zelle.ressourceId)))
geloescht += await loesche(claims(), hier.filter((zelle) => !bleibt(zelle)).map((zelle) => zelle.$id))
return hier.length === aktuell.length
},
{ waitMs: AUFRAEUM_WARTE_MS }
)
if (vollstaendig) break
}
return geloescht
}
/** Verwaiste Zellen aus zellen: bis laenger als einen Tag vorbei, oder die Buchung fehlt bzw. belegt nicht mehr. */
async function verwaisteAus(zellen, nowMs) {
const grenze = nowMs - TAG_MS
const alt = []
const pruefen = []
for (const zelle of zellen) (zeitMs(zelle.bis) < grenze ? alt : pruefen).push(zelle)
return [...alt, ...(await ungueltigeZellen(pruefen, nowMs))]
}
const alleZellen = () => listAllDocuments(claims(), [], { max: ALLE_ZELLEN_MAX })
/**
* IDs der Zellen, die der taegliche Job loeschen darf: bis laenger als einen Tag vorbei, oder die Buchung fehlt bzw.
* belegt nicht mehr (abgesagt, abgelehnt, verfallen, erledigt, nichterschienen, abgelaufene Haltung).
* belegt nicht mehr (abgesagt, abgelehnt, verfallen, erledigt, nichterschienen, abgelaufene Haltung). Nur Bericht:
* zum Loeschen NICHT diese Liste benutzen (sie veraltet), sondern raeumeVerwaisteZellen.
*/
export async function verwaisteZellen(now) {
const nowMs = pruefeJetzt(now, 'verwaisteZellen')
const grenze = nowMs - TAG_MS
const alle = await listAllDocuments(claims(), [], { max: ALLE_ZELLEN_MAX })
const verwaist = []
const pruefen = []
for (const zelle of alle) {
if (zeitMs(zelle.bis) < grenze) verwaist.push(zelle.$id)
else pruefen.push(zelle)
const nowMs = pruefeNow(now, 'verwaisteZellen')
return (await verwaisteAus(await alleZellen(), nowMs)).map((zelle) => zelle.$id)
}
/**
* Loescht die verwaisten Zellen (Regeln wie verwaisteZellen) selbst: je Ressource in Bloecken zu 100 unter deren
* Sperre, nach erneutem Lesen der Zellen und ihrer Buchungen. Eine Zelle, die inzwischen einer anderen Buchung gehoert
* oder wieder gilt, bleibt. Fehler eines Blocks stoppen die anderen nicht; der erste wird am Ende geworfen.
* => Zahl geloeschter Zellen.
*/
export async function raeumeVerwaisteZellen(now) {
const nowMs = pruefeNow(now, 'raeumeVerwaisteZellen')
const nachSperre = new Map()
for (const zelle of await verwaisteAus(await alleZellen(), nowMs)) {
const schluessel = sperrSchluessel(zelle.featureId, zelle.ressourceId)
nachSperre.set(schluessel, [...(nachSperre.get(schluessel) ?? []), zelle])
}
for (const zelle of await ungueltigeZellen(pruefen, nowMs)) verwaist.push(zelle.$id)
return verwaist
let geloescht = 0
let fehler = null
for (const [schluessel, liste] of nachSperre) {
for (let i = 0; i < liste.length; i += RAEUM_BLOCK) {
const block = new Map(liste.slice(i, i + RAEUM_BLOCK).map((zelle) => [zelle.$id, zelle.bookingId]))
try {
geloescht += await withLocks(
[schluessel],
async () => {
const aktuell = (await leseNachIds(claims(), [...block.keys()])).filter((zelle) => block.get(zelle.$id) === zelle.bookingId)
return loesche(claims(), (await verwaisteAus(aktuell, nowMs)).map((zelle) => zelle.$id))
},
{ waitMs: AUFRAEUM_WARTE_MS }
)
} catch (err) {
fehler ??= err
}
}
}
if (fehler) throw fehler
return geloescht
}