Blog ·
A progress bar that refuses to guess
Most time estimates are invented. Here is the rule that decides when our panel is allowed to show one, and what it says on the screen when it is not.
By Provibr
Somebody clicks install. A machine has to be cloned, booted, configured and checked, and somewhere in the middle of that the customer wants to know one thing: how long is this going to take.
The easy answer is a countdown. Pick a plausible number, subtract a second every second, and the screen looks like it knows something. It does not. A countdown is a promise about a moment, and nobody in the building knows that moment.
So we wrote the rule down, gave it a version number, and made the panel say out loud when it does not qualify.
The rule, in short
A time estimate appears only when two conditions are both true. There have to be at least twenty successful runs of the same kind of work in the last thirty days, and the spread has to be tight: the ninetieth percentile may not be more than two and a half times the median. If either fails, the screen shows the stage, the elapsed time, and a sentence that says there is no estimate.
Those three numbers live in one constant with a version field next to them, and nothing else in the codebase is allowed to have an opinion about them.
| Situation | What the screen shows |
|---|---|
| Fewer than twenty comparable runs in thirty days | "No reliable time estimate yet", with how many runs have been recorded and how many are needed |
| Twenty or more in thirty days, but the spread is too wide | "No reliable time estimate", and that the runs were too far apart |
| Both conditions met | "Comparable tasks took X to Y minutes, based on N runs in the last 30 days" |
Read the third row carefully. It is a range, and it names its own evidence. It is not a bar creeping towards a finish line, and it never turns into "3 minutes remaining".
Why a range and not a countdown
A countdown compounds two mistakes. It hides the uncertainty, and it re-asserts itself every second, so the wrongness has a rhythm. Everyone has watched a copy dialogue promise four minutes, then fourteen, then two, and learned in thirty seconds that the number means nothing. After that, every other number on the screen is worth a little less. A range, stated once with its sample count attached, cannot do that.
The spread condition is the part that does the real work. Twenty samples with a median of four minutes and a ninetieth percentile of forty minutes is not a cohort with a prediction in it; it is two different populations wearing one label, and averaging them produces a figure with a decimal point and no meaning. Two and a half is a chosen number, not a derived one, and the version field exists so that changing it later is a visible decision.
The cohort is your own account, and that is deliberate
The obvious way to reach twenty samples quickly is to measure across the whole platform. We do not. The cohort is the kind of work within a single organisation.
There are two reasons, and the second is the one that decided it.
The first is comparability. Your own history is the most similar population that exists: the same hypervisor, the same network, the same images, the same storage.
The second is that the sample count is on the screen. A sentence naming how many comparable runs the platform saw in thirty days is a statement about our own volume, printed in a customer's panel, and it would be handed out in exchange for a slightly better estimate. Scoping the query to one organisation means the number on your screen is a fact about you.
The practical consequence is that a young account sees no estimate at all for a while, and that is the correct behaviour rather than a gap to be filled. Showing nothing is better than showing a number that means nothing.
There is no samples table
The duration of a successful task is the difference between two timestamps that are already stored on the task itself. There is no separate table of measurements.
It is worth saying why that was a decision. A second table would hold the same truth twice, and the two would drift the first time a retention sweep touched one and not the other. The only thing it would genuinely add is samples that outlive the tasks they came from, and that only matters once the retention period is shorter than the measurement window. Today the retention is ninety days and the window is thirty, so the question does not arise.
One footnote that has bitten this codebase before: the percentile query returns a double and the count returns a bigint, which the driver hands back as a string. Both need conversion, and forgetting exactly one of the two fails quietly rather than loudly.
Stages, because a percentage has to come from somewhere
Every kind of task carries a list of stages in order. An install goes queued, preparing, booting, installing, finishing. A download from a URL goes queued, sent to the agent, downloading, verifying, applying. The percentage is derived from the position in that list, unless the underlying work reports a real percentage of its own, in which case that one wins.
That override matters more than it looks. An installation reports its own progress figure and it is shown on that server's own page; if the drawer computed a second number from stage positions, one screen would carry two different percentages about the same work. That is a second copy of the truth, one layer up.
The translation from installation phases to stages sits in exactly one place, written as an exhaustive record rather than a switch statement. Fourteen phases across two different install ladders map onto five stages a customer would recognise. A new phase is a compile error, not a stage that quietly falls through to the first one.
Stale is not failed
There are two thresholds on how fresh the last progress report is: two minutes and ten minutes. Past the first, the screen says there has been no update since a given time. Past the second, it says nothing has been reported since then.
Neither changes the status. The work may be running perfectly. What has stopped is the reporting, and a reader deserves to be told that rather than watching a bar that has not moved and drawing their own conclusions. Conflating silence with failure is the same mistake as closing an incident because the measurements stopped arriving, and it is worth keeping the two words apart everywhere they appear.
The drawer owns nothing
The background tasks drawer in the header sits on top of all of this, and its design rule is short: it holds no state of its own about the work. The only piece of local state in that component is whether the panel is open. There is no fetch loop, no interval, no second socket. The initial state arrives from the same query the detail page uses, and live updates arrive on the shared realtime store.
A test reads the component's syntax tree and fails if any local state ever holds a status, a stage or a percentage. That is a stronger guarantee than a code review, for the same reason a comment is not a test.
One detail in there is easy to get backwards: the groups in the drawer are computed after the live patch is applied, not before. Compute them first and a task that just failed stays sitting in the running group until the next full page load, quietly showing a spinner next to something that is already red.
Three defects that only the verification found
All three of these compiled, typechecked and passed the unit tests.
The receipt was never written anywhere. The code referred to a function that did not exist, so a successful install produced a task with a result section that stayed empty forever.
An unrecognised installation phase wiped the recorded stage to empty. The mapping correctly returns nothing for a phase it does not know, but the caller wrote that nothing into the row, so an older agent reporting a renamed phase would have erased the stage of a task that was running fine. The fix is one fallback to the stage that was already there, and the guard was proved red before it was trusted.
A finished task kept spinning. The stage of a completed task is still the last stage it reached, which is correct, because otherwise you could not see where it ended. The bug was asking "is this stage the current one" by comparing names, when the real question is whether the task is still active. A failed task also does not get a green tick on the stage it died in: that stage was abandoned rather than completed, so it has a state of its own instead of being filed under done or not started, both of which would claim something untrue.
What we would write down
- Put the estimate rule in one versioned constant. The question "was the estimate you showed in March defensible" is only answerable if you know which rule was in force.
- Make the absence of an estimate a sentence, not an empty space. A blank where a number should be reads as a bug; "No reliable time estimate yet. 6 of 20 comparable runs recorded." reads as a system that knows what it does not know.
- Scope the cohort to the customer, and accept that new accounts see nothing for a while. The alternative prints your own volume on their screen.
- Never let a drawer, a badge or a widget hold its own copy of a status. Two numbers about one job on one screen is worse than no number.
- Separate silence from failure in the vocabulary, in the data model and on the screen, and keep them separate everywhere.
The honest limits: this layer runs for four kinds of work today, and the list is deliberately short because a kind nobody can start is a category that renders empty. The cancellation flow can record a request but no executing layer confirms one yet, and the drawer has no entry point below 768 pixels of screen width.
The same argument, applied to the thing a progress bar is usually attached to, is in why a Proxmox 200 is not a result, and the reason we stopped trusting our own deadlines is in a timeout is not a stop button.
From the platform work on Provibr.