Compare commits

..
Author SHA1 Message Date
sysopsandClaude Sonnet 5 0d3779d03e ING-05: folder-state-uidvalidity-handling
Folder-State-Verwaltung inklusive UIDVALIDITY/UIDNEXT-Handling (RFC 3501
§2.3.1.1), damit Clients und Importvorgänge konsistente Sichten
erhalten. Direkte Vorbedingung für IMP-01.

- store.go: GetOrCreate/CurrentState konsistent bei parallelem Zugriff
  (INSERT ON CONFLICT + Rücklese). NextUID vergibt UIDs atomar über
  UPDATE...RETURNING unter Zeilensperre, protokolliert jede Vergabe.
  Rebuild garantiert über GREATEST(uidvalidity+1, jetzt) eine strikt neue
  UIDVALIDITY auch bei Neuaufbauten innerhalb derselben Nanosekunde,
  setzt UIDNEXT zurück auf 1. RecordDeletion ändert UIDNEXT nicht (UIDs
  werden nie wiederverwendet).
- Bekannten Fehler vermieden (archivmail: UIDVALIDITY=0 bricht Resync):
  UIDVALIDITY wird selbst erzeugt (Unix-Nanosekunden), nie von außen
  übernommen.
- Kein Umbau: mail/internal/imap (ING-01) unverändert, folderstate ist
  eigenständig und kann künftig (IMP-01) als MailboxStore-Implementierung
  dienen.

Prüfungen (alle real durchgeführt, siehe mail/docs/ING-05-PRUEFPROTOKOLL.md):
1. TestRebuild_ChangesUIDValidityOnSimulatedFolderRebuild: UIDVALIDITY
   real geändert, UIDNEXT real zurückgesetzt, Ereignis real protokolliert.
2. TestNextUID_ConcurrentSessionsOnSameFolderNoInconsistency: 20 reale
   gleichzeitige Vergaben, 0 Dopplungen.
3. TestNextUID_MonotonicAcrossManyInsertDeleteCycles: 200 Zyklen real
   strikt monoton, Löschungen ohne Einfluss auf UIDNEXT.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 23:37:21 +02:00
