Skip to content

fix(apps): Stop duplicating orders exported to Tiny and Bling ERP - #877

Open
vitorrgg wants to merge 2 commits into
mainfrom
fix/erp-export-idempotency-lock
Open

vitorrgg wants to merge 2 commits into
mainfrom
fix/erp-export-idempotency-lock

Conversation

@vitorrgg

@vitorrgg vitorrgg commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

Problema

Cada pedido gera dois eventos orders-anyStatusSet em poucos segundos: um no checkout e outro no pagamento. Nada impede que as duas execuções rodem ao mesmo tempo, porque maxInstances é best-effort e é estourado quando o GCF sobe duas instâncias em cold start simultâneo.

A única proteção contra criar o pedido duas vezes no ERP era consultá-lo antes (pedidos.pesquisa.php no Tiny, GET /pedidos/vendas no Bling). Isso não basta: a criação leva 2–3 s, então as duas execuções leem "não existe" antes de qualquer uma gravar, e as duas criam.

Numa loja em produção isso duplicou 7 de 31 pedidos em um dia, contra zero nos 30 dias anteriores (1218 pedidos exportados). Foi quando as duas instâncias passaram a iniciar no mesmo instante: entre 9 e 177 ms de diferença em todas as ocorrências.

Solução

acquireExportLock em @cloudcommerce/firebase/lib/helpers/export-lock: um lock por pedido no Firestore (tinyErpExportLocks, blingExportLocks), pego numa transação.

  • Quem pega o lock cria o pedido. Depois grava no lock o que foi criado (complete, com o id do ERP) ou libera o lock se nada foi criado (fail).
  • Quem perde o lock acompanha o doc do lock, sem consultar o ERP:
    • se o vencedor gravou o pedido, segue para a atualização de status com o id gravado;
    • se o vencedor liberou o lock, pega o lock e cria o pedido;
    • se o lock ficou velho, assume o lock e cria o pedido;
    • passado o maxWaitMs (Tiny 20 s, Bling 60 s), lança ConcurrentExportError. O afterQueue rejeita esse erro também em evento automático, e o evento é reentregue pelo failurePolicy. Por isso o eventMaxAgeMs do Tiny subiu para 10 min.
  • O lock é liberado em qualquer falha antes do POST e em erro respondido pelo ERP. Sem resposta (timeout) ele fica até expirar, porque o pedido pode ter sido criado.
  • O lock é assumido quando fica velho, numa janela maior que o timeout da função que o segura: Tiny 2 min, Bling 6 min. Sem isso, uma execução que morre com o lock deixaria o pedido sem exportar para sempre. É o mesmo mecanismo que mantém a reexportação funcionando quando o lojista apaga o pedido no ERP de propósito.
  • Sem Firestore, a exportação segue sem lock, como antes desta PR. Um possível duplicado é melhor que um pedido que não chega ao ERP.
  • O cron do Tiny não espera lock de outra execução: o pedido fica para a próxima rodada.

No Bling, o lock cobre só o caminho de criação (!blingOrderId). A atualização de pedido já exportado não passa por ele.

Resolve o item 2 da #815 (duplicação de pedido no Bling por reentrega do POST) por outro caminho: o lock é por pedido, e não por eventId.

TTL

Os docs têm expiresAt (agora + 1 dia). A política de TTL precisa ser habilitada em cada projeto:

gcloud firestore fields ttls update expiresAt --collection-group=tinyErpExportLocks --enable-ttl
gcloud firestore fields ttls update expiresAt --collection-group=blingExportLocks --enable-ttl

Verificação

  • packages/firebase/tests/export-lock.test.ts, com 13 casos sobre um Firestore em memória que serializa transações como o SDK de servidor:
    • execução concorrente recebe o id gravado pelo vencedor;
    • assume o lock quando o vencedor falha antes do POST;
    • lock mantido em timeout sem resposta e liberado com resposta de erro;
    • ConcurrentExportError depois do maxWaitMs;
    • takeover de lock velho e de doc sem lockedAt;
    • reexportação depois da janela;
    • quem teve o lock tomado não libera o lock novo;
    • Firestore fora do ar.
  • Fluxos dos dois exports exercitados com Tiny, Bling e o lock mockados: vencedor, perdedor, falha antes e durante o POST, timeout, cron e afterQueue. Esses testes não foram commitados porque os apps não têm vitest.
  • turbo run build (tsc) e ESLint limpos. Vitest do firebase 17/17, suíte do bling-erp 84/84.

