Running a scheduled task inside of a asp.net web application

Add to FacebookAdd to DiggAdd to Del.icio.usAdd to StumbleuponAdd to RedditAdd to BlinklistAdd to TwitterAdd to TechnoratiAdd to Yahoo BuzzAdd to Newsvine

Occasionally, there might be some activity in your web application that should trigger the need for some code to execute at a later date. One of the most common cases is sending an email (reminder, change digest, etc), but there are other uses too. You might want to defer some processing intensive activity to off hours. In my most recent case, I needed to check for changes to an purchased/installed application’s db and replicate them to another product every 5 minutes or so.

I’ve solved similar problems before by:

  1. From the web application, inserting a row in a ‘scheduled tasks’ database with a time to execute and a script url to run
  2. Creating an running a windows service somewhere that wakes up every 5 minutes or so, looks at the table for ‘due’ items, and opens a request to the url

This works, but it has some drawbacks.

  • You have to learn how to build and deploy a service.  Not particularly hard, but something a web developer doesn’t really need to know
  • You have to copy or figure out how to share some data access logic between the service and the web application, and maintain changes
  • You have to figure out where to deploy the service
  • You have to have somewhere to deploy the service – if you are using a shared webhost with no RDP you are out of luck
  • It’s hard to be sure the service is running.  It’s easy to forget about if your infrastructure changes.
  • You need to deal with it when the service errors.
  • You have to be careful that the service doesn’t run the same script twice (or make it so it doesn’t hurt anything if it does), in case it gets run on two machines, etc.
  • Many more tiny, but real headaches for dealing with and maintaining a separate but connected project

I really didn’t want to go through all of that again for yet another project.  There had to be a simpler solution.

Thanks to Google, I found this article that led me to use a Cache object expiration to simulate the service.  It’s a hack, but it solved my issue.

Later, I found this StackOverflow post about the same issue/fix.  The comments led me to a System.Timers.Timer solution, which is easier to understand. Here it is:

