MOJ — Fluxo de comunicação (API, daemons e juízes pull) — MOJ docs

MOJ — Fluxo de comunicação (API, daemons e juízes pull)

Como uma submissão viaja do browser até virar veredicto no placar, e como as peças conversam entre si. Tudo é bash + arquivos (sem DB, sem broker); a comunicação assíncrona usa spool de arquivos + inotifywait e, no julgamento, o modelo pull (os juízes puxam o job no heartbeat — sem master, sem push de entrada).

Visão geral

  Browser (web/)                      API (server/api/v1, via nginx+fcgiwrap)
  ──────────────                      ─────────────────────────────────────
   POST /api/v1/submit  ───────────▶  handlers/submit.sh
                                        1. valida + gera id (md5)
                                        2. escreve JSON no SPOOL  ───────────┐
                                        3. grava linha provisória            │
                                           "Not Answered Yet" no history     │
                                        4. responde {submission_id,queued}    │
   (front faz polling de /contest/history até sair de "Not Answered Yet")     │
                                                                              ▼
                                                       run/spool/submissions/<arquivo>
                                                                              │ inotifywait (push)
                                                                              ▼
  server/daemons/judged.sh  (consumidor do spool, systemd: moj-judged)
    para cada arquivo novo:
      1. lê o JSON {contest,login,problem_id,code_b64,lang,...}
      2. verdict = judge_run(...)   ──────────────▶  server/judge-gw/judge.sh
      3. troca a linha "Not Answered Yet :<id>" pelo veredicto real (mv atômico)
      4. recomputa users/<login>/metrics.json (fonte do placar)
      5. arquiva o fonte em users/<login>/submissions/<id>.<ext>
      6. roda server/score/build.sh <c>  ──▶  reescreve var/placar.txt
      7. move o arquivo de spool p/ run/spool/submissions-done/
      (+ spool de submit INVÁLIDO vira "Judge Error" na linha do history — nunca descarte mudo;
       e o reconciliador resolve pendência órfã >15 min: com fonte re-enfileira 1×, sem fonte
       Judge Error — incidente 2026-08-19)
                                                              │
   Placar:  GET /api/v1/contest/score  ◀── lê var/placar.txt (1ª linha = modo)

O ponto-chave: a CGI nunca bloqueia. submit.sh enfileira e retorna na hora; o judged.sh é o único que fala com o juiz. O front descobre o veredicto por polling do history (HTTP), não segurando a conexão.

1. API → spool (enfileiramento)

handlers/submit.sh (e handlers/contest/rejudge.sh, set-verdict.sh) escrevem um arquivo no spool run/spool/submissions/. O nome carrega o roteamento e o conteúdo é o JSON com os dados:

nome:   <contest>:<epoch>:<id>:<login>:<comando>:<arg>[:<FILETYPE>]
comando ∈ { submit, rejulgar, setverdict, synctreino, newcontest, … }

submit:     <c>:<ts>:<id>:<login>:submit:<problemid>:<LANG>
rejulgar:   <c>:<ts>:<id>:<login>:rejulgar:<subid>
setverdict: <c>:<ts>:<id>:<login>:setverdict:<problemid>

Conteúdo (submit): {contest,login,problem_id,filename,code_b64,lang,time,id}. A escrita é atômica (.in.<id>mv), então o daemon só vê o arquivo pronto.

Logo após enfileirar, submit.sh anexa ao contests/<c>/users/<login>/history uma linha provisória terminada em :<id> com veredicto Not Answered Yet (e recomputa o metrics.json do usuário, p/ o PENDING aparecer no placar) — é o que o front mostra como "julgando" enquanto faz polling.

Formato do history por-usuário (6 campos, login implícito no diretório): tempo:problemid:lang:verdict:epoch:subid. Os leitores agregados usam emit_user_history/emit_history_stream (lib/users.sh), que reinjetam o login e entregam o formato global de 7 campos tempo:login:problemid:lang:verdict:epoch:subid.

