Using Redis Blocking List Operations To Power Long-Polling In Lucee CFML 5.3.7.47
CAUTION: This is just a thought experiment. In general, I would not recommend performing a long-polling operation because it consumes a connection to both the application-server (ColdFusion) and the database-server (Redis). This could, theoretically, lead to thread exhaustion and a possible system outage if you had many people performing a long-polling operation at the same time. As such, please take this post as nothing more than a mental exercise to help keep the machinery firing.
The other day, when talking about using Pusher WebSockets in a ColdFusion application, I had mentioned that I typically think of WebSockets as a "nice to have" feature. Meaning, they can progressively enhance a user experience (UX); but, they are not critical to the operation of the product. This got me thinking about how I might implement the previous post using some sort of "fall back" mechanism. Without data being "pushed" to the client, the client has to "pull" data from server. One such approach to pulling data is a long-polling request that blocks-and-waits for a response. Always looking for more ways to leverage Redis in my ColdFusion applications, I wanted to see if I could use Redis' blocking list operations (BLPOP
) to power such a long-polling request in Lucee CFML 5.3.7.47.
The first question you might ask is, Why add the complexity of Redis just to perform the long-polling request? And, I will say that if you have a single ColdFusion server you can probably bypass Redis and just use an in-memory mechanism. However, at InVision, we have dozens of load-balanced Lucee CFML servers. As such, every request coming from a given user may hit any one of those servers. Therefore, I can't rely on the consistent existence of any in-memory value. Redis, therefore, becomes a way for me to create a distributed in-memory value that is shared by all of our ColdFusion servers.
With that said, this long-polling experiment is going to work by using the a blocking left-pop (BLPOP
) operation which will look for an item in a Redis List. The BLPOP
operation will hang for a given number of seconds before giving up (and returning null
). As such, we can power our long-polling request by piping it into a BLPOP
operation.
On the flip-side, we're then going to simulate some sort of "background task" that, upon completion, will push a value onto that Redis list. And, once the list is populated, the BLPOP
operation will return and the long-polling operation can complete.
Of course, we don't want the long-polling operation to run indefinitely. So, instead of having one really long, long-polling operation, we're going to have a series of finite operations that only block-and-hang for a few seconds.
Let's look at blocking portion of this workflow first since it is rather straightforward - all it does it perform a BLPOP
on a given Redis key and returns any non-null
response:
<cfscript>
param name="url.pollID" type="string";
// Lists in Redis provide both blocking and non-blocking operations. In this case,
// we're going to use BLPOP (Blocking Left-Pop) to block-and-hang the parent page
// request for several seconds (3) while waiting for the given list to be populated.
// If no list-item can be popped in the given timeout, the result will be null.
results = application.redisGateway.withRedis(
( redis ) => {
var blockTimeout = 3; // Time to block in seconds.
var lists = [ "poll:#url.pollID#" ];
var popResult = redis.blpop( blockTimeout, lists );
// The result of the BLPOP operation is 2-tuple where the first index is the
// name of the key (since we can block on multiple keys at the same time);
// and, the second index is the value of the list item that we popped.
if ( ! isNull( popResult ) ) {
return( popResult[ 2 ] );
}
}
);
// If there was no result, the job is still processing. For the sake of simplicity,
// let's just return a 404 Not Found in this case.
if ( isNull( results ) ) {
header
statuscode = 404
statustext = "Not Found"
;
exit;
}
content
type = "application/x-json"
variable = charsetDecode( serializeJson( results ), "utf-8" )
;
</cfscript>
As you can see, the request is passing in a url.pollID
parameter which we are then using to calculate a Redis key:
"poll:#url.pollID#"
This Redis key is our "List" data-structure and will eventually be populated by our simulated background task. But, until that list is populated, the BLPOP
operation will hang for 3-seconds. And, if it gets a response, we return the JSON (JavaScript Object Notation) payload; otherwise we return a 404 Not Found
.
SECURITY NOTE: Be careful when using user-provided data to interact with the database. In our case, I'm just blindly accepting the
pollID
. In reality, you'd likely want to validate the format of the input (a UUID in this case) in order to make sure that the user isn't trying to pull any shenanigans.
Now, on the client-side, I'm going to use the fetch()
API to make a request to the above end-point with a randomly-generated pollID
. But, since the BLPOP
operation only blocks for up-to 3-seconds, our client-side code is going to make a maximum of 5 fetch()
requests, in series, in order to check on the status of our background task.
ASIDE: This is first time I've ever used the
fetch()
API; so, hopefully I am not doing anything misleading.
Of course, we don't actually have a background task running; so, we're just going to simulate one using the CFThread
tag. Our CFThread
tag will sleep, asynchronously, for 8-seconds and then push an item onto the Redis list:
<cfscript>
// In this approach, each polling operation is going to correspond to a unique key
// in our Redis database.
pollID = createUUID();
// Since we don't actually have any background processing in this demo, we're just
// going to simulate some background job with a CFThread tag. The tag will sleep for
// a bit and then "finalize" the job by pushing a value onto the Redis List.
thread
name = "simulatedBackgroundTask"
pollID = pollID
{
// Simulated latency for our processing task.
sleep( 8 * 1000 );
// We're going to denote task completion by pushing an item onto a list in Redis.
// The long-polling operation will be monitoring this list, waiting for an item
// to be poppable.
application.redisGateway.withRedis(
( redis ) => {
var list = "poll:#pollID#";
var listItem = { jobID: 4, status: "done" };
// CAUTION: While we are expecting a polling operation to POP this item
// off the list, we cannot depend on that happening. As such, we want to
// make sure that we use an ATOMIC TRANSACTION to both PUSH the list item
// AND set the list to EXPIRE. This way, if the polling operation never
// happens, this list-key will eventually expire and get expunged from
// our Redis database.
var multi = redis.multi();
multi.rpush( list, [ serializeJson( listItem ) ] );
multi.expire( list, 60 );
multi.exec();
}
);
} // END: Thread.
</cfscript>
<!doctype html>
<html>
<head>
<title>
Using Redis Blocking List Operations To Power Long-Polling In Lucee CFML 5.3.7.47
</title>
</head>
<body>
<h1>
Using Redis Blocking List Operations To Power Long-Polling In Lucee CFML 5.3.7.47
</h1>
<p>
Our polling process will hit a ColdFusion end-point that uses Redis' blocking
list operations to check on the status of a job.
</p>
<script type="text/javascript">
var pollID = "<cfoutput>#encodeForJavaScript( pollID )#</cfoutput>";
// Some CSS for prettier console.log() output.
var cssSuccess = "color: white ; background-color: green ;";
var cssError = "color: white ; background-color: red ;";
// Let's attempt to poll the status of our background job up to 5 times.
longPoll( 5 ).then(
( response ) => {
console.group( "%cLong Poll Succeeded", cssSuccess );
console.log( response );
console.groupEnd();
},
( error ) => {
console.group( "Long Poll Failed" );
console.error( error );
console.groupEnd();
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I poll the job end-point for the given number of attempts. Returns a Promise.
async function longPoll( maxAttempts ) {
for ( var i = 1 ; i <= maxAttempts ; i++ ) {
console.log( `%cLong poll attempt ${ i }.`, cssError );
// NOTE: This Fetch request will HANG for some period of time because the
// server-side logic is going to perform a BLPOP (Blocking Left-Pop)
// operation on a Redis List - the same Redis List that we are pushing an
// item onto at the top of this page (inside the CFThread tag).
var result = await fetch( "./poll-target.cfm?pollID=" + encodeURIComponent( pollID ) );
if ( result.ok ) {
return( result.json() );
}
}
throw( new Error( `Job could not complete in ${ maxAttempts } attempts.` ) );
}
</script>
</body>
</html>
As you can see, our longPoll()
function is using the async
keyword. This makes it super easy to execute asynchronous AJAX requests in series using the await
keyword inside a basic for
-loop. And, if the long-polling fetch()
request returns with an OK response, we just return the JSON.
Now, if we run this code in the browser, we get the following output:
As you can see, the first two long-polling requests are hanging in a (pending) state, since we know our CFThread
tag is sleeping for 8-seconds. Then, the 3rd long-polling requests completed with a 200 OK
and we can see the response data.
Once thing to notice in my simulated background task is that I am using a multi command with Redis. This creates an atomic transaction that it wraps around the .rpush()
and .expire()
calls. This ensures that I never store the list-item without also setting an expiration date on the list, which is important when making sure that we don't have unnecessary data sticking around in our Redis database indefinitely.
This actually works quite nicely and isn't all that complex, especially if your application already has a Redis database running. Of course, please note the cautionary note at the top about the potential danger of long-polling. But, if nothing else, this was a fun mental exercise and an excuse for me to try the fetch()
API.
Epilogue on Polling vs. Long-Polling
As I mentioned at the top, long-polling can be dangerous because it actually holds requests open, which could theoretically lead to thread exhaustion (which is a "Bad Thing"). Another option would be to just use simple polling, which is a similar approach, but instead of blocking and hanging a request, it uses short-lived requests spaced-out using something like a setTimeout()
call.
RedisGateway.cfc
For Completeness, the While not the focus of this post, I figured I'd share the code for the RedisGateway.cfc
ColdFusion component that I am using because it employs and super awesome Lucee CFML feature which is the ability to load a Java class given a set of JAR files:
component
output = false
hint = "I provide a super simple wrapper around getting and returning Redis resources."
{
/**
* I initialize the Redis resource wrapper using Jedis 3.3.0.
*/
public void function init() {
variables.jarFiles = [
expandPath( "./lib/jedis-3.3.0/commons-pool2-2.6.2.jar" ),
expandPath( "./lib/jedis-3.3.0/jedis-3.3.0.jar" ),
expandPath( "./lib/jedis-3.3.0/slf4j-api-1.7.30.jar" )
];
var jedisPoolConfig = loadClass( "redis.clients.jedis.JedisPoolConfig" )
.init()
;
variables.redisClient = loadClass( "redis.clients.jedis.JedisPool" )
.init( jedisPoolConfig, "127.0.0.1" )
;
}
// ---
// PUBLIC METHODS.
// ---
/**
* I obtain a Redis resource from the Jedis pool and pass it to the given callback.
* The results of the callback are returned to the calling context. And, the resource
* is safely returned to the Jedis connection pool.
*
* @callback I am the callback that will receive the Redis resource.
*/
public any function withRedis( required function callback ) {
try {
var redis = redisClient.getResource();
return( callback( redis ) );
} finally {
redis?.close();
}
}
// ---
// PRIVATE METHODS.
// ---
/**
* I load the given class using the Jedis JAR files.
*
* @className I am the Java class being loaded.
*/
private any function loadClass( required string className ) {
return( createObject( "java", className, jarFiles ) );
}
}
Oh chickens, I love Lucee CFML so hard!
Want to use code from this post? Check out the license.
Reader Comments