= Graph Mediator GraphMediator is used to help coordinate state between a graph of ActiveRecord objects related to a single root node. Its role is assisting in cases where you are representing a complex concept as a graph of related objects with potentially circular interdependencies. Changing attributes in one object might require adding or removing of dependent objects. Adding these objects might necessitate a recalculation of memberships in a join table. Any such changes might require that cached calculations be redone. Touching any object in the graph might require a version bump for the concept of the graph as a whole. We want changes to be made once, in a single transaction, with a single overall version change. The version change should be guarded by an optimistic lock check to avoid conflicts between two processes updates to the same graph. To make interdependent state changes manageable, GraphMediator wraps an additional layer of callbacks around the ActiveRecord save cycle to ensure that a save occurs within a GraphMediator.mediated_transaction. * :before_mediation * * save * * :after_mediation The after_mediation callback is itself broken down into three phases: * :reconciliation - in this phase, any methods which bring the overall state of the graph into balance should be run to adjust for changes made during the save. * :cacheing - any calculations which rely on the state of a reconciled graph but which do not themselves alter the graph (in that they are reproducible from existing state) should be made in the cacheing phase. * :bumping - if the class has a +lock_column+ set (ActiveRecord::Locking::Optimistic) and has on +updated_at/on+ timestamp then the instance will be touched, bumping the +lock_column+ and checking for stale data. During a mediated_transaction, the +lock_column+ will only update during the +bumping+ phase of the after_mediation callback. But if there is no +update_at/on+ timestamp, then +lock_column+ cannot be incremented when dependent objects are updated. This is because there is nothing to touch on the root record to trigger the +lock_column+ update. GraphMediator ensures that after_mediation is run only once within the context of a mediated transaction. If the block being mediated returns false, the after_mediation is skipped; this allows for validations. == Usage # * :pen_number # * :dingo_count # * :biscuit_count # * :feed_rate # * :total_biscuit_weight # * :lock_version, :default => 0 # required for versioning # * :updated_at # required for versioning class DingoPen < ActiveRecord::Base has_many :dingos has_many :biscuits include GraphMediator mediate :purchase_biscuits, :dependencies => [Dingo, Biscuit], :when_reconciling => [:adjust_biscuit_supply, :feed_dingos], :when_cacheing => :calculate_total_biscuit_weight or mediate :purchase_biscuits, :dependencies => [Dingo, Biscuit], # ensures a mediated_transaction on Dingo#save or Biscuit#save mediate_reconciles :adjust_biscuit_supply, :feed_dingos mediate_caches do |instance| instance.calculate_total_biscuit_weight end ... def purchase_biscuits; ... end def adjust_biscuit_supply; ... end def feed_dingos; ... end def calculate_total_biscuit_weight; ... end end See spec/examples for real, dingo-free examples. == Caveats A lock_column and timestamp are not required, but without both columns in your schema there will be no versioning. +A lock_column by itself *without* a timestamp will not increment and will not provide any optimistic locking in a class including GraphMediator!+ Using a lock_column along with a counter_cache in a dependent child will raise a StaleObject error during a mediated_transaction if you touch the dependent. The cache_counters do not play well with optimistic locking because they are updated with a direct SQL call to the database, so ActiveRecord instance remain unaware of the lock_version change and assume it came from another transaction. You should not need to declare lock_version for any children that are declared as a dependency of the root node, since updates will also update the root nodes lock_version. So if another transaction updates a child, root.lock_version should increment, and the first transaction should raise a StaleObject error when it too tries to update the child. If you override super in the model hierarchy you are mediating, you must pass your override as a block to super or it will occur outside of mediation: def save super do my_local_changes end end You are probably better off hooking to before_save or after_save if they suffice. == Threads GraphMediator uses thread local variables to keep track of open mediators. It should be thread safe but this needs testing. == Advice Build a simple system first, rather than building a system to use GraphMediator. But if you have a web of observers/callbacks struggling to maintain state, repeated, redundant update calls from observed changes in collection members, or are running into +lock_column+ issues within your own updates, then GraphMediator may help. == Copyright Copyright (c) 2010 Josh Partlow. See LICENSE for details.