mirror of https://github.com/interlegis/sapl.git
Browse Source
ADR-0015, fecha o incidente da IND 826/2026: em producao (fora de docker) o laco dependia de alguem lembrar de subi-lo. Agora o laco grava batimento no banco a cada tick (inclusive durante varredura longa) e um middleware no processo web — o unico processo garantido, ja que o hub polla a API o tempo todo — o ressuscita quando o batimento envelhece (checagem 1x/min por worker, processo desgarrado, nunca custa um request). Guarda de instancia unica: laco que encontra batimento fresco de outro pid sai na hora, entao N workers ressuscitando juntos e inofensivo. Deploy: mtime do comando mudou -> laco se encerra e renasce atualizado. Painel mostra o batimento (vivo/pid/quando); supervisor vira redundancia opcional (MATERIALIZACAO_AUTOSSUPERVISAO=False para quem gerir por fora). Ponteiro do knowledge/ atualizado (ADR-0012). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>pull/3858/head
9 changed files with 457 additions and 4 deletions
@ -1 +1 @@ |
|||
Subproject commit 3ea2f52ec2f3484e5634dc98aeb5050230117f89 |
|||
Subproject commit 665a13d886cbac5ba96c50cb217cffc55a7fcb81 |
|||
@ -0,0 +1,106 @@ |
|||
"""Autossupervisão do laço de materialização (ADR 0015). |
|||
|
|||
O problema que este módulo mata: o SAPL de produção roda fora de docker, e o |
|||
laço de materialização dependia de alguém LEMBRAR de subi-lo (nohup, supervisor, |
|||
cron — todos exigem um ato manual que já falhou uma vez, IND 826/2026). O único |
|||
processo cuja existência é garantida é o próprio web: o hub bate na API de |
|||
integração o tempo inteiro, e cada request é um marca-passo de graça. |
|||
|
|||
Então: o laço grava um batimento no banco a cada tick (`BatimentoLaco`); este |
|||
middleware, no máximo uma vez por minuto por worker, confere o batimento e — |
|||
se ele envelheceu ou nunca existiu — ressuscita o laço como processo separado e |
|||
desgarrado (mesmo padrão do botão "Rodar agora" do painel: `start_new_session`, |
|||
processo sobrevive ao reload do worker). |
|||
|
|||
Corrida entre N workers ressuscitando ao mesmo tempo: inofensiva por |
|||
construção — o laço que sobe e encontra batimento fresco de outro pid SAI na |
|||
hora (guarda de instância única no próprio comando), e o lock de passada já |
|||
impedia conversão dupla de qualquer forma. |
|||
|
|||
Desligável por `MATERIALIZACAO_AUTOSSUPERVISAO=False` (ambiente onde o laço é |
|||
gerido por supervisor/systemd e não se quer o respawn automático). |
|||
""" |
|||
import logging |
|||
import os |
|||
import subprocess |
|||
import sys |
|||
import time |
|||
|
|||
from django.conf import settings |
|||
from django.core.exceptions import MiddlewareNotUsed |
|||
|
|||
logger = logging.getLogger(__name__) |
|||
|
|||
# No máximo uma consulta ao batimento por minuto POR WORKER: o custo por |
|||
# request no caminho quente é uma comparação de monotonic. |
|||
INTERVALO_VERIFICACAO_SEGUNDOS = 60 |
|||
|
|||
_ultima_verificacao = 0.0 |
|||
|
|||
|
|||
def laco_precisa_subir(): |
|||
"""Batimento ausente ou envelhecido = não há laço vivo.""" |
|||
from sapl.integracao_hub.models import BatimentoLaco |
|||
batimento = BatimentoLaco.objects.first() |
|||
return batimento is None or not batimento.fresco |
|||
|
|||
|
|||
def subir_laco(): |
|||
"""Sobe o laço como processo separado e desgarrado (padrão do painel). |
|||
|
|||
Intervalo/tick vêm das mesmas variáveis de ambiente do start.sh do |
|||
container — uma configuração só, seja quem for que suba o laço. |
|||
""" |
|||
from sapl.integracao_hub.painel import _raiz_do_projeto |
|||
intervalo = os.environ.get('MATERIALIZACAO_INTERVALO_SEGUNDOS', '300') |
|||
tick = os.environ.get('MATERIALIZACAO_TICK_SEGUNDOS', '15') |
|||
comando = [ |
|||
sys.executable, '-m', 'django', |
|||
'materializar_pdfs_para_assinatura', |
|||
'--intervalo', intervalo, |
|||
'--tick', tick, |
|||
] |
|||
ambiente = os.environ.copy() |
|||
ambiente.setdefault('DJANGO_SETTINGS_MODULE', 'sapl.settings') |
|||
subprocess.Popen( |
|||
comando, |
|||
cwd=_raiz_do_projeto(), |
|||
env=ambiente, |
|||
stdout=subprocess.DEVNULL, |
|||
stderr=subprocess.DEVNULL, |
|||
start_new_session=True) |
|||
logger.warning( |
|||
'autossupervisao: laco de materializacao sem batimento — novo laco ' |
|||
'disparado (intervalo=%ss, tick=%ss)', intervalo, tick) |
|||
|
|||
|
|||
class AutossupervisaoMaterializacao: |
|||
def __init__(self, get_response): |
|||
# Em teste, spawnar processo residente por efeito colateral de request |
|||
# seria vazamento; quem testa as peças chama as funções deste módulo. |
|||
if 'pytest' in sys.modules: |
|||
raise MiddlewareNotUsed('autossupervisao desligada sob pytest') |
|||
if not getattr(settings, 'MATERIALIZACAO_AUTOSSUPERVISAO', True): |
|||
raise MiddlewareNotUsed('MATERIALIZACAO_AUTOSSUPERVISAO=False') |
|||
self.get_response = get_response |
|||
|
|||
def __call__(self, request): |
|||
self._garantir_laco() |
|||
return self.get_response(request) |
|||
|
|||
def _garantir_laco(self): |
|||
global _ultima_verificacao |
|||
agora = time.monotonic() |
|||
if _ultima_verificacao and agora - _ultima_verificacao < \ |
|||
INTERVALO_VERIFICACAO_SEGUNDOS: |
|||
return |
|||
_ultima_verificacao = agora |
|||
try: |
|||
if laco_precisa_subir(): |
|||
subir_laco() |
|||
except Exception as exc: # noqa — a autossupervisão NUNCA pode custar |
|||
# um request: banco sem migração, tabela ausente, falha de spawn — |
|||
# tudo vira log e o SAPL segue servindo. |
|||
logger.warning('autossupervisao: verificacao falhou (%s) — ' |
|||
'seguira tentando a cada %ss', exc, |
|||
INTERVALO_VERIFICACAO_SEGUNDOS) |
|||
@ -0,0 +1,25 @@ |
|||
from django.db import migrations, models |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('integracao_hub', '0006_fila_prioridade_materializacao'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.CreateModel( |
|||
name='BatimentoLaco', |
|||
fields=[ |
|||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|||
('visto_em', models.DateTimeField(verbose_name='Último batimento')), |
|||
('iniciado_em', models.DateTimeField(verbose_name='Laço iniciado em')), |
|||
('pid', models.IntegerField(verbose_name='PID do processo do laço')), |
|||
('tick_segundos', models.IntegerField(default=15, help_text='Gravado pelo próprio laço: é a referência para decidir se o batimento envelheceu.', verbose_name='Tick configurado (s)')), |
|||
], |
|||
options={ |
|||
'verbose_name': 'Batimento do Laço de Materialização', |
|||
'verbose_name_plural': 'Batimentos do Laço de Materialização', |
|||
}, |
|||
), |
|||
] |
|||
@ -0,0 +1,161 @@ |
|||
"""Autossupervisão do laço de materialização (ADR 0015). |
|||
|
|||
O contrato: o laço bate no banco a cada tick; o processo web ressuscita o laço |
|||
quando o batimento envelhece; dois laços nunca residem juntos; e um deploy faz |
|||
o laço se encerrar para renascer com o código novo. |
|||
""" |
|||
import os |
|||
|
|||
import pytest |
|||
from django.core.management import call_command |
|||
from django.utils import timezone |
|||
|
|||
from sapl.integracao_hub.middleware import laco_precisa_subir, subir_laco |
|||
from sapl.integracao_hub.models import BatimentoLaco, PassadaMaterializacao |
|||
|
|||
CMD = ('sapl.integracao_hub.management.commands' |
|||
'.materializar_pdfs_para_assinatura') |
|||
|
|||
|
|||
@pytest.fixture(autouse=True) |
|||
def base_url_configurada(settings): |
|||
settings.SAPL_INTERNAL_URL = 'http://sapl-interno:8000' |
|||
settings.SITE_URL = '' |
|||
|
|||
|
|||
def batimento(segundos_atras=0, pid=None): |
|||
agora = timezone.now() |
|||
return BatimentoLaco.objects.create( |
|||
pk=1, |
|||
visto_em=agora - timezone.timedelta(seconds=segundos_atras), |
|||
iniciado_em=agora, |
|||
pid=pid if pid is not None else os.getpid() + 1, |
|||
tick_segundos=15) |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# O laço bate — inclusive é este batimento que o painel exibe |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_modo_laco_grava_batimento(db, monkeypatch): |
|||
def sleep_que_interrompe(segundos): |
|||
raise KeyboardInterrupt |
|||
|
|||
monkeypatch.setattr(CMD + '.time.sleep', sleep_que_interrompe) |
|||
|
|||
with pytest.raises(KeyboardInterrupt): |
|||
call_command('materializar_pdfs_para_assinatura', intervalo=30) |
|||
|
|||
vivo = BatimentoLaco.objects.get(pk=1) |
|||
assert vivo.pid == os.getpid() |
|||
assert vivo.fresco |
|||
assert vivo.tick_segundos == 15 |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_passada_avulsa_nao_bate(db): |
|||
"""O batimento mede a vida do LAÇO; passada manual não é laço.""" |
|||
call_command('materializar_pdfs_para_assinatura') |
|||
|
|||
assert not BatimentoLaco.objects.exists() |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Instância única: dois laços nunca residem juntos |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_segundo_laco_sai_quando_ha_batimento_fresco_de_outro_pid(db): |
|||
batimento(segundos_atras=5) |
|||
|
|||
# Sem monkeypatch de sleep: se o guarda falhar, o laço entraria em loop |
|||
# infinito — o retorno imediato É a asserção. |
|||
call_command('materializar_pdfs_para_assinatura', intervalo=30) |
|||
|
|||
assert not PassadaMaterializacao.objects.exists() |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_laco_assume_quando_batimento_esta_velho(db, monkeypatch): |
|||
batimento(segundos_atras=600) |
|||
|
|||
def sleep_que_interrompe(segundos): |
|||
raise KeyboardInterrupt |
|||
|
|||
monkeypatch.setattr(CMD + '.time.sleep', sleep_que_interrompe) |
|||
|
|||
with pytest.raises(KeyboardInterrupt): |
|||
call_command('materializar_pdfs_para_assinatura', intervalo=30) |
|||
|
|||
assert BatimentoLaco.objects.get(pk=1).pid == os.getpid() |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Deploy: laço defasado se encerra para renascer com o código novo |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_laco_sai_quando_o_codigo_muda_no_disco(db, monkeypatch): |
|||
getmtime_real = os.path.getmtime |
|||
contagem = {'n': 0} |
|||
|
|||
def getmtime_que_avanca(caminho): |
|||
# Só o arquivo do comando "muda de versão"; o resto do mundo segue |
|||
# normal — getmtime é global e outros códigos o usam no meio do teste. |
|||
if str(caminho).endswith('materializar_pdfs_para_assinatura.py'): |
|||
contagem['n'] += 1 |
|||
return float(contagem['n']) |
|||
return getmtime_real(caminho) |
|||
|
|||
monkeypatch.setattr(CMD + '.os.path.getmtime', getmtime_que_avanca) |
|||
|
|||
# Retorna sozinho (sem KeyboardInterrupt): a saída limpa É a asserção. |
|||
call_command('materializar_pdfs_para_assinatura', intervalo=30) |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# O lado do processo web: decidir e ressuscitar |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_precisa_subir_sem_batimento_ou_com_batimento_velho(db): |
|||
assert laco_precisa_subir() # nunca bateu |
|||
|
|||
b = batimento(segundos_atras=600) |
|||
assert laco_precisa_subir() # bateu ha 10min com tick de 15s |
|||
|
|||
b.visto_em = timezone.now() |
|||
b.save() |
|||
assert not laco_precisa_subir() # fresco |
|||
|
|||
|
|||
@pytest.mark.django_db(transaction=False) |
|||
def test_subir_laco_dispara_processo_desgarrado(db, monkeypatch): |
|||
chamadas = [] |
|||
|
|||
def popen_falso(comando, **kwargs): |
|||
chamadas.append((comando, kwargs)) |
|||
|
|||
monkeypatch.setattr( |
|||
'sapl.integracao_hub.middleware.subprocess.Popen', popen_falso) |
|||
monkeypatch.setenv('MATERIALIZACAO_INTERVALO_SEGUNDOS', '120') |
|||
monkeypatch.setenv('MATERIALIZACAO_TICK_SEGUNDOS', '5') |
|||
|
|||
subir_laco() |
|||
|
|||
comando, kwargs = chamadas[0] |
|||
assert 'materializar_pdfs_para_assinatura' in comando |
|||
assert comando[comando.index('--intervalo') + 1] == '120' |
|||
assert comando[comando.index('--tick') + 1] == '5' |
|||
# Desgarrado do worker: sobrevive ao reload do gunicorn. |
|||
assert kwargs['start_new_session'] is True |
|||
|
|||
|
|||
def test_middleware_nao_liga_sob_pytest(): |
|||
"""Spawnar laço residente por efeito de request de teste seria vazamento.""" |
|||
from django.core.exceptions import MiddlewareNotUsed |
|||
from sapl.integracao_hub.middleware import AutossupervisaoMaterializacao |
|||
|
|||
with pytest.raises(MiddlewareNotUsed): |
|||
AutossupervisaoMaterializacao(lambda request: None) |
|||
Loading…
Reference in new issue