From 2c9a7482b619e1016b165a03d161fc719bf0d735 Mon Sep 17 00:00:00 2001 From: sysops Date: Sun, 30 Aug 2026 01:54:48 +0200 Subject: [PATCH] feat(archive): RET-02 Aufbewahrungsfristen-Engine internal/retentionengine: Frist je Aufbewahrungsklasse als natives Postgres-INTERVAL, Stichtagsberechnung an Postgres delegiert statt eigener Kalenderrechnung (Schaltjahr/Monatsende-Referenzwerte real verifiziert: 2024-02-29+1y=2025-02-28, 2026-01-31+1mo=2026-02-28). Periodischer Job (ListExpiringObjects) beschraenkt sich per DISTINCT ON auf die juengste Klassenzuordnung je Objekt - sonst wuerden Objekte mit mehrfach geaenderter Klasse (RET-01-Historisierung) doppelt auftauchen, real mit einem Zwei-Zuordnungen-Testobjekt bewiesen. Scope bewusst eng gehalten: keine RET-05-Anbindung, keine Vernichtungslogik - das ist Ticket-Scope, dependsOn ist nur RET-01. --- archive/docs/RET-02-PRUEFPROTOKOLL.md | 69 ++++++++ .../retentionengine/retentionengine.go | 96 +++++++++++ .../retentionengine/retentionengine_test.go | 161 ++++++++++++++++++ .../0004_retention_class_rules.down.sql | 1 + .../0004_retention_class_rules.up.sql | 9 + 5 files changed, 336 insertions(+) create mode 100644 archive/docs/RET-02-PRUEFPROTOKOLL.md create mode 100644 archive/internal/retentionengine/retentionengine.go create mode 100644 archive/internal/retentionengine/retentionengine_test.go create mode 100644 archive/migrations/0004_retention_class_rules.down.sql create mode 100644 archive/migrations/0004_retention_class_rules.up.sql diff --git a/archive/docs/RET-02-PRUEFPROTOKOLL.md b/archive/docs/RET-02-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..be42ca6 --- /dev/null +++ b/archive/docs/RET-02-PRUEFPROTOKOLL.md @@ -0,0 +1,69 @@ +# RET-02 – Prüfprotokoll: Aufbewahrungsfristen-Engine + +Voraussetzung RET-01 – erledigt, siehe eigenes Protokoll. + +**Scope-Klarstellung:** Dieses Ticket ist die Fristen-BERECHNUNGS-Engine +(Frist je Klasse, Stichtagsberechnung, periodischer Ablauf-Job) — +`dependsOn: ["RET-01"]`, KEINE Abhängigkeit auf RET-05. Die DMS-/Mail- +seitige Registrierung als RET-05-Adapter-Konsument sowie +Vernichtungs-Job-Fehlerbehandlung (2xx/Requeue) sind NICHT Teil dieser +Kachel — das ist ein späteres, eigenes Ticket (vermutlich im +ARC-*/DOC-*-Umfeld). Bewusst nicht mitgebaut, um nicht über den +Ticket-Umfang hinaus zu implementieren. + +## Grundsatzentscheidung: Postgres-INTERVAL statt eigener Kalenderrechnung + +`retention_class_rules.duration` ist ein natives Postgres-`INTERVAL` +(z. B. `'10 years'`, `'6 months'`) — `ComputeDueDate` delegiert die +gesamte Stichtagsberechnung an Postgres selbst (`start + duration`), +statt eine eigene Schaltjahr-/Monatsende-Logik in Go nachzubauen, die +von der WHERE-Klausel des periodischen Jobs (dieselbe Arithmetik) +abweichen könnte. Referenzwerte für Akzeptanzkriterium 2 real gegen +Postgres verifiziert, nicht angenommen: +`2024-02-29 + 1 year = 2025-02-28`, `2026-01-31 + 1 month = 2026-02-28`. + +## Umsetzung + +- `migrations/0004_retention_class_rules.up.sql`/`.down.sql`. +- `internal/retentionengine.ConfigureClassRule` — eine Regel je Klasse + (`UPSERT`). +- `internal/retentionengine.ComputeDueDate` — delegiert an Postgres. +- `internal/retentionengine.ListExpiringObjects` — periodischer Job: + `DISTINCT ON (retention_object_id)` auf die JÜNGSTE Klassenzuordnung + beschränkt, sonst würde ein Objekt mit mehrfach geänderter Klasse + (RET-01s Historisierung) mehrfach im Ergebnis auftauchen. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Fristberechnung an Referenzdaten mit bekannten Ablaufdaten geprüft | **bestanden** — `TestComputeDueDate_KnownReferenceDates`: Schaltjahr (29.02.2024 + 1 Jahr → 28.02.2025) und Monatsende (31.01.2026 + 1 Monat → 28.02.2026), beide Werte vorab real gegen Postgres verifiziert | +| 2 | Job liefert bei leerem Bestand ein leeres, nicht fehlerhaftes Ergebnis | **bestanden** — `TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError` | +| 3 | Mehrfachausführung des Jobs erzeugt keine doppelten Einträge | **bestanden** — `TestListExpiringObjects_NoDuplicatesAcrossHistoricalClassChanges`: Objekt mit ZWEI historischen Klassenzuordnungen (beide abgelaufen), zwei Job-Läufe liefern je genau 1 Eintrag — ohne die `DISTINCT ON`-Einschränkung wäre es 2 gewesen | + +## Echte Verdrahtung auf 192.168.1.131 + +- Migration real gegen `dms_tenant_test` angewendet — `retention_class_rules` + bestätigt vorhanden neben `retention_objects`/`retention_class_assignments` +- Kein systemd-Timer in diesem Ticket — "periodischer Job" ist die + Bibliotheksfunktion `ListExpiringObjects`; ihr tatsächlicher + Aufrufer/Zeitplan (systemd-Timer + Meldeweg für abgelaufene Objekte) + ist Aufgabe eines Folgetickets, das auch die Vernichtungslogik selbst + bringt (dieses Ticket berechnet nur, wer fällig ist — vernichtet + nichts) + +## Build/Test-Ergebnis (192.168.1.131, `make check`) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./... -p 1 -count=1 -> 10/10 Pakete mit Tests ok, 0 Fehlschläge +``` + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei +Pflichtprüfungen real erfüllt — Stichtagsberechnung gegen real +verifizierte Postgres-Referenzwerte, Job-Idempotenz gegen ein Objekt mit +echter Mehrfach-Historie bewiesen (nicht nur behauptet). diff --git a/archive/internal/retentionengine/retentionengine.go b/archive/internal/retentionengine/retentionengine.go new file mode 100644 index 0000000..a4de563 --- /dev/null +++ b/archive/internal/retentionengine/retentionengine.go @@ -0,0 +1,96 @@ +// Package retentionengine implementiert RET-02: Fristenmodell je +// Aufbewahrungsklasse mit Stichtagsberechnung und ein periodischer Job, +// der ablaufende Objekte ermittelt. Baut auf RET-01 (retention_objects, +// retention_class_assignments) auf — kennt weiter keine Modul-Interna +// (dieselbe Adapter-Disziplin). +package retentionengine + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// ConfigureClassRule legt die Frist (Postgres-INTERVAL, z. B. "10 years", +// "6 months") für eine Aufbewahrungsklasse fest oder ändert sie +// (Akzeptanzkriterium 1) — je Klasse GENAU eine aktive Regel. +func ConfigureClassRule(ctx context.Context, pool *pgxpool.Pool, retentionClass, duration string) error { + _, err := pool.Exec(ctx, ` + INSERT INTO retention_class_rules (retention_class, duration) + VALUES ($1, $2::interval) + ON CONFLICT (retention_class) DO UPDATE SET duration = EXCLUDED.duration + `, retentionClass, duration) + if err != nil { + return fmt.Errorf("retentionengine: fristregel konfigurieren: %w", err) + } + return nil +} + +// ComputeDueDate berechnet den Stichtag aus Beginn (start) und der +// konfigurierten Frist der Klasse — DELEGIERT an Postgres' eigene +// INTERVAL-Arithmetik (Akzeptanzkriterium 2: korrekt inklusive +// Schaltjahr/Monatsende), keine eigene Kalenderrechnung in Go, die von +// Postgres' späterer WHERE-Klausel im periodischen Job abweichen könnte. +func ComputeDueDate(ctx context.Context, pool *pgxpool.Pool, start time.Time, retentionClass string) (time.Time, error) { + var due time.Time + err := pool.QueryRow(ctx, ` + SELECT $1::timestamptz + r.duration + FROM retention_class_rules r WHERE r.retention_class = $2 + `, start, retentionClass).Scan(&due) + if err != nil { + return time.Time{}, fmt.Errorf("retentionengine: stichtag berechnen: %w", err) + } + return due, nil +} + +// ExpiringObject ist EIN Objekt, dessen Aufbewahrungsfrist erreicht ist. +type ExpiringObject struct { + RetentionObjectID string + ObjectType string + ObjectReference string + RetentionClass string + DueDate time.Time +} + +// ListExpiringObjects ist der periodische Job (Akzeptanzkriterium 3): +// liefert alle aktiven Retention-Objekte, deren Stichtag (aktuelle +// Klassenzuordnung + deren Frist) bis asOf erreicht ist. Betrachtet je +// Objekt AUSSCHLIESSLICH die JÜNGSTE Klassenzuordnung (`DISTINCT ON`) - +// ohne diese Einschränkung würde ein Objekt mit mehrfach geänderter +// Klasse (RET-01s Historisierung) mehrfach im Ergebnis auftauchen, +// genau der Doppelte-Einträge-Fehler, den Pflichtprüfung 3 ausschließt. +// Ein leerer Bestand liefert eine leere Liste, keinen Fehler +// (Akzeptanzkriterium/Pflichtprüfung 2). +func ListExpiringObjects(ctx context.Context, pool *pgxpool.Pool, asOf time.Time) ([]ExpiringObject, error) { + rows, err := pool.Query(ctx, ` + WITH latest_assignment AS ( + SELECT DISTINCT ON (retention_object_id) + retention_object_id, retention_class, assigned_at + FROM retention_class_assignments + ORDER BY retention_object_id, assigned_at DESC + ) + SELECT o.id, o.object_type, o.object_reference, a.retention_class, + a.assigned_at + r.duration AS due_date + FROM retention_objects o + JOIN latest_assignment a ON a.retention_object_id = o.id + JOIN retention_class_rules r ON r.retention_class = a.retention_class + WHERE o.status = 'active' AND (a.assigned_at + r.duration) <= $1 + ORDER BY due_date ASC + `, asOf) + if err != nil { + return nil, fmt.Errorf("retentionengine: ablaufende objekte ermitteln: %w", err) + } + defer rows.Close() + + var out []ExpiringObject + for rows.Next() { + var e ExpiringObject + if err := rows.Scan(&e.RetentionObjectID, &e.ObjectType, &e.ObjectReference, &e.RetentionClass, &e.DueDate); err != nil { + return nil, fmt.Errorf("retentionengine: zeile lesen: %w", err) + } + out = append(out, e) + } + return out, rows.Err() +} diff --git a/archive/internal/retentionengine/retentionengine_test.go b/archive/internal/retentionengine/retentionengine_test.go new file mode 100644 index 0000000..193c8fe --- /dev/null +++ b/archive/internal/retentionengine/retentionengine_test.go @@ -0,0 +1,161 @@ +package retentionengine + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func requireTestPool(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(func() { pool.Close() }) + + if _, err := pool.Exec(ctx, ` + CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE TABLE IF NOT EXISTS retention_objects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL, + object_reference TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (object_type, object_reference) + ); + CREATE TABLE IF NOT EXISTS retention_class_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE, + retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS retention_class_rules ( + retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `TRUNCATE retention_class_assignments, retention_objects CASCADE; TRUNCATE retention_class_rules`) + }) + return pool +} + +func mustTime(t *testing.T, layout, value string) time.Time { + t.Helper() + tm, err := time.Parse(layout, value) + if err != nil { + t.Fatalf("zeitangabe parsen: %v", err) + } + return tm +} + +// TestComputeDueDate_KnownReferenceDates ist Pruefung 1: Fristberechnung +// an Referenzdaten mit bekannten Ablaufdaten geprueft - inklusive +// Schaltjahr und Monatsende (Akzeptanzkriterium 2). Erwartete Werte real +// gegen Postgres verifiziert (dessen eigene INTERVAL-Arithmetik ist die +// Quelle der Wahrheit, keine eigene Nachbildung in Go). +func TestComputeDueDate_KnownReferenceDates(t *testing.T) { + pool := requireTestPool(t) + ctx := context.Background() + + if err := ConfigureClassRule(ctx, pool, "klasse-1-jahr", "1 year"); err != nil { + t.Fatalf("regel konfigurieren: %v", err) + } + if err := ConfigureClassRule(ctx, pool, "klasse-1-monat", "1 month"); err != nil { + t.Fatalf("regel konfigurieren: %v", err) + } + + cases := []struct { + name string + start time.Time + retentionClass string + want time.Time + }{ + { + name: "schaltjahr 29. februar plus 1 jahr", + start: mustTime(t, "2006-01-02", "2024-02-29"), + retentionClass: "klasse-1-jahr", + want: mustTime(t, "2006-01-02", "2025-02-28"), + }, + { + name: "monatsende 31. januar plus 1 monat", + start: mustTime(t, "2006-01-02", "2026-01-31"), + retentionClass: "klasse-1-monat", + want: mustTime(t, "2006-01-02", "2026-02-28"), + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := ComputeDueDate(ctx, pool, c.start, c.retentionClass) + if err != nil { + t.Fatalf("computeduedate: %v", err) + } + if !got.UTC().Equal(c.want.UTC()) { + t.Fatalf("stichtag = %v, want %v", got, c.want) + } + }) + } +} + +// TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError ist Pruefung 2. +func TestListExpiringObjects_EmptyBacklogReturnsEmptyNotError(t *testing.T) { + pool := requireTestPool(t) + ctx := context.Background() + + got, err := ListExpiringObjects(ctx, pool, time.Now().UTC()) + if err != nil { + t.Fatalf("erwartet keinen fehler bei leerem bestand, habe: %v", err) + } + if len(got) != 0 { + t.Fatalf("erwartet leere liste, habe %d eintraege", len(got)) + } +} + +// TestListExpiringObjects_NoDuplicatesAcrossHistoricalClassChanges ist +// Pruefung 3: Mehrfachausfuehrung des Jobs erzeugt keine doppelten +// Eintraege - real geprueft an einem Objekt mit MEHREREN historischen +// Klassenzuordnungen (RET-01s Historisierung), das ohne die +// DISTINCT-ON-Einschraenkung mehrfach im Ergebnis auftauchen wuerde. +func TestListExpiringObjects_NoDuplicatesAcrossHistoricalClassChanges(t *testing.T) { + pool := requireTestPool(t) + ctx := context.Background() + + if err := ConfigureClassRule(ctx, pool, "klasse-kurz", "1 day"); err != nil { + t.Fatal(err) + } + + var objID string + if err := pool.QueryRow(ctx, ` + INSERT INTO retention_objects (object_type, object_reference) VALUES ('dms_document', 'doc-mehrfach') RETURNING id + `).Scan(&objID); err != nil { + t.Fatalf("objekt anlegen: %v", err) + } + past := time.Now().UTC().Add(-72 * time.Hour) + // zwei historische Zuordnungen fuer DASSELBE Objekt, beide in der + // Vergangenheit (also beide laengst abgelaufen, wenn nicht auf die + // juengste beschraenkt wuerde). + if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, objID, past); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, objID, past.Add(time.Hour)); err != nil { + t.Fatal(err) + } + + for run := 1; run <= 2; run++ { + got, err := ListExpiringObjects(ctx, pool, time.Now().UTC()) + if err != nil { + t.Fatalf("lauf %d: %v", run, err) + } + if len(got) != 1 { + t.Fatalf("lauf %d: erwartet genau 1 eintrag (kein duplikat trotz 2 historischer zuordnungen), habe %d: %+v", run, len(got), got) + } + } +} diff --git a/archive/migrations/0004_retention_class_rules.down.sql b/archive/migrations/0004_retention_class_rules.down.sql new file mode 100644 index 0000000..f60ab41 --- /dev/null +++ b/archive/migrations/0004_retention_class_rules.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS retention_class_rules; diff --git a/archive/migrations/0004_retention_class_rules.up.sql b/archive/migrations/0004_retention_class_rules.up.sql new file mode 100644 index 0000000..2119a6f --- /dev/null +++ b/archive/migrations/0004_retention_class_rules.up.sql @@ -0,0 +1,9 @@ +-- RET-02: Fristenmodell je Aufbewahrungsklasse. duration ist ein +-- natives Postgres-INTERVAL statt eigener Tage-/Monatszaehlung, damit +-- Kalenderfaelle (Schaltjahr, Monatsende) exakt Postgres' eigene, +-- bewaehrte Intervall-Arithmetik nutzen statt eine eigene, potenziell +-- fehlerhafte Nachbildung. +CREATE TABLE IF NOT EXISTS retention_class_rules ( + retention_class TEXT PRIMARY KEY, + duration INTERVAL NOT NULL +);