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
This commit is contained in:
sysops
2026-08-31 23:37:21 +02:00
co-authored by Claude Sonnet 5
parent 54c5f74778
commit 0d3779d03e
5 changed files with 501 additions and 0 deletions
+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())
}