Skip to content
Browse files

initial commit

  • Loading branch information...
0 parents commit 7fb79836e4fc616b1f23e9ab9bc394e9a40a465a @tompesman committed Jun 15, 2012
4 .gitignore
@@ -0,0 +1,4 @@
+.bundle/
+log/*.log
+pkg/
+Gemfile.lock
23 Gemfile
@@ -0,0 +1,23 @@
+source "http://rubygems.org"
+
+# Declare your gem's dependencies in push.gemspec.
+# Bundler will treat runtime dependencies like base dependencies, and
+# development dependencies will be added by default to the :development group.
+gemspec
+
+gem 'rake'
+gem 'rspec'
+gem 'shoulda'
+gem 'activerecord', :require => 'active_record'
+gem 'pg'
+gem 'mysql2'
+gem 'sqlite3'
+gem 'database_cleaner'
+gem 'simplecov'
+
+group :development do
+ gem 'guard'
+ gem 'guard-rspec'
+ gem 'growl'
+ gem 'webmock'
+end
20 MIT-LICENSE
@@ -0,0 +1,20 @@
+Copyright 2012 YOURNAME
+
+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.
3 README.md
@@ -0,0 +1,3 @@
+# Push
+
+This project rocks and uses MIT-LICENSE.
27 bin/push
@@ -0,0 +1,27 @@
+#!/usr/bin/env ruby
+
+require 'optparse'
+require 'push'
+
+foreground = false
+environment = ARGV[0]
+banner = 'Usage: push <Rails environment> [options]'
+ARGV.options do |opts|
+ opts.banner = banner
+ opts.on('-f', '--foreground', 'Run in the foreground.') { foreground = true }
+ opts.on('-v', '--version', 'Print this version of push.') { puts "push #{Push::VERSION}"; exit }
+ opts.on('-h', '--help', 'You\'re looking at it.') { puts opts; exit }
+ opts.parse!
+end
+
+if environment.nil?
+ puts banner
+ exit 1
+end
+
+ENV['RAILS_ENV'] = environment
+load 'config/environment.rb'
+
+require 'push/daemon'
+
+Push::Daemon.start(environment, foreground)
22 lib/generators/push_generator.rb
@@ -0,0 +1,22 @@
+class PushGenerator < Rails::Generators::Base
+ include Rails::Generators::Migration
+ source_root File.expand_path('../templates', __FILE__)
+
+ def self.next_migration_number(path)
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
+ end
+
+ def copy_migration
+ migration_dir = File.expand_path("db/migrate")
+
+ if !self.class.migration_exists?(migration_dir, 'create_push')
+ migration_template "create_push.rb", "db/migrate/create_push.rb"
+ end
+ end
+
+ def copy_config
+ copy_file "development.rb", "config/push/development.rb"
+ copy_file "staging.rb", "config/push/staging.rb"
+ copy_file "production.rb", "config/push/production.rb"
+ end
+end
33 lib/generators/templates/create_push.rb
@@ -0,0 +1,33 @@
+class CreatePush < ActiveRecord::Migration
+ def self.up
+ create_table :push_messages do |t|
+ t.string :device, :null => false
+ t.string :type, :null => false
+ t.text :properties, :null => true
+ t.boolean :delivered, :null => false, :default => false
+ t.timestamp :delivered_at, :null => true
+ t.boolean :failed, :null => false, :default => false
+ t.timestamp :failed_at, :null => true
+ t.integer :error_code, :null => true
+ t.string :error_description, :null => true
+ t.timestamp :deliver_after, :null => true
+ t.timestamps
+ end
+
+ add_index :push_messages, [:delivered, :failed, :deliver_after]
+
+ create_table :push_feedback do |t|
+ t.string :device, :null => false
+ t.string :type, :null => false
+ t.timestamp :failed_at, :null => false
+ t.timestamps
+ end
+
+ add_index :push_feedback, :device
+ end
+
+ def self.down
+ drop_table :push_feedback
+ drop_table :push_messages
+ end
+end
19 lib/generators/templates/development.rb
@@ -0,0 +1,19 @@
+Push::Daemon::Builder.new do
+ daemon({ :poll => 2, :pid_file => "tmp/pids/push.pid", :airbrake_notify => false })
+
+ provider :apns,
+ {
+ :certificate => "development.pem",
+ :certificate_password => "",
+ :sandbox => true,
+ :connections => 3,
+ :feedback_poll => 60
+ }
+
+ provider :c2dm,
+ {
+ :connections => 2,
+ :email => "",
+ :password => ""
+ }
+end
19 lib/generators/templates/production.rb
@@ -0,0 +1,19 @@
+Push::Daemon::Builder.new do
+ daemon({ :poll => 2, :pid_file => "tmp/pids/push.pid", :airbrake_notify => false })
+
+ provider :apns,
+ {
+ :certificate => "production.pem",
+ :certificate_password => "",
+ :sandbox => false,
+ :connections => 3,
+ :feedback_poll => 60
+ }
+
+ provider :c2dm,
+ {
+ :connections => 2,
+ :email => "",
+ :password => ""
+ }
+end
19 lib/generators/templates/staging.rb
@@ -0,0 +1,19 @@
+Push::Daemon::Builder.new do
+ daemon({ :poll => 2, :pid_file => "tmp/pids/push.pid", :airbrake_notify => false })
+
+ provider :apns,
+ {
+ :certificate => "staging.pem",
+ :certificate_password => "",
+ :sandbox => true,
+ :connections => 3,
+ :feedback_poll => 60
+ }
+
+ provider :c2dm,
+ {
+ :connections => 2,
+ :email => "",
+ :password => ""
+ }
+end
4 lib/push.rb
@@ -0,0 +1,4 @@
+require 'active_record'
+require 'push/version'
+require 'push/message'
+require 'push/feedback'
124 lib/push/daemon.rb
@@ -0,0 +1,124 @@
+require 'thread'
+require 'push/daemon/builder'
+require 'push/daemon/interruptible_sleep'
+require 'push/daemon/delivery_error'
+require 'push/daemon/disconnection_error'
+require 'push/daemon/pool'
+require 'push/daemon/connection_pool'
+require 'push/daemon/database_reconnectable'
+require 'push/daemon/delivery_queue'
+require 'push/daemon/delivery_handler'
+require 'push/daemon/delivery_handler_pool'
+require 'push/daemon/feeder'
+require 'push/daemon/logger'
+
+module Push
+ module Daemon
+ class << self
+ attr_accessor :logger, :configuration, :delivery_queue,
+ :connection_pool, :delivery_handler_pool, :foreground, :providers
+ end
+
+ def self.start(environment, foreground)
+ self.providers = []
+ @foreground = foreground
+ setup_signal_hooks
+
+ require File.join(Rails.root, 'config', 'push', environment + '.rb')
+
+ self.logger = Logger.new(:foreground => foreground, :airbrake_notify => configuration[:airbrake_notify])
+
+ self.delivery_queue = DeliveryQueue.new
+
+ daemonize unless foreground
+
+ write_pid_file
+
+ dbconnections = 0
+ self.connection_pool = ConnectionPool.new
+ self.providers.each do |provider|
+ self.connection_pool.populate(provider)
+ dbconnections += provider.totalconnections
+ end
+
+ rescale_poolsize(dbconnections)
+
+ self.delivery_handler_pool = DeliveryHandlerPool.new(connection_pool.size)
+ delivery_handler_pool.populate
+
+ logger.info('[Daemon] Ready')
+
+ Push::Daemon::Feeder.start(foreground)
+ end
+
+ protected
+
+ def self.rescale_poolsize(size)
+ # 1 feeder + providers
+ size = 1 + size
+
+ h = ActiveRecord::Base.connection_config
+ h[:pool] = size
+ ActiveRecord::Base.establish_connection(h)
+ logger.info("[Daemon] Rescaled ActiveRecord ConnectionPool size to #{size}")
+ end
+
+ def self.setup_signal_hooks
+ @shutting_down = false
+
+ ['SIGINT', 'SIGTERM'].each do |signal|
+ Signal.trap(signal) do
+ handle_shutdown_signal
+ end
+ end
+ end
+
+ def self.handle_shutdown_signal
+ exit 1 if @shutting_down
+ @shutting_down = true
+ shutdown
+ end
+
+ def self.shutdown
+ puts "\nShutting down..."
+ Push::Daemon::Feeder.stop
+ Push::Daemon.delivery_handler_pool.drain if Push::Daemon.delivery_handler_pool
+
+ self.providers.each do |provider|
+ provider.stop
+ end
+
+ delete_pid_file
+ end
+
+ def self.daemonize
+ exit if pid = fork
+ Process.setsid
+ exit if pid = fork
+
+ Dir.chdir '/'
+ File.umask 0000
+
+ STDIN.reopen '/dev/null'
+ STDOUT.reopen '/dev/null', 'a'
+ STDERR.reopen STDOUT
+ end
+
+ def self.write_pid_file
+ if !configuration[:pid_file].blank?
+ begin
+ File.open(configuration[:pid_file], 'w') do |f|
+ f.puts $$
+ end
+ rescue SystemCallError => e
+ logger.error("Failed to write PID to '#{configuration[:pid_file]}': #{e.inspect}")
+ end
+ end
+ end
+
+ def self.delete_pid_file
+ pid_file = configuration[:pid_file]
+ File.delete(pid_file) if !pid_file.blank? && File.exists?(pid_file)
+ end
+ end
+end
23 lib/push/daemon/builder.rb
@@ -0,0 +1,23 @@
+module Push
+ module Daemon
+ class Builder
+ def initialize(&block)
+ instance_eval(&block) if block_given?
+ end
+
+ def daemon(options)
+ Push::Daemon.configuration = options
+ end
+
+ def provider(klass, options)
+ begin
+ middleware = Push::Daemon.const_get("#{klass}".camelize)
+ rescue NameError
+ raise LoadError, "Could not find matching push provider for #{klass.inspect}. You may need to install an additional gem (such as push-#{klass})."
+ end
+
+ Push::Daemon.providers << middleware.new(options)
+ end
+ end
+ end
+end
30 lib/push/daemon/connection_pool.rb
@@ -0,0 +1,30 @@
+module Push
+ module Daemon
+ class ConnectionPool
+ def initialize()
+ @connections = Hash.new
+ end
+
+ def populate(provider)
+ @connections[provider.connectiontype.to_s] = Queue.new
+ provider.pushconnections.times do |i|
+ c = provider.connectiontype.new(provider, i+1)
+ c.connect
+ checkin(c)
+ end
+ end
+
+ def checkin(connection)
+ @connections[connection.class.to_s].push(connection)
+ end
+
+ def checkout(notification_type)
+ @connections[notification_type.to_s].pop
+ end
+
+ def size
+ @connections.size
+ end
+ end
+ end
+end
51 lib/push/daemon/database_reconnectable.rb
@@ -0,0 +1,51 @@
+class PGError < StandardError; end if !defined?(PGError)
+module Mysql2; class Error < StandardError; end; end if !defined?(Mysql2)
+
+module Push
+ module Daemon
+ module DatabaseReconnectable
+ ADAPTER_ERRORS = [ActiveRecord::StatementInvalid, PGError, Mysql2::Error]
+
+ def with_database_reconnect_and_retry(name)
+ begin
+ yield
+ rescue *ADAPTER_ERRORS => e
+ Push::Daemon.logger.error(e)
+ database_connection_lost(name)
+ retry
+ end
+ end
+
+ def database_connection_lost(name)
+ Push::Daemon.logger.warn("[#{name}] Lost connection to database, reconnecting...")
+ attempts = 0
+ loop do
+ begin
+ Push::Daemon.logger.warn("[#{name}] Attempt #{attempts += 1}")
+ reconnect_database
+ check_database_is_connected
+ break
+ rescue *ADAPTER_ERRORS => e
+ Push::Daemon.logger.error(e, :airbrake_notify => false)
+ sleep_to_avoid_thrashing
+ end
+ end
+ Push::Daemon.logger.warn("[#{name}] Database reconnected")
+ end
+
+ def reconnect_database
+ ActiveRecord::Base.clear_all_connections!
+ ActiveRecord::Base.establish_connection
+ end
+
+ def check_database_is_connected
+ # Simply asking the adapter for the connection state is not sufficient.
+ Push::Message.count
+ end
+
+ def sleep_to_avoid_thrashing
+ sleep 2
+ end
+ end
+ end
+end
16 lib/push/daemon/delivery_error.rb
@@ -0,0 +1,16 @@
+module Push
+ class DeliveryError < StandardError
+ attr_reader :code, :description
+
+ def initialize(code, message_id, description, source)
+ @code = code
+ @message_id = message_id
+ @description = description
+ @source = source
+ end
+
+ def message
+ "Unable to deliver message #{@message_id}, received #{@source} error #{@code} (#{@description})"
+ end
+ end
+end
48 lib/push/daemon/delivery_handler.rb
@@ -0,0 +1,48 @@
+module Push
+ module Daemon
+ class DeliveryHandler
+ include DatabaseReconnectable
+
+ attr_reader :name
+ STOP = 0x666
+
+ def initialize(i)
+ @name = "DeliveryHandler #{i}"
+ end
+
+ def start
+ Thread.new do
+ loop do
+ break if @stop
+ handle_next_notification
+ end
+ end
+ end
+
+ def stop
+ @stop = true
+ Push::Daemon.delivery_queue.push(STOP)
+ end
+
+ protected
+
+ def handle_next_notification
+ notification = Push::Daemon.delivery_queue.pop
+
+ if notification == STOP
+ return
+ end
+
+ begin
+ connection = Push::Daemon.connection_pool.checkout(notification.use_connection)
+ notification.deliver(connection)
+ rescue StandardError => e
+ Push::Daemon.logger.error(e)
+ ensure
+ Push::Daemon.connection_pool.checkin(connection)
+ Push::Daemon.delivery_queue.notification_processed
+ end
+ end
+ end
+ end
+end
20 lib/push/daemon/delivery_handler_pool.rb
@@ -0,0 +1,20 @@
+module Push
+ module Daemon
+ class DeliveryHandlerPool < Pool
+
+ protected
+
+ def new_object_for_pool(i)
+ DeliveryHandler.new(i)
+ end
+
+ def object_added_to_pool(object)
+ object.start
+ end
+
+ def object_removed_from_pool(object)
+ object.stop
+ end
+ end
+ end
+end
28 lib/push/daemon/delivery_queue.rb
@@ -0,0 +1,28 @@
+module Push
+ module Daemon
+ class DeliveryQueue
+ def initialize
+ @mutex = Mutex.new
+ @num_notifications = 0
+ @queue = Queue.new
+ end
+
+ def push(notification)
+ @mutex.synchronize { @num_notifications += 1 }
+ @queue.push(notification)
+ end
+
+ def pop
+ @queue.pop
+ end
+
+ def notification_processed
+ @mutex.synchronize { @num_notifications -= 1 }
+ end
+
+ def notifications_processed?
+ @mutex.synchronize { @num_notifications == 0 }
+ end
+ end
+ end
+end
14 lib/push/daemon/disconnection_error.rb
@@ -0,0 +1,14 @@
+module Push
+ class DisconnectionError < StandardError
+ attr_reader :code, :description
+
+ def initialize
+ @code = nil
+ @description = "APNs disconnected without returning an error."
+ end
+
+ def message
+ "The APNs disconnected without returning an error. This may indicate you are using an invalid certificate for the host."
+ end
+ end
+end
43 lib/push/daemon/feeder.rb
@@ -0,0 +1,43 @@
+module Push
+ module Daemon
+ class Feeder
+ extend DatabaseReconnectable
+ extend InterruptibleSleep
+
+ def self.name
+ "Feeder"
+ end
+
+ def self.start(foreground)
+ reconnect_database unless foreground
+
+ loop do
+ break if @stop
+ enqueue_notifications
+ interruptible_sleep Push::Daemon.configuration[:poll]
+ end
+ end
+
+ def self.stop
+ @stop = true
+ interrupt_sleep
+ end
+
+ protected
+
+ def self.enqueue_notifications
+ begin
+ with_database_reconnect_and_retry(name) do
+ if Push::Daemon.delivery_queue.notifications_processed?
+ Push::Message.ready_for_delivery.each do |notification|
+ Push::Daemon.delivery_queue.push(notification)
+ end
+ end
+ end
+ rescue StandardError => e
+ Push::Daemon.logger.error(e)
+ end
+ end
+ end
+ end
+end
18 lib/push/daemon/interruptible_sleep.rb
@@ -0,0 +1,18 @@
+module Push
+ module Daemon
+ module InterruptibleSleep
+ def interruptible_sleep(seconds)
+ @_sleep_check, @_sleep_interrupt = IO.pipe
+ IO.select([@_sleep_check], nil, nil, seconds)
+ @_sleep_check.close rescue IOError
+ @_sleep_interrupt.close rescue IOError
+ end
+
+ def interrupt_sleep
+ if @_sleep_interrupt
+ @_sleep_interrupt.close rescue IOError
+ end
+ end
+ end
+ end
+end
53 lib/push/daemon/logger.rb
@@ -0,0 +1,53 @@
+module Push
+ module Daemon
+ class Logger
+ def initialize(options)
+ @options = options
+ log_path = File.join(Rails.root, 'log', 'push.log')
+ @logger = ActiveSupport::BufferedLogger.new(log_path, Rails.logger.level)
+ @logger.auto_flushing = Rails.logger.respond_to?(:auto_flushing) ? Rails.logger.auto_flushing : true
+ end
+
+ def info(msg)
+ log(:info, msg)
+ end
+
+ def error(msg, options = {})
+ airbrake_notify(msg) if notify_via_airbrake?(msg, options)
+ log(:error, msg, 'ERROR')
+ end
+
+ def warn(msg)
+ log(:warn, msg, 'WARNING')
+ end
+
+ private
+
+ def log(where, msg, prefix = nil)
+ if msg.is_a?(Exception)
+ msg = "#{msg.class.name}, #{msg.message}: #{msg.backtrace.join("\n")}"
+ end
+
+ formatted_msg = "[#{Time.now.to_s(:db)}] "
+ formatted_msg << "[#{prefix}] " if prefix
+ formatted_msg << msg
+ puts formatted_msg if @options[:foreground]
+ @logger.send(where, formatted_msg)
+ end
+
+ def airbrake_notify(e)
+ return unless @options[:airbrake_notify] == true
+
+ if defined?(Airbrake)
+ Airbrake.notify_or_ignore(e)
+ elsif defined?(HoptoadNotifier)
+ HoptoadNotifier.notify_or_ignore(e)
+ end
+ end
+
+ def notify_via_airbrake?(msg, options)
+ msg.is_a?(Exception) && options[:airbrake_notify] != false
+ end
+ end
+ end
+end
36 lib/push/daemon/pool.rb
@@ -0,0 +1,36 @@
+module Push
+ module Daemon
+ class Pool
+ def initialize(num_objects)
+ @num_objects = num_objects
+ @queue = Queue.new
+ end
+
+ def populate
+ @num_objects.times do |i|
+ object = new_object_for_pool(i)
+ @queue.push(object)
+ object_added_to_pool(object)
+ end
+ end
+
+ def drain
+ while !@queue.empty?
+ object = @queue.pop
+ object_removed_from_pool(object)
+ end
+ end
+
+ protected
+
+ def new_object_for_pool(i)
+ end
+
+ def object_added_to_pool(object)
+ end
+
+ def object_removed_from_pool(object)
+ end
+ end
+ end
+end
8 lib/push/feedback.rb
@@ -0,0 +1,8 @@
+module Push
+ class Feedback < ActiveRecord::Base
+ self.table_name = 'push_feedback'
+
+ validates :device, :presence => true
+ validates :failed_at, :presence => true
+ end
+end
47 lib/push/message.rb
@@ -0,0 +1,47 @@
+require 'active_record'
+require 'active_record/errors'
+require 'push/daemon/database_reconnectable'
+module Push
+ class Message < ActiveRecord::Base
+ include Push::Daemon::DatabaseReconnectable
+ self.table_name = "push_messages"
+
+ validates :device, :presence => true
+
+ scope :ready_for_delivery, lambda { where('delivered = ? AND failed = ? AND (deliver_after IS NULL OR deliver_after < ?)', false, false, Time.now) }
+
+ def deliver(connection)
+ begin
+ connection.write(self.to_message)
+ check_for_error(connection)
+
+ # this makes no sense in the rails environment, but it does in the daemon
+ with_database_reconnect_and_retry(connection.name) do
+ self.delivered = true
+ self.delivered_at = Time.now
+ self.save!(:validate => false)
+ end
+
+ Push::Daemon.logger.info("Message #{id} delivered to #{device}")
+ rescue Push::DeliveryError, Push::DisconnectionError => error
+ handle_delivery_error(error, connection)
+ raise
+ end
+ end
+
+ private
+
+ def handle_delivery_error(error, connection)
+ # this code makes no sense in the rails environment, but it does in the daemon
+ with_database_reconnect_and_retry(connection.name) do
+ self.delivered = false
+ self.delivered_at = nil
+ self.failed = true
+ self.failed_at = Time.now
+ self.error_code = error.code
+ self.error_description = error.description
+ self.save!(:validate => false)
+ end
+ end
+ end
+end
3 lib/push/version.rb
@@ -0,0 +1,3 @@
+module Push
+ VERSION = "0.0.1.pre"
+end
23 push.gemspec
@@ -0,0 +1,23 @@
+$:.push File.expand_path("../lib", __FILE__)
+
+# Maintain your gem's version:
+require "push/version"
+
+# Describe your gem and declare its dependencies:
+Gem::Specification.new do |s|
+ s.name = "push-core"
+ s.version = Push::VERSION
+ s.authors = ["Tom Pesman"]
+ s.email = ["tom@tnux.net"]
+ s.homepage = "https://github.com/tompesman/push-core"
+ s.summary = "Core of the modular push daemon."
+ s.description = "Push daemon for push notification services like APNS and C2DM."
+
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
+ s.files = `git ls-files lib`.split("\n") + ["README.md", "MIT-LICENSE"]
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
+ s.require_paths = ["lib"]
+
+ s.add_dependency "rails", "~> 3.2.1"
+ s.add_development_dependency "sqlite3"
+end

0 comments on commit 7fb7983

Please sign in to comment.
Something went wrong with that request. Please try again.