🤖 Generated with Claude Code

Cada pedido gera dois eventos `orders-anyStatusSet` em segundos (checkout e
pagamento), e nada impede que as duas execuções rodem ao mesmo tempo:
`maxInstances` é best-effort e é estourado em cold starts simultâneos. A única
proteção contra criar o pedido duas vezes no ERP era consultá-lo antes, o que
não basta porque a criação leva segundos e as duas execuções leem "não existe"
antes de qualquer uma gravar.

Em 06/11/2026 isso duplicou 7 de 31 pedidos de uma loja em produção (zero nos
30 dias anteriores, em 1218 pedidos), quando o GCF passou a subir duas
instâncias no mesmo instante.

Adiciona `tryLockExport`/`unlockExport` em `@cloudcommerce/firebase`: um
`create()` no Firestore, que falha com ALREADY_EXISTS para a segunda execução.
Quem perde o lock espera e procura o pedido de novo, seguindo para a
atualização de status em vez de criar. O lock é liberado quando o POST falha, e
é assumido após 5 min para não travar o pedido se a execução vencedora morrer
antes de liberá-lo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@leomp12 leomp12 changed the title fix(apps): Evita pedidos duplicados no Tiny e no Bling ERP fix(apps): Stop duplicating orders exported to Tiny and Bling ERP Nov 7, 2026

@leomp12 leomp12 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

O diagnóstico está certo e o mecanismo do lock é sólido: o create() com ALREADY_EXISTS funciona (o firebase-admin 13 lança code: 6 numérico), a tomada de lock velho é serializada pela transação e o refactor do searchTinyOrder é equivalente ao código antigo. Manter o doc depois do sucesso também me parece a escolha certa. O problema está no que acontece em volta do lock: hoje o fix de um edge case abre regressão em caminhos mais comuns que ele.

🏗️ Arquitetural

