[{"data":1,"prerenderedAt":2887},["ShallowReactive",2],{"navigation":3,"\u002Fblog\u002Fscheduled-sources":334,"\u002Fblog\u002Fscheduled-sources-surround":2876},[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":2866,"description":2867,"draft":42,"extension":2868,"image":2869,"meta":2871,"navigation":814,"path":2872,"seo":2873,"stem":2874,"__hash__":2875},"posts\u002F3.blog\u002F14.scheduled-sources.md","Scheduled sources: a topology source that isn't a topic",[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":2854},"minimark",[348,371,427,432,476,479,509,531,556,559,562,565,570,589,594,606,611,616,621,633,639,642,645,648,655,698,709,712,717,734,744,755,758,765,772,1226,1229,1569,1584,1818,1821,1837,1974,1993,1996,2002,2005,2020,2023,2239,2242,2571,2588,2591,2597,2600,2731,2734,2737,2842,2845,2850],[349,350,351,352,356,357,361,362,366,367,370],"p",{},"Every source in a Kafka Streams topology is a topic. It is a clean rule, and it has a cost: anything that isn't already in Kafka — the calendar, a snapshot across the keys you hold, a sweep over your own state — needs a job ",[353,354,355],"em",{},"outside"," the topology to write a trigger into a topic first, or a punctuator bent into a source. The job is its own deployment with its own failure modes and no transactional link to the processing it triggers; the punctuator was never designed for the role, and a great many teams used it that way anyway, because it was the only in-topology option. StoatFlow's ",[358,359,360],"strong",{},"scheduled sources"," make it first-class: ",[363,364,365],"code",{},"StreamsBuilder.scheduled()"," runs your code on a schedule — read the clock, read your state — and whatever it forwards becomes an ordinary ",[363,368,369],{},"KStream",": keyed, processed in parallel on the lanes, and committed in the same transaction as everything else. The same code can reach outside the engine too, to a database or an API; that read sits outside the transaction, and this post stays with the cases the engine can stand behind.",[372,373,374,379],"blockquote",{},[349,375,376],{},[358,377,378],{},"TL;DR",[380,381,382,400,406,412,418],"ul",{},[383,384,385,388,389,391,392,395,396,399],"li",{},[358,386,387],{},"What:"," ",[363,390,365],{}," — a source node that runs a ",[363,393,394],{},"ScheduledEmitter"," on an interval or a cron schedule and returns a ",[363,397,398],{},"KStream\u003CK, V>",". The emitter is your code, with the clock and a read-only view of every state store.",[383,401,402,405],{},[358,403,404],{},"Why:"," without it, time-driven and state-driven work needed an external job and a topic, or a punctuator used as a workaround — serial, on the processing path, outside your commit.",[383,407,408,411],{},[358,409,410],{},"How:"," the emitter runs on its own virtual thread; its records enter the engine like any source record, are routed by key to the lanes, and commit with the Kafka-sourced ones in one transaction.",[383,413,414,417],{},[358,415,416],{},"The catch:"," the emitter's view of state is read-only, by design; the scan is one thread's work per tick; a run does not yield to live traffic on its own; and a read from outside the engine is not part of the transaction.",[383,419,420,388,423,426],{},[358,421,422],{},"Docs:",[424,425,145],"a",{"href":146},".",[428,429,431],"h2",{"id":430},"in-this-post","In this post",[380,433,434,440,446,452,458,464,470],{},[383,435,436],{},[424,437,439],{"href":438},"#a-source-that-isnt-a-topic","A source that isn't a topic",[383,441,442],{},[424,443,445],{"href":444},"#what-this-opens-up","What this opens up",[383,447,448],{},[424,449,451],{"href":450},"#how-it-works","How it works",[383,453,454],{},[424,455,457],{"href":456},"#a-first-example-a-snapshot-across-keys","A first example: a snapshot across keys",[383,459,460],{},[424,461,463],{"href":462},"#one-use-case-in-depth-the-scan-and-act-workaround","One use case in depth: the scan-and-act workaround",[383,465,466],{},[424,467,469],{"href":468},"#tradeoffs-and-limits","Tradeoffs and limits",[383,471,472],{},[424,473,475],{"href":474},"#timer-punctuator-or-scheduled-source","Timer, punctuator, or scheduled source",[428,477,439],{"id":478},"a-source-that-isnt-a-topic",[349,480,481,484,485,488,489,492,493,496,497,500,501,504,505,508],{},[363,482,483],{},"scheduled()"," adds a node to the topology with no topic behind it. Two things define it: a schedule and an emitter. The schedule is an interval — on wall-clock time, or on stream time so it follows the watermark — or a cron expression (",[363,486,487],{},"CronExpression.unix",", ",[363,490,491],{},".quartz"," or ",[363,494,495],{},".spring",", matching the dialect of the string). The emitter is a ",[363,498,499],{},"ScheduledEmitter\u003CK, V>",": a function that receives a ",[363,502,503],{},"ScheduledEmitterContext"," and calls ",[363,506,507],{},"forward(key, value)"," zero, one or many times.",[349,510,511,512,515,516,519,520,523,524,527,528,530],{},"What the emitter does in between is up to you. It has two inputs the engine vouches for. The clock — ",[363,513,514],{},"currentWallClockTime()"," and ",[363,517,518],{},"currentWatermarkMs()",". And your own state, through ",[363,521,522],{},"getStateStore(name)",": a read-only view of any store in the topology, whatever its family. It is ordinary code on a virtual thread, so it ",[353,525,526],{},"can"," also reach outside — a database, an HTTP endpoint — and a blocking call there parks cheaply and holds up nothing else; but that read is not part of the Kafka transaction its records will commit in, which ",[424,529,469],{"href":468}," spells out. It has one output: keyed records.",[349,532,533,534,536,537,539,540,543,544,547,548,551,552,555],{},"The output is the point. What comes back from ",[363,535,483],{}," is a ",[363,538,398],{}," with nothing special about it. Join it against a ",[363,541,542],{},"KTable",". Aggregate or window it. ",[363,545,546],{},"toTable()"," it and join the live stream against ",[353,549,550],{},"that",". ",[363,553,554],{},"merge"," it with a stream read from Kafka. Send it to a topic. Every operator in the DSL applies, and because the records carry a key, the engine routes them to lanes the way it routes everything else — same key, same lane, in order.",[349,557,558],{},"What it replaces: the punctuator hung off an empty topic to get periodic work into the stream, and the cron-plus-producer job that existed only to put a calendar tick or a snapshot request into a topic. What it does not replace: a connector. High-volume ingestion from outside Kafka stays a connector's job, and the caveat above is why.",[428,560,445],{"id":561},"what-this-opens-up",[349,563,564],{},"A short list, deliberately without detail — each of these is a few lines of emitter plus whatever you build downstream, and every one of them reads state the engine already holds.",[349,566,567],{},[358,568,569],{},"Events derived from your own state",[380,571,572,578,586],{},[383,573,574,575,426],{},"Expiry sweeps, reconciliation passes, re-checks against thresholds that have changed since the record arrived — the case the scan-and-act workaround was invented for, ",[424,576,577],{"href":462},"covered in depth below",[383,579,580,581,585],{},"Manual TTL and eviction: entries past their timestamp, removed by a paced sweep. A timer per key is the efficient answer when the deadline is known at write time; the sweep is for when it is not, or when the eviction must stay out of the live path — and ",[424,582,584],{"href":583},"#backpressure-and-ordering","backpressure and ordering"," says what \"out of the live path\" takes. Native store TTL is planned.",[383,587,588],{},"Re-scoring: every open position against today's parameters, every open alert against the current rule set. The deadline was not knowable at ingest, so no timer could have been registered for it.",[349,590,591],{},[358,592,593],{},"A decision that needs a view across keys",[380,595,596,603],{},[383,597,598,599,602],{},"Totals and snapshots: open exposure across all accounts, how many are overdrawn right now, the top ten by balance — one pass, a few records, and something no per-key timer can see (",[424,600,601],{"href":456},"the first example below",").",[383,604,605],{},"Rankings and leaderboards, with each rank change flowing downstream under its own key.",[349,607,608],{},[358,609,610],{},"The calendar as a source",[380,612,613],{},[383,614,615],{},"End-of-day settlement, monthly billing runs, quota resets at the top of the hour, business-day cut-offs — one cron tick that fans out into one record per key.",[349,617,618],{},[358,619,620],{},"Records that no topic carries",[380,622,623,626],{},[383,624,625],{},"Heartbeats and health signals that downstream systems watch for.",[383,627,628,629,632],{},"Synthetic load for a demo or a soak test, generated in-topology (the ",[363,630,631],{},"map-filter-runtime"," example in the repository does exactly this).",[349,634,635,638],{},[358,636,637],{},"And outside data."," The same emitter can poll an endpoint or tail a table, one record per row, keyed. It works, and it is the one item here the engine does not yet vouch for: the read happens outside the Kafka transaction, so it is at-least-once from that source however exactly-once the topology is after it. We will write about that pattern when it has the guarantees it deserves; until then, treat it as possible rather than recommended.",[349,640,641],{},"What every item shares: the records are first-class, keyed, processed in parallel, and inside the commit. The external job never was; the punctuator workaround was none of the last three.",[428,643,451],{"id":644},"how-it-works",[349,646,647],{},"At the level that changes what you would build, the path is short.",[349,649,650],{},[651,652],"img",{"alt":653,"src":654},"A scheduled source end to end. A schedule fires an emitter on its own virtual thread; the emitter reads state through a read-only view and forwards keyed records. Those records join the Kafka-sourced records at the dispatcher, are routed by key to the same lanes — a scheduled settlement for acc-1 and a deposit for acc-1 land on lane 2 in that order — write state and output, and both kinds sit inside the same epoch, closed by one commit barrier and committed in one Kafka transaction.","\u002Fassets\u002Fblog\u002Fscheduled-sources_flow.svg",[656,657,658,664,670,683,689],"ol",{},[383,659,660,663],{},[358,661,662],{},"The schedule fires."," Interval sources on wall-clock time fire on the clock; on stream time they fire as the watermark advances. Cron sources fire on the clock.",[383,665,666,669],{},[358,667,668],{},"The emitter runs on its own virtual thread",", off the processing path. Its reads through the read-only view — and anything else it calls — block only that thread. Writes through the view throw, for every store family.",[383,671,672,675,676,679,680,682],{},[358,673,674],{},"Its records enter the engine like source records."," The dispatcher routes them by key to the lanes, exactly as it routes records read from Kafka. Same key, same lane — so a scheduled record for ",[363,677,678],{},"acc-1"," and a deposit for ",[363,681,678],{}," that arrives in the same epoch are processed on one lane, in a defined order.",[383,684,685,688],{},[358,686,687],{},"The lanes do the work."," Whatever you built downstream of the source runs per record with full read\u002Fwrite state and forwards to sinks.",[383,690,691,694,695,426],{},[358,692,693],{},"They commit with everything else."," The records participate in the same commit barriers as Kafka-sourced records. One barrier closes the epoch; one Kafka transaction commits state, output and offsets for both kinds of record together. Exactly-once holds for a scheduled emission exactly as it holds for a record you consumed — see ",[424,696,697],{"href":58},"Exactly-once",[349,699,700,701,704,705,708],{},"One run at a time, per source: if a tick is due while the previous run is still going, the new tick is skipped, not queued. An exception thrown by the emitter goes through the same processing-exception handler as a processor failure: ",[363,702,703],{},"continue"," drops that tick's effect, ",[363,706,707],{},"fail"," aborts the epoch and stops the application.",[349,710,711],{},"How the engine hands records from the emitter to the lanes is deliberately kept in the source. The contract is what you rely on: your code in, keyed records out, committed with the stream. Two properties of that hand-off decide what a large run does to the rest of the stream, and both are worth knowing before you write a sweep.",[713,714,716],"h3",{"id":715},"backpressure-and-ordering","Backpressure and ordering",[349,718,719,388,722,725,726,729,730,733],{},[358,720,721],{},"The emitter cannot outrun the engine.",[363,723,724],{},"forward()"," hands records into a bounded buffer — sized by ",[363,727,728],{},"lanes.queue-capacity",", 300 by default — and blocks when it is full, so a slow engine slows the emitter and never the reverse; a record then moves onto a lane only when that lane has room. The buffer's depth is the ",[363,731,732],{},"stoatflow.buffer.scheduled.source.size"," gauge. What this bounds is memory: a run cannot pile up unprocessed records.",[349,735,736,739,740,743],{},[358,737,738],{},"A run does not wait behind live traffic."," The dispatcher merges scheduled records with Kafka-sourced ones in timestamp order, and a scheduled record is placed at the watermark the run started with. That is the right position for a ",[363,741,742],{},"STREAM_TIME"," source, and it has a consequence for every other kind: the live records already buffered carry later timestamps, so the run's records go first. Live traffic wins only on equal timestamps. A sweep that emits a million records therefore leads the live stream, in lockstep with the dispatcher, until it is done.",[349,745,746,747,750,751,754],{},"So for low-priority work — a TTL sweep, a reconciliation pass — pace the run yourself: a bounded batch per tick, and a position in state so the next tick continues where this one stopped — ",[363,748,749],{},"range()"," from the stored key onwards, the position written back downstream with an atomic ",[363,752,753],{},"compute"," — and the schedule sets the rate. A per-epoch pacing option on the source — at most so many of its records in any one commit — and a background dispatch tier that drains scheduled records only when the live sources are quiet are on the roadmap; until they ship, batch-per-tick is the throttle.",[428,756,457],{"id":757},"a-first-example-a-snapshot-across-keys",[349,759,760,761,764],{},"A risk desk wants two numbers every minute: the open exposure — the sum of every positive balance — and how many accounts are overdrawn. Both are properties of the ",[353,762,763],{},"whole"," store. No timer can produce them, because a timer fires for one key; no record-driven operator can either, because they have to be true at one instant across keys that update independently. A scheduled source is one read-only pass at that instant.",[349,766,767,768,771],{},"First, the store and the processor that keeps balances current. Transactions arrive on a ",[363,769,770],{},"transactions"," topic keyed by account; the processor runs on the account's lane and is the only writer:",[773,774,779],"pre",{"className":775,"code":776,"language":777,"meta":778,"style":778},"language-kotlin shiki shiki-themes vitesse-light","private const val ACCOUNTS = \"accounts\"\n\n\u002F** Applies each transaction to the account's stored balance. Runs on the account's lane. *\u002F\nclass ApplyTransaction : ContextualFixedKeyProcessor\u003CString, Long, Long>() {\n    private lateinit var accounts: KeyValueStore\u003CString, Long>\n\n    override fun init(context: FixedKeyProcessorContext\u003CString, Long>) {\n        super.init(context)\n        accounts = context.getStateStore(ACCOUNTS)\n    }\n\n    override fun process(record: FixedKeyRecord\u003CString, Long>) {\n        accounts.put(record.key, (accounts.get(record.key) ?: 0L) + record.value)\n    }\n}\n\n\u002F** The store and the processor that keeps balances current — shared by both examples. *\u002F\nfun accountsTopology(): StreamsBuilder {\n    val builder = StreamsBuilder()\n\n    builder.addStateStore(\n        Stores.keyValueStoreBuilder(Stores.persistentKeyValueStore(ACCOUNTS), Serdes.String(), Serdes.Long()),\n    )\n\n    builder\n        .stream(\"transactions\", Consumed.with(Serdes.String(), Serdes.Long()))\n        .processValues({ ApplyTransaction() }, Named.`as`(\"apply-transaction\"), ACCOUNTS)\n\n    return builder\n}\n","kotlin","",[363,780,781,809,816,823,857,886,891,921,936,953,959,964,990,1027,1032,1038,1043,1049,1067,1084,1089,1101,1129,1135,1140,1146,1179,1207,1212,1221],{"__ignoreMap":778},[782,783,786,790,793,797,801,805],"span",{"class":784,"line":785},"line",1,[782,787,789],{"class":788},"si04Y","private",[782,791,792],{"class":788}," const",[782,794,796],{"class":795},"sbBg2"," val",[782,798,800],{"class":799},"suHK_"," ACCOUNTS ",[782,802,804],{"class":803},"sYZai","=",[782,806,808],{"class":807},"spphp"," \"accounts\"\n",[782,810,812],{"class":784,"line":811},2,[782,813,815],{"emptyLinePlaceholder":814},true,"\n",[782,817,819],{"class":784,"line":818},3,[782,820,822],{"class":821},"s8zF2","\u002F** Applies each transaction to the account's stored balance. Runs on the account's lane. *\u002F\n",[782,824,826,829,833,836,839,842,845,847,850,852,854],{"class":784,"line":825},4,[782,827,828],{"class":795},"class",[782,830,832],{"class":831},"sUxyF"," ApplyTransaction",[782,834,835],{"class":799}," : ",[782,837,838],{"class":831},"ContextualFixedKeyProcessor",[782,840,841],{"class":799},"\u003C",[782,843,844],{"class":831},"String",[782,846,488],{"class":799},[782,848,849],{"class":831},"Long",[782,851,488],{"class":799},[782,853,849],{"class":831},[782,855,856],{"class":799},">() {\n",[782,858,860,863,866,869,872,875,877,879,881,883],{"class":784,"line":859},5,[782,861,862],{"class":788},"    private",[782,864,865],{"class":788}," lateinit",[782,867,868],{"class":795}," var",[782,870,871],{"class":799}," accounts: ",[782,873,874],{"class":831},"KeyValueStore",[782,876,841],{"class":799},[782,878,844],{"class":831},[782,880,488],{"class":799},[782,882,849],{"class":831},[782,884,885],{"class":799},">\n",[782,887,889],{"class":784,"line":888},6,[782,890,815],{"emptyLinePlaceholder":814},[782,892,894,897,900,904,907,910,912,914,916,918],{"class":784,"line":893},7,[782,895,896],{"class":788},"    override",[782,898,899],{"class":795}," fun",[782,901,903],{"class":902},"sySUi"," init",[782,905,906],{"class":799},"(context: ",[782,908,909],{"class":831},"FixedKeyProcessorContext",[782,911,841],{"class":799},[782,913,844],{"class":831},[782,915,488],{"class":799},[782,917,849],{"class":831},[782,919,920],{"class":799},">) {\n",[782,922,924,928,930,933],{"class":784,"line":923},8,[782,925,927],{"class":926},"sEi1f","        super",[782,929,426],{"class":799},[782,931,932],{"class":902},"init",[782,934,935],{"class":799},"(context)\n",[782,937,939,942,944,947,950],{"class":784,"line":938},9,[782,940,941],{"class":799},"        accounts ",[782,943,804],{"class":803},[782,945,946],{"class":799}," context.",[782,948,949],{"class":902},"getStateStore",[782,951,952],{"class":799},"(ACCOUNTS)\n",[782,954,956],{"class":784,"line":955},10,[782,957,958],{"class":799},"    }\n",[782,960,962],{"class":784,"line":961},11,[782,963,815],{"emptyLinePlaceholder":814},[782,965,967,969,971,974,977,980,982,984,986,988],{"class":784,"line":966},12,[782,968,896],{"class":788},[782,970,899],{"class":795},[782,972,973],{"class":902}," process",[782,975,976],{"class":799},"(record: ",[782,978,979],{"class":831},"FixedKeyRecord",[782,981,841],{"class":799},[782,983,844],{"class":831},[782,985,488],{"class":799},[782,987,849],{"class":831},[782,989,920],{"class":799},[782,991,993,996,999,1002,1005,1008,1012,1015,1018,1021,1024],{"class":784,"line":992},13,[782,994,995],{"class":799},"        accounts.",[782,997,998],{"class":902},"put",[782,1000,1001],{"class":799},"(record.key, (accounts.",[782,1003,1004],{"class":902},"get",[782,1006,1007],{"class":799},"(record.key) ?: ",[782,1009,1011],{"class":1010},"s-TwI","0L",[782,1013,1014],{"class":799},") ",[782,1016,1017],{"class":788},"+",[782,1019,1020],{"class":799}," record.",[782,1022,1023],{"class":788},"value",[782,1025,1026],{"class":799},")\n",[782,1028,1030],{"class":784,"line":1029},14,[782,1031,958],{"class":799},[782,1033,1035],{"class":784,"line":1034},15,[782,1036,1037],{"class":799},"}\n",[782,1039,1041],{"class":784,"line":1040},16,[782,1042,815],{"emptyLinePlaceholder":814},[782,1044,1046],{"class":784,"line":1045},17,[782,1047,1048],{"class":821},"\u002F** The store and the processor that keeps balances current — shared by both examples. *\u002F\n",[782,1050,1052,1055,1058,1061,1064],{"class":784,"line":1051},18,[782,1053,1054],{"class":795},"fun",[782,1056,1057],{"class":902}," accountsTopology",[782,1059,1060],{"class":799},"(): ",[782,1062,1063],{"class":831},"StreamsBuilder",[782,1065,1066],{"class":799}," {\n",[782,1068,1070,1073,1076,1078,1081],{"class":784,"line":1069},19,[782,1071,1072],{"class":795},"    val",[782,1074,1075],{"class":799}," builder ",[782,1077,804],{"class":803},[782,1079,1080],{"class":902}," StreamsBuilder",[782,1082,1083],{"class":799},"()\n",[782,1085,1087],{"class":784,"line":1086},20,[782,1088,815],{"emptyLinePlaceholder":814},[782,1090,1092,1095,1098],{"class":784,"line":1091},21,[782,1093,1094],{"class":799},"    builder.",[782,1096,1097],{"class":902},"addStateStore",[782,1099,1100],{"class":799},"(\n",[782,1102,1104,1107,1110,1113,1116,1119,1121,1124,1126],{"class":784,"line":1103},22,[782,1105,1106],{"class":799},"        Stores.",[782,1108,1109],{"class":902},"keyValueStoreBuilder",[782,1111,1112],{"class":799},"(Stores.",[782,1114,1115],{"class":902},"persistentKeyValueStore",[782,1117,1118],{"class":799},"(ACCOUNTS), Serdes.",[782,1120,844],{"class":902},[782,1122,1123],{"class":799},"(), Serdes.",[782,1125,849],{"class":902},[782,1127,1128],{"class":799},"()),\n",[782,1130,1132],{"class":784,"line":1131},23,[782,1133,1134],{"class":799},"    )\n",[782,1136,1138],{"class":784,"line":1137},24,[782,1139,815],{"emptyLinePlaceholder":814},[782,1141,1143],{"class":784,"line":1142},25,[782,1144,1145],{"class":799},"    builder\n",[782,1147,1149,1152,1155,1158,1161,1164,1167,1170,1172,1174,1176],{"class":784,"line":1148},26,[782,1150,1151],{"class":799},"        .",[782,1153,1154],{"class":902},"stream",[782,1156,1157],{"class":799},"(",[782,1159,1160],{"class":807},"\"transactions\"",[782,1162,1163],{"class":799},", Consumed.",[782,1165,1166],{"class":902},"with",[782,1168,1169],{"class":799},"(Serdes.",[782,1171,844],{"class":902},[782,1173,1123],{"class":799},[782,1175,849],{"class":902},[782,1177,1178],{"class":799},"()))\n",[782,1180,1182,1184,1187,1190,1193,1196,1199,1201,1204],{"class":784,"line":1181},27,[782,1183,1151],{"class":799},[782,1185,1186],{"class":902},"processValues",[782,1188,1189],{"class":799},"({ ",[782,1191,1192],{"class":902},"ApplyTransaction",[782,1194,1195],{"class":799},"() }, Named.",[782,1197,1198],{"class":902},"`as`",[782,1200,1157],{"class":799},[782,1202,1203],{"class":807},"\"apply-transaction\"",[782,1205,1206],{"class":799},"), ACCOUNTS)\n",[782,1208,1210],{"class":784,"line":1209},28,[782,1211,815],{"emptyLinePlaceholder":814},[782,1213,1215,1218],{"class":784,"line":1214},29,[782,1216,1217],{"class":795},"    return",[782,1219,1220],{"class":799}," builder\n",[782,1222,1224],{"class":784,"line":1223},30,[782,1225,1037],{"class":799},[349,1227,1228],{},"Then the source: every minute, one pass, two records, each keyed by what it is:",[773,1230,1232],{"className":775,"code":1231,"language":777,"meta":778,"style":778},"fun snapshotTopology(): StreamsBuilder {\n    val builder = accountsTopology()\n\n    builder\n        .scheduled\u003CString, Long>(\n            named = Named.`as`(\"risk-snapshot\"),\n            interval = Duration.ofMinutes(1),\n            type = PunctuationType.WALL_CLOCK_TIME,\n            emitter = { context ->\n                val accounts = context.getStateStore\u003CReadOnlyKeyValueStore\u003CString, Long>>(ACCOUNTS)\n                var exposure = 0L\n                var overdrawn = 0L\n                accounts.all().use { entries ->\n                    entries.forEach { entry ->\n                        if (entry.value > 0) exposure += entry.value else if (entry.value \u003C 0) overdrawn++\n                    }\n                }\n                context.forward(\"exposure\", exposure)\n                context.forward(\"overdrawn\", overdrawn)\n            },\n        ).to(\"risk-snapshots\", Produced.with(Serdes.String(), Serdes.Long()))\n\n    return builder\n}\n",[363,1233,1234,1247,1259,1263,1267,1285,1305,1325,1335,1348,1378,1391,1402,1421,1434,1482,1487,1492,1508,1522,1527,1555,1559,1565],{"__ignoreMap":778},[782,1235,1236,1238,1241,1243,1245],{"class":784,"line":785},[782,1237,1054],{"class":795},[782,1239,1240],{"class":902}," snapshotTopology",[782,1242,1060],{"class":799},[782,1244,1063],{"class":831},[782,1246,1066],{"class":799},[782,1248,1249,1251,1253,1255,1257],{"class":784,"line":811},[782,1250,1072],{"class":795},[782,1252,1075],{"class":799},[782,1254,804],{"class":803},[782,1256,1057],{"class":902},[782,1258,1083],{"class":799},[782,1260,1261],{"class":784,"line":818},[782,1262,815],{"emptyLinePlaceholder":814},[782,1264,1265],{"class":784,"line":825},[782,1266,1145],{"class":799},[782,1268,1269,1271,1274,1276,1278,1280,1282],{"class":784,"line":859},[782,1270,1151],{"class":799},[782,1272,1273],{"class":902},"scheduled",[782,1275,841],{"class":799},[782,1277,844],{"class":831},[782,1279,488],{"class":799},[782,1281,849],{"class":831},[782,1283,1284],{"class":799},">(\n",[782,1286,1287,1290,1292,1295,1297,1299,1302],{"class":784,"line":888},[782,1288,1289],{"class":799},"            named ",[782,1291,804],{"class":803},[782,1293,1294],{"class":799}," Named.",[782,1296,1198],{"class":902},[782,1298,1157],{"class":799},[782,1300,1301],{"class":807},"\"risk-snapshot\"",[782,1303,1304],{"class":799},"),\n",[782,1306,1307,1310,1312,1315,1318,1320,1323],{"class":784,"line":893},[782,1308,1309],{"class":799},"            interval ",[782,1311,804],{"class":803},[782,1313,1314],{"class":799}," Duration.",[782,1316,1317],{"class":902},"ofMinutes",[782,1319,1157],{"class":799},[782,1321,1322],{"class":1010},"1",[782,1324,1304],{"class":799},[782,1326,1327,1330,1332],{"class":784,"line":923},[782,1328,1329],{"class":799},"            type ",[782,1331,804],{"class":803},[782,1333,1334],{"class":799}," PunctuationType.WALL_CLOCK_TIME,\n",[782,1336,1337,1340,1342,1345],{"class":784,"line":938},[782,1338,1339],{"class":799},"            emitter ",[782,1341,804],{"class":803},[782,1343,1344],{"class":799}," { context ",[782,1346,1347],{"class":803},"->\n",[782,1349,1350,1353,1356,1358,1360,1362,1364,1367,1369,1371,1373,1375],{"class":784,"line":955},[782,1351,1352],{"class":795},"                val",[782,1354,1355],{"class":799}," accounts ",[782,1357,804],{"class":803},[782,1359,946],{"class":799},[782,1361,949],{"class":902},[782,1363,841],{"class":799},[782,1365,1366],{"class":831},"ReadOnlyKeyValueStore",[782,1368,841],{"class":799},[782,1370,844],{"class":831},[782,1372,488],{"class":799},[782,1374,849],{"class":831},[782,1376,1377],{"class":799},">>(ACCOUNTS)\n",[782,1379,1380,1383,1386,1388],{"class":784,"line":961},[782,1381,1382],{"class":795},"                var",[782,1384,1385],{"class":799}," exposure ",[782,1387,804],{"class":803},[782,1389,1390],{"class":1010}," 0L\n",[782,1392,1393,1395,1398,1400],{"class":784,"line":966},[782,1394,1382],{"class":795},[782,1396,1397],{"class":799}," overdrawn ",[782,1399,804],{"class":803},[782,1401,1390],{"class":1010},[782,1403,1404,1407,1410,1413,1416,1419],{"class":784,"line":992},[782,1405,1406],{"class":799},"                accounts.",[782,1408,1409],{"class":902},"all",[782,1411,1412],{"class":799},"().",[782,1414,1415],{"class":902},"use",[782,1417,1418],{"class":799}," { entries ",[782,1420,1347],{"class":803},[782,1422,1423,1426,1429,1432],{"class":784,"line":1029},[782,1424,1425],{"class":799},"                    entries.",[782,1427,1428],{"class":902},"forEach",[782,1430,1431],{"class":799}," { entry ",[782,1433,1347],{"class":803},[782,1435,1436,1439,1442,1444,1447,1450,1453,1456,1459,1461,1464,1467,1469,1471,1474,1476,1479],{"class":784,"line":1034},[782,1437,1438],{"class":795},"                        if",[782,1440,1441],{"class":799}," (entry.",[782,1443,1023],{"class":788},[782,1445,1446],{"class":788}," >",[782,1448,1449],{"class":1010}," 0",[782,1451,1452],{"class":799},") exposure ",[782,1454,1455],{"class":803},"+=",[782,1457,1458],{"class":799}," entry.",[782,1460,1023],{"class":788},[782,1462,1463],{"class":795}," else",[782,1465,1466],{"class":795}," if",[782,1468,1441],{"class":799},[782,1470,1023],{"class":788},[782,1472,1473],{"class":788}," \u003C",[782,1475,1449],{"class":1010},[782,1477,1478],{"class":799},") overdrawn",[782,1480,1481],{"class":788},"++\n",[782,1483,1484],{"class":784,"line":1040},[782,1485,1486],{"class":799},"                    }\n",[782,1488,1489],{"class":784,"line":1045},[782,1490,1491],{"class":799},"                }\n",[782,1493,1494,1497,1500,1502,1505],{"class":784,"line":1051},[782,1495,1496],{"class":799},"                context.",[782,1498,1499],{"class":902},"forward",[782,1501,1157],{"class":799},[782,1503,1504],{"class":807},"\"exposure\"",[782,1506,1507],{"class":799},", exposure)\n",[782,1509,1510,1512,1514,1516,1519],{"class":784,"line":1069},[782,1511,1496],{"class":799},[782,1513,1499],{"class":902},[782,1515,1157],{"class":799},[782,1517,1518],{"class":807},"\"overdrawn\"",[782,1520,1521],{"class":799},", overdrawn)\n",[782,1523,1524],{"class":784,"line":1086},[782,1525,1526],{"class":799},"            },\n",[782,1528,1529,1532,1535,1537,1540,1543,1545,1547,1549,1551,1553],{"class":784,"line":1091},[782,1530,1531],{"class":799},"        ).",[782,1533,1534],{"class":902},"to",[782,1536,1157],{"class":799},[782,1538,1539],{"class":807},"\"risk-snapshots\"",[782,1541,1542],{"class":799},", Produced.",[782,1544,1166],{"class":902},[782,1546,1169],{"class":799},[782,1548,844],{"class":902},[782,1550,1123],{"class":799},[782,1552,849],{"class":902},[782,1554,1178],{"class":799},[782,1556,1557],{"class":784,"line":1103},[782,1558,815],{"emptyLinePlaceholder":814},[782,1560,1561,1563],{"class":784,"line":1131},[782,1562,1217],{"class":795},[782,1564,1220],{"class":799},[782,1566,1567],{"class":784,"line":1137},[782,1568,1037],{"class":799},[349,1570,1571,1572,1575,1576,1579,1580,1583],{},"A key per metric is what a snapshot series ",[353,1573,1574],{},"is",", and ",[363,1577,1578],{},"risk-snapshots"," is a topic that downstream systems read like any other. Nothing runs on the lanes for this one — the pass is the whole job, and the emitter is the right thread for it: a read-only scan, no writes, off the processing path, committed with the epoch it lands in. In a test, ",[363,1581,1582],{},"triggerScheduledSource(\"risk-snapshot\")"," fires it once:",[773,1585,1587],{"className":775,"code":1586,"language":777,"meta":778,"style":778},"val driver = TopologyTestDriver.fromBuilder(snapshotTopology())\nval transactions = driver.createInputTopic(\"transactions\", Serdes.String(), Serdes.Long())\nval snapshots = driver.createOutputTopic(\"risk-snapshots\", Serdes.String(), Serdes.Long())\n\ntransactions.pipeInput(\"acc-1\", 100_000L)\ntransactions.pipeInput(\"acc-2\", 25_000L)\ntransactions.pipeInput(\"acc-3\", -4_000L) \u002F\u002F overdrawn\n\ndriver.triggerScheduledSource(\"risk-snapshot\")\n\nsnapshots.readKeyValuesToList().map { \"${it.key}=${it.value}\" } shouldBe listOf(\"exposure=125000\", \"overdrawn=1\")\n",[363,1588,1589,1613,1643,1671,1675,1695,1713,1737,1741,1755,1759],{"__ignoreMap":778},[782,1590,1591,1594,1597,1599,1602,1605,1607,1610],{"class":784,"line":785},[782,1592,1593],{"class":795},"val",[782,1595,1596],{"class":799}," driver ",[782,1598,804],{"class":803},[782,1600,1601],{"class":799}," TopologyTestDriver.",[782,1603,1604],{"class":902},"fromBuilder",[782,1606,1157],{"class":799},[782,1608,1609],{"class":902},"snapshotTopology",[782,1611,1612],{"class":799},"())\n",[782,1614,1615,1617,1620,1622,1625,1628,1630,1632,1635,1637,1639,1641],{"class":784,"line":811},[782,1616,1593],{"class":795},[782,1618,1619],{"class":799}," transactions ",[782,1621,804],{"class":803},[782,1623,1624],{"class":799}," driver.",[782,1626,1627],{"class":902},"createInputTopic",[782,1629,1157],{"class":799},[782,1631,1160],{"class":807},[782,1633,1634],{"class":799},", Serdes.",[782,1636,844],{"class":902},[782,1638,1123],{"class":799},[782,1640,849],{"class":902},[782,1642,1612],{"class":799},[782,1644,1645,1647,1650,1652,1654,1657,1659,1661,1663,1665,1667,1669],{"class":784,"line":818},[782,1646,1593],{"class":795},[782,1648,1649],{"class":799}," snapshots ",[782,1651,804],{"class":803},[782,1653,1624],{"class":799},[782,1655,1656],{"class":902},"createOutputTopic",[782,1658,1157],{"class":799},[782,1660,1539],{"class":807},[782,1662,1634],{"class":799},[782,1664,844],{"class":902},[782,1666,1123],{"class":799},[782,1668,849],{"class":902},[782,1670,1612],{"class":799},[782,1672,1673],{"class":784,"line":825},[782,1674,815],{"emptyLinePlaceholder":814},[782,1676,1677,1680,1683,1685,1688,1690,1693],{"class":784,"line":859},[782,1678,1679],{"class":799},"transactions.",[782,1681,1682],{"class":902},"pipeInput",[782,1684,1157],{"class":799},[782,1686,1687],{"class":807},"\"acc-1\"",[782,1689,488],{"class":799},[782,1691,1692],{"class":1010},"100_000L",[782,1694,1026],{"class":799},[782,1696,1697,1699,1701,1703,1706,1708,1711],{"class":784,"line":888},[782,1698,1679],{"class":799},[782,1700,1682],{"class":902},[782,1702,1157],{"class":799},[782,1704,1705],{"class":807},"\"acc-2\"",[782,1707,488],{"class":799},[782,1709,1710],{"class":1010},"25_000L",[782,1712,1026],{"class":799},[782,1714,1715,1717,1719,1721,1724,1726,1729,1732,1734],{"class":784,"line":893},[782,1716,1679],{"class":799},[782,1718,1682],{"class":902},[782,1720,1157],{"class":799},[782,1722,1723],{"class":807},"\"acc-3\"",[782,1725,488],{"class":799},[782,1727,1728],{"class":788},"-",[782,1730,1731],{"class":1010},"4_000L",[782,1733,1014],{"class":799},[782,1735,1736],{"class":821},"\u002F\u002F overdrawn\n",[782,1738,1739],{"class":784,"line":923},[782,1740,815],{"emptyLinePlaceholder":814},[782,1742,1743,1746,1749,1751,1753],{"class":784,"line":938},[782,1744,1745],{"class":799},"driver.",[782,1747,1748],{"class":902},"triggerScheduledSource",[782,1750,1157],{"class":799},[782,1752,1301],{"class":807},[782,1754,1026],{"class":799},[782,1756,1757],{"class":784,"line":955},[782,1758,815],{"emptyLinePlaceholder":814},[782,1760,1761,1764,1767,1769,1772,1775,1778,1781,1784,1787,1789,1791,1794,1796,1798,1800,1803,1806,1808,1811,1813,1816],{"class":784,"line":961},[782,1762,1763],{"class":799},"snapshots.",[782,1765,1766],{"class":902},"readKeyValuesToList",[782,1768,1412],{"class":799},[782,1770,1771],{"class":902},"map",[782,1773,1774],{"class":799}," { ",[782,1776,1777],{"class":807},"\"",[782,1779,1780],{"class":795},"${",[782,1782,1783],{"class":807},"it.key",[782,1785,1786],{"class":795},"}",[782,1788,804],{"class":807},[782,1790,1780],{"class":795},[782,1792,1793],{"class":807},"it.",[782,1795,1023],{"class":788},[782,1797,1786],{"class":795},[782,1799,1777],{"class":807},[782,1801,1802],{"class":799}," } shouldBe ",[782,1804,1805],{"class":902},"listOf",[782,1807,1157],{"class":799},[782,1809,1810],{"class":807},"\"exposure=125000\"",[782,1812,488],{"class":799},[782,1814,1815],{"class":807},"\"overdrawn=1\"",[782,1817,1026],{"class":799},[428,1819,463],{"id":1820},"one-use-case-in-depth-the-scan-and-act-workaround",[349,1822,1823,1824,1828,1829,1832,1833,1836],{},"Punctuators are the right tool for periodic work on the processing path — a heartbeat, a flush of buffered output, a metrics sample, small periodic state maintenance — and StoatFlow runs them with the Kafka Streams API, on a dedicated ",[424,1825,1827],{"href":1826},"\u002Fdocs\u002Fbuilding\u002Fprocessor-api#punctuators","punctuation lane"," per sub-topology, with full read\u002Fwrite access to state. What they were never designed for, and were used for anyway, is being a ",[353,1830,1831],{},"source",": with no other way to get non-topic work into the stream, ",[363,1834,1835],{},"context.schedule(...)"," became the place for the periodic scan — every N seconds, iterate a state store and act on every entry that is due. It works, and it was always a workaround. The workaround has a shape, and the shape has a cost:",[773,1838,1840],{"className":775,"code":1839,"language":777,"meta":778,"style":778},"context.schedule(Duration.ofMinutes(1), PunctuationType.WALL_CLOCK_TIME) { now ->\n    store.all().use { entries ->\n        entries.forEach { entry ->\n            if (isDue(entry.value, now)) {\n                val result = settle(entry.value)    \u002F\u002F the business logic\n                store.put(entry.key, result)         \u002F\u002F the state mutation\n                context.forward(entry.key, result)   \u002F\u002F the output\n            }\n        }\n    }\n}\n",[363,1841,1842,1864,1879,1890,1909,1931,1944,1956,1961,1966,1970],{"__ignoreMap":778},[782,1843,1844,1847,1850,1853,1855,1857,1859,1862],{"class":784,"line":785},[782,1845,1846],{"class":799},"context.",[782,1848,1849],{"class":902},"schedule",[782,1851,1852],{"class":799},"(Duration.",[782,1854,1317],{"class":902},[782,1856,1157],{"class":799},[782,1858,1322],{"class":1010},[782,1860,1861],{"class":799},"), PunctuationType.WALL_CLOCK_TIME) { now ",[782,1863,1347],{"class":803},[782,1865,1866,1869,1871,1873,1875,1877],{"class":784,"line":811},[782,1867,1868],{"class":799},"    store.",[782,1870,1409],{"class":902},[782,1872,1412],{"class":799},[782,1874,1415],{"class":902},[782,1876,1418],{"class":799},[782,1878,1347],{"class":803},[782,1880,1881,1884,1886,1888],{"class":784,"line":818},[782,1882,1883],{"class":799},"        entries.",[782,1885,1428],{"class":902},[782,1887,1431],{"class":799},[782,1889,1347],{"class":803},[782,1891,1892,1895,1898,1901,1904,1906],{"class":784,"line":825},[782,1893,1894],{"class":795},"            if",[782,1896,1897],{"class":799}," (",[782,1899,1900],{"class":902},"isDue",[782,1902,1903],{"class":799},"(entry.",[782,1905,1023],{"class":788},[782,1907,1908],{"class":799},", now)) {\n",[782,1910,1911,1913,1916,1918,1921,1923,1925,1928],{"class":784,"line":859},[782,1912,1352],{"class":795},[782,1914,1915],{"class":799}," result ",[782,1917,804],{"class":803},[782,1919,1920],{"class":902}," settle",[782,1922,1903],{"class":799},[782,1924,1023],{"class":788},[782,1926,1927],{"class":799},")    ",[782,1929,1930],{"class":821},"\u002F\u002F the business logic\n",[782,1932,1933,1936,1938,1941],{"class":784,"line":888},[782,1934,1935],{"class":799},"                store.",[782,1937,998],{"class":902},[782,1939,1940],{"class":799},"(entry.key, result)         ",[782,1942,1943],{"class":821},"\u002F\u002F the state mutation\n",[782,1945,1946,1948,1950,1953],{"class":784,"line":893},[782,1947,1496],{"class":799},[782,1949,1499],{"class":902},[782,1951,1952],{"class":799},"(entry.key, result)   ",[782,1954,1955],{"class":821},"\u002F\u002F the output\n",[782,1957,1958],{"class":784,"line":923},[782,1959,1960],{"class":799},"            }\n",[782,1962,1963],{"class":784,"line":938},[782,1964,1965],{"class":799},"        }\n",[782,1967,1968],{"class":784,"line":955},[782,1969,958],{"class":799},[782,1971,1972],{"class":784,"line":961},[782,1973,1037],{"class":799},[349,1975,1976,1977,1980,1981,1984,1985,1988,1989,1992],{},"Two jobs are fused in that loop: ",[353,1978,1979],{},"deciding"," what needs doing — the scan, which has to see every entry — and ",[353,1982,1983],{},"doing"," it, which needs one entry at a time. In Kafka Streams the loop runs on the stream thread, in the same loop that calls ",[363,1986,1987],{},"process()",": while it iterates, no record on any task that thread owns moves, and it runs once per task, so a million entries across twelve partitions are scanned twelve times, serially. In StoatFlow the record lanes keep processing while a punctuator runs — but the punctuation lane takes part in the commit barrier, and in the default ",[363,1990,1991],{},"BLOCKING"," mode the next commit waits for it. Under exactly-once, output is visible only when the transaction commits, so a five-second scan adds five seconds to the end-to-end latency of every record on every lane in that epoch — and the six settlements inside it still ran one after another on one lane. None of this is a fault in punctuators. It is what happens when a callback built for small periodic work is asked to be an ingest path.",[349,1994,1995],{},"A scheduled source splits the two jobs. Deciding happens once, on one thread, against the whole store. Acting happens per key, on that key's lane, with full read\u002Fwrite state — in parallel across lanes, and ordered against every other record for that key. That is also why the emitter's view is read-only by design: a write from the emitter's thread would be unordered against the records for that key arriving on its lane, so the write belongs downstream, on the lane. The emitter observes; the lane acts.",[349,1997,1998],{},[651,1999],{"alt":2000,"src":2001},"Two panels with identical geometry. Left: a punctuator settles six accounts one after another on the punctuation lane while the record lanes keep processing, and the commit barrier waits for the sixth; everything the record lanes did meanwhile is marked as processed but not yet committed. Right: a scheduled source scans the store once through a read-only view and forwards one record per account to its own lane, so the settlements run three lanes wide and the barrier follows the busiest lane after three instead of six.","\u002Fassets\u002Fblog\u002Fscheduled-sources_scan-vs-act.svg",[349,2003,2004],{},"Left: six settlements in series on the punctuation lane, commit N waiting for all six, and the gold band is everything the four lanes processed meanwhile — done, but invisible downstream until the loop ends. Right: the same six on three lanes, by key, and commit N follows the busiest lane after three. The lane numbers come from the engine's real hash; only the layout was designed.",[349,2006,2007,2008,2011,2012,2016,2017,2019],{},"The example: the same accounts, and now they accrue interest daily. At midnight every account with a balance is settled — interest computed and written, a statement emitted. This is ",[353,2009,2010],{},"not"," a timer case. A ",[424,2013,2015],{"href":2014},"\u002Fdocs\u002Fbuilding\u002Fprocessor-api#timers","timer"," fires for one key at one instant you knew when the record arrived; here the trigger is a calendar event that fans out to every account at once, and which accounts are involved is a question only the store can answer. The store and ",[363,2018,1192],{}," are the ones from the first example.",[349,2021,2022],{},"The scheduled source fires at midnight, scans the store through the read-only view, and emits one record per account with something to settle. It decides; it does not act:",[773,2024,2026],{"className":775,"code":2025,"language":777,"meta":778,"style":778},"val builder = accountsTopology()\n\nval settlements =\n    builder.scheduled\u003CString, Long>(\n        named = Named.`as`(\"eod-scanner\"),\n        cron = CronExpression.unix(\"0 0 * * *\"), \u002F\u002F every day at midnight\n        emitter = { context ->\n            val day = context.currentWallClockTime() \u002F 86_400_000L\n            val accounts = context.getStateStore\u003CReadOnlyKeyValueStore\u003CString, Long>>(ACCOUNTS)\n            accounts.all().use { entries ->\n                entries.forEach { entry ->\n                    if (entry.value != 0L) context.forward(entry.key, day)\n                }\n            }\n        },\n    )\n",[363,2027,2028,2040,2044,2054,2070,2088,2112,2123,2147,2173,2188,2199,2222,2226,2230,2235],{"__ignoreMap":778},[782,2029,2030,2032,2034,2036,2038],{"class":784,"line":785},[782,2031,1593],{"class":795},[782,2033,1075],{"class":799},[782,2035,804],{"class":803},[782,2037,1057],{"class":902},[782,2039,1083],{"class":799},[782,2041,2042],{"class":784,"line":811},[782,2043,815],{"emptyLinePlaceholder":814},[782,2045,2046,2048,2051],{"class":784,"line":818},[782,2047,1593],{"class":795},[782,2049,2050],{"class":799}," settlements ",[782,2052,2053],{"class":803},"=\n",[782,2055,2056,2058,2060,2062,2064,2066,2068],{"class":784,"line":825},[782,2057,1094],{"class":799},[782,2059,1273],{"class":902},[782,2061,841],{"class":799},[782,2063,844],{"class":831},[782,2065,488],{"class":799},[782,2067,849],{"class":831},[782,2069,1284],{"class":799},[782,2071,2072,2075,2077,2079,2081,2083,2086],{"class":784,"line":859},[782,2073,2074],{"class":799},"        named ",[782,2076,804],{"class":803},[782,2078,1294],{"class":799},[782,2080,1198],{"class":902},[782,2082,1157],{"class":799},[782,2084,2085],{"class":807},"\"eod-scanner\"",[782,2087,1304],{"class":799},[782,2089,2090,2093,2095,2098,2101,2103,2106,2109],{"class":784,"line":888},[782,2091,2092],{"class":799},"        cron ",[782,2094,804],{"class":803},[782,2096,2097],{"class":799}," CronExpression.",[782,2099,2100],{"class":902},"unix",[782,2102,1157],{"class":799},[782,2104,2105],{"class":807},"\"0 0 * * *\"",[782,2107,2108],{"class":799},"), ",[782,2110,2111],{"class":821},"\u002F\u002F every day at midnight\n",[782,2113,2114,2117,2119,2121],{"class":784,"line":893},[782,2115,2116],{"class":799},"        emitter ",[782,2118,804],{"class":803},[782,2120,1344],{"class":799},[782,2122,1347],{"class":803},[782,2124,2125,2128,2131,2133,2135,2138,2141,2144],{"class":784,"line":923},[782,2126,2127],{"class":795},"            val",[782,2129,2130],{"class":799}," day ",[782,2132,804],{"class":803},[782,2134,946],{"class":799},[782,2136,2137],{"class":902},"currentWallClockTime",[782,2139,2140],{"class":799},"() ",[782,2142,2143],{"class":788},"\u002F",[782,2145,2146],{"class":1010}," 86_400_000L\n",[782,2148,2149,2151,2153,2155,2157,2159,2161,2163,2165,2167,2169,2171],{"class":784,"line":938},[782,2150,2127],{"class":795},[782,2152,1355],{"class":799},[782,2154,804],{"class":803},[782,2156,946],{"class":799},[782,2158,949],{"class":902},[782,2160,841],{"class":799},[782,2162,1366],{"class":831},[782,2164,841],{"class":799},[782,2166,844],{"class":831},[782,2168,488],{"class":799},[782,2170,849],{"class":831},[782,2172,1377],{"class":799},[782,2174,2175,2178,2180,2182,2184,2186],{"class":784,"line":955},[782,2176,2177],{"class":799},"            accounts.",[782,2179,1409],{"class":902},[782,2181,1412],{"class":799},[782,2183,1415],{"class":902},[782,2185,1418],{"class":799},[782,2187,1347],{"class":803},[782,2189,2190,2193,2195,2197],{"class":784,"line":961},[782,2191,2192],{"class":799},"                entries.",[782,2194,1428],{"class":902},[782,2196,1431],{"class":799},[782,2198,1347],{"class":803},[782,2200,2201,2204,2206,2208,2211,2214,2217,2219],{"class":784,"line":966},[782,2202,2203],{"class":795},"                    if",[782,2205,1441],{"class":799},[782,2207,1023],{"class":788},[782,2209,2210],{"class":788}," !=",[782,2212,2213],{"class":1010}," 0L",[782,2215,2216],{"class":799},") context.",[782,2218,1499],{"class":902},[782,2220,2221],{"class":799},"(entry.key, day)\n",[782,2223,2224],{"class":784,"line":992},[782,2225,1491],{"class":799},[782,2227,2228],{"class":784,"line":1029},[782,2229,1960],{"class":799},[782,2231,2232],{"class":784,"line":1034},[782,2233,2234],{"class":799},"        },\n",[782,2236,2237],{"class":784,"line":1040},[782,2238,1134],{"class":799},[349,2240,2241],{},"The processor that settles one account is attached downstream of the source, so it runs on the account's lane with read\u002Fwrite access to the same store — serialised against that account's transactions, in parallel with every other account's settlement:",[773,2243,2245],{"className":775,"code":2244,"language":777,"meta":778,"style":778},"\u002F** Accrues one day's interest for one account. Runs on that account's lane. *\u002F\nclass SettleAccount(private val dailyRateBps: Long) : ContextualProcessor\u003CString, Long, String, String>() {\n    private lateinit var accounts: KeyValueStore\u003CString, Long>\n\n    override fun init(context: ProcessorContext\u003CString, String>) {\n        super.init(context)\n        accounts = context.getStateStore(ACCOUNTS)\n    }\n\n    override fun process(record: Record\u003CString, Long>) {\n        val balance = accounts.get(record.key) ?: return\n        val interest = balance * dailyRateBps \u002F 10_000\n        accounts.put(record.key, balance + interest)\n        context().forward(record.key, \"day=${record.value} balance=${balance + interest} interest=$interest\")\n    }\n}\n\nsettlements\n    .process({ SettleAccount(dailyRateBps = 10) }, Named.`as`(\"settle-account\"), ACCOUNTS)\n    .to(\"daily-statements\", Produced.with(Serdes.String(), Serdes.String()))\n",[363,2246,2247,2252,2294,2316,2320,2343,2353,2365,2369,2373,2396,2416,2438,2452,2496,2500,2504,2508,2513,2546],{"__ignoreMap":778},[782,2248,2249],{"class":784,"line":785},[782,2250,2251],{"class":821},"\u002F** Accrues one day's interest for one account. Runs on that account's lane. *\u002F\n",[782,2253,2254,2256,2259,2261,2263,2265,2268,2270,2273,2276,2278,2280,2282,2284,2286,2288,2290,2292],{"class":784,"line":811},[782,2255,828],{"class":795},[782,2257,2258],{"class":831}," SettleAccount",[782,2260,1157],{"class":799},[782,2262,789],{"class":788},[782,2264,796],{"class":795},[782,2266,2267],{"class":799}," dailyRateBps: ",[782,2269,849],{"class":831},[782,2271,2272],{"class":799},") : ",[782,2274,2275],{"class":831},"ContextualProcessor",[782,2277,841],{"class":799},[782,2279,844],{"class":831},[782,2281,488],{"class":799},[782,2283,849],{"class":831},[782,2285,488],{"class":799},[782,2287,844],{"class":831},[782,2289,488],{"class":799},[782,2291,844],{"class":831},[782,2293,856],{"class":799},[782,2295,2296,2298,2300,2302,2304,2306,2308,2310,2312,2314],{"class":784,"line":818},[782,2297,862],{"class":788},[782,2299,865],{"class":788},[782,2301,868],{"class":795},[782,2303,871],{"class":799},[782,2305,874],{"class":831},[782,2307,841],{"class":799},[782,2309,844],{"class":831},[782,2311,488],{"class":799},[782,2313,849],{"class":831},[782,2315,885],{"class":799},[782,2317,2318],{"class":784,"line":825},[782,2319,815],{"emptyLinePlaceholder":814},[782,2321,2322,2324,2326,2328,2330,2333,2335,2337,2339,2341],{"class":784,"line":859},[782,2323,896],{"class":788},[782,2325,899],{"class":795},[782,2327,903],{"class":902},[782,2329,906],{"class":799},[782,2331,2332],{"class":831},"ProcessorContext",[782,2334,841],{"class":799},[782,2336,844],{"class":831},[782,2338,488],{"class":799},[782,2340,844],{"class":831},[782,2342,920],{"class":799},[782,2344,2345,2347,2349,2351],{"class":784,"line":888},[782,2346,927],{"class":926},[782,2348,426],{"class":799},[782,2350,932],{"class":902},[782,2352,935],{"class":799},[782,2354,2355,2357,2359,2361,2363],{"class":784,"line":893},[782,2356,941],{"class":799},[782,2358,804],{"class":803},[782,2360,946],{"class":799},[782,2362,949],{"class":902},[782,2364,952],{"class":799},[782,2366,2367],{"class":784,"line":923},[782,2368,958],{"class":799},[782,2370,2371],{"class":784,"line":938},[782,2372,815],{"emptyLinePlaceholder":814},[782,2374,2375,2377,2379,2381,2383,2386,2388,2390,2392,2394],{"class":784,"line":955},[782,2376,896],{"class":788},[782,2378,899],{"class":795},[782,2380,973],{"class":902},[782,2382,976],{"class":799},[782,2384,2385],{"class":831},"Record",[782,2387,841],{"class":799},[782,2389,844],{"class":831},[782,2391,488],{"class":799},[782,2393,849],{"class":831},[782,2395,920],{"class":799},[782,2397,2398,2401,2404,2406,2409,2411,2413],{"class":784,"line":961},[782,2399,2400],{"class":795},"        val",[782,2402,2403],{"class":799}," balance ",[782,2405,804],{"class":803},[782,2407,2408],{"class":799}," accounts.",[782,2410,1004],{"class":902},[782,2412,1007],{"class":799},[782,2414,2415],{"class":795},"return\n",[782,2417,2418,2420,2423,2425,2427,2430,2433,2435],{"class":784,"line":966},[782,2419,2400],{"class":795},[782,2421,2422],{"class":799}," interest ",[782,2424,804],{"class":803},[782,2426,2403],{"class":799},[782,2428,2429],{"class":788},"*",[782,2431,2432],{"class":799}," dailyRateBps ",[782,2434,2143],{"class":788},[782,2436,2437],{"class":1010}," 10_000\n",[782,2439,2440,2442,2444,2447,2449],{"class":784,"line":992},[782,2441,995],{"class":799},[782,2443,998],{"class":902},[782,2445,2446],{"class":799},"(record.key, balance ",[782,2448,1017],{"class":788},[782,2450,2451],{"class":799}," interest)\n",[782,2453,2454,2457,2459,2461,2464,2467,2469,2472,2474,2476,2479,2481,2484,2486,2489,2491,2494],{"class":784,"line":1029},[782,2455,2456],{"class":902},"        context",[782,2458,1412],{"class":799},[782,2460,1499],{"class":902},[782,2462,2463],{"class":799},"(record.key, ",[782,2465,2466],{"class":807},"\"day=",[782,2468,1780],{"class":795},[782,2470,2471],{"class":807},"record.",[782,2473,1023],{"class":788},[782,2475,1786],{"class":795},[782,2477,2478],{"class":807}," balance=",[782,2480,1780],{"class":795},[782,2482,2483],{"class":807},"balance ",[782,2485,1017],{"class":788},[782,2487,2488],{"class":807}," interest",[782,2490,1786],{"class":795},[782,2492,2493],{"class":807}," interest=$interest\"",[782,2495,1026],{"class":799},[782,2497,2498],{"class":784,"line":1034},[782,2499,958],{"class":799},[782,2501,2502],{"class":784,"line":1040},[782,2503,1037],{"class":799},[782,2505,2506],{"class":784,"line":1045},[782,2507,815],{"emptyLinePlaceholder":814},[782,2509,2510],{"class":784,"line":1051},[782,2511,2512],{"class":799},"settlements\n",[782,2514,2515,2518,2521,2523,2526,2529,2531,2534,2537,2539,2541,2544],{"class":784,"line":1069},[782,2516,2517],{"class":799},"    .",[782,2519,2520],{"class":902},"process",[782,2522,1189],{"class":799},[782,2524,2525],{"class":902},"SettleAccount",[782,2527,2528],{"class":799},"(dailyRateBps ",[782,2530,804],{"class":803},[782,2532,2533],{"class":1010}," 10",[782,2535,2536],{"class":799},") }, Named.",[782,2538,1198],{"class":902},[782,2540,1157],{"class":799},[782,2542,2543],{"class":807},"\"settle-account\"",[782,2545,1206],{"class":799},[782,2547,2548,2550,2552,2554,2557,2559,2561,2563,2565,2567,2569],{"class":784,"line":1086},[782,2549,2517],{"class":799},[782,2551,1534],{"class":902},[782,2553,1157],{"class":799},[782,2555,2556],{"class":807},"\"daily-statements\"",[782,2558,1542],{"class":799},[782,2560,1166],{"class":902},[782,2562,1169],{"class":799},[782,2564,844],{"class":902},[782,2566,1123],{"class":799},[782,2568,844],{"class":902},[782,2570,1178],{"class":799},[349,2572,2573,2574,2576,2577,2579,2580,2583,2584,2587],{},"A deposit for ",[363,2575,678],{}," that lands at one second past midnight is routed to the same lane as ",[363,2578,678],{},"'s settlement record. Whichever the dispatcher handed on first runs first, and the settlement reads the balance the lane has at that moment — no lock, no read-modify-write race, because the ordering is the lane's. The test has the same shape as the snapshot's: pipe a few transactions, ",[363,2581,2582],{},"triggerScheduledSource(\"eod-scanner\")",", read the statement, check the balance moved. (",[363,2585,2586],{},"advanceWallClockTime"," fires cron boundaries too, but cron resolves in the system default zone, so a test that crosses midnight is a test of the machine's zone.)",[349,2589,2590],{},"Both examples in this post run green against the current build; the code is lifted from those tests unchanged.",[349,2592,2593,2594,2596],{},"Porting the workaround from Kafka Streams is mechanical: the loop body splits where it stops reading and starts writing — everything before is the emitter, everything after is the downstream processor, and the key you would have written to the store is the key you ",[363,2595,1499],{},". The punctuator's interval becomes the source's interval or cron. Punctuators doing what they are for — a heartbeat, a flush, a metrics sample — stay exactly as they are.",[428,2598,469],{"id":2599},"tradeoffs-and-limits",[380,2601,2602,2618,2628,2634,2640,2648,2672,2678,2689,2697,2707,2713,2719],{},[383,2603,2604,2607,2608,492,2610,2613,2614,2617],{},[358,2605,2606],{},"The emitter cannot write."," Every store family — key-value, window, session, timestamped, versioned, headers-aware — is wrapped read-only, and ",[363,2609,998],{},[363,2611,2612],{},"delete"," through the view throws ",[363,2615,2616],{},"UnsupportedOperationException",". This is the point of the design, but it means a \"scan and mark as done\" loop becomes two pieces, not one. The mark belongs in the downstream processor.",[383,2619,2620,2623,2624,2627],{},[358,2621,2622],{},"A read from outside the engine is not in the transaction."," The emitter can query a database or an API; that call happens outside the Kafka transaction its records will commit in, so exactly-once holds for the records' ",[353,2625,2626],{},"effects"," and not for the read. It is at-least-once from that source until the engine can do better — which is why this post's examples read state, not the network.",[383,2629,2630,2633],{},[358,2631,2632],{},"One pass per tick, not a firehose."," One virtual thread, one run per tick, through a bounded buffer. High-volume ingestion from outside Kafka stays a connector's job.",[383,2635,2636,2639],{},[358,2637,2638],{},"Bound the run."," A slow emitter — a call that hangs, a scan that grows — holds the locked run, and every tick due meanwhile is skipped until it returns. Put timeouts on anything it calls and a budget on what it scans; the engine will not do it for you.",[383,2641,2642,2645,2646,426],{},[358,2643,2644],{},"A run does not yield to live traffic on its own."," Its records are placed at the watermark the run started with, ahead of the live records buffered at that moment; the buffer bounds memory, not latency. Pace low-priority sweeps with a bounded batch per tick and a position in state, and watch ",[363,2647,732],{},[383,2649,2650,2653,2654,2657,2658,2661,2662,2665,2666,515,2668,2671],{},[358,2651,2652],{},"The scan is still one thread's work per tick."," Parallelism applies to the ",[353,2655,2656],{},"act",", not the ",[353,2659,2660],{},"decide",". An ",[363,2663,2664],{},"all()"," over ten million entries every minute is a real cost on one virtual thread. Bound it: ",[363,2667,749],{},[363,2669,2670],{},"prefixScan()"," if the keys carry what you need, or have the processors maintain a small index store keyed by due date that the scanner reads instead of the main store. Keep deciding cheap; the lanes will absorb the doing.",[383,2673,2674,2677],{},[358,2675,2676],{},"Overlapping ticks are skipped, not queued."," If a run is still going when the next tick is due, the tick is dropped and logged. A run slower than its own interval silently halves its own frequency — watch for it.",[383,2679,2680,2683,2684,2686,2687,708],{},[358,2681,2682],{},"Failures route like a processor's."," An exception from the emitter goes to the processing-exception handler. ",[363,2685,703],{}," drops that tick — the next one re-derives everything from state; ",[363,2688,707],{},[383,2690,2691,2696],{},[358,2692,2693,2695],{},[363,2694,742],{}," is data-driven."," It fires as the watermark advances. With no records flowing it does not fire at all; use wall-clock or cron for anything that must happen regardless of throughput.",[383,2698,2699,2702,2703,2706],{},[358,2700,2701],{},"An emission has no Kafka offset."," If the process dies before the epoch commits, the emitted records are gone with the transaction — and so are the state writes the lanes made from them, since they were in the same epoch. The next tick re-derives its emissions from state, which has rolled back with everything else. That is the right behaviour, and it only holds if the emitter is a ",[353,2704,2705],{},"function of state",": \"every account with a balance\", \"every entry past its expiry\" — not \"the ones I have not emitted yet\".",[383,2708,2709,2712],{},[358,2710,2711],{},"A tick that falls while the process is down is not made up."," On start, a cron source computes its next fire time from now; a midnight the application slept through does not fire on restart. If the rule requires it, keep a \"last settled day\" in state and let the next tick settle the gap. An interval source simply resumes.",[383,2714,2715,2718],{},[358,2716,2717],{},"Cron resolves in the system default zone."," Pin the container's zone, or you have pinned it to wherever the image was built.",[383,2720,2721,388,2724,2726,2727,2730],{},[358,2722,2723],{},"No Kafka Streams equivalent.",[363,2725,483],{}," is a StoatFlow extension — see the ",[424,2728,2729],{"href":322},"compatibility matrix",". Code that uses it does not port back.",[428,2732,475],{"id":2733},"timer-punctuator-or-scheduled-source",[349,2735,2736],{},"All three do time-driven work; they answer different questions.",[2738,2739,2740,2759],"table",{},[2741,2742,2743],"thead",{},[2744,2745,2746,2750,2753,2756],"tr",{},[2747,2748,2749],"th",{},"Use",[2747,2751,2752],{},"When",[2747,2754,2755],{},"Runs on",[2747,2757,2758],{},"State access",[2760,2761,2762,2797,2817],"tbody",{},[2744,2763,2764,2779,2789,2794],{},[2765,2766,2767,1897,2772,488,2775,2778],"td",{},[424,2768,2769],{"href":2014},[358,2770,2771],{},"Timer",[363,2773,2774],{},"timerService()",[363,2776,2777],{},"onTimer",")",[2765,2780,2781,2782,515,2785,2788],{},"You know ",[353,2783,2784],{},"which key",[353,2786,2787],{},"when"," at ingest — a publish-at, an order timeout, a session expiry",[2765,2790,2791,2792],{},"That key's lane, serialised with ",[363,2793,1987],{},[2765,2795,2796],{},"Read\u002Fwrite",[2744,2798,2799,2809,2812,2815],{},[2765,2800,2801,1897,2806,2778],{},[424,2802,2803],{"href":1826},[358,2804,2805],{},"Punctuator",[363,2807,2808],{},"context.schedule",[2765,2810,2811],{},"Periodic work on the processing path — a heartbeat, a flush, a metrics sample, small periodic state maintenance; and Kafka Streams code ported as-is",[2765,2813,2814],{},"The punctuation lane; the commit waits for it",[2765,2816,2796],{},[2744,2818,2819,2829,2836,2839],{},[2765,2820,2821,1897,2826,2778],{},[424,2822,2823],{"href":146},[358,2824,2825],{},"Scheduled source",[363,2827,2828],{},"builder.scheduled",[2765,2830,2831,2832,2835],{},"The decision needs a view ",[353,2833,2834],{},"across"," keys; the trigger is a calendar event that fans out to every key; or a background sweep you want to pace",[2765,2837,2838],{},"Emitter on its own thread; the work on the lanes",[2765,2840,2841],{},"Emitter read-only; downstream read\u002Fwrite",[349,2843,2844],{},"Each row is the right tool for its question. If the record that arrives already tells you when to act on it, register a timer for that key and never iterate the store to find it. If the work is small and belongs on the processing path, a punctuator is exactly right. The scheduled source's job is the third question — and, by answering it, to stop the other two being bent into a source.",[349,2846,2847,2848,426],{},"The full API — interval and cron overloads, the emitter context, naming, and the test driver controls — is on ",[424,2849,145],{"href":146},[2851,2852,2853],"style",{},"html pre.shiki code .si04Y, html code.shiki .si04Y{--shiki-default:#AB5959}html pre.shiki code .sbBg2, html code.shiki .sbBg2{--shiki-default:#1E754F}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 .spphp, html code.shiki .spphp{--shiki-default:#B56959}html pre.shiki code .s8zF2, html code.shiki .s8zF2{--shiki-default:#A0ADA0}html pre.shiki code .sUxyF, html code.shiki .sUxyF{--shiki-default:#2E8F82}html pre.shiki code .sySUi, html code.shiki .sySUi{--shiki-default:#59873A}html pre.shiki code .sEi1f, html code.shiki .sEi1f{--shiki-default:#A65E2B}html pre.shiki code .s-TwI, html code.shiki .s-TwI{--shiki-default:#2F798A}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":778,"searchDepth":811,"depth":811,"links":2855},[2856,2857,2858,2859,2862,2863,2864,2865],{"id":430,"depth":811,"text":431},{"id":478,"depth":811,"text":439},{"id":561,"depth":811,"text":445},{"id":644,"depth":811,"text":451,"children":2860},[2861],{"id":715,"depth":818,"text":716},{"id":757,"depth":811,"text":457},{"id":1820,"depth":811,"text":463},{"id":2599,"depth":811,"text":469},{"id":2733,"depth":811,"text":475},"2026-08-29","Every source in a Kafka Streams topology is a topic. The calendar and your own state — a snapshot across keys, a sweep over what you hold — needed a job outside the topology or a punctuator bent into a source. StoatFlow's scheduled sources run your code on a schedule and turn what it forwards into an ordinary KStream: keyed, processed in parallel on the lanes, committed with the stream. What that opens up, how it works, two verified examples, and the catch — the emitter's view is read-only, a run does not yield to live traffic on its own, and a read from outside the engine is not in the transaction.","md",{"src":2870},"\u002Fassets\u002Fblog\u002Fog\u002Fscheduled-sources.png",{},"\u002Fblog\u002Fscheduled-sources",{"title":336,"description":2867},"3.blog\u002F14.scheduled-sources","WCKUyiDLzmnc5fvOA2b6BVmdicR_gSmmTViNQXBIIPY",[2877,2882],{"title":2878,"path":2879,"stem":2880,"description":2881,"children":-1},"Three clocks: stream time, Flink watermarks, and the one watermark StoatFlow computes","\u002Fblog\u002Fthree-clocks-stream-time-vs-watermarks","3.blog\u002F15.three-clocks-stream-time-vs-watermarks","Kafka Streams, Flink and StoatFlow answer \"is this window done?\" with three different clocks. StoatFlow runs two of them: it accepts a late record the way Kafka Streams does, on the operator's stream time, and closes the window the way Flink does, on a single global watermark. Part 1 of a four-part series on event time — including two corrections to a claim our own compatibility matrix made about grace.",{"title":2883,"path":2884,"stem":2885,"description":2886,"children":-1},"Cutting CI from 64 minutes to 16: choosing a runner platform","\u002Fblog\u002Fci-runner-platform-case-study","3.blog\u002F13.ci-runner-platform-case-study","Our CI p90 hit 54 minutes and the constraint was RAM, not CPU — a faster runner was the wrong instinct. We built a self-hosted Hetzner fleet, validated it, chose Blacksmith on a head-to-head benchmark, and got half the win from deleting four lines of YAML.",1788373635799]