Build a Self-Updating Windows Service Data Cache
by Peter A. Bromberg, Ph.D.

Peter Bromberg
"If we don't succeed, we run the risk of failure. " -- Dan Quayle

Often when you build a Windows Service you have the need to store metadata that the service will use for business rules and other logic. Typically what I do is load this data into a DataSet when the service is started, and I use the DataTable's Select and similar methods to create my rules engine based on incoming MSMQ, SysLog, UDP or other messages to the running service. For example, if it is a SysLog message my service is receiving, I might check the "machine" element of the message to determine what the source machine was, and use this as part of the Select Filter to get the rows from various datatables in my cached DataSet to enable me to determine what to do with this particular message. I might even have a DataTable in my DataSet that tells me, based on this filter criteria, the assembly name and class name of an assembly to load via Activator.CreateInstance, and I'll call a particular IMessage set of properties and methods dynamically, passing in the SysLog message for further processing.



As long as all my classes implement my IMessage interface, which has an Execute method, I can get the assembly and class name information out of the particular DataTable, along with say, a NameValue collection of parameter names and values that the Execute method needs, again based on the ActionID that I get from the Select filter, which would be applied to a second DataTable of parameters that is also in my cached DataSet.

Now the question becomes, what do you do with a running service that relies on metadata that could be changed in the database while the service is running? Well, you need to have a way to "invalidate" your DataSet and force it to reload the data without stopping your service.

There are some very sophisticated ways to have Windows Services that load assemblies and data dynamically, even to the point where an Assembly can be "dropped" into the runtime folder of the service and a FileSystemWatcher will sense this and the service will dynamically load the assembly into a new AppDomain, and call it's Start method, optionally loading any configuration data at the same time, if a config file is present. If you are interested in this more sophisticated approach, I refer you to a previous article, "Self Updating Windows Service with Command Pattern MSMQ Invoker".

However if you aren't ready to (or need to get) that sophisticated, here's a "plain vanilla" way to handle this in a "regular" Windows Service. All we need to do is this: When the service starts, we get the CurrentDirectory, and we construct a SQL statement that creates a trigger (or multiple triggers, if more than one Database table) for INSERT, UPDATE, DELETE on the target table whose data could be changed while our service is running. We have the trigger call the sp_MakeWebTask built-in stored procedure, which creates ("touches") a file that we have predetermined in our config file. We add the trigger dynamically at runtime, so that we don't need to worry about where the service has been installed, or even on which machine, since we'll be using the administrative Share ("C$") with a UNC path from our database.

Of course, the other "piece to the puzzle" is that we have a FileSystemWatcher that starts with our Service, and if it detects a change in its target file filter, the Changed event gives us the opportunity to completely reload our cached DataSet. We never have to stop and restart our service, it works very well, and it's reliable.

