lock_and_cache_msgpack 4.0.7.pre1

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