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
propriedadesavulsa eestoqueavulso nunca terão uma. O manifesto acrescenta garantia, nunca remove — é o que torna o rollout não-quebrante e mantémabastecimentoeencerra-mdfevivos 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
0003já 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 (
readTransactionnuma 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.integerpara as flags booleanas. Não traria nada: oPowerSyncWrapper.listarlê toda coluna comgetString, e o SDK mapeiabooldo 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/powersyncpublicando as duas tabelas, e o manifesto + backfill dointegrador-server. Sem elas o cliente coleta como hoje, 100% por presença — não quebra, mas o bug continua possível.