Zum Inhalt springen

Indizes

Status: Entwurf · Spec-Kandidat: ja

Pflicht-Indizes (Bestandteil des initialen DDL)

-- Eindeutigkeit nur unter aktiven (nicht soft-gelöschten) Datensätzen,
-- jeweils pro Tenant.
CREATE UNIQUE INDEX uq_entity_code_active
ON mdm.DDM_ENTITY (tenant_id, entity_type_id, code)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uq_entity_relation_active
ON mdm.DDM_ENTITY_RELATION (tenant_id, relation_type_id, from_entity_id, to_entity_id)
WHERE deleted_at IS NULL;
-- Lookup-Indizes auf Metadaten-Keys
CREATE INDEX ix_entity_type_key ON mdm.DDM_ENTITY_TYPE (key);
CREATE INDEX ix_enum_set_key ON mdm.DDM_ENUM_SET (key);
CREATE INDEX ix_relation_type_key ON mdm.DDM_RELATION_TYPE (key);
-- Attribut-Lookups je Entitätstyp (sortierbar)
CREATE INDEX ix_entity_type_attribute_entity_type
ON mdm.DDM_ENTITY_TYPE_ATTRIBUTE (entity_type_id, sort_order, key);
-- Häufigste Filter auf DDM_ENTITY (Tenant + Typ + Status, nur aktiv)
CREATE INDEX ix_entity_entity_type_status
ON mdm.DDM_ENTITY (tenant_id, entity_type_id, status)
WHERE deleted_at IS NULL;
CREATE INDEX ix_entity_deleted_at
ON mdm.DDM_ENTITY (deleted_at);
-- Volltext-Index (search_vector wird trigger-gepflegt, siehe Trigger-Spec)
CREATE INDEX ix_entity_search_vector_gin
ON mdm.DDM_ENTITY USING GIN (search_vector);
-- Hinweis: Ein generischer GIN auf `attributes` ist NICHT pauschal Teil des
-- Initial-DDL. Er wird selektiv pro DDM_ENTITY_TYPE aktiviert, abhängig vom
-- Flag DDM_ENTITY_TYPE.is_external_source_target. Siehe Abschnitt
-- "Search-Index-Strategie für JSONB-Attribute (OP-17)" weiter unten.
-- Beziehungs-Lookups
CREATE INDEX ix_entity_relation_from
ON mdm.DDM_ENTITY_RELATION (from_entity_id) WHERE deleted_at IS NULL;
CREATE INDEX ix_entity_relation_to
ON mdm.DDM_ENTITY_RELATION (to_entity_id) WHERE deleted_at IS NULL;
-- Audit-Log
CREATE INDEX ix_audit_log_lookup
ON mdm.AA_AUDIT_LOG (entity_name, entity_id, changed_at DESC);
CREATE INDEX ix_audit_log_actor
ON mdm.AA_AUDIT_LOG (actor_principal_id, changed_at DESC);
CREATE INDEX ix_audit_log_tenant
ON mdm.AA_AUDIT_LOG (tenant_id, changed_at DESC)
WHERE tenant_id IS NOT NULL;
-- =====================================================================
-- Tenant
-- =====================================================================
CREATE INDEX ix_tenant_key
ON mdm.AA_TENANT (key)
WHERE deleted_at IS NULL;
-- =====================================================================
-- Outbox
--
-- Dispatcher liest pending-Events strikt FIFO je aggregate (Reihenfolge-
-- Garantie pro Entität), daher (status, available_at, id) als Worker-Index.
-- =====================================================================
CREATE INDEX ix_outbox_event_pending
ON mdm.AA_OUTBOX_EVENT (status, available_at, id)
WHERE status = 'pending';
CREATE INDEX ix_outbox_event_aggregate
ON mdm.AA_OUTBOX_EVENT (aggregate_type, aggregate_id, id);
CREATE INDEX ix_outbox_event_correlation
ON mdm.AA_OUTBOX_EVENT (correlation_id)
WHERE correlation_id IS NOT NULL;
-- =====================================================================
-- DDM_RELATION_TYPE_ATTRIBUTE
-- =====================================================================
CREATE INDEX ix_relation_type_attribute_relation_type
ON mdm.DDM_RELATION_TYPE_ATTRIBUTE (relation_type_id, sort_order, key);
-- =====================================================================
-- RBAC + ACL
-- =====================================================================
-- Identity Lookups
CREATE INDEX ix_app_user_email
ON mdm.AA_APP_USER (lower(email))
WHERE deleted_at IS NULL AND email IS NOT NULL;
CREATE INDEX ix_service_account_key
ON mdm.AA_SERVICE_ACCOUNT (key)
WHERE deleted_at IS NULL;
CREATE INDEX ix_principal_group_key
ON mdm.AA_PRINCIPAL_GROUP (key)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uq_principal_group_member_active
ON mdm.AA_PRINCIPAL_GROUP_MEMBER
(principal_group_id, member_principal_type, member_principal_id)
WHERE deleted_at IS NULL;
CREATE INDEX ix_principal_group_member_lookup
ON mdm.AA_PRINCIPAL_GROUP_MEMBER
(member_principal_type, member_principal_id)
WHERE deleted_at IS NULL;
-- RBAC
CREATE INDEX ix_role_key
ON mdm.AA_ROLE (key)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uq_role_permission_active
ON mdm.AA_ROLE_PERMISSION
(role_id, permission_id,
coalesce(scope_entity_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
coalesce(scope_entity_id, '00000000-0000-0000-0000-000000000000'::uuid),
coalesce(scope_relation_type_id, '00000000-0000-0000-0000-000000000000'::uuid))
WHERE deleted_at IS NULL;
CREATE INDEX ix_role_permission_role
ON mdm.AA_ROLE_PERMISSION (role_id)
WHERE deleted_at IS NULL;
-- Principal-Role-Auflösung (Hot Path bei jedem Request)
CREATE UNIQUE INDEX uq_principal_role_active
ON mdm.AA_PRINCIPAL_ROLE
(principal_type, principal_id, role_id, scope,
coalesce(scope_entity_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
coalesce(scope_entity_id, '00000000-0000-0000-0000-000000000000'::uuid),
coalesce(scope_relation_type_id, '00000000-0000-0000-0000-000000000000'::uuid))
WHERE deleted_at IS NULL;
CREATE INDEX ix_principal_role_principal
ON mdm.AA_PRINCIPAL_ROLE (principal_type, principal_id)
WHERE deleted_at IS NULL;
CREATE INDEX ix_principal_role_role
ON mdm.AA_PRINCIPAL_ROLE (role_id)
WHERE deleted_at IS NULL;
-- ACL-Lookups (per principal + scope)
CREATE INDEX ix_acl_entry_principal
ON mdm.AA_ACL_ENTRY (principal_type, principal_id)
WHERE deleted_at IS NULL;
CREATE INDEX ix_acl_entry_entity
ON mdm.AA_ACL_ENTRY (scope_entity_id)
WHERE deleted_at IS NULL AND scope_entity_id IS NOT NULL;
CREATE INDEX ix_acl_entry_entity_type
ON mdm.AA_ACL_ENTRY (scope_entity_type_id)
WHERE deleted_at IS NULL AND scope_entity_type_id IS NOT NULL;
CREATE INDEX ix_acl_entry_relation_type
ON mdm.AA_ACL_ENTRY (scope_relation_type_id)
WHERE deleted_at IS NULL AND scope_relation_type_id IS NOT NULL;

Search-Index-Strategie für JSONB-Attribute (OP-17)

V1-Entscheidung (2026-04-29): zustandsgeführte Expression-Indizes über Async-Job, durchgesetzt über UI-Status-Modell. GIN-Catch-all wird selektiv nur für External-Source-Target-Typen aktiviert. Internal-Only-Typen verzichten auf den Catch-all und werden über UI-Sperren plus Engineering-Disziplin abgesichert.

Managed Expression-Indizes (alle Typen)

Expression-Indizes auf searchable- und sortable-Attributen sind nicht Teil des Initial-DDL. Sie werden bei Konfigurationsänderung von DDM_ENTITY_TYPE_ATTRIBUTE.searchable / .sortable über AA_JOB.job_kind='index_sync' aufgebaut bzw. abgebaut. Build läuft CONCURRENTLY. Naming-Konvention: ix_entity_<type_key>_<attr_key> (truncated SHA bei Längenüberschreitung des Postgres-Limits).

Jedes Attribut führt zwei zusätzliche Spalten in DDM_ENTITY_TYPE_ATTRIBUTE:

  • index_status text — eines aus pending | building | active | failed
  • index_built_at timestamptz

Beispiele für die generierten Indizes:

-- searchable=true (Text)
CREATE INDEX CONCURRENTLY ix_entity_kunde_customer_number
ON mdm.DDM_ENTITY ((attributes ->> 'customer_number'))
WHERE entity_type_id = '<kunde-type-id>' AND deleted_at IS NULL;
-- sortable=true mit numerischer Coercion
CREATE INDEX CONCURRENTLY ix_entity_kunde_annual_revenue
ON mdm.DDM_ENTITY (((attributes ->> 'annual_revenue')::numeric))
WHERE entity_type_id = '<kunde-type-id>' AND deleted_at IS NULL;

Durchsetzung:

  • UI und REST-API sperren Filter und Sort gegen ein Attribut, solange index_status != 'active'. Filter-Picker und Quick-Search listen ausschließlich Attribute mit searchable=true. REST-API rejectet ?filter[<key>]=... für nicht-searchable-Pfade mit 409 attribute_not_filterable.
  • Saved Searches mit pending/failed-Attributen zeigen Warnung und sind nicht ausführbar, bis active.
  • Drop bei searchable=false / sortable=false: Service prüft Referenzen in saved_search / report_view und blockt mit Liste der Abhängigkeiten. Force-Drop ist möglich, wird aber auditiert (AA_AUDIT_LOG-Aktion attribute_index_force_drop).
  • Type-Coercion-Schutz: vor Build eines numerischen / Datum-Index läuft Drift-Check gegen mdm.v_entity_attribute_drift. Build startet erst bei Drift = 0; Steward-Override ist möglich, wird auditiert.
  • Fehlertoleranz: gescheiterter Build setzt index_status='failed', schreibt AA_OUTBOX_EVENT index_build_failed und erscheint in mdm.v_index_drift (Soll/Ist je Attribut+Typ). Steward-Inbox.

GIN-Catch-all (nur External-Source-Target-Typen)

DDM_ENTITY_TYPE.is_external_source_target boolean DEFAULT false markiert Typen, deren Daten aus Quellsystemen oder Bulk-Imports stammen. Auto-Setzung sobald erster DDM_ENTITY_EXTERNAL_ID-Eintrag oder DDM_SOURCE_SYSTEM-Mapping für den Typ existiert; Steward kann das Flag manuell vorab setzen.

Für solche Typen wird ein partieller GIN-Catch-all-Index pro Typ angelegt:

CREATE INDEX CONCURRENTLY ix_entity_attributes_gin_<type_key>
ON mdm.DDM_ENTITY USING GIN ((attributes) jsonb_path_ops)
WHERE entity_type_id = '<uuid>' AND deleted_at IS NULL;

Build erfolgt automatisch beim Setzen des Flags über AA_JOB.job_kind='gin_backfill'. Promotion (intern → external) ist regulärer Workflow; Demotion (external → intern) ist Admin-Migration und droppt den Index nicht automatisch (Sicherheitsnetz bleibt, solange historische Daten vorhanden).

Der Catch-all dient als Sicherheitsnetz für:

  • Schema-Drift aus Quellsystemen, die unbekannte Keys liefern (in Verbindung mit Lazy-Validate, OP-19).
  • Bulk-Import-Drift (OP-41).
  • Generische JSONB-Path-Queries für Match/Merge (OP-29) und External-ID-Heuristiken (OP-30).
  • Steward-Reports „Diff zwischen MDM und Quellsystem”.

Internal-Only-Typen (is_external_source_target=false) bekommen keinen Catch-all. Filter/Sort-Pfade laufen ausschließlich über die managed Expression-Indizes oben. UI-Sperren und die folgende Engineering-Regel ersetzen das Sicherheitsnetz.

Engineering-Regel: keine Service-Path-Query ohne deklarierten Index

Service-Code darf JSONB-Path-Queries (->, ->>, @>, ?, jsonb_path_*) ausschließlich gegen explizit deklarierte Indizes ausführen. Jede Match/Merge-Regel und jede External-ID-Heuristik bringt ihre Index-Anforderung als Migration mit:

-- Beispiel: Match-Regel "tax_id-Exact-Match" auf kunde
CREATE INDEX ix_entity_kunde_tax_id_path
ON mdm.DDM_ENTITY USING GIN ((attributes -> 'tax_id') jsonb_path_ops)
WHERE entity_type_id = '<kunde-type-id>' AND deleted_at IS NULL;

Diese Regel gilt strikt für Internal-Only-Typen und weicher (Catch-all als Fallback) für External-Source-Target-Typen. Code-Review erzwingt die Deklaration.

Trigram-Indizes (Fuzzy-Suche, OP-43)

pg_trgm-Indizes folgen der gleichen Async-Job-Mechanik. Auslöser ist DDM_ENTITY_TYPE_ATTRIBUTE.fuzzy=true; der Job legt einen GIN-trgm-Index auf lower(attributes ->> '<attr_key>') an, gefiltert nach entity_type_id und deleted_at IS NULL.

Strategie

  • Partial Indexes auf deleted_at IS NULL sind Standard.
  • Expression-Indizes für JSONB-Filter/Sort werden async und zustandsgeführt verwaltet — Plattform ist runtime-konfigurierbar, daher kein DDL-Hardcoding.
  • GIN-Catch-all kommt selektiv für External-Source-Target-Typen, weil dort Schema-Drift, Bulk-Import-Drift und generische Path-Queries echte Treiber sind.
  • UI- und Engineering-Disziplin ersetzen den allgemeinen Catch-all für rein internal gepflegte Typen.

Offen

  • Auto-Setzung von is_external_source_target=true beim ersten DDM_ENTITY_EXTERNAL_ID- bzw. DDM_SOURCE_SYSTEM-Mapping wird als Trigger / Service-Layer-Regel in 04-triggers.md bzw. ../50-behavior/01-validation.md ausgearbeitet.
  • Worker-Implementierung für index_sync / gin_backfill (Coercion-Mapping data_type::numeric/::date/::timestamptz, Naming-Truncation bei langen Type-/Attribut-Keys, Force-Drop-Audit) wird mit Backend-Pick (OP-08) festgelegt.
  • Drift-Detektion für GIN-Catch-all (existiert der Index für jeden is_external_source_target=true-Typ?) läuft Aufgabe von cleanup_jobs, konkrete Frequenz und Alarm-Schwelle bleiben offen.

Verwandte Dokumente