Let's take a look at how this could be wired up. At the same time, I'm going to include self-installing Service code (via Craig Andera's original piece) as well as code from Neil Baliga that enables recovery and autostart info for a service installer, and in addition, I'll include some really useful (a - la Thomas the Tank Engine) code that let's you debug a service by having it run as an executable in the IDE in debug mode - a technique that has saved me many hours creating and debugging services, since the service does not need to be installed to be run!

First, let's look at the "TestService" sample code (the actual Windows Service):

namespace TestService

{

    using System;

    using System.Collections;

    using System.ComponentModel;

    using System.Configuration.Install;

    using System.Reflection;

    using System.ServiceProcess;

    using System.Threading;

    using TestLibrary;

 

    public class TestService : ServiceBase

    {

      

        private Container components = null;

 

        private TestLib test;

 

        public TestService()

        {           

            InitializeComponent();          

        }

 

        // The main entry point for the process

        private static void Main(string[] args)

        {

            string opt = null;

            // check for arguments

            if (args.Length > 0)

            {

                opt = args[0];

                if (opt != null && opt.ToLower() == "/install")

                {

                    TransactedInstaller ti = new TransactedInstaller();

                    ProjectInstaller pi = new ProjectInstaller();

                    ti.Installers.Add(pi);

                    String path = String.Format("/assemblypath={0}",

                                                Assembly.GetExecutingAssembly().Location);

                    String[] cmdline = {path};

                    InstallContext ctx = new InstallContext("", cmdline);

                    ti.Context = ctx;

                    ti.Install(new Hashtable());

                }

                else if (opt != null && opt.ToLower() == "/uninstall")

                {

                    TransactedInstaller ti = new TransactedInstaller();

                    ProjectInstaller mi = new ProjectInstaller();

                    ti.Installers.Add(mi);

                    String path = String.Format("/assemblypath={0}",

                                                Assembly.GetExecutingAssembly().Location);

                    String[] cmdline = {path};

                    InstallContext ctx = new InstallContext("", cmdline);

                    ti.Context = ctx;

                    ti.Uninstall(null);

 

                }

            }

            if (opt == null) // e.g. ,nothing on the command line

            {

#if ( ! DEBUG )

                ServiceBase[] ServicesToRun;

                ServicesToRun = new ServiceBase[] {new TestService()};

                ServiceBase.Run(ServicesToRun);

#else

                // debug code: allows the process to run as a non-service

                // will kick off the service start point, but never kill it

                // shut down the debugger to exit

                TestService service = new TestService();

                service.OnStart(null);

                Thread.Sleep(Timeout.Infinite);

#endif

            }

        }

 

        /// <summary>

        /// Required method for Designer support - do not modify

        /// the contents of this method with the code editor.

        /// </summary>

        private void InitializeComponent()

        {

            components = new Container();

            this.ServiceName = "TestService";

        }

 

        /// <summary>

        /// Clean up any resources being used.

        /// </summary>

        protected override void Dispose(bool disposing)

        {

            if (disposing)

            {

                if (components != null)

                {

                    components.Dispose();

                }

            }

            base.Dispose(disposing);

        }

 

        /// <summary>

        /// Set things in motion so your service can do its work.

        /// </summary>

        protected override void OnStart(string[] args)

        {

            test = new TestLib();

            test.Start();

        }

 

        /// <summary>

        /// Stop this service.

        /// </summary>

        protected override void OnStop()

        {

            test.Stop() ;

        }

    }

}

The only notable code you see above is that in Main, we use the self-installing service arrangement, which means you can install your service with "MyService.Exe /install". Nice! Same with uninstall. We also have the debug code which as can be seen, will run the service from the IDE in debug mode as a simple executable -- no need to install the service to debug it. Neil Baliga's code is in another project in the solution, and is well - documented, so I won't cover that here. You can read his article for more information. Suffice to say, I've shown how you can read your own configuration file settings to determine what the recovery and startup options will be when the service is first installed, including whether the service is to start itself when it is first installed.

Now we'll move on to the TestLib class library, which implements all my cool stuff about the FileSystemWatcher and the DataSet caching as described above:

namespace TestLibrary

{

    using System;

    using System.Configuration;

    using System.Data;

    using System.Diagnostics;

    using System.IO;

    using System.Threading;

    using PAB.Data.Utils;

 

    /// <summary>

    /// Summary description for Class1.

    /// </summary>

    public class TestLib

    {

        public Thread runThread = null;

        public bool running = false;

        public DataSet cacheDs = null;

        private static String fileToWatch = Environment.CurrentDirectory + @"\" + ConfigurationSettings.AppSettings["fileToWatch"].ToString();

        private FileSystemWatcher watcher;

        private string connString = ConfigurationSettings.AppSettings["connectionString"];

 

 

        public TestLib()

        {

            string machineName = Environment.MachineName;

            string strTrigger = "CREATE TRIGGER trg_WriteCacheDepFile ON [dbo].[Employees] ";

            strTrigger += "FOR INSERT, UPDATE, DELETE AS ";

            strTrigger += @"EXEC sp_makewebtask '\\" + machineName;

            strTrigger += @"\C$\";

            string tmp = fileToWatch.Replace(@"C:\", "");

            strTrigger += tmp + "', 'SELECT top 1 FirstName FROM employees'";

 

            string strRemoveTrigger = "DROP TRIGGER trg_WriteCacheDepFile";

            int res = 0;

            try

            {

                res = SqlHelper.ExecuteNonQuery(connString, CommandType.Text, strRemoveTrigger);

                Debug.WriteLine("Remove Trigger: " + res.ToString());

            }

            catch (Exception ex)

            {

                Debug.WriteLine(ex.Message + ex.StackTrace);

            }

            try

            {

                res = SqlHelper.ExecuteNonQuery(connString, CommandType.Text, strTrigger);

                Debug.WriteLine("CREATE Trigger: " + res.ToString());

 

            }

            catch (Exception ex)

            {

                Debug.WriteLine(ex.Message + ex.StackTrace);

            }

 

            Debug.Write(strTrigger);

            if (!File.Exists(fileToWatch))

            {

                FileStream fs = File.Create(fileToWatch);

                fs.Write(new byte[] {1, 2, 3, 4}, 0, 4);

                fs.Close();

 

            }

            watcher = new FileSystemWatcher(Environment.CurrentDirectory, ".txt");

            watcher.EnableRaisingEvents = true;

            watcher.NotifyFilter = NotifyFilters.LastAccess | NotifyFilters.LastWrite;

            watcher.Filter = "mycache.txt";

        }

 

        public void Start()

        {

            runThread = new Thread(new ThreadStart(MainLoop));

            runThread.IsBackground = true;

            running = true;

            watcher.Changed += new FileSystemEventHandler(watcher_Changed);

            ReloadMyCache();

            try

            {

                runThread.Start();

            }

            catch (Exception ex)

            {

                Debug.WriteLine(ex.Message + ex.StackTrace);

            }

        }

 

        public void Stop()

        {

            running = false;

            watcher.Dispose();

            runThread.Join();

 

        }

 

        public void MainLoop()

        {

            while (running)

            {

                // Your business logic here

            }

 

 

        }

 

        private void ReloadMyCache()

        {

            try

            {

                this.cacheDs = SqlHelper.ExecuteDataset(connString, "dbo.Get_Employees", null);

            }

            catch (Exception ex)

            {

                EventLog.WriteEntry("Stopping Service: Cache", ex.Message + ex.StackTrace);

                //Want to see how to STOP a running service dead in its tracks (if necessary)?

                throw new InvalidOperationException("Service stopped due to unrecoverable error:" + ex.Message);

            }

 

            EventLog.WriteEntry("Cache", "MyCache Reloaded.");

 

        }

 

        private void ReloadMyOtherCache()

        {

            try

            {

                this.cacheDs = SqlHelper.ExecuteDataset(connString, "dbo.Get_Employees", null);

            }

            catch (Exception ex)

            {

                Debug.WriteLine(ex.Message + ex.StackTrace);

            }

            EventLog.WriteEntry("Cache", "MyCache Reloaded.");

 

        }

 

        private DateTime lastChangeTime;

        private string lastFilePath;

 

        private void watcher_Changed(object sender, FileSystemEventArgs e)

        {

            // handle issue with FileSystemWatcher where multiple events are fired.

            // P.S. It's not necessarily a "BUG", its just that we don't want this behavior.

            TimeSpan span = DateTime.Now.Subtract(lastChangeTime);

            if (span.TotalSeconds > 2 || lastFilePath != e.FullPath)

            {

                // wait a second for any locks to be released

                // before taking actions

                Thread.Sleep(1000);

                EventLog.WriteEntry("TestLib", "Cache Invalidated at " + e.FullPath + ": " + e.ChangeType + DateTime.Now.ToString());

                // you can have a switch statement here for various different cache items,

                // each with its own different monitored file name...

                if (e.FullPath.ToLower().IndexOf("mycache.txt") > -1)

                {

                    ReloadMyCache();

                    // update bugHelpers

                    lastChangeTime = DateTime.Now;

                    lastFilePath = e.FullPath;

                }

 

                if (e.FullPath.ToLower().IndexOf("myothercache.txt") > -1)

                {

                    ReloadMyOtherCache();

                    // update bugHelpers

                    lastChangeTime = DateTime.Now;

                    lastFilePath = e.FullPath;

                }

            }

        }

    }

}

The above code should be self-explanatory for most developers. Notably, the Start method starts a background thread loop that can be stopped via the Stop method, providing a graceful way to handle thread managment and get the business logic "out of the service". You can see how the trigger is set up dynamically so that it doesn't matter what the machine is or the installation directory, the trigger will be created to match it automatically. You can have multiple triggers on more than one table by simply repeating the code. SqlHelper is used throughout, because it is very easy to use, efficient, best-practices code, and I don't believe in reinventing the wheel where simplified, reliable data access is involved. Finally you can see the watcher_Changed event handler which reloads our DataSet with fresh data if anyone changes something in the database.

NOTE: If you want the sample solution to run properly "out of the box", create a new stored proc in the Northwind database:

CREATE PROC dbo.Get_Employees
AS
Select * from dbo.employees

I hope this little collection of "techniques" is helpful to you.

Download the VS.NET 2003 Solution that accompanies this article.

 

Peter Bromberg is a C# MVP, MCP, and .NET consultant who has worked in the banking and financial industry for 20 years. He has architected and developed web - based corporate distributed application solutions since 1995, and focuses exclusively on the .NET Platform.
Article Discussion: