frivol 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,195 @@
1
+ module Frivol
2
+ # == Frivol::ClassMethods
3
+ # These methods are available on the class level when Frivol is included in the class.
4
+ module ClassMethods
5
+ # Set the storage expiry time in seconds for the default bucket or the bucket passed.
6
+ def storage_expires_in(time, bucket = nil)
7
+ @frivol_storage_expiry ||= {}
8
+ @frivol_storage_expiry[bucket.to_s] = time
9
+ end
10
+
11
+ # Get the storage expiry time in seconds for the default bucket or the bucket passed.
12
+ def storage_expiry(bucket = nil)
13
+ @frivol_storage_expiry ||= {}
14
+ @frivol_storage_expiry.key?(bucket.to_s) ? @frivol_storage_expiry[bucket.to_s] : NEVER_EXPIRE
15
+ end
16
+
17
+ # Create a storage bucket.
18
+ # Frivol creates store_#{bucket} and retrieve_#{bucket} methods automatically.
19
+ # These methods work exactly like the default store and retrieve methods except that the bucket is
20
+ # stored in it's own key in Redis and can have it's own expiry time.
21
+ #
22
+ # Counters are special in that they do not store a hash but only a single integer value and also
23
+ # that the data in a counter is not cached for the lifespan of the object, but rather each call
24
+ # hits Redis. This is intended to make counters thread safe (for example you may have multiple
25
+ # workers working on a job and they can each increment a progress counter which would not work
26
+ # with the default retrieve/store method that normal buckets use). For this to actually be thread safe
27
+ # you need to pass the thread safe option to the config when you make the connection.
28
+ #
29
+ # In the case of a counter, the methods work slightly differently:
30
+ # - store_#{bucket} only takes an integer value to store (no key)
31
+ # - retrieve_#{bucket} only takes an integer default, and returns only the integer value
32
+ # - there is an added increment_#{bucket} method which increments the counter by 1
33
+ # - as well as increment_#{bucket}_by(value) method which increments the counter by the value
34
+ # - and similar decrement_#{bucket} and decrement_#{bucket}_by(value) methods
35
+ #
36
+ # Options are
37
+ # - <tt>:expires_in</tt> which sets the expiry time for a bucket;
38
+ # - <tt>:counter</tt> to create a special counter storage bucket;
39
+ # - <tt>:condition</tt> that must be satisfied before an action is taken on a bucket;
40
+ # - <tt>:else</tt>, which is an action that is performed if <tt>:condition</tt> is not satisfied
41
+ def storage_bucket(bucket, options = {})
42
+ time = options[:expires_in]
43
+ storage_expires_in(time, bucket) if !time.nil?
44
+
45
+ is_counter = options[:counter]
46
+ seed_callback = options[:seed]
47
+
48
+
49
+ condition_block = Functor.new(options[:condition], true).compile
50
+ else_block = Functor.new(options[:else]).compile
51
+
52
+ define_method :condition_evaluation do |*args, &block|
53
+ if instance_exec(*args, &condition_block)
54
+ block.call
55
+ else
56
+ instance_exec(*args, &else_block)
57
+ end
58
+ end
59
+
60
+ self.class_eval do
61
+ if is_counter
62
+ define_method "store_#{bucket}" do |value|
63
+ condition_evaluation("store_#{bucket}", value) do
64
+ Frivol::Helpers.store_counter(self, bucket, value)
65
+ end
66
+ end
67
+
68
+ define_method "retrieve_#{bucket}" do |default|
69
+ return_value = default
70
+ condition_evaluation("store_#{bucket}", default) do
71
+ return_value = Frivol::Helpers.retrieve_counter(self, bucket, default)
72
+ end
73
+ return_value
74
+ end
75
+
76
+ define_method "increment_#{bucket}" do
77
+ condition_evaluation("increment_#{bucket}") do
78
+ Frivol::Helpers.increment_counter(self, bucket, seed_callback)
79
+ end
80
+ end
81
+
82
+ define_method "increment_#{bucket}_by" do |amount|
83
+ condition_evaluation("increment_#{bucket}_by", amount) do
84
+ Frivol::Helpers.increment_counter_by(self, bucket, amount, seed_callback)
85
+ end
86
+ end
87
+
88
+ define_method "decrement_#{bucket}" do
89
+ Frivol::Helpers.decrement_counter(self, bucket, seed_callback)
90
+ end
91
+
92
+ define_method "decrement_#{bucket}_by" do |amount|
93
+ Frivol::Helpers.decrement_counter_by(self, bucket, amount, seed_callback)
94
+ end
95
+ else
96
+ define_method "store_#{bucket}" do |keys_and_values|
97
+ condition_evaluation("store_#{bucket}", keys_and_values) do
98
+ hash = Frivol::Helpers.retrieve_hash(self, bucket)
99
+ keys_and_values.each do |key, value|
100
+ hash[key.to_s] = value
101
+ end
102
+ Frivol::Helpers.store_hash(self, hash, bucket)
103
+ end
104
+ end
105
+
106
+ define_method "retrieve_#{bucket}" do |keys_and_defaults|
107
+ hash = {}
108
+ condition_evaluation("store_#{bucket}", keys_and_defaults) do
109
+ hash = Frivol::Helpers.retrieve_hash(self, bucket)
110
+ end
111
+
112
+ result = keys_and_defaults.map do |key, default|
113
+ hash[key.to_s] || (default.is_a?(Symbol) && respond_to?(default) && send(default)) || default
114
+ end
115
+ return result.first if result.size == 1
116
+ result
117
+ end
118
+ end
119
+
120
+ define_method "delete_#{bucket}" do
121
+ condition_evaluation("delete_#{bucket}") do
122
+ Frivol::Helpers.delete_hash(self, bucket)
123
+ end
124
+ end
125
+
126
+ define_method "clear_#{bucket}" do
127
+ condition_evaluation("clear_#{bucket}") do
128
+ Frivol::Helpers.clear_hash(self, bucket)
129
+ end
130
+ end
131
+ end
132
+
133
+ # Use Frivol to cache results for a method (similar to memoize).
134
+ # Options are :bucket which sets the bucket name for the storage,
135
+ # :expires_in which sets the expiry time for a bucket,
136
+ # and :counter to create a special counter storage bucket.
137
+ #
138
+ # If not :counter the key is the method_name.
139
+ #
140
+ # If you supply :expires_in you must also supply a :bucket otherwise
141
+ # it is ignored (and the default class expires_in is used if supplied).
142
+ #
143
+ # If :counter and no :bucket is provided the :bucket is set to the
144
+ # :bucket is set to the method_name (and so the :expires_in will be used).
145
+ def frivolize(method_name, options = {})
146
+ bucket = options[:bucket]
147
+ time = options[:expires_in]
148
+ is_counter = options[:counter]
149
+ seed_callback = options[:seed]
150
+
151
+ bucket = method_name if bucket.nil? && is_counter
152
+ frivolized_method_name = "frivolized_#{method_name}"
153
+
154
+ self.class_eval do
155
+ alias_method frivolized_method_name, method_name
156
+ unless bucket.nil?
157
+ storage_bucket(bucket, {
158
+ :expires_in => time,
159
+ :counter => is_counter,
160
+ :seed => seed_callback })
161
+ end
162
+
163
+ if is_counter
164
+ define_method method_name do
165
+ value = send "retrieve_#{bucket}", -2147483647 # A rediculously small number that is unlikely to be used: -2**31 + 1
166
+ if value == -2147483647
167
+ value = send frivolized_method_name
168
+ send "store_#{bucket}", value
169
+ end
170
+ value
171
+ end
172
+ elsif !bucket.nil?
173
+ define_method method_name do
174
+ value = send "retrieve_#{bucket}", { method_name => false }
175
+ if !value
176
+ value = send frivolized_method_name
177
+ send "store_#{bucket}", { method_name => value }
178
+ end
179
+ value
180
+ end
181
+ else
182
+ define_method method_name do
183
+ value = retrieve method_name => false
184
+ if !value
185
+ value = send frivolized_method_name
186
+ store method_name.to_sym => value
187
+ end
188
+ value
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,39 @@
1
+ module Frivol
2
+ # == Frivol::Config
3
+ # Sets the Frivol configuration (currently only the Redis config), allows access to the configured Redis instance,
4
+ # and has a helper method to include Frivol in a class with an optional storage expiry parameter
5
+ module Config
6
+ # Set the Redis configuration.
7
+ #
8
+ # Expects a hash such as
9
+ # REDIS_CONFIG = {
10
+ # :host => "localhost",
11
+ # :port => 6379
12
+ # }
13
+ # Frivol::Config.redis_config = REDIS_CONFIG
14
+ def self.redis_config=(config)
15
+ @@redis_config = config
16
+ Thread.current[:frivol_redis] = nil
17
+ end
18
+
19
+ # Returns the configured Redis instance
20
+ def self.redis
21
+ Thread.current[:frivol_redis] ||= Redis.new(@@redis_config)
22
+ end
23
+
24
+ def self.allow_json_create
25
+ @@allow_json_create ||= []
26
+ end
27
+
28
+ # A convenience method to include Frivol in a class, with an optional storage expiry parameter.
29
+ #
30
+ # For example, you might have the following in environment.rb:
31
+ # Frivol::Config.redis_config = REDIS_CONFIG
32
+ # Frivol::Config.include_in ActiveRecord::Base, 600
33
+ # Which would include Frivol in ActiveRecord::Base and set the default storage expiry to 10 minutes
34
+ def self.include_in(host_class, storage_expires_in = nil)
35
+ host_class.send(:include, Frivol)
36
+ host_class.storage_expires_in storage_expires_in if storage_expires_in
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,37 @@
1
+ module Frivol
2
+ # == Frivol::Functor
3
+ # Compiles proc, symbols, false, true into a proc that executes within a scope,
4
+ # or on an object.
5
+ class Functor
6
+ # Create a new functor which takes:
7
+ # method: can be a proc, symbol, false or true
8
+ # default: the value which is returned from the compiled proc if method is
9
+ # not a proc, symbol, false or true. Defaults to nil
10
+ def initialize(method, default=nil)
11
+ @method = method
12
+ @default = default
13
+ end
14
+
15
+ # returns a compiled proc based on the initialization arguments
16
+ def compile
17
+ case @method
18
+ when Proc
19
+ method = @method
20
+ proc do |*args|
21
+ args.unshift(self)
22
+ method.call(*args)
23
+ end
24
+ when Symbol
25
+ method = @method
26
+ proc do |*args|
27
+ self.send(method, *args)
28
+ end
29
+ when FalseClass, TrueClass
30
+ proc{ @method }
31
+ else
32
+ default_return = @default
33
+ proc{ default_return }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,133 @@
1
+ module Frivol
2
+ module Helpers #:nodoc:
3
+ require 'multi_json'
4
+
5
+ def self.dump_json(hash)
6
+ MultiJson.dump(hash)
7
+ end
8
+
9
+ def self.load_json(json)
10
+ hash = MultiJson.load(json)
11
+ return hash if Frivol::Config.allow_json_create.empty?
12
+ hash.each do |k,v|
13
+ if v.is_a?(Hash) && v['json_class']
14
+ klass = constantize(v['json_class'])
15
+ hash[k] = klass.send(:json_create, v) if Frivol::Config.allow_json_create.include?(klass)
16
+ end
17
+ end
18
+ end
19
+
20
+ def self.constantize(const)
21
+ unless /\A(?:::)?([A-Z]\w*(?:::[A-Z]\w*)*)\z/ =~ const
22
+ raise NameError, "#{const.inspect} is not a valid constant name!"
23
+ end
24
+ Object.module_eval("::#{$1}", __FILE__, __LINE__)
25
+ end
26
+
27
+
28
+ def self.store_hash(instance, hash, bucket = nil)
29
+ data, is_new = get_data_and_is_new instance
30
+ data[bucket.to_s] = hash
31
+
32
+ store_value instance, is_new[bucket.to_s], dump_json(hash), bucket
33
+
34
+ self.set_data_and_is_new instance, data, is_new
35
+ end
36
+
37
+ def self.store_value(instance, is_new, value, bucket = nil)
38
+ key = instance.send(:storage_key, bucket)
39
+ time = instance.class.storage_expiry(bucket)
40
+ if time == Frivol::NEVER_EXPIRE
41
+ Frivol::Config.redis[key] = value
42
+ else
43
+ Frivol::Config.redis.multi do |redis|
44
+ # TODO: write test for the to_i bug fix
45
+ time = redis.ttl(key).to_i unless is_new
46
+ redis[key] = value
47
+ redis.expire(key, time)
48
+ end
49
+ end
50
+ end
51
+
52
+ def self.retrieve_hash(instance, bucket = nil)
53
+ data, is_new = get_data_and_is_new instance
54
+ return data[bucket.to_s] if data.key?(bucket.to_s)
55
+ key = instance.send(:storage_key, bucket)
56
+ json = Frivol::Config.redis[key]
57
+
58
+ is_new[bucket.to_s] = json.nil?
59
+
60
+ hash = json.nil? ? {} : load_json(json)
61
+ data[bucket.to_s] = hash
62
+
63
+ self.set_data_and_is_new instance, data, is_new
64
+ hash
65
+ end
66
+
67
+ def self.delete_hash(instance, bucket = nil)
68
+ key = instance.send(:storage_key, bucket)
69
+ Frivol::Config.redis.del key
70
+ clear_hash(instance, bucket)
71
+ end
72
+
73
+ def self.clear_hash(instance, bucket = nil)
74
+ key = instance.send(:storage_key, bucket)
75
+ data = instance.instance_variable_defined?(:@frivol_data) ? instance.instance_variable_get(:@frivol_data) : {}
76
+ data.delete(bucket.to_s)
77
+ instance.instance_variable_set :@frivol_data, data
78
+ end
79
+
80
+ def self.get_data_and_is_new(instance)
81
+ data = instance.instance_variable_defined?(:@frivol_data) ? instance.instance_variable_get(:@frivol_data) : {}
82
+ is_new = instance.instance_variable_defined?(:@frivol_is_new) ? instance.instance_variable_get(:@frivol_is_new) : {}
83
+ [data, is_new]
84
+ end
85
+
86
+ def self.set_data_and_is_new(instance, data, is_new)
87
+ instance.instance_variable_set :@frivol_data, data
88
+ instance.instance_variable_set :@frivol_is_new, is_new
89
+ end
90
+
91
+ def self.store_counter(instance, counter, value)
92
+ key = instance.send(:storage_key, counter)
93
+ is_new = !Frivol::Config.redis.exists(key)
94
+ store_value instance, is_new, value, counter
95
+ end
96
+
97
+ def self.retrieve_counter(instance, counter, default)
98
+ key = instance.send(:storage_key, counter)
99
+ (Frivol::Config.redis[key] || default).to_i
100
+ end
101
+
102
+ def self.increment_counter(instance, counter, seed_callback=nil)
103
+ key = instance.send(:storage_key, counter)
104
+ store_counter_seed_value(key, instance, counter, seed_callback)
105
+ Frivol::Config.redis.incr(key)
106
+ end
107
+
108
+ def self.increment_counter_by(instance, counter, amount, seed_callback=nil)
109
+ key = instance.send(:storage_key, counter)
110
+ store_counter_seed_value(key, instance, counter, seed_callback)
111
+ Frivol::Config.redis.incrby(key, amount)
112
+ end
113
+
114
+ def self.decrement_counter(instance, counter, seed_callback=nil)
115
+ key = instance.send(:storage_key, counter)
116
+ store_counter_seed_value(key, instance, counter, seed_callback)
117
+ Frivol::Config.redis.decr(key)
118
+ end
119
+
120
+ def self.decrement_counter_by(instance, counter, amount, seed_callback=nil)
121
+ key = instance.send(:storage_key, counter)
122
+ store_counter_seed_value(key, instance, counter, seed_callback)
123
+ Frivol::Config.redis.decrby(key, amount)
124
+ end
125
+
126
+ def self.store_counter_seed_value(key, instance, counter, seed_callback)
127
+ unless Frivol::Config.redis.exists(key) || seed_callback.nil?
128
+ store_counter( instance, counter, seed_callback.call(instance))
129
+ end
130
+ end
131
+ private_class_method :store_counter_seed_value
132
+ end
133
+ end
@@ -0,0 +1,45 @@
1
+ require 'time'
2
+
3
+ # == Time
4
+ # An extension to the <tt>Time</tt> class which allows instances to be
5
+ # serialized by <tt>MultiJson#dump</tt> and deserialized by
6
+ # <tt>MultiJson#load</tt>.
7
+ class Time
8
+ # Serialize to JSON
9
+ def to_json(*a)
10
+ MultiJson.dump(
11
+ 'json_class' => self.class.name,
12
+ 'data' => self.to_s
13
+ )
14
+ end
15
+
16
+ # Deserialize from JSON
17
+ def self.json_create(o)
18
+ Time.parse(*o['data'])
19
+ end
20
+ end
21
+
22
+ Frivol::Config.allow_json_create << Time
23
+
24
+ begin
25
+ # == ActiveSupport::TimeWithZone
26
+ # An extension to the <tt>ActiveSupport::TimeWithZone</tt> class which allows
27
+ # instances to be serialized by <tt>MultiJson#dump</tt> and deserialized by
28
+ # <tt>MultiJson#load</tt>.
29
+ class ActiveSupport::TimeWithZone
30
+ # Serialize to JSON
31
+ def to_json(*a)
32
+ MultiJson.dump(
33
+ 'json_class' => self.class.name,
34
+ 'data' => self.to_s
35
+ )
36
+ end
37
+
38
+ # Deserialize from JSON
39
+ def self.json_create(o)
40
+ Time.zone.parse(*o['data'])
41
+ end
42
+ end
43
+
44
+ Frivol::Config.allow_json_create << ActiveSupport::TimeWithZone
45
+ rescue; end