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.
This commit is contained in:
@@ -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).
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS retention_class_rules;
|
||||
@@ -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
|
||||
);
|
||||
Reference in New Issue
Block a user