[{"data":1,"prerenderedAt":1390},["ShallowReactive",2],{"navigation":3,"\u002Fblog\u002Fclosing-a-window-under-exactly-once":334,"\u002Fblog\u002Fclosing-a-window-under-exactly-once-surround":1380},[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":1368,"description":1369,"draft":42,"extension":1370,"image":1371,"meta":1373,"navigation":616,"ogImage":1374,"path":1375,"seo":1376,"sitemap":1377,"stem":1378,"updated":1374,"__hash__":1379},"posts\u002F3.blog\u002F17.closing-a-window-under-exactly-once.md","Closing a window under exactly-once",[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},"Deep Dive",{"type":346,"value":347,"toc":1357},"minimark",[348,380,393,409,458,463,513,516,531,538,548,563,719,722,745,751,816,835,842,845,848,859,904,907,916,919,926,933,939,942,950,960,971,974,997,1004,1007,1018,1021,1055,1066,1079,1094,1097,1115,1157,1160,1167,1182,1185,1203,1213,1234,1243,1253,1264,1283,1286,1302,1307,1353],[349,350,353],"callout",{"color":351,"icon":352},"neutral","i-lucide-list-ordered",[354,355,356,360,361,366,367,371,372,366,376],"p",{},[357,358,359],"strong",{},"Event time in StoatFlow — part 3 of 4."," ",[362,363,365],"a",{"href":364},"\u002Fblog\u002Fthree-clocks-stream-time-vs-watermarks","Three clocks"," · ",[362,368,370],{"href":369},"\u002Fblog\u002Fmaking-the-watermark-move","Making the watermark move"," · Closing a window under exactly-once ",[373,374,375],"em",{},"(this post)",[362,377,379],{"href":378},"\u002Fblog\u002Ftesting-and-operating-event-time","Testing and operating event time",[349,381,384],{"color":382,"icon":383},"info","i-lucide-info",[354,385,386,360,389,392],{},[357,387,388],{},"Changed in 1.0.0-rc.4.",[362,390,391],{"href":364},"Part 1"," described two clocks; StoatFlow now keeps one — the watermark decides both when a window closes and whether a record is late, for every window kind and emit strategy, and it survives restarts. This post describes it.",[354,394,395,396,400,401,404,405,408],{},"The naive model of a windowed aggregation says the final result is emitted the moment the watermark passes ",[397,398,399],"code",{},"windowEnd + grace",". In StoatFlow it is not: the result is emitted at a commit barrier, and the watermark it is judged against is the one stamped on the ",[373,402,403],{},"previous"," barrier — so a final emission can trail the watermark by up to two commit intervals. That lag is deliberate. It is the price of a final result whose every contributing record is already committed on every lane, which is what \"exactly-once\" has to mean for a window rather than for a record. ",[362,406,407],{"href":369},"Part 2"," got the watermark moving; this post follows a single advance from the consumer thread to the committed result.",[410,411,412,417],"blockquote",{},[354,413,414],{},[357,415,416],{},"TL;DR",[418,419,420,427,433,439,445],"ul",{},[421,422,423,426],"li",{},[357,424,425],{},"What:"," the path from a watermark advance to a committed window result — the punctuation lane, the pre-barrier force-emit and the barrier's stamp, emit strategies and suppress, the one-clock late rule, the committed-watermark frontier, the durable floor and emit frontier, and event-time store expiry.",[421,428,429,432],{},[357,430,431],{},"Why:"," under exactly-once a final result must not include a record the transaction can still roll back — and it must not be emitted while a record that will still be admitted is queued on another lane. The watermark says \"no earlier records are coming\"; it does not say \"every earlier record is processed and committed\". The frontier closes that gap.",[421,434,435,438],{},[357,436,437],{},"The guarantee:"," a window's final result is emitted exactly once, reflects every record the operator admitted, and survives a crash, an in-place restart and an HA promotion — the emit frontier and the late-drop floor are restored with the state, and the watermark resumes from the partition times committed with the offsets.",[421,440,441,444],{},[357,442,443],{},"The catch:"," final emissions trail the watermark by up to ~2 commit intervals — two more per sub-topology hop they are fed through — and so does the null side of a left\u002Fouter stream-stream join; a record is late once the watermark stamped on the last barrier its lane crossed has passed its window's close, which can count different records into a window than Kafka Streams for out-of-order or filtered input; a session closes only after its last event plus the gap plus grace.",[421,446,447,360,450,366,452,366,455,457],{},[357,448,449],{},"Docs:",[362,451,130],{"href":131},[362,453,454],{"href":58},"Exactly-once",[362,456,72],{"href":73},".",[459,460,462],"h2",{"id":461},"in-this-post","In this post",[418,464,465,471,477,483,489,495,501,507],{},[421,466,467],{},[362,468,470],{"href":469},"#what-a-watermark-advance-triggers","What a watermark advance triggers",[421,472,473],{},[362,474,476],{"href":475},"#emit-strategies-and-the-three-suppress-triggers","Emit strategies and the three suppress triggers",[421,478,479],{},[362,480,482],{"href":481},"#one-clock-decides-late","One clock decides late",[421,484,485],{},[362,486,488],{"href":487},"#the-committed-watermark-frontier","The committed-watermark frontier",[421,490,491],{},[362,492,494],{"href":493},"#what-survives-a-restart","What survives a restart",[421,496,497],{},[362,498,500],{"href":499},"#when-state-expires","When state expires",[421,502,503],{},[362,504,506],{"href":505},"#tradeoffs-and-limits","Tradeoffs and limits",[421,508,509],{},[362,510,512],{"href":511},"#whats-next","What's next",[459,514,470],{"id":515},"what-a-watermark-advance-triggers",[354,517,518,519,522,523,526,527,530],{},"The global watermark is recomputed in three places. The consumer thread asks for a new minimum after every poll, and the tracker emits one at most every 200 ms — ",[397,520,521],{},"stoatflow.watermark.auto-interval-ms",", Flink's ",[397,524,525],{},"pipeline.auto-watermark-interval"," under another name. The dispatcher recomputes it once more immediately before it fixes a commit barrier — a ",[373,528,529],{},"force-emit"," — and stamps that value on the barrier, so the stamp covers every record the closing epoch dispatched. And shutdown recomputes it a final time, for a final barrier that carries a flag this post returns to.",[354,532,533,534,537],{},"An advance does not reach the operators as a number they read. It is delivered as an event — one per sub-topology — on that sub-topology's ",[357,535,536],{},"punctuation lane",": the dedicated virtual thread that runs punctuators and, since ADR-084, watermark processing, with the same epoch isolation and state access as record processing. The commit barrier travels down the same lane, and the dispatcher enqueues the watermark event ahead of it. The lane is FIFO, so the advance is processed inside the epoch the barrier closes — but what closes windows is the stamp on the barrier, not the event.",[354,539,540,541,544,545,547],{},"What acts on the event itself is the part of the engine that fires on event time: event-time timers, ",[397,542,543],{},"STREAM_TIME"," punctuators and ",[397,546,543],{}," scheduled sources. Everything that closes something — every windowed aggregation, tumbling, hopping, sliding, session and their cogroup forms, both watermark-driven suppress processors, and the stream-stream join — acts at the next barrier instead, on the stamp. The event also marks the punctuation lane as having done work: the idle-throttled barrier cadence (ADR-134) treats an epoch as empty only when no record was dispatched, that lane did nothing, and no sub-topology still has final results to emit or commit. That last term is the emission work signal (ADR-149): under at-least-once a downstream sub-topology's input is its upstream's final results, forwarded in memory and never sent, so without it the last windows would wait for new input once input stops. With it, barriers keep coming until those windows are emitted and committed.",[354,549,550,551,554,555,558,559,562],{},"Then the twist this post is about. For anything that closes — ",[397,552,553],{},"onWindowClose",", ",[397,556,557],{},"suppress(untilWindowCloses)"," and ",[397,560,561],{},"untilTimeLimit",", the null side of a join — the watermark event emits nothing. That waits for the barrier.",[564,565,570],"pre",{"className":566,"code":567,"language":568,"meta":569,"style":569},"language-mermaid shiki shiki-themes vitesse-light","sequenceDiagram\n    participant C as Consumer thread\n    participant D as Dispatcher\n    participant L as Punctuation lane (one per sub-topology)\n    participant P as Windowed processor\n    participant T as Commit thread\n\n    Note over C: every 200 ms — recompute the global watermark\n    C->>L: watermark event (W)\n    Note over L,P: nothing closes here\u003Cbr\u002F>windows close on barrier stamps, not on W\n\n    Note over D: barrier N is due\u003Cbr\u002F>created only after TX N-1 committed\n    D->>L: watermark event (W) — the pre-barrier force-emit\n    D->>D: stamp barrier N with W, call it W(N)\u003Cbr\u002F>write W(N) to the watermark store, it commits with TX N\n    D->>L: barrier N, stamped W(N)\n    Note over D,L: a record lane that crosses barrier N\u003Cbr\u002F>takes W(N) as its late-drop floor\n    L->>T: barrier N processed on this lane\n    L->>L: wait until every record lane has crossed N\u003Cbr\u002F>roll — committedW = W(N-1), the barrier before\n    L->>P: onWatermarkAdvance(committedW)\n    P->>P: range-fetch windows whose close time ≤ committedW\u003Cbr\u002F>gated by the durable emit frontier\n    P-->>L: forward the final results, each carrying its own event time\n    Note over L,T: these sends land in TX N+1\u003Cbr\u002F>they happen after the lane reported barrier N\n    T->>T: every lane aligned — commit TX N\u003Cbr\u002F>state, output, offsets, the W(N) stamp\n    Note over D,T: only now is barrier N+1 created — TX N is durable\n","mermaid","",[397,571,572,581,587,593,599,605,611,618,624,630,636,641,647,653,659,665,671,677,683,689,695,701,707,713],{"__ignoreMap":569},[573,574,577],"span",{"class":575,"line":576},"line",1,[573,578,580],{"class":579},"suHK_","sequenceDiagram\n",[573,582,584],{"class":575,"line":583},2,[573,585,586],{"class":579},"    participant C as Consumer thread\n",[573,588,590],{"class":575,"line":589},3,[573,591,592],{"class":579},"    participant D as Dispatcher\n",[573,594,596],{"class":575,"line":595},4,[573,597,598],{"class":579},"    participant L as Punctuation lane (one per sub-topology)\n",[573,600,602],{"class":575,"line":601},5,[573,603,604],{"class":579},"    participant P as Windowed processor\n",[573,606,608],{"class":575,"line":607},6,[573,609,610],{"class":579},"    participant T as Commit thread\n",[573,612,614],{"class":575,"line":613},7,[573,615,617],{"emptyLinePlaceholder":616},true,"\n",[573,619,621],{"class":575,"line":620},8,[573,622,623],{"class":579},"    Note over C: every 200 ms — recompute the global watermark\n",[573,625,627],{"class":575,"line":626},9,[573,628,629],{"class":579},"    C->>L: watermark event (W)\n",[573,631,633],{"class":575,"line":632},10,[573,634,635],{"class":579},"    Note over L,P: nothing closes here\u003Cbr\u002F>windows close on barrier stamps, not on W\n",[573,637,639],{"class":575,"line":638},11,[573,640,617],{"emptyLinePlaceholder":616},[573,642,644],{"class":575,"line":643},12,[573,645,646],{"class":579},"    Note over D: barrier N is due\u003Cbr\u002F>created only after TX N-1 committed\n",[573,648,650],{"class":575,"line":649},13,[573,651,652],{"class":579},"    D->>L: watermark event (W) — the pre-barrier force-emit\n",[573,654,656],{"class":575,"line":655},14,[573,657,658],{"class":579},"    D->>D: stamp barrier N with W, call it W(N)\u003Cbr\u002F>write W(N) to the watermark store, it commits with TX N\n",[573,660,662],{"class":575,"line":661},15,[573,663,664],{"class":579},"    D->>L: barrier N, stamped W(N)\n",[573,666,668],{"class":575,"line":667},16,[573,669,670],{"class":579},"    Note over D,L: a record lane that crosses barrier N\u003Cbr\u002F>takes W(N) as its late-drop floor\n",[573,672,674],{"class":575,"line":673},17,[573,675,676],{"class":579},"    L->>T: barrier N processed on this lane\n",[573,678,680],{"class":575,"line":679},18,[573,681,682],{"class":579},"    L->>L: wait until every record lane has crossed N\u003Cbr\u002F>roll — committedW = W(N-1), the barrier before\n",[573,684,686],{"class":575,"line":685},19,[573,687,688],{"class":579},"    L->>P: onWatermarkAdvance(committedW)\n",[573,690,692],{"class":575,"line":691},20,[573,693,694],{"class":579},"    P->>P: range-fetch windows whose close time ≤ committedW\u003Cbr\u002F>gated by the durable emit frontier\n",[573,696,698],{"class":575,"line":697},21,[573,699,700],{"class":579},"    P-->>L: forward the final results, each carrying its own event time\n",[573,702,704],{"class":575,"line":703},22,[573,705,706],{"class":579},"    Note over L,T: these sends land in TX N+1\u003Cbr\u002F>they happen after the lane reported barrier N\n",[573,708,710],{"class":575,"line":709},23,[573,711,712],{"class":579},"    T->>T: every lane aligned — commit TX N\u003Cbr\u002F>state, output, offsets, the W(N) stamp\n",[573,714,716],{"class":575,"line":715},24,[573,717,718],{"class":579},"    Note over D,T: only now is barrier N+1 created — TX N is durable\n",[459,720,476],{"id":721},"emit-strategies-and-the-three-suppress-triggers",[354,723,724,725,728,729,732,733,736,737,740,741,744],{},"A windowed aggregation decides ",[373,726,727],{},"when"," to forward with an ",[397,730,731],{},"EmitStrategy",". ",[397,734,735],{},"onWindowUpdate()",", the default, forwards on every record, and none of what follows applies to it. ",[397,738,739],{},"onWindowClose()"," forwards nothing per record: when the frontier passes a window's close, it range-fetches the store for every window whose close lies between the last close it already emitted and the frontier, forwards each exactly once, and advances that emit frontier. The cost is latency — at least the grace period, plus the frontier lag below. Kafka Streams' ",[397,742,743],{},"EmitStrategy.onWindowClose()"," does the same on each aggregation's own stream time.",[354,746,747,750],{},[397,748,749],{},"suppress"," is the other way to hold a result, and it is worth one table, because the three triggers run on two different clocks:",[752,753,754,770],"table",{},[755,756,757],"thead",{},[758,759,760,764,767],"tr",{},[761,762,763],"th",{},"Trigger",[761,765,766],{},"Clock",[761,768,769],{},"Releases when",[771,772,773,789,803],"tbody",{},[758,774,775,781,784],{},[776,777,778],"td",{},[397,779,780],{},"Suppressed.untilWindowCloses(...)",[776,782,783],{},"watermark",[776,785,786],{},[397,787,788],{},"watermark >= windowEnd + grace",[758,790,791,796,798],{},[776,792,793],{},[397,794,795],{},"Suppressed.untilTimeLimit(...)",[776,797,783],{},[776,799,800],{},[397,801,802],{},"watermark >= firstBuffered + timeLimit",[758,804,805,810,813],{},[776,806,807],{},[397,808,809],{},"Suppressed.untilBarrier(...)",[776,811,812],{},"commit barrier",[776,814,815],{},"the epoch commits — at most one record per key per epoch",[354,817,818,819,822,823,826,827,831,832,834],{},"The two watermark-driven buffers are the Kafka Streams ones on a different clock; both span epochs, so both are backed by a changelogged store. ",[397,820,821],{},"untilBarrier"," is StoatFlow's own, and it is not a time trigger at all. It buffers the records that flow through it during an epoch and releases ",[357,824,825],{},"at most one per key at the commit barrier",", inside that epoch's transaction, carrying the operator's state as of the epoch's consistent cut. Nothing is emitted between barriers. It needs no store and no changelog: the buffer lives inside one epoch, and on a crash the transaction aborts and the epoch is reprocessed, rebuilding it. That is the valve the ",[362,828,830],{"href":829},"\u002Fblog\u002Finternal-consistency","internal-consistency post"," is built on — a coherent answer at every commit rather than a stream of partial ones — and, unlike an eager ",[397,833,561],{}," buffer, it never evicts an intermediate under memory pressure, because an evicted intermediate is precisely the inconsistent answer it exists to prevent.",[354,836,837,838,841],{},"Both carry the buffered record out: a release keeps the latest update's headers and timestamp, and records released together leave in buffer-time, then key, order — as in Kafka Streams. ",[397,839,840],{},"BufferConfig.maxBytes(...)"," bounds the serialised buffer.",[459,843,482],{"id":844},"one-clock-decides-late",[354,846,847],{},"\"Late\" happens twice on a record's way into a window, and only the second one drops anything.",[354,849,850,851,854,855,858],{},"The first is at the dispatcher. After a batch has advanced the per-partition watermarks, every record whose event time is below the ",[373,852,853],{},"global"," watermark is counted as a late arrival — ",[397,856,857],{},"stoatflow.watermark.late.records.total",", a debug-level meter — and then dispatched like any other. Nothing at this layer drops a record or sends it to a dead-letter topic; the counter measures how out-of-order the input is, not how much of it is lost.",[354,860,861,862,865,866,869,870,873,874,877,878,880,881,884,885,888,889,892,893,896,897,900,901,903],{},"The second is inside the windowed processor, and it judges the record's window, not the record. Every lane carries a ",[373,863,864],{},"floor",": the watermark stamped on the last commit barrier it crossed. A record is dropped when its window closes at or before that floor — ",[397,867,868],{},"end + grace"," for a time window, ",[397,871,872],{},"start + D + 1 + grace"," for a sliding window, ",[397,875,876],{},"mergedEnd + gap + grace"," for a session. Every window kind and emit strategy uses this one test, and so does ",[397,879,557],{},"; it is the clock that closes the window too, so a window that has closed never changes again. Those drops are the real signal: ",[397,882,883],{},"stoatflow.dropped.records.total{reason=\"late_window\"}"," (",[397,886,887],{},"late_session"," for sessions), Kafka Streams' ",[397,890,891],{},"dropped-records"," parity, at info level. We used to admit on a per-operator stream time and close on the watermark, as ",[362,894,895],{"href":364},"part 1"," described; behind a ",[397,898,899],{},"filter"," the two disagreed, and a window already emitted could still take a write. Since rc.4 the watermark is the only clock (ADR-149). Kafka Streams decides on each aggregation's own clock, the largest event time that reached it, so StoatFlow admits a record up to the out-of-orderness bound more out of order and, behind a ",[397,902,899],{},", drops one Kafka Streams would still count.",[354,905,906],{},"The boundary is inclusive: a window whose close time equals the floor is closed — as the emission is, and as Kafka Streams' drop is. It used to be strict, one tick longer than Kafka Streams, to protect the race the next section is about: the watermark advanced, on other keys, to exactly a window's close while a record for that window is still queued on its lane. With the floor taken from barrier stamps that record cannot lose: it was dispatched before the barrier whose stamp reaches the close, so its lane judges it against the stamp before, and admits it. The one-tick entry in the ADR-118 register is retired.",[354,908,909,910,912,913,915],{},"Sessions are judged after the merge scan, on the merged session: a record is late when ",[397,911,876],{}," is at or before the floor, and is counted as ",[397,914,887],{},". A late record that bridges two still-open sessions is folded in and rescued, as in Kafka Streams. A closed session, though, is final: it is left out of the merge scan, so an on-time record within its gap starts a new session instead of re-opening one already emitted — Flink's behaviour; Kafka Streams merges and retracts.",[459,917,488],{"id":918},"the-committed-watermark-frontier",[354,920,921,922,925],{},"The watermark makes one claim: no record with a lower timestamp is still to come from the sources. It does not claim that every record with a lower timestamp has been ",[373,923,924],{},"processed",", and inside a single instance that gap is concrete. Records reach a windowed operator from many key-affinity lanes at once, each with its own queue.",[354,927,928,929,932],{},"Take a window ",[397,930,931],{},"[10:00, 10:05)"," with a minute of grace, closing at 10:06:00, on a source with a 30-second out-of-orderness bound. A record R stamped 10:04:50 — inside the window, inside grace — is dispatched to lane 7 and queues behind other keys. A moment later a record F stamped 10:06:30 is dispatched to lane 3, and because the watermark is the maximum seen less 30 seconds, F moves the raw watermark to 10:06:00: the window's close. Under the naive model the punctuation lane now processes the watermark event, range-fetches the window and emits its final count. R is not in it.",[354,934,935,936,938],{},"Then lane 7 processes R. Is R admitted? Judge it against the live watermark and the answer depends on timing: R was on time when it was dispatched, but a lane that reaches it after F has moved the watermark sees a window that is already closing. Admit it, and the final count already emitted is wrong, permanently, because ",[397,937,553],{}," emits each window once; drop it, and a record that arrived in time is lost. Same input, two outcomes, decided by which virtual thread the scheduler ran first — and the emission happened before either had run, so it could not know. Under exactly-once there is a second problem on top: the records that did contribute to that count are in a transaction that has not committed and can still abort.",[354,940,941],{},"StoatFlow's answer is to judge both the drop and the emission against watermarks the barriers carry, never the live one. At offset fixation the dispatcher stamps the current watermark on the barrier — call barrier N's stamp W(N) — and writes it to the state, where it commits with transaction N. Every lane keeps a short ring of the stamps it has crossed:",[564,943,948],{"className":944,"code":946,"language":947},[945],"language-text","floor of a record lane that crossed barrier N   = W(N)\nfrontier of the punctuation lane at barrier N   = W(N−1)\n","text",[397,949,946],{"__ignoreMap":569},[354,951,952,953,956,957,959],{},"A record lane judges every record it processes next against its floor. The punctuation lane, once every record lane of its sub-topology has crossed barrier N, hands W(N−1) — the stamp of the barrier ",[373,954,955],{},"before"," — to every watermark-aware operator of the sub-topology as the gate for its final emissions, if it moved: the range fetch above runs up to it, not up to the raw watermark. The emitted records carry the event time of what they emit — a time or sliding window its latest record's time, a session its end, a ",[397,958,749],{}," release its buffered record's time, the null side of a join its unmatched record's time — as in Kafka Streams, so a final result windowed again downstream lands in the right window. Only the moment they appear depends on the barrier.",[354,961,962,963,966,967,970],{},"Why the frontier is safe to emit against comes down to two facts about barriers. First, barrier N is only ever created after epoch N−1's transaction has committed — the \"one committing, one processing\" rule of ADR-077 — and every record dispatched before barrier N−1 was processed before its lane reported that barrier, because lanes are FIFO. So at barrier N, everything dispatched before N−1 is not just processed but durable. Second, every record lane that has crossed barrier N−1 judges what it processes next against W(N−1), so a record for a window closing at or below W(N−1) that comes later is dropped as late. Put together: at barrier N, the membership of every window closing at or below W(N−1) is ",[357,964,965],{},"settled"," — no record can still join it — and ",[357,968,969],{},"committed"," — every record that did join it is in a transaction that cannot abort. In the scenario, R and F were both dispatched in epoch 3, and barrier 3 was stamped 10:06:00. Lane 7 processes R before it crosses barrier 3, against its floor W(2) = 10:05:20, so R is admitted whichever lane runs first; both are committed in transaction 3, the frontier reaches 10:06:00 at barrier 4, and the count emitted there is the one that actually happened.",[354,972,973],{},"The emission rides the barrier, not the watermark event, for a reason that is easy to miss: the frontier only changes at barriers, and an idle stream fires no further watermark events, so the barrier is the only tick guaranteed to keep arriving — and the emission work signal keeps barriers coming until the last final results are emitted and committed, under at-least-once too, where nothing else would. It is also what makes the flush exactly-once. The lane has already reported barrier N when it flushes, so the emitted records are epoch-gated into transaction N+1, together with the update to the durable frontier in the next section — the result and the record that it has been emitted commit atomically.",[354,975,976,977,980,981,984,985,988,989,992,993,996],{},"That is where the lag comes from. A watermark advance that lands just after barrier N−1 is fixed is stamped on barrier N and becomes the frontier at barrier N+1 — emitted at N+1, up to two commit intervals after the advance. A sub-topology fed by another's final results — a window over a window across a re-key, say — runs two barriers further back for each such hop, because those finals reach it one epoch after they were emitted. The interval self-tunes: ",[397,978,979],{},"stoatflow.commit-barrier.interval-ms"," seeds it at 500 ms, ",[397,982,983],{},"interval-factor"," (1.5) scales it with the measured commit duration, and ",[397,986,987],{},"min-interval-ms"," \u002F ",[397,990,991],{},"max-interval-ms"," clamp it to ",[397,994,995],{},"[150 ms, 5 s]"," by default. So the frontier adds between 0.3 and 10 seconds to a final result, typically around a second, and as much again per such hop, on top of the out-of-orderness bound Part 1 already charged.",[354,998,999],{},[1000,1001],"img",{"alt":1002,"src":1003},"A wall-clock timeline with five commit barriers, B1 to B5, and the transactions that commit between them. The raw watermark steps once per epoch — 10:04:40, 10:05:20, then exactly 10:06:00 inside epoch 3, then 10:06:40, 10:07:20 — and each barrier carries the watermark stamped on it at fixation. Below it the frontier row rolls one barrier behind: the stamp of the barrier before. The frontier reaches 10:06:00 at barrier 4, one barrier after the stamp did. On the window row a gold marker shows where the naive model emits, at the watermark event in epoch 3, and a teal marker where StoatFlow emits, at barrier 4 into transaction 5. Two lane rows show record R, stamped 10:04:50 and inside the window, dispatched to lane 7 just before the raw watermark reached the close and judged against barrier 2's stamp, 10:05:20, so it is admitted; and record F, stamped 10:06:30, the record that moved the watermark, processed on lane 3.","\u002Fassets\u002Fblog\u002Fclosing-a-window-under-exactly-once_committed-watermark-frontier.svg",[354,1005,1006],{},"Read the frontier rows against the barriers: barrier 3 is stamped 10:06:00 while the frontier is still barrier 2's stamp, 10:05:20, so nothing is emitted; at barrier 4 the frontier becomes 10:06:00 and StoatFlow emits, into transaction 5 — after R and F, on the lane rows, have both been processed and made durable by transaction 3. Lane 7 judged R against barrier 2's stamp, 10:05:20, so it was admitted whichever lane ran first.",[354,1008,1009,1010,1013,1014,1017],{},"Two smaller mechanics complete the picture. Shutdown does not wait for two more barriers: the final barrier carries a ",[373,1011,1012],{},"final flush",". Each punctuation lane waits until every record lane of its sub-topology has crossed it — every record of the last epoch processed — then emits every window closing at or before the barrier's stamp before it reports the barrier, so those results commit in the final transaction, in every sub-topology, and the barrier records that it flushed, so the next start resumes at that watermark. A record lane that does not cross in time aborts the barrier instead, and the next start resumes from the cut before. And an unset frontier — ",[397,1015,1016],{},"Long.MIN_VALUE",", before the first stamps have rolled through — runs no emission pass at all.",[459,1019,494],{"id":1020},"what-survives-a-restart",[354,1022,1023,1026,1027,1030,1031,1034,1035,1039,1040,1043,1044,1047,1048,1050,1051,1054],{},[357,1024,1025],{},"The floor is persisted, with the state."," The barrier stamps do not live only in the engine's memory: the dispatcher writes the last few into an engine store, ",[397,1028,1029],{},"stoatflow-watermark"," — in memory, changelogged to ",[397,1032,1033],{},"\u003Capplication.id>-stoatflow-watermark-changelog",", one compacted partition — before the epoch rotates, so they commit inside the barrier's own transaction, exactly with the cut. A crash restart, an in-place engine restart (",[362,1036,1038],{"href":1037},"\u002Fblog\u002Fin-place-restart-multi-standby","ADR-127"," builds a fresh engine) and an HA promotion all seed every lane's floor and every punctuation lane's frontier from it. After a crash the newest stamp is left out: the emission pass barrier ",[373,1041,1042],{},"c"," triggered commits only with transaction ",[373,1045,1046],{},"c + 1",", so the first barrier after the restart plays barrier ",[373,1049,1042],{},"'s role and runs that pass again, judged against the durable emit frontier below. After a graceful shutdown it is kept: the final barrier flushed everything up to its stamp and recorded that it did. The watermark itself resumes from each source partition's largest event time, committed with the offsets in Kafka Streams' ",[397,1052,1053],{},"TopicPartitionMetadata"," format, not from re-read records.",[354,1056,1057,1058,1061,1062,1065],{},"Why the state and not the offsets: offset tools — ",[397,1059,1060],{},"kafka-consumer-groups --reset-offsets",", MirrorMaker 2's offset sync — rewrite offsets with empty metadata, and offsets expire after a long downtime; none of that touches a changelogged store. Flink does not checkpoint watermarks (FLINK-5601, ",[362,1063,895],{"href":1064},"\u002Fblog\u002Fthree-clocks-stream-time-vs-watermarks#flink-one-clock-pessimistic-and-per-operator","), and a Kafka Streams windowed aggregation rebuilds its stream time from the records it processes after a restart. What this buys is that no record late before a restart is admitted after it, and the first window close after a restart does not wait for event time to be rebuilt.",[354,1067,1068,1071,1072,1074,1075,1078],{},[357,1069,1070],{},"The emit frontier is persisted."," Each ",[397,1073,553],{}," operator keeps the highest close it has already emitted in a companion store — ",[397,1076,1077],{},"\u003Cstore>-emitfrontier",", a compacted key-value store registered like any other, so it rides the ordinary changelog write, the epoch commit and the restoration path with no bespoke code of its own (ADR-130). Because it is an ordinary store, the frontier update and the emitted results commit in the same transaction, and a window emitted in an epoch that later aborts is emitted again, once, when the epoch is reprocessed — never twice, and never zero times. Four integration tests pin it: a pending window or session is re-emitted exactly once after a crash, an already-emitted one is not re-emitted, and a session that merges across a restart emits its merged result once.",[354,1080,1081,1082,1085,1086,1089,1090,1093],{},"The same idea appears at a Kafka Streams cutover. With the frontier companion left unseeded, the first emission pass after the ",[362,1083,1084],{"href":280},"migration tool"," has translated a windowed changelog would re-emit a final result for ",[373,1087,1088],{},"every"," restored closed window still inside retention, in one burst; ",[397,1091,1092],{},"emitFrontier: true"," seeds the companion from the store's own maximum changelog timestamp, so nothing already emitted is emitted again. The late-drop floor, which a migrated application does not have yet, is taken from Kafka Streams' own partition times in the committed offsets.",[459,1095,500],{"id":1096},"when-state-expires",[354,1098,1099,1100,1103,1104,554,1107,1110,1111,1114],{},"Windowed, sliding, session and cogroup stores expire their state at their retention (",[397,1101,1102],{},"Materialized.withRetention",", else ",[397,1105,1106],{},"size + grace",[397,1108,1109],{},"2·D + grace"," or ",[397,1112,1113],{},"gap + grace","), and the clock that drives it is never the raw watermark:",[752,1116,1117,1130],{},[755,1118,1119],{},[758,1120,1121,1124,1127],{},[761,1122,1123],{},"Store and emit strategy",[761,1125,1126],{},"Expiry clock",[761,1128,1129],{},"Why",[771,1131,1132,1145],{},[758,1133,1134,1139,1142],{},[776,1135,1136,1137],{},"any, ",[397,1138,739],{},[776,1140,1141],{},"the durable emit frontier",[776,1143,1144],{},"expiry must never overtake an un-emitted close — emit, then expire, in the same pass",[758,1146,1147,1151,1154],{},[776,1148,1136,1149],{},[397,1150,735],{},[776,1152,1153],{},"the committed watermark",[776,1155,1156],{},"every closed window was already forwarded per record, and no lane's floor is behind it",[354,1158,1159],{},"Sessions used to be the odd row, expiring on the operator's own stream time; with that clock gone they expire on the committed watermark like everything else. The drop, the close and the expiry read one clock, and no lane's floor is behind the watermark expiry uses — the punctuation lane runs its pass only once every record lane has crossed the barrier — so nothing admitted lands in a window or session already removed.",[354,1161,1162,1163,1166],{},"There is one more schedule, and it is not event time at all. The store-level retention tick that reclaims versioned stores' history (ADR-142) fires from the punctuation lane's ",[373,1164,1165],{},"barrier"," handler, after the lane has reported the barrier and after the frontier flush — not from a watermark event. Three reasons, in the order the code gives them: the deletes become ordinary epoch mutations, so a local delete and the source offsets commit in one transaction rather than best-effort; the epoch attribution is right, because the handler has already cut the epoch; and it fires for every topology, where watermark events only exist in event-time ones.",[354,1168,1169,1170,1172,1173,1176,1177,1181],{},"What all of this means for state size under ",[397,1171,739],{},": effective retention is bounded by ",[397,1174,1175],{},"retention + segmentInterval + (watermark − emit frontier)",", and that last term is the frontier lag from the previous section. The ",[362,1178,1180],{"href":1179},"\u002Fdocs\u002Fbuilding\u002Fwindowing#retention-under-emitstrategyonwindowclose","windowing page's retention contract"," has the regimes and worked examples.",[459,1183,506],{"id":1184},"tradeoffs-and-limits",[354,1186,1187,1190,1191,558,1194,1196,1197,1199,1200,1202],{},[357,1188,1189],{},"Final results trail the watermark by up to two commit intervals"," — two more for each sub-topology hop they are fed through. With the default cadence bounds that is between 0.3 and 10 seconds, typically about a second, on top of the out-of-orderness bound. Compute yours from ",[397,1192,1193],{},"stoatflow.commit-barrier.min-interval-ms",[397,1195,991],{},"; a lower ",[397,1198,991],{}," tightens the worst case at the cost of more commits. ",[397,1201,735],{}," results are unaffected — they forward per record and never wait for a barrier.",[354,1204,1205,1208,1209,1212],{},[357,1206,1207],{},"The null side of a left or outer stream-stream join trails the same way",", because it is a final emission gated on the same committed watermark. One further divergence rides with it: N identical unmatched arrivals at the same key and timestamp emit N null-joins, where Kafka Streams' plain put overwrites and emits one. Output ",[373,1210,1211],{},"sets"," are identical; timing and multiplicity are not.",[354,1214,1215,1218,1219,1222,1223,1226,1227,1230,1231,457],{},[357,1216,1217],{},"Late records are dropped silently at the processor."," A debug log line and the ",[397,1220,1221],{},"dropped.records.total"," counter — no dead-letter topic, no callback. If the record matters, widen grace, or intercept it upstream in a ",[397,1224,1225],{},"Processor"," with ",[397,1228,1229],{},"isLate()",", which is true when the record's timestamp is below the watermark during ",[397,1232,1233],{},"process(...)",[354,1235,1236,1239,1240,1242],{},[357,1237,1238],{},"Lateness can differ from Kafka Streams."," Kafka Streams decides per aggregation, on the largest event time that reached it; StoatFlow decides on the watermark, which trails the newest event by the out-of-orderness bound and also moves on records a ",[397,1241,899],{}," removed. A record up to that bound out of order is admitted where Kafka Streams drops it, and behind a filter one Kafka Streams would still count is dropped (KSC-132 in the ADR-118 register).",[354,1244,1245,1248,1249,1252],{},[357,1246,1247],{},"A crash restart re-runs one emission pass."," The floor and the emit frontier come back with the state, so nothing is emitted twice and nothing late before the crash is admitted after it; the pass the last committed barrier triggered had not committed, so the first barrier after the restart runs it again. With ",[397,1250,1251],{},"stoatflow.changelog.enabled: false"," the floor has no changelog to come back from, and the engine resumes it from the partition times committed with the offsets.",[354,1254,1255,1260,1261,1263],{},[357,1256,1257,1259],{},[397,1258,821],{}," is a valve, not a trigger."," It consults no clock and can be combined with a time-based ",[397,1262,749],{}," upstream, but it is not a substitute for one.",[354,1265,1266,1272,1273,1275,1276,1278,1279,1282],{},[357,1267,1268,1269,1271],{},"One extra changelog topic per ",[397,1270,553],{}," aggregation, and one per application."," The emit frontier's companion store is compacted and takes its aggregation store's changelog partition count; it appears in the topic list and the restore plan. Every topology with a windowed aggregation, a time-based ",[397,1274,749],{}," or a stream-stream join also gets ",[397,1277,1033],{},", one compacted partition, for the floor; it is registered at engine start, so ",[397,1280,1281],{},"describe()"," does not list it, and a deployment that pre-creates its topics must create it too.",[459,1284,512],{"id":1285},"whats-next",[354,1287,1288,1289,1292,1293,1296,1297,1301],{},"Everything above is observable — but only if you know which counter is off by default, why a window-close test that passes in the ",[397,1290,1291],{},"TopologyTestDriver"," emits later on a real broker, and what ",[397,1294,1295],{},"\u002Fwatermarks"," is telling you when the global value stops moving. Part 4, ",[362,1298,1299],{"href":378},[373,1300,379],{},", is the operator's half of this series: which tests transfer, how to read the endpoint, the meters that matter, the one alert to set on day one, and what a restart does to the watermark.",[354,1303,1304],{},[357,1305,1306],{},"Read on:",[418,1308,1309,1314,1319,1324,1333,1338,1343],{},[421,1310,1311,1313],{},[362,1312,130],{"href":131}," — emit strategies, suppression, buffer configuration, the retention contract.",[421,1315,1316,1318],{},[362,1317,454],{"href":58}," — the commit barrier the frontier hangs off.",[421,1320,1321,1323],{},[362,1322,72],{"href":73}," — the concept page.",[421,1325,1326,1329,1330,1332],{},[362,1327,1328],{"href":829},"Internal consistency"," — ",[397,1331,821],{}," in action.",[421,1334,1335,1337],{},[362,1336,370],{"href":369}," — part 2 of this series.",[421,1339,1340,1342],{},[362,1341,379],{"href":378}," — part 4 of this series.",[421,1344,1345,1348,1349,1352],{},[362,1346,1347],{"href":280},"Migrating with data"," — the ",[397,1350,1351],{},"emitFrontier"," cutover flag.",[1354,1355,1356],"style",{},"html pre.shiki code .suHK_, html code.shiki .suHK_{--shiki-default:#393A34}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":569,"searchDepth":583,"depth":583,"links":1358},[1359,1360,1361,1362,1363,1364,1365,1366,1367],{"id":461,"depth":583,"text":462},{"id":515,"depth":583,"text":470},{"id":721,"depth":583,"text":476},{"id":844,"depth":583,"text":482},{"id":918,"depth":583,"text":488},{"id":1020,"depth":583,"text":494},{"id":1096,"depth":583,"text":500},{"id":1184,"depth":583,"text":506},{"id":1285,"depth":583,"text":512},"2026-09-29","In StoatFlow a window's final result is not emitted the instant the watermark passes its close — it is emitted at a commit barrier, judged against the watermark stamped on the barrier before, up to two commit intervals later. Part 3 of the event-time series follows one watermark advance from the consumer thread to the committed result: what listens, the one clock that drops and closes, the committed-watermark frontier, and what survives a restart.","md",{"src":1372},"\u002Fassets\u002Fblog\u002Fog\u002Fclosing-a-window-under-exactly-once.png",{},null,"\u002Fblog\u002Fclosing-a-window-under-exactly-once",{"title":336,"description":1369},{"loc":1375},"3.blog\u002F17.closing-a-window-under-exactly-once","vn0PaZxGWf1-zKhrw_qtLp5GxVyRj5AazzSJvPlhNOM",[1381,1386],{"title":1382,"path":1383,"stem":1384,"description":1385,"children":-1},"A use-after-free in a RocksDB iterator, and the test that passed on Linux","\u002Fblog\u002Frocksdb-iterator-use-after-free","3.blog\u002F25.rocksdb-iterator-use-after-free","A StoatFlow state store layers each epoch's uncommitted writes over RocksDB in native write batches, and an iterator reads through all of them. Four defects let a batch be freed, or rewritten, under an iterator still reading it: segmentation faults, keys read twice, a value read back as garbage. None was reachable through a store StoatFlow builds. The interesting part is how we proved them — one JVM per scenario, the allocator scribbling freed memory — and why the first version of that test passed on Linux.",{"title":1387,"path":369,"stem":1388,"description":1389,"children":-1},"Making the watermark move: sources, idleness, and the partition that stopped the world","3.blog\u002F16.making-the-watermark-move","StoatFlow's watermark is a minimum across partitions, so one source decides for all of them. Part 2 of the event-time series is the configuration discipline that follows: what each source declares about its own time, what idleness does and the two bugs it took to get it right, the one strategy StoatFlow refuses beside a window, how to give a single source a Kafka Streams-style watermark, and why ordered dispatch already does most of what Flink needs watermark alignment for.",1792981295978]