Coding for Failure

July 25th, 2008

We all love mash-ups, right? Especially us developers, builders of fine web tools. When we build useful web applications I think we all tend to want to provide integration hooks to other services because our users will get more functionality (in many cases they get more bang for their buck, so to speak) and because it’s kinda cool! Nothing wrong with that, gives you something to get in touch with your users about, sometimes gets you a bit of a press, too.

But mash-ups aren’t all fun and games, they require some careful planning and hard work, even if your current system is well designed with low-coupling and a good MVC model. I saw this post by Hampton over at Unspace and got to thinking that I ought to do a little musing on coding for failure and discuss some of the techniques we’ve used in our services.

When you run a reminder service like PingMe, where your users trust you to deliver their messages without fail and on time, you have to step up your game when it comes to implementing a robust system. When you then integrate your app with an external service like Twitter to provide your users with a useful and cheap SMS/text messaging interface, you have to consider the reliability of that external service and code for failure.

Now, on some level there’s only so much failure you can prevent. Mail systems and domains can go dark, e-mail to sms gateways can blink out, there’s not much you can do about it beyond picking a good MTA and spending a solid amount of time configuring it properly. (We highly recommend Exim, which is the most flexible one out there with great documentation and a strong user/development community.)

The great thing about serious business mail servers like Exim is that they have been very good at handling failure, retrying, and eventually giving up for a very long time, and negotiate this process with other mail servers over a long-established protocols. So if we send a message to your_phone_number@vtext.com (Verizon Wireless’s email-to-sms gateway), and the vtext.com MTA is temporarily unavailable, Exim will try again. And again. And again. And then give up. And our PingMe messaging dispatchers never have to worry about this. The E-mail and SMS handlers simply turn the messages over to Exim on time and wash their hands of the matter.

While most of PingMe’s outbound messages are delivered via e-mail, a large portion go out over Twitter. Without beating a dead horse, and while acknowledging that their reliability has improved quite a bit, Twitter is not like our local MTA, it’s just not as reliable and as a remote HTTP service, not nearly as fast. On the other hand, once in a while our MTA might be down (perhaps I bork the config file and it doesn’t come back from a restart). More importantly, there is no mechanism in place for handling failure. When you send a message to the Twitter API, it either works out or you get a failure. And if you don’t handle that failure, you fail, too!

We handled this by implementing a retry system for our dispatchers. We caused a number of exceptions to bubble up in our test environment, everything from inability to connect to twitter to no network at all, and began catching the exceptions and wrapping them as DeliveryExceptions. If Twitter (or our MTA) is down, the message instance is delayed by a few minutes and marked for retry. We’ll retry numerous times before giving up (there comes a point at which a time-based message loses its relevance…).

Just a little peaking into our messaging code:

rescue DeliveryException => e
@log.error "Caught delivery exception, marking event for retry." 
retry_event(event)
...
def retry_event(event)
      event.status = Event::STATUS_RETRY
      event.retry_count += 1 # up the retry count
      event.retry_at = event.dt_when + (5.minutes * event.retry_count)
