Blog ·
The child wakes the parent
A flow that calls another flow could poll, or be told. We built both, and the fallback clock is a day rather than five minutes for a measured reason.
By Provibr
A flow can now call another flow. The calling run stops, the called run starts, and when the called run finishes the calling run continues on the ok or the error wire.
The whole design question in that sentence is the word when. Something has to notice that the child is done. There are two ways to arrange that and we ended up with both, in a specific order, and the second one has a number in it that took some arguing.
The parent does not wait, it is told
The natural way to write this is a parent that waits. The parent knows which run it is waiting for, so it can ask. Poll every ten seconds, see whether the child has finished, continue when it has.
That is the wrong way round, and the reason is that the child already knows. It is the one that transitions to finished. Making the parent ask means the information travels from the party that has it to the party that needs it via a repeated question, and the frequency of that question is the latency of the whole feature.
So the child announces. When a child run reaches a terminal state it sets the wake time on the wait row that points at it to now, and the ordinary queue worker picks it up within its next tick. That is exactly the same mechanism a human approval uses: deciding sets the wake time to now. One column steers the queue, and there is deliberately no second route into the executor.
That announcement is one statement: update the wait row where the child run identifier matches and the status is still waiting.
Why that identifier is a column and not part of the state blob
The continuation of a paused walk is stored as JSON, and it holds everything the walk needs to resume: the stack, the visited nodes, the variables, the counters. It would have been consistent to put the child's identifier there too.
It is a column instead, and the reason is directionality. Everything else in that blob is read by the walk, from the inside. This one value is read from the outside, by a child run that wants to find the row pointing at it. In the blob that is a scan over every open wait in the system, and the index you would need to make it cheap is this column anyway.
The foreign key is SET NULL rather than CASCADE. If the child run is cleaned up, the parent's wait must not vanish with it, because then a parent is suspended with nothing that will ever wake it. Losing the reference is survivable; losing the row is not.
The fallback clock is a day, and the number is the argument
The announcement is the mechanism. The clock is the safety net for the case where the announcement never arrives: a container that fell over halfway through finishing the child, a row touched by hand.
The obvious value for a safety net is something small. Five minutes feels responsible.
It is wrong, and the reason is what a child is allowed to do. A child run can contain a human approval, and an approval can have a deadline thirty days out. With a five minute clock, the parent of that child wakes thousands of times over those thirty days to find out that nothing has changed, and every one of those wake ups is an HTTP request from the gateway to the web container.
Twenty four hours costs at most thirty pointless ticks across the longest wait the product allows, and bounds the damage of a genuinely missed announcement to one day. That is the trade, stated in the units that matter: how many requests does the safety net cost when nothing is wrong, against how long a real failure stays invisible.
The general shape: a retry interval is not "as short as we can afford". It is a function of the longest legitimate wait, and if you have not asked what that is, the interval is a guess.
A clock you cannot shorten is a clock you cannot prove
A safety net only fires when the mechanism under it failed. With the interval fixed at a day, demonstrating that it works takes a day, so the round that built it delivered that branch as unproven, honestly labelled.
The temptation is to prove it by hand: reach into the database, move the wake time into the past, watch the run resume. That proves the resuming. It does not prove the clock, because the clock is precisely the thing you replaced.
So the interval reads an environment variable, and the hook lives in the source next to the constant rather than inside a test file. It is used by an end to end script that sets it to a few seconds, lets the safety net actually fire, and watches a parent whose child never announced anything recover on its own.
Two properties make that hook safe to ship. It can only make the interval shorter: the value is clamped against the production constant, so a misconfigured environment can never make a parent wait longer than a day. And anything that is not a positive number falls back to the production value.
That second one is not defensive coding for its own sake. Copying our example environment file gives you the variable present and empty, and an empty string is not undefined. Converting it to a number gives zero. Without the sieve, a copied configuration file would set the interval to zero and put every parent with an outstanding child into a wake up loop against the web container. We have been bitten by the empty string versus undefined distinction before, in a place where it cost a download route a 404.
Two limits, each enforced twice
A flow calling a flow raises the question of a flow calling itself. There are two limits, recursion and depth, and each is enforced in two places.
Statically, when a flow is saved: the graph is walked, the flows it calls are resolved, and a cycle or a chain that is too deep is refused with the chain named in the error. Dynamically, during a run: the same two limits again, with their own refusal codes.
Both are necessary because flows are edited independently. Two flows that are individually fine can become a cycle the moment somebody adds a call to the second one, and nothing revalidates the first. The static check is what gives a useful error message at the moment somebody can still fix it; the dynamic check is what actually holds.
The depth is a column on the run rather than something computed by walking parents. That goes against the line we normally take, which is that anything derivable should be derived. It does not hold here, because the parent reference is SET NULL: once a parent run has been cleaned up, the chain cannot be walked back and the depth of a running child would become unknowable at exactly the moment it matters.
What the follow up round measured
The branches this design left open were taken in a later round, and two of them are the interesting ones.
A child run that disappears entirely: the wait keeps its row and loses its reference, the fallback clock wakes the parent, and the resume finds no child and fails that node with a specific reason rather than hanging. A graph that cannot be read at all produces a different reason, so the two situations are distinguishable in the run log.
A subflow inside a loop, in the two shapes that behave differently: a child that finishes inside the originating request, and a child that suspends itself. The first never touches the queue at all. The second is the whole feature.
What we would write down
- The party that knows should tell, not be asked. Polling turns your latency into a configuration value.
- Reuse the wake up path you already have. A decision and a finished child are the same event to the queue.
- A value read from outside the state blob belongs in a column. Directionality decides this, not consistency.
SET NULL, notCASCADE, when the row that would be deleted is somebody's only way to be woken.- A safety net interval is a function of the longest legitimate wait, not of how responsive you would like to feel.
- State the trade in requests and hours. Thirty wasted ticks against a day of invisibility is a decision; "five minutes" is a reflex.
- A constant you cannot vary is a branch you cannot prove. The hook belongs beside the constant.
- Let the hook only make things safer, and clamp it against the production value.
- An empty environment variable is not undefined, and converting it to a number gives you zero.
- Check a limit where it can be fixed and where it must hold. Independently edited graphs need both.
For clarity about what this is: the merge verification ran 16 of 16 steps without a failure line, the web suite was 4583 green, packages, gateway and tools together 7286 green, the walk 75 green, the static subflow check 13 green, the end to end run against the development database 58 green, the recipient measurement 4 green, a browser test with a real gateway worker 31 green in which a ninety second pause resumed after ninety five seconds, the editor test 9 green and the accessibility sweep found nothing across six pages. Not proven at the time: the fallback clock, a subflow inside a loop, a vanished child run, and the retry exhaustion branch. Those four were taken in the following round.
From the platform work on Provibr. How the walk became something that can be written to a row is in every self call was in tail position, and the round that finally reached the branches this one left open is in green because it could not get there.