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.
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.
# * :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.
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 save 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.
GraphMediator uses thread local variables to keep track of open mediators. It should be thread safe but this needs testing.
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 © 2010 Josh Partlow. See LICENSE for details.