Importar un repositorio externo

Referencia del flujo de importación del repositorio externo en gitrust: rol de la base de datos, diagrama de secuencia completo y vías de optimización.

1. Papel de la base de datos

Durante una importación, la base de datos cumple cuatro funciones distintas:

Rôle Table Fréquence
File d'attente persistante import_jobs 1 INSERT à la création
Suivi d'état pour l'UI import_jobs 1 UPDATE toutes les ~1.5 s
Enregistrement final du dépôt repositories + resources 2 INSERT à la fin
Journal d'audit audit_log 1 INSERT à la fin

El clon en sí nunca pasa a través de la base de datos: solo los metadatos y los contadores de progreso lo atraviesan. Los objetos Git se escriben directamente en el disco en {GIT_REPOS_BASE_PATH}/{owner}/{slug}.git/.

¿Por qué persistir el Estado?

  • Reanudar después del reinicio: el servidor puede fallar durante una clonación que dura varios minutos. La tabla permite que estos trabajos se marquen como "fallidos" al reiniciar.
  • SSE: la interfaz de usuario lee el progreso a través de un punto final SSE que sondea la base de datos cada 2 s. Sin DB, necesitaría un canal en memoria más un mecanismo de enrutamiento al cliente correcto.
  • Multinavegador: si el usuario cierra la pestaña y la vuelve a abrir, encontrará el estado exacto del trabajo.
  • Auditoría/historial: conservación de importaciones pasadas con sus estadísticas.

2. Diagrama de flujo actual

sequenceDiagram
  autonumber
  participant UI as Navigateur
  participant H as Handler HTTP
  participant Svc as ImportService
  participant Chan as mpsc Channel
  participant W as ImportWorker
  participant G as git2 (libgit2)
  participant DB as PostgreSQL
  participant FS as Disque

  UI->>H: POST /import
(url, slug, pat) H->>Svc: create_job Svc->>DB: INSERT import_jobs (pending) H->>Chan: try_send(ImportTask+PAT) H-->>UI: 302 /imports/{id} UI->>H: GET /imports/{id}/stream (SSE) Note over UI,H: EventSource ouvert Chan->>W: ImportTask W->>Svc: mark_running Svc->>DB: SELECT import_jobs
UPDATE status=running W->>G: RepoBuilder.bare(true).clone() G->>FS: init_bare + fetch loop callbacks transfer_progress (flood) G-->>W: stats (objets/bytes) alt throttle 1500 ms écoulé W->>Svc: update_progress (tokio::spawn) Svc->>DB: SELECT + UPDATE import_jobs else dans le throttle W--xW: ignore end end loop toutes les 2 s H->>DB: SELECT import_jobs WHERE id=? DB-->>H: état courant H-->>UI: SSE event (JSON) UI->>UI: bar.value = percent end G-->>FS: objets écrits G-->>W: Ok W->>Svc: check cancel (SELECT import_jobs) W->>Svc: update_progress (Finalizing) Svc->>DB: SELECT + UPDATE W->>RepositoryService: create RepositoryService->>DB: INSERT resources
INSERT repositories W->>DB: UPDATE repositories
(import_source_url, is_empty=false) W->>Svc: mark_success Svc->>DB: SELECT + UPDATE import_jobs
(status=success, duration) W->>AuditService: log REPO_IMPORTED AuditService->>DB: INSERT audit_log H->>DB: SELECT (prochain tick SSE) H-->>UI: terminal=true UI->>UI: window.location.reload()

3. Costo de BD por importación

Para un clon de 90 segundos con aceleración de 1500 ms:

Étape Opérations DB Cumul
create_job 1 INSERT 1
mark_running 1 SELECT + 1 UPDATE 3
update_progress pendant clone ~60 x (1 SELECT + 1 UPDATE) 123
check cancel 1 SELECT 124
update_progress finalizing 1 SELECT + 1 UPDATE 126
RepositoryService::create 2 INSERT (repo + resource) 128
update repositories 1 UPDATE 129
mark_success 1 SELECT + 1 UPDATE 131
audit log 1 INSERT 132
SSE stream (45 ticks à 2 s) 45 SELECT 177

~177 solicitudes de importación, de las cuales 120+ son actualizaciones de progreso.

4. Rutas de optimización (sin cambiar libgit2)

4.1 Eliminar el SELECT antes de ACTUALIZAR en update_progress

update_progress actualmente hace load_model (SELECCIONAR) y luego active.update(db) (ACTUALIZAR + REGRESAR). SELECT es redundante: conocemos el ID, solo queremos parchear 4 columnas.

Ganancia: divide las solicitudes de progreso por 2 (~60 solicitudes menos para un clon de 90 s).

Rediseño sugerido con UpdateMany (patrón ya utilizado en ci_service.rs::cancel_running_pipelines):

import_job::Entity::update_many()
    .col_expr(Column::ReceivedObjects, Expr::value(received as i32))
    .col_expr(Column::TotalObjects, Expr::value(total as i32))
    .col_expr(Column::ReceivedBytes, Expr::value(bytes as i64))
    .col_expr(Column::Phase, Expr::value(phase.as_str()))
    .col_expr(Column::UpdatedAt, Expr::value(Utc::now()))
    .filter(Column::Id.eq(job_id))
    .exec(db)
    .await?;

El mismo principio para mark_running, mark_success, mark_failed, mark_cancelled → todos dividen sus viajes de ida y vuelta a la base de datos por 2.

4.2 Reducir la progresión del trabajador (dedicado)

Plan alternativo: la devolución de llamada simplemente escribe en tokio::sync::watch<TransferStats> (en memoria, cero DB). Una tarea dedicada consume este canal y ACTUALIZA la base de datos solo a 1 Hz.

flowchart LR
  G[git2 callback] -->|watch::send
très haute fréquence| W[watch channel] W --> T[Tâche update
ticker 1 Hz] T -->|1 UPDATE / s| DB

Gains : - 1 transmisor DB único en lugar de N compitiendo tokio::spawn → cero contención en el grupo - Frecuencia determinista (1 Hz) independiente de la velocidad de la red - Código más claro (la tarea es asíncrona, podemos "esperar" directamente)

4.3 Eliminar el "RETORNO" implícito de SeaORM

ActiveModel::update(db) devuelve el modelo completo mediante ... RETURNING *. Para actualizaciones de progreso, no utilizamos comentarios. UpdateMany::exec no emite RETURNING → menos bytes en el cable, menos deserialización.

4.4 Grupo de bases de datos dedicado para el trabajador

Hoy el trabajador comparte el grupo principal con los controladores HTTP. Un grupo de 4 a 8 conexiones dedicadas al trabajador evitaría que una importación sature el grupo y provoque que las solicitudes de los usuarios expiren.

4.5 Antirrebote del SSE del lado del servidor

El SSE SELECT cada 2 s incluso cuando nada cambia. Alternativa: PostgreSQL ESCUCHAR/NOTIFY en una cadena import_job_{uuid}. Cada ACTUALIZACIÓN emite una NOTIFICACIÓN, la conexión SSE ESCUCHA → envío directo, cero sondeo.

Costo: requiere una conexión de base de datos dedicada mediante SSE (LISTEN tiene estado). Compromiso a evaluar.

5. Optimización importante: desembolso de git clone --bare

Independiente de las optimizaciones de la base de datos pero, con diferencia, es la mayor ganancia. libgit2 es estructuralmente entre 2 y 5 veces más lento que la CLI git en clones HTTPS grandes (sin multiplexación, resolución delta de un solo subproceso, etc.).

flowchart TB
  subgraph Actuel[Flux actuel — libgit2]
    A1[RepoBuilder::clone] --> A2[libgit2: fetch + resolve]
    A2 -->|lent 2-5x| A3[bare repo]
  end
  subgraph Cible[Flux optimisé — git CLI]
    B1[Command::new git] --> B2[git clone --bare --progress]
    B2 -->|stderr| B3[Parser regex
Receiving objects: X%] B3 --> B4[update watch channel] B2 -->|vitesse native| B5[bare repo] end

Avantages : - Velocidad nativa (paridad con la CLI git) - Progreso nativo vía stderr Recepción de objetos: 42% (368/876), 1,10 MiB | 2,05 MB/s - HEAD correctamente posicionado (no es necesaria la solución alternativa RepoBuilder) - Menos dependencia de libgit2 para el caso del clon inicial

Contraparte: binario git requerido en el servidor (ya es el caso, usado por SbomService::git_archive).

6. Prioridades recomendadas

Si quisiéramos optimizar ahora, orden propuesto:

  1. Shell-out git clone --bare: ganancia real para el usuario (clon 3-5 veces más rápido).
  2. update_many para update_progress: divide la presión de la base de datos por 2, cambio mecánico de ~20 líneas.
  3. canal watch + tarea de 1 Hz: arquitectura más limpia, elimina problemas de agrupación.
  4. Grupo de bases de datos de trabajadores dedicados: red de seguridad operativa.
  5. ESCUCHAR/NOTIFICAR para SSE: solo si hay muchos clientes simultáneos.

Las ganancias 2-4 son útiles incluso con libgit2; La ganancia 1 es la más visible para el usuario final.