mirror of https://github.com/interlegis/sapl.git
Browse Source
Feat(Materializacao): laco renasce sozinho — autossupervisao pelo processo web + supervisor opcional AB#1480pull/3858/head
committed by
GitHub
11 changed files with 497 additions and 4 deletions
@ -1 +1 @@ |
|||||
Subproject commit f8c0df145abbb482cb80760a298f2bdff5e07578 |
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) |
||||
@ -0,0 +1,35 @@ |
|||||
|
; Laco da materializacao do PDF-alvo da assinatura (ADR 0014). |
||||
|
; |
||||
|
; Sem este programa rodando, materia protocolada NUNCA vira pendencia de |
||||
|
; assinatura no app — so por clique manual no painel, em silencio. O SAPL de |
||||
|
; producao roda fora de docker, entao o laco nao sobe sozinho com o servico: |
||||
|
; e o supervisor (o mesmo que mantem o gunicorn) quem garante boot + restart. |
||||
|
; |
||||
|
; Instalacao (uma vez): |
||||
|
; sudo cp scripts/supervisor-materializacao.conf /etc/supervisor/conf.d/sapl-materializacao.conf |
||||
|
; -> ajustar command/directory/user espelhando o [program:sapl] ja existente |
||||
|
; em /etc/supervisor/conf.d/ (mesmo venv, mesmo diretorio, mesmo usuario) |
||||
|
; sudo supervisorctl reread && sudo supervisorctl update |
||||
|
; sudo supervisorctl status sapl-materializacao |
||||
|
; |
||||
|
; Acompanhamento: o painel /sistema/integracao/materializacao/ — fila de |
||||
|
; prioridade crescendo sem esvaziar = este programa caiu. |
||||
|
|
||||
|
[program:sapl-materializacao] |
||||
|
; AJUSTAR os tres caminhos abaixo para o layout da VPS (copiar do [program:sapl]) |
||||
|
command=/var/interlegis/.virtualenvs/sapl/bin/python manage.py materializar_pdfs_para_assinatura --intervalo 300 --tick 15 |
||||
|
directory=/var/interlegis/sapl |
||||
|
user=sapl |
||||
|
|
||||
|
autostart=true |
||||
|
autorestart=true |
||||
|
startsecs=10 |
||||
|
; O SIGTERM derruba o laco no meio de uma passada sem drama: a linha aberta em |
||||
|
; PassadaMaterializacao fica orfa e a propria rotina a fecha e destrava o lock |
||||
|
; na subida seguinte (_fechar_orfas). |
||||
|
stopasgroup=true |
||||
|
killasgroup=true |
||||
|
redirect_stderr=true |
||||
|
stdout_logfile=/var/log/sapl-materializacao.log |
||||
|
stdout_logfile_maxbytes=10MB |
||||
|
stdout_logfile_backups=3 |
||||
Loading…
Reference in new issue