diff --git a/docker/docker-compose-dev.yml b/docker/docker-compose-dev.yml index 40717a11e..987733fd1 100644 --- a/docker/docker-compose-dev.yml +++ b/docker/docker-compose-dev.yml @@ -19,7 +19,8 @@ services: # producao): sem ela, materia protocolada nunca vira pendencia no app. command: > sh -c "python3 manage.py materializar_pdfs_para_assinatura - --intervalo ${MATERIALIZACAO_INTERVALO_SEGUNDOS:-300} & + --intervalo ${MATERIALIZACAO_INTERVALO_SEGUNDOS:-300} + --tick ${MATERIALIZACAO_TICK_SEGUNDOS:-15} & exec python3 manage.py runserver 0:8000" volumes: - ..:/sapl-dev diff --git a/docker/startup_scripts/start.sh b/docker/startup_scripts/start.sh index a6fb153ee..afd512a03 100755 --- a/docker/startup_scripts/start.sh +++ b/docker/startup_scripts/start.sh @@ -267,8 +267,9 @@ setup_cache_dir() { # seguinte recupera sozinha o DOCX que o OnlyOffice ainda nao converteu. start_materializacao_assinatura() { local intervalo="${MATERIALIZACAO_INTERVALO_SEGUNDOS:-300}" - log "Starting materializacao do PDF-alvo (a cada ${intervalo}s)..." - python manage.py materializar_pdfs_para_assinatura --intervalo "$intervalo" & + local tick="${MATERIALIZACAO_TICK_SEGUNDOS:-15}" + log "Starting materializacao do PDF-alvo (varredura a cada ${intervalo}s, fila de prioridade a cada ${tick}s)..." + python manage.py materializar_pdfs_para_assinatura --intervalo "$intervalo" --tick "$tick" & } start_services() { diff --git a/sapl/integracao_hub/__init__.py b/sapl/integracao_hub/__init__.py index e69de29bb..118a38158 100644 --- a/sapl/integracao_hub/__init__.py +++ b/sapl/integracao_hub/__init__.py @@ -0,0 +1 @@ +default_app_config = 'sapl.integracao_hub.apps.IntegracaoHubConfig' diff --git a/sapl/integracao_hub/apps.py b/sapl/integracao_hub/apps.py index 895256505..af85b28ef 100644 --- a/sapl/integracao_hub/apps.py +++ b/sapl/integracao_hub/apps.py @@ -6,3 +6,8 @@ class IntegracaoHubConfig(AppConfig): name = 'sapl.integracao_hub' label = 'integracao_hub' verbose_name = _('Integração com o Hub') + + def ready(self): + # Liga o receiver que alimenta a fila de prioridade da materialização + # (ADR 0014). Padrão do sapl.base: importar registra os @receiver. + from sapl.integracao_hub import receivers # noqa diff --git a/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py b/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py index 1dfcad0a6..73f649459 100644 --- a/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py +++ b/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py @@ -12,6 +12,7 @@ from django.utils import timezone from sapl.integracao_hub.models import (DocumentoParaAssinatura, MateriaComFalhaMaterializacao, + MateriaParaMaterializar, PassadaMaterializacao) from sapl.materia.models import MateriaLegislativa @@ -111,11 +112,29 @@ class Command(BaseCommand): type=int, default=0, metavar='SEGUNDOS', - help=('Segundos entre passadas. 0 (padrao) roda uma vez e sai — ' - 'o modo para invocacao manual. Maior que zero fica em laco, ' - 'que e como o container sobe a rotina (start.sh): sem isso a ' - 'materializacao vira passo manual e materia protocolada NUNCA ' - 'vira pendencia no app, em silencio.')) + help=('Segundos entre VARREDURAS COMPLETAS do acervo. 0 (padrao) ' + 'roda uma varredura e sai — o modo para invocacao manual. ' + 'Maior que zero fica em laco, que e como o container sobe a ' + 'rotina (start.sh): sem isso a materializacao vira passo ' + 'manual e materia protocolada NUNCA vira pendencia no app, ' + 'em silencio. No laco, entre uma varredura e outra, o tick ' + 'curto (--tick) processa a fila de prioridade (ADR 0014).')) + parser.add_argument( + '--tick', + type=int, + default=15, + metavar='SEGUNDOS', + help=('Cadencia do laco entre varreduras: a cada tick a fila de ' + 'prioridade (materias marcadas pelo post_save no momento do ' + 'protocolo/retificacao) e processada — e o que faz a materia ' + 'recem-protocolada virar pendencia em segundos, nao em ' + 'minutos. Tick vazio nao grava passada nem toca o banco alem ' + 'de um SELECT na fila.')) + parser.add_argument( + '--fila', + action='store_true', + help=('Processa a fila de prioridade UMA vez e sai, sem varredura. ' + 'Modo de teste/diagnostico do tick.')) parser.add_argument( '--somente-novos', action='store_true', @@ -149,21 +168,39 @@ class Command(BaseCommand): 'que o servidor do OnlyOffice alcance.') intervalo = options['intervalo'] + tick = max(1, options['tick']) somente_novos = options['somente_novos'] disparada_por = options['disparada_por'] + if options['fila']: + self._passada_prioritaria(somente_novos, disparada_por) + return if intervalo <= 0: self._passada_registrada(somente_novos, disparada_por) return self.stdout.write( - 'materializar_pdfs: laco a cada %ss (Ctrl-C para sair)' % intervalo) + 'materializar_pdfs: laco — varredura completa a cada %ss, fila de ' + 'prioridade a cada %ss (Ctrl-C para sair)' % (intervalo, tick)) + # A proxima varredura conta a partir do FIM da anterior: uma varredura + # que leva uma hora num acervo grande nao pode emendar na seguinte com + # so um tick de folga. + proxima_varredura = 0.0 while True: try: - self._passada_registrada(somente_novos, disparada_por) + if time.monotonic() >= proxima_varredura: + try: + self._passada_registrada(somente_novos, disparada_por) + finally: + # Reagenda mesmo quando a varredura estoura: sem isso a + # varredura quebrada re-rodaria a cada tick, martelando + # o acervo inteiro a cada 15s em vez de a cada 300s. + proxima_varredura = time.monotonic() + intervalo + else: + self._passada_prioritaria(somente_novos) except Exception as exc: # noqa — o laco NUNCA morre: se morrer, # a materializacao para de vez e ninguem percebe ate a materia # nao aparecer para assinar. logger.exception('materializar_pdfs: passada falhou: %s', exc) - time.sleep(intervalo) + time.sleep(tick) def _fechar_orfas(self): """Libera o lock de passada cujo processo morreu sem fechar a linha. @@ -188,17 +225,40 @@ class Command(BaseCommand): 'materializar_pdfs: passada %s abandonada (aberta desde %s) — ' 'lock liberado', orfa.pk, orfa.iniciada_em) - def _passada_registrada(self, somente_novos=False, disparada_por=''): + def _passada_prioritaria(self, somente_novos=False, disparada_por=''): + """Só as matérias marcadas pelo evento — o caminho dos segundos (ADR 0014). + + Fila vazia é o caso de quase todo tick: sai sem gravar passada nem + disputar o lock. Com fila, roda como passada normal (mesmo lock, mesma + linha na tela) restrita às marcadas — se a varredura completa estiver no + meio, o lock dispensa esta passada e as marcas ficam para o próximo + tick. + """ + marcas = list(MateriaParaMaterializar.objects + .select_related('materia') + .order_by('marcada_em', 'materia_id')) + if not marcas: + return None + return self._passada_registrada(somente_novos, disparada_por, + marcas=marcas) + + def _passada_registrada(self, somente_novos=False, disparada_por='', + marcas=None): """Envelope da passada: abre a linha, roda, fecha — sempre fecha. A linha existe para a tela responder "quando rodou, o que fez, e por que falhou" sem ninguém abrir shell. E o `em_andamento` único garante que - laço e botão nunca convertam o mesmo documento ao mesmo tempo. + laço, tick de prioridade e botão nunca convertam o mesmo documento ao + mesmo tempo. """ self._fechar_orfas() - disparo = (PassadaMaterializacao.DISPARO_MANUAL if disparada_por - else PassadaMaterializacao.DISPARO_LACO) + if marcas is not None: + disparo = PassadaMaterializacao.DISPARO_PRIORIDADE + elif disparada_por: + disparo = PassadaMaterializacao.DISPARO_MANUAL + else: + disparo = PassadaMaterializacao.DISPARO_LACO try: # `atomic` aqui não é transação de negócio: sem ele a IntegrityError # do índice único envenena a transação corrente e o rollback leva @@ -216,23 +276,53 @@ class Command(BaseCommand): return None try: - self._passada(somente_novos, passada) + self._passada(somente_novos, passada, marcas) finally: passada.em_andamento = None passada.terminada_em = timezone.now() passada.save() return passada - def _passada(self, somente_novos=False, passada=None): - materias = (MateriaLegislativa.objects - .filter(numero_protocolo__isnull=False, - texto_original__isnull=False) - .exclude(texto_original='') - .order_by('id')) + def _pares_da_fila(self, marcas): + """(matéria, marca) das marcadas ainda elegíveis; as demais desmarcam. + + A matéria pode ter saído do filtro entre o evento e o tick (protocolo + anulado pelo protocoloadm, texto removido) — processá-la seria erro, + deixar a marca seria fila que nunca esvazia. + """ + pares = [] + for marca in marcas: + materia = marca.materia + if not materia.numero_protocolo or not materia.texto_original: + self._desmarcar(marca) + continue + pares.append((materia, marca)) + return pares + + def _desmarcar(self, marca): + """Tira da fila SÓ se a marca não avançou depois da leitura. + + Retificação que chega no meio da conversão remarca com `marcada_em` + novo — o filtro deixa essa marca viva e o próximo tick regenera sobre + o texto novo. Sem o filtro, o evento se perderia até a varredura. + """ + MateriaParaMaterializar.objects.filter( + pk=marca.pk, marcada_em__lte=marca.marcada_em).delete() + + def _passada(self, somente_novos=False, passada=None, marcas=None): + if marcas is None: + materias = (MateriaLegislativa.objects + .filter(numero_protocolo__isnull=False, + texto_original__isnull=False) + .exclude(texto_original='') + .order_by('id')) + pares = ((materia, None) for materia in materias.iterator()) + else: + pares = self._pares_da_fila(marcas) gerados = retificados = pulados = falhas = adiados = 0 motivos = {} - for materia in materias.iterator(): + for materia, marca in pares: try: resultado, motivo = self._materializar(materia, somente_novos) except Exception as exc: # noqa — uma matéria não trava as demais (§5.1) @@ -243,7 +333,14 @@ class Command(BaseCommand): motivos[type(exc).__name__] = motivos.get( type(exc).__name__, 0) + 1 self._registrar_falha(materia, type(exc).__name__) + # Falha também desmarca: a falha já está registrada para a tela + # e a varredura completa retenta — manter a marca faria o tick + # martelar o OnlyOffice a cada 15s com o ambiente quebrado. + if marca is not None: + self._desmarcar(marca) continue + if marca is not None: + self._desmarcar(marca) if resultado == 'gerado': gerados += 1 self._limpar_falha(materia) @@ -424,7 +521,14 @@ class Command(BaseCommand): materia.assinado_em = None materia.assinado_por = None materia.codigo_autenticacao = None - materia.save() + # O post_save de MateriaLegislativa alimenta a fila de prioridade + # (ADR 0014); este save é a PRÓPRIA materialização zerando a + # assinatura — remarcar aqui criaria um ciclo fila→passada→fila. + materia._materializacao_em_curso = True + try: + materia.save() + finally: + materia._materializacao_em_curso = False logger.info( 'materializar_pdfs: matéria %s RETIFICADA — alvo regenerado (%s) ' diff --git a/sapl/integracao_hub/migrations/0006_fila_prioridade_materializacao.py b/sapl/integracao_hub/migrations/0006_fila_prioridade_materializacao.py new file mode 100644 index 000000000..124d040e1 --- /dev/null +++ b/sapl/integracao_hub/migrations/0006_fila_prioridade_materializacao.py @@ -0,0 +1,31 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('materia', '0094_add_anexoproposicao'), + ('integracao_hub', '0005_painel_materializacao'), + ] + + operations = [ + migrations.AlterField( + model_name='passadamaterializacao', + name='disparo', + field=models.CharField(choices=[('laco', 'Laço automático'), ('manual', 'Disparo manual pela tela'), ('prioridade', 'Fila de prioridade')], default='laco', max_length=10, verbose_name='Origem do disparo'), + ), + migrations.CreateModel( + name='MateriaParaMaterializar', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('marcada_em', models.DateTimeField(help_text='Avança a cada remarcação. O tick de prioridade só remove a marca se ela não avançou depois da leitura — retificação no meio da conversão não se perde.', verbose_name='Marcada em')), + ('materia', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='materia.MateriaLegislativa', verbose_name='Matéria Legislativa')), + ], + options={ + 'verbose_name': 'Matéria na Fila de Materialização', + 'verbose_name_plural': 'Matérias na Fila de Materialização', + 'ordering': ('marcada_em', 'materia_id'), + }, + ), + ] diff --git a/sapl/integracao_hub/models.py b/sapl/integracao_hub/models.py index 66b724ccb..78d19f513 100644 --- a/sapl/integracao_hub/models.py +++ b/sapl/integracao_hub/models.py @@ -210,9 +210,11 @@ class PassadaMaterializacao(models.Model): DISPARO_LACO = 'laco' DISPARO_MANUAL = 'manual' + DISPARO_PRIORIDADE = 'prioridade' DISPARO_CHOICES = ( (DISPARO_LACO, _('Laço automático')), (DISPARO_MANUAL, _('Disparo manual pela tela')), + (DISPARO_PRIORIDADE, _('Fila de prioridade')), ) iniciada_em = models.DateTimeField( @@ -321,3 +323,43 @@ class MateriaComFalhaMaterializacao(models.Model): def __str__(self): return 'Matéria %s: %s' % (self.materia_id, self.motivo) + + +class MateriaParaMaterializar(models.Model): + """Fila de prioridade da materialização (ADR 0014): a marca, não o trabalho. + + Fecha o gap entre o evento (matéria protocolada com texto, texto + retificado) e a varredura do laço, que rodava a cada 5 minutos sobre o + acervo inteiro. O receiver de `post_save` de `MateriaLegislativa` grava + UMA linha aqui — custo de um upsert dentro do request — e quem converte + continua sendo só o processo do laço, no tick curto de prioridade. É isso + que preserva o dono único da conversão OnlyOffice (§5.1): o caminho quente + das requisições nunca converte, só aponta. + + Uma linha por matéria, não por evento: dois saves seguidos da mesma + matéria são um único trabalho pendente. `marcada_em` avança a cada + remarcação — é ela que protege a marca de ser apagada por um tick que + processou a versão anterior do texto (o tick só apaga marca com + `marcada_em` menor ou igual à que ele leu). + """ + + materia = models.OneToOneField( + 'materia.MateriaLegislativa', + on_delete=models.CASCADE, + related_name='+', + verbose_name=_('Matéria Legislativa')) + + marcada_em = models.DateTimeField( + verbose_name=_('Marcada em'), + help_text=_('Avança a cada remarcação. O tick de prioridade só ' + 'remove a marca se ela não avançou depois da leitura — ' + 'retificação no meio da conversão não se perde.')) + + class Meta: + verbose_name = _('Matéria na Fila de Materialização') + verbose_name_plural = _('Matérias na Fila de Materialização') + ordering = ('marcada_em', 'materia_id') + + def __str__(self): + return 'Matéria %s na fila desde %s' % (self.materia_id, + self.marcada_em) diff --git a/sapl/integracao_hub/painel.py b/sapl/integracao_hub/painel.py index e226b2b91..f572ce34a 100644 --- a/sapl/integracao_hub/painel.py +++ b/sapl/integracao_hub/painel.py @@ -42,7 +42,7 @@ from django.urls import reverse from django.views.generic import TemplateView, View from .models import (DocumentoParaAssinatura, MateriaComFalhaMaterializacao, - PassadaMaterializacao) + MateriaParaMaterializar, PassadaMaterializacao) logger = logging.getLogger(__name__) @@ -105,6 +105,9 @@ class PainelMaterializacaoView(PermissionRequiredMixin, TemplateView): 'total_falhas': total_falhas, 'falhas_ocultas': max(0, total_falhas - len(falhas)), 'alvos_materializados': DocumentoParaAssinatura.objects.count(), + # Fila de prioridade (ADR 0014): matérias marcadas pelo evento + # aguardando o tick. Cresce e não esvazia = o laço não está de pé. + 'na_fila': MateriaParaMaterializar.objects.count(), # A configuração que decide o sucesso da conversão fica na tela # porque foi exatamente ela a causa do incidente de 25/08 — e lê-la # exigia shell. diff --git a/sapl/integracao_hub/receivers.py b/sapl/integracao_hub/receivers.py new file mode 100644 index 000000000..873e4edb1 --- /dev/null +++ b/sapl/integracao_hub/receivers.py @@ -0,0 +1,49 @@ +"""Marca a matéria para materialização imediata — o lado 'evento' do ADR 0014. + +O `post_save` de `MateriaLegislativa` é deliberadamente o ÚNICO ponto de escuta: +todos os caminhos que interessam (protocolo efetivado, texto_original salvo ou +retificado, proposição incorporada, edição pelo CRUD) terminam num save da +matéria. Escutar cada view seria uma lista que envelhece; escutar o modelo é uma +linha que não envelhece. + +O receiver só grava a marca (um upsert) — quem converte é o laço do +`materializar_pdfs_para_assinatura`, no tick de prioridade. O request nunca +paga a conversão OnlyOffice (§5.1: a conversão tem UM dono). +""" +import logging + +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.utils import timezone + +from sapl.materia.models import MateriaLegislativa + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender=MateriaLegislativa, + dispatch_uid='integracao_hub_marcar_para_materializar') +def marcar_para_materializar(sender, instance, raw=False, **kwargs): + # A retificação feita pela PRÓPRIA rotina salva a matéria (zera a + # assinatura, §5.1) — remarcar aqui criaria um ciclo: cada passada + # alimentaria a fila que ela mesma consome. + if getattr(instance, '_materializacao_em_curso', False): + return + if raw: # loaddata/fixtures não é evento de negócio + return + # Só matéria que a materialização enxerga (mesmo filtro da varredura): + # protocolada e com texto. As demais entrariam na fila para o tick + # descartar — ruído puro. + if not instance.numero_protocolo or not instance.texto_original: + return + from sapl.integracao_hub.models import MateriaParaMaterializar + try: + MateriaParaMaterializar.objects.update_or_create( + materia=instance, + defaults={'marcada_em': timezone.now()}) + except Exception as exc: # noqa — a marca é aceleração, não requisito: + # falhar aqui NUNCA pode derrubar o save da matéria; a varredura + # completa (rede de segurança) materializa no ciclo dela. + logger.warning( + 'integracao_hub: falha ao marcar matéria %s para materialização ' + '(%s) — a varredura completa cobre', instance.pk, exc) diff --git a/sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html b/sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html index ca5225b0e..995323369 100644 --- a/sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html +++ b/sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html @@ -46,6 +46,12 @@
{{ alvos_materializados }} documento(s) já materializado(s) e disponível(is) para assinatura. + {% if na_fila %} + + {{ na_fila }} matéria(s) na fila de prioridade, + aguardando o próximo tick da rotina. + + {% endif %} {% if total_falhas %} {{ total_falhas }} matéria(s) travada(s) agora. @@ -200,6 +206,8 @@