Pular para conteúdo

0004 — Coleta por remessa: a unidade de entrega é declarada na origem

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-09 · Decidido em: 2026-09-09

Contexto

A decisão 0003 partiu de uma premissa errada: que o pedido órfão tinha uma causa — o sync aterrissando no meio da coleta — e que ler duas vezes bastava. Três incidentes medidos mostraram que há um segundo modo, que a leitura dupla não cobre, porque as duas leituras concordam:

pai ausente filho rejeitado evidência pedido
propriedades (cliente) contrato → item → fórmulas 120-Cliente CPF 072.508.149-07 não cadastrado. 260079421, 08-set
contratos (pedido) item, fórmulas 120-Pedido (10738.24) não cadastrado. 260079618, 09-set
estoque (produto) item, fórmulas 120-Produto para o pedido (260078864) não cadastrado. 260078864, 09-set

Às 11:24:52 do 260079618 a coleta levou todos os pendentes locais e ainda assim o contrato não saiu: ele não estava no SQLite. O PowerSync entregou linhas da mesma transação Postgres em checkpoints diferentes.

A cascata é 1:N e silenciosa — no 260079421, uma ausência produziu nove linhas terminais, e 53 no log inteiro. 1XX é terminal e a coleta lê só 000/9XX: a linha some para sempre. Estado no dia da decisão: 11 pedidos, 76 linhas travadas.

A raiz não é dependência semântica, é ausência de unidade de trabalho. O cliente lê um banco local e não distingue "a linha ainda não chegou" de "a linha não existe". Qualquer regra que ele infira — gate de FK, releitura — é inferência sobre informação incompleta. O Integrador, no instante do applyPut, já sabe exatamente quais linhas aquele pedido precisa entregar.

Decisão

O integrador-server passa a declarar a unidade de entrega: uma remessa por componente de dependência de cada push (integracao_remessa + integracao_remessa_item), replicada pelo PowerSync junto com as linhas. Este cliente monta o arquivo por duas vias que convivem por desenho:

  • Via remessa — linha coberta por uma remessa viva só sai quando a remessa está inteira no banco local. Uma regra, independente de quantas tabelas o fluxo tenha.
  • Via presença — linha que não pertence a remessa nenhuma continua coletável como sempre. O servidor cria remessa só onde há dependência (grupo com 2+ linhas ligadas), então propriedades avulsa e estoque avulso nunca terão uma. O manifesto acrescenta garantia, nunca remove — é o que torna o rollout não-quebrante e mantém abastecimento e encerra-mdfe vivos sem manifesto próprio.

O que o cliente lê, e não deduz

campo quem calcula para quê
total_itens servidor detectar que a própria lista de itens chegou pela metade
bloqueado servidor um pai da linha, na mesma remessa, está em 1XX — não reenviar
resolvido servidor nada mais a esperar: 006, soft-deletada ou órfã
status servidor ABERTA/ENTREGUE/TRAVADA (só as vivas sincronizam)

O grafo de dependência mora no servidor (registry.ordemPut()) e não é replicado aqui. O cliente lê flags. É o que dá a precisão do gate de FK sem o acoplamento: pai morto retém os filhos, folha morta não retém as irmãs.

Carência de 3 ciclos

Linha pendente sem remessa só sai depois de aparecer descoberta em 3 ciclos seguidos. Sem isso, a linha de um pedido cuja remessa ainda não aterrissou é indistinguível da linha solta que nunca terá remessa, sai por presença, e o incidente se repete — o manifesto atravessa a mesma fronteira que quebrou as linhas. Em ciclos e não em relógio porque o ciclo acelera justamente na janela de risco (logo após o push): no 260079618 os dois ciclos ficaram a 6,5 s um do outro. Contrato declarado dos dois lados (integrador-server .ia/005 R16b).

Consequências no ciclo

  • A leitura é uma passada só — pendentes, manifesto e presença — e a conferência das duas leituras cobre tudo. Conferir só o dado deixaria nua a decisão coberto × descoberto, que é a que o manifesto existe para tomar.
  • O corte de LIMITE_REGISTROS (999) passou a ser por remessas inteiras: cortar cego fatiaria uma remessa e produziria o próprio órfão.
  • temPendenteNovo() lembra os ids retidos, senão remessa presa fura o backoff a cada tick.
  • coletarPendentes() continua devolvendo tudo: o modo debug tem de mostrar a realidade ao operador, inclusive a linha retida.

O que foi descartado

  • Gate de FK no cliente (o que o 0003 já havia removido). Cinco regras hoje, quinze no fluxo completo, escritas por quem talvez não conheça a semântica — e regra errada trava produção em silêncio. Não resolve o caso da linha que ainda não chegou, que é o caso dos três incidentes.
  • Estado 002 ("aguardando pai") na linha — exigiria inventar código numa coluna compartilhada com o ERP, com homologação do dono do pAbast. O manifesto o torna desnecessário: a elegibilidade é propriedade da remessa, não da linha.
  • Snapshot de leitura (readTransaction numa transação só). Mata a leitura rasgada por construção e continua boa ideia, mas com o manifesto o pior efeito dela no caminho coberto passa a ser um ciclo de espera. Adiado, para não misturar duas mudanças no mesmo PR.
  • Column.integer para as flags booleanas. Não traria nada: o PowerSyncWrapper.listar lê toda coluna com getString, e o SDK mapeia bool do Postgres para inteiro — chega "1"/"0" de qualquer jeito. A coerção está travada por teste contra o SQLite real.

Consequências

  • Nenhum arquivo sai com linha órfã entre as linhas cobertas; remessa incompleta vira espera.
  • Remessa travada, remessa retida há muito tempo e item de tabela desconhecida aparecem no GlitchTip, com dedup por causa — reter em silêncio foi o que derrubou a primeira tentativa.
  • Cadastro avulso legítimo espera 3 ciclos na primeira vez. É o preço de não distinguir o indistinguível, e é pago uma vez por linha.
  • O valor depende de duas peças de fora: as sync rules do maxsul/powersync publicando as duas tabelas, e o manifesto + backfill do integrador-server. Sem elas o cliente coleta como hoje, 100% por presença — não quebra, mas o bug continua possível.