lib/phusion_passenger/abstract_server_collection.rb



#  Phusion Passenger - http://www.modrails.com/
#  Copyright (c) 2010 Phusion
#
#  "Phusion Passenger" is a trademark of Hongli Lai & Ninh Bui.
#
#  Permission is hereby granted, free of charge, to any person obtaining a copy
#  of this software and associated documentation files (the "Software"), to deal
#  in the Software without restriction, including without limitation the rights
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
#  copies of the Software, and to permit persons to whom the Software is
#  furnished to do so, subject to the following conditions:
#
#  The above copyright notice and this permission notice shall be included in
#  all copies or substantial portions of the Software.
#
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
#  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
#  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
#  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
#  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
#  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
#  THE SOFTWARE.

require 'phusion_passenger/utils'

module PhusionPassenger

# This class maintains a collection of AbstractServer objects. One can add new
# AbstractServer objects, or look up existing ones via a key.
# AbstractServerCollection also automatically takes care of cleaning up
# AbstractServers that have been idle for too long.
#
# This class exists because both SpawnManager and ClassicRails::FrameworkSpawner need this kind
# of functionality. SpawnManager maintains a collection of ClassicRails::FrameworkSpawner
# and ClassicRails::ApplicationSpawner objects, while ClassicRails::FrameworkSpawner maintains a
# collection of ClassicRails::ApplicationSpawner objects.
#
# This class is thread-safe as long as the specified thread-safety rules are followed.
class AbstractServerCollection
	attr_reader :next_cleaning_time
	
	include Utils
	
	def initialize
		@collection = {}
		@lock = Mutex.new
		@cleanup_lock = Mutex.new
		@cond = ConditionVariable.new
		@done = false
		
		# The next time the cleaner thread should check for idle servers.
		# The value may be nil, in which case the value will be calculated
		# at the end of the #synchronized block.
		#
		# Invariant:
		#    if value is not nil:
		#       There exists an s in @collection with s.next_cleaning_time == value.
		#       for all s in @collection:
		#          if eligable_for_cleanup?(s):
		#             s.next_cleaning_time <= value
		@next_cleaning_time = Time.now + 60 * 60
		@next_cleaning_time_changed = false
		
		@cleaner_thread = Thread.new do
			begin
				@lock.synchronize do
					cleaner_thread_main
				end
			rescue Exception => e
				print_exception(self.class.to_s, e)
			end
		end
	end
	
	# Acquire the lock for this AbstractServerCollection object, and run
	# the code within the block. The entire block will be a single atomic
	# operation.
	def synchronize
		@lock.synchronize do
			@in_synchronize_block = true
			begin
				yield
			ensure
				if @next_cleaning_time.nil?
					@collection.each_value do |server|
						if @next_cleaning_time.nil? ||
						   (eligable_for_cleanup?(server) &&
						    server.next_cleaning_time < @next_cleaning_time
						   )
							@next_cleaning_time = server.next_cleaning_time
						end
					end
					if @next_cleaning_time.nil?
						# There are no servers in the collection with an idle timeout.
						@next_cleaning_time = Time.now + 60 * 60
					end
					@next_cleaning_time_changed = true
				end
				if @next_cleaning_time_changed
					@next_cleaning_time_changed = false
					@cond.signal
				end
				@in_synchronize_block = false
			end
		end
	end
	
	# Lookup and returns an AbstractServer with the given key.
	#
	# If there is no AbstractSerer associated with the given key, then the given
	# block will be called. That block must return an AbstractServer object. Then,
	# that object will be stored in the collection, and returned.
	#
	# The block must set the 'max_idle_time' attribute on the AbstractServer.
	# AbstractServerCollection's idle cleaning interval will be adapted to accomodate
	# with this. Changing the value outside this block is not guaranteed to have any
	# effect on the idle cleaning interval.
	# A max_idle_time value of nil or 0 means the AbstractServer will never be idle cleaned.
	#
	# If the block raises an exception, then the collection will not be modified,
	# and the exception will be propagated.
	#
	# Precondition: this method must be called within a #synchronize block.
	def lookup_or_add(key)
		raise ArgumentError, "cleanup() has already been called." if @done
		must_be_in_synchronize_block
		server = @collection[key]
		if server
			register_activity(server)
			return server
		else
			server = yield
			if !server.respond_to?(:start)
				raise TypeError, "The block didn't return a valid AbstractServer object."
			end
			if eligable_for_cleanup?(server)
				server.next_cleaning_time = Time.now + server.max_idle_time
				if @next_cleaning_time && server.next_cleaning_time < @next_cleaning_time
					@next_cleaning_time = server.next_cleaning_time
					@next_cleaning_time_changed = true
				end
			end
			@collection[key] = server
			return server
		end
	end
	
	# Checks whether there's an AbstractServer object associated with the given key.
	#
	# Precondition: this method must be called within a #synchronize block.
	def has_key?(key)
		must_be_in_synchronize_block
		return @collection.has_key?(key)
	end
	
	# Checks whether the collection is empty.
	#
	# Precondition: this method must be called within a #synchronize block.
	def empty?
		must_be_in_synchronize_block
		return @collection.empty?
	end
	
	# Deletes from the collection the AbstractServer that's associated with the
	# given key. If no such AbstractServer exists, nothing will happen.
	#
	# If the AbstractServer is started, then it will be stopped before deletion.
	#
	# Precondition: this method must be called within a #synchronize block.
	def delete(key)
		raise ArgumentError, "cleanup() has already been called." if @done
		must_be_in_synchronize_block
		server = @collection[key]
		if server
			if server.started?
				server.stop
			end
			@collection.delete(key)
			if server.next_cleaning_time == @next_cleaning_time
				@next_cleaning_time = nil
			end
		end
	end
	
	# Notify this AbstractServerCollection that +server+ has performed an activity.
	# This AbstractServerCollection will update the idle information associated with +server+
	# accordingly.
	#
	# lookup_or_add already automatically updates idle information, so you only need to
	# call this method if the time at which the server has performed an activity is
	# not close to the time at which lookup_or_add had been called.
	#
	# Precondition: this method must be called within a #synchronize block.
	def register_activity(server)
		must_be_in_synchronize_block
		if eligable_for_cleanup?(server)
			if server.next_cleaning_time == @next_cleaning_time
				@next_cleaning_time = nil
			end
			server.next_cleaning_time = Time.now + server.max_idle_time
		end
	end
	
	# Tell the cleaner thread to check the collection as soon as possible, instead
	# of sleeping until the next scheduled cleaning time.
	#
	# Precondition: this method must NOT be called within a #synchronize block.
	def check_idle_servers!
		must_not_be_in_synchronize_block
		@lock.synchronize do
			@next_cleaning_time = Time.now - 60 * 60
			@cond.signal
		end
	end
	
	# Iterate over all AbstractServer objects.
	#
	# Precondition: this method must be called within a #synchronize block.
	def each
		must_be_in_synchronize_block
		each_pair do |key, server|
			yield server
		end
	end
	
	# Iterate over all keys and associated AbstractServer objects.
	#
	# Precondition: this method must be called within a #synchronize block.
	def each_pair
		raise ArgumentError, "cleanup() has already been called." if @done
		must_be_in_synchronize_block
		@collection.each_pair do |key, server|
			yield(key, server)
		end
	end
	
	# Delete all AbstractServers from the collection. Each AbstractServer will be
	# stopped, if necessary.
	#
	# Precondition: this method must be called within a #synchronize block.
	def clear
		must_be_in_synchronize_block
		@collection.each_value do |server|
			if server.started?
				server.stop
			end
		end
		@collection.clear
		@next_cleaning_time = nil
	end
	
	# Cleanup all resources used by this AbstractServerCollection. All AbstractServers
	# from the collection will be deleted. Each AbstractServer will be stopped, if
	# necessary. The background thread which removes idle AbstractServers will be stopped.
	#
	# After calling this method, this AbstractServerCollection object will become
	# unusable.
	#
	# Precondition: this method must *NOT* be called within a #synchronize block.
	def cleanup
		must_not_be_in_synchronize_block
		@cleanup_lock.synchronize do
			return if @done
			@lock.synchronize do
				@done = true
				@cond.signal
			end
			@cleaner_thread.join
			synchronize do
				clear
			end
		end
	end

