ETags and Concurrency Control in Laravel 4

I've been writing quite a lot about ETags, an often underutilized detail in the API world.

I previously wrote about implementing Conditional Gets. This article will cover implementing ETags and Concurrency Control within Laravel 4.

Note, this builds off of my previous article on using ETags for Conditional GETs in Laravel 4. The build for this is available on Github.

For review, an ETag can be used to solve the Lost Update problem, which describes when two or more clients attempt to update a resource at or near the same time. Without any concurrency checks, the client who submits changes last, wins.

Think of this as two people opening a Word document in a shared Dropbox folder. Imagine they make totally different changes, and then click "Save". Whoever saves last will have the document updated to their version, losing the other persons changes completely (Note: In reality, Dropbox handles that situation by creating a "conflicting copy" of the file).

If you have not read my previous Laravel 4 ETag article, note that each "resource" (each "article", in this example), will have an associated ETag, which will be updated any time the resource is updated. A ETag is a representation of a resources state. Anytime that state is changed (if the article changed in any way), its ETag changes as well.

We will create a way to handle such concurrency issues in this API. Let's see how ETags can be used for that.

An outline of a Request

A request such as: PUT example.com/api/v1/article

PUT requests are generally use for updating a resource; POST for creating a new resource.

  1. A PUT request is made to update a resource. It contains an If-Match header with an ETag.
  2. A controller method is fired to handle the request. The code gets the ETag for the requested resource. It then compares the generated ETag to the one supplied in the PUT request.
  3. If the ETags match, then the client making the update request has the most up-to-date knowledge about the resource. The update proceeds, and the modified resource is returned with a newly-regenerated ETag.
  4. If the ETags do not match, then the client making the PUT request is assumed to have outdated or incorrect knowledge of the resource, and a 412 Precondition Failed response is sent back.

The controller code

Since most of the infrastructure is in place, let's hop right into the heart of it. I have a Resource Controller setup in my Article API. Laravel will map the PUT request to the update method.

/**
 * Update the specified resource in storage.
 *
 * @return Response
 */
public function update($id)
{
	// Find article
	$article = Article::find($id);

	// If no article return a bad request
	// because article id is invalid
	if( !$article )
	{
		App::abort(400);
	}

	// Check If-Match header
	$etag = Request::header('if-match');

	// If etag is given, and does not match
	if( $etag !== null && $etag !== $article->getEtag() )
	{
		return Response::json([], 412);
	}

	// Some validation, only update fields that are present
	if ( Request::get('title') )
	{
		$article->title = Request::get('title');
	}

	if ( Request::get('content') )
	{
		$article->content = Request::get('content');
	}

	// Save it
	$article->save();

	// Refresh the eTag, since it'll be new
	$article->getEtag(true);

	return Response::resourceJson($article, [], 200);
}

Let's go over what's happening here.

First, the code attempts to find a resource to update. If the requested article does not exist, we can stop and respond with a 400 Bad Request, since there's nothing to update.

Next, it checks to see if an If-Match header exists. If so, it grabs the ETag provided within it. Because the request has provided an ETag to compare against, we can go ahead and compare it to the resource's ETag. If the ETags do not match, then the client is likely trying to update a resource without having the latest version. If this is the case, return the 412 Precondition Failed response. The client should re-retrieve the resource and then attempt to update based on the latest version. This prevents the "Lost Update".

If the ETags do match, then the server can assume that the client is attempting to update the latest version of the resource. It continues with the update, in this case by updating the article's title and/or content.

If the update was successful, the response includes the updated resource along with a newly-regenerated ETag.

Notes and Gotchas

New Code

Note that in addition to the controller code above, there is new code to accomplish that.

Headers

In this current iteration of code, the presence of the If-Match triggers the ETag comparison. If no If-Match header is supplied, the update continues as normal. Whether or not this is acceptable is a decision an API designer needs to make based on business needs.

There are other available headers specified to help with Concurrency Control and Conditional GETs. This Stack Overflow question covers them well.

A Request

Let's see this in action from a client's point of view.

First, Client A retrieves a resource:

$ curl -i example.com/api/v1/article/1
HTTP/1.1 200 OK
Date: Sat, 30 Mar 2013 16:48:05 GMT
ETag: "51e2666066885d90fdf31dd830f558b5"
Content-Type: application/json

{"articles":{"id":"1","user_id":"1","title":"This is my first article","content":"Heres some content for this article...","created_at":"2013-02-16 01:53:37","updated_at":"2013-03-29 19:11:31"}}

Client A then has the ETag and can pass that along when performing an update.

$ curl -i -X PUT -H "If-Match: 51e2666066885d90fdf31dd830f558b5" -d 'title=This is my first *edited* article' example.com/api/v1/article/1
HTTP/1.1 200 OK
Date: Sat, 30 Mar 2013 16:50:41 GMT
ETag: "be4b25450ccd637cd41cafb471405f4a"
Content-Type: application/json

{"articles":{"id":"1","user_id":"1","title":"This is my first *edited* article","content":"Heres some content for this article...","created_at":"2013-02-16 01:53:37","updated_at":"2013-03-30 16:50:41"}}

Now, lets say Client B didn't know about this update. They only have the now-defunct ETag 51e2666066885d90fdf31dd830f558b5. They attempt an update:

$ curl -i -X PUT -H "If-Match: 51e2666066885d90fdf31dd830f558b5" -d 'title=Im totally rewriting this, like its Wikipedia, lulz' example.com/api/v1/article/1
HTTP/1.1 412 Precondition Failed
Date: Sat, 30 Mar 2013 16:53:29 GMT
Content-Type: application/json

Because the resource was updated since the last time Client B checked, the update fails. Client B needs to GET the resource again to get the latest version and ETag.