Quem perde o lock não pode terminar sem rastro. Depois dos 5 s, se o pedido ainda não aparece no ERP, o Tiny retorna null (export-order-to-tiny.ts:128) e o Bling recursa com canCreateNew: false e cai em "skipped" (export-order-to-bling.ts:168). Os dois casos chegam ao afterQueue como sucesso. Se o evento perdido era o do pagamento, o status "pago" não chega ao ERP. No Tiny o cronSendOrders recupera em até 3 h. No Bling nada recupera (é o item 1 da #815).

Lançar erro não basta: evento de pedido é isNotQueued, e o afterQueue dos dois apps só repassa o erro com isQueued (after-tiny-queue.ts:39, after-bling-queue.ts:39). Hoje evento de pedido não tem redelivery pelo failurePolicy. O caminho que vejo é um erro específico de "exportação concorrente" que o afterQueue repasse também para entradas não enfileiradas, para o evento voltar pela redelivery e achar o pedido criado. Atenção ao eventMaxAgeMs: o Bling usa 10 min, mas o Tiny está no padrão de 60 s (pubsub.ts:26), o que pode descartar a reentrega.

Precisa entrar agora porque, no Bling, esse caminho não é raro. Depois de pegar o lock, o vencedor ainda faz GET e PUT/POST de contato, /formas-pagamentos, produtos e o POST do pedido, com o client limitado a ~1 req/s (bling-auth/client.ts:17-23). Isso dá 4 s ou mais antes do POST, contra os 5 s fixos de espera do perdedor.

🔴 Bloqueantes

  1. A recursão do Bling relê um pedido desatualizado (export-order-to-bling.ts:168). Ela passa o mesmo apiDoc, e como orderId === apiDoc._id (:32) o pedido não é buscado de novo. Os metafields bling:id/bling:numero gravados pelo vencedor ficam invisíveis. Em loja com random_order_number: true, searchOrderNumber fica undefined (:71-81), findBlingOrder retorna undefined sem nem consultar o Bling (:92-93) e o perdedor cai em "skipped" sempre, mesmo quando o vencedor já criou o pedido. O mesmo vale para colisão de número (hasFoundByNumber, :151-152), em que o vencedor cria com número aleatório.

  2. O lock vaza no Bling quando a falha acontece antes do POST (export-order-to-bling.ts:160 até :195). Só o catch do POST libera o lock. O getCustomerBling engole qualquer erro do contato (validação de CPF ou e-mail, 5xx, timeout) e retorna undefined (get-customer-bling.ts:99-105). Aí a :174 lança Bling Customer not found com o lock preso, e o /formas-pagamentos também não tem catch. Passo a passo:

    1. O evento de checkout falha no contato e o lock fica preso.
    2. O evento de pagamento chega segundos depois. Na main ele tentaria de novo e criaria. Aqui ele perde o lock, espera e sai como "skipped".
    3. Se o lojista corrige o cadastro e reexporta pelo admin em menos de 5 min, o afterQueue grava success: true sem ter criado nada.

    É o cenário comum que o lock não pode piorar. O lock precisa cobrir só o trecho até o POST e ser liberado em qualquer saída sem pedido criado.

🟠 Estruturais

  • A regra de liberar o lock fica a cargo de cada chamador. O Tiny usa try/catch em volta do POST, o Bling usa a flag hasExportLock num catch externo, e o bloqueante 2 nasce justamente daí. A espera e as constantes (CONCURRENT_EXPORT_WAIT_MS) também estão duplicadas nos dois apps. Como o helper é público em @cloudcommerce/firebase e o próximo candidato já existe (o events-to-melhor-envio.ts:35-66 compra etiqueta com o mesmo padrão de busca seguida de POST), vale o helper assumir o ciclo inteiro: pegar o lock, criar, liberar se lançar erro e sinalizar quando perdeu.
  • Reexportação manual em menos de 5 min de uma criação bem-sucedida é pulada e registrada como sucesso, nos dois apps. Exemplo: o lojista testa a integração, apaga o pedido no ERP e reenfileira. Na main o pedido seria recriado. Se o perdedor passar a sinalizar erro (ponto arquitetural), isso aparece no log do app em vez de virar sucesso falso.
  • Nomes das coleções: tinyExportedOrders/blingExportedOrders leem como "pedido exportado", mas o doc é um lock (só lockedAt, e é apagado quando a criação falha). O app já usa o prefixo tinyErp (tinyErpOrders). Sugiro tinyErpExportLocks/blingExportLocks. Trocar agora é barato; depois do deploy a coleção antiga fica para trás.

🟢 Minors

  • export-lock.ts:17: ALREADY_EXISTS = 6 já existe como GrpcStatus.ALREADY_EXISTS em firebase-admin/firestore.
  • Falta teste do helper. Há precedente com vitest e vi.mock em packages/firebase/tests/ e em packages/modules/tests/4-checkout-customer-guard.test.ts. O caminho de tomada de lock velho quase nunca roda em produção, e é justamente o que mais precisa de teste.
  • No Tiny, um erro do Firestore que não seja ALREADY_EXISTS agora bloqueia uma criação que antes funcionava. É improvável, mas vale registrar.
  • Os docs se acumulam, um por pedido, para sempre. Uma TTL policy sobre lockedAt resolve fora do código.
  • export-order-to-bling.ts:156,166,229: os comentários novos estão em inglês e os comentários inline do arquivo estão em português.
  • O body poderia citar a #815: o lock por pedido resolve o item 2 dela (duplicação por redelivery do POST no Bling) por outro caminho.
  • O CI vermelho não é desta PR. A main quebra desde a migração para Node 22 (node --test tests com diretório).

Antes do merge: os dois bloqueantes do Bling e um caminho de retry para quem perde o lock, sem descartar o evento como sucesso.

@vitorrgg vitorrgg left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revisão complementar, feita com o Claude Code. Boa parte coincide com a revisão do leomp12: o lock vaza antes do POST no Bling, a recursão usa apiDoc desatualizado e o perdedor sai como sucesso. Os pontos abaixo complementam aquela revisão. As correções vêm num commit seguinte neste branch.

O lock resolve o caso observado, com as duas execuções iniciando juntas. O problema é que quem perde o lock desiste cedo demais e sem deixar rastro. Isso faz o pedido não chegar ao ERP, ou perder a atualização de status, em quatro situações:

  • falha antes do POST, que deixa o lock preso;
  • falha no POST do vencedor;
  • pedido com número diferente do da loja no Bling;
  • vencedor mais lento que os 5 s fixos de espera.

Fora do diff:

  • tiny-erp-send-orders.ts:68: o cronSendOrders exporta até 200 pedidos em sequência, com timeout de 540 s. Agora cada pedido que não existe no Tiny e tem lock fresco soma 5 s de sleep ao loop. Com algumas dezenas de locks frescos, por exemplo depois de uma rajada de falhas, o cron estoura o timeout antes do fim da lista.

🤖 Generated with Claude Code

}
}

