raeumeVerwaisteZellen(now, { deckel = config.termine.zellenDeckel }) liest
Zellen mit bis < jetzt - 1 Tag in Seiten zu 100 ueber idx_bis (aelteste
zuerst, ohne Cursor neu gelesen), gruppiert je Ressourcensperre mit Map und
loescht unter der Sperre nur, was dort noch dieselbe Buchung hat und noch alt
ist. Die Buchungen werden nicht gelesen. Der Lauf endet am Deckel (5000,
gedeckelt wird im Tagesjob gezaehlt und im Log gewarnt), bei einer leeren
Seite oder einer Seite ohne Erfolg; der erste Fehler wird am Ende geworfen.
ungueltigeZellen prueft zuerst das Zeitfenster (auch fuer belegeZellen).
verwaisteZellen, alleZellen und ALLE_ZELLEN_MAX entfallen.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
307 lines
15 KiB
JavaScript
307 lines
15 KiB
JavaScript
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:<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
|
|
}
|
|
|
|
/**
|
|
* 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 }
|
|
}
|