AJAX & Record Locking

David Perelman-Hall presents a client-based technique for record locking of multiuser data-driven web applications--and it all hinges on AJAX.


September 08, 2006
URL:http://www.drdobbs.com/web-development/ajax-record-locking/192700218

David teaches and writes about Java and C#, and can be contacted at [email protected].


Consider this scenario: Client A calls up Record100 of a multiuser web application and begins editing it. Client B calls up the same Record100 moments later, and while Client B is modifying it, Client A saves changes to Record100. Now Client B is unwittingly modifying a record that is no longer current, and when Client B saves the record, the changes made by Client A are lost. This is an untenable situation where the last one to the finish line wins, and all others are obliterated. It would be best to find a way to inform Client B that editing is not possible as long as Client A is editing. And when Client A is done editing, it would be best to refresh Client B's view of Record100 so it includes the changes made by Client A.

Relying on database locking in web-based applications is less than optimal because the lock exists in the database. This is significant because it exists only for the duration of the transaction itself—only when the server is working with the database, not when the client is working with the UI. Because the lock exists in the database, clients have no knowledge of locked records. If the server could push information about locked records to clients, this situation would be remedied, but there really is no good web-oriented push technology.

In this article, I describe a client-based technique for record locking for multiuser data-driven web applications that does not take place in the database. Instead, it is enforced by modifying the UI to lockout users when more than one user accesses the same record. I show how we do this for our web applications, which for the most part are data-mining applications backed by a leading relational database and developed for a closed intranet committed to IE browsers. These applications have three user roles—readers, editors, and administrators. Typically, we control application access by user role, so when users log into an application, their navigational path through the web app is determined by the role that user is assigned. This means that users with the role of reader are never given a link to editable content—only to read-only content.