const customerIdBling = await getCustomerBling(bling, appData, order);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lock vaza quando algo falha antes do POST. O lock é pego na :160, mas getCustomerBling, getPaymentBling, getProductsBling e parseOrder (:172-191) ficam fora do try que libera o lock. Qualquer erro aqui deixa o lock preso por 5 min, inclusive o Bling Customer not found e um 429 no /contatos.

Nesse intervalo o evento de pagamento perde o lock, recursa com canCreateNew: false, não acha o pedido e pula. Como o Bling não tem cron de reenvio, o pedido nunca é exportado. Na main, o segundo evento criaria o pedido.

No Tiny acontece o mesmo se o parseOrder (export-order-to-tiny.ts:133) lançar erro.

setTimeout(resolve, CONCURRENT_EXPORT_WAIT_MS);
});
originalTinyOrder = await searchTinyOrder();
if (!originalTinyOrder) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quem perde o lock desiste quando o POST do vencedor falha. O perdedor procura o pedido uma única vez, 5 s depois. Se o POST do vencedor falha (código 6 do Tiny, que vira 503, ou 5xx do Bling), o vencedor libera o lock e lança erro. Só que o perdedor já saiu, e evento de pedido é isNotQueued, então o afterQueue engole o erro e ninguém tenta de novo.

Resultado: nenhuma das duas execuções cria o pedido. No Tiny ele só volta pelo cron de 3 em 3 h, e só se estiver pago. No Bling ele não volta.

Sugestão: o perdedor deve acompanhar o estado do lock, e não do ERP. Se o vencedor liberar o lock, o perdedor assume e cria o pedido.

});
/* Starts over to find the order the winning execution created and go
on to the status update; `canCreateNew: false` can never create. */
return exportOrderToBling(apiDoc, queueEntry, appData, false);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A recursão usa o apiDoc lido antes de o vencedor gravar bling:id/bling:numero. Por isso ela nunca faz GET por id: só busca por numero.

  • Com random_order_number: true, searchEndpoint fica undefined e o pedido é pulado sem nem consultar o Bling.
  • Com disable_order_number ou colisão de número (hasFoundByNumber), a busca por numero=order.number também não acha o pedido.

Nos três casos a atualização de status do perdedor se perde em silêncio. Sugestão: o vencedor grava o id criado no doc do lock, e o perdedor usa esse id direto.

