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