Pular para o conteúdo

Desafios de entrevista em Go · parte 10 de 11

GitLab - Concurrent Job Queue & Runner Pool

avançadoPublicada em · 4 min de leitura

Tamanho da fonte:
Largura da coluna:

Dados da empresa e da vaga

  • Empresa: GitLab Inc.
  • Sede: 100% Remota Global (pioneira mundial em cultura assíncrona e listada na NASDAQ: GTLB)
  • Perfil da vaga: Senior Backend Engineer — GitLab Runner Core & Distributed CI/CD Platform
  • Modelo de trabalho: Remoto internacional (contratação via B2B Contractor ou EOR global em mais de 65 países)
  • Faixa salarial estimada da vaga:
    • Sênior nos EUA: $165.000 a $225.000 (Base) / $230.000 a $320.000+ USD/ano (Total Comp com RSUs)
    • Sênior Remoto Internacional (LATAM/Europa): $85.000 a $135.000 USD/ano (ajustado por região, com equity líquido em bolsa)
  • Stack principal do time: Go, Ruby on Rails, Docker, Kubernetes, Linux Syscalls, gRPC, Prometheus, Git

Contexto do desafio técnico

O GitLab Runner é a aplicação de código aberto escrita em Go responsável por executar milhões de jobs de compilação, testes e deploys diários na infraestrutura do GitLab.

Em entrevistas para o time central do Runner, os avaliadores técnicos testam a capacidade do candidato de manipular concorrência no nível de sistemas, orquestração de goroutines, sinais de sistema operacional (POSIX signals), escalonamento justo (Fair Scheduling) e encerramento gracioso (Graceful Drain).

O desafio técnico consiste em construir um Pool de Executores Concorrentes (Runner Pool) com Fila de Prioridades e Limites por Projeto, garantindo que um projeto com milhares de tarefas enfileiradas não monopolize os recursos da máquina em detrimento de outros projetos (noisy neighbor problem).


Requisitos funcionais e de arquitetura

1. Fila de Tarefas com Limites de Concorrência

  • Limite Global de Concorrência (max_workers): A máquina possui um limite máximo fixo de tarefas executadas simultaneamente (ex.: 8 ou 16 workers).
  • Limite por Projeto (max_per_project): Um único projeto não pode ocupar mais do que um número pré-determinado de slots de execução (ex.: no máximo 2 workers por projeto simultaneamente), garantindo distribuição justa de capacidade entre diferentes repositórios.

2. Prioridade de Execução

  • Tarefas de maior prioridade (ex.: pipelines de branch principal main ou tags de produção) devem ser escalonadas antes de tarefas de branch de desenvolvimento, mesmo que tenham entrado na fila posteriormente.

3. Cancelamento em Tempo Real com context.Context

  • Se o usuário cancelar a execução de um job na interface web, o sistema deve disparar o cancelamento do contexto (ctx.Cancel()), abortando a goroutine em execução imediatamente e liberando o slot para o próximo job da fila.

4. Drenagem Graciosa (Graceful Drain / Shutdown)

  • Ao capturar sinais de interrupção do sistema operacional (SIGINT ou SIGTERM):
    1. O runner deve parar imediatamente de aceitar novos jobs da fila.
    2. Aguardar a conclusão dos jobs que já estão em execução até um tempo limite de tolerância (grace period, ex.: 30 segundos).
    3. Se o tempo limite expirar, forçar o encerramento dos jobs restantes através de cancelamento de contexto e finalizar o processo com código de saída limpo.

Implementação idiomática do Runner Pool em Go

package runner

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"
)

type Priority int

const (
	LowPriority    Priority = 1
	MediumPriority Priority = 2
	HighPriority   Priority = 3
)

type Job struct {
	ID        string
	ProjectID string
	Priority  Priority
	Task      func(ctx context.Context) error
}

type RunnerPool struct {
	maxGlobalWorkers int
	maxPerProject    int

	mu             sync.Mutex
	activeGlobal   int
	activeProjects map[string]int

	jobQueue chan Job
	wg       sync.WaitGroup
	ctx      context.Context
	cancel   context.CancelFunc
}

func NewRunnerPool(maxGlobalWorkers, maxPerProject int) *RunnerPool {
	ctx, cancel := context.WithCancel(context.Background())
	return &RunnerPool{
		maxGlobalWorkers: maxGlobalWorkers,
		maxPerProject:    maxPerProject,
		activeProjects:   make(map[string]int),
		jobQueue:         make(chan Job, 1000),
		ctx:              ctx,
		cancel:           cancel,
	}
}

func (p *RunnerPool) Start() {
	go p.dispatcher()
}

func (p *RunnerPool) Submit(job Job) error {
	select {
	case <-p.ctx.Done():
		return errors.New("o runner está em processo de encerramento")
	case p.jobQueue <- job:
		return nil
	}
}

func (p *RunnerPool) dispatcher() {
	for {
		select {
		case <-p.ctx.Done():
			return
		case job := <-p.jobQueue:
			p.waitForSlot(job.ProjectID)

			p.mu.Lock()
			p.activeGlobal++
			p.activeProjects[job.ProjectID]++
			p.mu.Unlock()

			p.wg.Add(1)
			go p.executeJob(job)
		}
	}
}

func (p *RunnerPool) waitForSlot(projectID string) {
	for {
		p.mu.Lock()
		canRun := (p.activeGlobal < p.maxGlobalWorkers) && (p.activeProjects[projectID] < p.maxPerProject)
		p.mu.Unlock()

		if canRun {
			return
		}

		time.Sleep(10 * time.Millisecond) // Pequeno backoff de espera ativa controlada
	}
}

func (p *RunnerPool) executeJob(job Job) {
	defer p.wg.Done()
	defer func() {
		p.mu.Lock()
		p.activeGlobal--
		p.activeProjects[job.ProjectID]--
		p.mu.Unlock()
	}()

	// Cria contexto isolado com timeout para o job
	jobCtx, cancel := context.WithTimeout(p.ctx, 10*time.Minute)
	defer cancel()

	if err := job.Task(jobCtx); err != nil {
		fmt.Printf("Job %s falhou: %v\n", job.ID, err)
	}
}

func (p *RunnerPool) Stop(graceTimeout time.Duration) {
	// 1. Sinaliza parada para novas tarefas
	p.cancel()

	// 2. Aguarda jobs ativos com canal de conclusão
	done := make(chan struct{})
	go func() {
		p.wg.Wait()
		close(done)
	}()

	select {
	case <-done:
		fmt.Println("Todos os jobs foram encerrados graciosamente.")
	case <-time.After(graceTimeout):
		fmt.Println("Tempo de tolerância esgotado. Encerramento forçado.")
	}
}

O que os avaliadores do GitLab buscam

  1. Gestão de canais e condições de corrida: Avaliam se o encerramento do pool e a submissão de novos jobs respeitam a sincronização sem causar pânico por envio em canal fechado (send on closed channel).
  2. Prevenção de vazamento de goroutines: O avaliador verifica se, ao acionar Stop(), nenhuma goroutine do dispatcher ou de trabalhadores permanece suspensa indefinidamente.
  3. Escalonamento justo (Fair Scheduling): Capacidade de explicar e demonstrar como evitar starvation (inanição) de projetos com prioridades menores quando uma fila de alta prioridade é volumosa.

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?