hasExportLock = await tryLockExport(EXPORT_LOCKS_COLLECTION, orderId);
if (!hasExportLock) {
logger.info(`${orderId} waiting concurrent export to Bling`);
await new Promise((resolve) => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 s fixos são menos que o caminho do vencedor no Bling. O client serializa as chamadas a ~1 req/s, e depois de pegar o lock o vencedor ainda faz de 4 a 6 chamadas antes do POST (contato, forma de pagamento, produtos em lotes). O perdedor quase sempre busca antes de o pedido existir.

Se o perdedor é o evento de pagamento e o vencedor saiu do checkout com status pendente, o pedido fica "Em aberto" no Bling. É mais robusto o perdedor consultar o doc do lock (leitura barata no Firestore) até ele ter o id, ser liberado ou ficar velho, em vez de dormir e buscar no ERP.

return takenOver;
} catch (err: any) {
logger.warn(`Failed checking export lock ${collectionName}/${resourceId}`, { err });
return false;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Erro na transação de takeover vira "lock ocupado". Qualquer falha do runTransaction retorna false (ABORTED depois das tentativas, DEADLINE_EXCEEDED, indisponibilidade). O chamador então espera, não acha o pedido e pula. É o oposto da prioridade que a própria PR declara ("pior que duplicar") e não bate com o caminho do create(), que relança o erro. Melhor falhar aberto: registrar o erro e seguir como antes da PR.

): Promise<boolean> => {
const docRef = getFirestore().collection(collectionName).doc(resourceId);
try {
await docRef.create({ lockedAt: Timestamp.now() });

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Os docs nunca são apagados depois do sucesso. Fica um doc por pedido para sempre, sem campo para TTL. Sugestão: gravar um expiresAt e configurar a TTL policy sobre ele. Não dá para usar o lockedAt, porque uma TTL nele apagaria o doc logo depois de criado.

/* Another execution is creating this same order on Tiny right now: the
search above cannot see it yet, so creating it here would duplicate it. */
logger.info(`${orderId} waiting concurrent export to Tiny`);
await new Promise((resolve) => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A espera está duplicada nos dois apps. O CONCURRENT_EXPORT_WAIT_MS, o sleep e o fluxo de esperar e procurar de novo estão repetidos, e já divergem: o Tiny registra warn ao pular, o Bling só info pela recursão. Esse ciclo, junto com a regra de liberação, deveria ficar no helper.


const ALREADY_EXISTS = 6;

export const tryLockExport = async (

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Altitude, para discutir à parte. A causa raiz é o createAppEventsFunction depender de maxInstances: 1 para serializar eventos do mesmo recurso. Este lock cobre só a criação no Tiny e no Bling. Outros handlers de evento de pedido com efeito não idempotente continuam expostos à mesma corrida, como a compra de etiqueta no Melhor Envio. Uma serialização por resource_id no middleware do pubsub cobriria todos os apps, mas é uma mudança maior e não entra nesta PR.

/* Creating is not idempotent: the same order gets one event at checkout and
another on payment, and two executions can run them at the same time. Both
searched Bling before either wrote, so without this lock both create. */
if (!blingOrderId) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sem teste automatizado do lock. A validação no emulador não foi commitada, e os 84 testes do bling-erp não passam por nenhuma linha nova. Os caminhos de takeover, liberação e espera do perdedor são justamente os que quase nunca rodam em produção.

"./lib/env": "./lib/env.js",
"./lib/helpers/pubsub": "./lib/helpers/pubsub.js",
"./lib/helpers/firestore": "./lib/helpers/firestore.js",
"./lib/helpers/export-lock": "./lib/helpers/export-lock.js",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nota de convenção: o CLAUDE.md do repo pede para não incluir package.json no stage sem pedido explícito. Aqui a entrada em exports é necessária para o import novo, então provavelmente foi intencional. Fica só o registro.

…ções concorrem

Responde às revisões da PR. A execução que perdia o lock esperava 5 s, procurava
o pedido uma vez no ERP e saía como sucesso: o pedido ficava sem exportar quando
o vencedor falhava ou demorava, e no Bling com número aleatório a atualização de
status se perdia sempre.

- `acquireExportLock` assume o ciclo do lock. Quem perde acompanha o doc no
  Firestore até o vencedor gravar o id criado (segue para a atualização de
  status com ele), liberar o lock (assume e cria) ou o lock ficar velho. Passado
  `maxWaitMs`, lança `ConcurrentExportError`, que o `afterQueue` rejeita para o
  evento ser reentregue, também na exportação automática.
- O lock é liberado em qualquer falha antes do POST e em erro respondido pelo
  ERP. Sem resposta (timeout) fica até expirar, porque o pedido pode ter sido
  criado.
- Erro do Firestore não bloqueia a exportação: ela segue sem lock, como antes.
- Janela de lock velho por app, maior que o timeout da função (Tiny 2 min,
  Bling 6 min). O `eventMaxAgeMs` do Tiny sobe para 10 min para a reentrega não
  ser descartada.
- O cron do Tiny não espera lock alheio. Coleções renomeadas para
  `tinyErpExportLocks` e `blingExportLocks`, com `expiresAt` para TTL.
- Testes do helper com Firestore em memória.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vitorrgg

vitorrgg commented Nov 9, 2026

Copy link
Copy Markdown
Member Author

O commit 02e3c76 atende as duas revisões, a do leomp12 e a minha logo acima.

O que mudou

tryLockExport/unlockExport viraram acquireExportLock, que assume o ciclo inteiro do lock. Quem perde o lock não dorme mais 5 s nem busca no ERP. Ele acompanha o doc do lock no Firestore, a cada 2 s, até uma destas situações:

Situação O que o perdedor faz
O vencedor gravou o pedido criado (complete) Segue para a atualização de status com o id gravado no lock, sem buscar no ERP
O vencedor liberou o lock (fail, nada foi criado) Pega o lock e cria o pedido
O lock ficou velho (Tiny 2 min, Bling 6 min, os dois acima do timeout da função) Assume o lock e cria o pedido
Passou o maxWaitMs (Tiny 20 s, Bling 60 s) Lança ConcurrentExportError. O afterQueue rejeita esse erro mesmo em evento isNotQueued, e o evento é reentregue pelo failurePolicy

O eventMaxAgeMs do Tiny subiu para 10 min. Com o padrão de 60 s, a reentrega seria descartada antes de um lock abandonado ficar velho.

A regra de liberação também ficou no helper (setSending + fail(err)):

  • Falha antes do POST (contato, forma de pagamento, produtos, parse): o lock é liberado.
  • O ERP respondeu com erro: o lock é liberado.
  • O POST foi enviado e não houve resposta (timeout, rede): o lock fica até expirar, porque o pedido pode ter sido criado.

Por ponto

Revisão do leomp12

  • Arquitetural, perdedor sem rastro: quem perde nunca mais termina como sucesso. Ou ele segue com o pedido criado, ou cria, ou lança erro com retry pela reentrega.
  • Bloqueante 1, recursão com apiDoc desatualizado: a recursão foi removida. O id vem do doc do lock, então random_order_number, disable_order_number e colisão de número deixam de importar.
  • Bloqueante 2, lock vaza antes do POST: o try do Bling agora cobre do getCustomerBling até o POST.
  • Helper assume o ciclo: feito. A espera duplicada saiu dos apps.
  • Reexportação manual vira falso sucesso: não vira mais. Dentro da janela, ela segue para a atualização de status no id criado, e se o pedido foi apagado no ERP isso falha com erro visível. Depois da janela, o lock é assumido e o pedido é recriado.
  • Nomes das coleções: agora são tinyErpExportLocks e blingExportLocks.
  • GrpcStatus.ALREADY_EXISTS: deixou de ser necessário. O caminho create() + ALREADY_EXISTS saiu, e tudo passa por uma transação que lê o doc e cria, assume ou espera.
  • Testes: 13 casos em packages/firebase/tests/export-lock.test.ts, com um Firestore em memória que serializa transações como o SDK de servidor. Cobrem concorrência, liberação, timeout sem resposta, takeover, doc legado, exportação antiga, takeover por outra execução e Firestore fora do ar.
  • Erro do Firestore bloqueia a criação: agora a exportação segue sem lock e registra o erro.
  • TTL: os docs ganham expiresAt (agora + 1 dia). Não dá para usar o lockedAt, porque uma TTL nele apagaria o lock logo depois de criado. A política precisa ser habilitada em cada projeto:
    gcloud firestore fields ttls update expiresAt --collection-group=tinyErpExportLocks --enable-ttl
    gcloud firestore fields ttls update expiresAt --collection-group=blingExportLocks --enable-ttl
  • Comentários em português no Bling: feito.
  • Bling ERP: reconciliação periódica e dedupe de redelivery do PubSub #815 na descrição: feito.

Minha revisão

  • Corrigidos: lock preso antes do POST; perdedor que desistia quando o vencedor falhava; recursão com doc desatualizado; espera fixa de 5 s; erro de takeover tratado como lock ocupado; unlock em timeout; reexportação manual como sucesso; janela de lock velho igual ao timeout do Bling; Firestore como dependência dura; docs sem TTL.
  • Cron do Tiny: passa concurrentExportWaitMs: 0. Pedido com lock alheio conta como pulado e fica para a próxima rodada, sem sleep no loop.
  • Não feito: a serialização por recurso no middleware do pubsub. Ela mudaria todos os apps e fica para outra PR.
  • Sem mudança: a nota sobre o package.json. A entrada em exports é necessária.

Verificação

  • turbo run build do firebase, do tiny-erp e do bling-erp, e ESLint dos arquivos alterados: limpos.
  • packages/firebase: 17/17 no vitest (13 testes novos do lock).
  • bling-erp: 84/84.
  • Os fluxos dos dois exports passaram em 13 cenários, com Tiny, Bling e o lock mockados: vencedor, perdedor que recebe o id, falha antes do POST, falha no POST, timeout de concorrência, cron e afterQueue com evento automático. Esses testes não foram commitados porque os apps não têm vitest configurado.

Riscos que ficam

  • Retry mais longo no Tiny: com o eventMaxAgeMs em 10 min, qualquer falha que rejeita no Tiny é retentada por até 10 min em vez de 60 s. Isso vale para fila com 429/5xx e para timeout.
  • Formato da resposta do pedido.incluir.php: o id é lido de registros.registro aceitando objeto ou array. Se não vier, o perdedor volta a buscar o pedido no Tiny.
  • CI vermelho: continua vermelho pela main (node --test tests com diretório no Node 22), não por esta PR.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants