[{"data":1,"prerenderedAt":1094},["ShallowReactive",2],{"navigation":3,"\u002Fblog\u002Fthe-self-join-that-emitted-nothing":334,"\u002Fblog\u002Fthe-self-join-that-emitted-nothing-surround":1083},[4,43,91,154,181,224,262,293],{"title":5,"path":6,"stem":7,"children":8,"icon":42},"Getting Started","\u002Fdocs\u002Fgetting-started","1.docs\u002F1.getting-started\u002F1.index",[9,12,17,22,27,32,37],{"title":10,"path":6,"stem":7,"icon":11},"Getting started","i-lucide-flag",{"title":13,"path":14,"stem":15,"icon":16},"Modules overview","\u002Fdocs\u002Fgetting-started\u002Fmodules-overview","1.docs\u002F1.getting-started\u002F2.modules-overview","i-lucide-boxes",{"title":18,"path":19,"stem":20,"icon":21},"Installation","\u002Fdocs\u002Fgetting-started\u002Finstallation","1.docs\u002F1.getting-started\u002F3.installation","i-lucide-download",{"title":23,"path":24,"stem":25,"icon":26},"License configuration","\u002Fdocs\u002Fgetting-started\u002Flicense-configuration","1.docs\u002F1.getting-started\u002F4.license-configuration","i-lucide-key-round",{"title":28,"path":29,"stem":30,"icon":31},"Your first app","\u002Fdocs\u002Fgetting-started\u002Ffirst-app","1.docs\u002F1.getting-started\u002F5.first-app","i-lucide-square-play",{"title":33,"path":34,"stem":35,"icon":36},"Project setup","\u002Fdocs\u002Fgetting-started\u002Fproject-setup","1.docs\u002F1.getting-started\u002F6.project-setup","i-lucide-package",{"title":38,"path":39,"stem":40,"icon":41},"AI assistants","\u002Fdocs\u002Fgetting-started\u002Fai-assistants","1.docs\u002F1.getting-started\u002F7.ai-assistants","i-lucide-bot",false,{"title":44,"path":45,"stem":46,"children":47,"icon":50},"Core Concepts","\u002Fdocs\u002Fconcepts","1.docs\u002F2.concepts\u002F1.index",[48,51,56,61,66,71,76,81,86],{"title":49,"path":45,"stem":46,"icon":50},"Core concepts","i-lucide-book-open",{"title":52,"path":53,"stem":54,"icon":55},"Architecture","\u002Fdocs\u002Fconcepts\u002Farchitecture","1.docs\u002F2.concepts\u002F2.architecture","i-lucide-layers",{"title":57,"path":58,"stem":59,"icon":60},"Exactly-once semantics","\u002Fdocs\u002Fconcepts\u002Fexactly-once","1.docs\u002F2.concepts\u002F3.exactly-once","i-lucide-shield-check",{"title":62,"path":63,"stem":64,"icon":65},"Lanes and parallelism","\u002Fdocs\u002Fconcepts\u002Flanes-and-parallelism","1.docs\u002F2.concepts\u002F4.lanes-and-parallelism","i-lucide-split",{"title":67,"path":68,"stem":69,"icon":70},"State and thread-safety","\u002Fdocs\u002Fconcepts\u002Fstate-and-thread-safety","1.docs\u002F2.concepts\u002F5.state-and-thread-safety","i-lucide-lock",{"title":72,"path":73,"stem":74,"icon":75},"Event time and watermarks","\u002Fdocs\u002Fconcepts\u002Fevent-time-and-watermarks","1.docs\u002F2.concepts\u002F6.event-time-and-watermarks","i-lucide-clock",{"title":77,"path":78,"stem":79,"icon":80},"The configuration model","\u002Fdocs\u002Fconcepts\u002Fconfiguration-model","1.docs\u002F2.concepts\u002F7.configuration-model","i-lucide-sliders-horizontal",{"title":82,"path":83,"stem":84,"icon":85},"The error-handling model","\u002Fdocs\u002Fconcepts\u002Ferror-handling-model","1.docs\u002F2.concepts\u002F8.error-handling-model","i-lucide-triangle-alert",{"title":87,"path":88,"stem":89,"icon":90},"How StoatFlow differs from Kafka Streams","\u002Fdocs\u002Fconcepts\u002Fhow-stoatflow-differs-from-ks","1.docs\u002F2.concepts\u002F9.how-stoatflow-differs-from-ks","i-lucide-arrow-left-right",{"title":92,"path":93,"stem":94,"children":95,"icon":98},"Building Topologies","\u002Fdocs\u002Fbuilding","1.docs\u002F3.building\u002F1.index",[96,99,104,109,114,119,124,129,134,139,144,149],{"title":97,"path":93,"stem":94,"icon":98},"Building topologies","i-lucide-blocks",{"title":100,"path":101,"stem":102,"icon":103},"Error handling and DLQ","\u002Fdocs\u002Fbuilding\u002Ferror-handling-dlq","1.docs\u002F3.building\u002F10.error-handling-dlq","i-lucide-circle-x",{"title":105,"path":106,"stem":107,"icon":108},"State stores","\u002Fdocs\u002Fbuilding\u002Fstate-stores","1.docs\u002F3.building\u002F11.state-stores","i-lucide-database",{"title":110,"path":111,"stem":112,"icon":113},"Testing topologies","\u002Fdocs\u002Fbuilding\u002Ftesting","1.docs\u002F3.building\u002F12.testing","i-lucide-flask-conical",{"title":115,"path":116,"stem":117,"icon":118},"Sources and sinks","\u002Fdocs\u002Fbuilding\u002Fstreams-builder","1.docs\u002F3.building\u002F2.streams-builder","i-lucide-import",{"title":120,"path":121,"stem":122,"icon":123},"KStream and KTable operations","\u002Fdocs\u002Fbuilding\u002Fkstream-ktable","1.docs\u002F3.building\u002F3.kstream-ktable","i-lucide-waypoints",{"title":125,"path":126,"stem":127,"icon":128},"Aggregations","\u002Fdocs\u002Fbuilding\u002Faggregations","1.docs\u002F3.building\u002F4.aggregations","i-lucide-sigma",{"title":130,"path":131,"stem":132,"icon":133},"Windowing","\u002Fdocs\u002Fbuilding\u002Fwindowing","1.docs\u002F3.building\u002F5.windowing","i-lucide-calendar-clock",{"title":135,"path":136,"stem":137,"icon":138},"Joins","\u002Fdocs\u002Fbuilding\u002Fjoins","1.docs\u002F3.building\u002F6.joins","i-lucide-git-merge",{"title":140,"path":141,"stem":142,"icon":143},"The Processor API","\u002Fdocs\u002Fbuilding\u002Fprocessor-api","1.docs\u002F3.building\u002F7.processor-api","i-lucide-cpu",{"title":145,"path":146,"stem":147,"icon":148},"Scheduled sources","\u002Fdocs\u002Fbuilding\u002Fscheduled-sources","1.docs\u002F3.building\u002F8.scheduled-sources","i-lucide-timer",{"title":150,"path":151,"stem":152,"icon":153},"Serdes and Avro","\u002Fdocs\u002Fbuilding\u002Fserdes","1.docs\u002F3.building\u002F9.serdes","i-lucide-binary",{"title":155,"path":156,"stem":157,"children":158,"icon":80},"Configuration","\u002Fdocs\u002Fconfiguration","1.docs\u002F4.configuration\u002F1.index",[159,161,166,171,176],{"title":160,"path":156,"stem":157,"icon":80},"How configuration works",{"title":162,"path":163,"stem":164,"icon":165},"Engine configuration (:core)","\u002Fdocs\u002Fconfiguration\u002Fcore-config","1.docs\u002F4.configuration\u002F2.core-config","i-lucide-settings-2",{"title":167,"path":168,"stem":169,"icon":170},"Runtime configuration (:runtime)","\u002Fdocs\u002Fconfiguration\u002Fruntime-config","1.docs\u002F4.configuration\u002F3.runtime-config","i-lucide-server-cog",{"title":172,"path":173,"stem":174,"icon":175},"Defaults, adaptivity, and presets","\u002Fdocs\u002Fconfiguration\u002Fdefaults-and-presets","1.docs\u002F4.configuration\u002F4.defaults-and-presets","i-lucide-gauge",{"title":177,"path":178,"stem":179,"icon":180},"Kafka client configuration","\u002Fdocs\u002Fconfiguration\u002Fkafka-client-config","1.docs\u002F4.configuration\u002F5.kafka-client-config","i-lucide-plug",{"title":182,"path":183,"stem":184,"children":185,"icon":188},"Running in Production","\u002Fdocs\u002Fruntime","1.docs\u002F5.runtime\u002F1.index",[186,189,194,199,204,209,214,219],{"title":187,"path":183,"stem":184,"icon":188},"The runtime","i-lucide-server",{"title":190,"path":191,"stem":192,"icon":193},"The REST API","\u002Fdocs\u002Fruntime\u002Frest-api","1.docs\u002F5.runtime\u002F2.rest-api","i-lucide-globe",{"title":195,"path":196,"stem":197,"icon":198},"Health checks","\u002Fdocs\u002Fruntime\u002Fhealth-checks","1.docs\u002F5.runtime\u002F3.health-checks","i-lucide-heart-pulse",{"title":200,"path":201,"stem":202,"icon":203},"Metrics","\u002Fdocs\u002Fruntime\u002Fmetrics","1.docs\u002F5.runtime\u002F4.metrics","i-lucide-activity",{"title":205,"path":206,"stem":207,"icon":208},"Pause and resume","\u002Fdocs\u002Fruntime\u002Fpause-unpause","1.docs\u002F5.runtime\u002F5.pause-unpause","i-lucide-pause",{"title":210,"path":211,"stem":212,"icon":213},"Plugins and lifecycle hooks","\u002Fdocs\u002Fruntime\u002Fplugins","1.docs\u002F5.runtime\u002F6.plugins","i-lucide-puzzle",{"title":215,"path":216,"stem":217,"icon":218},"Docker images","\u002Fdocs\u002Fruntime\u002Fdocker","1.docs\u002F5.runtime\u002F7.docker","i-lucide-container",{"title":220,"path":221,"stem":222,"icon":223},"GraalVM native image","\u002Fdocs\u002Fruntime\u002Fnative-image","1.docs\u002F5.runtime\u002F8.native-image","i-lucide-zap",{"title":225,"path":226,"stem":227,"children":228,"icon":231},"Deploying & Operating","\u002Fdocs\u002Foperating","1.docs\u002F6.operating\u002F1.index",[229,232,237,242,247,252,257],{"title":230,"path":226,"stem":227,"icon":231},"Deploying and operating","i-lucide-life-buoy",{"title":233,"path":234,"stem":235,"icon":236},"Running on Kubernetes","\u002Fdocs\u002Foperating\u002Fkubernetes","1.docs\u002F6.operating\u002F2.kubernetes","i-lucide-ship",{"title":238,"path":239,"stem":240,"icon":241},"High availability","\u002Fdocs\u002Foperating\u002Fhigh-availability","1.docs\u002F6.operating\u002F3.high-availability","i-lucide-copy",{"title":243,"path":244,"stem":245,"icon":246},"Liveness and readiness probes","\u002Fdocs\u002Foperating\u002Fprobes","1.docs\u002F6.operating\u002F4.probes","i-lucide-stethoscope",{"title":248,"path":249,"stem":250,"icon":251},"Observability","\u002Fdocs\u002Foperating\u002Fobservability","1.docs\u002F6.operating\u002F5.observability","i-lucide-telescope",{"title":253,"path":254,"stem":255,"icon":256},"Tuning under load","\u002Fdocs\u002Foperating\u002Ftuning","1.docs\u002F6.operating\u002F6.tuning","i-lucide-sliders",{"title":258,"path":259,"stem":260,"icon":261},"Production checklist","\u002Fdocs\u002Foperating\u002Fproduction-checklist","1.docs\u002F6.operating\u002F7.production-checklist","i-lucide-clipboard-check",{"title":263,"path":264,"stem":265,"children":266,"icon":90},"Migrating from Kafka Streams","\u002Fdocs\u002Fmigration","1.docs\u002F7.migration\u002F1.index",[267,268,273,278,283,288],{"title":263,"path":264,"stem":265,"icon":90},{"title":269,"path":270,"stem":271,"icon":272},"Automated port","\u002Fdocs\u002Fmigration\u002Fautomated-port","1.docs\u002F7.migration\u002F2.automated-port","i-lucide-wand-sparkles",{"title":274,"path":275,"stem":276,"icon":277},"Migration without carrying state","\u002Fdocs\u002Fmigration\u002Fwithout-data-migration","1.docs\u002F7.migration\u002F3.without-data-migration","i-lucide-sparkles",{"title":279,"path":280,"stem":281,"icon":282},"Migration carrying state","\u002Fdocs\u002Fmigration\u002Fwith-data-migration","1.docs\u002F7.migration\u002F4.with-data-migration","i-lucide-database-backup",{"title":284,"path":285,"stem":286,"icon":287},"The migration tool","\u002Fdocs\u002Fmigration\u002Fmigration-tool","1.docs\u002F7.migration\u002F5.migration-tool","i-lucide-truck",{"title":289,"path":290,"stem":291,"icon":292},"Reusing your Kafka Streams dashboards","\u002Fdocs\u002Fmigration\u002Freusing-kafka-streams-dashboards","1.docs\u002F7.migration\u002F6.reusing-kafka-streams-dashboards","i-lucide-line-chart",{"title":294,"path":295,"stem":296,"children":297,"icon":299},"Reference","\u002Fdocs\u002Freference","1.docs\u002F8.reference\u002F1.index",[298,300,305,310,315,320,325,329],{"title":294,"path":295,"stem":296,"icon":299},"i-lucide-list",{"title":301,"path":302,"stem":303,"icon":304},"Configuration reference","\u002Fdocs\u002Freference\u002Fconfiguration-reference","1.docs\u002F8.reference\u002F2.configuration-reference","i-lucide-table",{"title":306,"path":307,"stem":308,"icon":309},"REST API reference","\u002Fdocs\u002Freference\u002Frest-api-reference","1.docs\u002F8.reference\u002F3.rest-api-reference","i-lucide-network",{"title":311,"path":312,"stem":313,"icon":314},"Gradle plugin reference","\u002Fdocs\u002Freference\u002Fgradle-plugin-reference","1.docs\u002F8.reference\u002F4.gradle-plugin-reference","i-lucide-box",{"title":316,"path":317,"stem":318,"icon":319},"Maven reference","\u002Fdocs\u002Freference\u002Fmaven-reference","1.docs\u002F8.reference\u002F5.maven-reference","i-simple-icons-apachemaven",{"title":321,"path":322,"stem":323,"icon":324},"Kafka Streams compatibility matrix","\u002Fdocs\u002Freference\u002Fks-compatibility-matrix","1.docs\u002F8.reference\u002F6.ks-compatibility-matrix","i-lucide-table-2",{"title":326,"path":327,"stem":328,"icon":175},"Metrics reference","\u002Fdocs\u002Freference\u002Fmetrics-reference","1.docs\u002F8.reference\u002F7.metrics-reference",{"title":330,"path":331,"stem":332,"icon":333},"Glossary","\u002Fdocs\u002Freference\u002Fglossary","1.docs\u002F8.reference\u002F8.glossary","i-lucide-book-a",{"id":335,"title":336,"authors":337,"badge":343,"body":345,"date":1070,"description":1071,"draft":42,"extension":1072,"image":1073,"meta":1075,"navigation":1076,"ogImage":1077,"path":1078,"seo":1079,"sitemap":1080,"stem":1081,"updated":1077,"__hash__":1082},"posts\u002F3.blog\u002F26.the-self-join-that-emitted-nothing.md","The self-join that emitted nothing",[338],{"name":339,"to":340,"avatar":341},"Hartmut Armbruster","https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fhartmut-co-uk\u002F",{"src":342},"\u002Fassets\u002Fhartmut_armbruster_monochromatic.jpg",{"label":344},"Engineering",{"type":346,"value":347,"toc":1063},"minimark",[348,379,391,452,457,460,473,477,628,638,642,652,927,930,933,937,950,953,1003,1007,1013,1023,1029,1034,1059],[349,350,351,352,356,357,360,361,364,365,368,369,368,372,368,375,378],"p",{},"Join a stream with itself on Kafka Streams — ",[353,354,355],"code",{},"s.join(s)"," — and pipe two records with the same key, ",[353,358,359],{},"a"," then ",[353,362,363],{},"b",", inside the join window. Kafka Streams emits four results: ",[353,366,367],{},"a+a",", ",[353,370,371],{},"b+a",[353,373,374],{},"a+b",[353,376,377],{},"b+b",". StoatFlow emitted none. No exception, no log line, no metric moved; the output topic stayed empty.",[349,380,381,382,386,387,390],{},"The cause was one decision in how StoatFlow's multi-input operators worked out where a record came from: a stream–stream join picked its side, and a cogroup its aggregator, by the name of the record's ",[383,384,385],"strong",{},"source node",". A self-join's records all come from one source, so every record was processed as the left side and the right side's store stayed empty. The same rule broke chained joins, joins over a ",[353,388,389],{},"merge",", records forwarded from timers and window closes, and a cogroup of two groupings of one stream. This post lists the shapes, shows how Kafka Streams' own test driver served as the oracle that found them, and describes the fix: the input travels with the edge.",[392,393,394,399],"blockquote",{},[349,395,396],{},[383,397,398],{},"TL;DR",[400,401,402,409,415,425,439],"ul",{},[403,404,405,408],"li",{},[383,406,407],{},"What:"," a join chose left or right, and a cogroup its aggregator, by the record's source node name — not by the edge the record arrived on.",[403,410,411,414],{},[383,412,413],{},"Symptom:"," a self-join emitted nothing; chained and merged legs lost one side's records; records from timers, punctuators and window closes were dropped before a join. Silently.",[403,416,417,420,421,424],{},[383,418,419],{},"How we found every shape:"," the same topology built and run on both Kafka Streams' ",[353,422,423],{},"TopologyTestDriver"," and StoatFlow's, outputs compared. On the old code every case but the control failed.",[403,426,427,430,431,435,436,438],{},[383,428,429],{},"The fix:"," input ",[432,433,434],"em",{},"i"," of a node is position ",[432,437,434],{}," among its parents, and that input number travels with the edge — inside a sub-topology and across a lane hop.",[403,440,441,444,445,447,448,451],{},[383,442,443],{},"Docs:"," ",[359,446,135],{"href":136}," · ",[359,449,450],{"href":111},"Testing",".",[453,454,456],"h2",{"id":455},"how-a-join-knew-its-side","How a join knew its side",[349,458,459],{},"In Kafka Streams each side of a stream–stream join is its own processor: the left stream feeds a \"this side\" processor, the right stream feeds an \"other side\" processor, and a self-join wires one stream into both. A record runs through each side because it travels along each edge.",[349,461,462,463,368,466,469,470,472],{},"StoatFlow's join was one processor with two inputs, and it decided which input a record was on by comparing the record's source node name with the names it had resolved for its left and right parents. A stream derived from a source — through ",[353,464,465],{},"mapValues",[353,467,468],{},"selectKey"," or a ",[353,471,389],{}," — keeps its parent's source name. So the decision went wrong wherever two inputs shared a source, or a record's source name was not one of the two the join expected. The four cogroup operators did the same with a map from source name to aggregator, and skipped a record whose name was missing.",[453,474,476],{"id":475},"every-shape-it-broke","Every shape it broke",[478,479,480,496],"table",{},[481,482,483],"thead",{},[484,485,486,490,493],"tr",{},[487,488,489],"th",{},"Shape",[487,491,492],{},"Kafka Streams",[487,494,495],{},"StoatFlow before the fix",[497,498,499,522,539,560,582,593,615],"tbody",{},[484,500,501,506,516],{},[502,503,504],"td",{},[353,505,355],{},[502,507,508,368,510,368,512,368,514],{},[353,509,367],{},[353,511,371],{},[353,513,374],{},[353,515,377],{},[502,517,518,519],{},"inner join: nothing; left and outer joins: only ",[353,520,521],{},"(left, null)",[484,523,524,529,532],{},[502,525,526],{},[353,527,528],{},"s.selectKey(f1).join(s.selectKey(f2))",[502,530,531],{},"both sides joined",[502,533,534,535,538],{},"as above — both legs carry ",[353,536,537],{},"s","'s source name",[484,540,541,546,552],{},[502,542,543],{},[353,544,545],{},"a.join(b).join(c)",[502,547,548,549],{},"every first-join result joins ",[353,550,551],{},"c",[502,553,554,555,557,558],{},"results triggered by a ",[353,556,363],{}," record dropped: the second join's left side was named after ",[353,559,359],{},[484,561,562,567,575],{},[502,563,564],{},[353,565,566],{},"s1.merge(s2).join(s3)",[502,568,569,368,572],{},[353,570,571],{},"one+R",[353,573,574],{},"two+R",[502,576,577,578,581],{},"every ",[353,579,580],{},"s2"," record dropped",[484,583,584,587,590],{},[502,585,586],{},"a window close, timer or punctuator forwarding into a join",[502,588,589],{},"joined",[502,591,592],{},"dropped — such records carry no source the join recognises",[484,594,595,598,612],{},[502,596,597],{},"two cogrouped groupings of one stream",[502,599,600,368,603,368,606,368,609],{},[353,601,602],{},"[Aa]",[353,604,605],{},"[Aa][Ba]",[353,607,608],{},"[Aa][Ba][Ab]",[353,610,611],{},"[Aa][Ba][Ab][Bb]",[502,613,614],{},"one aggregator overwrote the other",[484,616,617,622,625],{},[502,618,619],{},[353,620,621],{},"s.merge(s.mapValues(f))",[502,623,624],{},"each record once, plus its mapped copy",[502,626,627],{},"the mapped copy never ran — only one chain was kept per source",[349,629,630,631,633,634,637],{},"In StoatFlow's own ",[353,632,423],{}," it went further: every record reaching a join or cogroup through any node other than its source was dropped, so even ",[353,635,636],{},"stream.mapValues(…).join(…)"," produced nothing there. The last row is the odd one out: the test driver was not affected, and only an integration test on a real broker showed it.",[453,639,641],{"id":640},"kafka-streams-as-the-oracle","Kafka Streams as the oracle",[349,643,644,645,648,649,651],{},"The tempting way to test a fix like this is to write down the expected output by hand. For joins that is error-prone — window semantics, the order in which a self-join emits, which side a merged record lands on. So the tests do not write it down. They build each topology twice, once with Kafka Streams' ",[353,646,647],{},"StreamsBuilder"," and once with StoatFlow's, pipe the same input into Kafka Streams' ",[353,650,423],{}," and StoatFlow's, and compare the output values in order:",[653,654,659],"pre",{"className":655,"code":656,"language":657,"meta":658,"style":658},"language-kotlin shiki shiki-themes vitesse-light","@Test\nfun `self-join runs every record as both sides`() {\n    val expected =\n        assertMatchesKs(\n            ab, \u002F\u002F two records, same key, values a then b, inside the join window\n            { b -> \u002F\u002F Kafka Streams' StreamsBuilder\n                val s = b.stream\u003CString, String>(\"in\")\n                s.join(s, { l, r -> \"$l+$r\" }, KsJoinWindows.ofTimeDifferenceWithNoGrace(window)).to(\"out\")\n            },\n            { b -> \u002F\u002F StoatFlow's StreamsBuilder\n                val s = b.stream\u003CString, String>(\"in\")\n                s.join(s, { l, r -> \"$l+$r\" }, JoinWindows.ofTimeDifferenceWithNoGrace(window)).to(\"out\")\n            },\n        )\n    expected shouldContainExactly listOf(\"a+a\", \"b+a\", \"a+b\", \"b+b\")   \u002F\u002F pins the oracle itself\n}\n","kotlin","",[353,660,661,670,685,698,707,717,729,767,804,810,820,847,875,880,886,921],{"__ignoreMap":658},[662,663,666],"span",{"class":664,"line":665},"line",1,[662,667,669],{"class":668},"sUxyF","@Test\n",[662,671,673,677,681],{"class":664,"line":672},2,[662,674,676],{"class":675},"sbBg2","fun",[662,678,680],{"class":679},"sySUi"," `self-join runs every record as both sides`",[662,682,684],{"class":683},"suHK_","() {\n",[662,686,688,691,694],{"class":664,"line":687},3,[662,689,690],{"class":675},"    val",[662,692,693],{"class":683}," expected ",[662,695,697],{"class":696},"sYZai","=\n",[662,699,701,704],{"class":664,"line":700},4,[662,702,703],{"class":679},"        assertMatchesKs",[662,705,706],{"class":683},"(\n",[662,708,710,713],{"class":664,"line":709},5,[662,711,712],{"class":683},"            ab, ",[662,714,716],{"class":715},"s8zF2","\u002F\u002F two records, same key, values a then b, inside the join window\n",[662,718,720,723,726],{"class":664,"line":719},6,[662,721,722],{"class":683},"            { b ",[662,724,725],{"class":696},"->",[662,727,728],{"class":715}," \u002F\u002F Kafka Streams' StreamsBuilder\n",[662,730,732,735,738,741,744,747,750,753,755,757,760,764],{"class":664,"line":731},7,[662,733,734],{"class":675},"                val",[662,736,737],{"class":683}," s ",[662,739,740],{"class":696},"=",[662,742,743],{"class":683}," b.",[662,745,746],{"class":679},"stream",[662,748,749],{"class":683},"\u003C",[662,751,752],{"class":668},"String",[662,754,368],{"class":683},[662,756,752],{"class":668},[662,758,759],{"class":683},">(",[662,761,763],{"class":762},"spphp","\"in\"",[662,765,766],{"class":683},")\n",[662,768,770,773,776,779,781,784,787,790,793,796,799,802],{"class":664,"line":769},8,[662,771,772],{"class":683},"                s.",[662,774,775],{"class":679},"join",[662,777,778],{"class":683},"(s, { l, r ",[662,780,725],{"class":696},[662,782,783],{"class":762}," \"$l+$r\"",[662,785,786],{"class":683}," }, KsJoinWindows.",[662,788,789],{"class":679},"ofTimeDifferenceWithNoGrace",[662,791,792],{"class":683},"(window)).",[662,794,795],{"class":679},"to",[662,797,798],{"class":683},"(",[662,800,801],{"class":762},"\"out\"",[662,803,766],{"class":683},[662,805,807],{"class":664,"line":806},9,[662,808,809],{"class":683},"            },\n",[662,811,813,815,817],{"class":664,"line":812},10,[662,814,722],{"class":683},[662,816,725],{"class":696},[662,818,819],{"class":715}," \u002F\u002F StoatFlow's StreamsBuilder\n",[662,821,823,825,827,829,831,833,835,837,839,841,843,845],{"class":664,"line":822},11,[662,824,734],{"class":675},[662,826,737],{"class":683},[662,828,740],{"class":696},[662,830,743],{"class":683},[662,832,746],{"class":679},[662,834,749],{"class":683},[662,836,752],{"class":668},[662,838,368],{"class":683},[662,840,752],{"class":668},[662,842,759],{"class":683},[662,844,763],{"class":762},[662,846,766],{"class":683},[662,848,850,852,854,856,858,860,863,865,867,869,871,873],{"class":664,"line":849},12,[662,851,772],{"class":683},[662,853,775],{"class":679},[662,855,778],{"class":683},[662,857,725],{"class":696},[662,859,783],{"class":762},[662,861,862],{"class":683}," }, JoinWindows.",[662,864,789],{"class":679},[662,866,792],{"class":683},[662,868,795],{"class":679},[662,870,798],{"class":683},[662,872,801],{"class":762},[662,874,766],{"class":683},[662,876,878],{"class":664,"line":877},13,[662,879,809],{"class":683},[662,881,883],{"class":664,"line":882},14,[662,884,885],{"class":683},"        )\n",[662,887,889,892,895,897,900,902,905,907,910,912,915,918],{"class":664,"line":888},15,[662,890,891],{"class":683},"    expected shouldContainExactly ",[662,893,894],{"class":679},"listOf",[662,896,798],{"class":683},[662,898,899],{"class":762},"\"a+a\"",[662,901,368],{"class":683},[662,903,904],{"class":762},"\"b+a\"",[662,906,368],{"class":683},[662,908,909],{"class":762},"\"a+b\"",[662,911,368],{"class":683},[662,913,914],{"class":762},"\"b+b\"",[662,916,917],{"class":683},")   ",[662,919,920],{"class":715},"\u002F\u002F pins the oracle itself\n",[662,922,924],{"class":664,"line":923},16,[662,925,926],{"class":683},"}\n",[349,928,929],{},"Three details keep the comparison honest. Kafka Streams' record cache is set to zero, so each cogroup update is emitted rather than folded into one per input record. Order is relaxed in two places only: a source joined with a processed copy of itself, where the fan-out's two branches may run in either order, and one Processor API fan-in case. And where the test also states an expected list, it checks Kafka Streams against it, so a misunderstanding of Kafka Streams' behaviour fails the test too.",[349,931,932],{},"The suite has 17 cases, 15 of them compared with Kafka Streams. The other two check hand-written values: a window close feeding a join, which StoatFlow closes on the watermark — a clock Kafka Streams' driver does not have — and a scheduled source as a cogroup leg, which Kafka Streams does not have at all. In the first red run on the old code, every case but the control failed, 12 of them; four more were added during review. A broker integration test runs the main shapes end to end — the self-join, two re-keyed legs, a merged leg, the cogroup of two groupings, a window close into a join, a source merged or joined with a processed copy of itself — plus the Processor API fan-in shapes.",[453,934,936],{"id":935},"the-fix-the-edge-carries-the-input","The fix: the edge carries the input",[349,938,939,940,949],{},"A node's inputs are now defined by its parents: ",[383,941,942,943,945,946,948],{},"input ",[432,944,434],{}," is position ",[432,947,434],{}," in the node's parent list at build time",", the same rule Kafka Streams' wiring implies. The input number travels with the edge. Inside a sub-topology, each forward to a child carries the input that child receives on; when a record crosses to another lane, the input travels on the record itself. The join maps input 0 to left and 1 to right; a cogroup holds its aggregators in input order.",[349,951,952],{},"What that means in practice:",[400,954,955,964,982,988,997],{},[403,956,957,960,961,963],{},[383,958,959],{},"A self-join runs each record as left, then as right",", as Kafka Streams does, so ",[353,962,355],{}," emits all four results.",[403,965,966,969,970,973,974,977,978,981],{},[383,967,968],{},"A Processor API node that lists the same parent twice runs twice"," — every edge keeps its slot, as in Kafka Streams; a forward ",[432,971,972],{},"by name"," to such a child runs it once, also as in Kafka Streams. ",[353,975,976],{},"KStream.merge"," de-duplicates its parents, so ",[353,979,980],{},"s.merge(s)"," emits once.",[403,983,984,987],{},[383,985,986],{},"Records from callbacks join normally",", because the input comes from the edge, not from the record's origin.",[403,989,990,993,994,996],{},[383,991,992],{},"Every source dispatches into exactly one chain."," A source whose records also feed a node beyond its own first sub-topology reaches it through an ordinary lane hop, so ",[353,995,621],{}," runs both legs.",[403,998,999,1002],{},[383,1000,1001],{},"An unknown input is fatal."," The compiler checks every edge's input number. A mismatch at runtime — which would mean the compiled metadata drifted — fails loudly instead of being dropped like before.",[453,1004,1006],{"id":1005},"tradeoffs-and-limits","Tradeoffs and limits",[349,1008,1009,1012],{},[383,1010,1011],{},"The oracle only covers what Kafka Streams can express."," Two of the cases cannot run on Kafka Streams' driver and check hand-written values; StoatFlow-specific behaviour — lanes, one global watermark — needs broker tests and invariants, not a reference driver. The comparison also checks values, not record timestamps.",[349,1014,1015,1018,1019,1022],{},[383,1016,1017],{},"Some fan-in shapes now pay a lane hop."," A node whose parents' chains cannot be joined, or whose parents sit at different depths of one chain, moves into its own sub-topology, so every edge into it crosses to a lane. The hop is an in-memory hand-off with no repartition topic, but it is still work the old, wrong routing skipped. A source whose only edge leads into a merge that a processed leg also feeds gets a sub-topology of its own that only forwards: its own lanes, and a key hash and a queue hop per record. ",[353,1020,1021],{},"Consumed.withNumberOfLanes(...)"," on the source sizes it down.",[349,1024,1025,1028],{},[383,1026,1027],{},"None of it was visible."," None of these shapes threw, logged or counted anything; an empty output topic looks the same as no input. The comparison against Kafka Streams is now a permanent part of the build, and an input the compiler cannot account for stops the application rather than dropping the record.",[349,1030,1031],{},[383,1032,1033],{},"Read on:",[400,1035,1036,1041,1049,1054],{},[403,1037,1038,1040],{},[359,1039,135],{"href":136}," — stream–stream, stream–table and table–table joins in StoatFlow.",[403,1042,1043,1045,1046,1048],{},[359,1044,450],{"href":111}," — the ",[353,1047,423],{},", and what it cannot show.",[403,1050,1051,1053],{},[359,1052,321],{"href":322}," — where StoatFlow matches Kafka Streams, and where it deliberately does not.",[403,1055,1056,1058],{},[359,1057,62],{"href":63}," — the in-memory lane hop that replaces a repartition topic.",[1060,1061,1062],"style",{},"html pre.shiki code .sUxyF, html code.shiki .sUxyF{--shiki-default:#2E8F82}html pre.shiki code .sbBg2, html code.shiki .sbBg2{--shiki-default:#1E754F}html pre.shiki code .sySUi, html code.shiki .sySUi{--shiki-default:#59873A}html pre.shiki code .suHK_, html code.shiki .suHK_{--shiki-default:#393A34}html pre.shiki code .sYZai, html code.shiki .sYZai{--shiki-default:#999999}html pre.shiki code .s8zF2, html code.shiki .s8zF2{--shiki-default:#A0ADA0}html pre.shiki code .spphp, html code.shiki .spphp{--shiki-default:#B56959}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);}",{"title":658,"searchDepth":672,"depth":672,"links":1064},[1065,1066,1067,1068,1069],{"id":455,"depth":672,"text":456},{"id":475,"depth":672,"text":476},{"id":640,"depth":672,"text":641},{"id":935,"depth":672,"text":936},{"id":1005,"depth":672,"text":1006},"2026-10-07","On Kafka Streams, s.join(s) over two records of one key emits four results. On StoatFlow it emitted none — no error, no log line, no metric. A stream–stream join chose which side a record belonged to by the name of the source it came from, and a self-join's records all come from one source. The same rule dropped records in chained joins, merged legs and cogroups. How we found every affected shape by running Kafka Streams' own test driver as the oracle, and how the fix makes the edge carry the input.","md",{"src":1074},"\u002Fassets\u002Fblog\u002Fog\u002Fthe-self-join-that-emitted-nothing.png",{},true,null,"\u002Fblog\u002Fthe-self-join-that-emitted-nothing",{"title":336,"description":1071},{"loc":1078},"3.blog\u002F26.the-self-join-that-emitted-nothing","LGtkTG-ley_ioKR4YJvYwmoCrvfSP1qHcabTeFLsDKo",[1084,1089],{"title":1085,"path":1086,"stem":1087,"description":1088,"children":-1},"Your timer fired on another thread","\u002Fblog\u002Ftimer-on-the-wrong-lane","3.blog\u002F23.timer-on-the-wrong-lane","StoatFlow runs a key's timers on the same lane as its records, so onTimer never races process() for that key. The lane is chosen by hashing key bytes — and a timer key is serialised again, by its own serde. Behind a Processor API source declared with only a deserializer, that serde was the default, and when the default wrote different bytes than the topic, a key's timer fired on another lane, concurrently with its records, without a word. How it happens, why Kafka Streams never meets it, and how StoatFlow now refuses to run a proven split.",{"title":1090,"path":1091,"stem":1092,"description":1093,"children":-1},"Testing and operating event time","\u002Fblog\u002Ftesting-and-operating-event-time","3.blog\u002F18.testing-and-operating-event-time","The TopologyTestDriver's stream time is the maximum it has seen, with no out-of-orderness lag and no cross-partition minimum; production keeps a 10-second lag and the minimum. A window-close test that passes in the driver emits later on a real broker, and can count a record the driver dropped. Part 4 of the event-time series: which tests transfer, how to read \u002Fwatermarks, the meters that matter, the one alert to set on day one, and what a restart does to the watermark.",1792981295843]