The global.asax:

     public const int MINUTES_TO_WAIT = 5;

    private string _workerPageUrl = null;
    protected string WorkerPageUrl
    {
        get
        {
            if (_workerPageUrl == null)
                _workerPageUrl = (Application["WebRoot"] + VirtualPathUtility.ToAbsolute("~/DoTimedWork.ashx")).Replace("//", "/").Replace(":/", "://") + "?schedule=true";


            return _workerPageUrl;
        }
    }


    protected void Application_Start(Object sender, EventArgs e)
    {
        Uri reqUri = HttpContext.Current.Request.Url;
        Application["WebRoot"] = new UriBuilder(reqUri.Scheme, reqUri.Host, reqUri.Port).ToString();
        Application["TimerRunning"] = false;

        //StartTimer();   // don't want timer to start unless we call the url (don't run in dev, etc).  Url will be called by montastic for live site.
    }

    private void StartTimer()
    {
        if (!(bool)Application["TimerRunning"]) // don't want multiple timers
        {
            System.Timers.Timer timer = new System.Timers.Timer(MINUTES_TO_WAIT * 60 * 1000);
            timer.AutoReset = false;
            timer.Enabled = true;
            timer.Elapsed += new System.Timers.ElapsedEventHandler(timer_Elapsed);
            timer.Start();
            Application["TimerRunning"] = true;
        }

    }

    void timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
    {
        Application["TimerRunning"] = false;
        System.Net.WebClient client = new System.Net.WebClient();
        // have to issue a request so that there is a context
        // also lets us separate all of the scheduling logic from the work logic
        client.DownloadData(WorkerPageUrl + "&LastSuccessfulRun=" + Server.UrlEncode((CurrentSetting.LastSuccessfulRun ?? DateTime.Now.AddYears(-1)).ToString()));
    }

    protected void Application_BeginRequest(Object sender, EventArgs e)
    {
        if (HttpContext.Current.Request.Path == GetLocalUrl(new Uri(WorkerPageUrl)))
        {
            CurrentSetting.LastRun = DateTime.Now;
            try
            {
                CurrentSetting.RunCount++;
            }
            catch (Exception)
            {
                CurrentSetting.RunCount = 0;  // just in case of an overflow
            }
            SaveSettings();
        }
    }
    protected void Application_EndRequest(Object sender, EventArgs e)
    {
        if (HttpContext.Current.Request.Path == GetLocalUrl(new Uri(WorkerPageUrl)))
        {
            if (HttpContext.Current.Error == null) //
            {
                CurrentSetting.LastSuccessfulRun = DateTime.Now;
                SaveSettings();
            }

            if (HttpContext.Current.Request["schedule"] == "true")// register the next iteration whenever worker finished
                StartTimer();
        }
    }

    void Application_Error(object sender, EventArgs e)
    {
        if (HttpContext.Current.Request.Path == GetLocalUrl(new Uri(WorkerPageUrl)))
        {
            Common.LogException(HttpContext.Current.Error.GetBaseException());
        }

    }
    
    protected class Setting
    {
        public DateTime? LastRun { get; set; }
        public DateTime? LastSuccessfulRun { get; set; }
        public long RunCount { get; set; }
    }
    Setting currentSetting = null;
    protected Setting CurrentSetting
    {
        get
        {
            if (currentSetting == null)
            {
                using (System.Security.Principal.WindowsImpersonationContext imp = Common.Impersonate())
                {
                    System.IO.FileInfo f = new System.IO.FileInfo(HttpContext.Current.Server.MapPath("~/data/settings.xml"));
                    if (f.Exists)
                    {
                        System.Xml.Linq.XDocument doc = System.Xml.Linq.XDocument.Load(f.FullName);
                        currentSetting = (from s in doc.Elements("Setting")
                                          select new Setting()
                                          {
                                              LastRun = DateTime.Parse(s.Element("LastRun").Value),
                                              LastSuccessfulRun = DateTime.Parse(s.Element("LastSuccessfulRun").Value),
                                              RunCount = long.Parse(s.Element("RunCount").Value)
                                          }).First();

                    }
                }
            }

            if (currentSetting == null)
            {
                currentSetting = new Setting()
                {
                    LastRun = null,
                    LastSuccessfulRun = DateTime.Now.AddYears(-1),//ignore older than one year old in test
                    RunCount = 0
                };
            }

            return currentSetting;
        }
        set
        {
            currentSetting = value;
            if (Common.Live)
            {
                using (System.Security.Principal.WindowsImpersonationContext imp = Common.Impersonate())
                {
                    System.IO.DirectoryInfo di = new System.IO.DirectoryInfo(HttpContext.Current.Server.MapPath("~/data"));
                    if (!di.Exists)
                        di.Create();
                    System.Xml.XmlWriter writer = System.Xml.XmlWriter.Create(HttpContext.Current.Server.MapPath("~/data/settings.xml"));
                    try
                    {
                        System.Xml.Linq.XDocument doc = new System.Xml.Linq.XDocument(
                                new System.Xml.Linq.XElement("Setting",
                                    new System.Xml.Linq.XElement("LastRun", currentSetting.LastRun ?? DateTime.Now),
                                    new System.Xml.Linq.XElement("LastSuccessfulRun", currentSetting.LastSuccessfulRun),
                                    new System.Xml.Linq.XElement("RunCount", currentSetting.RunCount)
                                    )
                            );
                        doc.WriteTo(writer);
                    }
                    catch (Exception exc)
                    {
                        Common.LogException(exc);
                    }
                    finally
                    {
                        writer.Flush();
                        writer.Close();
                    }
                }
            }
        }
    }
    protected void SaveSettings()
    {
        CurrentSetting = CurrentSetting; // reset to ensure "setter" code saves to file
    }




 

    private string GetLocalUrl(Uri uri)
    {
        string ret = uri.PathAndQuery;
        if (uri.Query != null && uri.Query.Length>0)
            ret = ret.Replace(uri.Query, "");

        return ret;
    }

