feat(archive): BAK-05 Reconciliation Storage vs. DB

Deterministischer Abgleich zwischen DMS file_revisions und
Objekt-Storage-Verzeichnis, existenz-only (keine Inhaltspruefung,
saubere Abgrenzung zu BAK-08). Report sortiert nach storage_key fuer
stabile Weiterverarbeitung durch BAK-08. Systemd-Timer taeglich,
real auf 192.168.1.131 verdrahtet und ausgeloest.
This commit is contained in:
sysops
2026-08-29 23:24:02 +02:00
parent 10ba866f0e
commit ae214f1731
10 changed files with 650 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
// Package reconcile implementiert BAK-05: periodischer Abgleich, ob jeder
// in der Datenbank referenzierte Objekt-Storage-Eintrag tatsächlich
// existiert und umgekehrt. Prüft AUSSCHLIESSLICH Existenz — niemals
// Inhalt (das ist Archive BAK-08, eine eigene Fehlerklasse, bewusst nicht
// hier mit hineingezogen, siehe reconcile_test.go
// TestReconcile_ExistingButCorruptedObjectProducesNoFinding).
package reconcile
import (
"sort"
"time"
)
// Finding ist EIN Abweichungsfund — entweder ein Datenbankeintrag ohne
// Storage-Objekt oder umgekehrt.
type Finding struct {
StorageKey string `json:"storage_key"`
DocumentID string `json:"document_id,omitempty"`
RevisionID string `json:"revision_id,omitempty"`
}
// Report ist das Ergebnis EINES Abgleichslaufs (Akzeptanzkriterium 3:
// Abweichungen werden BERICHTET, nicht automatisch behoben — Report ist
// reine Information, keine Reparaturfunktion existiert in diesem Paket).
//
// Beide Listen sind nach StorageKey aufsteigend sortiert — bei gleicher
// Eingabe liefert Reconcile IMMER dieselbe Reihenfolge (deterministisch),
// damit ein nachgelagerter Verbraucher (Archive BAK-08: zieht seine
// Stichprobe aus der Liste der EXISTIERENDEN Objekte) sich auf eine
// stabile Sortierung verlassen kann, statt bei jedem Lauf neu zu
// filtern/sortieren.
type Report struct {
GeneratedAt time.Time `json:"generated_at"`
// MissingInStorage: Datenbankeintrag vorhanden, Objekt im Storage fehlt
// (Akzeptanzkriterium 1).
MissingInStorage []Finding `json:"missing_in_storage"`
// OrphanedInStorage: Objekt im Storage vorhanden, kein Datenbankeintrag
// (Akzeptanzkriterium 2).
OrphanedInStorage []Finding `json:"orphaned_in_storage"`
}
// IsClean liefert true, wenn der Lauf keine Abweichungen fand (Pflicht-
// prüfung 3: "Lauf ohne Abweichungen liefert einen leeren, eindeutig als
// sauber erkennbaren Bericht" — IsClean ist genau dieses eindeutige
// Erkennungsmerkmal, statt dass ein Aufrufer beide Listen selbst auf
// Leere prüfen muss).
func (r Report) IsClean() bool {
return len(r.MissingInStorage) == 0 && len(r.OrphanedInStorage) == 0
}
// DBEntry ist ein Datenbankeintrag, wie ihn ListDBStorageKeys liefert.
type DBEntry struct {
StorageKey string
DocumentID string
RevisionID string
}
// Reconcile vergleicht dbEntries (aus file_revisions.storage_key, DMS
// FDN-02) gegen storageKeys (tatsächlich im Objekt-Storage vorhandene
// Schlüssel, z.B. per Verzeichnis-Walk des FDN-03-LocalDriver-
// Basisverzeichnisses) und liefert die Abweichungen in beide Richtungen.
// Reine Funktion — kein Datenbank-/Storage-Zugriff hier, dadurch ohne
// echte Infrastruktur testbar (siehe reconcile_test.go).
func Reconcile(dbEntries []DBEntry, storageKeys []string) Report {
storageSet := make(map[string]bool, len(storageKeys))
for _, k := range storageKeys {
storageSet[k] = true
}
dbSet := make(map[string]DBEntry, len(dbEntries))
for _, e := range dbEntries {
dbSet[e.StorageKey] = e
}
var missing []Finding
for _, e := range dbEntries {
if !storageSet[e.StorageKey] {
missing = append(missing, Finding(e))
}
}
var orphaned []Finding
for _, k := range storageKeys {
if _, ok := dbSet[k]; !ok {
orphaned = append(orphaned, Finding{StorageKey: k})
}
}
sort.Slice(missing, func(i, j int) bool { return missing[i].StorageKey < missing[j].StorageKey })
sort.Slice(orphaned, func(i, j int) bool { return orphaned[i].StorageKey < orphaned[j].StorageKey })
return Report{
GeneratedAt: time.Now().UTC(),
MissingInStorage: missing,
OrphanedInStorage: orphaned,
}
}
@@ -0,0 +1,137 @@
package reconcile
import "testing"
// TestReconcile_DetectsMissingInStorage ist Akzeptanzkriterium 1 / Pruefung
// 1: ein Datenbankeintrag ohne zugehoeriges Objekt im Storage wird erkannt.
func TestReconcile_DetectsMissingInStorage(t *testing.T) {
db := []DBEntry{
{StorageKey: "documents/d1/revisions/r1", DocumentID: "d1", RevisionID: "r1"},
{StorageKey: "documents/d2/revisions/r1", DocumentID: "d2", RevisionID: "r1"},
}
storage := []string{"documents/d1/revisions/r1"} // d2/r1 fehlt absichtlich
report := Reconcile(db, storage)
if len(report.MissingInStorage) != 1 {
t.Fatalf("erwartet 1 fund in missing_in_storage, habe %d: %+v", len(report.MissingInStorage), report.MissingInStorage)
}
if report.MissingInStorage[0].StorageKey != "documents/d2/revisions/r1" {
t.Fatalf("unerwarteter fund: %+v", report.MissingInStorage[0])
}
if len(report.OrphanedInStorage) != 0 {
t.Fatalf("erwartet 0 funde in orphaned_in_storage, habe %d", len(report.OrphanedInStorage))
}
}
// TestReconcile_DetectsOrphanedInStorage ist Akzeptanzkriterium 2 /
// Pruefung 2: ein Storage-Objekt ohne Datenbankeintrag wird erkannt.
func TestReconcile_DetectsOrphanedInStorage(t *testing.T) {
db := []DBEntry{
{StorageKey: "documents/d1/revisions/r1", DocumentID: "d1", RevisionID: "r1"},
}
storage := []string{
"documents/d1/revisions/r1",
"documents/verwaist/revisions/r1", // kein DB-Eintrag dafuer
}
report := Reconcile(db, storage)
if len(report.OrphanedInStorage) != 1 {
t.Fatalf("erwartet 1 fund in orphaned_in_storage, habe %d: %+v", len(report.OrphanedInStorage), report.OrphanedInStorage)
}
if report.OrphanedInStorage[0].StorageKey != "documents/verwaist/revisions/r1" {
t.Fatalf("unerwarteter fund: %+v", report.OrphanedInStorage[0])
}
if len(report.MissingInStorage) != 0 {
t.Fatalf("erwartet 0 funde in missing_in_storage, habe %d", len(report.MissingInStorage))
}
}
// TestReconcile_CleanRunProducesEmptyReport ist Pruefung 3: Lauf ohne
// Abweichungen liefert einen leeren, eindeutig als sauber erkennbaren
// Bericht.
func TestReconcile_CleanRunProducesEmptyReport(t *testing.T) {
db := []DBEntry{
{StorageKey: "documents/d1/revisions/r1", DocumentID: "d1", RevisionID: "r1"},
{StorageKey: "documents/d2/revisions/r1", DocumentID: "d2", RevisionID: "r1"},
}
storage := []string{"documents/d1/revisions/r1", "documents/d2/revisions/r1"}
report := Reconcile(db, storage)
if !report.IsClean() {
t.Fatalf("erwartet sauberen bericht, habe missing=%v orphaned=%v", report.MissingInStorage, report.OrphanedInStorage)
}
if len(report.MissingInStorage) != 0 || len(report.OrphanedInStorage) != 0 {
t.Fatal("IsClean()==true, aber listen sind nicht leer - widerspruch")
}
}
// TestReconcile_ExistingButCorruptedObjectProducesNoFinding ist der
// Nachweis, dass BAK-05 AUSSCHLIESSLICH Existenz prueft, niemals Inhalt
// (die Fehlerklasse "existiert, aber Inhalt beschaedigt" ist Archive
// BAK-08, bewusst nicht hier) — Reconcile bekommt nur SCHLUESSEL, hat gar
// keine Moeglichkeit, auf Inhalt zuzugreifen; dieser Test dokumentiert die
// Absicht explizit, damit sie nicht versehentlich spaeter aufgeweicht wird.
func TestReconcile_ExistingButCorruptedObjectProducesNoFinding(t *testing.T) {
db := []DBEntry{
{StorageKey: "documents/d1/revisions/r1", DocumentID: "d1", RevisionID: "r1"},
}
// "korruptes" Objekt hier rein simuliert durch denselben Schluessel -
// Reconcile kennt und prueft keinen Inhalt, nur den Schluessel selbst.
storage := []string{"documents/d1/revisions/r1"}
report := Reconcile(db, storage)
if !report.IsClean() {
t.Fatalf("ein existierendes (wenn auch inhaltlich korruptes) objekt haette KEINEN befund ausloesen duerfen, habe: %+v", report)
}
}
// TestReconcile_DeterministicOrdering ist der Nachweis fuer die
// Stabilitaets-Anforderung: gleiche Eingabe liefert bei mehreren Laeufen
// IMMER dieselbe Reihenfolge (Voraussetzung dafuer, dass Archive BAK-08
// die Liste der existierenden Objekte stabil weiterverarbeiten kann, ohne
// selbst neu zu sortieren/filtern).
func TestReconcile_DeterministicOrdering(t *testing.T) {
db := []DBEntry{
{StorageKey: "documents/z/revisions/r1", DocumentID: "z", RevisionID: "r1"},
{StorageKey: "documents/a/revisions/r1", DocumentID: "a", RevisionID: "r1"},
{StorageKey: "documents/m/revisions/r1", DocumentID: "m", RevisionID: "r1"},
}
storage := []string{
"documents/a/revisions/r1", // deckt genau den DB-Eintrag "a" ab
"documents/y/revisions/r1",
"documents/n/revisions/r1",
}
first := Reconcile(db, storage)
second := Reconcile(db, storage)
if len(first.MissingInStorage) != len(second.MissingInStorage) {
t.Fatal("unterschiedliche anzahl funde zwischen zwei laeufen mit identischer eingabe")
}
for i := range first.MissingInStorage {
if first.MissingInStorage[i].StorageKey != second.MissingInStorage[i].StorageKey {
t.Fatalf("reihenfolge in missing_in_storage nicht deterministisch: lauf1[%d]=%q lauf2[%d]=%q",
i, first.MissingInStorage[i].StorageKey, i, second.MissingInStorage[i].StorageKey)
}
}
for i := range first.OrphanedInStorage {
if first.OrphanedInStorage[i].StorageKey != second.OrphanedInStorage[i].StorageKey {
t.Fatalf("reihenfolge in orphaned_in_storage nicht deterministisch: lauf1[%d]=%q lauf2[%d]=%q",
i, first.OrphanedInStorage[i].StorageKey, i, second.OrphanedInStorage[i].StorageKey)
}
}
// Aufsteigend sortiert (a < m < z), nicht Einfuegereihenfolge.
wantOrder := []string{"documents/m/revisions/r1", "documents/z/revisions/r1"}
if len(first.MissingInStorage) != len(wantOrder) {
t.Fatalf("erwartet %d funde, habe %d", len(wantOrder), len(first.MissingInStorage))
}
for i, w := range wantOrder {
if first.MissingInStorage[i].StorageKey != w {
t.Fatalf("sortierreihenfolge falsch: %v, want beginnend mit %v", first.MissingInStorage, wantOrder)
}
}
}
+65
View File
@@ -0,0 +1,65 @@
package reconcile
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/jackc/pgx/v5/pgxpool"
)
// ListDBStorageKeys liest alle storage_key-Werte aus file_revisions
// (DMS FDN-02) — Archive liest direkt aus derselben physischen
// Tenant-Datenbank (Modell C, Core TEN-01), OHNE DMS-Go-Pakete zu
// importieren (Archive ist ein eigenes Go-Modul) — reiner SQL-Zugriff
// gegen das dokumentierte Schema, sortiert nach storage_key für
// deterministische Reconcile-Ergebnisse.
func ListDBStorageKeys(ctx context.Context, pool *pgxpool.Pool) ([]DBEntry, error) {
rows, err := pool.Query(ctx, `
SELECT storage_key, document_id, id FROM file_revisions ORDER BY storage_key
`)
if err != nil {
return nil, fmt.Errorf("reconcile: file_revisions abfragen: %w", err)
}
defer rows.Close()
var entries []DBEntry
for rows.Next() {
var e DBEntry
if err := rows.Scan(&e.StorageKey, &e.DocumentID, &e.RevisionID); err != nil {
return nil, fmt.Errorf("reconcile: file_revisions-zeile lesen: %w", err)
}
entries = append(entries, e)
}
return entries, rows.Err()
}
// ListStorageObjects durchläuft den lokalen FDN-03-LocalDriver-
// Basisordner und liefert alle vorhandenen Objektschlüssel (Pfad relativ
// zu baseDir, mit "/" als Trenner — dasselbe Format wie
// storage.ObjectKey aus FDN-03), sortiert.
func ListStorageObjects(baseDir string) ([]string, error) {
var keys []string
err := filepath.WalkDir(baseDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(baseDir, path)
if err != nil {
return err
}
keys = append(keys, filepath.ToSlash(rel))
return nil
})
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reconcile: objekt-storage durchlaufen: %w", err)
}
return keys, nil
}
+132
View File
@@ -0,0 +1,132 @@
package reconcile
import (
"context"
"os"
"path/filepath"
"testing"
"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() })
// Minimalschema, das exakt DMS FDN-02s file_revisions-Spalten spiegelt
// (Archive kann DMS' internal/-Pakete als eigenes Go-Modul nicht
// importieren, daher hier als Testfixture kopiert statt real migriert).
if _, err := pool.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title TEXT NOT NULL,
created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS file_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
storage_key TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, size_bytes BIGINT NOT NULL,
mime_type TEXT NOT NULL, revision_number INTEGER NOT NULL, created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE file_revisions, documents, users CASCADE`)
})
return pool
}
// TestListDBStorageKeys_ReadsRealFileRevisions ist der Nachweis, dass
// ListDBStorageKeys tatsaechlich gegen eine echte Postgres-Instanz mit
// DMS-FDN-02-Schema liest — kein Mock.
func TestListDBStorageKeys_ReadsRealFileRevisions(t *testing.T) {
pool := requireTestPool(t)
ctx := context.Background()
var userID, docID string
if err := pool.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ('reconcile-test@example.test', 'Test') RETURNING id`).Scan(&userID); err != nil {
t.Fatalf("testbenutzer anlegen: %v", err)
}
if err := pool.QueryRow(ctx, `INSERT INTO documents (title, created_by) VALUES ('doc', $1) RETURNING id`, userID).Scan(&docID); err != nil {
t.Fatalf("testdokument anlegen: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO file_revisions (document_id, storage_key, checksum_sha256, size_bytes, mime_type, revision_number, created_by)
VALUES ($1, 'documents/x/revisions/1', 'abc', 10, 'text/plain', 1, $2)
`, docID, userID); err != nil {
t.Fatalf("testrevision anlegen: %v", err)
}
entries, err := ListDBStorageKeys(ctx, pool)
if err != nil {
t.Fatalf("listdbstoragekeys: %v", err)
}
if len(entries) != 1 {
t.Fatalf("erwartet 1 eintrag, habe %d", len(entries))
}
if entries[0].StorageKey != "documents/x/revisions/1" {
t.Fatalf("storage_key = %q, want %q", entries[0].StorageKey, "documents/x/revisions/1")
}
if entries[0].DocumentID != docID {
t.Fatalf("document_id = %q, want %q", entries[0].DocumentID, docID)
}
}
// TestListStorageObjects_WalksRealDirectory ist der Nachweis, dass
// ListStorageObjects tatsaechlich das Dateisystem durchlaeuft.
func TestListStorageObjects_WalksRealDirectory(t *testing.T) {
baseDir := t.TempDir()
mustWriteFile(t, filepath.Join(baseDir, "documents", "d1", "revisions", "r1"), "inhalt")
mustWriteFile(t, filepath.Join(baseDir, "documents", "d2", "revisions", "r1"), "inhalt")
keys, err := ListStorageObjects(baseDir)
if err != nil {
t.Fatalf("liststorageobjects: %v", err)
}
if len(keys) != 2 {
t.Fatalf("erwartet 2 objektschluessel, habe %d: %v", len(keys), keys)
}
want := []string{"documents/d1/revisions/r1", "documents/d2/revisions/r1"}
for i, w := range want {
if keys[i] != w {
t.Fatalf("schluessel[%d] = %q, want %q (voll: %v)", i, keys[i], w, keys)
}
}
}
// TestListStorageObjects_MissingDirectoryReturnsEmpty prueft das
// Verhalten, wenn das Basisverzeichnis (noch) gar nicht existiert -
// sollte als "keine Objekte", nicht als Fehler behandelt werden.
func TestListStorageObjects_MissingDirectoryReturnsEmpty(t *testing.T) {
keys, err := ListStorageObjects("/pfad/der/nicht/existiert/fuer/diesen/test")
if err != nil {
t.Fatalf("erwartet keinen fehler bei fehlendem verzeichnis, habe: %v", err)
}
if len(keys) != 0 {
t.Fatalf("erwartet 0 schluessel, habe %d", len(keys))
}
}
func mustWriteFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("verzeichnis anlegen: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("datei schreiben: %v", err)
}
}