import { createHash } from 'node:crypto' import { config } from '../../config.js' import { Query, createDocuments, deleteDocument, listAllDocuments, listDocuments } 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). * * Jede 5-Minuten-Zelle (config.termine.zelleMin) von Beginn bis Blockende ist ein termineClaims-Dokument mit * deterministischer ID (c_ + 34 Hex von sha1(featureId|ressourceId|zelleIso)). Alle neuen Zellen einer Belegung gehen * in EINEM Bulk-Create (hoechstens 100 Zellen = 500 Minuten): Appwrite schreibt alles oder nichts, eine vergebene ID * ergibt 409 document_already_exists. Zusaetzlich serialisiert withLocks je Ressource innerhalb des Prozesses. * * Ob eine vorhandene Zelle noch gilt, entscheidet zuerst das Zeitfenster, danach ihre Buchung: eine Zelle, deren Ende mehr * als einen Tag zurueckliegt, ist ohne Buchungsabfrage ungueltig; bei allen anderen ist sie frei, wenn ihre Buchung fehlt, * nicht belegend ist (siehe istBelegend in slots.js) oder die Haltung abgelaufen ist. Solche Zellen loescht belegeZellen * erst, wenn sie eine Belegung blockieren (danach genau ein zweiter Versuch). Der taegliche Job raeumt nur noch, was mehr als * einen Tag vorbei ist (raeumeVerwaisteZellen, seitenweise ueber idx_bis, mit Deckel); eine Zelle mit kuenftigem Ende und * fehlender oder nicht mehr belegender Buchung (nur moeglich, wenn gibZellenFrei scheiterte) stoert nicht und faellt * spaetestens einen Tag nach ihrem Ende in diesen Sweep. * * 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. (Sweep) noch dieselbe Buchung hat und noch alt 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 * Zeit zwischen Zellen und Buchung wegraeumen. Scheitert belegeZellen (SLOT_VERGEBEN), raeumt der Aufrufer die * Buchung selbst wieder auf. Beim Verschieben ist die Buchung ohnehin da. */ /** Hoechstens so viele Zellen in einem Bulk-Create (Appwrite: 100 Dokumente je Anfrage). */ 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 /** Wiederholungen beim Freigeben, falls Zellen auf weiteren Ressourcen auftauchen. */ const RAEUM_RUNDEN = 3 /** Werte je $id-Abfrage (haelt die Adresse kurz), Seitengroesse dieser Abfragen und gleichzeitige Loeschungen. */ const ID_BLOCK = 50 const ID_SEITE = 100 const LOESCH_PARALLEL = 10 /** Zellen je Seite des Sweeps; zugleich die Zellen, die unter einer Sperre geloescht werden (haelt die Sperre kurz). */ const SWEEP_SEITE = 100 const SWEEP_FELDER = ['$id', 'featureId', 'ressourceId', 'bookingId', 'bis'] const claims = () => config.collections.termineClaims const buchungen = () => config.collections.termineBookings 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.'), { status: 409, code: 'SLOT_VERGEBEN', }) /** * Alle Zellen von start bis blockEnde (ausgeschlossen) als Date, ausgerichtet am Raster seit 1970 (die Berliner * Zeitzone verschiebt um ganze Stunden, daher stimmt das Raster mit der Wanduhr ueberein). Krumme Zeiten werden zu * ganzen Zellen erweitert. Wirft bei ungueltigen Zeiten, Ende <= Beginn und mehr als 100 Zellen. */ export function zellenFuer(start, blockEnde) { const von = zeitMs(start) const bis = zeitMs(blockEnde) if (!Number.isFinite(von) || !Number.isFinite(bis) || bis <= von) throw new RangeError('zellenFuer: Beginn und Ende ungültig') const zelle = config.termine.zelleMin * 60000 const erste = Math.floor(von / zelle) const letzte = Math.ceil(bis / zelle) if (letzte - erste > MAX_ZELLEN) throw new RangeError(`zellenFuer: mehr als ${MAX_ZELLEN} Zellen`) const zellen = [] for (let i = erste; i < letzte; i += 1) zellen.push(new Date(i * zelle)) return zellen } /** Dokument-ID einer Zelle: c_ + 34 Hex (36 Zeichen, das Maximum fuer Appwrite-IDs). */ export function zellenId(featureId, ressourceId, zelle) { const iso = new Date(zeitMs(zelle)).toISOString() return `c_${createHash('sha1').update(`${featureId}|${ressourceId}|${iso}`).digest('hex').slice(0, 34)}` } /** Dokumente einer Collection nach ihren IDs (in Bloecken, damit die Abfrage kurz bleibt; fehlende fehlen im Ergebnis). */ async function leseNachIds(collection, ids) { const gefunden = [] for (let i = 0; i < ids.length; i += ID_BLOCK) { gefunden.push(...(await listAllDocuments(collection, [Query.equal('$id', ids.slice(i, i + ID_BLOCK))], { pageSize: ID_SEITE }))) } return gefunden } /** Zeitfenster: das Ende der Zelle liegt zum Zeitpunkt nowMs mehr als einen Tag zurueck (genau ein Tag zaehlt noch nicht). */ const istZuAlt = (zelle, nowMs) => zeitMs(zelle.bis) < nowMs - TAG_MS /** * Zellen, die zum Zeitpunkt nowMs ungueltig sind. Zuerst das Zeitfenster: eine Zelle, deren Ende mehr als einen Tag zurueckliegt, * ist ohne Buchungsabfrage ungueltig; nur die uebrigen werden gegen ihre Buchung geprueft (fehlt oder belegt nichts). */ async function ungueltigeZellen(zellen, nowMs) { const alt = [] const pruefen = [] for (const zelle of zellen) (istZuAlt(zelle, nowMs) ? alt : pruefen).push(zelle) const buchungsIds = [...new Set(pruefen.map((zelle) => zelle.bookingId).filter((id) => typeof id === 'string' && id !== ''))] const vorhanden = new Map((await leseNachIds(buchungen(), buchungsIds)).map((buchung) => [buchung.$id, buchung])) return [ ...alt, ...pruefen.filter((zelle) => { const buchung = vorhanden.get(zelle.bookingId) return !buchung || !istBelegend(buchung, nowMs) }), ] } /** Loescht Dokumente (404 = schon weg) und versucht alle. => { geloescht, fehler (der erste andere Fehler | null) } */ async function loescheAlle(collection, ids) { let geloescht = 0 let fehler = null for (let i = 0; i < ids.length; i += LOESCH_PARALLEL) { const ergebnisse = await Promise.allSettled(ids.slice(i, i + LOESCH_PARALLEL).map((id) => deleteDocument(collection, id))) for (const ergebnis of ergebnisse) { if (ergebnis.status === 'fulfilled') geloescht += 1 else if (ergebnis.reason?.status !== 404) fehler ??= ergebnis.reason } } return { geloescht, fehler } } /** Loescht Dokumente (404 = schon weg); versucht alle, wirft danach den ersten anderen Fehler. => Zahl geloeschter. */ async function loesche(collection, ids) { const { geloescht, fehler } = await loescheAlle(collection, ids) if (fehler) throw fehler return geloescht } /** Ein Bulk-Create; false bei 409 (eine Zelle ist vergeben, nichts geschrieben), andere Fehler gehen durch. */ async function legeZellenAn(dokumente) { try { await createDocuments(claims(), dokumente) return true } catch (err) { if (err?.status === 409) return false throw err } } /** * Nach einem 409: die blockierenden Zellen lesen und die ungueltigen loeschen. true = ein zweiter Versuch lohnt * (ungueltige geloescht oder die Blockierer sind inzwischen weg), false = alle Blockierer gelten. */ async function raeumeBlockierer(ids, nowMs) { const blockierer = await leseNachIds(claims(), ids) if (blockierer.length === 0) return true const ungueltig = await ungueltigeZellen(blockierer, nowMs) if (ungueltig.length === 0) return false await loesche(claims(), ungueltig.map((zelle) => zelle.$id)) return true } /** * Belegt die Zellen von start bis blockEnde fuer bookingId auf einer Ressource oder wirft { status: 409, code: * 'SLOT_VERGEBEN' } (dann ist nichts geschrieben). Unter der Sperre termine-res:: (hoechstens * 5 s Wartezeit; die Datenbank bleibt die eigentliche Sicherung). * * ausser = ID einer Buchung, deren Zellen als eigene zaehlen (Verschieben): angelegt wird nur, was von ihr noch fehlt; * ihre alten Zellen bleiben, bis der Aufrufer gibZellenFrei(id, { behalte }) aufruft. Bei einem Fehler aendert sich an * ihnen nichts. */ export async function belegeZellen({ featureId, ressourceId, bookingId, start, blockEnde, now, ausser = null } = {}) { pruefeText(featureId, 'featureId') pruefeText(ressourceId, 'ressourceId') pruefeText(bookingId, 'bookingId') if (ausser !== null) pruefeText(ausser, 'ausser') const nowMs = pruefeNow(now, 'belegeZellen') const zellen = zellenFuer(start, blockEnde) const bis = new Date(zeitMs(blockEnde)).toISOString() const dokumente = zellen.map((zelle) => ({ $id: zellenId(featureId, ressourceId, zelle), featureId, ressourceId, bookingId, zelle: zelle.toISOString(), bis, })) await withLocks( [sperrSchluessel(featureId, ressourceId)], async () => { let neu = dokumente if (ausser !== null) { const eigene = new Set((await listAllDocuments(claims(), [Query.equal('bookingId', ausser)])).map((zelle) => zelle.$id)) neu = dokumente.filter((dokument) => !eigene.has(dokument.$id)) } if (neu.length === 0) return if (await legeZellenAn(neu)) return if (!(await raeumeBlockierer(neu.map((dokument) => dokument.$id), nowMs))) throw slotVergeben() if (!(await legeZellenAn(neu))) throw slotVergeben() }, { waitMs: SPERRE_WARTE_MS } ) } /** * 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). 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 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 } /** * Taeglicher Sweep: loescht Zellen, deren Ende mehr als einen Tag zurueckliegt (bis < jetzt - 1 Tag), unabhaengig von ihrer * Buchung (die Buchungen werden nicht gelesen). Seiten zu 100 Zellen ueber idx_bis (aelteste zuerst), nach jeder Seite ohne * Cursor neu gelesen (was geloescht ist, fehlt dann). Je Seite werden die Zellen nach Ressourcensperre gruppiert; unter der * Sperre jeder Gruppe liest der Sweep die Zellen noch einmal und loescht nur, was dort noch dieselbe Buchung hat und noch alt * ist (eine Zelle, die inzwischen einer anderen Buchung gehoert oder verlaengert wurde, bleibt). Fehler einer Gruppe stoppen * die anderen nicht; der erste wird am Ende geworfen. Eine Seite, auf der nichts geloescht wurde, beendet den Lauf, ebenso eine * Seite aus lauter schon versuchten Zellen (kein Endlosdurchlauf). Bewusst: Zellen mit kuenftigem Ende, deren Buchung fehlt * oder nichts mehr belegt, findet der Sweep nicht (belegeZellen raeumt sie bei Bedarf); sie fallen spaetestens einen Tag nach bis hinein. * * deckel = hoechstens so viele Zellen je Lauf (config.termine.zellenDeckel, bei jedem Aufruf gelesen); der Rest folgt am * naechsten Tag. gedeckelt = der Lauf endete am Deckel und es steht noch etwas an (0 = nur nachsehen). * => { geloescht, gedeckelt } */ export async function raeumeVerwaisteZellen(now, { deckel = config.termine.zellenDeckel } = {}) { const nowMs = pruefeNow(now, 'raeumeVerwaisteZellen') if (!Number.isInteger(deckel) || deckel < 0) throw new TypeError(`raeumeVerwaisteZellen: deckel muss eine ganze Zahl ab 0 sein, nicht ${deckel}`) const grenze = new Date(nowMs - TAG_MS).toISOString() const versucht = new Set() let geloescht = 0 let gedeckelt = false let fehler = null for (;;) { let seite try { seite = await listDocuments(claims(), [Query.lessThan('bis', grenze), Query.orderAsc('bis'), Query.limit(SWEEP_SEITE), Query.select(SWEEP_FELDER)]) } catch (err) { fehler ??= err break } const neu = seite.filter((zelle) => !versucht.has(zelle.$id)) if (neu.length === 0) break if (geloescht >= deckel) { gedeckelt = true break } const nachSperre = new Map() for (const zelle of neu.slice(0, deckel - geloescht)) { versucht.add(zelle.$id) const schluessel = sperrSchluessel(zelle.featureId, zelle.ressourceId) const gruppe = nachSperre.get(schluessel) if (gruppe) gruppe.push(zelle) else nachSperre.set(schluessel, [zelle]) } const vorher = geloescht for (const [schluessel, gruppe] of nachSperre) { const gelesen = new Map(gruppe.map((zelle) => [zelle.$id, zelle.bookingId])) try { const erg = await withLocks( [schluessel], async () => { const aktuell = (await leseNachIds(claims(), [...gelesen.keys()])).filter( (zelle) => gelesen.get(zelle.$id) === zelle.bookingId && istZuAlt(zelle, nowMs) ) return loescheAlle(claims(), aktuell.map((zelle) => zelle.$id)) }, { waitMs: AUFRAEUM_WARTE_MS } ) geloescht += erg.geloescht fehler ??= erg.fehler } catch (err) { fehler ??= err } } if (geloescht === vorher) break } if (fehler) throw fehler return { geloescht, gedeckelt } }