Contexto
ulfblk-calendar necesita mantener sincronizados los eventos entre una BD local y Google Calendar. El reto: los cambios pueden venir de ambos lados -- un usuario crea un evento en tu app, otro lo modifica desde Google Calendar en su telefono. Sin una estrategia clara de conflictos, terminas con datos inconsistentes o eventos duplicados.
Lo que aprendi
Two-way sync requiere tres cosas: push notifications para enterarte de cambios en Google, un sync token para traer solo los deltas, y un mecanismo de conflict resolution.
Memory provider interface
El storage se abstrae detras de un memory provider. Esto permite usar PostgreSQL en produccion, Redis para cache, o un dict en tests.
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
@dataclass
class CalendarEvent:
id: str
google_event_id: str | None
title: str
start: datetime
end: datetime
sequence: int # Para conflict resolution
updated_at: datetime
source: str # "local" | "google"
class CalendarMemoryProvider(ABC):
@abstractmethod
async def get_event(self, event_id: str) -> CalendarEvent | None: ...
@abstractmethod
async def upsert_event(self, event: CalendarEvent) -> None: ...
@abstractmethod
async def get_sync_token(self, calendar_id: str) -> str | None: ...
@abstractmethod
async def save_sync_token(self, calendar_id: str, token: str) -> None: ...
@abstractmethod
async def list_events_modified_since(self, since: datetime) -> list[CalendarEvent]: ...
Webhook receiver para push notifications
Google Calendar manda notificaciones push cuando hay cambios. El webhook recibe la notificacion y dispara un sync incremental.
from fastapi import APIRouter, Request, Response
router = APIRouter(prefix="/webhooks", tags=["calendar"])
@router.post("/google-calendar")
async def google_calendar_webhook(request: Request) -> Response:
channel_id = request.headers.get("X-Goog-Channel-ID")
resource_id = request.headers.get("X-Goog-Resource-ID")
resource_state = request.headers.get("X-Goog-Resource-State")
if resource_state == "sync":
# Confirmacion inicial del canal, ignorar
return Response(status_code=200)
if resource_state == "exists":
# Hay cambios, disparar sync incremental
await sync_from_google(channel_id=channel_id)
return Response(status_code=200)
Sync incremental con delta tokens
En vez de traer todos los eventos cada vez, usamos el sync token de Google para obtener solo lo que cambio.
from googleapiclient.discovery import build
async def sync_from_google(
channel_id: str,
service: build = None,
memory: CalendarMemoryProvider = None,
) -> int:
"""Sincroniza cambios de Google Calendar a la BD local."""
calendar_id = await get_calendar_for_channel(channel_id)
sync_token = await memory.get_sync_token(calendar_id)
params = {"calendarId": calendar_id, "singleEvents": True}
if sync_token:
params["syncToken"] = sync_token
else:
# Primera sync: traer eventos futuros
params["timeMin"] = datetime.utcnow().isoformat() + "Z"
synced_count = 0
page_token = None
while True:
if page_token:
params["pageToken"] = page_token
result = service.events().list(**params).execute()
for google_event in result.get("items", []):
await resolve_and_store(google_event, memory)
synced_count += 1
page_token = result.get("nextPageToken")
if not page_token:
break
# Guardar el nuevo sync token para la siguiente llamada
new_sync_token = result.get("nextSyncToken")
if new_sync_token:
await memory.save_sync_token(calendar_id, new_sync_token)
return synced_count
Conflict resolution con sequence numbers
Google Calendar usa un campo sequence que se incrementa con cada modificacion. La estrategia: last-write-wins comparando sequence numbers.
async def resolve_and_store(
google_event: dict,
memory: CalendarMemoryProvider,
) -> None:
"""Resuelve conflictos entre evento local y de Google."""
google_event_id = google_event["id"]
google_sequence = google_event.get("sequence", 0)
local_event = await memory.get_event_by_google_id(google_event_id)
if local_event is None:
# Evento nuevo de Google, crear local
new_event = CalendarEvent(
id=generate_id(),
google_event_id=google_event_id,
title=google_event.get("summary", ""),
start=parse_google_datetime(google_event["start"]),
end=parse_google_datetime(google_event["end"]),
sequence=google_sequence,
updated_at=datetime.utcnow(),
source="google",
)
await memory.upsert_event(new_event)
return
# Conflicto: comparar sequence numbers
if google_sequence > local_event.sequence:
# Google tiene la version mas reciente
local_event.title = google_event.get("summary", "")
local_event.start = parse_google_datetime(google_event["start"])
local_event.end = parse_google_datetime(google_event["end"])
local_event.sequence = google_sequence
local_event.updated_at = datetime.utcnow()
local_event.source = "google"
await memory.upsert_event(local_event)
# Si local_event.sequence >= google_sequence, el local gana
Leccion clave
Two-way sync necesita una fuente canonica de verdad para resolver conflictos. En nuestro caso, el sequence number de Google Calendar actua como version vector. Last-write-wins es simple y predecible -- estrategias mas sofisticadas (merge, manual resolution) agregan complejidad que rara vez se justifica en un sistema de calendario.