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).
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.
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.
server/daemons/judged.shServiç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.json → CONTEST_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.
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:
mock (default em dev) — heurística
local, sem juiz; bom para testar a malha assíncrona.local — compila/roda na própria
máquina via mojtools (bubblewrap sandbox).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.
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).
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.
server/score/build.shChamado 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.txt — 1ª 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.).
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:
{c,l,t,n} assinado RSA-PSS
com a chave do contest, renovado a cada comando com rede —
GET /contest/beacon). O pacote provadamente nasceu
depois de beacon.t.beacon.t ≤ claimed ≤ now+30s, claimed dentro da janela DO
aluno (extend por sede conta) e monotonicidade entre pacotes
aceitos.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.
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/.
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:
not_ready) com
job no spool/fila, veredicto pendente, review aberto, daemon caído, sem
rodada planejada ou contas compartilhadas
(USERS_FROM);pr_reconcile_balloons → build.sh →
stats-gen.sh (fecha os números da rodada);report-gen.sh →
rounds/<slug>/relatorio/ — antes de
mover qualquer coisa;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;history vazio,
submissions/mojlog/results recriados),
staff-filters.json e os templates de docs/
preservados, .seq da impressão zerado;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);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).
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) |
run/registry/<host>.json).inotifywait + flock, fiel ao bash-nativo do
MOJ.