private
	def cleaner_thread_main
		while !@done
			current_time = Time.now
			# We add a 0.2 seconds delay to the sleep time because system
			# timers are not entirely accurate.
			sleep_time = (@next_cleaning_time - current_time).to_f + 0.2
			if sleep_time > 0 && @cond.timed_wait(@lock, sleep_time)
				next
			else
				keys_to_delete = nil
				@next_cleaning_time = nil
				@collection.each_pair do |key, server|
					if eligable_for_cleanup?(server)
						# Cleanup this server if its idle timeout has expired.
						if server.next_cleaning_time <= current_time
							keys_to_delete ||= []
							keys_to_delete << key
							if server.started?
								server.stop
							end
						# If not, then calculate the next cleaning time because
						# we're iterating the collection anyway.
						elsif @next_cleaning_time.nil? ||
						      server.next_cleaning_time < @next_cleaning_time
							@next_cleaning_time = server.next_cleaning_time
						end
					end
				end
				if keys_to_delete
					keys_to_delete.each do |key|
						@collection.delete(key)
					end
				end
				if @next_cleaning_time.nil?
					# There are no servers in the collection with an idle timeout.
					@next_cleaning_time = Time.now + 60 * 60
				end
			end
		end
	end
	
	# Checks whether the given server is eligible for being idle cleaned.
	def eligable_for_cleanup?(server)
		return server.max_idle_time && server.max_idle_time != 0
	end
	
	def must_be_in_synchronize_block
		if !@in_synchronize_block
			raise RuntimeError, "This method may only be called within a #synchronize block!"
		end
	end
	
	def must_not_be_in_synchronize_block
		if @in_synchronize_block
			raise RuntimeError, "This method may NOT be called within a #synchronize block!"
		end
	end
end

end # module PhusionPassenger