= 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 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.
== 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.