2. Daemon de julgamento — server/daemons/judged.sh

Serviço systemd moj-judged. Observa o spool com inotifywait -m -e create -e moved_to (fallback: poll de 1s). Para cada arquivo: lê o JSON, chama judge_run, e aplica o veredicto reescrevendo só a linha com sufixo :<id> no history do usuário (casamento seguro, reescrita atômica via mv), recomputa users/<login>/metrics.json, arquiva o fonte decodificado, e dispara server/score/build.sh <contest> para recalcular o placar. Por fim move o arquivo para run/spool/submissions-done/.

Shards do escritor (JUDGED_SHARDS=K, default 1 — api/v1/lib/spool-shard.sh): com K>1 o daemon vira K workers, cada um dono dos logins com hash(login) % K == k. O teto serial (~820 veredictos/min medidos) escala ~K× porque history/metrics/submissions são POR USUÁRIO; placar (flock coalescido), clog (append) e a fila do cluster (flock próprio) já eram seguros entre processos. Layout: shard 0 = a raiz do spool; shard k>0 = spool/submissions/s<k>/. Os produtores que sabem o login (/submit, offline-submit, /judge/result, setverdict) gravam direto no shard do dono; o que cair na raiz (rejudge de admin, produtor legado) o worker 0 ROTEIA. O reconciliador roda particionado (cada worker só olha os próprios logins) e cada worker bate judged.alive (global) + judged.alive.s<k>. ⚠ API e daemon precisam do MESMO JUDGED_SHARDS no env (mismatch tem rede: sweep de dirs órfãos devolve à raiz). Teste: smoke-judged-shard.sh.

No modo fila/pull (INTAKE_MODE=queue, ver server/judge-gw/PULL.md), em vez de julgar na hora o daemon enfileira um job JSON {id,contest,problem_id,login,lang,filename, code_b64,priority,enqueued_at,allowed_hosts?} na banda do CONTEST_PRIORITY; um juiz o reivindica no heartbeat. allowed_hosts é o pool de juízes efetivo (override do problema em problem-judges.jsonCONTEST_JUDGES do conf; ausente = qualquer juiz) — o claim é estrito: com o pool offline o job espera na fila (preflight/dashboard avisam).

O resultado do juiz carrega, além do verdict de display (com o score embutido, ex. Accepted,100p), os campos estruturados verdict_canon (canônico sem score), score/score_max/score_kind, correct/total_tests e groups (subtarefas [{earned,max},…], quando o problema pontua por grupos) — persistidos em results/<id>.json e servidos pelo /submission/summary. Ao competidor o veredicto servido é SEMPRE o canônico (todos os modos; lib/verdict.sh canoniza os endpoints de history na leitura — o history em disco não muda) e o summary é redigido por modo: treino/lista = tudo; obi/heurístico/outro = score/grupos/heur; icpc/ausente = só o canônico. No modo veredicto manual, o casamento da matriz de auto-veredicto usa o verdict_canon (não a string com score), e os erros de juiz (Judge Error/No_Servers) também são segurados p/ revisão — o competidor vê só Not Answered Yet até um veredicto sair.

3. Gateway de juiz — server/judge-gw/judge.sh (dev/legado)

Expõe judge_run <contest> <problemid> <lang> <code_b64> <filename> → ecoa o veredicto. Não é usado no modo pull de produção (lá o daemon enfileira antes de chamar judge_run — ver §2 e PULL.md). Fica p/ dev e p/ o intake legado (INTAKE_MODE=legacy), com dois backends por $JUDGE_BACKEND:

4. Julgamento pull — os juízes puxam o job

