lock_and_cache_msgpack 4.0.7.pre1

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
7
+
8
+ require 'yard'
9
+ YARD::Rake::YardocTask.new
@@ -0,0 +1,87 @@
1
+ require 'set'
2
+ require 'date'
3
+ require 'benchmark/ips'
4
+
5
+ ALLOWED_IN_KEYS = [
6
+ ::String,
7
+ ::Symbol,
8
+ ::Numeric,
9
+ ::TrueClass,
10
+ ::FalseClass,
11
+ ::NilClass,
12
+ ::Integer,
13
+ ::Float,
14
+ ::Date,
15
+ ::DateTime,
16
+ ::Time,
17
+ ].to_set
18
+ parts = RUBY_VERSION.split('.').map(&:to_i)
19
+ unless parts[0] >= 2 and parts[1] >= 4
20
+ ALLOWED_IN_KEYS << ::Fixnum
21
+ ALLOWED_IN_KEYS << ::Bignum
22
+ end
23
+
24
+ EXAMPLES = [
25
+ 'hi',
26
+ :there,
27
+ 123,
28
+ 123.54,
29
+ 1e99,
30
+ 123456789 ** 2,
31
+ 1e999,
32
+ true,
33
+ false,
34
+ nil,
35
+ Date.new(2015,1,1),
36
+ Time.now,
37
+ DateTime.now,
38
+ Mutex,
39
+ Mutex.new,
40
+ Benchmark,
41
+ { hi: :world },
42
+ [[]],
43
+ Fixnum,
44
+ Struct,
45
+ Struct.new(:a),
46
+ Struct.new(:a).new(123)
47
+ ]
48
+ EXAMPLES.each do |example|
49
+ puts "#{example} -> #{example.class}"
50
+ end
51
+
52
+ puts
53
+
54
+ [
55
+ Date.new(2015,1,1),
56
+ Time.now,
57
+ DateTime.now,
58
+ ].each do |x|
59
+ puts x.to_s
60
+ end
61
+
62
+ puts
63
+
64
+ EXAMPLES.each do |example|
65
+ a = ALLOWED_IN_KEYS.any? { |thing| example.is_a?(thing) }
66
+ b = ALLOWED_IN_KEYS.include? example.class
67
+ unless a == b
68
+ raise "#{example.inspect}: #{a.inspect} vs #{b.inspect}"
69
+ end
70
+ end
71
+
72
+ Benchmark.ips do |x|
73
+ x.report("any") do
74
+ example = EXAMPLES.sample
75
+ y = ALLOWED_IN_KEYS.any? { |thing| example.is_a?(thing) }
76
+ a = 1
77
+ y
78
+ end
79
+
80
+ x.report("include") do
81
+ example = EXAMPLES.sample
82
+ y = ALLOWED_IN_KEYS.include? example.class
83
+ a = 1
84
+ y
85
+ end
86
+
87
+ end
@@ -0,0 +1,164 @@
1
+ require 'logger'
2
+ require 'timeout'
3
+ require 'digest/sha1'
4
+ require 'base64'
5
+ require 'redis'
6
+ require 'redlock'
7
+ require 'active_support'
8
+ require 'active_support/core_ext'
9
+ require 'msgpack'
10
+ require 'messagepack_ext'
11
+
12
+ require_relative 'lock_and_cache_msgpack/version'
13
+ require_relative 'lock_and_cache_msgpack/action'
14
+ require_relative 'lock_and_cache_msgpack/key'
15
+
16
+ # Lock and cache using redis!
17
+ #
18
+ # Most caching libraries don't do locking, meaning that >1 process can be calculating a cached value at the same time. Since you presumably cache things because they cost CPU, database reads, or money, doesn't it make sense to lock while caching?
19
+ module LockAndCacheMsgpack
20
+ DEFAULT_MAX_LOCK_WAIT = 60 * 60 * 24 # 1 day in seconds
21
+
22
+ DEFAULT_HEARTBEAT_EXPIRES = 32 # 32 seconds
23
+
24
+ class TimeoutWaitingForLock < StandardError; end
25
+
26
+ # @param redis_connection [Redis] A redis connection to be used for lock and cached value storage
27
+ def LockAndCacheMsgpack.storage=(redis_connection)
28
+ raise "only redis for now" unless redis_connection.class.to_s == 'Redis'
29
+ @storage = redis_connection
30
+ @lock_manager = Redlock::Client.new [redis_connection], retry_count: 1
31
+ end
32
+
33
+ # @return [Redis] The redis connection used for lock and cached value storage
34
+ def LockAndCacheMsgpack.storage
35
+ @storage
36
+ end
37
+
38
+ # @param logger [Logger] A logger.
39
+ def LockAndCacheMsgpack.logger=(logger)
40
+ @logger = logger
41
+ end
42
+
43
+ # @return [Logger] The logger.
44
+ def LockAndCacheMsgpack.logger
45
+ @logger
46
+ end
47
+
48
+ # Flush LockAndCacheMsgpack's storage.
49
+ #
50
+ # @note If you are sharing a redis database, it will clear it...
51
+ #
52
+ # @note If you want to clear a single key, try `LockAndCacheMsgpack.clear(key)` (standalone mode) or `#lock_and_cache_clear(method_id, *key_parts)` in context mode.
53
+ def LockAndCacheMsgpack.flush
54
+ storage.flushdb
55
+ end
56
+
57
+ # Lock and cache based on a key.
58
+ #
59
+ # @param key_parts [*] Parts that should be used to construct a key.
60
+ #
61
+ # @note Standalone mode. See also "context mode," where you mix LockAndCacheMsgpack into a class and call it from within its methods.
62
+ #
63
+ # @note A single hash arg is treated as a cache key, e.g. `LockAndCacheMsgpack.lock_and_cache(foo: :bar, expires: 100)` will be treated as a cache key of `foo: :bar, expires: 100` (which is probably wrong!!!). Try `LockAndCacheMsgpack.lock_and_cache({ foo: :bar }, expires: 100)` instead. This is the opposite of context mode.
64
+ def LockAndCacheMsgpack.lock_and_cache(*key_parts_and_options, &blk)
65
+ options = (key_parts_and_options.last.is_a?(Hash) && key_parts_and_options.length > 1) ? key_parts_and_options.pop : {}
66
+ raise "need a cache key" unless key_parts_and_options.length > 0
67
+ key = LockAndCacheMsgpack::Key.new key_parts_and_options
68
+ action = LockAndCacheMsgpack::Action.new key, options, blk
69
+ action.perform
70
+ end
71
+
72
+ # Clear a single key
73
+ #
74
+ # @note Standalone mode. See also "context mode," where you mix LockAndCacheMsgpack into a class and call it from within its methods.
75
+ def LockAndCacheMsgpack.clear(*key_parts)
76
+ key = LockAndCacheMsgpack::Key.new key_parts
77
+ key.clear
78
+ end
79
+
80
+ # Check if a key is locked
81
+ #
82
+ # @note Standalone mode. See also "context mode," where you mix LockAndCacheMsgpack into a class and call it from within its methods.
83
+ def LockAndCacheMsgpack.locked?(*key_parts)
84
+ key = LockAndCacheMsgpack::Key.new key_parts
85
+ key.locked?
86
+ end
87
+
88
+ # Check if a key is cached already
89
+ #
90
+ # @note Standalone mode. See also "context mode," where you mix LockAndCacheMsgpack into a class and call it from within its methods.
91
+ def LockAndCacheMsgpack.cached?(*key_parts)
92
+ key = LockAndCacheMsgpack::Key.new key_parts
93
+ key.cached?
94
+ end
95
+
96
+ # @param seconds [Numeric] Maximum wait time to get a lock
97
+ #
98
+ # @note Can be overridden by putting `max_lock_wait:` in your call to `#lock_and_cache`
99
+ def LockAndCacheMsgpack.max_lock_wait=(seconds)
100
+ @max_lock_wait = seconds.to_f
101
+ end
102
+
103
+ # @private
104
+ def LockAndCacheMsgpack.max_lock_wait
105
+ @max_lock_wait || DEFAULT_MAX_LOCK_WAIT
106
+ end
107
+
108
+ # @param seconds [Numeric] How often a process has to heartbeat in order to keep a lock
109
+ #
110
+ # @note Can be overridden by putting `heartbeat_expires:` in your call to `#lock_and_cache`
111
+ def LockAndCacheMsgpack.heartbeat_expires=(seconds)
112
+ memo = seconds.to_f
113
+ raise "heartbeat_expires must be greater than 2 seconds" unless memo >= 2
114
+ @heartbeat_expires = memo
115
+ end
116
+
117
+ # @private
118
+ def LockAndCacheMsgpack.heartbeat_expires
119
+ @heartbeat_expires || DEFAULT_HEARTBEAT_EXPIRES
120
+ end
121
+
122
+ # @private
123
+ def LockAndCacheMsgpack.lock_manager
124
+ @lock_manager
125
+ end
126
+
127
+ # Check if a method is locked on an object.
128
+ #
129
+ # @note Subject mode - this is expected to be called on an object whose class has LockAndCacheMsgpack mixed in. See also standalone mode.
130
+ def lock_and_cache_locked?(method_id, *key_parts)
131
+ key = LockAndCacheMsgpack::Key.new key_parts, context: self, method_id: method_id
132
+ key.locked?
133
+ end
134
+
135
+ # Clear a lock and cache given exactly the method and exactly the same arguments
136
+ #
137
+ # @note Subject mode - this is expected to be called on an object whose class has LockAndCacheMsgpack mixed in. See also standalone mode.
138
+ def lock_and_cache_clear(method_id, *key_parts)
139
+ key = LockAndCacheMsgpack::Key.new key_parts, context: self, method_id: method_id
140
+ key.clear
141
+ end
142
+
143
+ # Lock and cache a method given key parts.
144
+ #
145
+ # The cache key will automatically include the class name of the object calling it (the context!) and the name of the method it is called from.
146
+ #
147
+ # @param key_parts_and_options [*] Parts that you want to include in the lock and cache key. If the last element is a Hash, it will be treated as options.
148
+ #
149
+ # @return The cached value (possibly newly calculated).
150
+ #
151
+ # @note Subject mode - this is expected to be called on an object whose class has LockAndCacheMsgpack mixed in. See also standalone mode.
152
+ #
153
+ # @note A single hash arg is treated as an options hash, e.g. `lock_and_cache(expires: 100)` will be treated as options `expires: 100`. This is the opposite of standalone mode.
154
+ def lock_and_cache(*key_parts_and_options, &blk)
155
+ options = key_parts_and_options.last.is_a?(Hash) ? key_parts_and_options.pop : {}
156
+ key = LockAndCacheMsgpack::Key.new key_parts_and_options, context: self, caller: caller
157
+ action = LockAndCacheMsgpack::Action.new key, options, blk
158
+ action.perform
159
+ end
160
+ end
161
+
162
+ logger = Logger.new $stderr
163
+ logger.level = (ENV['LOCK_AND_CACHE_DEBUG'] == 'true') ? Logger::DEBUG : Logger::INFO
164
+ LockAndCacheMsgpack.logger = logger
@@ -0,0 +1,109 @@
1
+ module LockAndCacheMsgpack
2
+ # @private
3
+ class Action
4
+ attr_reader :key
5
+ attr_reader :options
6
+ attr_reader :blk
7
+
8
+ def initialize(key, options, blk)
9
+ raise "need a block" unless blk
10
+ @key = key
11
+ @options = options.stringify_keys
12
+ @blk = blk
13
+ end
14
+
15
+ def expires
16
+ return @expires if defined?(@expires)
17
+ @expires = options.has_key?('expires') ? options['expires'].to_f.round : nil
18
+ end
19
+
20
+ def nil_expires
21
+ return @nil_expires if defined?(@nil_expires)
22
+ @nil_expires = options.has_key?('nil_expires') ? options['nil_expires'].to_f.round : nil
23
+ end
24
+
25
+ def digest
26
+ @digest ||= key.digest
27
+ end
28
+
29
+ def lock_digest
30
+ @lock_digest ||= key.lock_digest
31
+ end
32
+
33
+ def storage
34
+ @storage ||= LockAndCacheMsgpack.storage or raise("must set LockAndCacheMsgpack.storage=[Redis]")
35
+ end
36
+
37
+ def perform
38
+ max_lock_wait = options.fetch 'max_lock_wait', LockAndCacheMsgpack.max_lock_wait
39
+ heartbeat_expires = options.fetch('heartbeat_expires', LockAndCacheMsgpack.heartbeat_expires).to_f.ceil
40
+ raise "heartbeat_expires must be >= 2 seconds" unless heartbeat_expires >= 2
41
+ heartbeat_frequency = (heartbeat_expires / 2).ceil
42
+ LockAndCacheMsgpack.logger.debug { "[lock_and_cache] A1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" }
43
+ if storage.exists(digest) and (existing = storage.get(digest)).is_a?(String)
44
+ return MessagePack.unpack(existing)
45
+ end
46
+ LockAndCacheMsgpack.logger.debug { "[lock_and_cache] B1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" }
47
+ retval = nil
48
+ lock_manager = LockAndCacheMsgpack.lock_manager
49
+ lock_info = nil
50
+ begin
51
+ Timeout.timeout(max_lock_wait, TimeoutWaitingForLock) do
52
+ until lock_info = lock_manager.lock(lock_digest, heartbeat_expires*1000)
53
+ LockAndCacheMsgpack.logger.debug { "[lock_and_cache] C1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" }
54
+ sleep rand
55
+ end
56
+ end
57
+ LockAndCacheMsgpack.logger.debug { "[lock_and_cache] D1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" }
58
+ if storage.exists(digest) and (existing = storage.get(digest)).is_a?(String)
59
+ LockAndCacheMsgpack.logger.debug { "[lock_and_cache] E1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" }
60
+ retval = MessagePack.unpack existing
61
+ end
62
+ unless retval
63
+ LockAndCacheMsgpack.logger.debug { "[lock_and_cache] F1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" }
64
+ done = false
65
+ begin
66
+ lock_extender = Thread.new do
67
+ loop do
68
+ LockAndCacheMsgpack.logger.debug { "[lock_and_cache] heartbeat1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" }
69
+ break if done
70
+ sleep heartbeat_frequency
71
+ break if done
72
+ LockAndCacheMsgpack.logger.debug { "[lock_and_cache] heartbeat2 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" }
73
+ lock_manager.lock lock_digest, heartbeat_expires*1000, extend: lock_info
74
+ end
75
+ end
76
+ retval = blk.call
77
+ retval.nil? ? set_nil : set_non_nil(retval)
78
+ ensure
79
+ done = true
80
+ lock_extender.join if lock_extender.status.nil?
81
+ end
82
+ end
83
+ ensure
84
+ lock_manager.unlock lock_info if lock_info
85
+ end
86
+ retval
87
+ end
88
+
89
+ NIL = MessagePack.pack nil
90
+ def set_nil
91
+ if nil_expires
92
+ storage.setex digest, nil_expires, NIL
93
+ elsif expires
94
+ storage.setex digest, expires, NIL
95
+ else
96
+ storage.set digest, NIL
97
+ end
98
+ end
99
+
100
+ def set_non_nil(retval)
101
+ raise "expected not null #{retval.inspect}" if retval.nil?
102
+ if expires
103
+ storage.setex digest, expires, MessagePack.pack(retval)
104
+ else
105
+ storage.set digest, MessagePack.pack(retval)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,135 @@
1
+ require 'date'
2
+
3
+ module LockAndCacheMsgpack
4
+ # @private
5
+ class Key
6
+ class << self
7
+ # @private
8
+ #
9
+ # Extract the method id from a method's caller array.
10
+ def extract_method_id_from_caller(kaller)
11
+ kaller[0] =~ METHOD_NAME_IN_CALLER
12
+ raise "couldn't get method_id from #{kaller[0]}" unless $1
13
+ $1.to_sym
14
+ end
15
+
16
+ # @private
17
+ #
18
+ # Get a context object's class name, which is its own name if it's an object.
19
+ def extract_class_name(context)
20
+ (context.class == ::Class) ? context.name : context.class.name
21
+ end
22
+
23
+ # @private
24
+ #
25
+ # Recursively extract id from obj. Calls #lock_and_cache_key if available, otherwise #id
26
+ def extract_obj_id(obj)
27
+ klass = obj.class
28
+ if ALLOWED_IN_KEYS.include?(klass)
29
+ obj
30
+ elsif DATE.include?(klass)
31
+ obj.to_s
32
+ elsif obj.respond_to?(:lock_and_cache_key)
33
+ extract_obj_id obj.lock_and_cache_key
34
+ elsif obj.respond_to?(:id)
35
+ extract_obj_id obj.id
36
+ elsif obj.respond_to?(:map)
37
+ obj.map { |objj| extract_obj_id objj }
38
+ else
39
+ raise "#{obj.inspect} must respond to #lock_and_cache_key or #id"
40
+ end
41
+ end
42
+ end
43
+
44
+ ALLOWED_IN_KEYS = [
45
+ ::String,
46
+ ::Symbol,
47
+ ::Numeric,
48
+ ::TrueClass,
49
+ ::FalseClass,
50
+ ::NilClass,
51
+ ::Integer,
52
+ ::Float,
53
+ ].to_set
54
+ parts = ::RUBY_VERSION.split('.').map(&:to_i)
55
+ unless parts[0] >= 2 and parts[1] >= 4
56
+ ALLOWED_IN_KEYS << ::Fixnum
57
+ ALLOWED_IN_KEYS << ::Bignum
58
+ end
59
+ DATE = [
60
+ ::Date,
61
+ ::DateTime,
62
+ ::Time,
63
+ ].to_set
64
+ METHOD_NAME_IN_CALLER = /in `([^']+)'/
65
+
66
+ attr_reader :context
67
+ attr_reader :method_id
68
+
69
+ def initialize(parts, options = {})
70
+ @_parts = parts
71
+ @context = options[:context]
72
+ @method_id = if options.has_key?(:method_id)
73
+ options[:method_id]
74
+ elsif options.has_key?(:caller)
75
+ Key.extract_method_id_from_caller options[:caller]
76
+ elsif context
77
+ raise "supposed to call context with method_id or caller"
78
+ end
79
+ end
80
+
81
+ # A (non-cryptographic) digest of the key parts for use as the cache key
82
+ def digest
83
+ @digest ||= ::Digest::SHA1.hexdigest MessagePack.pack(key)
84
+ end
85
+
86
+ # A (non-cryptographic) digest of the key parts for use as the lock key
87
+ def lock_digest
88
+ @lock_digest ||= 'lock/' + digest
89
+ end
90
+
91
+ # A human-readable representation of the key parts
92
+ def key
93
+ @key ||= if context
94
+ [class_name, context_id, method_id, parts].compact
95
+ else
96
+ parts
97
+ end
98
+ end
99
+
100
+ def locked?
101
+ LockAndCacheMsgpack.storage.exists lock_digest
102
+ end
103
+
104
+ def cached?
105
+ LockAndCacheMsgpack.storage.exists digest
106
+ end
107
+
108
+ def clear
109
+ LockAndCacheMsgpack.logger.debug { "[lock_and_cache] clear #{debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" }
110
+ storage = LockAndCacheMsgpack.storage
111
+ storage.del digest
112
+ storage.del lock_digest
113
+ end
114
+
115
+ alias debug key
116
+
117
+ def context_id
118
+ return @context_id if defined?(@context_id)
119
+ @context_id = if context.class == ::Class
120
+ nil
121
+ else
122
+ Key.extract_obj_id context
123
+ end
124
+ end
125
+
126
+ def class_name
127
+ @class_name ||= Key.extract_class_name context
128
+ end
129
+
130
+ # An array of the parts we use for the key
131
+ def parts
132
+ @parts ||= Key.extract_obj_id @_parts
133
+ end
134
+ end
135
+ end