Thursday, 05 June 2008

Paged Asynchronous LDAP Searches Revisited

A member in the book's forum mentioned some code I had originally posted here in the blog for asynchronous, paged searches in System.DirectoryServices.Protocols (SDS.P).  He questioned whether or not it was thread safe.  I honestly don't know - it might not be as I didn't test it extensively.

Regardless, I had actually moved on from that code and started using anonymous delegates for callbacks instead of events.  I liked this pattern a bit better because it also got rid of the shared resources.

After reading Stephen Toub's article on asynchronous stream processing, I learned about the AsyncOperationManager which was something I was missing in my implementation.  I have been doing a lot lately with .NET 3.5, LINQ, and lambda expressions, so I also decided to rewrite the anonymous delegates to lambda expressions.  That is not as big a change, but it is more concise.

I actively investigated using async iterators, but ultimately I decided closures seemed to be more intuitive for me.  I might revisit this at some time and change my mind.  Here is my outcome:

public class AsyncSearcher
{
    LdapConnection _connect;

    public AsyncSearcher(LdapConnection connection)
    {
        this._connect = connection;
        this._connect.AutoBind = true; //will bind on first search
    }

    public void BeginPagedSearch(
            string baseDN,
            string filter,
            string[] attribs,
            int pageSize,
            Action<SearchResponse> page,
            Action<Exception> completed                
            )
    {
        if (page == null)
            throw new ArgumentNullException("page");

        AsyncOperation asyncOp = AsyncOperationManager.CreateOperation(null);

        Action<Exception> done = e =>
            {
                if (completed != null) asyncOp.Post(delegate
                {
                    completed(e);
                }, null);
            };

        SearchRequest request = new SearchRequest(
            baseDN,
            filter,
            System.DirectoryServices.Protocols.SearchScope.Subtree,
            attribs
            );

        PageResultRequestControl prc = new PageResultRequestControl(pageSize);

        //add the paging control
        request.Controls.Add(prc);

        AsyncCallback rc = null;

        rc = readResult =>
            {
                try
                {
                    var response = (SearchResponse)_connect.EndSendRequest(readResult);
                    
                    //let current thread handle results
                    asyncOp.Post(delegate
                    {
                        page(response);
                    }, null);

                    var cookie = response.Controls
                        .Where(c => c is PageResultResponseControl)
                        .Select(s => ((PageResultResponseControl)s).Cookie)
                        .Single();

                    if (cookie != null && cookie.Length != 0)
                    {
                        prc.Cookie = cookie;
                        _connect.BeginSendRequest(
                            request,
                            PartialResultProcessing.NoPartialResultSupport,
                            rc,
                            null
                            );
                    }
                    else done(null); //signal complete
                }
                catch (Exception ex) { done(ex); }
            };


        //kick off async
        try
        {
            _connect.BeginSendRequest(
                request,
                PartialResultProcessing.NoPartialResultSupport,
                rc,
                null
                );
        }
        catch (Exception ex) { done(ex); }
    }

}

It can be consumed very easily using something like this:

class Program
{
    static ManualResetEvent _resetEvent = new ManualResetEvent(false);
    
     static void Main(string[] args)
    {
        //set these to your environment
        string servername = "server.yourdomain.com";
        string baseDN = "dc=yourdomain,dc=com";

        using (LdapConnection connection = CreateConnection(servername))
        {
            AsyncSearcher searcher = new AsyncSearcher(connection);

            searcher.BeginPagedSearch(
                baseDN,
                "(sn=Dunn)",
                null,
                100,
                f => //runs per page
                {
                    foreach (var item in f.Entries)
                    {
                        var entry = item as SearchResultEntry;

                        if (entry != null)
                        {
                            Console.WriteLine(entry.DistinguishedName);
                        }
                    }

                },
                c => //runs on error or when done
                {
                    if (c != null) Console.WriteLine(c.ToString());
                    Console.WriteLine("Done");
                    _resetEvent.Set();
                }
            );

            _resetEvent.WaitOne();
            
        }

        Console.WriteLine();
        Console.WriteLine("Finished.... Press Enter to Continue.");
        Console.ReadLine();
    }

    static LdapConnection CreateConnection(string server)
    {
        LdapConnection connect = new LdapConnection(
            new LdapDirectoryIdentifier(server),
            null,
            AuthType.Negotiate
            );

        connect.SessionOptions.ProtocolVersion = 3;
        connect.SessionOptions.ReferralChasing = ReferralChasingOptions.None;

        connect.SessionOptions.Sealing = true;
        connect.SessionOptions.Signing = true;

        return connect;
    }
}

 

The important thing to note is that because everything is running asynchronously, it is totally possible for the end delegate to be invoked before the paging delegate has a chance to finish processing results (depending on how complicated your code is).  You would need to compensate for this yourself.

This client is a console application, so I am using a ManualResetEvent just to prevent it from closing before finishing.  You wouldn't need to do this in a WinForms or WPF app.

I am sure there are other optimizations you could make to pass in parameters or even other directory controls.  However, the general pattern should apply.