[{"data":1,"prerenderedAt":1442},["ShallowReactive",2],{"navigation_docs_en":3,"-en-async-outbox":342,"-en-async-outbox-surround":1437},[4,45,61,96,132,166,196,217,241,265],{"title":5,"icon":6,"path":7,"stem":8,"children":9,"page":44},"Getting Started","i-lucide-rocket","\u002Fen\u002Fgetting-started","en\u002F1.getting-started",[10,15,20,24,29,34,39],{"title":11,"path":12,"stem":13,"icon":14},"Introduction","\u002Fen\u002Fgetting-started\u002Fintroduction","en\u002F1.getting-started\u002F1.introduction","i-lucide-house",{"title":16,"path":17,"stem":18,"icon":19},"Coming from Laravel","\u002Fen\u002Fgetting-started\u002Fcoming-from-laravel","en\u002F1.getting-started\u002F2.coming-from-laravel","i-lucide-arrow-right-left",{"title":21,"path":22,"stem":23,"icon":19},"Coming from Symfony","\u002Fen\u002Fgetting-started\u002Fcoming-from-symfony","en\u002F1.getting-started\u002F3.coming-from-symfony",{"title":25,"path":26,"stem":27,"icon":28},"Installation","\u002Fen\u002Fgetting-started\u002Finstallation","en\u002F1.getting-started\u002F4.installation","i-lucide-download",{"title":30,"path":31,"stem":32,"icon":33},"Your first module","\u002Fen\u002Fgetting-started\u002Ffirst-module","en\u002F1.getting-started\u002F5.first-module","i-lucide-package-plus",{"title":35,"path":36,"stem":37,"icon":38},"Project structure","\u002Fen\u002Fgetting-started\u002Fproject-structure","en\u002F1.getting-started\u002F6.project-structure","i-lucide-folder-tree",{"title":40,"path":41,"stem":42,"icon":43},"The shop sample application","\u002Fen\u002Fgetting-started\u002Fsample-app","en\u002F1.getting-started\u002F7.sample-app","i-lucide-shopping-cart",false,{"title":46,"icon":47,"path":48,"stem":49,"children":50,"page":44},"Reference","i-lucide-book-open","\u002Fen\u002Freference","en\u002F10.reference",[51,56],{"title":52,"path":53,"stem":54,"icon":55},"Configuration","\u002Fen\u002Freference\u002Fconfiguration","en\u002F10.reference\u002F1.configuration","i-lucide-settings",{"title":57,"path":58,"stem":59,"icon":60},"External dependencies","\u002Fen\u002Freference\u002Fdependencies","en\u002F10.reference\u002F2.dependencies","i-lucide-package",{"title":62,"icon":63,"path":64,"stem":65,"children":66,"page":44},"Concepts","i-lucide-lightbulb","\u002Fen\u002Fconcepts","en\u002F2.concepts",[67,72,77,82,86,91],{"title":68,"path":69,"stem":70,"icon":71},"Architecture","\u002Fen\u002Fconcepts\u002Farchitecture","en\u002F2.concepts\u002F1.architecture","i-lucide-layers",{"title":73,"path":74,"stem":75,"icon":76},"Application lifecycle","\u002Fen\u002Fconcepts\u002Fapplication-lifecycle","en\u002F2.concepts\u002F2.application-lifecycle","i-lucide-power",{"title":78,"path":79,"stem":80,"icon":81},"Error model","\u002Fen\u002Fconcepts\u002Ferror-model","en\u002F2.concepts\u002F3.error-model","i-lucide-shield-alert",{"title":52,"path":83,"stem":84,"icon":85},"\u002Fen\u002Fconcepts\u002Fconfiguration","en\u002F2.concepts\u002F4.configuration","i-lucide-settings-2",{"title":87,"path":88,"stem":89,"icon":90},"Codegen pipeline","\u002Fen\u002Fconcepts\u002Fcodegen-pipeline","en\u002F2.concepts\u002F5.codegen-pipeline","i-lucide-file-json",{"title":92,"path":93,"stem":94,"icon":95},"Design patterns","\u002Fen\u002Fconcepts\u002Fdesign-patterns","en\u002F2.concepts\u002F6.design-patterns","i-lucide-puzzle",{"title":97,"icon":98,"path":99,"stem":100,"children":101,"page":44},"HTTP & Routing","i-lucide-globe","\u002Fen\u002Fhttp","en\u002F3.http",[102,107,112,117,122,127],{"title":103,"path":104,"stem":105,"icon":106},"The server core","\u002Fen\u002Fhttp\u002Fserver","en\u002F3.http\u002F1.server","i-lucide-server",{"title":108,"path":109,"stem":110,"icon":111},"The Fiber engine","\u002Fen\u002Fhttp\u002Ffiber","en\u002F3.http\u002F2.fiber","i-lucide-zap",{"title":113,"path":114,"stem":115,"icon":116},"The chi engine","\u002Fen\u002Fhttp\u002Fchi","en\u002F3.http\u002F3.chi","i-lucide-route",{"title":118,"path":119,"stem":120,"icon":121},"Error responses","\u002Fen\u002Fhttp\u002Ferror-responses","en\u002F3.http\u002F4.error-responses","i-lucide-octagon-alert",{"title":123,"path":124,"stem":125,"icon":126},"Validation","\u002Fen\u002Fhttp\u002Fvalidation","en\u002F3.http\u002F5.validation","i-lucide-badge-check",{"title":128,"path":129,"stem":130,"icon":131},"Pagination","\u002Fen\u002Fhttp\u002Fpagination","en\u002F3.http\u002F6.pagination","i-lucide-list-ordered",{"title":133,"icon":134,"path":135,"stem":136,"children":137,"page":44},"Database","i-lucide-database","\u002Fen\u002Fdatabase","en\u002F4.database",[138,142,147,151,156,161],{"title":139,"path":140,"stem":141,"icon":134},"Overview","\u002Fen\u002Fdatabase\u002Foverview","en\u002F4.database\u002F1.overview",{"title":143,"path":144,"stem":145,"icon":146},"pgx","\u002Fen\u002Fdatabase\u002Fpgx","en\u002F4.database\u002F2.pgx","i-lucide-plug",{"title":148,"path":149,"stem":150,"icon":71},"bun","\u002Fen\u002Fdatabase\u002Fbun","en\u002F4.database\u002F3.bun",{"title":152,"path":153,"stem":154,"icon":155},"Transactions","\u002Fen\u002Fdatabase\u002Ftransactions","en\u002F4.database\u002F4.transactions","i-lucide-git-merge",{"title":157,"path":158,"stem":159,"icon":160},"Migrations","\u002Fen\u002Fdatabase\u002Fmigrations","en\u002F4.database\u002F5.migrations","i-lucide-file-stack",{"title":162,"path":163,"stem":164,"icon":165},"Seeders","\u002Fen\u002Fdatabase\u002Fseeding","en\u002F4.database\u002F6.seeding","i-lucide-sprout",{"title":167,"icon":168,"path":169,"stem":170,"children":171,"page":44},"Async & Background","i-lucide-workflow","\u002Fen\u002Fasync","en\u002F5.async",[172,176,181,186,191],{"title":173,"path":174,"stem":175,"icon":131},"Queue","\u002Fen\u002Fasync\u002Fqueue","en\u002F5.async\u002F1.queue",{"title":177,"path":178,"stem":179,"icon":180},"Events","\u002Fen\u002Fasync\u002Fevents","en\u002F5.async\u002F2.events","i-lucide-radio",{"title":182,"path":183,"stem":184,"icon":185},"Outbox","\u002Fen\u002Fasync\u002Foutbox","en\u002F5.async\u002F3.outbox","i-lucide-inbox",{"title":187,"path":188,"stem":189,"icon":190},"Scheduling","\u002Fen\u002Fasync\u002Fscheduler","en\u002F5.async\u002F4.scheduler","i-lucide-calendar-clock",{"title":192,"path":193,"stem":194,"icon":195},"Worker","\u002Fen\u002Fasync\u002Fworker","en\u002F5.async\u002F5.worker","i-lucide-cog",{"title":197,"icon":198,"path":199,"stem":200,"children":201,"page":44},"Security","i-lucide-shield-check","\u002Fen\u002Fsecurity","en\u002F6.security",[202,207,212],{"title":203,"path":204,"stem":205,"icon":206},"Authentication","\u002Fen\u002Fsecurity\u002Fauthentication","en\u002F6.security\u002F1.authentication","i-lucide-key-round",{"title":208,"path":209,"stem":210,"icon":211},"RBAC","\u002Fen\u002Fsecurity\u002Frbac","en\u002F6.security\u002F2.rbac","i-lucide-users",{"title":213,"path":214,"stem":215,"icon":216},"Policies","\u002Fen\u002Fsecurity\u002Fpolicies","en\u002F6.security\u002F3.policies","i-lucide-gavel",{"title":218,"icon":219,"path":220,"stem":221,"children":222,"page":44},"Observability","i-lucide-activity","\u002Fen\u002Fobservability","en\u002F7.observability",[223,226,231,236],{"title":139,"path":224,"stem":225,"icon":219},"\u002Fen\u002Fobservability\u002Foverview","en\u002F7.observability\u002F1.overview",{"title":227,"path":228,"stem":229,"icon":230},"Logging","\u002Fen\u002Fobservability\u002Flogging","en\u002F7.observability\u002F2.logging","i-lucide-scroll-text",{"title":232,"path":233,"stem":234,"icon":235},"OpenTelemetry","\u002Fen\u002Fobservability\u002Fopentelemetry","en\u002F7.observability\u002F3.opentelemetry","i-lucide-radar",{"title":237,"path":238,"stem":239,"icon":240},"Sentry","\u002Fen\u002Fobservability\u002Fsentry","en\u002F7.observability\u002F4.sentry","i-lucide-bug",{"title":242,"icon":243,"path":244,"stem":245,"children":246,"page":44},"Storage & Mail","i-lucide-hard-drive","\u002Fen\u002Fstorage-mail","en\u002F8.storage-mail",[247,251,256,261],{"title":248,"path":249,"stem":250,"icon":243},"Storage","\u002Fen\u002Fstorage-mail\u002Fstorage","en\u002F8.storage-mail\u002F1.storage",{"title":252,"path":253,"stem":254,"icon":255},"Email","\u002Fen\u002Fstorage-mail\u002Fmail","en\u002F8.storage-mail\u002F2.mail","i-lucide-mail",{"title":257,"path":258,"stem":259,"icon":260},"Notifications","\u002Fen\u002Fstorage-mail\u002Fnotifications","en\u002F8.storage-mail\u002F3.notifications","i-lucide-bell",{"title":262,"path":263,"stem":264,"icon":180},"Realtime","\u002Fen\u002Fstorage-mail\u002Frealtime","en\u002F8.storage-mail\u002F4.realtime",{"title":266,"icon":267,"path":268,"stem":269,"children":270,"page":44},"CLI Reference","i-lucide-terminal","\u002Fen\u002Fcli","en\u002F9.cli",[271,274,279,284,289,294,299,303,308,313,318,323,328,333,337],{"title":139,"path":272,"stem":273,"icon":267},"\u002Fen\u002Fcli\u002Foverview","en\u002F9.cli\u002F1.overview",{"title":275,"path":276,"stem":277,"icon":278},"add db","\u002Fen\u002Fcli\u002Fadd-db","en\u002F9.cli\u002F10.add-db","i-lucide-database-zap",{"title":280,"path":281,"stem":282,"icon":283},"add compose \u002F add docker","\u002Fen\u002Fcli\u002Fadd-compose","en\u002F9.cli\u002F11.add-compose","i-lucide-container",{"title":285,"path":286,"stem":287,"icon":288},"add mail","\u002Fen\u002Fcli\u002Fadd-mail","en\u002F9.cli\u002F12.add-mail","i-lucide-mail-plus",{"title":290,"path":291,"stem":292,"icon":293},"add notification","\u002Fen\u002Fcli\u002Fadd-notification","en\u002F9.cli\u002F13.add-notification","i-lucide-bell-plus",{"title":295,"path":296,"stem":297,"icon":298},"add realtime","\u002Fen\u002Fcli\u002Fadd-realtime","en\u002F9.cli\u002F14.add-realtime","i-lucide-radio-tower",{"title":300,"path":301,"stem":302,"icon":165},"add seeder","\u002Fen\u002Fcli\u002Fadd-seeder","en\u002F9.cli\u002F15.add-seeder",{"title":304,"path":305,"stem":306,"icon":307},"new project","\u002Fen\u002Fcli\u002Fnew-project","en\u002F9.cli\u002F2.new-project","i-lucide-folder-plus",{"title":309,"path":310,"stem":311,"icon":312},"new module","\u002Fen\u002Fcli\u002Fnew-module","en\u002F9.cli\u002F3.new-module","i-lucide-blocks",{"title":314,"path":315,"stem":316,"icon":317},"add surface","\u002Fen\u002Fcli\u002Fadd-surface","en\u002F9.cli\u002F4.add-surface","i-lucide-layers-2",{"title":319,"path":320,"stem":321,"icon":322},"add core","\u002Fen\u002Fcli\u002Fadd-core","en\u002F9.cli\u002F5.add-core","i-lucide-box",{"title":324,"path":325,"stem":326,"icon":327},"add handler","\u002Fen\u002Fcli\u002Fadd-handler","en\u002F9.cli\u002F6.add-handler","i-lucide-webhook",{"title":329,"path":330,"stem":331,"icon":332},"new migration","\u002Fen\u002Fcli\u002Fnew-migration","en\u002F9.cli\u002F7.new-migration","i-lucide-file-plus",{"title":334,"path":335,"stem":336,"icon":195},"worker generators","\u002Fen\u002Fcli\u002Fworker-generators","en\u002F9.cli\u002F8.worker-generators",{"title":338,"path":339,"stem":340,"icon":341},"upgrade templates","\u002Fen\u002Fcli\u002Fupgrade-templates","en\u002F9.cli\u002F9.upgrade-templates","i-lucide-refresh-cw",{"id":343,"title":182,"body":344,"description":1430,"extension":1431,"links":1432,"meta":1433,"navigation":1434,"path":183,"seo":1435,"stem":184,"__hash__":1436},"docs_en\u002Fen\u002F5.async\u002F3.outbox.md",{"type":345,"value":346,"toc":1419},"minimark",[347,377,395,400,415,437,444,448,533,541,545,617,648,659,715,726,837,841,965,991,994,1022,1027,1030,1051,1058,1062,1082,1257,1276,1279,1294,1388,1392,1415],[348,349,354],"pre",{"className":350,"code":351,"language":352,"meta":353,"style":353},"language-go shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","import \"github.com\u002Fgp-system\u002Fgpsystem\u002Fevents\u002Foutbox\"\n","go","",[355,356,357],"code",{"__ignoreMap":353},[358,359,362,366,370,374],"span",{"class":360,"line":361},"line",1,[358,363,365],{"class":364},"s7zQu","import",[358,367,369],{"class":368},"sMK4o"," \"",[358,371,373],{"class":372},"sBMFI","github.com\u002Fgp-system\u002Fgpsystem\u002Fevents\u002Foutbox",[358,375,376],{"class":368},"\"\n",[378,379,380,383,384,389,390,394],"p",{},[355,381,382],{},"events\u002Foutbox"," is the delivery guarantee behind ",[385,386,388],"a",{"href":387},"\u002Fen\u002Fasync\u002Fevents#dispatch","dispatch",": instead of handing the event straight to Redis, it writes it into a Postgres table ",[391,392,393],"strong",{},"in the caller's transaction",", and a relay forwards committed rows to the queue. In a generated project this is the default dispatcher: not an optional extra, but the starting point.",[396,397,399],"h2",{"id":398},"the-dual-write-problem","The dual-write problem",[378,401,402,403,406,407,410,411,414],{},"Take the shop's ",[355,404,405],{},"PlaceOrder",": the order insert and the ",[355,408,409],{},"orderPlaced"," event must happen ",[391,412,413],{},"together or not at all",". But two writes into two systems (Postgres + Redis) cannot be atomic:",[416,417,418,426],"ul",{},[419,420,421,422,425],"li",{},"Enqueue the event ",[391,423,424],{},"before"," the commit, and if the transaction rolls back, the worker sends a confirmation for an order that never happened.",[419,427,428,429,432,433,436],{},"Enqueue it ",[391,430,431],{},"after"," the commit, and if the process dies in between (a deploy, an OOM kill, a node failure), the order exists but the event is ",[391,434,435],{},"lost",": the customer never gets an e-mail, and nothing tells you something went wrong.",[378,438,439,440,443],{},"An after-commit hook alone doesn't close this gap: it can skip a rollback, but a crash between the commit and the hook running still loses the event. In the kit the pattern comes stock: the outbox writes the event ",[391,441,442],{},"into the same database, in the same transaction"," as the business change, one commit that persists everything or nothing.",[396,445,447],{"id":446},"how-it-works","How it works",[449,450,452,457,474,478,481,485,496,500,518,522],"steps",{"level":451},"4",[453,454,456],"h4",{"id":455},"the-dispatch-writes-inside-the-transaction","The dispatch writes inside the transaction",[378,458,459,461,462,465,466,469,470,473],{},[355,460,405],{}," calls ",[355,463,464],{},"Dispatch"," inside ",[355,467,468],{},"WithinTransaction","; the outbox dispatcher inserts a row into the ",[355,471,472],{},"outbox_events"," table, through the same transaction as the order and stock writes.",[453,475,477],{"id":476},"one-commit","One commit",[378,479,480],{},"The transaction commits: order + stock decrement + outbox row, atomically. On rollback the event row disappears too: no phantom events.",[453,482,484],{"id":483},"the-relay-claims-committed-rows","The relay claims committed rows",[378,486,487,488,491,492,495],{},"The relay running inside the worker polls unpublished rows every ",[355,489,490],{},"OUTBOX_POLL_INTERVAL",", with ",[355,493,494],{},"FOR UPDATE SKIP LOCKED",", so N worker replicas never contend on the same rows.",[453,497,499],{"id":498},"enqueue-with-a-deterministic-taskid","Enqueue with a deterministic TaskID",[378,501,502,503,506,507,510,511,517],{},"It enqueues each row to Redis, using the outbox ",[355,504,505],{},"event_id"," as the asynq ",[355,508,509],{},"TaskID",". If an earlier run died after the enqueue but before marking the row, the repeated enqueue is an ",[385,512,514],{"href":513},"\u002Fen\u002Fasync\u002Fqueue#client",[355,515,516],{},"ErrDuplicate",": the relay treats it as success and just catches up on the marking.",[453,519,521],{"id":520},"mark-and-clean-up","Mark and clean up",[378,523,524,525,528,529,532],{},"Submitted rows get a ",[355,526,527],{},"published_at",", and the claiming transaction commits. A cleanup loop deletes published rows after ",[355,530,531],{},"OUTBOX_RETENTION",".",[378,534,535,536,540],{},"From here asynq takes over: fan-out to the listeners, retries, archiving; see ",[385,537,539],{"href":538},"\u002Fen\u002Fasync\u002Fevents#fan-out-how-one-event-becomes-n-tasks","events",". The chain is at-least-once all the way, which is why listener idempotency remains a precondition.",[396,542,544],{"id":543},"store-and-dispatcher","Store and dispatcher",[348,546,548],{"className":350,"code":547,"language":352,"meta":353,"style":353},"store := outbox.NewStore(pg.NewDB(pool)) \u002F\u002F accepts a pg.DBTX\ndispatcher := outbox.NewDispatcher(store) \u002F\u002F an events.Dispatcher\n",[355,549,550,591],{"__ignoreMap":353},[358,551,552,556,559,562,564,568,571,574,576,579,581,584,587],{"class":360,"line":361},[358,553,555],{"class":554},"sTEyZ","store ",[358,557,558],{"class":368},":=",[358,560,561],{"class":554}," outbox",[358,563,532],{"class":368},[358,565,567],{"class":566},"s2Zo4","NewStore",[358,569,570],{"class":368},"(",[358,572,573],{"class":554},"pg",[358,575,532],{"class":368},[358,577,578],{"class":566},"NewDB",[358,580,570],{"class":368},[358,582,583],{"class":554},"pool",[358,585,586],{"class":368},"))",[358,588,590],{"class":589},"sHwdD"," \u002F\u002F accepts a pg.DBTX\n",[358,592,594,597,599,601,603,606,608,611,614],{"class":360,"line":593},2,[358,595,596],{"class":554},"dispatcher ",[358,598,558],{"class":368},[358,600,561],{"class":554},[358,602,532],{"class":368},[358,604,605],{"class":566},"NewDispatcher",[358,607,570],{"class":368},[358,609,610],{"class":554},"store",[358,612,613],{"class":368},")",[358,615,616],{"class":589}," \u002F\u002F an events.Dispatcher\n",[378,618,619,620,623,624,627,628,630,631,636,637,640,641,643,644,647],{},"The ",[355,621,622],{},"Store"," is a single method (",[355,625,626],{},"Insert(ctx, env)","), and ",[355,629,567],{}," builds on the kit's pgx executor: because ",[385,632,633],{"href":144},[355,634,635],{},"pg.DB"," joins the transaction carried by the ",[355,638,639],{},"ctx",", an insert made inside ",[355,642,468],{}," ",[391,645,646],{},"automatically runs in the caller's transaction",": the service needs to know nothing about the outbox. Called outside a transaction it is a plain insert; the event is still delivered, the atomicity guarantee just doesn't apply.",[378,649,650,651,654,655,658],{},"Projects ",[385,652,653],{"href":149},"using bun"," wire the ",[355,656,657],{},"events\u002Foutbox\u002Fbunx"," adapter, which joins the bun transaction instead:",[348,660,662],{"className":350,"code":661,"language":352,"meta":353,"style":353},"import outboxbunx \"github.com\u002Fgp-system\u002Fgpsystem\u002Fevents\u002Foutbox\u002Fbunx\"\n\ndispatcher := outbox.NewDispatcher(outboxbunx.NewStore(bunDB))\n",[355,663,664,679,685],{"__ignoreMap":353},[358,665,666,668,671,674,677],{"class":360,"line":361},[358,667,365],{"class":364},[358,669,670],{"class":554}," outboxbunx ",[358,672,673],{"class":368},"\"",[358,675,676],{"class":372},"github.com\u002Fgp-system\u002Fgpsystem\u002Fevents\u002Foutbox\u002Fbunx",[358,678,376],{"class":368},[358,680,681],{"class":360,"line":593},[358,682,684],{"emptyLinePlaceholder":683},true,"\n",[358,686,688,690,692,694,696,698,700,703,705,707,709,712],{"class":360,"line":687},3,[358,689,596],{"class":554},[358,691,558],{"class":368},[358,693,561],{"class":554},[358,695,532],{"class":368},[358,697,605],{"class":566},[358,699,570],{"class":368},[358,701,702],{"class":554},"outboxbunx",[358,704,532],{"class":368},[358,706,567],{"class":566},[358,708,570],{"class":368},[358,710,711],{"class":554},"bunDB",[358,713,714],{"class":368},"))\n",[378,716,717,718,721,722,725],{},"In the generated ",[355,719,720],{},"main.go","s this wiring is ready-made (the first ",[355,723,724],{},"add event"," inserts it); in the shop's HTTP entry point it looks like this:",[348,727,730],{"className":350,"code":728,"filename":729,"language":352,"meta":353,"style":353},"deps := shop.Dependencies{\n    DB:         pg.NewDB(pool),\n    Transactor: pg.NewTransactor(pool),\n    Dispatcher: outbox.NewDispatcher(outbox.NewStore(pg.NewDB(pool))),\n}\n","cmd\u002Fshop\u002Fmain.go (excerpt)",[355,731,732,750,772,793,831],{"__ignoreMap":353},[358,733,734,737,739,742,744,747],{"class":360,"line":361},[358,735,736],{"class":554},"deps ",[358,738,558],{"class":368},[358,740,741],{"class":372}," shop",[358,743,532],{"class":368},[358,745,746],{"class":372},"Dependencies",[358,748,749],{"class":368},"{\n",[358,751,752,755,758,761,763,765,767,769],{"class":360,"line":593},[358,753,754],{"class":554},"    DB",[358,756,757],{"class":368},":",[358,759,760],{"class":554},"         pg",[358,762,532],{"class":368},[358,764,578],{"class":566},[358,766,570],{"class":368},[358,768,583],{"class":554},[358,770,771],{"class":368},"),\n",[358,773,774,777,779,782,784,787,789,791],{"class":360,"line":687},[358,775,776],{"class":554},"    Transactor",[358,778,757],{"class":368},[358,780,781],{"class":554}," pg",[358,783,532],{"class":368},[358,785,786],{"class":566},"NewTransactor",[358,788,570],{"class":368},[358,790,583],{"class":554},[358,792,771],{"class":368},[358,794,796,799,801,803,805,807,809,812,814,816,818,820,822,824,826,828],{"class":360,"line":795},4,[358,797,798],{"class":554},"    Dispatcher",[358,800,757],{"class":368},[358,802,561],{"class":554},[358,804,532],{"class":368},[358,806,605],{"class":566},[358,808,570],{"class":368},[358,810,811],{"class":554},"outbox",[358,813,532],{"class":368},[358,815,567],{"class":566},[358,817,570],{"class":368},[358,819,573],{"class":554},[358,821,532],{"class":368},[358,823,578],{"class":566},[358,825,570],{"class":368},[358,827,583],{"class":554},[358,829,830],{"class":368},"))),\n",[358,832,834],{"class":360,"line":833},5,[358,835,836],{"class":368},"}\n",[396,838,840],{"id":839},"the-relay","The relay",[348,842,844],{"className":350,"code":843,"language":352,"meta":353,"style":353},"relay := outbox.MustNewRelay(ctx, cfg.Outbox, pool, cfg.Worker.Redis)\n\nerr := worker.Run(ctx, cfg.Worker, register,\n    worker.WithOutboxRelay(relay), \u002F\u002F the worker runs it and shuts it down\n    \u002F\u002F ...\n)\n",[355,845,846,895,899,934,955,960],{"__ignoreMap":353},[358,847,848,851,853,855,857,860,862,864,867,870,872,874,876,879,881,883,885,887,889,892],{"class":360,"line":361},[358,849,850],{"class":554},"relay ",[358,852,558],{"class":368},[358,854,561],{"class":554},[358,856,532],{"class":368},[358,858,859],{"class":566},"MustNewRelay",[358,861,570],{"class":368},[358,863,639],{"class":554},[358,865,866],{"class":368},",",[358,868,869],{"class":554}," cfg",[358,871,532],{"class":368},[358,873,182],{"class":554},[358,875,866],{"class":368},[358,877,878],{"class":554}," pool",[358,880,866],{"class":368},[358,882,869],{"class":554},[358,884,532],{"class":368},[358,886,192],{"class":554},[358,888,532],{"class":368},[358,890,891],{"class":554},"Redis",[358,893,894],{"class":368},")\n",[358,896,897],{"class":360,"line":593},[358,898,684],{"emptyLinePlaceholder":683},[358,900,901,904,906,909,911,914,916,918,920,922,924,926,928,931],{"class":360,"line":687},[358,902,903],{"class":554},"err ",[358,905,558],{"class":368},[358,907,908],{"class":554}," worker",[358,910,532],{"class":368},[358,912,913],{"class":566},"Run",[358,915,570],{"class":368},[358,917,639],{"class":554},[358,919,866],{"class":368},[358,921,869],{"class":554},[358,923,532],{"class":368},[358,925,192],{"class":554},[358,927,866],{"class":368},[358,929,930],{"class":554}," register",[358,932,933],{"class":368},",\n",[358,935,936,939,941,944,946,949,952],{"class":360,"line":795},[358,937,938],{"class":554},"    worker",[358,940,532],{"class":368},[358,942,943],{"class":566},"WithOutboxRelay",[358,945,570],{"class":368},[358,947,948],{"class":554},"relay",[358,950,951],{"class":368},"),",[358,953,954],{"class":589}," \u002F\u002F the worker runs it and shuts it down\n",[358,956,957],{"class":360,"line":833},[358,958,959],{"class":589},"    \u002F\u002F ...\n",[358,961,963],{"class":360,"line":962},6,[358,964,894],{"class":368},[378,966,967,970,971,973,974,979,980,983,984,990],{},[355,968,969],{},"NewRelay"," \u002F ",[355,972,859],{}," opens its own ",[385,975,976],{"href":513},[355,977,978],{},"queue.Client"," (verified with a PING), and ",[355,981,982],{},"Run(ctx)"," polls until the context is cancelled. You typically call neither by hand: the generated worker passes it via ",[385,985,987],{"href":986},"\u002Fen\u002Fasync\u002Fworker#options",[355,988,989],{},"worker.WithOutboxRelay",", and the worker lifecycle starts and stops it.",[378,992,993],{},"What's worth knowing about the poll loop:",[416,995,996,1010,1016],{},[419,997,998,1001,1002,1005,1006,1009],{},[391,999,1000],{},"A full batch triggers an immediate re-poll."," When a round processed ",[355,1003,1004],{},"OUTBOX_BATCH_SIZE"," rows, it doesn't sleep but polls again right away: bursts drain quickly, and ",[355,1007,1008],{},"POLL_INTERVAL"," is only the idle latency.",[419,1011,1012,1015],{},[391,1013,1014],{},"Errors back off exponentially."," A failing round (say, a Redis outage) retries with a doubling wait, capped at 30 seconds, and the first successful round returns to the normal cadence.",[419,1017,1018,1021],{},[391,1019,1020],{},"Partial success is not lost."," If an enqueue fails mid-batch, the rows already submitted get marked; the error doesn't replay the whole batch.",[1023,1024,1026],"h3",{"id":1025},"scalability-n-replicas-no-double-delivery","Scalability: N replicas, no double delivery",[378,1028,1029],{},"The relay runs in every worker replica, with no coordination. Two mechanisms make that safe:",[1031,1032,1033,1038],"ol",{},[419,1034,1035,1037],{},[355,1036,494],{}," means two replicas never claim the same row;",[419,1039,1040,1041,1043,1044,1046,1047,1050],{},"the deterministic ",[355,1042,509],{}," (= ",[355,1045,505],{},") means a task submitted twice inside the crash window deduplicates in Redis: ",[355,1048,1049],{},"OUTBOX_TASK_RETENTION"," is the time window the dedupe lives for.",[378,1052,1053,1054,532],{},"That's why you can scale the worker horizontally without a second thought; see ",[385,1055,1057],{"href":1056},"\u002Fen\u002Fasync\u002Fworker#scaling","worker → scaling",[396,1059,1061],{"id":1060},"the-table-and-the-migration","The table and the migration",[378,1063,1064,1066,1067,1069,1070,1073,1074,1077,1078,1081],{},[355,1065,472],{}," arrives as a goose migration: ",[355,1068,304],{}," writes it to ",[355,1071,1072],{},"migrations\u002F20200101000100_outbox.sql"," (",[355,1075,1076],{},"add worker"," backfills it into older projects); the schema's source of truth in the package is ",[355,1079,1080],{},"outbox.MigrationSQL",". The essence:",[348,1083,1087],{"className":1084,"code":1085,"language":1086,"meta":353,"style":353},"language-sql shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","CREATE TABLE outbox_events (\n    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    event_id     uuid        NOT NULL UNIQUE,\n    event_name   text        NOT NULL,\n    payload      jsonb       NOT NULL,\n    metadata     jsonb       NOT NULL DEFAULT '{}'::jsonb,\n    created_at   timestamptz NOT NULL DEFAULT now(),\n    published_at timestamptz\n);\nCREATE INDEX outbox_events_unpublished_idx ON outbox_events (id) WHERE published_at IS NULL;\n","sql",[355,1088,1089,1104,1130,1143,1156,1165,1188,1210,1219,1225],{"__ignoreMap":353},[358,1090,1091,1095,1098,1101],{"class":360,"line":361},[358,1092,1094],{"class":1093},"sbssI","CREATE",[358,1096,1097],{"class":1093}," TABLE",[358,1099,1100],{"class":566}," outbox_events",[358,1102,1103],{"class":554}," (\n",[358,1105,1106,1109,1113,1116,1119,1122,1125,1128],{"class":360,"line":593},[358,1107,1108],{"class":554},"    id           ",[358,1110,1112],{"class":1111},"spNyl","bigint",[358,1114,1115],{"class":1093}," GENERATED",[358,1117,1118],{"class":1093}," ALWAYS",[358,1120,1121],{"class":1093}," AS",[358,1123,1124],{"class":1093}," IDENTITY",[358,1126,1127],{"class":1111}," PRIMARY KEY",[358,1129,933],{"class":554},[358,1131,1132,1135,1138,1141],{"class":360,"line":687},[358,1133,1134],{"class":554},"    event_id     uuid        ",[358,1136,1137],{"class":1093},"NOT NULL",[358,1139,1140],{"class":1093}," UNIQUE",[358,1142,933],{"class":554},[358,1144,1145,1148,1151,1154],{"class":360,"line":795},[358,1146,1147],{"class":554},"    event_name   ",[358,1149,1150],{"class":1111},"text",[358,1152,1153],{"class":1093},"        NOT NULL",[358,1155,933],{"class":554},[358,1157,1158,1161,1163],{"class":360,"line":833},[358,1159,1160],{"class":554},"    payload      jsonb       ",[358,1162,1137],{"class":1093},[358,1164,933],{"class":554},[358,1166,1167,1170,1172,1175,1178,1182,1185],{"class":360,"line":962},[358,1168,1169],{"class":554},"    metadata     jsonb       ",[358,1171,1137],{"class":1093},[358,1173,1174],{"class":1111}," DEFAULT",[358,1176,1177],{"class":368}," '",[358,1179,1181],{"class":1180},"sfazB","{}",[358,1183,1184],{"class":368},"'",[358,1186,1187],{"class":554},"::jsonb,\n",[358,1189,1191,1194,1197,1200,1202,1205,1208],{"class":360,"line":1190},7,[358,1192,1193],{"class":554},"    created_at   ",[358,1195,1196],{"class":1111},"timestamptz",[358,1198,1199],{"class":1093}," NOT NULL",[358,1201,1174],{"class":1111},[358,1203,1204],{"class":1093}," now",[358,1206,1207],{"class":368},"()",[358,1209,933],{"class":554},[358,1211,1213,1216],{"class":360,"line":1212},8,[358,1214,1215],{"class":554},"    published_at ",[358,1217,1218],{"class":1111},"timestamptz\n",[358,1220,1222],{"class":360,"line":1221},9,[358,1223,1224],{"class":554},");\n",[358,1226,1228,1230,1233,1236,1239,1242,1245,1248,1251,1254],{"class":360,"line":1227},10,[358,1229,1094],{"class":1093},[358,1231,1232],{"class":1093}," INDEX",[358,1234,1235],{"class":566}," outbox_events_unpublished_idx",[358,1237,1238],{"class":1093}," ON",[358,1240,1241],{"class":554}," outbox_events (id) ",[358,1243,1244],{"class":1093},"WHERE",[358,1246,1247],{"class":554}," published_at ",[358,1249,1250],{"class":1093},"IS",[358,1252,1253],{"class":1093}," NULL",[358,1255,1256],{"class":554},";\n",[378,1258,1259,1262,1263,1267,1268,1271,1272,1275],{},[355,1260,1261],{},"metadata"," carries the ",[385,1264,1266],{"href":1265},"\u002Fen\u002Fasync\u002Fqueue#the-envelope","envelope's"," trace context, so the trace stays continuous through the outbox; the partial index keeps the poll fast even when the table grows large over time. Run it before starting the worker: ",[355,1269,1270],{},"go run .\u002Fcmd\u002Fmigrate up"," (see ",[385,1273,1274],{"href":158},"migrations",").",[396,1277,52],{"id":1278},"configuration",[378,1280,1281,1282,1285,1286,1289,1290,1293],{},"The project config composes ",[355,1283,1284],{},"outbox.Config"," under the ",[355,1287,1288],{},"OUTBOX_"," prefix (ready-made in the generated ",[355,1291,1292],{},"config.go","):",[1295,1296,1297,1313],"table",{},[1298,1299,1300],"thead",{},[1301,1302,1303,1307,1310],"tr",{},[1304,1305,1306],"th",{},"Variable",[1304,1308,1309],{},"Default",[1304,1311,1312],{},"Meaning",[1314,1315,1316,1331,1345,1359,1374],"tbody",{},[1301,1317,1318,1323,1328],{},[1319,1320,1321],"td",{},[355,1322,490],{},[1319,1324,1325],{},[355,1326,1327],{},"1s",[1319,1329,1330],{},"how long the relay sleeps after a less-than-full batch",[1301,1332,1333,1337,1342],{},[1319,1334,1335],{},[355,1336,1004],{},[1319,1338,1339],{},[355,1340,1341],{},"100",[1319,1343,1344],{},"one poll claims and publishes at most this many rows",[1301,1346,1347,1351,1356],{},[1319,1348,1349],{},[355,1350,531],{},[1319,1352,1353],{},[355,1354,1355],{},"168h",[1319,1357,1358],{},"published rows are kept this long before deletion",[1301,1360,1361,1366,1371],{},[1319,1362,1363],{},[355,1364,1365],{},"OUTBOX_CLEANUP_INTERVAL",[1319,1367,1368],{},[355,1369,1370],{},"1h",[1319,1372,1373],{},"how often published rows are purged",[1301,1375,1376,1380,1385],{},[1319,1377,1378],{},[355,1379,1049],{},[1319,1381,1382],{},[355,1383,1384],{},"24h",[1319,1386,1387],{},"the asynq retention on published tasks (also the TaskID dedupe window)",[396,1389,1391],{"id":1390},"patterns-used","Patterns used",[378,1393,1394,1395,1398,1399,1402,1403,1405,1406,1408,1409,532],{},"This page is the full write-up of the ",[385,1396,92],{"href":1397},"\u002Fen\u002Fconcepts\u002Fdesign-patterns#transactional-outbox"," catalog's ",[391,1400,1401],{},"Transactional outbox"," entry: the ",[355,1404,494],{}," batch-claiming and the dual-write problem, with code and the shop ",[355,1407,405],{}," example, live here. Canonical external description: ",[385,1410,1414],{"href":1411,"rel":1412},"https:\u002F\u002Fmicroservices.io\u002Fpatterns\u002Fdata\u002Ftransactional-outbox.html",[1413],"nofollow","microservices.io: Transactional outbox",[1416,1417,1418],"style",{},"html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}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 .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}",{"title":353,"searchDepth":593,"depth":593,"links":1420},[1421,1422,1423,1424,1427,1428,1429],{"id":398,"depth":593,"text":399},{"id":446,"depth":593,"text":447},{"id":543,"depth":593,"text":544},{"id":839,"depth":593,"text":840,"children":1425},[1426],{"id":1025,"depth":687,"text":1026},{"id":1060,"depth":593,"text":1061},{"id":1278,"depth":593,"text":52},{"id":1390,"depth":593,"text":1391},"The transactional outbox pattern, the business write and the event commit atomically, with a delivery guarantee.","md",null,{},{"icon":185},{"title":182,"description":1430},"Hqs31TakovsuCBD9i0BAQpntKEzpFyMD96deJ5ztiRE",[1438,1440],{"title":177,"path":178,"stem":179,"description":1439,"icon":180,"children":-1},"Events and listeners over asynq, every listener is an independent task with its own retry budget.",{"title":187,"path":188,"stem":189,"description":1441,"icon":190,"children":-1},"Task scheduling in code, cron events and jobs, replica-safe with a Redis-lease leader election.",1784668703335]