In fact, all users originally see only the reader view no matter what their role-based privileges allow. Because the reader view does not present editable content, qualified editors (which include administrators) must request the editable view to modify data. This request involves clicking on an Edit button in the UI of the reader view. (If a user's role is below that of editor, the Edit button is permanently removed from the UI.) This edit-request step lets us be certain of always fetching the most current data when an editing session begins. Whenever users are editing, they are modifying existing records, for which there is a unique record ID in the database. If an editor clicks on the Edit button, the browser requests the editable view of the current page. That trip to the server places an entry in a map denoting what record is locked, and by which editor. The user then receives the editable view of the record.

To prevent two editors from editing the same page, view-only pages (with the Edit button on them) contain an AJAX call that runs initially in window.onload, and thereafter in a window.setTimeout loop. This AJAX call bounces the record ID of the currently viewed record off the server, asking if someone holds the lock for that record ID. If the server responds that it is locked, the Edit button is removed from the UI and an alert is popped up informing the current user that the record is locked, and who locked it. Because the AJAX call runs in the window.setTimeout loop, such a response to another user's locking of the record could happen a while after the page is loaded.

The window.setTimeout loop on the locked-out user's page continues to run, requesting the lock status from the server, and when the lock owner saves the record or leaves the editable page, the AJAX call detects this and restores the Edit button. Clicking on the Edit button to go into edit mode then causes another round trip to the server to get the latest version of the record to place on the page in edit mode, as well as establish with the server that there is a new lock owner for that record.

Server-Side Components

Because the server code is language independent, I won't detail the server-side implementation. I do examine how the server handles the client's AJAX requests, but more from a higher level using pseudocode. Still, it's important to understand the server's roles.

We develop in Java and have our own homegrown Struts-like framework. In this framework, actions that users take on the Web are delivered through a controlling servlet to the framework as URLs. The path portion of the URL is parsed apart, mapped first to a particular application, and then within that application to an implementation of an action, where the real server-side work is done. This server-side action is a Java type that the framework looks up, instantiates if necessary, and invokes the lifecycle. The lifecycle of a server-side action handles the user request in some way—using the query portion of the URL—to render content that can be used to write a response to a client. The server-side action lifecycle API contains this central method:

public Page execute(UrlArguments args, Person person)


There is an implementation of this interface on the server that responds to the AJAX lock request running in the window.setTimeout loop. Included in the URL arguments is the nature of the client's lock request, whether for obtaining a lock, releasing a lock, or querying the status of a lock—and the ID of the record being used. This server-side component can be handling numerous client requests. There will be a request for every client page, coming in at the rate of the timer in the window.setTimeout loop. If there are only 10 clients and the call is made every 10 seconds per client, the server will be handling 60 requests per minute. Therefore, this code must run quickly, and in our implementation it mostly maintains a hash map from RecordId to RecordLock. Requests to obtain locks insert RecordLocks into the map; requests to free locks remove them; and status requests check for their existence.

Clients can make three kinds of requests of the server:

A record's status is locked or unlocked. Because all clients make requests for lock status, the owner of a lock is either the current user or someone else. Also, it's possible for a lock owner to let a session time out; say a user locks a record for editing, then goes for a two-hour lunch. To accommodate all these different states, the server sends back one of four state messages to the client:

For every lock status request a client makes, the server is given the ID of a record and the person making the request. Requesters may or may not be the owner of the lock. Our applications are configured to hold a map with record IDs as keys and RecordLocks as values, and to track whether a user's session is still valid. The pseudocode in Listing One is the logic of our server-side handling of client lock requests.

public Page execute(UrlArguments args, Person requester)
{
    recordID = args.getValue("recordID");
recordLock = map.get(recordID); // try to get lock for record
lockOwner = requester; // changes if lock owned by someone else
locked = "unlocked"; // changes based on actual lock state
// Requesting status of lock for a given record
if(request_is_for_lock_status)
{
    if(recordLock != null) 
{
// Since client can continue to poll server,
// even after session has timed out,
// this will stop the loop from running on the client.
        if(app.userSessionExpired(recordLock.getOwner()))
        {
            locked = "noSession";
        }
        // Record is locked by someone
else
        {
            // Request was from current lock owner
            if(recordLock.getOwner() == requester)
            {
                locked = "owned";
            }
            // request by someone other than owner
            else
            {
                lockOwner = recordLock.getOwner();
                locked = "locked";
            }
        }
    }
}
// Requesting a lock for a given record
else if(request_is_for_lock)
{
    // Record already locked by the current requester
if(recordLock != null  &&
recordLock.getOwner() == requester)
    {
        locked = "owned";
    }
// Record is owned/locked by someone else
    {
        locked = "locked";
        lockOwner = recordLock.getOwner();
    }
    // Not owned or locked already, so lock it
    else if(recordLock == null)
    {
        map.add(recordID,requester);
        locked = "owned";
    }
}
// Else, unlocking
else
{
// Should never get a request to unlock a record  
// which isn't already locked
Assert(recordLock != null);
map.remove(recordID);
    locked = "unlocked";
}
return PageLockPage(lockOwner, locked);
}
Listing One

Client-Side Components

AJAX is the confluence of JavaScript, XML, and the W3C's document object model (DOM) in the browser (often with the UI gussied up through the use of cascading stylesheets). AJAX technologies let JavaScript running on web pages communicate with a server without reloading the page. For example, the read-only view of a record can use AJAX to asynchronously ask the server for the status of the lock. Client-side JavaScript handles the server response by going through the web page's DOM to locate and remove the Edit button. The beauty of this is that users will not notice the asynchronous communication because the page does not reload, and the application appears responsive and natural.

The important tool for corresponding with the server is the HttpRequestObject that is part of the DOM of the browser page. Different browser makers offer different implementations of this object, as expected, and there are cross-browser libraries that offer a single API as a facade over the different implementations.

With the HttpRequestObject, JavaScript can make standard HTTP POST (or GET, HEADER, and so on) requests and read the server's reply. The server's reply can be accessed from the HttpRequestObject either as text or as already-parsed XML. Which of these gets used is controlled by the server's content type setting, either text/html or text/xml. The client should know whether to expect XML or plain text. In the application at hand, we simply pass around small messages as strings.

The HttpRequestObject has five properties that are accessed once the request to the server has been made. They track the interaction with the server and let the client determine if the request was handled and completed successfully or not. These properties are:

An HttpRequestObject has the following API, used to setup and initiate the request:

A Reusable HttpRequest Type

Because JavaScript offers basic object-oriented encapsulation, it is possible to build a reusable HttpRequest JavaScript type that wraps a more desirable API around the HttpRequestObject's API. Listing Two is our version of this, and it has been borrowed almost entirely from code in Ajax in Action by Dave Crane et al. (which, incidentally, has a nice section on object-oriented JavaScript). The net.ContentLoader type in this code demonstrates the technique of using a JavaScript Object variable as a namespace to which to add a type definition. The trimString function, all the READYSTATE_ constants, and the ContentLoader constructor and full prototype are defined in the net namespace.

// HttpRequestObject in net namspace
var net = new Object();
net.trimString = function(inString) { return 
   inString.replace( /^\s+/g, "" ).replace( /\s+$/g, "" ); } 
      // belongs in another namespace
net.READYSTATE_UNITITIALIZED=0;
net.READYSTATE_LOADING=1;
net.READYSTATE_LOADED=2;
net.READYSTATE_INTERACTIVE=3;
net.READYSTATE_COMPLETE=4;
net.ContentLoader = function()
{
    this.req = null;
    this.doc = null;
    this.text = null;
    this.url = null;
    this.onload = onload;
    this.onerror = this.defaultError;
}
net.ContentLoader.prototype =
{
    load:function(url)
    {
        if(window.XMLHttpRequest) this.req = new XMLHttpRequest();
        else if(window.ActiveXObject) this.req = 
                   new ActiveXObject('Microsoft.XMLHTTP');
        if(this.req)
        {
            try
            {
               this.setUrl(url);
                var loader = this;
                this.req.onreadystatechange = function() {
                   loader.onReadyStateHandler.call(loader); }
                this.req.open('POST', url, true);
                this.req.send(null);
            }
            catch(err)
            {
                this.onerror.call(this);
            }
        }
    },
    onReadyStateHandler:function()
    {
        var ready = this.req.readyState;
        if(ready == net.READYSTATE_COMPLETE)
        {
            if(this.req.status == 200 || this.req.status == 0)
            {
                this.setText(this.req.responseText);
                this.setXml(this.req.responseXML);
                this.onload.call(this);
            }
            else this.onerror.call(this);
        }
    },
    defaultError:function()
    {
        alert("Error with AJAX communication"
            + "\nURL: " + this.url
            + "\nreadyState: " + this.req.readyState
            + "\nstatus: " + this.req.status
            + "\nheaders: " + this.req.getAllResponseHeaders());
    },
    setResponseHandler:function(handler) { this.onload = handler; },
    setErrorHandler:function(handler) { this.onerror = handler; },
    setUrl:function(url) { this.url = url; },
    getUrl:function() { return this.url; },
    setXml:function(doc) { this.doc = doc; },
    getXml:function() { return this.doc; },
    setText:function(text) { this.text = text; },
    getText:function() { return this.text; }
}
Listing Two

An instance of HttpRequest goes through the process of contacting the server, making the request, and receiving back the server's response. As it goes through these steps, it calls back to HttpRequestObject.onReadyStateChange, for which you can supply a function handler. Typically, your handler checks the value of HttpRequestObject.readyState to see if it indicates that the server's response is complete. Once the server's response finishes and the value of HttpRequestObject.status indicates that the server's response succeeded, the HttpRequest object calls onload, which is the key response-handling function supplied by developers. This is, for example, where we place the code that examines the server's status messages and hides or shows the Edit button.

The constructor of the net.ContentLoader type assigns default event handlers for onload and onerror. The default onerror handler (which pops up an alert when an AJAX call fails) is net.ContentLoader.defaultError, a function of the net.ContentLoader prototype. The error handler may be reassigned through net.ContentLoader.setErrorHandler. The default onload handler is defined as a global onload method, which is defined outside of the net.ContentLoader prototype and consequently must be supplied by users of the net.ContentLoader. It's a good idea to supply a specialized handler through the net.ContentLoader.setResponseHandler function; otherwise, all instances of the net.ContentLoader on the same page end up using the same response-handling function.

When the window.onload function runs in our application, it runs on a page that is either read-only or editable. window.onload creates a net.ContentLoader object for us:


var contentLoader = new net.ContentLoader();


Lock Handling

Recall that all pages initially load as read-only. The lock-handling logic on the client behaves like this: If the page is in view mode, then see right away if the lock for the current record is owned by anyone, and spawn a timer to repeatedly test the lock status of the page. If not in view mode, then verify that the current user owns the lock for the given record. The values for parameters such as <%=curRecId%> and <%=person%> are given to the page as part of the JSP session values:


if('<%=mode%>' == 'view') 
{
  requestLockAction("status","<%=curRecId%>","anyone");
  spawnLockStatusTimer("<%=curRecId%>", 5);
}
else requestLockAction("lock","<%=curRecId%>","<%=person%>");


The work of communicating with the server is done in the requestLockAction(request,id,owner) function. If the client page simply wants to know the status of the lock for the given page, it will take the first branch of logic. However, if the client is asking for the lock (or relinquishing it), it takes the second branch of logic. Notice that we first assign the response handlers according to the request type, then invoke the contentLoader.load function. The response handlers handleLockStatus and handleLockRequest are functions defined elsewhere in the JavaScript.


function requestLockAction(request,id,owner)
{
   // call back for request for lock status
   if(request == "status")
   {
      contentLoader.setResponseHandler(handleLockStatus);
   }
   // call back for request to obtain or release a lock
   else
   {
      contentLoader.setResponseHandler(handleLockRequest);
   }
   // automatically submit asynchronous POST request to server
   contentLoader.load("/xpe/xpe/lockPage?request=
        "+request+"&id="+id+"&owner="+owner);
}

For a user who is asking for the lock (a user who clicked the Edit button) the callback handler is the handleLockRequest function. In this case, the server's response is the message "owned" if the lock request succeeded. It could be the message "locked" if the lock was owned by someone other than the requester. If a user is relinquishing the lock, the server's response indicating success would be the message "unlocked."


function handleLockRequest(reply)
{
    var values = reply.split("~");

    // If the user does not own 
    // thisrecord lock, spawn a 
    // timer to watch changes 
    // in it
    // 
    if(values[0] != 'owned')
    {
        spawnLockStatusTimer
            ("<%=curRecId%>", 5);
    }
    if(values[0] == "locked")
    {
        lockThisRecordOnClient
            (true, values[1]);
    }
    else if(values[0] == "unlocked")
    {
        lockThisRecordOnClient(false);
    }
}

Listing Three (available at http://www.ddj.com/code/) is the function lockThisRecordOnClient(lock, owner). This code simply hides or shows the Edit button. If the button is hidden because a record is being edited by someone else, then an alert lets the record viewer know who has locked the record. The request to the server can also be a request for the status of the lock of the given record, for which the callback handler would call the handleLockStatus function. This is going to be the most common request, as it will be made repeatedly by the timed loop running on the page as a result of the call to spawnLockStatusTimer("<%=curRecId%>",5).

This function handles the server message "noSession" by stopping the looping request timer clock. This only happens when users let a session timeout. The server detects the session timeout and forces the lock to be freed for whatever record it was held on. The client, however, could just sit there polling the server endlessly, so you merely abort the timer loop if this circumstance takes place:

     
function handleLockStatus(status)
{
    var values = status.split("~");
    if(values[0] == "locked")
    {
        lockThisRecordOnClient(true,
            values[1]);
    }
    else if(values[0] == "unlocked")
    {
        lockThisRecordOnClient(false);
    }
    else if(values[0] == "noSession") 
        stopTheClock();
}


The penultimate point to cover is the looped status check (see Listing Four, also available in the source code area). This loop is initiated by the function spawnLockStatusTimer(id, seconds), which establishes for the timer what record ID to periodically check the status of, and the number of seconds to delay between checks. It invokes the startTheTimer() function, which uses window.setTimeout to call back to itself every second, counting down the seconds until it should invoke requestLockAction("status",rec_id,"anyone"). The whole thing is controlled by the timerRunning variable, which can be used to abort the timer process. The third argument to requestLockAction signifies to the server that this client doesn't care who the owner is, just that it wonders if anyone does own the lock for the current record.

Finally, what if a user locks a record, then navigates to a page unrelated to that record without unlocking it? There are two scenarios, one of which is that users navigate out of the web application altogether, the other that users navigate to another page within the web application. Before addressing these, assume users have locked a record and made changes. In our applications, the only way to ensure those changes are saved back to the database is to click the Submit button. This frees the lock on the server by removing the owner and record ID from the map, and returns the read-only view of the just-modified record.

If a user puts a record in edit mode, makes changes, then navigates to another page within the web application, the new client page is a view-only reader page that handles this by telling the server to clear the map of locks owned by the user. In effect it says, "check for any locks owned by this guy, and if you find any, free them because he is no longer editing that record." If a user navigates out of the web application altogether, there is nothing we can do about it. Eventually, however, the user's session within the application will timeout, and any lock will be freed. We also provide an administrator's panel that shows who is viewing or editing what page in the application, and can be used to kick users out of the application altogether by ending their sessions.

DDJ

Terms of Service | Privacy Statement | Copyright © 2024 UBM Tech, All rights reserved.