Em produção (INTAKE_MODE=queue JUDGE_BACKEND=queue) o julgamento é pull, e o lado servidor é a biblioteca server/judge-gw/sched-lib.sh + os handlers handlers/judge/*. Não há master, worker nem porta de entrada p/ os juízes:

daemon enfileira ─▶ run/queue/<banda>/<id>.json        (bandas 000-super … 080-…)
juiz (repo judge/, agente moj-agent@)
   POST /judge/heartbeat  ─▶ reivindica um job (claim atômico flock+mv → run/assigned/<host>/)
   GET  /judge/package     ─▶ baixa o pacote sob demanda p/ o cache local (calibra na 1ª vez)
   ...julga (mojtools/build-and-test.sh, sandbox bwrap)...
   POST /judge/result      ─▶ sched-lib grava run/results/<id>.json (o daemon consome via consumer)
   POST /judge/tl-report   ─▶ reporta o TL calibrado (run/tl/<id>.json)

allowed_hosts (pool de juízes do problema/contest) é respeitado no claim: com o pool offline o job espera na fila (o preflight/dashboard avisam). Protocolo completo em server/judge-gw/PULL.md.

Test-run de autoria (POST /problems/test-run, moj testrun): o autor roda UMA solução avulsa no juiz real — o job entra na MESMA fila (banda 040-lista-privada) com o contest sentinela _testrun, o juiz o julga como submissão normal, e o ingest_result do judged DESVIA o resultado p/ run/testrun/<run>.json (+ r/<run>.html) em vez do history — não é submissão de ninguém, não toca placar. Gate de EDIÇÃO no problema; TTL de 7 dias.

5. Registro + heartbeat — sched-lib.sh + handlers/judge/*

Cada juiz se registra (POST /judge/register) e manda heartbeat (POST /judge/heartbeat), gravando um registro JSON por host:

run/registry/<host>.json     (cpu, langs, slots, last_seen; vivo = last_seen recente)
  last_seen < agora-REG_TTL (30s) = juiz morto (sai do free-set)

O escalonador in-daemon (sched-lib.sh) casa job×juiz por capacidade e por allowed_hosts, com claim atômico (flock+mv p/ run/assigned/); job de juiz VIVO que não devolveu resultado em ASSIGN_TTL (900s) volta p/ a fila — o teto tem de caber a correção INTEIRA, download do pacote incluso (um problema de 737 MB revogado no meio recomeça do zero em outro juiz e DUPLICA o trabalho; foi o que segurou uma submissão por 915s no ensaio de 24/08/2026, sendo que a correção levou 67s). Juiz MORTO não espera teto: sem beat há REG_TTL (30s) o job volta na hora, e o register boot:true devolve tudo do agente que reiniciou. No beat, o lote de jobs agrega por arquivo e a resposta sai por ok_json_slurp — job com fonte grande (>128 KiB de base64) passava do teto por-argumento do jq, o beat respondia 200 vazio e o job quicava assigned→TTL→fila p/ sempre (incidente 2026-08-19).

6. Juízes — judge/ (máquinas separadas, modelo pull)

Uma máquina de juiz clona só judge/ + mojtools/ (não o cdmoj) e sobe o agente moj-agent@<cap> (pos/gpu/cm/hu). O agente: registra a capacidade, puxa job no heartbeat, baixa o pacote sob demanda p/ um cache local, calibra na 1ª vez e reporta o TL, e roda a solução em sandbox bubblewrap (mojtools/cage-run.sh + lang/<lang>/{compile,run}.sh, tipicamente sobre um rootfs moj-sysroot). Ver judge/README.md.

A API expõe o estado dos juízes por handlers/treino/admin/judges.sh (painel admin, model:"pull") e a página pública /status/ (handlers/index/status.sh) agrega fila + juízes + liveness do daemon.

7. Placar — server/score/build.sh

Chamado pelo judged.sh após cada veredicto. Lê contests/<c>/conf (CONTEST_TYPE), gera o placar a partir de users/*/metrics.json (uma passada; ver SCOREBOARD.md) e grava contests/<c>/var/placar.txt1ª linha = modo (icpc/obi/treino/heuristic/outro), as demais já ordenadas. O front (web/contest/score/) busca GET /contest/score, despacha pelo modo e renderiza (com bandeiras locais de /shared/flags/, regras teams-meta, modo anônimo, etc.).