5 changed files with 501 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
# ING-05 Prüfprotokoll: Folder-State & UIDVALIDITY-Handling
Voraussetzung ING-01 (Fertig). ING-05 ist die direkte Vorbedingung für
IMP-01 (gemeinsam mit ING-01, bereits Fertig) — ohne ING-05 bleibt IMP-01
weiterhin blockiert.
## Umsetzung
- `mail/internal/folderstate/store.go``Store` (Postgres,
`mail_folder_state` + `mail_folder_state_events`, gleiches Muster wie
`dedup`/`indexworker`/`savedsearch`):
- `GetOrCreate`/`CurrentState`: konsistente Sicht bei parallelem Zugriff
(Akzeptanzkriterium 2) — `INSERT ... ON CONFLICT DO NOTHING` +
Rücklese, kein Lese-dann-Schreib-Fenster.
- `NextUID`: vergibt UIDs atomar über `UPDATE ... RETURNING` unter
Postgres-Zeilensperre (Akzeptanzkriterium 1/3), protokolliert jede
Vergabe als Ereignis in derselben Transaktion.
- `Rebuild`: simulierter Ordner-Neuaufbau — `GREATEST(uidvalidity + 1,
jetzt_in_ns)` garantiert eine STRENG neue UIDVALIDITY, auch wenn zwei
Neuaufbauten innerhalb derselben Nanosekunde laufen; UIDNEXT wird auf
1 zurückgesetzt.
- `RecordDeletion`/`Events`: Löschungen ändern UIDNEXT nicht (RFC 3501:
UIDs werden nie wiederverwendet), alle Zustandsänderungen bleiben
nachvollziehbar (Akzeptanzkriterium 3).
- Bekannten Fehler vermieden (archivmail: UIDVALIDITY=0 bricht Resync bei
nicht-konformen Servern): `newUIDValidity` erzeugt den Wert selbst
(Unix-Nanosekunden, garantiert > 0), statt einen extern gelieferten
Wert unbesehen zu übernehmen.
- Kein Umbau: `mail/internal/imap` (ING-01) unverändert — `folderstate`
ist ein eigenständiges Paket, das ING-01 künftig (IMP-01) als
`MailboxStore`-Implementierung nutzen kann, ohne dass ING-01 selbst
angefasst werden musste.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Automatisierter Test für UIDVALIDITY-Änderung bei simuliertem Ordner-Neuaufbau | **bestanden** `TestRebuild_ChangesUIDValidityOnSimulatedFolderRebuild`: Ordner angelegt, UID vergeben, `Rebuild` aufgerufen — UIDVALIDITY real geändert, UIDNEXT real auf 1 zurückgesetzt, `rebuilt`-Ereignis real protokolliert |
| 2 | Nebenläufigkeitstest: zwei Sessions auf demselben Ordner ohne Inkonsistenz | **bestanden** `TestNextUID_ConcurrentSessionsOnSameFolderNoInconsistency`: 20 reale gleichzeitige `NextUID`-Aufrufe auf demselben Ordner, alle 20 UIDs real eindeutig, keine Dopplung |
| 3 | Test für UIDNEXT-Monotonie über viele Einfüge-/Löschzyklen | **bestanden** `TestNextUID_MonotonicAcrossManyInsertDeleteCycles`: 200 Zyklen, jede zweite Nachricht real "gelöscht" — UIDNEXT bleibt real strikt monoton steigend, Löschungen beeinflussen die Vergabe nicht |
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=... go test ./internal/folderstate/... -v -> 3/3 bestanden
TEST_TENANT_DSN=... TEST_MANTICORE_URL=... go test ./... -p 1
-> alle 13 Pakete bestanden, keine Regression
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
real erfüllt. Entsperrt IMP-01 (gemeinsam mit ING-01, bereits Fertig) und
ING-10.
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS mail_folder_state (
tenant_slug TEXT NOT NULL,
mailbox_name TEXT NOT NULL,
uidvalidity BIGINT NOT NULL,
uidnext BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_slug, mailbox_name)
)
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS mail_folder_state_events (
id BIGSERIAL PRIMARY KEY,
tenant_slug TEXT NOT NULL,
mailbox_name TEXT NOT NULL,
event_type TEXT NOT NULL CHECK (event_type IN ('uid_assigned', 'deleted', 'rebuilt')),
uid BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
+251
View File
@@ -0,0 +1,251 @@
// Package folderstate implementiert ING-05: Folder-State-Verwaltung
// inklusive UIDVALIDITY/UIDNEXT-Handling für IMAP-Ordner (RFC 3501
// §2.3.1.1), damit Clients (mail/internal/imap, ING-01) und
// Importvorgänge (IMP-01) konsistente Sichten erhalten. Persistiert in
// Postgres, gleiches Muster wie mail/internal/dedup/indexworker/
// savedsearch — kein zentraler Migrationsläufer für Mandanten-
// Datenbanken im Mail-Modul vorhanden, EnsureSchema legt die Tabellen
// idempotent an.
//
// Bekannten Fehler vermeiden (siehe ING-01/repos-analyse-mail-reuse.md):
// archivmail brach den Resync bei UIDVALIDITY=0 nicht-konformer Server —
// dieses Paket erzeugt UIDVALIDITY selbst (Unix-Zeitstempel beim
// Ordner-Neuaufbau, garantiert > 0 und monoton wachsend über
// aufeinanderfolgende Neuaufbauten hinweg) statt einen von außen
// gelieferten Wert unbesehen zu übernehmen.
package folderstate
import (
"context"
_ "embed"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/0001_mail_folder_state.sql
var schemaMigration string
//go:embed migrations/0002_mail_folder_state_events.sql
var eventsSchemaMigration string
// EventType (Akzeptanzkriterium 3: State-Änderungen nachvollziehbar
// persistiert).
const (
EventUIDAssigned = "uid_assigned"
EventDeleted = "deleted"
EventRebuilt = "rebuilt"
)
// FolderState ist der aktuelle UIDVALIDITY/UIDNEXT-Zustand eines Ordners.
type FolderState struct {
TenantSlug string
MailboxName string
UIDValidity uint64
UIDNext uint64
}
// Event ist ein einzelner, nachvollziehbarer Zustandsänderungseintrag.
type Event struct {
EventType string
UID *uint64
CreatedAt time.Time
}
// Store verwaltet Folder-State je Mandant und Postfach.
type Store struct {
pool *pgxpool.Pool
// now ist austauschbar für Tests (deterministische UIDVALIDITY-Werte).
now func() time.Time
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool, now: time.Now}
}
// EnsureSchema legt die Tabellen an, falls sie noch nicht existieren.
func (s *Store) EnsureSchema(ctx context.Context) error {
if _, err := s.pool.Exec(ctx, schemaMigration); err != nil {
return fmt.Errorf("folderstate: schema anlegen: %w", err)
}
if _, err := s.pool.Exec(ctx, eventsSchemaMigration); err != nil {
return fmt.Errorf("folderstate: ereignis-schema anlegen: %w", err)
}
return nil
}
// GetOrCreate liefert den aktuellen Zustand eines Ordners und legt ihn
// bei erstem Zugriff neu an (UIDNEXT beginnt bei 1, RFC 3501 §2.3.1.1).
// Konsistent bei parallelem Zugriff (Akzeptanzkriterium 2): INSERT ...
// ON CONFLICT DO NOTHING + Rücklese, kein Lese-dann-Schreib-Fenster.
func (s *Store) GetOrCreate(ctx context.Context, tenantSlug, mailboxName string) (FolderState, error) {
uidvalidity := s.newUIDValidity()
if _, err := s.pool.Exec(ctx, `
INSERT INTO mail_folder_state (tenant_slug, mailbox_name, uidvalidity, uidnext)
VALUES ($1, $2, $3, 1)
ON CONFLICT (tenant_slug, mailbox_name) DO NOTHING
`, tenantSlug, mailboxName, uidvalidity); err != nil {
return FolderState{}, fmt.Errorf("folderstate: ordner anlegen: %w", err)
}
return s.CurrentState(ctx, tenantSlug, mailboxName)
}
// CurrentState liest den Zustand ohne ihn anzulegen (Akzeptanzkriterium
// 2: konsistente Sicht bei SELECT/EXAMINE).
func (s *Store) CurrentState(ctx context.Context, tenantSlug, mailboxName string) (FolderState, error) {
var st FolderState
st.TenantSlug = tenantSlug
st.MailboxName = mailboxName
err := s.pool.QueryRow(ctx, `
SELECT uidvalidity, uidnext FROM mail_folder_state
WHERE tenant_slug = $1 AND mailbox_name = $2
`, tenantSlug, mailboxName).Scan(&st.UIDValidity, &st.UIDNext)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return FolderState{}, ErrNotFound
}
return FolderState{}, fmt.Errorf("folderstate: zustand lesen: %w", err)
}
return st, nil
}
// ErrNotFound wird geliefert, wenn für den angefragten Ordner noch kein
// Zustand existiert (GetOrCreate anlegen lassen, statt hier zu raten).
var ErrNotFound = errors.New("folderstate: ordner nicht gefunden")
// NextUID vergibt atomar die nächste UID für eine neu eintreffende
// Nachricht (Akzeptanzkriterium 1/3) und protokolliert die Vergabe.
// Nebenläufigkeitssicher: UPDATE ... RETURNING läuft unter Postgres'
// Zeilensperre, zwei gleichzeitige Aufrufe für denselben Ordner können
// niemals dieselbe UID liefern (Pflichtprüfung 2).
func (s *Store) NextUID(ctx context.Context, tenantSlug, mailboxName string) (uid uint64, err error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("folderstate: transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
err = tx.QueryRow(ctx, `
UPDATE mail_folder_state
SET uidnext = uidnext + 1, updated_at = now()
WHERE tenant_slug = $1 AND mailbox_name = $2
RETURNING uidnext - 1
`, tenantSlug, mailboxName).Scan(&uid)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrNotFound
}
return 0, fmt.Errorf("folderstate: uid vergeben: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO mail_folder_state_events (tenant_slug, mailbox_name, event_type, uid)
VALUES ($1, $2, $3, $4)
`, tenantSlug, mailboxName, EventUIDAssigned, uid); err != nil {
return 0, fmt.Errorf("folderstate: ereignis protokollieren: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("folderstate: uid-vergabe committen: %w", err)
}
return uid, nil
}
// RecordDeletion protokolliert die Löschung einer Nachricht mit
// gegebener UID (Akzeptanzkriterium 3). UIDNEXT bleibt unverändert —
// gelöschte UIDs werden gemäß RFC 3501 niemals wiederverwendet.
func (s *Store) RecordDeletion(ctx context.Context, tenantSlug, mailboxName string, uid uint64) error {
if _, err := s.pool.Exec(ctx, `
INSERT INTO mail_folder_state_events (tenant_slug, mailbox_name, event_type, uid)
VALUES ($1, $2, $3, $4)
`, tenantSlug, mailboxName, EventDeleted, uid); err != nil {
return fmt.Errorf("folderstate: löschung protokollieren: %w", err)
}
return nil
}
// Rebuild simuliert einen Ordner-Neuaufbau (z. B. nach erkannter
// Inkonsistenz oder bei einem Server, der seinerseits eine neue
// UIDVALIDITY meldet): vergibt eine garantiert neue UIDVALIDITY und
// setzt UIDNEXT zurück auf 1 (Pflichtprüfung 1).
func (s *Store) Rebuild(ctx context.Context, tenantSlug, mailboxName string) (FolderState, error) {
candidateUIDValidity := s.newUIDValidity()
tx, err := s.pool.Begin(ctx)
if err != nil {
return FolderState{}, fmt.Errorf("folderstate: transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
// GREATEST(...)+1 garantiert eine STRENG größere UIDVALIDITY als die
// bisherige, unabhängig von der Uhrenauflösung — zwei Neuaufbauten
// innerhalb derselben Nanosekunde dürfen niemals denselben Wert
// liefern (Pflichtprüfung 1).
var newUIDValidity uint64
err = tx.QueryRow(ctx, `
UPDATE mail_folder_state
SET uidvalidity = GREATEST(uidvalidity + 1, $3), uidnext = 1, updated_at = now()
WHERE tenant_slug = $1 AND mailbox_name = $2
RETURNING uidvalidity
`, tenantSlug, mailboxName, candidateUIDValidity).Scan(&newUIDValidity)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return FolderState{}, ErrNotFound
}
return FolderState{}, fmt.Errorf("folderstate: neuaufbau: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO mail_folder_state_events (tenant_slug, mailbox_name, event_type)
VALUES ($1, $2, $3)
`, tenantSlug, mailboxName, EventRebuilt); err != nil {
return FolderState{}, fmt.Errorf("folderstate: neuaufbau-ereignis protokollieren: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return FolderState{}, fmt.Errorf("folderstate: neuaufbau committen: %w", err)
}
return FolderState{TenantSlug: tenantSlug, MailboxName: mailboxName, UIDValidity: newUIDValidity, UIDNext: 1}, nil
}
// Events liefert die protokollierten Zustandsänderungen eines Ordners in
// zeitlicher Reihenfolge (Akzeptanzkriterium 3: nachvollziehbar).
func (s *Store) Events(ctx context.Context, tenantSlug, mailboxName string) ([]Event, error) {
rows, err := s.pool.Query(ctx, `
SELECT event_type, uid, created_at FROM mail_folder_state_events
WHERE tenant_slug = $1 AND mailbox_name = $2
ORDER BY id ASC
`, tenantSlug, mailboxName)
if err != nil {
return nil, fmt.Errorf("folderstate: ereignisse lesen: %w", err)
}
defer rows.Close()
var events []Event
for rows.Next() {
var e Event
var uid *int64
if err := rows.Scan(&e.EventType, &uid, &e.CreatedAt); err != nil {
return nil, fmt.Errorf("folderstate: ereigniszeile lesen: %w", err)
}
if uid != nil {
u := uint64(*uid)
e.UID = &u
}
events = append(events, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("folderstate: ereignisse iterieren: %w", err)
}
return events, nil
}
// newUIDValidity erzeugt eine garantiert positive, für praktische Zwecke
// eindeutige UIDVALIDITY (Unix-Nanosekunden) — vermeidet den bekannten
// archivmail-Fehler UIDVALIDITY=0.
func (s *Store) newUIDValidity() uint64 {
return uint64(s.now().UnixNano())
}
+176
View File
@@ -0,0 +1,176 @@
// Integrationstest (ING-05): echte Postgres-Instanz, folgt derselben
// Testhost-Konvention wie mail/internal/dedup/indexworker/savedsearch —
// TEST_TENANT_DSN.
package folderstate
import (
"context"
"os"
"sync"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupStore(t *testing.T) *Store {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
store := NewStore(pool)
if err := store.EnsureSchema(ctx); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
ctx := context.Background()
_, _ = pool.Exec(ctx, `DELETE FROM mail_folder_state WHERE tenant_slug LIKE 'mandant-ing05-%'`)
_, _ = pool.Exec(ctx, `DELETE FROM mail_folder_state_events WHERE tenant_slug LIKE 'mandant-ing05-%'`)
})
return store
}
// TestRebuild_ChangesUIDValidityOnSimulatedFolderRebuild ist die
// geforderte Pflichtprüfung 1: automatisierter Test für
// UIDVALIDITY-Änderung bei simuliertem Ordner-Neuaufbau.
func TestRebuild_ChangesUIDValidityOnSimulatedFolderRebuild(t *testing.T) {
store := setupStore(t)
ctx := context.Background()
tenant := "mandant-ing05-rebuild"
initial, err := store.GetOrCreate(ctx, tenant, "INBOX")
if err != nil {
t.Fatalf("getorcreate: %v", err)
}
if initial.UIDValidity == 0 {
t.Fatal("erwartete uidvalidity != 0 (bekannter archivmail-fehler vermeiden)")
}
// UIDNEXT vor dem Neuaufbau real erhöhen, damit der Reset auf 1
// nachweisbar ist.
if _, err := store.NextUID(ctx, tenant, "INBOX"); err != nil {
t.Fatalf("nextuid: %v", err)
}
rebuilt, err := store.Rebuild(ctx, tenant, "INBOX")
if err != nil {
t.Fatalf("rebuild: %v", err)
}
if rebuilt.UIDValidity == initial.UIDValidity {
t.Fatalf("erwartete geänderte uidvalidity nach neuaufbau, habe weiterhin %d", rebuilt.UIDValidity)
}
if rebuilt.UIDNext != 1 {
t.Fatalf("erwartete uidnext=1 nach neuaufbau, habe %d", rebuilt.UIDNext)
}
events, err := store.Events(ctx, tenant, "INBOX")
if err != nil {
t.Fatalf("events: %v", err)
}
found := false
for _, e := range events {
if e.EventType == EventRebuilt {
found = true
}
}
if !found {
t.Fatal("erwartete protokolliertes 'rebuilt'-ereignis (akzeptanzkriterium 3: nachvollziehbar)")
}
}
// TestNextUID_ConcurrentSessionsOnSameFolderNoInconsistency ist die
// geforderte Pflichtprüfung 2: Nebenläufigkeitstest — zwei Sessions auf
// demselben Ordner ohne Inkonsistenz.
func TestNextUID_ConcurrentSessionsOnSameFolderNoInconsistency(t *testing.T) {
store := setupStore(t)
ctx := context.Background()
tenant := "mandant-ing05-concurrent"
if _, err := store.GetOrCreate(ctx, tenant, "INBOX"); err != nil {
t.Fatalf("getorcreate: %v", err)
}
const parallelSessions = 20
var wg sync.WaitGroup
uids := make(chan uint64, parallelSessions)
errs := make(chan error, parallelSessions)
for i := 0; i < parallelSessions; i++ {
wg.Add(1)
go func() {
defer wg.Done()
uid, err := store.NextUID(ctx, tenant, "INBOX")
if err != nil {
errs <- err
return
}
uids <- uid
}()
}
wg.Wait()
close(uids)
close(errs)
for err := range errs {
t.Fatalf("nextuid unter nebenläufigkeit: %v", err)
}
seen := make(map[uint64]bool, parallelSessions)
for uid := range uids {
if seen[uid] {
t.Fatalf("uid %d doppelt vergeben — inkonsistenz unter nebenläufigem zugriff", uid)
}
seen[uid] = true
}
if len(seen) != parallelSessions {
t.Fatalf("erwartete %d eindeutige uids, habe %d", parallelSessions, len(seen))
}
}
// TestNextUID_MonotonicAcrossManyInsertDeleteCycles ist die geforderte
// Pflichtprüfung 3: Test für UIDNEXT-Monotonie über viele Einfüge-/
// Löschzyklen.
func TestNextUID_MonotonicAcrossManyInsertDeleteCycles(t *testing.T) {
store := setupStore(t)
ctx := context.Background()
tenant := "mandant-ing05-monotonie"
if _, err := store.GetOrCreate(ctx, tenant, "INBOX"); err != nil {
t.Fatalf("getorcreate: %v", err)
}
var lastUID uint64
for i := 0; i < 200; i++ {
uid, err := store.NextUID(ctx, tenant, "INBOX")
if err != nil {
t.Fatalf("nextuid (zyklus %d): %v", i, err)
}
if i > 0 && uid <= lastUID {
t.Fatalf("uidnext nicht monoton steigend: zyklus %d, vorherige uid=%d, neue uid=%d", i, lastUID, uid)
}
lastUID = uid
// Löschung darf UIDNEXT NICHT verändern (RFC 3501: UIDs werden nie
// wiederverwendet) — jede zweite Nachricht wird "gelöscht".
if i%2 == 0 {
if err := store.RecordDeletion(ctx, tenant, "INBOX", uid); err != nil {
t.Fatalf("recorddeletion (zyklus %d): %v", i, err)
}
}
}
final, err := store.CurrentState(ctx, tenant, "INBOX")
if err != nil {
t.Fatalf("currentstate: %v", err)
}
if final.UIDNext != lastUID+1 {
t.Fatalf("erwartete uidnext=%d nach 200 vergebenen uids, habe %d", lastUID+1, final.UIDNext)
}
}