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:
co-authored by
Claude Sonnet 5
parent
54c5f74778
commit
0d3779d03e
@@ -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()
|
||||||
|
)
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user