7½. Submissão OFFLINE de contest (moj-comp, rota emergencial)

Caiu a Internet na sala? A CLI do competidor (moj-comp) empacota a submissão cifrada com a hora UTC corrente e reenvia quando a rede volta — e ela conta no horário do carimbo (penalidade justa). O tempo é cercado por dois lados:

O pacote é híbrido RSA-OAEP(+sha do conteúdo no envelope — integridade)/AES-256-CBC; a privada vive em contests/<c>/secrets/ e nunca sai. A CLI mede o desvio do relógio local contra server_utc a cada contato (funciona sem root) e carimba com a hora corrigida. Pacote aceito entra no MESMO spool da seção 1 com time=claimed + offline:true; o organizador enxerga tudo em var/offline-log e no audit (offline-submit, com os gaps beacon→claimed→chegada). Lib: server/api/v1/lib/contest-offline.sh; rotas em API.md.

8. Outros caminhos pelo mesmo spool

O mesmo mecanismo (API → spool → daemon) serve comandos administrativos vindos da web e do mojinho-bot (cliente da API): synctreino, newcontest, adduser, passwd, alteravigenciacontest, rejulgar. O jplag roda à parte: handlers/contest/admin/jplag-run.sh dispara server/score/jplag-run.sh em background, que junta as soluções aceitas, roda o jar e grava os pares de similaridade em contests/<c>/jplag/.

Troca de rodada (aquecimento → prova oficial)

O mesmo contest roda o aquecimento e depois a prova. A rodada ATIVA é o conf (CONTEST_START/END/FREEZE_TIME/PROBS + ROUND/ROUND_NAME/ROUND_KIND); rounds.json guarda o plano das outras. Promover (POST /contest/admin/rounds {action:"promote"}, lib/contest-rounds.sh) é arquivar + zerar + reapontar, sob flock em var/.round.lock:

  1. checklist — recusa (409 not_ready) com job no spool/fila, veredicto pendente, review aberto, daemon caído, sem rodada planejada ou contas compartilhadas (USERS_FROM);
  2. pr_reconcile_balloonsbuild.shstats-gen.sh (fecha os números da rodada);
  3. report-gen.shrounds/<slug>/relatorio/antes de mover qualquer coisa;
  4. mv do que é dado de rodada (users/<l>/{history,metrics.json,submissions,mojlog,results}, review/, print-requests/, clarifications/, news*, backups/, jplag/, docs/*.pdf, placar*.txt, statistics.cache.json, time-overrides.json, resources.json) + cópia de var/{access.log,admin-audit.log,editor-log,offline-log} + conf.snapshot + meta.json + machines.json;
  5. store vazio de volta (history vazio, submissions/mojlog/results recriados), staff-filters.json e os templates de docs/ preservados, .seq da impressão zerado;
  6. rd_apply grava a janela e o PROBS da rodada nova (CC_KEEP_STATEMENTS=1, p/ não re-baixar enunciado do banco por cima do que o admin subiu);
  7. touch var/.score-dirty, remove .metrics-stamp (recompute limpo) e rounds.json passa a apontar para a rodada nova.

O arquivo é lido depois por /contest/round (site estático, gate por published) e por /contest/admin/round-archive (tar.gz cru, só admin).

Serviços systemd (server/etc/systemd/)

Unit Papel
moj-fcgiwrap.socket/.service socket + fcgiwrap que roda o router.sh (a API)
moj-judged.service daemon que consome o spool e enfileira p/ o pull
moj-agent@.service agente do juiz (repo judge/, nas máquinas de julgamento; @pos/@gpu/…)
moj-bot.service mojinho-bot (cliente da API)
moj-contest-backup@.service/.timer snapshot rotacionado do contest %i a cada 5 min durante a prova (server/bin/contest-backup.sh: tar de contests/<c>/ + spool pendente; ligar o timer no dia)

Resumo do "porquê"