Timeboxing Code Execution In ColdFusion
In my post earlier this week, about sending 7 million emails in 5 days, I mentioned that the underlying mechanics were driven by a ColdFusion scheduled task that executes every 5 minutes. In order to prevent scheduled tasks—like this one—from overlapping in execution, I will often include timeboxing logic that makes sure that a given algorithm executes within a given time period. Seeing that I use this logic in a number of places, I wanted to think about what an abstraction for timeboxing code might look like in ColdFusion.
First, let me share the logic that I normally use with this kind of timeboxing effort. It's quite simple and requires little more than a time-stamp and a conditional loop. In the following ColdFusion code, we want to repeatedly execute a block of code for up to 3 seconds, knowing that each block execution will only take a few hundred milliseconds. To keep things simple, I'm going to use the sleep()
function to simulate "hard work" that takes a non-trivial amount of time:
<cfscript>
// We want to allow the loop of work to operate for UP TO 3 seconds before we
// short-circuit the execution.
startedAt = getTickCount();
cutoffAt = ( startedAt + ( 3 * 1000 ) );
// This loop represents some sub-maximal amount of work that we want to allow in the
// given timebox. Therefore, in order to fulfill the processing needs, we need to keep
// looping until we've met our maximal amount of time.
while ( getTickCount() < cutoffAt ) {
// Simulating blocking work.
sleep( randRange( 100, 300 ) );
}
echo( "Total Time: #( getTickCount() - startedAt )#" );
</cfscript>
As you can see, it's just a while()
loop that keeps running until the current time-stamp passes a given cut-off. And, when we run this CFML code several times in a row, we get the following output:
Total Time: 3088
Total Time: 3041
Total Time: 3123
Total Time: 3104
Total Time: 3116
As you can see, each page executed for about 3 seconds. But, notice that each execution was always greater than 3 seconds. This makes sense since the algorithm's final execution is likely to run longer than the overall cut-off.
To abstract this logic, we can use a callback function or a closure. Essentially, we'll use the closure to define the logic within each loop; and then, rely on an abstraction to figure out how many times said closure can be invoked within the 3 second timebox. And, since we're now hiding logic away from the developer, we have an opportunity to make the logic more robust without adding complexity.
To that end, I'm going to create a function called runUntil()
. This function will take three arguments:
- The closure / function that defines the developer logic.
- The duration of the timebox.
- The algorithm to be used: "loose" or "tight".
In this case, the "loose" algorithm will match what we have above: the internal loop will just keep executing until it passes the given duration. But, if the developer specifies "tight", the internal loop will omit the "final" iteration if it predicts that said iteration will take the overall runtime past the cut-off. It will implement this prediction by tracking the duration of each previous execution and then using the average execution to guess the next execution:
<cfscript>
param name="url.algorithm" type="string" default="loose";
startedAt = getTickCount();
// This closure represents some sub-maximal amount of work that we want to allow in
// the given timebox. Therefore, in order to fulfill the processing needs, we need to
// keep looping (internally to runUntil) until we've met our maximal amount of time.
runUntil(
() => {
// Simulating blocking work.
sleep( randRange( 100, 300 ) );
},
( 3 * 1000 ), // Duration: Run up to 3-seconds.
url.algorithm // Algorithm: "tight" or "loose".
);
echo( "Total Time: #( getTickCount() - startedAt )#" );
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
/**
* I execute the given operator as many times as I can without exceeding the given
* timebox duration. When using the "tight" algorithm, a best effort will be made to
* never exceed the given duration on the last operator execution.
*/
public void function runUntil(
required function operator,
required numeric durationInMilliseconds,
string algorithm = "loose"
) {
var cutoffAt = ( getTickCount() + durationInMilliseconds );
var durations = [];
do {
var startedAt = getTickCount();
// The operator can short-circuit its own execution if it returns False. If
// returns True, or doesn't return anything (an implicit undefined), then the
// iteration will continue.
if ( operator() == false ) {
break;
}
// When using the "tight" algorithm, we're going to try our best to never
// exceed the duration of the timebox. In order to do this, we're going to
// keep track of the duration of each iteration. And then, use the average
// duration of the previous iterations to predict the next iteration. And, if
// such a predication takes us beyond the timebox, we're going to short-
// circuit the execution, erring on the side of shorter runs.
if ( algorithm == "tight" ) {
durations.append( getTickCount() - startedAt );
if ( ( getTickCount() + durations.avg() ) > cutoffAt ) {
break;
}
}
} while ( getTickCount() < cutoffAt );
}
</cfscript>
The closure that we pass into the runUntil()
method is invoked as operator()
inside of the internal / abstracted do-while
loop. This creates a nice separation of concerns between the developer's logic and the timeboxing logic.
Now, if we run this ColdFusion code a few times with ?algorithm=loose
, we get:
Total Time: 3130
Total Time: 3026
Total Time: 3179
Total Time: 3211
You can see this "loose" approach matches our original example. But now, let's run this ColdFusion code with ?algorithm=tight
:
Total Time: 2919
Total Time: 2933
Total Time: 3041 <-- Notice over 3 seconds.
Total Time: 2884
Total Time: 2929
When switching to the tight algorithm, which uses the average execution of each previous iteration to guess the next iteration, most of the execution paths are timeboxed entirely with the given duration (3 seconds). But, one execution did go slightly over. So, it's not perfect; but, on balance, the "tight" algorithm is better about the timeboxing than the "loose" algorithm.
I'm a huge fan of using closures and callbacks in my ColdFusion code. But, I also love the CFLoop
tag. Especially in Lucee, which has some added convenience attributes like times
:
loop times = 10 { ... }
Not that I want to continually overload such a tag; but, it wouldn't be crazy (in my mind) to have yet another attribute like duration
(note: this does not exist):
loop duration = 300 { ... }
To explore what this could look like, I'm going to create a ColdFusion custom tag called RunUntil.cfm
. This custom tag will accepts attributes for duration
and algorithm
:
<cfscript>
param name="attributes.duration" type="numeric";
param name="attributes.algorithm" type="string" default="loose";
switch ( thistag.executionMode ) {
case "start":
startedAt = getTickCount();
cutoffAt = ( startedAt + attributes.duration );
durations = [];
break;
case "end":
if ( attributes.algorithm == "tight" ) {
durations.append( getTickCount() - startedAt );
if ( ( getTickCount() + durations.avg() ) > cutoffAt ) {
exit;
}
}
if ( getTickCount() < cutoffAt ) {
startedAt = getTickCount();
exit method = "loop";
}
break;
}
</cfscript>
This is (roughly) doing the same thing that our runUntil()
function is doing. But, it's using start
and end
custom tag logic instead of an internal do-while
loop. Consuming this ColdFusion tag could give us an idea of what an enhanced CFLoop
tag might feel like:
<cfscript>
param name="url.algorithm" type="string" default="loose";
startedAt = getTickCount();
_runUntil
duration = ( 3 * 1000 ) // Duration: Run up to 3-seconds.
algorithm = url.algorithm // Algorithm: "tight" or "loose".
{
hardWork = randRange( 100, 300 );
// Simulating blocking work.
systemOutput( "Work Duration: #hardWork#", true );
sleep( hardWork );
}
echo( "Total Time: #( getTickCount() - startedAt )#" );
</cfscript>
Since _runUntil
is a ColdFusion custom tag invocation, the CFBreak
tag won't work (you have to jump through hoops to break out of custom tag loop). But, I do love the ergonomics of tag-based logic.
To be honest, I don't really use timeboxing outside of ColdFusion scheduled tasks (and occasional performance testing). But, I do use it often enough across my scheduled tasks that it would be nice to have an abstraction that factors-out the low-level logic.
Want to use code from this post? Check out the license.
Reader Comments
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →