C
CULTIVA Engine
Referencia de Concurrencia Go — cultivaengine v1.0 — IA-Ingenieria-MLOps
Go 1.22+
Worker Pool — lotes de documentos
Fan-Out/Fan-In — 3 microservicios en paralelo
Graceful Shutdown — K8s SIGTERM
Semaphore — rate-limit API externa
errgroup — propagación de errores
REF

Primitivas de Concurrencia Go

Referencia rápida
Primitiva Propósito Uso en CULTIVA Engine
goroutine Ejecución concurrente ligera (~2KB stack) workers de inferencia
chan T Comunicación segura entre goroutines pipeline de documentos
select Multiplexar operaciones de canal timeout + cancellation
sync.WaitGroup Esperar a que terminen goroutines colector de resultados
context.Context Cancelación y deadlines propagados shutdown + timeouts
semaphore.Weighted Limitar concurrencia máxima rate-limit LLM API
errgroup.Group Goroutines con propagación de error fan-out de microservicios
sync.Map Mapa concurrente seguro (lectura frecuente) caché de embeddings
1

Worker Pool — Lotes de Documentos

goroutine + channel
Escenario: CULTIVA recibe lotes de hasta 500 documentos para indexar. En lugar de procesarlos en serie (lento) o lanzar 500 goroutines (RAM desbordada), un pool fijo de N workers consume un canal de jobs. Cuando el canal se cierra, los workers terminan solos.
Caso real cultivaengine/internal/indexer/pool.go — procesa embeddings con 8 workers paralelos
cultivaengine/internal/indexer/pool.go Go
package indexer

import (
    "context"
    "fmt"
    "sync"
)

// Document representa un documento a procesar por el pipeline de IA.
type Document struct {
    ID      string
    Content string
}

// IndexResult encapsula el resultado (embedding + score) o un error.
type IndexResult struct {
    DocID     string
    Embedding []float32
    Score     float64
    Err       error
}

// RunPool lanza numWorkers goroutines que consumen docs y escriben en results.
func RunPool(ctx context.Context, numWorkers int, docs <-chan Document) <-chan IndexResult {
    results := make(chan IndexResult, numWorkers*2)

    var wg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func(workerID int) {
            defer wg.Done()
            for doc := range docs {
                select {
                case <-ctx.Done():
                    return // contexto cancelado: salir limpiamente
                default:
                    result := embedDocument(doc)
                    results <- result
                }
            }
        }(i)
    }

    // Cerrar results cuando TODOS los workers terminen.
    go func() {
        wg.Wait()
        close(results)
    }()

    return results
}

// embedDocument llama al microservicio de embeddings (simplificado).
func embedDocument(doc Document) IndexResult {
    // En producción: llamada gRPC al embedder con timeout propio.
    return IndexResult{
        DocID:     doc.ID,
        Embedding: []float32{0.12, 0.87, 0.34},
        Score:     0.95,
    }
}

// Uso desde main o handler:
//   docs := make(chan Document, 500)
//   go feedDocs(docs, batch)            // productor cierra el canal
//   for r := range RunPool(ctx, 8, docs) {
//       store(r)                         // consumidor
//   }
💡 Regla de oro: Cierra siempre el canal desde el productor (quien escribe), nunca desde el consumidor. Aquí el canal docs lo cierra feedDocs; el canal results lo cierra la goroutine que hace wg.Wait().
2

Fan-Out / Fan-In — 3 Microservicios en Paralelo

pipeline + merge
Escenario: Para cada documento, CULTIVA Engine llama en paralelo a Embedder, Classifier y Reranker (3 servicios gRPC independientes). Fan-out lanza las 3 llamadas simultáneas; fan-in fusiona las respuestas en un único canal de resultados.
Caso real cultivaengine/internal/inference/fanout.go — latencia reducida de 900ms a ~320ms
cultivaengine/internal/inference/fanout.go Go
package inference

import (
    "context"
    "sync"
)

type ServiceResult struct {
    Service string
    Payload any
    Err     error
}

// callService simula una llamada gRPC a un microservicio.
func callService(ctx context.Context, name string, docID string) <-chan ServiceResult {
    out := make(chan ServiceResult, 1)
    go func() {
        defer close(out)
        select {
        case <-ctx.Done():
            out <- ServiceResult{Service: name, Err: ctx.Err()}
        default:
            // Llamada real al servicio (omitida para claridad)
            out <- ServiceResult{Service: name, Payload: map[string]any{"doc": docID}}
        }
    }()
    return out
}

