Simulate a Windows Service using ASP.NET to run scheduled jobs
Introduction
How to run
scheduled jobs from ASP.NET without requiring a Windows Service to be installed
on the server? Very often we need to run some maintenance tasks or scheduled
tasks like sending reminder emails to users from our websites. This can only be
achieved using a Windows service. ASP.NET being stateless provides no support
to run some code continuously or to run code at a scheduled time. As a result,
we have to make our own Windows Services in order to run scheduled jobs or cron
jobs. But in a shared hosted environment, we do not always have the luxury to
deploy our own Windows service to our hosting provider’s web server. We either
have to buy a dedicated server which is very costly, or sacrifice such features
in our web solution. However, running a scheduled task is a very handy feature
especially for sending reminder emails to users, maintenance reports to
administrators, or run cleanup operations etc. So, I will show you a tricky way
to run scheduled jobs using pure ASP.NET without requiring any Windows service.
This solution runs on any hosting service providing just ASP.NET hosting. As a
result, you can have the scheduled job feature in your ASP.NET web projects
without buying dedicated servers.
How it works
First we need
something in ASP.NET that is continuously running and gives us a callback. The
web server IIS is continuously running. So, we somehow need to get a frequent
callback from it so that we can lookup a job queue and see if there’s something
that needs to be executed. Now, there are several ways a web server comes to
us:
- When a page hits
- When an application starts
- When an application stops
- When a session starts and ends/timeouts
- When a cache item expires
The page hit is
random. If no body visits your website for hours, you can’t do the pending jobs
for hours. Besides, the execution of a request is very short and needs to
finish as soon as possible. If you plan to execute scheduled jobs on page
execution, then the page will take longer to execute which will result in a
poor user experience. So, clearly this is not an option.
When an
application starts, we get a callback in the Application_Start method of Global.asax. So, this is a good place to start a
background thread which runs forever and executes the scheduled jobs. However,
the thread can be killed anytime the web server decides to take a nap due to
zero load.
When an
application stops, we get a callback at Application_End. But we can’t do anything here because the whole application is going to
die soon.
Session_Start in Global.asax is triggered when a user visits a page that requires a new session to be
initiated. So, this is also random. We need something that consistently and
periodically fires.
A cache item
expires on a given time or duration. In ASP.NET, you can add entries in the Cache and set an absolute expiry date time or you can set a duration after which
the item is removed from the cache. You can do this by utilizing the following
method of the Cache class:
public void Insert ( System.String key , System.Object value ,
System.Web.Caching.CacheDependency dependencies ,
System.DateTime
absoluteExpiration ,
System.TimeSpan
slidingExpiration ,
System.Web.Caching.CacheItemPriority priority ,
System.Web.Caching.CacheItemRemovedCallback onRemoveCallback )
The onRemoveCallback is a delegate to a method which is called whenever a cache item expires.
In that method we can do anything we like. So, this is a good candidate for
running code periodically, consistently without requiring any page visit.
This means, we can
simulate a Windows Service utilizing Cache timeout! Now who thought that this
would be possible?
Creating cache item callbacks
First on Application_Start we need to register a cache item that will expire in 2 minutes. Please
note, the minimum duration you can set for expire callback is 2 minutes.
Although you can set a lower value, it does not work. Most probably the ASP.NET
worker process looks at the cache items once every two minutes.
private const string DummyCacheItemKey = "GagaGuguGigi";
protected void Application_Start(Object sender, EventArgs e)
{
RegisterCacheEntry();
}
private bool RegisterCacheEntry()
{
if( null != HttpContext.Current.Cache[ DummyCacheItemKey ] ) return false;
HttpContext.Current.Cache.Add(
DummyCacheItemKey, "Test", null,
DateTime.MaxValue,
TimeSpan.FromMinutes(1),
CacheItemPriority.Normal,
new CacheItemRemovedCallback( CacheItemRemovedCallback ) );
return true;
}
This cache entry
is a dummy entry. We do not store any valuable information here because
whatever we store here, might be gone on application restart. Besides all we
need is the frequent callback from this item.
Inside the
callback, we do all the service work:
public void CacheItemRemovedCallback( string key,
object value, CacheItemRemovedReason reason)
{
Debug.WriteLine("Cache item callback: " + DateTime.Now.ToString() );
// Do the service works
DoWork();
}
Store item in cache again upon expire
Whenever the cache
item expires, we get a callback and the item is gone from the cache. So, we no
longer get any callback in future. In order to have a continuous supply of
callback, we need to store an item in cache again upon expiration. This seems
quite easy; we can call the RegisterCacheEntry function shown above from the callback function, isn’t it? It does not
work. When the callback method fires, there is no HttpContext available. TheHttpContext object is only available when a request is being processed. As the
callback is fired from the web server behind the scene, there is no request
being processed and thus no HttpContext is available. As a result, you cannot get access to the Cache object from the callback function.
The solution is,
we need to simulate a request. We can make a dummy call to a dummy webpage by
utilizing theWebClient class in the .NET framework. When the dummy page is being executed, we can
get hold of theHttpContext and then register the callback item again.
So, the callback
method is modified a bit to make the dummy call:
public void CacheItemRemovedCallback( string key,
object value, CacheItemRemovedReason reason)
{
Debug.WriteLine("Cache item callback: " + DateTime.Now.ToString() );
HitPage();
// Do the service works
DoWork();
}
The HitPage function makes a call to a dummy page:
private const string DummyPageUrl =
"http://localhost/TestCacheTimeout/WebForm1.aspx";
private void HitPage()
{
WebClient client = new WebClient();
client.DownloadData(DummyPageUrl);
}
Whenever the dummy
page executes, the Application_BeginRequest method gets called. There we can check whether this is a dummy page
request or not.
protected void Application_BeginRequest(Object sender, EventArgs e)
{
// If the dummy page is hit, then it means we want to add another item
// in cache
if( HttpContext.Current.Request.Url.ToString() == DummyPageUrl )
{
// Add the item in cache and when succesful, do the work.
RegisterCacheEntry();
}
}
We only intercept
the request to the dummy page, and we let other pages execute as it is.
Restart cache item callback on web process restart
There are several
cases where the web server might be restarted. For example, if the
Administrator restarts IIS, or restarts the computer, or the web process gets
recycled (Windows 2003). In such cases, the service will stop running unless a
page is hit and the Application_Start is called. Application_Start is called only when a page is visited for the first time in a web project.
So, when the web process is restarted, we need to manually call the dummy page
or someone needs to visit the home page of your website in order to get the
service running again.
One tricky
solution to this problem can be adding your website to search engines. Search
engines crawl pages frequently. So, they will hit a page of your website
resulting in Application_Start and thus the service will get up and running.
Another idea is to
register your website to some traffic or availability monitoring services.
There are lots of web services which keep an eye on your website and checks
whether it is alive and the performance is satisfactory. All these services hit
your web site pages and then collect the statistics. So, by registering in such
services, you can guarantee your web application is alive all the time.
Testing possible types of job executions
Let’s test whether
we can do all possible tasks that we can do from a Windows Service. The first
problem is, we cannot do everything a Windows service does because it runs in
the Local System account. This is a very high privilege account and you can do
anything to your system under this account. However, the ASP.NET web process
runs in the ASPNET account (Windows XP) or NETWORK SERVICE account (Windows
2003). This is a low privilege account and does not have the right to access
the hard drive. In order to allow the service to write to the hard drive, the
web process account needs to be allowed the write permission to a folder
exclusively. We all know about this security problem, so I won’t go into
details about it.
Now we will be
testing all possible things that we normally do from a Windows Service:
- Write to a file
- Database operations
- Web service calls
- MSMQ operations
- Email send
Let’s write some
test code for this inside the DoWork method:
private void DoWork()
{
Debug.WriteLine("Begin DoWork...");
Debug.WriteLine("Running as: " +
WindowsIdentity.GetCurrent().Name );
DoSomeFileWritingStuff();
DoSomeDatabaseOperation();
DoSomeWebserviceCall();
DoSomeMSMQStuff();
DoSomeEmailSendStuff();
Debug.WriteLine("End DoWork...");
}
Testing File Write
Let’s test if we
can really write to a file. Create a folder named “temp” in your C drive
and if the drive is an NTFS partition, allow the ASPNET/NETWORK SERVICE account
to write to that folder.
private void DoSomeFileWritingStuff()
{
Debug.WriteLine("Writing to file...");
try
{
using( StreamWriter writer =
new StreamWriter(@"c:\temp\Cachecallback.txt", true) )
{
writer.WriteLine("Cache Callback: {0}", DateTime.Now);
writer.Close();
}
}
catch( Exception x )
{
Debug.WriteLine( x );
}
Debug.WriteLine("File write successful");
}
Open the log file
and you should be seeing entries like:
Cache Callback:
10/17/2005 2:50:00 PM
Cache Callback:
10/17/2005 2:52:00 PM
Cache Callback:
10/17/2005 2:54:00 PM
Cache Callback:
10/17/2005 2:56:00 PM
Cache Callback:
10/17/2005 2:58:00 PM
Cache Callback:
10/17/2005 3:00:00 PM
Testing Database Connectivity
Run the following
script in your “tempdb” database inside SQL Server 2000:
IF EXISTS (SELECT * FROM dbo.sysobjects WHERE id =
object_id(N'[dbo].[ASPNETServiceLog]') AND
OBJECTPROPERTY(id, N'IsUserTable') = 1)
DROP TABLE
[dbo].[ASPNETServiceLog]
GO
CREATE TABLE
[dbo].[ASPNETServiceLog] (
[Mesage] [varchar] (1000)
COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL ,
[DateTime] [datetime] NOT NULL
) ON [PRIMARY]
GO
This will create a
table named ASPNETServiceLog for you. Remember, as it is created in tempdb, this table will disappear when SQL Server
restarts.
Next make the ASPNET/NETWORK SERVICE account a member of db_datawriter of the tempdb database. Alternatively you can define a more specific permission and
allow only writing to the table.
Now write the test
function:
private void DoSomeDatabaseOperation()
{
Debug.WriteLine("Connecting to
database...");
using( SqlConnection con = new SqlConnection("Data Source" +
"=(local);Initial Catalog=tempdb;Integrated Security=SSPI;") )
{
con.Open();
using( SqlCommand cmd = new SqlCommand( "INSERT" +
" INTO ASPNETServiceLog VALUES" +
" (@Message, @DateTime)", con ) )
{
cmd.Parameters.Add("@Message", SqlDbType.VarChar, 1024).Value =
"Hi I'm the ASP NET Service";
cmd.Parameters.Add("@DateTime", SqlDbType.DateTime).Value =
DateTime.Now;
cmd.ExecuteNonQuery();
}
con.Close();
}
Debug.WriteLine("Database connection
successful");
}
This will create
entries in the log table which you can check later on to ensure if there was
any delay between the service execution. You should be getting one row per two
minutes.
Figure 1: ASP NET
Service Log Table Data
Testing Email Delivery
The most common
requirement for running a Windows Service is to send periodical email alerts,
notifications, status reports etc. via mail. So, it’s important that we test if
email works from such a service:
private void DoSomeEmailSendStuff()
{
try
{
MailMessage msg = new MailMessage();
msg.From = "abc@cde.fgh";
msg.To = "ijk@lmn.opq";
msg.Subject = "Reminder: " + DateTime.Now.ToString();
msg.Body = "This is a server generated message";
SmtpMail.Send( msg );
}
catch( Exception x )
{
Debug.WriteLine( x );
}
}
Please replace
From and To recipient addresses with some meaningful address and you should be
getting email alerts every two minutes.
Testing MSMQ
Let's write a
small function to test whether we can access MSMQ from ASP.NET properly:
private void DoSomeMSMQStuff()
{
using( MessageQueue queue = new
MessageQueue(MSMQ_NAME) )
{
queue.Send(DateTime.Now);
queue.Close();
}
}
Alternatively, you
can call the Receive method of the queue to get pending messages in the queue that needs to be
processed.
One issue that you
should remember here is, do not subscribe to the Receive event of the queue. As the thread can die anytime and the web server can
be restarted anytime, a continuous blocked Receive will not work properly. Besides if you call the BeginReceive method and block code execution until a message arrives, the service gets
stuck and other codes will not run. So, in this case, you will have to call the Receive method to fetch the pending messages.
Extending the system
The ASP.NET
Service can be extended in many ways to allow pluggable jobs. You can introduce
Job queuing from web pages which this service will execute periodically. For
example, you can queue jobs in a cache item and the services pick up the job
and executes it. This way, you can simulate a nice job processing system inside
your ASP.NET project. Previously this was only possible with a Windows Service
but now you can do it with ASP.NET also.
Let’s make a
simple Job class which contains information about a job to execute.
public class Job
{
public string Title;
public DateTime ExecutionTime;
public Job( string title, DateTime executionTime )
{
this.Title = title;
this.ExecutionTime = executionTime;
}
public void Execute()
{
Debug.WriteLine("Executing job at: " + DateTime.Now );
Debug.WriteLine(this.Title);
Debug.WriteLine(this.ExecutionTime);
}
}
From a simple ASPX
page, we will queue a job to a static ArrayList called _JobQueue declared in Global.asax:
Job newJob = new Job( "A job queued at: " + DateTime.Now,
DateTime.Now.AddMinutes(4) );
lock( Global._JobQueue )
{
Global._JobQueue.Add( newJob );
}
So, the job gets
queued to be executed after 4 minutes. The service code runs every 2 minutes
and checks the job queue if there’s anything that is overdue and needs to be
executed. If there’s any job waiting, it is removed from the queue and
executed. The service code now has an additional method called ExecuteQueuedJobs. This function does the scheduled job execution:
private void ExecuteQueuedJobs()
{
ArrayList jobs = new ArrayList();
// Collect which jobs are overdue
foreach( Job job in _JobQueue )
{
if( job.ExecutionTime <= DateTime.Now )
jobs.Add( job );
}
// Eecute the jobs that are overdue
foreach( Job job in jobs )
{
lock( _JobQueue )
{
_JobQueue.Remove( job );
}
job.Execute();
}
}
Don’t forget to
lock the static job collection because ASP.NET is multithreaded and pages being
executed on different threads have a high possibility for trying to write on
the job queue at the same time.
Who can benefit from this
ASP.NET developers
who host their website on shared hosting environments which do not offer a
Windows Service feature can benefit from this code. Also for those who do not
have their own dedicated server to install Windows Services.
How to run the sample project
The sample project
is a very simple website with a dummy page. All the code is inside Global.asax. You need to modify the connection string,
MSMQ name and the folder path according to your settings.
Here's how to
setup the code:
- Extract the zip in a folder.
- Create a virtual directory named "TestCacheTimeout"
and map to the extracted folder.
- Load the project file and run.
- Create a MSMQ Queue called
"ASPNETService".
- Run the SQL to create the log table in tempdb as explained earlier.
- Create a temp folder in C: drive and add ASPNET/NETWORK
SERVICE account with Write permission.
- Fix all the constants available in Global.asax.
If you want to add
the code in your own project, do the following:
- Copy the Global.asax code to yours.
- Create a dummy page which queues the cache item.
The dummy page needs no code.
- Modify the constants available in the Global.asax at the beginning.
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix