271 lines
13 KiB
JavaScript
271 lines
13 KiB
JavaScript
import { createHash } from 'node:crypto'
|
|
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).
|
|
*
|
|
* 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 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
|
|
* (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
|
|
* 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
|
|
/** 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
|
|
/** Der taegliche Job liest bewusst alle Zellen; mehr als das waere ein Fehler. */
|
|
const ALLE_ZELLEN_MAX = 100000
|
|
|
|
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))])))
|
|
}
|
|
return gefunden
|
|
}
|
|
|
|
/** Zellen, deren Buchung fehlt oder zum Zeitpunkt nowMs nichts belegt. */
|
|
async function ungueltigeZellen(zellen, nowMs) {
|
|
const buchungsIds = [...new Set(zellen.map((zelle) => zelle.bookingId).filter((id) => typeof id === 'string' && id !== ''))]
|
|
const vorhanden = new Map((await leseNachIds(buchungen(), buchungsIds)).map((buchung) => [buchung.$id, buchung]))
|
|
return zellen.filter((zelle) => {
|
|
const buchung = vorhanden.get(zelle.bookingId)
|
|
return !buchung || !istBelegend(buchung, nowMs)
|
|
})
|
|
}
|
|
|
|
/** Loescht Dokumente (404 = schon weg); versucht alle, wirft danach den ersten anderen Fehler. => Zahl geloeschter. */
|
|
async function loesche(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
|
|
}
|
|
}
|
|
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:<featureId>:<ressourceId> (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
|
|
}
|
|
|
|
/** 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). Nur Bericht:
|
|
* zum Loeschen NICHT diese Liste benutzen (sie veraltet), sondern raeumeVerwaisteZellen.
|
|
*/
|
|
export async function verwaisteZellen(now) {
|
|
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])
|
|
}
|
|
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
|
|
}
|