...
  def lock_a_block(type_name)
    before = (Time.now.utc).to_s(:db)

    ActiveRecord::Base.connection.execute(
    <<-END_OF_SQL
      UPDATE events SET dispatcher = '#{@name}'
      WHERE id IN (
        SELECT e.id FROM 
          (( events e INNER JOIN targets t ON e.target_id = t.id )
          INNER JOIN pings p ON e.ping_id = p.id)
          INNER JOIN target_types tt ON t.target_type_id = tt.id
        WHERE 
          tt.const = '#{type_name}'
          AND 
          (
            (e.dt_when < '#{before}' AND e.status = '#{Event::STATUS_PENDING}')
            OR
            (e.retry_at < '#{before}' AND e.status = '#{Event::STATUS_RETRY}')
          )
          ...

The code actually gets quite a bit more complicated than that, and I don’t really want to go fully dissecting the polymorphic message handlers we’ve written, but it shows you how handling failure isn’t really an outlier problem, it becomes core to your system. It’s just as important as returning those nice model validation errors that Rails makes so convenient for you.

Another technique we use in PingMe is pipeline prevention. Well, that’s what I call it. But basically you can’t have one Twitter-bound ping holding up every other outbound ping at 5pm EST! We spent a lot of time implementing a system that allows for many concurrent dispatcher daemons, and all Twitter-bound pings go through only two of them, preventing the others from being affected by the high latency when connecting to Twitter. We ended up using the mutex pattern with Postgres:

  def acquire_mutex
    ActiveRecord::Base.connection.execute(
    <<-END_OF_SQL
      LOCK mutex IN ACCESS EXCLUSIVE MODE;
    END_OF_SQL
    )
  end

In our time-tracking app Tempo, we allow users to send time entries and start timers by sending messages to our Twitter account (twitter.com/keeptempo), and we have a daemon checking the API for new direct messages every couple of minutes.

Two things have to happen for that to work over direct messaging – both accounts have to be “following” each other. So the user follows us on Twitter, then enters their Twitter ID on their Tempo profile. Tempo does a quick check to make sure you’re following ‘keeptempo’, and then attempts to follow you. Either of those connections to the Twitter API can and often do fail.

So what do we do? We put together a rake task that generates a list of twitter ids on our user’s profiles that we aren’t following, and sends a follow request for each of them. We run it as a periodically and it catches quite a few. Not perfect, but just about the best we can do. It’s better than letting users walk away thinking that it doesn’t work at all! In that case you just look bad, and it’s not even your fault!

But it is your fault, actually, because you have to code for failure, or you look pretty bad when the exceptions bubble up to the surface, literally. Or, worse, you present the user with inaccurate information based on an exception state you didn’t plan for, which can really put you in a bad light.

I stay positive, but I code for failure ;-)

Introducing acts_as_union

June 12th, 2008

While doing a bit of work on Tempo recently, we found we had the need to provide a union’ed set of Active Record associations on a model. We already had the code in place in acts_as_network to do something very similar via the UnionCollection class, so it was a simple step to create ActsAsUnion within the same plugin.

Here’s an example of what we mean and how it works:

class Person < ActiveRecord::Base
  acts_as_network :friends
  acts_as_network :colleagues, :through => :invites, :foreign_key => 'person_id',
    :conditions => ["is_accepted = 't'"]
  acts_as_union :aquantainces, [:friends, :colleagues]
end

In this case a call to the aquantainces method will return a UnionCollection on both a person’s friends and their colleagues. Likewise, finder operations will work accross the two distinct sets as if they were one. Thus, for the following code:

stephen = Person.find_by_name('Stephen')
# search for user by login
billy = stephen.aquantainces.find_by_name('Billy')

both Stephen’s friends and colleagues collections would be searched for someone named Billy.

To obtain acts_as_union, simple install the newest version of the acts_as_network plugin and use as shown above. The acts_as_union method does not accept any options.

The useful acts_as_network plugin for cleanly representing network relationships in rails has officially been moved over to GitHub. GitHub will be the official repository from now on so we won’t be updating the SVN repo on rubyforge any more. Check it out!

http://github.com/sjlombardo/acts_as_network

Update 06-JUN-2008: This plugin now includes acts_as_union, and we moved the repository to GitHub.

A better-late-than-never announcement: we released a Rails plugin a while ago that implements a better, DRYer way to roll network relationships using ActiveRecord. It's called, acts_as_network and it now updated to support Rails 2.0.

So why is this such a problem? It may not be immediately apparent, but the short answer is that these types of relationships usually require 2 redundant rows of storage in your database. Take a social network relationship: one record might say that Jack is Jill's friend, but a separate row must be present to say Jill is Jack's friend.

acts_as_network does away with this nonsense, and lets you say implicitly that If Jack is Jill's friend then Jill is Jack's friend. Or, in Ruby

# Jane invites Jack to be friends
invite = Invite.create(:person => jane, :person_target => jack, :message => "let's be friends!")    

jane.friends.include?(jack)    =>  false   # Jack is not yet Jane's friend
jack.friends.include?(jane)    =>  false   # Jane is not yet Jack's friend either

invite.is_accepted = true  # Now Jack accepts the invite
invite.save and jane.reload and jack.reload

jane.friends.include?(jack)    =>  true   # Jack is Janes friend now
jack.friends.include?(jane)    =>  true   # Jane is also Jacks friend

The syntax is clean, and it stores only one row in your HABTM table. Online Documentation available or install/upgrade the plugin:

% script/plugin install git://github.com/sjlombardo/acts_as_network.git
% rake doc:plugins

Much thanks to Maurycy for submitting patches to AAN!

Note: for a more in depth look at the acts_as_network syntax and usage please check out the original release page.

Respect Is Due

March 20th, 2008

Throughout the course of building Tempo, we've relied heavily on software written by other people and made freely available. It's worth doing a quick run-down to give credit where credit is due.

Ruby On Rails web application framework

No surprise there, right?

PostgreSQL relational database

Our favorite relational database system, Postgres is the most mature of the free systems out there, has the best feature set, and has quite a bit in common with Oracle. Highly recommended.

FamFamFam icons

Everybody needs icons, we're big fans of the Silk set.

Acts As State Machine Rails plugin

This plugin by Scott Barron allows an ActiveRecord model to act as a finite state machine rather elegantly.

HAML & SASS HTML & CSS templating

Gone are the days when we painfully labor over HTML templates thanks to this great Rails plugin by Hampton Catlin. We can't live without it now.

gchartrb Google Charts for Ruby

Those charts in Tempo look really good, but they're largely the work of Google's Chart API and this wrapper library for Ruby written by deepak.jois and aseemtandon. All we had to do was write some clever SQL and voila!

Active Merchant Rails plugin

Definitely the easiest way to integrate with a payment gateway in Rails. Also provides an awesome layer of abstraction in the event that we decide to switch gateways - we won't have to do a major rewrite of the code in our site that handles payment processing.

Ruport Ruby Reports

Ruport made it incredibly easy for us to provide Excel/CSV and PDF exports from within Tempo's WYSIWYG reporting interface.

RESTful Authentication Rails plugin

Very handy plugin by Rick Olson for quickly setting up an authentication system for your users that includes an activation step.

Thanks everyone for making these valuable open source contributions to make software like Tempo possible.

Tempo: Launch!

March 18th, 2008

Today marks the launch of our newest app, Tempo, a time tracking app for stats addicts, consultants, and anybody involved in professional services and billing time. The "beta" tag is gone, the gloves are off, the doors are open. Check out the tour to find out what it's all about, sign up for a free account, let us know what you think (e-mail us: support AT zetetic DOT net). As our regulars know, we love hearing your suggestions.

This took enough of my time that I think it's worth a blog post. In Tempo you'll see a familiar paradigm in the time reporting interface (the main screen): a list of editable items in a row, each with the same set of controls. They are editable via AJAX calls, so you can open a number of them for editing at once.

Now, when you're looking to add javascript observers to these elements (to do automated things like type ahead, etc), you have to assign them unique id attributes, usually based on the object id. While it's easy to add an :id attribute to any of the usual tag helpers in Rails, it doesn't work like this with date_select:

%table
  %tr.s1
    %td{:colspan => '2'}= project_select(f, @current_user.projects, entry)
    %td{}
      = f.date_select :occurred_on, :order => [:month, :day, :year], :start_year => 2007, :use_short_month => true, :use_short_year => true, :id => "#{entry.id}"
      = popup_calendar("entry_#{entry.id}_occurred_on", entry.occurred_on)

Our javascript calendar is expecting a unique ID on the date select drop downs so that it can set their values. But, that's not the case, the id of each drop down is generated automatically from the name attribute, thus:

<select id="entry_occurred_on_2i" name="entry[occurred_on(2i)]">
<option value="1">Jan</option>
<option value="2">Feb</option>
<option value="3" selected="selected">Mar</option>
...
</select>

Makes sense, really, since the separate drop downs are being generated to be re-assembled when posted, and what else to id them?

The trick to getting unique id's into these elements was a monkey patch I put in config/initializers/date_helper.rb:

module ActionView
  module Helpers
    module DateHelper
      def name_and_id_from_options(options, type)
        options[:name] = (options[:prefix] || DEFAULT_PREFIX) + (options[:discard_type] ? '' : "[#{type}]")
        name = options[:name].gsub(/([\[\(])|(\]\[)/, '_').gsub(/[\]\)]/, '')
        unless options[:id].nil?
          options[:id] = name.sub(/_/, "_#{options[:id]}_")
        else
          options[:id] = name
        end
      end
    end
  end
end

Pretty close to the original, it preserves the original behavior, but respects your inclusion of the :id attribute in the options you pass to date_select. Now our id's look like:

<select id="entry_2013_occurred_on_3i" name="entry[occurred_on(3i)]">

We found a similar problem with the auto_complete plugin - doesn't work when there are more than one active on the screen at once, due to non-unique id's. That required a bit more work. First a monkey-patch in config/initializers/auto_complete_macros_helper.rb:

module AutoCompleteMacrosHelper
  def text_field_with_auto_complete(object, method, tag_options = {}, completion_options = {}, object_id = nil)
    if object_id.nil?
      field_id = "#{object}_#{method}"
    else
      field_id = "#{object}_#{object_id}_#{method}"
      tag_options[:id] = field_id
    end
      
    (completion_options[:skip_style] ? "" : auto_complete_stylesheet) +
    text_field(object, method, tag_options) +
    content_tag("div", "", :id => "#{field_id}_auto_complete", :class => "auto_complete") +
    auto_complete_field(field_id, { :url => { :action => "auto_complete_for_#{object}_#{method}" } }.update(completion_options))
  end
end

Adding an optional parameter on there seemed like the easiest thing to do for the moment, creates for only a slight change in our form:

= text_field_with_auto_complete :entry, :tag_s, {:size => 40}, { :indicator => "entry_#{entry.id}_tag_s_form_loader", :frequency => 0.4, :tokens => ' ' }, entry.id

I'm tempted to make it work off of whatever shows up in tag_options[:id] but this will do for now.

Over at Ryan's Scraps, in a post about the new TimeWithZone functionality in edge Rails, there are a pair of comments that I want to highlight. A fella named Ben asks "Couldn’t this be pushed deeper so that current_user.registered_at is a TimeWithZone?"

Then there's a response from the main guy who developed the TimeWithZone functionality, Geoff Buesig, in regards to how they intend it to be used (and with a bunch of other neat and helpful notes that you should check out):

1.TimeWithZone is similar to the Duration class, in that, you should never need to create an instance directly—in the TWZ case, you’ve got the #in_time_zone, #in_current_time_zone, #change_time_zone and #change_time_zone_to_current methods on Time and DateTime instances that will handle that for you.

So, for example, you can do this:

current_user.registered_at.in_current_time_zone

... and the result will automatically be wrapped in a TimeWithZone

What Ben is asking for, and what Geoff seems to be distancing himself from, is exactly what we here at Zetetic would find incredibly useful: the ability to harness our database backend's time zone support, PostgreSQL's 'timestamp with time zone'.

Here's the deal. PingMe was designed for users around the globe so it supports time zones. We set it up so that all timestamps (:datetime) were stored in UTC in the database, and converted to the user's local time on display. We also convert from the user's local time on datetime input. Nothing fancy or unexpected there, really. And hey, the tzinfo gem supports DST, so we're good, right?

Well, PingMe is a scheduling system. It has a scheduler daemon that's constantly checking to see which pings need to be sent out, then it creates outbound events for the dispatcher daemons to deliver. Never mind the terminology, the important thing here is that it's working in UTC. And that Rails is storing the timestamps in Postgres' default TIMESTAMP WITHOUT TIME ZONE data type. Here's an illustrative query:

  def lock_a_block(type_name)
    before = (Time.now.utc).to_s(:db)
    
    ActiveRecord::Base.connection.execute(
    <<-END_OF_SQL
      UPDATE events SET dispatcher = '#{@name}'
      WHERE id IN (
        SELECT e.id FROM 
          (( events e INNER JOIN targets t ON e.target_id = t.id )
          INNER JOIN pings p ON e.ping_id = p.id)
          INNER JOIN target_types tt ON t.target_type_id = tt.id
        WHERE 
          tt.const = '#{type_name}'
          AND 
          (
            (e.dt_when < '#{before}' AND e.status = '#{Event::STATUS_PENDING}')
            OR
            (e.retry_at < '#{before}' AND e.status = '#{Event::STATUS_RETRY}')
          )
          AND e.dispatcher IS NULL
          AND t.activated_at IS NOT NULL
          AND (p.is_done = 'f' OR p.is_done IS NULL)
          AND (p.deleted_at IS NULL)
        ORDER BY
          e.dt_when ASC
        LIMIT #{@block_size}
        );
    END_OF_SQL
    )
  end

So the app is providing a UTC timestamp for the before variable, and the timestamps are in UTC in the database. What happens when DST begins or ends? Nothing changes. Everything is sent at the set time, for UTC. So a ping set for 5pm EST was stored at 12:00 UTC, and when 5pm shifts an hour for EDT, that ping is still stored at 12:00 UTC and will be sent either an hour early or an hour late, depending on the circumstance.

The only way we could break this up to work off the time zone setting on the user model is to execute separate queries for all of our users all the time joining against their timezone. Ridiculous! And following Geoff's notion of things above, it's just not a clean solution -- storing the ping's time without the time zone is decidedly *inaccurate*. I hate to say it.

I think the best solution is not to store in UTC here, but to store as a timestamp with time zone. I realize that sounds like an impure solution, but it's not: PostgreSQL actually stores the data in UTC and can do all sorts of magical conversions for us. We could still use the code above and work in proper UTC, but any DST on the timezone would be respected:

WHERE ... e.dt_when AT TIME ZONE 'UTC' < '#{before}'

And that is why I hope Geoff changes his mind, because we do need TimeWithZone as a data type in Rails, or perhaps a col definition that will provide a TimeWithZone instead of Time objects:

col.datetime :col_name, :with_time_zone => true

As an aside, we don't leave PingMe users to hang when DST rolls around, we update the relevant time stamps via SQL. But I would like to get us to a better solution. Being able to store TimeWithZone would do just the thing.

Using PingMe With Twitter

January 7th, 2008

We're big Twitter fans, and for quite some time we've wanted to allow PingMe users to interact with our service through Twitter. A number of folks have asked for it and, selfishly, we also wanted this capability for ourselves. Now, if you're a twittaholic, you can access all mobile PingMe functionality straight through the service you know and love.

On a side note, this feature also introduces an alternate way to use SMS messages with PingMe. Previously, in order to send and receive SMS messages with our users (in a cost-efficient way) PingMe would send reminders through a provider's sms-to-email gateways. These gateway's are provided by most (but unfortunately not all) cell carriers, and some people pay an extra fee for the capability. Now that we've added support Twitter, you can use their service as a universal transport for SMS or even Instant Messaging.

In this post I'll step you through the process of using PingMe with Twitter. Various details about how messages to create and update pings in this way are covered in older articles and our help section, so I'm going to stick to just the bits pertinent to Twitter.

To get started I'll assume you already have a Twitter account, and are logged in to their web site. To be able to send get messages from PingMe on twitter, you have to "follow" the PingMe twitter account, 'gpm', like so:

Now that you've got your twitter account set up, log in to PingMe and click that "Add target" link under the Targets listing on the right side of the page. There's not much to do but select 'Twitter' from the type drop-down and then enter your username on twitter:

Note that if you skipped the first step, where you follow gpm on Twitter, you'll get an error in that last step.

Now that you've got a Twitter target for your account, you can have your pings sent there just like any other target:

So let's try creating a ping from Twitter. We'll use the web interface for our example, but keep in mind that you can do this in all the ways you interact with Twitter - including from your phone or IM. What we are doing is sending a direct message to gpm ('d gpm ...') that contains a create-ping instruction.

The syntax is very similar to the format we use for creating remote pings from e-mail and SMS. The '5h' tells ping me "five hours", the "p:10" tells us to pester you every ten minutes until you respond with 'done', and the 't:t' tells us that you want this ping sent to your Twitter targets (click here for info on setting default targets). The only new trick here is the addition of the '+' sign. Since Twitter is conversational, we have to have a way of distinguishing your create messages from your updates, so after the direct message bit, you begin your ping creation with a plus sign.

Updates, as you might have expected, are simpler. The only caveat is that we don't necessarily know which ping you are trying to update, so we assume it is the most recent one sent to you. You can send an update like this:

That message will update the most recent ping sent to you to be sent again in 30 minutes.

Making Rails Logs More Useful

November 8th, 2007

Here at Zetetic we do a lot of logging, and a lot of looking at logs. In particular, we have a couple of daemon processes implemented in Rails for PingMe that handle our message queueing and parsing of incoming messages (when you reply to your pings or create new ones by remote). If you have any experience with message queueing systems, you'll recall that these are not easy things to maintain, and require access to really good diagnostics. And if you are familiar with Rails you'll recall that there are no time-stamps prepended to the log messages, making it very difficult sometimes to track down what happened *when*.

I did a quick bit of poking around and came across this fantastic article with a number of tips in terms of logging. Their solution for the issue of formatting the messages (so that you can have timestamps) is to subclass Logger, and instantiate that.

However, we have our own Loggers all over the place, in our daemons, they use the Logger class which has been patched by Rails to have that timestamp-less format. What we do from there is replace the Rails logger instance with our own (there are a few reasons for this, having to do with forking processes, resources, and the nature of daemons that I don't want to get into), which works beautifully:

logger = Logger.new("#{config[:log_dir]}/#{config[:name]}.log", 'daily')
unless config[:log_level].blank?
  begin
    logger.level = Logger.const_get(config[:log_level])
  rescue StandardError => e
    logger.level = Logger::INFO
    logger.error "An exception occurred while setting log level to #{config[:log_level]}, setting to INFO.  Exception: #{e.message}"
  end
else
  logger.level = Logger::INFO
end

logger.info "Initialized log @ #{Time.now.utc} with log_level #{logger.level.to_s}"
logger.info "Starting up Dispatcher #{config[:name]}..."

# over-ride the active record logger (which would be closed now)
ActiveRecord::Base.logger = logger
ActionMailer::Base.logger = logger

I really don't feel like subclassing Logger, I just want to adjust the default behavior, since we're using the same loggers everywhere. So I opened up config/environment.rb, and at the bottom of it, added this:

# re-patch logger to restore format patched out by Rails
class Logger
  def format_message(severity, timestamp, program, message)
    "#{timestamp.to_formatted_s(:db)} #{program}: [#{severity}] #{message}\n"
  end
end
Works fantastic! Thanks to Maintainable Software for their post.

Update 25-APR-2008: This plugin has been updated for Rails 2.0.
Update 06-JUN-2008: This plugin now includes acts_as_union, and we moved the repository to GitHub.

When we started integrating simple social networking features into PingMe we wanted to easily represent a bi-directional relationship between users in the system. When a user signs up for PingMe they can invite another user to join them. Once an invite is accepted, the users become mutual friends, or contacts in PingMe parlance, and can send Pings to each other.

Most importantly, we wanted the relationship to be bidirectional - when Jack is a friend of Jane then Jane should also be a friend of Jack.

Unfortunately we quickly realized that this model was not going to be so easy. The usual way of representing this type of network relationship using ActiveRecord is with an intermediate HABTM join, or with a self-referential has_many :through association. For example one might define a simple person model and then a join table to store the friendship relation:

  create_table :people, :force => true do |t|
    t.column :name, :string
  end

  create_table :friends, {:id => false} do |t|
    t.column :person_id, :integer, :null => false
    t.column :person_id_friend, :integer, :null => false      # target of the relationship
  end

The problem is that this model requires two rows in the intermediate table to make a relationship bi-directional.

  jane = Person.create(:name => 'Jane')
  jack = Person.create(:name => 'Jack')
  
  jane.friends << jack              
  jane.friends.include?(jack)    =>  true   # Jack is Janes friend
  jack.friends.include?(jane)    =>  false  # Jane is NOT Jack's friend

In short, you must explicitly define the reverse relation in order for this to work.

  jack.friends << jane
  jack.friends.include?(jane)    =>  true  # now they're buds

This can be implemented in a fairly DRY way using association callbacks as documented in Rails Recipes, but things start to get ugly when you want to express the relationship through a "proper" join model (like for an Invite) using has_many :through.

  create_table :invites do |t|
    t.column :person_id, :integer, :null => false           # source of the relationship
    t.column :person_id_friend, :integer, :null => false    # target of the relationship
    t.column :code, :string                                 # random invitation code
    t.column :message, :text                                # invitation message
    t.column :is_accepted, :boolean
    t.column :accepted_at, :timestamp                       # when did they accept?
  end
In this case creating a reverse relationship is much more complex and could require the duplication of multiple values, making the data model decidedly non-DRY.

Enter acts_as_network

acts_as_network is a plugin that we developed for PingMe to resolve some of these issues. It drives the social networking features of the site. It's intended to simplify the definition and storage of reciprocal relationships between entities using ActiveRecord by exposing a "network" of 2-way connections.

What makes it special is that it does this in a DRY way using only a single record in an intermediate has_and_belongs_to_many join table or has_many :through join model. There is no redundancy, and you need only one instance of an association or join model to represent both directions of the relationship. Consider this more desirable implementation:

  class Invite < ActiveRecord::Base
    belongs_to :person                    # the source of the invite
    belongs_to :person_target,            # the target of the invite
        :class_name => 'Person', 
        :foreign_key => 'person_id_target'       
  end

  class Person < ActiveRecord::Base
    acts_as_network :friends, :through => :invites, :conditions => ["is_accepted = ?", true]
  end

In this case acts_as_network implicitly defines five new properties on the Person model

  person.invites_out        # has_many invites originating from me to others
  person.invites_in         # has_many invites originating from others to me
  person.friends_out        # has_many friends :through outbound accepted invites from me to others
  person.friends_in         # has_many friends :through inbound accepted invites from others to me
  person.friends            # the union of the two friend sets - all people who I have
                            # invited all the people who have invited me and 

Now...

  # Jane invites Jack to be friends
  invite = Invite.create(:person => jane, :person_target => jack, :message => "let's be friends!")    
  
  jane.friends.include?(jack)    =>  false   # Jack is not yet Jane's friend
  jack.friends.include?(jane)    =>  false   # Jane is not yet Jack's friend either

  invite.is_accepted = true  # Now Jack accepts the invite
  invite.save and jane.reload and jack.reload

  jane.friends.include?(jack)    =>  true   # Jack is Janes friend now
  jack.friends.include?(jane)    =>  true   # Jane is also Jacks friend

So much cleaner!

Most of this magic is actually accomplished with a UnionCollection class that provides useful application-space functionality for emulating set unions across ActiveRecord collections. Once initialized, the UnionCollection itself will act as an array containing all of the records from each of its member sets, but its more interesting feature is that it will intelligently forward ActiveRecord method calls like find, find_all_by_*, etc. to its member sets.

Check it out

Further documentation is available online, and you can easily install acts_as_network as a plugin to try it out:

% script/plugin install git://github.com/sjlombardo/acts_as_network.git
% rake doc:plugins

Please check it out and let us know what you think.