Continuations with Ruby on Rails

Thursday, 11 December 2008

The short story: acts_as_continuable

One of the things that can make programming a web app difficult is managing state and execution flow. HTTP is a stateless protocol: The user makes a request and your server passes back a response. After that, you're done. As far as the protocol is concerned, there's no difference between the next request coming from the same user as before or a new one.

This is no problem for static resources and RESTful APIs, but for more complex user interactions it becomes a pain. Aside from maintaining the state for each user, you have to create multiple entry points into your app for each step of a process, one for each request/response cycle. Additionally, you have to make sure that your application logic can handle users entering a process at incorrect points or repeating certain things twice, or generally trying to break things. So the flow of your code goes like this:

  1. Get a request,
  2. Associate it with a specific resource or interaction,
  3. Associate the request with a certain user's state (or create a new one),
  4. From the resource requested, the parameters passed, and the user's state, find out what part of the interaction the user just finished,
  5. Generate a response/process resulting data,
  6. Save the user's current state,
  7. And return the response.

Now as an app programmer, all I really want to focus on as much as possible is the meat of my app's logic. Everything else is bookkeeping, right? Moreover, it's bookkeeping that is common to almost every stateful web app out there.

Now most of this stuff has been abstracted already so you don't have to think about it (much). In Rails, you don't have to figure out which resource a request is associated with, as routes takes care of that. You don't have to connect the request to a past user as Rail's sessions already store user-related state between requests.

What Rails doesn't do is abstract away the bookkeeping you need to take care of when dealing with complex user interactions that involve multiple steps and multiple request/response cycles. Other web app frameworks have done this, however. Seaside, written in Smalltalk, is probably the most well-known example.

So how could you do this in Rails? How about this: acts_as_continuable is a little Rails plugin experiment of mine that allows you to define actions in your controller classes that persist across multiple request/response cycles. You can do things like:

def_continued :an_action do 
  render :partial => 'step_1' 
  # params are results from step 1 
  render :partial => 'step_2' 
  # params are results from step 2 
  render :partial => 'step_3' 
  # params are results from step 3 
  render :partial => 'finish' 

When you call continue from within your action method, the current execution context (backed by a thread) is paused and stuffed in the user session. When the user submits, the thread is pulled out of the session and runs until the next continue statement or the method returns.

Of course this works great when the user never presses the back button or otherwise tries to submit the same results twice. You can handle that, but what would be great is if the method would rewind the right spot and go from there. That's where Ruby's Continuations come in. Each time continue is called, it also creates and stores a continuation at that point--essentially a bookmark of where your code is at in its execution--which gets stored with the backing thread in the user session.

So, for each step, continue stores the thread and the continuation or reentrance point. Now if we associate a hash with each thread/continuation pair, it's trivial to use that hash to jump back in where your code last left off. As long as your action method doesn't actually return, the user can go as far back into the past as they want and undo to the beginning of the method. In acts_as_continuable this context id hash is passed to your views as @context_id.

So, yeah. I'm sure there are things that can go horribly wrong shoehorning continuations into Rails, but hey, it was fun.