feat(calendar): Abgleich nur der Aenderungen (sync-collection, RFC 6578)

Bis hierher holte jeder Durchgang die volle Terminliste: ctag abfragen,
Aenderung sehen, alles neu laden. Eine verschobene Besprechung kostete
damit so viel wie ein Neuanfang -- bei einem Jahr Termine, alle fuenfzehn
Minuten, auf Akku und Datenvolumen des Telefons.

Kein zweiter Zaehler: Das ctag **ist** bereits eine Zahl je Kalender, die
bei jedem Schreiben um eins steigt. Jedes Objekt merkt sich in sync_seq den
Stand, bei dem es zuletzt angefasst wurde (Migration 24).

Grabsteine (cal_tombstones) halten geloeschte UIDs fest -- ohne sie bliebe
ein geloeschter Termin auf jedem Geraet stehen, das gerade nicht verbunden
war. Sie stehen 180 Tage; beim Raeumen wandert sync_floor mit, und ein
Token darunter bekommt 403 <valid-sync-token/> statt einer Antwort mit
fehlenden Loeschungen.

go-webdav kennt fuer REPORT nur calendar-query und calendar-multiget, und
weder sync-token noch supported-report-set. Beide gehen denselben Weg wie
getctag und calendar-color. Ohne supported-report-set probiert DAVx5
sync-collection gar nicht erst aus.

Zwei Fehler noch vor dem Ausliefern gefunden:

* Die Anfuehrungszeichen am ETag standen **innerhalb** der Maskierung --
  xml.EscapeText machte daraus &#34;. Gueltig, aber genug Clients
  vergleichen ETags roh und saehen bei jedem Durchgang eine Aenderung.
* pruneTombstones nahm die erste Zeile eines DELETE ... RETURNING als
  Maximum. Das hat keine Reihenfolge -- der sync_floor waere zu niedrig
  gesetzt worden, und ein Client dazwischen haette Loeschungen verpasst.

caldav-selbsttest.py: 62 -> 86 Pruefungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-12 00:03:08 +02:00
1 parent df9d2d6ded
commit 8fdde182b7
11 files changed
+728 -33

No files matched your search

+216 -25
View File
@@ -29,7 +29,7 @@ func NewStore(db *pgxpool.Pool) *Store {
func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calendar, error) {
rows, err := s.db.Query(ctx,
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
FROM cal_calendars WHERE owner_id = $1 ORDER BY created_at`,
ownerID,
)
@@ -41,7 +41,7 @@ func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calenda
var out []Calendar
for rows.Next() {
var c Calendar
if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt); err != nil {
if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt); err != nil {
return nil, fmt.Errorf("scan calendar: %w", err)
}
out = append(out, c)
@@ -52,10 +52,10 @@ func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calenda
func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calendar, error) {
var c Calendar
err := s.db.QueryRow(ctx,
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
FROM cal_calendars WHERE owner_id = $1 AND id = $2`,
ownerID, id,
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
@@ -75,13 +75,13 @@ func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calend
func (s *Store) GetCalendarByRef(ctx context.Context, ownerID uuid.UUID, ref string) (*Calendar, error) {
var c Calendar
err := s.db.QueryRow(ctx,
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
FROM cal_calendars
WHERE owner_id = $1 AND (path_segment = $2 OR (path_segment IS NULL AND id::text = $2))
ORDER BY path_segment NULLS LAST
LIMIT 1`,
ownerID, ref,
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
@@ -102,9 +102,9 @@ func (s *Store) CreateCalendarAt(ctx context.Context, ownerID uuid.UUID, segment
err := s.db.QueryRow(ctx,
`INSERT INTO cal_calendars (owner_id, name, color, description, path_segment)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at`,
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
ownerID, name, color, description, segment,
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
if isUniqueViolation(err) {
return nil, ErrConflict
}
@@ -142,9 +142,9 @@ func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, col
err := s.db.QueryRow(ctx,
`INSERT INTO cal_calendars (owner_id, name, color, description)
VALUES ($1, $2, $3, $4)
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at`,
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
ownerID, name, color, description,
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
if err != nil {
return nil, fmt.Errorf("insert calendar: %w", err)
}
@@ -164,9 +164,9 @@ func (s *Store) UpdateCalendar(ctx context.Context, ownerID, id uuid.UUID, name,
description = COALESCE($5, description),
timezone = COALESCE($6, timezone)
WHERE owner_id = $1 AND id = $2
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at`,
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
ownerID, id, name, color, description, timezone,
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
@@ -248,22 +248,35 @@ func (s *Store) PutObject(ctx context.Context, calendarID uuid.UUID, uid, compon
}
defer tx.Rollback(ctx)
// Die Sequenz **vor** dem Schreiben ziehen: Das Objekt soll genau den
// Stand tragen, auf den der Kalender damit springt.
seq, err := bumpSeq(ctx, tx, calendarID)
if err != nil {
return nil, err
}
var o Object
err = tx.QueryRow(ctx,
`INSERT INTO cal_objects (calendar_id, uid, component, data, etag, dtstart, dtend, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
`INSERT INTO cal_objects (calendar_id, uid, component, data, etag, dtstart, dtend, sync_seq, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
ON CONFLICT (calendar_id, uid) DO UPDATE
SET component = EXCLUDED.component, data = EXCLUDED.data, etag = EXCLUDED.etag,
dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend, updated_at = now()
dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend,
sync_seq = EXCLUDED.sync_seq, updated_at = now()
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
calendarID, uid, component, data, etag, dtstart, dtend,
calendarID, uid, component, data, etag, dtstart, dtend, seq,
).Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("upsert object: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1`, calendarID); err != nil {
return nil, fmt.Errorf("bump ctag: %w", err)
// Auferstehung: Wurde dieselbe UID vorher geloescht, steht noch ein
// Grabstein da. Bliebe er liegen, bekaeme ein Client beim naechsten
// Abgleich beides zu sehen -- die Datei und ihre Todesmeldung.
if _, err := tx.Exec(ctx,
`DELETE FROM cal_tombstones WHERE calendar_id = $1 AND uid = $2`, calendarID, uid,
); err != nil {
return nil, fmt.Errorf("clear tombstone: %w", err)
}
if err := tx.Commit(ctx); err != nil {
@@ -303,11 +316,22 @@ func (s *Store) MoveObject(ctx context.Context, fromCal, toCal uuid.UUID, uid st
return nil, ErrConflict
}
// Beide Seiten bekommen einen neuen Stand: In der Quelle ist der Termin
// fort (Grabstein), im Ziel ist er neu.
fromSeq, err := bumpSeq(ctx, tx, fromCal)
if err != nil {
return nil, err
}
toSeq, err := bumpSeq(ctx, tx, toCal)
if err != nil {
return nil, err
}
row := tx.QueryRow(ctx,
`UPDATE cal_objects SET calendar_id = $1, updated_at = now()
WHERE calendar_id = $2 AND uid = $3
`UPDATE cal_objects SET calendar_id = $1, sync_seq = $2, updated_at = now()
WHERE calendar_id = $3 AND uid = $4
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
toCal, fromCal, uid,
toCal, toSeq, fromCal, uid,
)
o, err := scanObject(row)
if errors.Is(err, pgx.ErrNoRows) {
@@ -317,10 +341,14 @@ func (s *Store) MoveObject(ctx context.Context, fromCal, toCal uuid.UUID, uid st
return nil, err
}
if err := setTombstone(ctx, tx, fromCal, uid, fromSeq); err != nil {
return nil, err
}
// Im Ziel darf kein alter Grabstein derselben UID stehen bleiben.
if _, err := tx.Exec(ctx,
`UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1 OR id = $2`, fromCal, toCal,
`DELETE FROM cal_tombstones WHERE calendar_id = $1 AND uid = $2`, toCal, uid,
); err != nil {
return nil, fmt.Errorf("bump ctag: %w", err)
return nil, fmt.Errorf("clear tombstone: %w", err)
}
if err := tx.Commit(ctx); err != nil {
@@ -343,8 +371,171 @@ func (s *Store) DeleteObject(ctx context.Context, calendarID uuid.UUID, uid stri
if tag.RowsAffected() == 0 {
return ErrNotFound
}
if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1`, calendarID); err != nil {
return fmt.Errorf("bump ctag: %w", err)
seq, err := bumpSeq(ctx, tx, calendarID)
if err != nil {
return err
}
if err := setTombstone(ctx, tx, calendarID, uid, seq); err != nil {
return err
}
if err := pruneTombstones(ctx, tx, calendarID); err != nil {
return err
}
return tx.Commit(ctx)
}
// ── Abgleich nur der Änderungen (RFC 6578) ───────────────────────────────────
// Wie lange ein Grabstein stehen bleibt.
//
// Er ist die einzige Spur eines gelöschten Termins. Solange er da ist, kann
// ein Gerät, das ein halbes Jahr aus war, die Löschung noch nachvollziehen;
// danach macht es einen Vollabgleich und kommt auch dorthin – nur teurer.
// Ein halbes Jahr deckt einen Urlaub, ein defektes Telefon und eine
// Elternzeit ab; wer länger fort war, hat ohnehin ein neues Gerät.
const tombstoneTTL = 180 * 24 * time.Hour
type execer interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
// bumpSeq zählt den Kalender einen Stand weiter und liefert den neuen.
//
// Es ist **dasselbe** ctag, das Clients schon zum Erkennen von Änderungen
// abfragen – ein zweiter Zähler daneben wäre eine zweite Wahrheit über
// denselben Sachverhalt, und zwei Wahrheiten laufen auseinander.
func bumpSeq(ctx context.Context, tx execer, calendarID uuid.UUID) (int64, error) {
var seq int64
if err := tx.QueryRow(ctx,
`UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1 RETURNING ctag`, calendarID,
).Scan(&seq); err != nil {
return 0, fmt.Errorf("bump ctag: %w", err)
}
return seq, nil
}
func setTombstone(ctx context.Context, tx execer, calendarID uuid.UUID, uid string, seq int64) error {
_, err := tx.Exec(ctx,
`INSERT INTO cal_tombstones (calendar_id, uid, sync_seq, deleted_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (calendar_id, uid) DO UPDATE
SET sync_seq = EXCLUDED.sync_seq, deleted_at = now()`,
calendarID, uid, seq)
if err != nil {
return fmt.Errorf("set tombstone: %w", err)
}
return nil
}
// pruneTombstones räumt alte Grabsteine weg und hebt dabei den Boden an.
//
// Beides gehört zusammen: Einen Grabstein zu löschen, ohne festzuhalten, bis
// wohin geräumt wurde, wäre genau der stille Datenverlust, gegen den es die
// Grabsteine überhaupt gibt – ein Client mit altem Token bekäme eine Antwort,
// in der eine Löschung einfach fehlt, und behielte den Termin für immer.
//
// Läuft beim Löschen mit, statt in einem eigenen Auftrag: Nur dort entstehen
// Grabsteine, also ist es genau der Moment, in dem es etwas zu räumen gibt.
func pruneTombstones(ctx context.Context, tx execer, calendarID uuid.UUID) error {
// Das Maximum muss **in der Abfrage** entstehen, nicht beim Lesen der
// ersten Zeile: `DELETE … RETURNING` hat keine Reihenfolge. Wer sich die
// erste Zeile greift, setzt den Boden auf irgendeinen der geräumten
// Stände – und ein Client mit einem Token dazwischen bekäme dann eine
// Antwort, in der Löschungen fehlen, ohne dass es jemandem auffiele.
var hoechste *int64
if err := tx.QueryRow(ctx,
`WITH geraeumt AS (
DELETE FROM cal_tombstones
WHERE calendar_id = $1 AND deleted_at < now() - $2::interval
RETURNING sync_seq
)
SELECT max(sync_seq) FROM geraeumt`,
calendarID, tombstoneTTL.String(),
).Scan(&hoechste); err != nil {
return fmt.Errorf("prune tombstones: %w", err)
}
if hoechste == nil {
return nil
}
if _, err := tx.Exec(ctx,
`UPDATE cal_calendars SET sync_floor = GREATEST(sync_floor, $2) WHERE id = $1`,
calendarID, *hoechste,
); err != nil {
return fmt.Errorf("raise sync floor: %w", err)
}
return nil
}
// Changes ist die Antwort auf „was hat sich seit Stand N getan".
type Changes struct {
// Angelegt oder geändert – beides sieht für den Client gleich aus, er
// holt das Objekt in beiden Fällen neu.
Changed []Object
// UIDs, die es nicht mehr gibt.
Deleted []string
// Der Stand, auf dem der Client danach steht.
Token int64
}
// ChangesSince liefert alles, was sich in einem Kalender seit `since` getan
// hat. `since` = 0 heißt „ich fange bei null an" und liefert den vollen
// Bestand – genau das, was ein Client beim ersten Abgleich braucht.
//
// Der Rückgabewert `ok` ist falsch, wenn das Token zu alt ist: Dann sind
// Grabsteine aus diesem Zeitraum schon weggeräumt, und eine Antwort wäre
// unvollständig, ohne dass es jemandem auffiele.
func (s *Store) ChangesSince(ctx context.Context, cal *Calendar, since int64) (*Changes, bool, error) {
if since < 0 || since > cal.CTag {
// Ein Token aus der Zukunft gibt es nicht – vermutlich stammt es von
// einer anderen Instanz oder aus einer wiederhergestellten Sicherung.
return nil, false, nil
}
if since > 0 && since < cal.SyncFloor {
return nil, false, nil
}
out := &Changes{Token: cal.CTag, Changed: []Object{}, Deleted: []string{}}
rows, err := s.db.Query(ctx,
`SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at
FROM cal_objects WHERE calendar_id = $1 AND sync_seq > $2 ORDER BY sync_seq`,
cal.ID, since)
if err != nil {
return nil, false, fmt.Errorf("query changed objects: %w", err)
}
defer rows.Close()
for rows.Next() {
o, err := scanObject(rows)
if err != nil {
return nil, false, err
}
out.Changed = append(out.Changed, o)
}
if err := rows.Err(); err != nil {
return nil, false, err
}
// Beim ersten Abgleich gibt es nichts zu melden, was es nicht mehr gibt:
// Der Client kennt den Kalender ja noch gar nicht. Grabsteine würden ihm
// nur Löschungen für Termine schicken, die er nie gesehen hat.
if since == 0 {
return out, true, nil
}
trows, err := s.db.Query(ctx,
`SELECT uid FROM cal_tombstones WHERE calendar_id = $1 AND sync_seq > $2 ORDER BY sync_seq`,
cal.ID, since)
if err != nil {
return nil, false, fmt.Errorf("query tombstones: %w", err)
}
defer trows.Close()
for trows.Next() {
var uid string
if err := trows.Scan(&uid); err != nil {
return nil, false, err
}
out.Deleted = append(out.Deleted, uid)
}
return out, true, trows.Err()
}