Geschäftsregeln für Stammdaten (Business Rules)
Einleitung
Die Stammdaten des Adapters – UnitOfMeasurement, UnitOfMeasurementKey, TelemetryKey und ObservedProperty – liegen in der Adapter‑Datenbank und referenzieren sich gegenseitig; ObservedProperty wird zusätzlich als Kopie in den FROST‑Server jedes Mandanten repliziert (siehe unten). Damit über die REST‑API bzw. die Verwaltungsoberfläche keine inkonsistenten Zustände entstehen können, prüfen die jeweiligen Module unter src/thingsboard_frost_adapter/services/ vor Lösch‑ und Umbenennungs‑Operationen die Referenzen auf verwandte Entitäten. Die Regeln liegen dort und nirgends sonst: Die Router setzen einen Aufruf ab, die Repositories entscheiden nichts (siehe Architektur im Detail).
Alle Regelverletzungen werden über die Exception BusinessRuleViolationError (thingsboard_frost_adapter/exceptions/business_rule_violation_error.py) signalisiert. Ein globaler FastAPI‑Exception‑Handler in main.py wandelt diese Exception in eine HTTP‑Antwort mit Status 422 Unprocessable Entity und einer sprechenden Fehlermeldung im Body um.
Referenzprüfung beim Löschen
| Entität | Löschen wird verhindert, wenn… | Implementiert in |
|---|---|---|
|
noch ein |
|
|
noch ein |
|
|
in irgendeinem Mandanten ein FROST‑Datastream existiert, dessen Name dem Telemetry Key entspricht, oder ein Mandant nicht erreichbar ist |
|
Die Stammdatentabellen sind global – ihre Routen führen keinen Mandanten‑Slug –, ihre FROST‑Prüfungen laufen deshalb über alle Mandanten. Jede Prüfung fragt pro Mandant dessen eigenen FROST‑Server über das TenantFrostRepository dieses Mandanten (repositories/external/frost_repository.py, konstruiert über frost_repository_for(tenant)):
-
find_datastreams_by_name– Datastreams, deren Name einem Telemetry Key entspricht -
find_datastreams_by_uom_name– Datastreams, derenunitOfMeasurement.nameeiner lokalen Maßeinheit entspricht -
find_datastreams_by_observed_property_id– Datastreams, die die dortige Kopie einerObservedPropertyreferenzieren (über diefrost_idaus der Zuordnungstabelle)
Alle drei Prüfungen sind fail‑closed (services/cross_tenant.py::reject_if_any_tenant_uses): Sie müssen von jedem Mandanten ein „nicht in Benutzung“ hören. Ein Mandant, der nicht antworten kann, verhindert das Löschen genauso wie einer, der die Daten noch benutzt – „nicht erreichbar“ ist kein Beleg für „nicht in Benutzung“. Die Fehlermeldung nennt in beiden Fällen den Mandanten.
| Eine tote Mandantenzeile, deren FROST‑Server bereits abgebaut wurde, lässt damit jede Stammdatenlöschung dauerhaft scheitern, weil die Prüfung nie eine Antwort bekommt. Ein Force‑Flag gibt es bewusst nicht; der Ausweg ist das Löschen der Mandantenzeile. |
UnitOfMeasurementKey selbst hat keine eigene Löschsperre, da keine Entität darauf zurückverweist.
Validierung beim Bearbeiten
telemetry_key_service.update prüft beim Setzen von observed_property_id, dass unter dieser Id tatsächlich eine ObservedProperty existiert – ein Select gegen die kanonische Postgres‑Tabelle (observed_property_repository.get_by_id), nicht gegen FROST. Existiert sie nicht, wird die Änderung mit einer BusinessRuleViolationError abgelehnt, statt einen Telemetry Key mit einer nicht existenten Zuordnung zu speichern.
Die Spalte trägt seit dem Umbau auf einen echten Fremdschlüssel (ondelete="RESTRICT") die Id und nicht mehr den Namen der Observed Property. Der Wächter bleibt trotzdem: RESTRICT verweigert mit einem IntegrityError, der weder sagt, welches Feld falsch war, noch als 422 beim Aufrufer ankommt. Die Prüfung läuft nur auf dem PATCH‑Pfad, weil dort der Wert steht, den ein Mensch in der Oberfläche ausgewählt hat; eine unsinnige Id an POST fängt die Bedingung ab.
ObservedProperty: kanonisch in Postgres, kopiert je Mandant
ObservedProperty ist die einzige Stammdatenentität mit einer eigenen physischen FROST‑Identität. Die Wahrheit liegt in der Postgres‑Tabelle observedproperty; jeder Mandant hält in seinem FROST eine abgeleitete Kopie. Welche FROST‑Entität die Kopie ist, steht in tenantobservedproperty (tenant_id, observed_property_id → frost_id); die Spalten dieser Tabelle stehen in Datenbankschema des Adapters.
Lesen, Auflösen, Schreiben
-
Lesen (
get_all,get_by_id,get_by_name) sind reine Postgres‑Selects und sprechen keinen FROST‑Server an. -
Auflösen einer Kopie erfolgt ausschließlich über die hinterlegte
frost_id, niemals über den Namen. Fehlt die Zuordnung, ist die Kopie nie angekommen und wird angelegt; antwortet FROST auf die hinterlegte ID mit404, wurde sie außerhalb des Adapters gelöscht und wird neu angelegt. Eine namensbasierte Auflösung kann diese beiden Fälle nicht unterscheiden und würde im zweiten Fall ein Duplikat erzeugen. -
Schreiben (Anlegen, Ändern, Löschen der Kopien) läuft best‑effort über alle Mandanten. Postgres wird in jedem Fall committet; nicht erreichte Mandanten stehen im Feld
failed_tenantsder Antwort und werden protokolliert.
Der Abgleich
-
Abgleich:
POST /tenant/{id}/sync-observed-properties(in der Oberfläche der Button „Sync observed properties“ je Mandant) stellt idempotent sicher, dass jeder kanonische Eintrag im FROST des Mandanten vorhanden und aktuell ist. Ein und derselbe Code‑Pfad dient als Wiederholung einer fehlgeschlagenen Übertragung, als Onboarding‑Schritt für einen neuen Mandanten und als Cutover‑Schritt. -
Der Abgleich ist rein ergänzend: er löscht nichts im FROST des Mandanten. Kopien ohne Zuordnungszeile werden nicht angetastet — weder die unbenutzten, die durch die geschlossene Löschprüfung nachweislich inert sind und deshalb auch nicht gemeldet werden, noch die benutzten, deren Löschen in SensorThings kaskadierend Datastreams und Observations vernichten würde. Benutzte, nicht verwaltete Kopien werden ausschließlich gemeldet (
unmanaged_in_use): dieser Zustand — eine Größe auf zwei Entitäten verteilt — lässt sich nicht automatisch reparieren und erfordert eine menschliche Entscheidung. -
Der Abgleich bricht beim ersten Fehler ab und die Route antwortet mit
502unter Nennung des Mandanten. Der realistische Fehlerfall ist nicht eine einzelne Eigenschaft, sondern ein nicht erreichbarer FROST, der jede Übertragung gleichermaßen scheitern lässt. Da der Abgleich idempotent ist, ist ein Abbruch unkritisch: die Wiederholung erledigt den Rest. -
Wie viele Einträge ein Abgleich übertragen hat, steht in der Logzeile, nicht in der Antwort; die Namen neu angelegter Kopien protokolliert der Adapter beim Anlegen selbst. Eine leere Antwort bedeutet: alles in Ordnung.
Aufnahme und Stilllegung eines Mandanten
Die Aufnahme eines Mandanten ist eine Abfolge, keine eigene Operation: es gibt weder einen onboard‑Endpunkt noch eine Spalte onboarded_at. POST /tenant (services/tenant_service.py::onboard) erledigt beides in einem Aufruf und ohne Neustart:
-
Prüfen der Zugangsdaten – fail‑closed, in zwei Stufen. Zuerst die Form: Zugangsdaten, die nicht zur konfigurierten ThingsBoard‑Edition passen, werden mit
422abgelehnt, bevor irgendein Fremdsystem angesprochen wird — ein API‑Key gegen eine Community Edition ist kein Zugang, der vielleicht funktioniert, sondern einer, der es nie kann. Danach die Erreichbarkeit: Schlägt eine der drei Prüfungen fehl, antwortet die Route mit400. In beiden Fällen wird keine Zeile angelegt. -
Schreiben der Mandantenzeile.
-
Replizieren der kanonischen Observed Properties in den FROST des Mandanten – best‑effort, über denselben Abgleich wie der Button „Sync observed properties“.
Die beiden letzten Schritte folgen bewusst nicht derselben Regel: Ein fehlgeschlagener Abgleich rollt die Zeile nicht zurück. Die Kopien entstehen spätestens beim ersten Telemetriezugriff, ein fehlgeschlagener Abgleich ist also eine Verzögerung und kein Defekt – eine geprüfte Zeile dafür zu verwerfen wäre schlechter. Die Antwort trägt deshalb beides: {tenant, sync}, wobei sync: null bedeutet, dass die Replikation nicht durchgelaufen ist und über den Sync‑Button wiederholt werden sollte.
Die Zugangsdaten werden ausschließlich beim Schreiben geprüft und danach nie wieder; ein rotiertes ThingsBoard‑Zugangsdatum fällt erst durch Telemetrieverkehr auf und wird über GET /tenant/{id}/health sichtbar. Was der Adapter von selbst überbrückt, ist nur die ablaufende Sitzung: Der Client‑Cache baut das Paar eines Mandanten neu, bevor dessen ThingsBoard‑Token abläuft. Nötig ist das unter der Community Edition, weil deren Token nach 150 Minuten stirbt, der Cache aber die Lebensdauer des Prozesses hat. Der Adapter schreibt dabei zu keinem Zeitpunkt in ThingsBoard – die Rule Chain des Mandanten wird von Hand angelegt und bleibt in der Hand des Betreibers.
PATCH mit geändertem frost_server_url
Ändert ein PATCH den FROST‑Server, werden alle Zuordnungszeilen dieses Mandanten gelöscht und der Abgleich läuft erneut. Grund: Jede hinterlegte frost_id ist eine ID im alten Server. FROST vergibt kleine fortlaufende Ganzzahlen, ein frisch bereitgestellter Server antwortet auf ID 3 also durchaus mit irgendeiner Entität – die Auflösung würde einer bestehenden Zuordnung vertrauen und neue Datastreams still an fremde Entitäten hängen. Ein PATCH, der die URL nicht anfasst, löst keinen Abgleich aus; die Zuordnungen sind weiterhin gültig.
Der FROST‑Server bleibt änderbar (im Gegensatz zum slug): Da Löschen der einzige andere Ausweg und das ThingsBoard‑Geheimnis nur schreibbar ist, würde „löschen und neu anlegen“ bedeuten, für einen Tippfehler in der URL die ThingsBoard‑Zugangsdaten erneut eingeben zu müssen.
DELETE /tenant/{id} ist auf Things abgesichert
Die Route fragt den FROST des Mandanten und lehnt mit 409 ab, wenn dort noch ein Thing existiert – oder wenn der Server nicht erreichbar ist. Fail‑closed wie die Stammdatenprüfungen: „nicht erreichbar“ ist kein Beleg für „leer“. Geprüft werden Things und nicht Datastreams, weil ein Datastream in SensorThings ohne Thing nicht existieren kann – „keine Things“ ist die stärkere Bedingung und „dieser Mandant hat noch Geräte registriert“ die bessere Meldung.
|
Reihenfolge der Stilllegung: Geräte des Mandanten in ThingsBoard aus der Publish‑to‑FROST‑Gruppe entfernen → Mandanten im Adapter löschen → danach die FROST‑Instanz abbauen. Der erste Schritt ist keine für die Prüfung erfundene Zusatzarbeit: |
Ein erfolgreiches Löschen entfernt die Zeile und kaskadierend ihre TenantObservedProperty‑Zuordnungen; der FROST‑Server des Mandanten und seine Daten bleiben unangetastet. Ein Force‑Flag gibt es bewusst nicht: Bei zwei bis drei Mandanten ist das Geraderücken einer falsch sortierten Stilllegung von Hand vertretbar, während das Flag eine dauerhafte Möglichkeit wäre, genau den Zustand herzustellen, den die Zuordnungstabelle verhindert. Ein Mandant, der nur pausieren soll, braucht davon nichts – eine Zeile ohne Verkehr kostet nichts.
Daraus folgt die Invariante, auf der die Selbstheilung beruht: Der Adapter registriert einen Mandanten nur gegen einen FROST ohne Datastreams. Löschen und neu anlegen konvergiert deshalb – keine Zuordnungszeilen, der Abgleich legt frische Kopien an, und die alten Kopien bleiben unbenutzt und inert liegen (siehe oben: der Abgleich löscht nichts).
Propagation von Umbenennungen nach FROST
Wird der Name bzw. das Symbol/die Definition einer lokal verwalteten Entität geändert, überträgt der jeweilige Service diese Änderung best‑effort auf die zugehörigen FROST‑Datastreams:
| Auslöser | Wirkung auf FROST |
|---|---|
|
|
|
|
|
|
Alle drei Übertragungen laufen über alle Mandanten (services/cross_tenant.py::push_to_every_tenant) und sind best‑effort: Schlägt die Aktualisierung eines einzelnen Datastreams fehl, wird der Fehler protokolliert (logger.exception) und die Verarbeitung der übrigen Datastreams fortgesetzt; schlägt ein ganzer Mandant fehl, werden die übrigen trotzdem bedient. Die lokale Änderung wird in jedem Fall übernommen; es erfolgt kein Rollback. Nicht erreichte Mandanten stehen im Feld failed_tenants der Antwort, das die Oberfläche als Hinweis anzeigt.
Jeder Schreibzugriff auf einen Datastream läuft dabei unter dem Thing‑Lock dieses Mandanten (entity_lock(slug, LockedEntity.THING, …)), damit eine Umbenennung nicht mit dem Telemetriepfad kollidiert und Mandanten sich nicht gegenseitig blockieren, wenn sie zufällig ein gleichnamiges Gerät haben.
Für ObservedProperty ist die Wiederholung der Abgleich („Sync observed properties“). Für Telemetry Keys und Maßeinheiten gibt es sie nicht: Diese Entitäten haben keine eigene FROST‑Identität, sondern stehen nur als Name bzw. eingebetteter Wert in den Datastreams. Sobald die kanonische Zeile den neuen Namen trägt, findet keine Abfrage die nicht umbenannten Datastreams mehr – sie müssen im FROST des betroffenen Mandanten von Hand korrigiert werden.