// merge fusiona N canales en uno solo (fan-in).
func merge(ctx context.Context, channels ...<-chan ServiceResult) <-chan ServiceResult {
    var wg sync.WaitGroup
    out := make(chan ServiceResult, len(channels))

    forward := func(ch <-chan ServiceResult) {
        defer wg.Done()
        for r := range ch {
            select {
            case <-ctx.Done():
                return
            case out <- r:
            }
        }
    }

    wg.Add(len(channels))
    for _, ch := range channels {
        go forward(ch)
    }

    go func() { wg.Wait(); close(out) }()
    return out
}

// ProcessDocument hace fan-out a los 3 servicios y fan-in de resultados.
func ProcessDocument(ctx context.Context, docID string) []ServiceResult {
    // Fan-out: lanzar los 3 servicios simultáneamente
    embedCh  := callService(ctx, "embedder",   docID)
    classCh  := callService(ctx, "classifier", docID)
    rerankCh := callService(ctx, "reranker",   docID)

    // Fan-in: recoger todos los resultados
    var results []ServiceResult
    for r := range merge(ctx, embedCh, classCh, rerankCh) {
        results = append(results, r)
    }
    return results
}
Ganancia real: Las 3 llamadas tardan ~300ms cada una. En serie = 900ms. En paralelo (fan-out) = ~320ms (el más lento). Latencia reducida un 64% con este patrón.
3

Graceful Shutdown — SIGTERM en Kubernetes

context + signal
Escenario: GKE envía SIGTERM antes de eliminar el pod (preStop hook). CULTIVA Engine tiene 30s para terminar jobs en vuelo. El contexto raíz se cancela, los workers lo detectan vía ctx.Done() y hacen cleanup antes de salir.
Caso real cultivaengine/cmd/engine/main.go — graceful shutdown configurado para deploys sin downtime
cultivaengine/cmd/engine/main.go Go
package main

import (
    "context"
    "fmt"
    "log/slog"
    "os"
    "os/signal"
    "sync"
    "syscall"
    "time"
)

type Engine struct {
    wg sync.WaitGroup
}

func (e *Engine) Start(ctx context.Context) {
    for i := 0; i < 8; i++ {
        e.wg.Add(1)
        go e.inferenceWorker(ctx, i)
    }
}

func (e *Engine) inferenceWorker(ctx context.Context, id int) {
    defer e.wg.Done()
    slog.Info("worker started", "id", id)

    ticker := time.NewTicker(200 * time.Millisecond)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            // Cleanup: flush buffer, cerrar conexión gRPC, etc.
            slog.Info("worker draining", "id", id)
            time.Sleep(100 * time.Millisecond) // flush simulado
            slog.Info("worker stopped", "id", id)
            return
        case <-ticker.C:
            // Procesar siguiente documento de la cola
            fmt.Printf("[w%d] processing...\n", id)
        }
    }
}

func main() {
    // Contexto raíz cancelable por señal del SO
    ctx, stop := signal.NotifyContext(
        context.Background(),
        syscall.SIGINT, syscall.SIGTERM,
    )
    defer stop()

    engine := &Engine{}
    engine.Start(ctx)

    slog.Info("cultivaengine running, waiting for signal...")
    <-ctx.Done() // bloquea hasta SIGTERM / SIGINT

    slog.Info("signal received, shutting down (max 30s)")

    // Esperar workers con timeout de 30s (margen K8s)
    done := make(chan struct{})
    go func() { engine.wg.Wait(); close(done) }()

    select {
    case <-done:
        slog.Info("clean shutdown completed")
    case <-time.After(30 * time.Second):
        slog.Warn("shutdown timeout, forcing exit")
        os.Exit(1)
    }
}
🛡 Kubernetes: Configura terminationGracePeriodSeconds: 35 en el Deployment (5s extra sobre el timeout de 30s del código) para garantizar que el pod no sea forzado antes de que el proceso termine limpiamente.
4

Semáforo — Rate-Limit a API Externa

semaphore.Weighted
Escenario: El API de OpenAI (o proveedor externo LLM) permite máximo 10 requests/s. Con 50 goroutines activas se producen errores 429. Un semáforo ponderado limita la concurrencia exacta sin throttling artificial.
Caso real cultivaengine/internal/llm/ratelimit.go — evita errores 429 en picos de ingesta
cultivaengine/internal/llm/ratelimit.go Go
package llm

import (
    "context"
    "fmt"
    "golang.org/x/sync/semaphore"
    "sync"
)

// LLMClient llama a una API externa con concurrencia limitada.
type LLMClient struct {
    sem *semaphore.Weighted
    mu  sync.Mutex
}

// NewLLMClient crea un cliente con maxConcurrent llamadas simultáneas.
func NewLLMClient(maxConcurrent int64) *LLMClient {
    return &LLMClient{
        sem: semaphore.NewWeighted(maxConcurrent),
    }
}

// Complete envía un prompt al LLM respetando el rate-limit.
func (c *LLMClient) Complete(ctx context.Context, prompt string) (string, error) {
    // Adquirir semáforo — bloquea si ya hay maxConcurrent llamadas activas
    if err := c.sem.Acquire(ctx, 1); err != nil {
        return "", fmt.Errorf("semaphore acquire: %w", err)
    }
    defer c.sem.Release(1) // liberar siempre, incluso con panic

    // Llamada real a la API (gRPC / HTTP)
    response, err := callExternalAPI(ctx, prompt)
    if err != nil {
        return "", fmt.Errorf("llm api: %w", err)
    }
    return response, nil
}

// BatchComplete procesa prompts concurrentemente con rate-limit aplicado.
func (c *LLMClient) BatchComplete(ctx context.Context, prompts []string) ([]string, []error) {
    results := make([]string, len(prompts))
    errors  := make([]error,  len(prompts))

    var wg sync.WaitGroup
    for i, p := range prompts {
        i, p := i, p
        wg.Add(1)
        go func() {
            defer wg.Done()
            results[i], errors[i] = c.Complete(ctx, p)
        }()
    }
    wg.Wait()
    return results, errors
}

func callExternalAPI(ctx context.Context, prompt string) (string, error) {
    // Stub: en producción, http.Post a api.openai.com con ctx
    return "respuesta generada para: " + prompt, nil
}

// Instanciación recomendada para CULTIVA Engine:
//   client := llm.NewLLMClient(10)  // 10 req concurrentes máx
//   responses, errs := client.BatchComplete(ctx, prompts)
Alternativa simple: Si no quieres la dependencia golang.org/x/sync, usa un canal como semáforo: sem := make(chan struct{}, 10). Envía antes de la llamada y recibe al terminar. Mismo efecto, cero dependencias.
5

errgroup — Propagación del Primer Error

golang.org/x/sync
Escenario: CULTIVA Engine healthcheck llama en paralelo a 4 dependencias (Redis, Postgres, Embedder, Classifier). Si cualquiera falla, se cancela el resto y se reporta solo el primer error — sin goroutines colgadas.
Caso real cultivaengine/internal/health/checker.go — K8s readiness probe con timeout de 5s
cultivaengine/internal/health/checker.go Go
package health

import (
    "context"
    "fmt"
    "golang.org/x/sync/errgroup"
    "time"
)

type Dependency struct {
    Name    string
    CheckFn func(ctx context.Context) error
}

// CheckAll verifica todas las dependencias en paralelo con timeout de 5s.
// Devuelve el primer error encontrado; cancela las demás goroutines.
func CheckAll(parentCtx context.Context, deps []Dependency) error {
    ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
    defer cancel()

    // errgroup.WithContext cancela ctx automáticamente al primer error
    g, ctx := errgroup.WithContext(ctx)

    for _, dep := range deps {
        dep := dep // capturar variable del loop (pre-Go 1.22)
        g.Go(func() error {
            if err := dep.CheckFn(ctx); err != nil {
                return fmt.Errorf("%s unhealthy: %w", dep.Name, err)
            }
            return nil
        })
    }

    // Esperar: si alguna goroutine falla, g.Wait() devuelve ese error
    return g.Wait()
}

// Configuración en main / handler HTTP:
var cultivaDeps = []Dependency{
    {Name: "redis",      CheckFn: checkRedis},
    {Name: "postgres",   CheckFn: checkPostgres},
    {Name: "embedder",   CheckFn: checkEmbedderGRPC},
    {Name: "classifier", CheckFn: checkClassifierGRPC},
}

// HTTP handler para /healthz (K8s readiness probe)
func ReadinessHandler(w http.ResponseWriter, r *http.Request) {
    if err := CheckAll(r.Context(), cultivaDeps); err != nil {
        http.Error(w, err.Error(), http.StatusServiceUnavailable)
        return
    }
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("ok"))
}
🔬 Detección de races: Ejecuta siempre los tests con go test -race ./.... El race detector de Go añade ~5-10% overhead pero detecta condiciones de carrera en tiempo de ejecución — indispensable en CI/CD de CULTIVA Engine.