Repository navigation
Conversation
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
left a comment
There was a problem hiding this comment.
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
-
A recursão do Bling relê um pedido desatualizado (
export-order-to-bling.ts:168). Ela passa o mesmoapiDoc, e comoorderId === apiDoc._id(:32) o pedido não é buscado de novo. Os metafieldsbling:id/bling:numerogravados pelo vencedor ficam invisíveis. Em loja comrandom_order_number: true,searchOrderNumberfica undefined (:71-81),findBlingOrderretorna 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. -
O lock vaza no Bling quando a falha acontece antes do POST (
export-order-to-bling.ts:160até:195). Só ocatchdo POST libera o lock. OgetCustomerBlingengole qualquer erro do contato (validação de CPF ou e-mail, 5xx, timeout) e retorna undefined (get-customer-bling.ts:99-105). Aí a:174lançaBling Customer not foundcom o lock preso, e o/formas-pagamentostambém não tem catch. Passo a passo:- O evento de checkout falha no contato e o lock fica preso.
- O evento de pagamento chega segundos depois. Na
mainele tentaria de novo e criaria. Aqui ele perde o lock, espera e sai como "skipped". - Se o lojista corrige o cadastro e reexporta pelo admin em menos de 5 min, o
afterQueuegravasuccess: truesem 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
hasExportLocknum 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/firebasee o próximo candidato já existe (oevents-to-melhor-envio.ts:35-66compra 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
maino 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/blingExportedOrdersleem como "pedido exportado", mas o doc é um lock (sólockedAt, e é apagado quando a criação falha). O app já usa o prefixotinyErp(tinyErpOrders). SugirotinyErpExportLocks/blingExportLocks. Trocar agora é barato; depois do deploy a coleção antiga fica para trás.
🟢 Minors
export-lock.ts:17:ALREADY_EXISTS = 6já existe comoGrpcStatus.ALREADY_EXISTSemfirebase-admin/firestore.- Falta teste do helper. Há precedente com vitest e
vi.mockempackages/firebase/tests/e empackages/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_EXISTSagora 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
lockedAtresolve 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
mainquebra desde a migração para Node 22 (node --test testscom 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
left a comment
There was a problem hiding this comment.
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: ocronSendOrdersexporta 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); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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,searchEndpointfica undefined e o pedido é pulado sem nem consultar o Bling. - Com
disable_order_numberou colisão de número (hasFoundByNumber), a busca pornumero=order.numbertambé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) => { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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() }); |
There was a problem hiding this comment.
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) => { |
There was a problem hiding this comment.
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 ( |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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>
|
O commit 02e3c76 atende as duas revisões, a do leomp12 e a minha logo acima. O que mudou
O A regra de liberação também ficou no helper (
Por pontoRevisão do leomp12
Minha revisão
Verificação
Riscos que ficam
🤖 Generated with Claude Code |
Problema
Cada pedido gera dois eventos
orders-anyStatusSetem poucos segundos: um no checkout e outro no pagamento. Nada impede que as duas execuções rodem ao mesmo tempo, porquemaxInstancesé 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.phpno Tiny,GET /pedidos/vendasno 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
acquireExportLockem@cloudcommerce/firebase/lib/helpers/export-lock: um lock por pedido no Firestore (tinyErpExportLocks,blingExportLocks), pego numa transação.complete, com o id do ERP) ou libera o lock se nada foi criado (fail).maxWaitMs(Tiny 20 s, Bling 60 s), lançaConcurrentExportError. OafterQueuerejeita esse erro também em evento automático, e o evento é reentregue pelofailurePolicy. Por isso oeventMaxAgeMsdo Tiny subiu para 10 min.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: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:ConcurrentExportErrordepois domaxWaitMs;lockedAt;afterQueue. Esses testes não foram commitados porque os apps não têm vitest.turbo run build(tsc) e ESLint limpos. Vitest dofirebase17/17, suíte dobling-erp84/84.🤖 Generated with Claude Code