DoTimedWork.ashx:

 protected DateTime LastSuccessfulRun
    {
        get
        {
            try
            {
                return DateTime.Parse(HttpContext.Current.Request["LastSuccessfulRun"]);
            }
            catch (Exception) { }
            return DateTime.Now.AddDays(-1);
        }
    }
    
    public void ProcessRequest(HttpContext context)
    {
        if (context.Request["dowork"] != "false") // don't do work if it's just motastic hitting the page (to make sure the  timer is running)
        {
                context.Server.ScriptTimeout = 1800; // 30 minutes

                // do work
        }
        context.Response.Write("done");
    }
 
    public bool IsReusable {
        get {
            return false;
        }
    }

Common.cs

using System.Runtime.InteropServices;
using System.Security.Principal;

    public static  bool Live
    {
        get
        {
            return HttpContext.Current.Request.Url.Host != "localhost";
        }
    }
    public const int LOGON_TYPE_INTERACTIVE = 2;
    public const int LOGON_TYPE_PROVIDER_DEFAULT = 0;
    // Using this api to get an accessToken of specific Windows User by its user name and password
    [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    static public extern bool LogonUser(string userName, string domain, string passWord, int logonType, int logonProvider, ref IntPtr accessToken);

    public static WindowsImpersonationContext Impersonate() //run code as a windows user with permissions to write files, etc.
    {

        IntPtr accessToken = IntPtr.Zero;
        LogonUser("REPLACE_WITH_WINDOWS_USER", "", "REPLACE_WITH_WINDOWS_PASSWORD", LOGON_TYPE_INTERACTIVE, LOGON_TYPE_PROVIDER_DEFAULT, ref accessToken);

        WindowsIdentity identity = new WindowsIdentity(accessToken);

        return identity.Impersonate();
    }
    public static void LogException(Exception exc)
    {
        LogActivity(exc.Message + "\n" + exc.StackTrace);
    }
    public static void LogActivity(string message)
    {
        if (Live)
        {
            using (WindowsImpersonationContext imp = Impersonate())
            {
                DirectoryInfo d = new DirectoryInfo(HttpContext.Current.Server.MapPath("~/data/temp/"));
                if (!d.Exists)
                    d.Create();
                var file = File.Create(HttpContext.Current.Server.MapPath("~/data/temp/" + DateTime.Now.Ticks + ".log"));
                try
                {
                    byte[] m = System.Text.Encoding.ASCII.GetBytes(message + "\n");
                    file.Write(m, 0, m.Length);
                }
                catch (Exception exc)
                {
                    byte[] m = System.Text.Encoding.ASCII.GetBytes(exc.Message + "\n" + exc.StackTrace);
                    try
                    {
                        file.Write(m, 0, m.Length);
                    }
                    catch (Exception) { }
                }
                finally
                {
                    file.Flush();
                    file.Close();
                }
            }
        }
        else
        {
            HttpContext.Current.Response.Write(message);
        }
    }

There are some issues with this approach to consider.

  • for very high usage sites or very intensive timed work, the work may put burden you wouldn’t want on your webserver
  • Application_Start only runs after the first request to your site after an app pool recycle, IIS restart, server restart, etc. If your site goes through periods of inactivity, you may or may not care if the Timer executes. If you do, you need to ensure the site is hit regularly in some way. I use the website monitor montastic for this.

So, there are going to be circumstances that make the windows service solution better. You just need to decide whether the benefits of using a service outweigh the pain of developing, maintaining, and deploying it alongside your web application.

Advertisements

One Response to “Running a scheduled task inside of a asp.net web application”

  1. Threading with Impersonation in an ASP.NET Project « The Foliotek Development Blog Says:

    […] Running a scheduled task inside of a asp.net web application […]


Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: