My dispatcher cost 10 CU-hours a day. The database it kept awake cost 136.
For a month I thought I had already fixed the cost of our data refresh control panel. In September I moved its dispatcher notebook from Spark to the Python kernel and cut it from about 99 CU-hours a day to about 10.5. Then I sorted Capacity Metrics by item and found that the largest thing on the whole capacity was not a notebook or a pipeline.
It was the panel's small Fabric SQL database, at about 136 CU-hours a day. It was online about 97% of the time to do under two CPU-minutes of real query work. The dispatcher was cheap. The database it kept awake was not.
This post covers how I found that, the four changes I shipped in one day, and what I would check first on any Fabric app that polls.
The setup
The control panel is an internal web app for managing a nightly data refresh. People use it to run a pipeline on demand, pause the schedule, and change which sources and portfolios are in scope. It is deployed to Fabric through a shared app platform, and it has its own Fabric SQL database.
The panel never starts pipelines itself. Every click writes a request row into its database. A separate dispatcher notebook reads those requests, claims each one, triggers the right pipelines and writes the status back.
The catch was how the dispatcher found out there was work. It didn't. It ran on a cron schedule every minute, 1,440 times a day, and checked. A click in the panel just waited for the next tick, up to 60 seconds. The panel's UI also refreshed itself every 15 seconds, including in browser tabs nobody was looking at.
Where the capacity actually went
Capacity Metrics for the two weeks before the change, 29 September to 12 October, told a different story from the one I expected.
| Item | CU-hours a day | What it was doing |
|---|---|---|
| Panel's Fabric SQL database | ~136 | Online ~97% of the time for under 2 CPU-minutes of real work |
| Control lakehouse and its SQL endpoint | ~30 | Read by the dispatcher every minute |
| Source-launch notebook (Spark) | ~28 | Mostly an idle Spark session waiting on pipelines, 3 times a night |
| Dispatcher notebook (Python) | ~10 | 1,440 runs a day at ~30 s each |
Capacity Metrics groups items by name, so the database row also includes its own SQL endpoint and the app's server-side functions.
The notebook I had already optimized was the smallest line.
The reason is how a Fabric SQL database bills. It bills while it is online, at roughly 150 to 170 CU-seconds per online minute, and it releases compute only after 15 idle minutes. A dispatcher that touches it every 60 seconds means it never gets those 15 minutes. Neither does a forgotten browser tab polling every 15 seconds. Each touch was tiny. The cost came from the gaps between touches never being long enough.
When I first saw that figure, billing while online explained only about half of it. At 150 to 170 CU-seconds a minute and 97% online, that comes to 58 to 66 CU-hours a day. The after numbers settled it: the item fell to about 1.2 CU-hours a day. So almost all of the 136 came from the constant touches, either as online time or as the reads and GraphQL calls themselves. I haven't split those two apart.
The September port did not cause this. Even at the old 5-minute cadence the database would never have reached 15 idle minutes. But it shows the trap: I measured the thing I had changed, and the thing I had changed was not the problem.
What I changed
Four changes, all shipped on the same day.
1. The panel wakes the dispatcher
I added a small server-side function to the app that starts the dispatcher through the Fabric jobs API (POST .../jobs/instances?jobType=RunNotebook). It runs as the app's own identity. The workspace and notebook IDs are fixed inside the function, so a caller cannot point it at anything else; the caller's reason string is only logged.
Before starting anything, the function lists the notebook's job instances. If one is already NotStarted or InProgress, it returns "already running" and does nothing. Any response other than HTTP 202 counts as not started.
On the client, every change already went through one helper, so wiring in the wake was one line. There is a 10-second cooldown, and the wake never throws: a save always succeeds, and a one-line note says whether the dispatcher started. The panel also wakes the dispatcher on refresh when the last sync is more than 30 minutes old, at most once every 5 minutes. That catches someone opening the panel after a quiet spell.
2. The dispatcher keeps itself going while there is work
One wake is not enough. A pipeline run takes minutes to hours, and the dispatcher has to keep checking its status and queue follow-up steps. So each pass now ends with one question: is anything still open? That means pending, claimed or running requests from the last 24 hours, plus any setting whose desired state does not match its actual state yet.
If work is open, the pass sleeps 45 seconds, then posts a new run of itself with chain_depth + 1. Three details mattered:
- Create the next job before the current one ends. The panel's "already running?" check never sees a gap, so a click mid-chain cannot start a second chain.
- Pass every parameter forward explicitly. Any notebook parameter you leave out reverts to its default.
- Cap it. A chain stops after 240 passes, about 5 hours. Anything still open is picked up by the next panel action or the daily run.
Each request is also claimed with a conditional update: UPDATE ... SET status = 'claimed' WHERE id = @id AND status = 'pending'. If no row changed, another pass already has it and this one skips it. Two overlapping passes cannot trigger the same pipeline twice.
The every-minute schedule became one run a day at 06:00 local time, a safety net for anything a missed wake leaves behind. That is also the main trade-off: if a wake fails, the change waits for the next panel action, the 30-minute stale-sync wake, or the daily run, and the panel says "Could not start the dispatcher".
3. The panel stops polling when nobody is using it
After the first two changes went live, Capacity Metrics still showed SQL usage every minute. The cause was one open panel tab, reading the database through GraphQL every 15 seconds.
The panel now pauses auto-refresh while its tab is hidden, and after 10 minutes with no pointer, key, wheel or touch event. A banner says why, and any click resumes it. It keeps refreshing while a run is in flight, using the same "is work open?" rules as the dispatcher, so a run being watched never looks frozen. An idle panel now lets the database sleep about 25 minutes after the last interaction: 10 idle minutes, then the database's own 15.
4. The source-launch notebook moved off Spark
The notebook the nightly refresh runs three times to launch the source systems was using about 28 CU-hours a day on Spark. Most of that was an idle session waiting on pipelines, which is not work Spark is needed for. It now runs on the Python 3.12 kernel, and now averages 3.9 CU-hours a day, 86% less, doing the same job.
What happened
The last every-minute run started at 15:42 UTC. From then to 21:10 the dispatcher ran 30 times, where the old schedule would have started about 328. Of those 30, 27 were one chain driving a real workbook refresh and publish end to end. Nothing at all ran after 17:38.
That chain is the clearest picture of the new design:
| Step | Time (UTC) | Result |
|---|---|---|
| Click Refresh in the panel | 15:50:50 | Request queued |
| Panel wakes the dispatcher | 15:50:57 | Started 7 s after the click, no schedule involved |
| Workbook pipeline runs | 15:51 to 15:58 | Finished; publish queued automatically at 15:59 |
| Publish pipeline runs | 16:00 to 16:21 | Succeeded |
| Chain stops itself | 16:22:08 | 27 passes, about 71 s apart |
The last pass took 31 seconds instead of about 75, because nothing was open: it did not sleep or requeue.
Then the part I actually cared about. Capacity Metrics showed the database asleep at 18:07, 15 minutes after I closed the panel tabs. It woke for the next deploy and check from 18:10 to 18:34, then stayed asleep through at least 19:28, with zero interactive operations on the capacity.
There were two side benefits. A change now applies 7 to 9 seconds after Save instead of up to 60. And the dispatcher no longer starts during the 05:00 burst, when the nightly refresh starts about a dozen notebook sessions in the same minute.
Eleven days later, Capacity Metrics had the answer. This compares the two weeks before the change with the 11 days after it, leaving out the change day itself.
| Item | Before, 29 Sep to 12 Oct (CU-h/day) | After, 14 to 24 Oct (CU-h/day) | Change |
|---|---|---|---|
| Panel's SQL database | 135.8 | 1.24 | -99.1% |
| Control lakehouse and SQL endpoint | 30.2 | 1.42 | -95.3% |
| Source-launch notebook | 28.0 | 3.94 | -85.9% |
| Dispatcher notebook | 10.0 | 0.08 | -99.2% |
| Total | 204.0 | 6.68 | -96.7% |
That is about 197 CU-hours a day back on the capacity, roughly 13% of an F64's 1,536 a day. The busiest day after the change, with real panel work in flight, came to about 12 CU-hours, still only 6% of a normal day before it.
One caveat: the lakehouse averaged about 20 CU-hours a day until early October, then climbed to 39 to 58 in the last five days before the change. Against the earlier level, its drop is closer to 93%.
What I would check on any Fabric app that polls
- Sort Capacity Metrics by item, not by what you just changed. The biggest line on the capacity was one I had never looked at.
- For anything that bills while online, count the gaps, not the work. A Fabric SQL database pauses only after 15 idle minutes. A touch every 60 seconds and a touch every 15 seconds cost the same, because neither ever lets it pause.
- Count every client, including browser tabs. Fixing the server-side poller was not enough. One forgotten tab kept doing the same damage.
- Event-driven does not have to mean one-shot. Wake the worker on the event, let it keep itself alive while work is open, and let it stop by itself when nothing is. Guard it with an "already running?" check, a conditional claim, and a cap.
- Re-check schedules after every notebook import. On deploy day, run history showed every-minute runs coming back for a few minutes after the dispatcher was re-imported, until the extra schedule was removed. My reading is that the import recreated it. Now, after any import, I list the notebook's schedules and keep exactly one.
- Deploy from merged main only. That afternoon, a deploy from a different feature branch overwrote the event-driven build and dropped the wake function. Two features that deploy to the same app item have to ship as one build.
The change that mattered most didn't make anything faster. It let one small database go to sleep, and 136 CU-hours a day became 1.2.
I rebuilt the pattern from scratch on synthetic data as a small open-source repo, with the wake function, the self-chaining dispatcher and the conditional claim: fabric-event-dispatcher.
About the author
- Data engineer in Tashkent. Since 2018 I've built data platforms and BI for US companies, most recently on Microsoft Fabric: notebooks, pipelines, SQL databases and the deployment tooling around them. I write up what I learn in production, with real numbers. Certified DP-600, PL-600, AZ-104.
Comments
Join the discussion"Online 97% of the time for two CPU-minutes of work" is the same arithmetic that bites on plain VMs: with a 12-hour weekday window a machine is used 60 of the 168 hours in a week and could be off for 108, 64%. The part worth underlining for anyone copying the fix is the dependency: stopping the thing that polls does nothing for the thing it kept awake. Outside Fabric the "does not stop when you stop the workers" list is short: managed databases (a stopped RDS instance restarts itself after seven days), load balancers and NAT gateways billed per hour of existence, disks, public IPs. Sorting the bill by item, as you did, is the only way those show up.
Thanks, Gonzalo. That's the part I'd underline too. The poller was what I had optimized, but the database was what showed up on the bill. In Fabric the only lever was the idle gap: the database pauses after 15 quiet minutes, so the fix was removing every touch, including one forgotten browser tab. The RDS seven-day auto-restart is a good one to add to that list.
The forgotten tab has a cousin in Azure SQL serverless: the auto-pause delay defaults to 60 minutes (15 is the floor, same as Fabric), and any login or activity resets the clock, so a health check every 30 minutes keeps the database billed around the clock while looking idle. Checking who still connects is part of the fix, not only the workload.