[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"blog-it-transactional-outbox-postgres-zero-data-loss":3},{"id":4,"title":5,"author":6,"body":7,"cover":856,"description":857,"draft":858,"extension":859,"locale":860,"meta":861,"navigation":263,"path":862,"publishedAt":863,"seo":864,"seoTitle":865,"slug":866,"stem":867,"tags":868,"__hash__":872},"blog\u002Fit\u002Fblog\u002Ftransactional-outbox-postgres-zero-data-loss.md","Outbox transazionale su Postgres: come non perdere un evento nemmeno quando il server crasha","Manuel Tardivo",{"type":8,"value":9,"toc":845},"minimark",[10,24,29,32,35,38,41,45,51,54,58,123,137,141,342,345,349,362,397,705,715,719,731,734,748,751,755,758,778,781,785,797,800,803,807,841],[11,12,13,14,18,19,23],"p",{},"Il ",[15,16,17],"strong",{},"pattern outbox transazionale"," rende atomica rispetto al database l’emissione di eventi (webhook, notifiche, messaggi verso una coda): scrivi l’evento in una tabella ",[20,21,22],"code",{},"outbox"," nella stessa transazione dei dati business e un worker lo pubblica dopo. È uno dei pattern che usiamo in Snowinch quando un sistema non può permettersi di perdere ordini, pagamenti o aggiornamenti di stato, anche con crash, restart o timeout di rete.",[25,26,28],"h2",{"id":27},"il-problema-che-sembra-non-esistere-finché-non-esiste","Il problema che sembra non esistere finché non esiste",[11,30,31],{},"Hai un endpoint che riceve un ordine. Salvi l’ordine sul database, poi invii un messaggio alla coda (o chiami un webhook, o pubblichi un evento). Se il processo crasha tra le due operazioni, l’ordine è salvato ma l’evento non è mai partito. Nessun errore visibile, nessun retry automatico, solo un evento silenziosamente perso.",[11,33,34],{},"L’approccio opposto, emettere l’evento prima di salvare, ha lo stesso problema invertito: l’evento parte ma il salvataggio fallisce.",[11,36,37],{},"Il punto critico è che le due operazioni sono su sistemi separati. Il database ha le transazioni ACID; la coda, il broker, il servizio di notifica esterno non partecipano a quella transazione. Non esiste un two-phase commit praticabile in questo contesto, e anche se esistesse non vorresti usarlo.",[11,39,40],{},"È il pattern che emerge più spesso quando un sistema cresce oltre il prototipo e inizia a gestire dati che hanno conseguenze reali.",[25,42,44],{"id":43},"la-soluzione-scrivere-tutto-sul-database-pubblicare-dopo","La soluzione: scrivere tutto sul database, pubblicare dopo",[11,46,47,48,50],{},"L’outbox transazionale funziona così: invece di emettere l’evento direttamente verso il sistema esterno, lo scrivi in una tabella ",[20,49,22],{}," nella stessa transazione in cui scrivi i dati business. Un worker separato legge la tabella e pubblica gli eventi verso la destinazione finale.",[11,52,53],{},"Le due operazioni diventano atomiche rispetto al database, o entrambe avvengono, o nessuna delle due. Il worker può fallire, andare in timeout, essere riavviato: riprende da dove si era fermato, perché gli eventi non pubblicati sono ancora nella tabella.",[25,55,57],{"id":56},"schema-minimo","Schema minimo",[59,60,65],"pre",{"className":61,"code":62,"language":63,"meta":64,"style":64},"language-sql shiki shiki-themes github-light github-dark","CREATE TABLE outbox (\n  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n  created_at    timestamptz NOT NULL DEFAULT now(),\n  published_at  timestamptz,                  -- null = da pubblicare\n  topic         text NOT NULL,                -- es. 'order.created'\n  payload       jsonb NOT NULL,\n  attempts      int NOT NULL DEFAULT 0,\n  last_error    text\n);\n","sql","",[20,66,67,75,81,87,93,99,105,111,117],{"__ignoreMap":64},[68,69,72],"span",{"class":70,"line":71},"line",1,[68,73,74],{},"CREATE TABLE outbox (\n",[68,76,78],{"class":70,"line":77},2,[68,79,80],{},"  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n",[68,82,84],{"class":70,"line":83},3,[68,85,86],{},"  created_at    timestamptz NOT NULL DEFAULT now(),\n",[68,88,90],{"class":70,"line":89},4,[68,91,92],{},"  published_at  timestamptz,                  -- null = da pubblicare\n",[68,94,96],{"class":70,"line":95},5,[68,97,98],{},"  topic         text NOT NULL,                -- es. 'order.created'\n",[68,100,102],{"class":70,"line":101},6,[68,103,104],{},"  payload       jsonb NOT NULL,\n",[68,106,108],{"class":70,"line":107},7,[68,109,110],{},"  attempts      int NOT NULL DEFAULT 0,\n",[68,112,114],{"class":70,"line":113},8,[68,115,116],{},"  last_error    text\n",[68,118,120],{"class":70,"line":119},9,[68,121,122],{},");\n",[11,124,125,128,129,132,133,136],{},[20,126,127],{},"published_at"," null indica che l’evento è in attesa. Il worker lo marca dopo la pubblicazione confermata. ",[20,130,131],{},"attempts"," e ",[20,134,135],{},"last_error"," servono per il monitoraggio e per la logica di backoff, non sono decorativi.",[25,138,140],{"id":139},"scrittura-nella-transazione-business","Scrittura nella transazione business",[59,142,146],{"className":143,"code":144,"language":145,"meta":64,"style":64},"language-typescript shiki shiki-themes github-light github-dark","\u002F\u002F Tutto in una singola transazione: o entrambe le scritture avvengono, o nessuna\nawait db.transaction(async (trx) => {\n  const order = await trx('orders').insert({\n    user_id: userId,\n    total: orderTotal,\n    status: 'confirmed',\n  }).returning('*');\n\n  await trx('outbox').insert({\n    topic: 'order.created',\n    payload: {\n      order_id: order[0].id,\n      user_id: userId,\n      total: orderTotal,\n    },\n  });\n});\n","typescript",[20,147,148,154,190,223,228,233,244,259,265,283,294,300,312,318,324,330,336],{"__ignoreMap":64},[68,149,150],{"class":70,"line":71},[68,151,153],{"class":152},"sJ8bj","\u002F\u002F Tutto in una singola transazione: o entrambe le scritture avvengono, o nessuna\n",[68,155,156,160,164,168,171,174,177,181,184,187],{"class":70,"line":77},[68,157,159],{"class":158},"szBVR","await",[68,161,163],{"class":162},"sVt8B"," db.",[68,165,167],{"class":166},"sScJk","transaction",[68,169,170],{"class":162},"(",[68,172,173],{"class":158},"async",[68,175,176],{"class":162}," (",[68,178,180],{"class":179},"s4XuR","trx",[68,182,183],{"class":162},") ",[68,185,186],{"class":158},"=>",[68,188,189],{"class":162}," {\n",[68,191,192,195,199,202,205,208,210,214,217,220],{"class":70,"line":83},[68,193,194],{"class":158},"  const",[68,196,198],{"class":197},"sj4cs"," order",[68,200,201],{"class":158}," =",[68,203,204],{"class":158}," await",[68,206,207],{"class":166}," trx",[68,209,170],{"class":162},[68,211,213],{"class":212},"sZZnC","'orders'",[68,215,216],{"class":162},").",[68,218,219],{"class":166},"insert",[68,221,222],{"class":162},"({\n",[68,224,225],{"class":70,"line":89},[68,226,227],{"class":162},"    user_id: userId,\n",[68,229,230],{"class":70,"line":95},[68,231,232],{"class":162},"    total: orderTotal,\n",[68,234,235,238,241],{"class":70,"line":101},[68,236,237],{"class":162},"    status: ",[68,239,240],{"class":212},"'confirmed'",[68,242,243],{"class":162},",\n",[68,245,246,249,252,254,257],{"class":70,"line":107},[68,247,248],{"class":162},"  }).",[68,250,251],{"class":166},"returning",[68,253,170],{"class":162},[68,255,256],{"class":212},"'*'",[68,258,122],{"class":162},[68,260,261],{"class":70,"line":113},[68,262,264],{"emptyLinePlaceholder":263},true,"\n",[68,266,267,270,272,274,277,279,281],{"class":70,"line":119},[68,268,269],{"class":158},"  await",[68,271,207],{"class":166},[68,273,170],{"class":162},[68,275,276],{"class":212},"'outbox'",[68,278,216],{"class":162},[68,280,219],{"class":166},[68,282,222],{"class":162},[68,284,286,289,292],{"class":70,"line":285},10,[68,287,288],{"class":162},"    topic: ",[68,290,291],{"class":212},"'order.created'",[68,293,243],{"class":162},[68,295,297],{"class":70,"line":296},11,[68,298,299],{"class":162},"    payload: {\n",[68,301,303,306,309],{"class":70,"line":302},12,[68,304,305],{"class":162},"      order_id: order[",[68,307,308],{"class":197},"0",[68,310,311],{"class":162},"].id,\n",[68,313,315],{"class":70,"line":314},13,[68,316,317],{"class":162},"      user_id: userId,\n",[68,319,321],{"class":70,"line":320},14,[68,322,323],{"class":162},"      total: orderTotal,\n",[68,325,327],{"class":70,"line":326},15,[68,328,329],{"class":162},"    },\n",[68,331,333],{"class":70,"line":332},16,[68,334,335],{"class":162},"  });\n",[68,337,339],{"class":70,"line":338},17,[68,340,341],{"class":162},"});\n",[11,343,344],{},"Il sistema esterno, coda, webhook, notifica, non viene toccato qui. La transazione è breve e non dipende dalla disponibilità di nessun servizio esterno.",[25,346,348],{"id":347},"il-worker-skip-locked-per-concorrenza-sicura","Il worker: SKIP LOCKED per concorrenza sicura",[11,350,351,352,361],{},"Il worker deve essere idempotente e funzionare correttamente anche con più istanze in parallelo. ",[353,354,358],"a",{"href":355,"rel":356},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fsql-select.html#SQL-FOR-UPDATE-SHARE",[357],"nofollow",[20,359,360],{},"SKIP LOCKED"," è la primitiva Postgres che permette a più worker di pescare righe diverse senza collisioni e senza bloccarsi a vicenda.",[59,363,365],{"className":61,"code":364,"language":63,"meta":64,"style":64},"-- Pesca fino a 10 eventi non ancora pubblicati, saltando quelli già in lavorazione\nSELECT * FROM outbox\nWHERE published_at IS NULL\nORDER BY created_at ASC\nFOR UPDATE SKIP LOCKED\nLIMIT 10;\n",[20,366,367,372,377,382,387,392],{"__ignoreMap":64},[68,368,369],{"class":70,"line":71},[68,370,371],{},"-- Pesca fino a 10 eventi non ancora pubblicati, saltando quelli già in lavorazione\n",[68,373,374],{"class":70,"line":77},[68,375,376],{},"SELECT * FROM outbox\n",[68,378,379],{"class":70,"line":83},[68,380,381],{},"WHERE published_at IS NULL\n",[68,383,384],{"class":70,"line":89},[68,385,386],{},"ORDER BY created_at ASC\n",[68,388,389],{"class":70,"line":95},[68,390,391],{},"FOR UPDATE SKIP LOCKED\n",[68,393,394],{"class":70,"line":101},[68,395,396],{},"LIMIT 10;\n",[59,398,400],{"className":143,"code":399,"language":145,"meta":64,"style":64},"async function processOutbox() {\n  await db.transaction(async (trx) => {\n    const events = await trx.raw(`\n      SELECT * FROM outbox\n      WHERE published_at IS NULL\n      ORDER BY created_at ASC\n      FOR UPDATE SKIP LOCKED\n      LIMIT 10\n    `).then(r => r.rows);\n\n    for (const event of events) {\n      try {\n        await publishToExternalSystem(event.topic, event.payload);\n\n        await trx('outbox')\n          .where({ id: event.id })\n          .update({ published_at: new Date() });\n\n      } catch (err) {\n        \u002F\u002F Non si rilancia: l'evento resta in coda, il worker continua\n        await trx('outbox')\n          .where({ id: event.id })\n          .update({\n            attempts: trx.raw('attempts + 1'),\n            last_error: String(err),\n          });\n      }\n    }\n  });\n}\n",[20,401,402,415,437,460,465,470,475,480,485,506,510,529,536,547,551,564,575,594,599,611,617,630,639,648,664,676,682,688,694,699],{"__ignoreMap":64},[68,403,404,406,409,412],{"class":70,"line":71},[68,405,173],{"class":158},[68,407,408],{"class":158}," function",[68,410,411],{"class":166}," processOutbox",[68,413,414],{"class":162},"() {\n",[68,416,417,419,421,423,425,427,429,431,433,435],{"class":70,"line":77},[68,418,269],{"class":158},[68,420,163],{"class":162},[68,422,167],{"class":166},[68,424,170],{"class":162},[68,426,173],{"class":158},[68,428,176],{"class":162},[68,430,180],{"class":179},[68,432,183],{"class":162},[68,434,186],{"class":158},[68,436,189],{"class":162},[68,438,439,442,445,447,449,452,455,457],{"class":70,"line":83},[68,440,441],{"class":158},"    const",[68,443,444],{"class":197}," events",[68,446,201],{"class":158},[68,448,204],{"class":158},[68,450,451],{"class":162}," trx.",[68,453,454],{"class":166},"raw",[68,456,170],{"class":162},[68,458,459],{"class":212},"`\n",[68,461,462],{"class":70,"line":89},[68,463,464],{"class":212},"      SELECT * FROM outbox\n",[68,466,467],{"class":70,"line":95},[68,468,469],{"class":212},"      WHERE published_at IS NULL\n",[68,471,472],{"class":70,"line":101},[68,473,474],{"class":212},"      ORDER BY created_at ASC\n",[68,476,477],{"class":70,"line":107},[68,478,479],{"class":212},"      FOR UPDATE SKIP LOCKED\n",[68,481,482],{"class":70,"line":113},[68,483,484],{"class":212},"      LIMIT 10\n",[68,486,487,490,492,495,497,500,503],{"class":70,"line":119},[68,488,489],{"class":212},"    `",[68,491,216],{"class":162},[68,493,494],{"class":166},"then",[68,496,170],{"class":162},[68,498,499],{"class":179},"r",[68,501,502],{"class":158}," =>",[68,504,505],{"class":162}," r.rows);\n",[68,507,508],{"class":70,"line":285},[68,509,264],{"emptyLinePlaceholder":263},[68,511,512,515,517,520,523,526],{"class":70,"line":296},[68,513,514],{"class":158},"    for",[68,516,176],{"class":162},[68,518,519],{"class":158},"const",[68,521,522],{"class":197}," event",[68,524,525],{"class":158}," of",[68,527,528],{"class":162}," events) {\n",[68,530,531,534],{"class":70,"line":302},[68,532,533],{"class":158},"      try",[68,535,189],{"class":162},[68,537,538,541,544],{"class":70,"line":314},[68,539,540],{"class":158},"        await",[68,542,543],{"class":166}," publishToExternalSystem",[68,545,546],{"class":162},"(event.topic, event.payload);\n",[68,548,549],{"class":70,"line":320},[68,550,264],{"emptyLinePlaceholder":263},[68,552,553,555,557,559,561],{"class":70,"line":326},[68,554,540],{"class":158},[68,556,207],{"class":166},[68,558,170],{"class":162},[68,560,276],{"class":212},[68,562,563],{"class":162},")\n",[68,565,566,569,572],{"class":70,"line":332},[68,567,568],{"class":162},"          .",[68,570,571],{"class":166},"where",[68,573,574],{"class":162},"({ id: event.id })\n",[68,576,577,579,582,585,588,591],{"class":70,"line":338},[68,578,568],{"class":162},[68,580,581],{"class":166},"update",[68,583,584],{"class":162},"({ published_at: ",[68,586,587],{"class":158},"new",[68,589,590],{"class":166}," Date",[68,592,593],{"class":162},"() });\n",[68,595,597],{"class":70,"line":596},18,[68,598,264],{"emptyLinePlaceholder":263},[68,600,602,605,608],{"class":70,"line":601},19,[68,603,604],{"class":162},"      } ",[68,606,607],{"class":158},"catch",[68,609,610],{"class":162}," (err) {\n",[68,612,614],{"class":70,"line":613},20,[68,615,616],{"class":152},"        \u002F\u002F Non si rilancia: l'evento resta in coda, il worker continua\n",[68,618,620,622,624,626,628],{"class":70,"line":619},21,[68,621,540],{"class":158},[68,623,207],{"class":166},[68,625,170],{"class":162},[68,627,276],{"class":212},[68,629,563],{"class":162},[68,631,633,635,637],{"class":70,"line":632},22,[68,634,568],{"class":162},[68,636,571],{"class":166},[68,638,574],{"class":162},[68,640,642,644,646],{"class":70,"line":641},23,[68,643,568],{"class":162},[68,645,581],{"class":166},[68,647,222],{"class":162},[68,649,651,654,656,658,661],{"class":70,"line":650},24,[68,652,653],{"class":162},"            attempts: trx.",[68,655,454],{"class":166},[68,657,170],{"class":162},[68,659,660],{"class":212},"'attempts + 1'",[68,662,663],{"class":162},"),\n",[68,665,667,670,673],{"class":70,"line":666},25,[68,668,669],{"class":162},"            last_error: ",[68,671,672],{"class":166},"String",[68,674,675],{"class":162},"(err),\n",[68,677,679],{"class":70,"line":678},26,[68,680,681],{"class":162},"          });\n",[68,683,685],{"class":70,"line":684},27,[68,686,687],{"class":162},"      }\n",[68,689,691],{"class":70,"line":690},28,[68,692,693],{"class":162},"    }\n",[68,695,697],{"class":70,"line":696},29,[68,698,335],{"class":162},[68,700,702],{"class":70,"line":701},30,[68,703,704],{"class":162},"}\n",[11,706,707,708,711,712,714],{},"Un dettaglio importante: il ",[20,709,710],{},"FOR UPDATE SKIP LOCKED"," deve stare dentro la stessa transazione che poi aggiorna ",[20,713,127],{},". Se la transazione del worker non va a buon fine, il lock viene rilasciato e l’evento rimane disponibile per il prossimo ciclo. Tieni la chiamata esterna il più breve possibile: stai tenendo un lock di riga finché la destinazione non risponde.",[25,716,718],{"id":717},"frequenza-e-backoff","Frequenza e backoff",[11,720,721,722,725,726,730],{},"Il worker gira su un intervallo, ogni 5 secondi, ogni 30, dipende dalla latenza accettabile per il sistema. Non è un cron in senso stretto: puoi usare un semplice ",[20,723,724],{},"setInterval"," in un processo Node long-running, oppure un job schedulato con la stessa logica descritta nell’articolo sui ",[353,727,729],{"href":728},"\u002Fit\u002Fblog\u002Fgithubcron-cron-job-serverless-gratuiti","cron serverless con GitHub Actions"," se preferisci non tenere un processo sempre attivo.",[11,732,733],{},"Per gli eventi con molti tentativi falliti, aggiungi una condizione di backoff:",[59,735,737],{"className":61,"code":736,"language":63,"meta":64,"style":64},"WHERE published_at IS NULL\n  AND (attempts = 0 OR created_at \u003C now() - (attempts * interval '1 minute'))\n",[20,738,739,743],{"__ignoreMap":64},[68,740,741],{"class":70,"line":71},[68,742,381],{},[68,744,745],{"class":70,"line":77},[68,746,747],{},"  AND (attempts = 0 OR created_at \u003C now() - (attempts * interval '1 minute'))\n",[11,749,750],{},"Questo evita di riprovare continuamente un evento che fallisce per un problema strutturale, un topic inesistente, un payload malformato, un servizio esterno definitivamente down.",[25,752,754],{"id":753},"pulizia","Pulizia",[11,756,757],{},"Gli eventi pubblicati possono accumularsi. Una finestra di retention ragionevole, 7 giorni, 30 giorni, dipende dal dominio, e un job periodico di pulizia:",[59,759,761],{"className":61,"code":760,"language":63,"meta":64,"style":64},"DELETE FROM outbox\nWHERE published_at IS NOT NULL\n  AND published_at \u003C now() - interval '30 days';\n",[20,762,763,768,773],{"__ignoreMap":64},[68,764,765],{"class":70,"line":71},[68,766,767],{},"DELETE FROM outbox\n",[68,769,770],{"class":70,"line":77},[68,771,772],{},"WHERE published_at IS NOT NULL\n",[68,774,775],{"class":70,"line":83},[68,776,777],{},"  AND published_at \u003C now() - interval '30 days';\n",[11,779,780],{},"Non cancellare eventi non pubblicati con criteri temporali: se sono ancora lì, c’è un motivo.",[25,782,784],{"id":783},"cosa-questo-pattern-non-risolve","Cosa questo pattern non risolve",[11,786,787,788,791,792,796],{},"L’outbox garantisce che l’evento venga emesso ",[15,789,790],{},"almeno una volta",", non esattamente una volta. Il sistema di destinazione deve essere in grado di gestire duplicati, o devi implementare idempotenza lato consumer. Su questo tema, l’articolo sui ",[353,793,795],{"href":794},"\u002Fit\u002Fblog\u002Fstripe-webhook-idempotency-duplicates","webhook Stripe e idempotenza"," copre il lato consumer in dettaglio.",[11,798,799],{},"Il pattern non sostituisce una coda dedicata (RabbitMQ, SQS, Kafka) per volumi alti o requisiti di fan-out complessi. È la soluzione giusta per sistemi che già usano Postgres come database primario e non vogliono introdurre infrastruttura aggiuntiva per gestire l’affidabilità degli eventi, che è la maggior parte dei casi fino a un certo volume.",[11,801,802],{},"Non copre il caso in cui il sistema esterno richiede esattamente un delivery senza duplicati e non supporta idempotency key. In quel caso serve una coordinazione più sofisticata.",[25,804,806],{"id":805},"sintesi-operativa","Sintesi operativa",[808,809,810,817,823,829,835,838],"ul",{},[811,812,813,814,816],"li",{},"Scrivi l’evento nella tabella ",[20,815,22],{}," nella stessa transazione dei dati business, mai in due operazioni separate.",[811,818,819,820,822],{},"Il worker usa ",[20,821,710],{}," per la concorrenza sicura tra istanze multiple.",[811,824,825,826,828],{},"Marca ",[20,827,127],{}," solo dopo conferma della pubblicazione, dentro la stessa transazione del lock.",[811,830,831,832,834],{},"Gestisci i fallimenti con ",[20,833,131],{}," e backoff, non rilanciare eccezioni che bloccano il worker.",[811,836,837],{},"Implementa idempotenza lato consumer: l’outbox garantisce at-least-once, non exactly-once.",[811,839,840],{},"Aggiungi un job di pulizia sugli eventi pubblicati, la tabella non deve crescere indefinitamente.",[842,843,844],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":64,"searchDepth":77,"depth":77,"links":846},[847,848,849,850,851,852,853,854,855],{"id":27,"depth":77,"text":28},{"id":43,"depth":77,"text":44},{"id":56,"depth":77,"text":57},{"id":139,"depth":77,"text":140},{"id":347,"depth":77,"text":348},{"id":717,"depth":77,"text":718},{"id":753,"depth":77,"text":754},{"id":783,"depth":77,"text":784},{"id":805,"depth":77,"text":806},"\u002Fblog\u002F37_transactional_outbox_postgres_zero_data_loss_1x.webp","Outbox transazionale su Postgres: scrivi gli eventi con i dati business, pubblica con un worker SKIP LOCKED, consegna at-least-once e idempotenza consumer.",false,"md","it",{},"\u002Fit\u002Fblog\u002Ftransactional-outbox-postgres-zero-data-loss","2026-06-19",{"title":5,"description":857},"Outbox Postgres: zero eventi persi al crash","transactional-outbox-postgres-zero-data-loss","it\u002Fblog\u002Ftransactional-outbox-postgres-zero-data-loss",[869,870,871],"postgres","mvp","automation","AGCkEyUA_qGPcFVgeYcrtyTe5-pVqGKUeDsla0-QOa4"]