Cansei de CRUD, então estou construindo um agendador
Eu queria ficar melhor em Java e Spring, então, naturalmente, comecei a construir um agendador de jobs self-hosted e acabei encontrando JPA, máquinas de estados e sistemas distribuídos esperando por mim.
Faz tempo que quero ficar realmente bom em Java e Spring.
Não bom no nível de "sei criar um controller e conectar num banco de dados".
Quero entender o que acontece por baixo o suficiente para o Spring parar de parecer uma coleção de anotações que decorei de tutoriais.
Então, naturalmente, decidi construir algo desnecessariamente difícil.
O nome dele é orbit.
A ideia é construir um agendador de jobs distribuído e self-hosted.
Algo que vive vagamente no mesmo universo que o cron, os runners do GitHub Actions, o Rundeck e o Temporal.
Só que significativamente menor.
E, por enquanto, incapaz de fazer quase tudo aquilo com que acabei de compará-lo.
A gente chega lá.
A regra é: nada de infraestrutura mágica
Uma coisa que decidi bem cedo foi que não quero construir isso como um diagrama falso de arquitetura de produção.
Nada de Kafka só porque Kafka parece legal.
Nada de Kubernetes só porque, pelo visto, todo projeto pessoal agora precisa de um cluster Kubernetes.
Nada de Redis só porque alguém no YouTube falou "sistemas distribuídos".
Nada de doze microsserviços conversando entre si enquanto a aplicação inteira tem três usuários.
Por enquanto, o orbit é uma aplicação Spring Boot falando direto com o PostgreSQL.
orbit├── orbit-server├── docs├── compose.yaml└── README.mdEventualmente também vai ter:
orbit-workerorbit-webMas eles ainda não existem.
E eu gosto muito disso.
Estou tentando só introduzir complexidade quando o projeto me der um motivo.
O que provavelmente é a primeira vez que resisto voluntariamente a adicionar mais infraestrutura em alguma coisa.
Um Job não é uma execução
A primeira coisa que precisei modelar foi um Job.
Um Job é basicamente a definição de algo que o orbit sabe executar.
Hoje ele contém coisas como:
namecommandcronExpressionmaxRetriestimeoutSecondsenabledEntão, por exemplo:
name: database-backupcommand: ./backup.shmaxRetries: 3timeoutSeconds: 300A percepção importante veio um pouco depois.
Um Job não é a coisa que está de fato rodando.
Ele é a definição.
Se esse mesmo backup roda dez vezes, eu não tenho dez Jobs.
Eu tenho:
Job├── Execution #1├── Execution #2├── Execution #3└── ...Essa distinção parece incrivelmente óbvia agora.
Não era óbvia quando comecei.
E ela muda bastante a forma como o sistema precisa ser projetado.
Mas, antes de chegar nas Execuções, eu precisava fazer os Jobs existirem direito.
Pelo visto, entidades podem ter opiniões
Meu primeiro instinto com entidades JPA, historicamente, era algo assim:
private String name;public void setName(String name) { this.name = name;}Repetir até o arquivo chegar a 200 linhas.
Mas, no orbit, eu quis tentar tratar o objeto de domínio como algo que realmente é dono das suas regras.
Então, em vez de dar a Job setters para tudo, a entidade se valida quando é criada:
public Job( String name, String command, String cronExpression, int maxRetries, int timeoutSeconds) { validateName(name); validateCommand(command); validateCronExpression(cronExpression); validateMaxRetries(maxRetries); validateTimeoutSeconds(timeoutSeconds); this.name = name; this.command = command; this.cronExpression = cronExpression; this.enabled = true; this.maxRetries = maxRetries; this.timeoutSeconds = timeoutSeconds; this.createdAt = Instant.now(); this.updatedAt = this.createdAt;}E, em vez disto:
job.setEnabled(false);eu tenho:
job.disable();que atualmente faz:
public void disable() { this.enabled = false; this.updatedAt = Instant.now();}Obviamente isso não é complicado.
Mas eu gosto da diferença de significado.
Uma diz:
mude este booleanoA outra diz:
desabilite este jobSe desabilitar um Job ficar mais complicado depois, esse comportamento ainda tem um lugar para morar.
Estou aos poucos entendendo por que as pessoas insistem em me dizer que objetos de domínio deveriam fazer coisas.
Aí o Spring criou um repositório que eu nunca escrevi
O repositório, por enquanto, é este:
public interface JobRepository extends JpaRepository<Job, Long> {}É isso. É tudo.
E o Spring, de alguma forma, transforma isso em algo que consegue:
savefindByIdfindAlldeletecountexistssem eu implementar a classe.
Eu já sabia que o Spring Data fazia isso.
Saber uma coisa e parar de verdade para entendê-la são coisas muito diferentes.
O Spring encontra a interface do repositório, cria uma implementação em tempo de execução e injeta essa implementação em qualquer lugar onde eu peça um JobRepository.
O que significa que meu service pode simplesmente fazer isto:
public JobService(JobRepository jobRepository) { this.jobRepository = jobRepository;}Eu não instancio nada por conta própria.
O Spring me entrega.
A injeção de dependência finalmente parece um pouco menos bruxaria.
Um pouco.
Não quero que meu banco de dados se crie sozinho
Outra decisão que tomei foi não deixar o Hibernate gerenciar o schema.
Então, em vez de:
spring.jpa.hibernate.ddl-auto=updateo orbit usa:
spring.jpa.hibernate.ddl-auto=validateMudanças de schema pertencem ao Flyway.
A primeira migração cria jobs:
CREATE TABLE jobs ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(120) NOT NULL, command TEXT NOT NULL, cron_expression VARCHAR(100), enabled BOOLEAN NOT NULL DEFAULT TRUE, max_retries INTEGER NOT NULL DEFAULT 0, timeout_seconds INTEGER NOT NULL DEFAULT 300, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP);O Flyway altera o banco.
O Hibernate olha o resultado e diz se o mapeamento da minha entidade concorda com ele.
Gosto muito mais dessa separação do que de:
Mudei uma classe Java e acho que o banco agora é diferente.As migrações também deixam um histórico de verdade do que aconteceu com o schema.
O que parece um controle de versão para o banco de dados.
Isso provavelmente é uma simplificação exagerada.
Também é, no momento, como meu cérebro guarda o conceito.
Meu primeiro endpoint
Eventualmente eu tinha peças suficientes para fazer isto:
POST /jobsA requisição é assim:
{ "name": "database-backup", "command": "./backup.sh", "cronExpression": null, "maxRetries": 3, "timeoutSeconds": 300}E o Postman me devolveu:
201 CreatedFiquei muito mais empolgado com isso do que alguém deveria ficar com um endpoint POST.
Mas a parte interessante foi o caminho que a requisição percorreu.
Postman ↓JobController ↓CreateJobRequest ↓JobService ↓Job ↓JobRepository ↓Hibernate ↓PostgreSQLJá tem camada demais envolvida só para colocar cinco valores num banco de dados.
Engenharia de software é linda.
Por que não simplesmente receber a entidade?
No começo teria sido muito fácil escrever algo como:
public Job create(@RequestBody Job job)e dar o assunto por encerrado.
Em vez disso, criei um DTO de requisição:
public record CreateJobRequest( @NotBlank @Size(max = 120) String name, @NotBlank String command, String cronExpression, @NotNull @PositiveOrZero Integer maxRetries, @NotNull @Positive Integer timeoutSeconds) {}Isso dá à camada HTTP o seu próprio contrato.
O cliente pode enviar:
namecommandcronExpressionmaxRetriestimeoutSecondsEle não pode decidir coisas como:
idenabledcreatedAtupdatedAtIsso pertence à aplicação.
A mesma ideia vale na saída.
Em vez de retornar a entidade JPA diretamente, eu retorno:
JobResponseque mapeia explicitamente os campos que a API pode expor.
Então agora eu tenho:
entrada HTTP→ CreateJobRequestdomínio→ Jobsaída HTTP→ JobResponseIsso pareceu um pouco exagerado quando havia só cinco campos.
Agora já parece muito menos exagerado.
Aprendi por que Integer às vezes faz mais sentido que int
Esta foi outra coisinha em que eu nunca tinha pensado de verdade.
Dentro da entidade:
private int maxRetries;faz sentido.
Um Job válido nunca deveria ter:
maxRetries = nullMas dentro de um DTO de requisição HTTP eu uso:
@NotNullInteger maxRetriesPor quê?
Porque existe uma diferença entre:
{ "maxRetries": 0}e não enviar maxRetries de forma alguma.
Com um int, um dado ausente pode colapsar no valor padrão do primitivo:
0Com Integer, a ausência pode continuar sendo:
nulle o Bean Validation consegue rejeitá-la.
Um tipo wrapper de repente virou parte de uma decisão de design de API.
Java continua achando novas formas de tornar o null relevante.
Aí eu parei de chamar save()
Atualizar um Job me ensinou uma coisa que eu já tinha ouvido falar, mas nunca tinha realmente internalizado.
Isto é, hoje, parte do meu service:
@Transactionalpublic Optional<Job> updateConfiguration( Long id, String name, String command, String cronExpression, int maxRetries, int timeoutSeconds) { return jobRepository.findById(id) .map(job -> { job.updateConfiguration( name, command, cronExpression, maxRetries, timeoutSeconds ); return job; });}Repare no que está faltando.
Não existe:
jobRepository.save(job);Isso originalmente me pareceu errado.
Mas o Job foi carregado dentro de uma transação.
O Hibernate está gerenciando essa entidade.
Eu a modifico.
A transação termina.
O Hibernate percebe que o objeto mudou e gera um UPDATE SQL.
Isso se chama dirty checking.
Enviei isto pelo Postman:
PUT /jobs/1fiquei olhando os logs e lá estava.
Um UPDATE.
Eu nunca chamei save().
Esse é exatamente o tipo de comportamento de framework que quero entender, em vez de só aceitar que o Spring faz coisas quando coloco anotações nos lugares certos.
CRUD, mas um pouco menos amaldiçoado
Neste momento a API tem:
POST /jobsGET /jobsGET /jobs/{id}PUT /jobs/{id}Ela consegue criar, listar, buscar e atualizar Jobs.
Eu não implementei o delete.
Isso é intencional.
Quando um Job tiver histórico de execuções, não sei se apagá-lo deveria significar:
apagar tudoou:
manter as execuções históricasou:
você não pode apagar istoou talvez:
arquivar em vez dissoEu ainda não tenho esse requisito.
Então, em vez de inventar uma resposta só porque tutoriais de REST costumam terminar com um endpoint DELETE, simplesmente não fiz um.
Progresso.
Habilitar e desabilitar não são só booleanos
Também adicionei:
POST /jobs/{id}/enablePOST /jobs/{id}/disablePode parecer estranho comparado a simplesmente fazer um patch com:
{ "enabled": false}Mas estou tratando isso como comandos.
A API não está dizendo:
defina enabled como falseEla está dizendo:
desabilite este jobE o domínio executa esse comportamento.
job.disable();De novo: hoje esse comportamento é minúsculo.
Mas o vocabulário parece certo.
Estou começando a me importar de um jeito suspeito com nomes de métodos.
O banco de dados dos meus testes é PostgreSQL de verdade
Para os testes de integração dos repositórios, estou usando Testcontainers.
Então, em vez de fingir que o H2 é o PostgreSQL, os testes realmente sobem um container PostgreSQL.
Algo assim:
@Container@ServiceConnectionstatic PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");Aí o Spring se conecta a ele.
O Flyway roda as migrações.
O Hibernate conversa com ele.
O repositório executa SQL de verdade.
E, quando o teste termina, o banco de dados desaparece.
Meu teste de repositório até faz isto:
entityManager.flush();entityManager.clear();No começo eu não entendia por quê.
flush() força as alterações pendentes para dentro do banco de dados.
clear() remove a entidade do contexto de persistência do Hibernate.
Isso significa que, quando chamo findById() depois, o Hibernate não pode me entregar discretamente o mesmo objeto Java que ele já tem em memória.
Ele realmente precisa carregar a linha de novo.
Então o teste verifica:
objeto Java→ INSERT→ PostgreSQL→ SELECT→ objeto Javaem vez de testar o cache do Hibernate sem querer.
Esse é um teste muito melhor.
Nem todo teste precisa do Spring
Os testes do service são diferentes.
Não tem PostgreSQL.
Não tem nem um contexto de aplicação do Spring.
Eu crio um repositório mockado:
@Mockprivate JobRepository jobRepository;e o entrego manualmente ao service:
jobService = new JobService(jobRepository);Aí posso definir comportamentos como:
when(jobRepository.findById(1L)) .thenReturn(Optional.of(job));O teste não está perguntando:
o PostgreSQL funciona?
Ele está perguntando:
JobServicese comporta corretamente quando o repositório retorna isto?
Finalmente estou começando a separar essas coisas na cabeça:
JobTest→ regras de domínioJobServiceTest→ comportamento da aplicaçãoJobRepositoryTest→ integração de persistênciaAntes deste projeto, eu tinha a tendência de simplesmente pensar:
test = testPelo visto, tem mais lore aqui também.
Agora ele realmente começa a virar o orbit
Tudo isso ainda deixa o orbit perigosamente perto de ser uma aplicação CRUD com um README dramático.
Então agora estou trabalhando em Execution.
A distinção é:
Job→ o que deve rodarExecution→ uma execução concretaUma Execução começa com um destes estados:
public enum ExecutionStatus { QUEUED, RUNNING, SUCCEEDED, FAILED, CANCELLED}E não quero expor:
setStatus(...)porque aí nada impede transições amaldiçoadas como:
SUCCEEDED → RUNNINGFAILED → QUEUEDCANCELLED → SUCCEEDEDEm vez disso, quero que o objeto se comporte mais como uma pequena máquina de estados.
Algo mais ou menos assim:
QUEUED | v RUNNING / \ v v SUCCEEDED FAILEDQUEUED / RUNNING | v CANCELLEDEu ainda não implementei essas transições.
Essa é a próxima coisa em que vou trabalhar.
Execuções precisam lembrar do passado
A migração de banco de dados das Execuções já existe.
Uma decisão de que gosto particularmente é que uma Execução guarda um snapshot de parte da configuração do Job.
Por exemplo:
commandmaxRetriestimeoutSecondsPor que duplicar isso se a Execução já tem um job_id?
Porque Jobs podem mudar.
Imagine isto:
10:00command = ./backup-v1.sh10:01A Execução #42 é criada10:10command = ./backup-v2.shSe a Execução #42 olhasse só para a configuração atual do Job, os dados históricos mentiriam.
Ela deveria lembrar:
Eu rodei ./backup-v1.shmesmo que o Job agora diga algo completamente diferente.
Então a Execução guarda a configuração que recebeu quando foi criada.
Esse foi meu primeiro pequeno encontro com um problema que parece muito mais sistemas distribuídos do que CRUD.
A verdade histórica importa.
E a gente ainda não distribuiu nada
Apesar do nome, o orbit hoje não tem workers.
Nada está rodando em várias máquinas.
Não há heartbeats.
Nem eleição de líder.
Nem engine de retry.
Nem recuperação de falhas.
Nem atualizações de status por WebSocket.
Nem loop de agendador.
Nem máquina sumindo no meio de uma tarefa e me forçando a questionar o significado da realidade.
Ainda.
Eventualmente quero algo assim:
orbit-server | ├── worker: desktop ├── worker: macbook └── worker: homelabO servidor deve conseguir enfileirar trabalho.
Os workers devem pegar esse trabalho para si.
As Execuções devem transitar entre estados.
Máquinas devem desaparecer.
Tarefas devem falhar.
Retries devem acontecer.
E, em algum ponto desse processo, provavelmente vou aprender por que engenheiros de sistemas distribuídos sempre parecem cansados.
Esse é o objetivo.
Não acho que o mundo precise particularmente de mais um agendador de jobs.
Estou construindo o orbit porque queria um projeto que, com o tempo, me obrigasse a ir além de controllers, repositórios e CRUD.
Até agora aprendi mais com um único Job do que esperava.
E a parte interessante nem começou ainda.
Continue lendo
Comentários
Com tecnologia do GitHub Discussions.
Os comentários carregam do giscus (GitHub Discussions) quando você chega a esta seção.