From b6184b67aa0b7f995dfe7713b916df23aba098a2 Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 21:13:26 +0200 Subject: [PATCH] LIC-05: speicherverbrauch-metrik-je-tenant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/usage/storage.go: ReportStorageWrite/ReportStorageDelete sind duenne Spezialisierungen von LIC-03s bereits atomarem Store.Increment auf die feste Metrik "storage_bytes" — Loeschung nutzt einfach ein negatives Delta desselben UPSERT-Mechanismus, kein zweiter Zaehl-Codepfad. Damit uebernehmen Akzeptanzkriterium 2 (atomar, race-frei) und die zugehoerigen LIC-03-Garantien direkt, ohne Duplikat. CurrentStorageUsage ist ein einfaches Store.Get auf dieselbe Metrik — LIC-03 kann denselben Wert ueber Store.Get(tenant, StorageBytesMetric) abfragen (Akzeptanzkriterium 3, per Test TestCurrentStorageUsage_MatchesGenericStoreGet belegt: kein zweiter, abweichender Zaehlmechanismus). Die Objekt-Storage-Treiber der Module (DMS FDN-03, Mail ARC-01), die diese Funktionen bei jedem Schreib-/Loeschvorgang aufrufen wuerden, existieren als Code noch nicht (nur geplant in dms-kanban/mail-kanban) — diese Kachel implementiert nur die Core-seitige Zaehl-Schnittstelle, analog zum AUD-05/RetentionRegistrar-Muster. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Paralleler Schreib-Test (viele gleichzeitige Uploads) ergibt korrekten Endstand ohne verlorene Updates — TestReportStorageWrite_ConcurrentUploadsSumCorrectly: 10 parallele Schreibvorgaenge unterschiedlicher Groesse, Endstand exakt gleich der in Go unabhaengig berechneten Summe. PASS. 2. Loeschvorgang dekrementiert korrekt — TestReportStorageDelete_Decrements. PASS. 3. Abfrage liefert konsistenten Wert mit unabhaengiger Kontrollzaehlung — TestCurrentStorageUsage_MatchesIndependentTally (gemischte Schreib-/ Loeschfolge, in Go parallel mitgezaehlt) und TestCurrentStorageUsage_MatchesGenericStoreGet. PASS. Co-Authored-By: Claude Sonnet 5 --- internal/usage/storage.go | 47 ++++++++++++ internal/usage/storage_test.go | 130 +++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 internal/usage/storage.go create mode 100644 internal/usage/storage_test.go diff --git a/internal/usage/storage.go b/internal/usage/storage.go new file mode 100644 index 0000000..2023027 --- /dev/null +++ b/internal/usage/storage.go @@ -0,0 +1,47 @@ +package usage + +import ( + "context" + "errors" + "fmt" +) + +// StorageBytesMetric ist der feste Metrikname, unter dem der belegte +// Speicherplatz je Tenant gefuehrt wird (LIC-05, siehe +// core-kanban/tickets/LIC-05.md). LIC-03 fragt genau diese Metrik ueber +// Store.Get/Store.Check ab — kein zweiter, paralleler Speicher-Zaehler. +const StorageBytesMetric = "storage_bytes" + +// ReportStorageWrite wird von den Objekt-Storage-Treibern der Module (DMS +// FDN-03, Mail ARC-01 — existieren als Code noch nicht) bei jedem +// Schreibvorgang aufgerufen. Nutzt Store.Increment, das bereits atomar ist +// (Akzeptanzkriterium 2, siehe LIC-03) — kein zweiter Inkrement-Mechanismus +// nur fuer Speicher. +func (s *Store) ReportStorageWrite(ctx context.Context, tenantID string, sizeBytes int64) error { + if sizeBytes < 0 { + return errors.New("usage: sizeBytes darf bei einem schreibvorgang nicht negativ sein") + } + if err := s.Increment(ctx, tenantID, StorageBytesMetric, sizeBytes); err != nil { + return fmt.Errorf("speicherverbrauch (schreiben) melden: %w", err) + } + return nil +} + +// ReportStorageDelete wird bei jedem Loeschvorgang aufgerufen — dekrementiert +// denselben Zaehler ueber ein negatives Delta desselben atomaren UPSERT. +func (s *Store) ReportStorageDelete(ctx context.Context, tenantID string, sizeBytes int64) error { + if sizeBytes < 0 { + return errors.New("usage: sizeBytes darf bei einem loeschvorgang nicht negativ sein") + } + if err := s.Increment(ctx, tenantID, StorageBytesMetric, -sizeBytes); err != nil { + return fmt.Errorf("speicherverbrauch (loeschen) melden: %w", err) + } + return nil +} + +// CurrentStorageUsage liefert den aktuellen Speicherverbrauch eines Tenants +// (Akzeptanzkriterium 3) — ein einfaches Get auf den bereits gefuehrten +// Zaehler, kein Scan des Objekt-Storage. +func (s *Store) CurrentStorageUsage(ctx context.Context, tenantID string) (int64, error) { + return s.Get(ctx, tenantID, StorageBytesMetric) +} diff --git a/internal/usage/storage_test.go b/internal/usage/storage_test.go new file mode 100644 index 0000000..3fbfef4 --- /dev/null +++ b/internal/usage/storage_test.go @@ -0,0 +1,130 @@ +package usage + +import ( + "context" + "sync" + "testing" +) + +// Akzeptanzkriterium 1 + 2 + Pruefung 1: paralleler Schreib-Test (viele +// gleichzeitige Uploads) ergibt korrekten Endstand ohne verlorene Updates. +func TestReportStorageWrite_ConcurrentUploadsSumCorrectly(t *testing.T) { + store, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + tenant := newTenantID() + + sizes := []int64{1024, 2048, 4096, 8192, 512, 256, 1000, 999, 1, 7000} + var wg sync.WaitGroup + for _, size := range sizes { + wg.Add(1) + go func(sz int64) { + defer wg.Done() + if err := store.ReportStorageWrite(ctx, tenant, sz); err != nil { + t.Errorf("report write: %v", err) + } + }(size) + } + wg.Wait() + + var expected int64 + for _, s := range sizes { + expected += s + } + + got, err := store.CurrentStorageUsage(ctx, tenant) + if err != nil { + t.Fatalf("current usage: %v", err) + } + if got != expected { + t.Fatalf("erwartet %d bytes (unabhaengige kontrollsumme), habe %d — hinweis auf verlorene updates", expected, got) + } +} + +// Akzeptanzkriterium 1 + Pruefung 2: Loeschvorgang dekrementiert korrekt. +func TestReportStorageDelete_Decrements(t *testing.T) { + store, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + tenant := newTenantID() + + if err := store.ReportStorageWrite(ctx, tenant, 10_000); err != nil { + t.Fatalf("write: %v", err) + } + if err := store.ReportStorageDelete(ctx, tenant, 3_000); err != nil { + t.Fatalf("delete: %v", err) + } + + got, err := store.CurrentStorageUsage(ctx, tenant) + if err != nil { + t.Fatalf("current usage: %v", err) + } + if got != 7_000 { + t.Fatalf("erwartet 7000 nach schreiben(10000)+loeschen(3000), habe %d", got) + } +} + +// Akzeptanzkriterium 3 + Pruefung 3: Abfrage liefert konsistenten Wert mit +// einer unabhaengigen Kontrollzaehlung ueber gemischte Schreib-/Loeschvorgaenge. +func TestCurrentStorageUsage_MatchesIndependentTally(t *testing.T) { + store, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + tenant := newTenantID() + + type op struct { + write bool + size int64 + } + ops := []op{ + {true, 5000}, {true, 3000}, {false, 1000}, {true, 2000}, {false, 4000}, {true, 500}, + } + + var tally int64 + for _, o := range ops { + if o.write { + if err := store.ReportStorageWrite(ctx, tenant, o.size); err != nil { + t.Fatalf("write: %v", err) + } + tally += o.size + } else { + if err := store.ReportStorageDelete(ctx, tenant, o.size); err != nil { + t.Fatalf("delete: %v", err) + } + tally -= o.size + } + } + + got, err := store.CurrentStorageUsage(ctx, tenant) + if err != nil { + t.Fatalf("current usage: %v", err) + } + if got != tally { + t.Fatalf("erwartet %d (unabhaengige kontrollzaehlung), habe %d", tally, got) + } +} + +// Akzeptanzkriterium 3: LIC-03s generischer Store.Get liefert denselben Wert +// wie CurrentStorageUsage — kein zweiter, abweichender Zaehlmechanismus. +func TestCurrentStorageUsage_MatchesGenericStoreGet(t *testing.T) { + store, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + tenant := newTenantID() + + if err := store.ReportStorageWrite(ctx, tenant, 42); err != nil { + t.Fatalf("write: %v", err) + } + + viaStorage, err := store.CurrentStorageUsage(ctx, tenant) + if err != nil { + t.Fatalf("current usage: %v", err) + } + viaGeneric, err := store.Get(ctx, tenant, StorageBytesMetric) + if err != nil { + t.Fatalf("generic get: %v", err) + } + if viaStorage != viaGeneric || viaStorage != 42 { + t.Fatalf("erwartet beide wege liefern 42, habe storage=%d generic=%d", viaStorage, viaGeneric) + } +}