ferblape / query_memcached

A replacement for ActiveRecord query_cache that a adds a Memcache layer for persistence of the query's cache

This URL has Read+Write access

query_memcached / lib / query_memcached.rb
100644 191 lines (157 sloc) 7.588 kb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
require 'digest/md5'
 
module ActiveRecord
  
  class Base
 
    # table_names is a special attribute that contains a regular expression with all the tables of the application
    # Its main purpose is to detect all the tables that a query affects.
    # It is build in a special way:
    # - first we get all the tables
    # - then we sort them from major to minor lenght, in order to detect tables which name is a composition of two
    # names, i.e, posts, comments and comments_posts. It is for make easier the regular expression
    # - and finally, the regular expression is built
    cattr_accessor :table_names, :enableMemcacheQueryForModels
    
    self.table_names = /#{connection.tables.sort_by { |c| c.length }.join('|')}/i
    self.enableMemcacheQueryForModels ||= {}
 
    class << self
            
      # put this class method at the top of your AR model to enable memcache for the queryCache,
      # otherwise it will use standard query cache
      def enable_memcache_querycache(options = {})
        if ActionController::Base.perform_caching && defined?(::Rails.cache) && ::Rails.cache.is_a?(ActiveSupport::Cache::MemCacheStore)
          options[:expires_in] ||= 90.minutes
          self.enableMemcacheQueryForModels[ActiveRecord::Base.send(:class_name_of_active_record_descendant, self).to_s] = options
        else
          warning = "[Query memcached WARNING] Disabled for #{ActiveRecord::Base.send(:class_name_of_active_record_descendant, self)} -- Memcache for QueryCache is not enabled for this model because caching is not turned on, Rails.cache is not defined, or cache engine is not mem_cache_store"
          ActiveRecord::Base.logger.error warning
        end
      end
      
      def connection_with_memcache_query_cache
        conn = connection_without_memcache_query_cache
        conn.memcache_query_cache_options = self.enableMemcacheQueryForModels[self.to_s]
        conn
      end
      
      alias_method_chain :connection, :memcache_query_cache
      
      def cache_version_key(table_name = nil)
        "#{global_cache_version_key}/#{table_name || self.table_name}"
      end
 
      def global_cache_version_key; 'version' end
 
      # Increment the class version key number
      def increase_version!(table_name = nil)
        key = cache_version_key(table_name)
        if r = ::Rails.cache.read(key)
          ::Rails.cache.write(key, r.to_i + 1)
        else
          ::Rails.cache.write(key, 1)
        end
      end
      
      # Given a sql query this method extract all the table names of the database affected by the query
      # thanks to the regular expression we have generated on the load of the plugin
      def extract_table_names(sql)
        sql.gsub(/`/,'').scan(self.table_names).map {|t| t.strip}.uniq
      end
 
    end
 
  end
 
  module ConnectionAdapters # :nodoc:
    
    class AbstractAdapter
      attr_accessor :memcache_query_cache_options
    end
 
    class MysqlAdapter < AbstractAdapter
      
      # alias_method_chain for expiring cache if necessary
      def execute_with_clean_query_cache(*args)
        return execute_without_clean_query_cache(*args) unless self.memcache_query_cache_options && query_cache_enabled
        sql = args[0].strip
        if sql =~ /^(INSERT|UPDATE|ALTER|DROP|DELETE)/i
          # can only modify one table at a time...so stop after matching the first table name
          table_name = ActiveRecord::Base.extract_table_names(sql).first
          ActiveRecord::Base.increase_version!(table_name)
        end
        execute_without_clean_query_cache(*args)
      end
 
      alias_method_chain :execute, :clean_query_cache
      
    end
    
    class PostgreSQLAdapter < AbstractAdapter
 
      def execute_with_clean_query_cache(*args)
        return execute_without_clean_query_cache(*args) unless self.memcache_query_cache_options && query_cache_enabled
        sql = args[0].strip
        if sql =~ /^(INSERT|UPDATE|ALTER|DROP|DELETE)/i
          table_name = ActiveRecord::Base.extract_table_names(sql).first
          ActiveRecord::Base.increase_version!(table_name)
        end
        execute_without_clean_query_cache(*args)
      end
      
      alias_method_chain :execute, :clean_query_cache
      
    end
    
    module QueryCache
    
      # Enable the query cache within the block
      def cache
        old, @query_cache_enabled = @query_cache_enabled, true
        @query_cache ||= {}
        @cache_version ||= {}
        yield
      ensure
        @query_cache_enabled = old
        clear_query_cache
      end
    
      # Clears the query cache.
      #
      # One reason you may wish to call this method explicitly is between queries
      # that ask the database to randomize results. Otherwise the cache would see
      # the same SQL query and repeatedly return the same result each time, silently
      # undermining the randomness you were expecting.
      def clear_query_cache
        @query_cache.clear if @query_cache
        @cache_version.clear if @cache_version
      end
    
      private
    
      def cache_sql(sql)
        # priority order:
        # - if in @query_cache (memory of local app server) we prefer this
        # - else if exists in Memcached we prefer that
        # - else perform query in database and save memory caches
        result =
          if (query_cache_enabled || self.memcache_query_cache_options) && @query_cache.has_key?(sql)
            log_info(sql, "CACHE", 0.0)
            @query_cache[sql]
          elsif self.memcache_query_cache_options && cached_result = ::Rails.cache.read(query_key(sql), self.memcache_query_cache_options)
            log_info(sql, "MEMCACHE", 0.0)
            @query_cache[sql] = cached_result
          else
            query_result = yield
            @query_cache[sql] = query_result if query_cache_enabled || self.memcache_query_cache_options
            ::Rails.cache.write(query_key(sql), query_result, self.memcache_query_cache_options) if self.memcache_query_cache_options
            query_result
          end
    
        if Array === result
          result.collect { |row| row.dup }
        else
          result.duplicable? ? result.dup : result
        end
      rescue TypeError
        result
      end
    
      # Transforms a sql query into a valid key for Memcache
      def query_key(sql)
        table_names = ActiveRecord::Base.extract_table_names(sql)
        # version_number is the sum of the global version number and all
        # the version numbers of each table
        version_number = get_cache_version # global version
        table_names.each { |table_name| version_number += get_cache_version(table_name) }
        "#{version_number}_#{Digest::MD5.hexdigest(sql)}"
      end
    
      # Returns the cache version of a table_name. If table_name is empty its the global version
      #
      # We prefer to search for this key first in memory and then in Memcache
      def get_cache_version(table_name = nil)
        key_class_version = table_name ? ActiveRecord::Base.cache_version_key(table_name) : ActiveRecord::Base.global_cache_version_key
        if @cache_version && @cache_version[key_class_version]
          @cache_version[key_class_version]
        elsif version = ::Rails.cache.read(key_class_version)
          @cache_version[key_class_version] = version if @cache_version
          version
        else
          @cache_version[key_class_version] = 0 if @cache_version
          ::Rails.cache.write(key_class_version, 0)
          0
        end
      end
    
    end
    
  end
end