ActiveRecord Identity Map for Rails Transactions
Listen to this article
I happened to be reading a blog entry last night that mentioned some "short comings" in Rails' ActiveRecord and its handling of record loading. Specifically, AR will load the same record twice, into two different instances, within the same transaction. Ie. the following test fails:
Customer.transaction do
c = Customer.find_by_name('RedHill Consulting, Pty. Ltd.')
assert_same c, Customer.find(c.id)
end
To be honest, I've not yet been burned by this but it may just catch-out some so I quickly whipped up a very basic plugin to see how difficult it would be solve:
module RedHillConsulting
module IdentityMap
class Cache
def initialize
@objects = {}
end
def put(object)
objects = @objects[object.class] ||= {}
objects[object.id] ||= object
end
end
module Base
def self.included(base)
base.extend(ClassMethods)
base.class_eval do
alias_method_chain :create, :identity_map
end
end
module ClassMethods
def self.extended(base)
class << base
[:instantiate, :increment_open_transactions, :decrement_open_transactions].each do |method|
alias_method_chain method, :identity_map
end
end
end
def instantiate_with_identity_map(record)
enlist_in_transaction(instantiate_without_identity_map(record))
end
def enlist_in_transaction(object)
identity_map = Thread.current['identity_map']
return object unless identity_map
identity_map.put(object)
end
private
def increment_open_transactions_with_identity_map
increment_open_transactions_without_identity_map
Thread.current['identity_map'] ||= Cache.new
end
def decrement_open_transactions_with_identity_map
Thread.current['identity_map'] = nil if decrement_open_transactions_without_identity_map < 1
end
end
def create_with_identity_map()
create_without_identity_map
self.class.enlist_in_transaction(self)
id
end
end
end
end
The code essentially interferes with create and instantiate (called from find) and ensures that, within a transactions, the same record will always be returned for the same id (IdentityMap).
As I mentioned, unlike all my other plugins, I've never used nor needed to use this one—and I'm not sure I will unless it proves to be a problem for me—but it's yet another example of how easy it is to extend Rails to do pretty much whatever you might imagine.
Comments
It seems like bit by bit you're inventing Hibernate.
Posted by: ocean | September 19, 2006 02:00 PM
Hmm...I'm not sure about that. Perhaps in this case, but Hibernate doesn't do any of the things for which I've created plugins. This would in fact be the first and, as I stated, I don't even need the functionality.
In no way do I wish to re-create hibernate it is precicesly the things which hibernate/spring/struts/etc. don't give--or what I don't have to do--that keeps me using Rails are.
If one ends up re-inventing say hibernate, then perhaps it's time to revist why Rails was chosen as the target platform in the first place.
Posted by: Simon Harris
|
September 19, 2006 02:10 PM
I *love* ActiveRecord. It's so quick to get up and running. I wish Og was more supported though so I could use that as it's quite a bit cleaner. The internals of AR are pretty scary.
However Hibernate is also *fantastic* (Gavin King's Hibernate book is the best book on general ORM I've ever seen). It's the one thing I do actually like about Java. This is mainly due to its proper ANTLR-based HQL query parser (compare to AR's "parser") so you can do complex things like nested sub-selects with confidence.
Hibernate has more flexible cascading (compared to ActiveRecord simple dependency model), returnMaps(), interceptors, first level session cache, proxied classes and attributes for lazy loading, pluggable cache providers, very cool dynamic session filters and much more. Yes this is stuff I make heavy use of. Some of it I have hacked into ActiveRecord.
The jewel in the crown for me for Hibernate though is HQL (and the criteria API). In the real world, queries just get much more complicated than you see in ORM HOWTOs.
ActiveRecord is great for per table queries but it leaks SQL as soon as you need to do something vaguely real-world and starts to mess up my nice Ruby code ;(
It's a breeze doing *arbitrary joins* in Hibernate across my object model without using actual SQL (although using SQL is trivial to do using createSQLQuery() if you want to) with multiple complex conditions.
The crucial thing here is that we stay in Object space - not SQL space. At first I was sceptical of the value of HQL but now I realize what a difference it makes to my code readability. An object based query api just works so much better with, er, objects! e.g "select b.manager from bar b where bar.manager.address.city = 'LONDON'" does 3 joins for me automatically in the easiest way possible.
A more complex bit of HQL:
select order.id, sum(price.amount), count(item)
from Order as order
join order.lineItems as item
join item.product as product,
Catalog as catalog
join catalog.prices as price
where order.paid = false
and order.customer = :customer
and price.product = product
and catalog.effectiveDate = all (
select cat.effectiveDate
from Catalog as cat
where cat.effectiveDate :minAmount
order by sum(price.amount) desc
Love ActiveRecord but don't diss Hibernate!! Hibernate has had much more weight behind it and I have no doubt that AR will catch up given time, and surpass it using the power of Ruby. But it most definitely aint there yet.
Posted by: Bex | September 28, 2006 03:29 AM
You'd be mistaking me with someone who was dissing hibernate; I was _actually_ dissing the idea that AR and Hibernate were comparable purely by feature because in many ways, they have competing concerns. I've used hibernate for years and will continue to do so but I also find Rails+AR to be incredibly good on many projects too and far simpler for some things, more complex for other.
Posted by: Simon Harris
|
September 28, 2006 09:17 AM
Stupid question. I think you have a bug in your code. With this:
def decrement_open_transactions_with_identity_map
Thread.current['identity_map'] = nil if decrement_open_transactions_without_identity_map < 1
end
You are changing the return value semantics of decrement_open_transactions. It normally returns Thread.current['open_transactions'], with your module it will always return 'nil'.
Sorry for that, but in a statically typed langauge the code would not compile with bug like this. ;)
Posted by: Michal Plechawski | October 25, 2006 07:00 PM
Nicely spotted but if I'd actually tested it rather than spike it, I think that problem would have been found anyways. I don't buy the argument that says statically typed languages make up for a lack of testing.
Posted by: Simon Harris
|
October 25, 2006 10:45 PM
One more thing worth noting. You should be very careful when using classes as hash keys. The #hash method of a class might not return the same value across different invocations of the Ruby interpreter. It seems to depend on the order in which classes are loaded.
This is especially dangerous in applications such as Rails when classes are being reloaded in development mode. As a result you can't store an identity map in the session if you were so inclined. Generally speaking, it's not possible to marshal hashes using classes as keys.
I consider this a weakness of Ruby. A symbol will always have the same hash while a class won't. This doesn't really make sense.
Posted by: Rhonda Starke | December 6, 2006 03:14 AM
I could never understand why one would want to learn Yet Another Query Language (HQL).
I tried using Castle and NHiberante in the .Net world for a while (dayjob at the time). After using ActiveRecord and Rails, I kept thinking when trying to use NHibernate/etc: is this a joke? people actually use this by choice when AR/rails is available?!?
Posted by: Shanti Braford | December 13, 2006 10:55 AM