├── .gitignore ├── .rspec ├── .travis.yml ├── .yardopts ├── CHANGELOG ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── benchmarks └── allowed_in_keys.rb ├── lib ├── lock_and_cache.rb └── lock_and_cache │ ├── action.rb │ ├── key.rb │ └── version.rb ├── lock_and_cache.gemspec └── spec ├── lock_and_cache └── key_spec.rb ├── lock_and_cache_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | mkmf.log 15 | *.gem 16 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --order random 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | rvm: 4 | - 2.3.0 5 | - 2.4.1 6 | services: 7 | - redis-server 8 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --no-private 2 | --readme README.md 3 | --markup-provider redcarpet 4 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 6.0.1 2 | 3 | * Enhancements 4 | 5 | * Use Redis#exists? to avoid deprec warning 6 | 7 | 6.0.0 8 | 9 | * Breaking changes 10 | 11 | * Set lock_storage and cache_storage separately 12 | 13 | 5.0.0 14 | 15 | * Enhancements / breaking changes 16 | 17 | * Propagate errors to all waiters up to 1 second after the error 18 | * Stop using redlock, just use plain single-node Redis locking 19 | 20 | 4.0.6 21 | 22 | * ? 23 | 24 | * Don't test on ruby 2.1 25 | 26 | * Enhancements 27 | 28 | * LockAndCache.cached?(*key_parts) to check if a value is cached 29 | 30 | 4.0.5 / 2017-04-01 31 | 32 | * Enhancements 33 | 34 | * allow dates and times in keys 35 | * Test on ruby 2.3.0 and 2.4.1 36 | * 2x faster key generation 37 | 38 | 4.0.4 / 2016-04-11 39 | 40 | * Bug fixes 41 | 42 | * Don't default to debug logging 43 | 44 | 4.0.3 / 2016-04-11 45 | 46 | * Bug fixes 47 | 48 | * Allow true or false in keys 49 | 50 | 4.0.2 / 2016-04-11 51 | 52 | * Bug fixes 53 | 54 | * When generating key, recurse into #lock_and_cache_key 55 | 56 | 4.0.1 / 2016-04-11 57 | 58 | * Bug fixes 59 | 60 | * Avoid deadlocks related to logging 61 | 62 | 4.0.0 / 2016-04-11 63 | 64 | * Breaking changes 65 | 66 | * The cache key generation I've always wanted: recursively call #id or #lock_and_cache_key 67 | 68 | 3.0.1 / 2016-04-04 69 | 70 | * Enhancements 71 | 72 | * Don't use deprecated Thread.exclusive 73 | 74 | 3.0.0 / 2016-04-02 75 | 76 | * Breaking changes 77 | 78 | * In context mode (when you `include LockAndCache`), really call #lock_and_cache_key or #id on the instance 79 | 80 | 2.2.2 / 2015-12-18 81 | 82 | * Bug fixes 83 | 84 | * Don't die if you pass a non-integer expires - round it 85 | 86 | 2.2.1 / 2015-12-14 87 | 88 | * Bug fixes 89 | 90 | * Construct key using the correct class name 91 | 92 | 2.2.0 / 2015-11-15 93 | 94 | * Enhancements 95 | 96 | * Increase default heartbeat expires to 32 seconds from 2 (which was too strict IMO) 97 | * Allow setting heartbeat_expires: globally (LockAndCache.heartbeat_expires=) or per call 98 | * Provide LockAndCache.locked?() 99 | 100 | 2.1.1 / 2015-10-26 101 | 102 | * Bug fixes 103 | 104 | * Blow up if you try to use standalone mode without a key 105 | 106 | 2.1.0 / 2015-10-26 107 | 108 | * Enhancements 109 | 110 | * Better documentation 111 | * Standalone mode (LockAndCache.lock_and_cache([...]) {}) 112 | * Nulls can be set to expire sooner than non-null return values (`nil_expires`) 113 | 114 | 2.0.2 / 2015-10-16 115 | 116 | * Bug fixes (?) 117 | 118 | * Make sure cached values are valid marshal format (seen in the wild that they're nil) 119 | 120 | * Enhancements 121 | 122 | * Use original redlock gem now that it supports extend 123 | 124 | 2.0.1 / 2015-09-14 125 | 126 | * Bug fixes 127 | 128 | * Don't explicitly kill the lock extender thread because that sometimes causes deadlocks (don't know why) 129 | 130 | 2.0.0 / 2015-09-11 131 | 132 | * Breaking changes 133 | 134 | * Stricter key digest - differentiates symbols and strings 135 | * No more lock_expires or lock_spin options 136 | 137 | * Bug fixes 138 | 139 | * Allow method names with non-word chars like #foo? 140 | 141 | * Enhancements 142 | 143 | * heartbeats so that SIGKILL will effectively clear the lock 144 | * #lock_and_cache_clear now clears lock too 145 | 146 | 1.1.0 / 2015-08-07 147 | 148 | * Breaking changes 149 | 150 | * Reduce default lock expiry to 1 day instead of weird 3 days 151 | 152 | * Enhancements 153 | 154 | * Added :max_lock_wait option inspired by @leandromoreira 155 | 156 | 1.0.3 / 2015-08-06 157 | 158 | * Enhancements 159 | 160 | * More granular debug output 161 | 162 | 1.0.2 / 2015-08-06 163 | 164 | * Bug fixes 165 | 166 | * Put LockAndCache.flush back 167 | 168 | 1.0.1 / 2015-08-06 169 | 170 | * Bug fixes 171 | 172 | * Return value properly if lock was acquired but cached value immediately found 173 | 174 | * Enhancements 175 | 176 | * Documentation 177 | 178 | 1.0.0 / 2015-08-05 179 | 180 | * Enhancements 181 | 182 | * Use Redis redlock http://redis.io/topics/distlock instead of Postgres advisory locks 183 | * No more dependency on ActiveRecord or Postgres! 184 | 185 | 0.1.2 / 2015-06-24 186 | 187 | * Enhancements 188 | 189 | * Add :expires option in seconds 190 | 191 | 0.1.1 / 2015-02-04 192 | 193 | * Enhancements 194 | 195 | * Clear individual cached things with #lock_and_cache_clear 196 | 197 | 0.1.0 / 2015-01-22 198 | 199 | * Breaking changes 200 | 201 | * Redis only 202 | * Now you use it inside methods (like Rails.cache.fetch) instead of outside (like cache_method) 203 | 204 | * Enhancements 205 | 206 | * Way simpler, no dependency on CacheMethod 207 | 208 | 0.0.5 / 2014-12-12 209 | 210 | * Enhancements 211 | 212 | * ENV['LOCK_AND_CACHE_DEBUG'] == 'true' debug output to $stderr 213 | 214 | 0.0.4 / 2014-12-12 215 | 216 | * Bug fixes 217 | 218 | * Pass arguments while caching method results 219 | 220 | 0.0.3 / 2014-12-12 221 | 222 | * Enhancements 223 | 224 | * Save a trip to the database if something is already cached 225 | 226 | 0.0.2 / 2014-12-11 227 | 228 | * Bug fixes 229 | 230 | * Gem name is activerecord 231 | 232 | 0.0.1 / 2014-12-11 233 | 234 | initial release! 235 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in lock_and_cache.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Seamus Abshere 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LockAndCache 2 | 3 | [](https://travis-ci.org/seamusabshere/lock_and_cache) 4 | [](https://codeclimate.com/github/seamusabshere/lock_and_cache) 5 | [](http://badge.fury.io/rb/lock_and_cache) 6 | [](https://hakiri.io/github/seamusabshere/lock_and_cache/master) 7 | [](http://inch-ci.org/github/seamusabshere/lock_and_cache) 8 | 9 | Lock and cache using redis! 10 | 11 | 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? 12 | 13 | ## Quickstart 14 | 15 | ```ruby 16 | LockAndCache.lock_storage = Redis.new db: 3 17 | LockAndCache.cache_storage = Redis.new db: 4 18 | 19 | LockAndCache.lock_and_cache(:stock_price, {company: 'MSFT', date: '2015-05-05'}, expires: 10, nil_expires: 1) do 20 | # get yer stock quote 21 | # if 50 processes call this at the same time, only 1 will call the stock quote service 22 | # the other 49 will wait on the lock, then get the cached value 23 | # the value will expire in 10 seconds 24 | # but if the value you get back is nil, that will expire after 1 second 25 | end 26 | ``` 27 | 28 | ## Sponsor 29 | 30 |
31 | 32 | We use [`lock_and_cache`](https://github.com/seamusabshere/lock_and_cache) for [B2C customer intelligence at Faraday](https://www.faraday.io). 33 | 34 | ## TOC 35 | 36 | 37 | 38 | 39 | 40 | - [Theory](#theory) 41 | - [Practice](#practice) 42 | - [Setup](#setup) 43 | - [Locking](#locking) 44 | - [Caching](#caching) 45 | - [Standalone mode](#standalone-mode) 46 | - [Context mode](#context-mode) 47 | - [Special features](#special-features) 48 | - [Locking of course!](#locking-of-course) 49 | - [Heartbeat](#heartbeat) 50 | - [Context mode](#context-mode-1) 51 | - [nil_expires](#nil_expires) 52 | - [Tunables](#tunables) 53 | - [Few dependencies](#few-dependencies) 54 | - [Wishlist](#wishlist) 55 | - [Contributing](#contributing) 56 | - [Copyright](#copyright) 57 | 58 | 59 | 60 | ## Theory 61 | 62 | `lock_and_cache`... 63 | 64 | 1. returns cached value (if exists) 65 | 2. acquires a lock 66 | 3. returns cached value (just in case it was calculated while we were waiting for a lock) 67 | 4. calculates and caches the value 68 | 5. releases the lock 69 | 6. returns the value 70 | 71 | As you can see, most caching libraries only take care of (1) and (4) (well, and (5) of course). 72 | 73 | If an error is raised during calculation, that error is propagated to all waiters for 1 second. 74 | 75 | ## Practice 76 | 77 | ### Setup 78 | 79 | ```ruby 80 | LockAndCache.lock_storage = Redis.new db: 3 81 | LockAndCache.cache_storage = Redis.new db: 4 82 | ``` 83 | 84 | It will use this redis for both locking and storing cached values. 85 | 86 | ### Locking 87 | 88 | Just uses Redis naive locking with NX. 89 | 90 | A 32-second heartbeat is used that will clear the lock if a process is killed. 91 | 92 | ### Caching 93 | 94 | This gem is a simplified, improved version of https://github.com/seamusabshere/cache_method. In that library, you could only cache a method call. 95 | 96 | In this library, you have two options: providing the whole cache key every time (standalone) or letting the library pull information about its context. 97 | 98 | ```ruby 99 | # standalone example 100 | LockAndCache.lock_and_cache(:stock_price, {company: 'MSFT', date: '2015-05-05'}, expires: 10) do 101 | # ... 102 | end 103 | 104 | # context example 105 | def stock_price(date) 106 | lock_and_cache(date, expires: 10) do 107 | # ... 108 | end 109 | end 110 | def lock_and_cache_key 111 | company 112 | end 113 | ``` 114 | 115 | #### Standalone mode 116 | 117 | ```ruby 118 | LockAndCache.lock_and_cache(:stock_price, company: 'MSFT', date: '2015-05-05') do 119 | # get yer stock quote 120 | end 121 | ``` 122 | 123 | You probably want an expiry 124 | 125 | ```ruby 126 | LockAndCache.lock_and_cache(:stock_price, {company: 'MSFT', date: '2015-05-05'}, expires: 10) do 127 | # get yer stock quote 128 | end 129 | ``` 130 | 131 | Note how we separated options (`{expires: 10}`) from a hash that is part of the cache key (`{company: 'MSFT', date: '2015-05-05'}`). 132 | 133 | One other crazy thing: `nil_expires` - for when you want to check more often if the external stock price service returned nil 134 | 135 | ```ruby 136 | LockAndCache.lock_and_cache(:stock_price, {company: 'MSFT', date: '2015-05-05'}, expires: 10, nil_expires: 1) do 137 | # get yer stock quote 138 | end 139 | ``` 140 | 141 | Clear it with 142 | 143 | ```ruby 144 | LockAndCache.clear :stock_price, company: 'MSFT', date: '2015-05-05' 145 | ``` 146 | 147 | Check locks with 148 | 149 | ```ruby 150 | LockAndCache.locked? :stock_price, company: 'MSFT', date: '2015-05-05' 151 | ``` 152 | 153 | #### Context mode 154 | 155 | "Context mode" simply adds the class name, method name, and context key (the results of `#id` or `#lock_and_cache_key`) of the caller to the cache key. 156 | 157 | ```ruby 158 | class Stock 159 | include LockAndCache 160 | 161 | def initialize(company) 162 | [...] 163 | end 164 | 165 | def stock_price(date) 166 | lock_and_cache(date, expires: 10) do 167 | # the cache key will be StockQuote (the class) + get (the method name) + id (the instance identifier) + date (the arg you specified) 168 | end 169 | end 170 | 171 | def lock_and_cache_key # <---------- if you don't define this, it will try to call #id 172 | company 173 | end 174 | end 175 | ``` 176 | 177 | The cache key will be StockQuote (the class) + get (the method name) + id (the instance identifier) + date (the arg you specified). 178 | 179 | In other words, it auto-detects the class, method, context key ... and you add other args if you want. 180 | 181 | Clear it with 182 | 183 | ```ruby 184 | blog.lock_and_cache_clear(:get, date) 185 | ``` 186 | 187 | ## Special features 188 | 189 | ### Locking of course! 190 | 191 | 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? 192 | 193 | ### Heartbeat 194 | 195 | If the process holding the lock dies, we automatically remove the lock so somebody else can do it (using heartbeats). 196 | 197 | ### Context mode 198 | 199 | This pulls information about the context of a lock_and_cache block from the surrounding class, method, and object... so that you don't have to! 200 | 201 | Standalone mode is cool too, tho. 202 | 203 | ### nil_expires 204 | 205 | You can expire nil values with a different timeout (`nil_expires`) than other values (`expires`). 206 | 207 | ## Tunables 208 | 209 | * `LockAndCache.lock_storage=[redis]` 210 | * `LockAndCache.cache_storage=[redis]` 211 | * `ENV['LOCK_AND_CACHE_DEBUG']='true'` if you want some debugging output on `$stderr` 212 | 213 | ## Few dependencies 214 | 215 | * [activesupport](https://rubygems.org/gems/activesupport) (come on, it's the bomb) 216 | * [redis](https://github.com/redis/redis-rb) 217 | 218 | ## Known issues 219 | 220 | * In cache keys, can't distinguish {a: 1} from [[:a, 1]] 221 | 222 | ## Wishlist 223 | 224 | * Convert most tests to use standalone mode, which is easier to understand 225 | * Check options 226 | * Lengthen heartbeat so it's not so sensitive 227 | * Clarify which options are seconds or milliseconds 228 | 229 | ## Contributing 230 | 231 | 1. Fork it ( https://github.com/[my-github-username]/lock_and_cache/fork ) 232 | 2. Create your feature branch (`git checkout -b my-new-feature`) 233 | 3. Commit your changes (`git commit -am 'Add some feature'`) 234 | 4. Push to the branch (`git push origin my-new-feature`) 235 | 5. Create a new Pull Request 236 | 237 | # Copyright 238 | 239 | Copyright 2015 Seamus Abshere 240 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 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 10 | -------------------------------------------------------------------------------- /benchmarks/allowed_in_keys.rb: -------------------------------------------------------------------------------- 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 88 | -------------------------------------------------------------------------------- /lib/lock_and_cache.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require 'timeout' 3 | require 'digest/sha1' 4 | require 'base64' 5 | require 'redis' 6 | require 'active_support' 7 | require 'active_support/core_ext' 8 | 9 | require_relative 'lock_and_cache/version' 10 | require_relative 'lock_and_cache/action' 11 | require_relative 'lock_and_cache/key' 12 | 13 | # Lock and cache using redis! 14 | # 15 | # 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? 16 | module LockAndCache 17 | DEFAULT_MAX_LOCK_WAIT = 60 * 60 * 24 # 1 day in seconds 18 | 19 | DEFAULT_HEARTBEAT_EXPIRES = 32 # 32 seconds 20 | 21 | class TimeoutWaitingForLock < StandardError; end 22 | 23 | # @param redis_connection [Redis] A redis connection to be used for lock storage 24 | def LockAndCache.lock_storage=(redis_connection) 25 | raise "only redis for now" unless redis_connection.class.to_s == 'Redis' 26 | @lock_storage = redis_connection 27 | end 28 | 29 | # @return [Redis] The redis connection used for lock and cached value storage 30 | def LockAndCache.lock_storage 31 | @lock_storage 32 | end 33 | 34 | # @param redis_connection [Redis] A redis connection to be used for cached value storage 35 | def LockAndCache.cache_storage=(redis_connection) 36 | raise "only redis for now" unless redis_connection.class.to_s == 'Redis' 37 | @cache_storage = redis_connection 38 | end 39 | 40 | # @return [Redis] The redis connection used for cached value storage 41 | def LockAndCache.cache_storage 42 | @cache_storage 43 | end 44 | 45 | # @param logger [Logger] A logger. 46 | def LockAndCache.logger=(logger) 47 | @logger = logger 48 | end 49 | 50 | # @return [Logger] The logger. 51 | def LockAndCache.logger 52 | @logger 53 | end 54 | 55 | # Flush LockAndCache's cached value storage. 56 | # 57 | # @note If you are sharing a redis database, it will clear it... 58 | # 59 | # @note If you want to clear a single key, try `LockAndCache.clear(key)` (standalone mode) or `#lock_and_cache_clear(method_id, *key_parts)` in context mode. 60 | def LockAndCache.flush_cache 61 | cache_storage.flushdb 62 | end 63 | 64 | # Flush LockAndCache's lock storage. 65 | # 66 | # @note If you are sharing a redis database, it will clear it... 67 | # 68 | # @note If you want to clear a single key, try `LockAndCache.clear(key)` (standalone mode) or `#lock_and_cache_clear(method_id, *key_parts)` in context mode. 69 | def LockAndCache.flush_locks 70 | lock_storage.flushdb 71 | end 72 | 73 | # Lock and cache based on a key. 74 | # 75 | # @param key_parts [*] Parts that should be used to construct a key. 76 | # 77 | # @note Standalone mode. See also "context mode," where you mix LockAndCache into a class and call it from within its methods. 78 | # 79 | # @note A single hash arg is treated as a cache key, e.g. `LockAndCache.lock_and_cache(foo: :bar, expires: 100)` will be treated as a cache key of `foo: :bar, expires: 100` (which is probably wrong!!!). Try `LockAndCache.lock_and_cache({ foo: :bar }, expires: 100)` instead. This is the opposite of context mode. 80 | def LockAndCache.lock_and_cache(*key_parts_and_options, &blk) 81 | options = (key_parts_and_options.last.is_a?(Hash) && key_parts_and_options.length > 1) ? key_parts_and_options.pop : {} 82 | raise "need a cache key" unless key_parts_and_options.length > 0 83 | key = LockAndCache::Key.new key_parts_and_options 84 | action = LockAndCache::Action.new key, options, blk 85 | action.perform 86 | end 87 | 88 | # Clear a single key 89 | # 90 | # @note Standalone mode. See also "context mode," where you mix LockAndCache into a class and call it from within its methods. 91 | def LockAndCache.clear(*key_parts) 92 | key = LockAndCache::Key.new key_parts 93 | key.clear 94 | end 95 | 96 | # Check if a key is locked 97 | # 98 | # @note Standalone mode. See also "context mode," where you mix LockAndCache into a class and call it from within its methods. 99 | def LockAndCache.locked?(*key_parts) 100 | key = LockAndCache::Key.new key_parts 101 | key.locked? 102 | end 103 | 104 | # Check if a key is cached already 105 | # 106 | # @note Standalone mode. See also "context mode," where you mix LockAndCache into a class and call it from within its methods. 107 | def LockAndCache.cached?(*key_parts) 108 | key = LockAndCache::Key.new key_parts 109 | key.cached? 110 | end 111 | 112 | # @param seconds [Numeric] Maximum wait time to get a lock 113 | # 114 | # @note Can be overridden by putting `max_lock_wait:` in your call to `#lock_and_cache` 115 | def LockAndCache.max_lock_wait=(seconds) 116 | @max_lock_wait = seconds.to_f 117 | end 118 | 119 | # @private 120 | def LockAndCache.max_lock_wait 121 | @max_lock_wait || DEFAULT_MAX_LOCK_WAIT 122 | end 123 | 124 | # @param seconds [Numeric] How often a process has to heartbeat in order to keep a lock 125 | # 126 | # @note Can be overridden by putting `heartbeat_expires:` in your call to `#lock_and_cache` 127 | def LockAndCache.heartbeat_expires=(seconds) 128 | memo = seconds.to_f 129 | raise "heartbeat_expires must be greater than 2 seconds" unless memo >= 2 130 | @heartbeat_expires = memo 131 | end 132 | 133 | # @private 134 | def LockAndCache.heartbeat_expires 135 | @heartbeat_expires || DEFAULT_HEARTBEAT_EXPIRES 136 | end 137 | 138 | # Check if a method is locked on an object. 139 | # 140 | # @note Subject mode - this is expected to be called on an object whose class has LockAndCache mixed in. See also standalone mode. 141 | def lock_and_cache_locked?(method_id, *key_parts) 142 | key = LockAndCache::Key.new key_parts, context: self, method_id: method_id 143 | key.locked? 144 | end 145 | 146 | # Clear a lock and cache given exactly the method and exactly the same arguments 147 | # 148 | # @note Subject mode - this is expected to be called on an object whose class has LockAndCache mixed in. See also standalone mode. 149 | def lock_and_cache_clear(method_id, *key_parts) 150 | key = LockAndCache::Key.new key_parts, context: self, method_id: method_id 151 | key.clear 152 | end 153 | 154 | # Lock and cache a method given key parts. 155 | # 156 | # 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. 157 | # 158 | # @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. 159 | # 160 | # @return The cached value (possibly newly calculated). 161 | # 162 | # @note Subject mode - this is expected to be called on an object whose class has LockAndCache mixed in. See also standalone mode. 163 | # 164 | # @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. 165 | def lock_and_cache(*key_parts_and_options, &blk) 166 | options = key_parts_and_options.last.is_a?(Hash) ? key_parts_and_options.pop : {} 167 | key = LockAndCache::Key.new key_parts_and_options, context: self, caller: caller 168 | action = LockAndCache::Action.new key, options, blk 169 | action.perform 170 | end 171 | end 172 | 173 | logger = Logger.new $stderr 174 | logger.level = (ENV['LOCK_AND_CACHE_DEBUG'] == 'true') ? Logger::DEBUG : Logger::INFO 175 | LockAndCache.logger = logger 176 | -------------------------------------------------------------------------------- /lib/lock_and_cache/action.rb: -------------------------------------------------------------------------------- 1 | module LockAndCache 2 | # @private 3 | class Action 4 | ERROR_MAGIC_KEY = :lock_and_cache_error 5 | 6 | attr_reader :key 7 | attr_reader :options 8 | attr_reader :blk 9 | 10 | def initialize(key, options, blk) 11 | raise "need a block" unless blk 12 | @key = key 13 | @options = options.stringify_keys 14 | @blk = blk 15 | end 16 | 17 | def expires 18 | return @expires if defined?(@expires) 19 | @expires = options.has_key?('expires') ? options['expires'].to_f.round : nil 20 | end 21 | 22 | def nil_expires 23 | return @nil_expires if defined?(@nil_expires) 24 | @nil_expires = options.has_key?('nil_expires') ? options['nil_expires'].to_f.round : nil 25 | end 26 | 27 | def digest 28 | @digest ||= key.digest 29 | end 30 | 31 | def lock_digest 32 | @lock_digest ||= key.lock_digest 33 | end 34 | 35 | def lock_storage 36 | @lock_storage ||= LockAndCache.lock_storage or raise("must set LockAndCache.lock_storage=[Redis]") 37 | end 38 | 39 | def cache_storage 40 | @cache_storage ||= LockAndCache.cache_storage or raise("must set LockAndCache.cache_storage=[Redis]") 41 | end 42 | 43 | def load_existing(existing) 44 | v = ::Marshal.load(existing) 45 | if v.is_a?(::Hash) and (founderr = v[ERROR_MAGIC_KEY]) 46 | raise "Another LockAndCache process raised #{founderr}" 47 | else 48 | v 49 | end 50 | end 51 | 52 | def perform 53 | max_lock_wait = options.fetch 'max_lock_wait', LockAndCache.max_lock_wait 54 | heartbeat_expires = options.fetch('heartbeat_expires', LockAndCache.heartbeat_expires).to_f.ceil 55 | raise "heartbeat_expires must be >= 2 seconds" unless heartbeat_expires >= 2 56 | heartbeat_frequency = (heartbeat_expires / 2).ceil 57 | LockAndCache.logger.debug { "[lock_and_cache] A1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } 58 | if cache_storage.exists?(digest) and (existing = cache_storage.get(digest)).is_a?(String) 59 | return load_existing(existing) 60 | end 61 | LockAndCache.logger.debug { "[lock_and_cache] B1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } 62 | retval = nil 63 | lock_secret = SecureRandom.hex 16 64 | acquired = false 65 | begin 66 | Timeout.timeout(max_lock_wait, TimeoutWaitingForLock) do 67 | until lock_storage.set(lock_digest, lock_secret, nx: true, ex: heartbeat_expires) 68 | LockAndCache.logger.debug { "[lock_and_cache] C1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } 69 | sleep rand 70 | end 71 | acquired = true 72 | end 73 | LockAndCache.logger.debug { "[lock_and_cache] D1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } 74 | if cache_storage.exists?(digest) and (existing = cache_storage.get(digest)).is_a?(String) 75 | LockAndCache.logger.debug { "[lock_and_cache] E1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } 76 | retval = load_existing existing 77 | end 78 | unless retval 79 | LockAndCache.logger.debug { "[lock_and_cache] F1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } 80 | done = false 81 | begin 82 | lock_extender = Thread.new do 83 | loop do 84 | LockAndCache.logger.debug { "[lock_and_cache] heartbeat1 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } 85 | break if done 86 | sleep heartbeat_frequency 87 | break if done 88 | LockAndCache.logger.debug { "[lock_and_cache] heartbeat2 #{key.debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } 89 | # FIXME use lua to check the value 90 | raise "unexpectedly lost lock for #{key.debug}" unless lock_storage.get(lock_digest) == lock_secret 91 | lock_storage.set lock_digest, lock_secret, xx: true, ex: heartbeat_expires 92 | end 93 | end 94 | begin 95 | retval = blk.call 96 | retval.nil? ? set_nil : set_non_nil(retval) 97 | rescue 98 | set_error $! 99 | raise 100 | end 101 | ensure 102 | done = true 103 | lock_extender.join if lock_extender.status.nil? 104 | end 105 | end 106 | ensure 107 | lock_storage.del lock_digest if acquired 108 | end 109 | retval 110 | end 111 | 112 | def set_error(exception) 113 | cache_storage.set digest, ::Marshal.dump(ERROR_MAGIC_KEY => exception.message), ex: 1 114 | end 115 | 116 | NIL = Marshal.dump nil 117 | def set_nil 118 | if nil_expires 119 | cache_storage.set digest, NIL, ex: nil_expires 120 | elsif expires 121 | cache_storage.set digest, NIL, ex: expires 122 | else 123 | cache_storage.set digest, NIL 124 | end 125 | end 126 | 127 | def set_non_nil(retval) 128 | raise "expected not null #{retval.inspect}" if retval.nil? 129 | if expires 130 | cache_storage.set digest, ::Marshal.dump(retval), ex: expires 131 | else 132 | cache_storage.set digest, ::Marshal.dump(retval) 133 | end 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /lib/lock_and_cache/key.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | 3 | module LockAndCache 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 ::Marshal.dump(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 | LockAndCache.lock_storage.exists? lock_digest 102 | end 103 | 104 | def cached? 105 | LockAndCache.cache_storage.exists? digest 106 | end 107 | 108 | def clear 109 | LockAndCache.logger.debug { "[lock_and_cache] clear #{debug} #{Base64.encode64(digest).strip} #{Digest::SHA1.hexdigest digest}" } 110 | LockAndCache.cache_storage.del digest 111 | LockAndCache.lock_storage.del lock_digest 112 | end 113 | 114 | alias debug key 115 | 116 | def context_id 117 | return @context_id if defined?(@context_id) 118 | @context_id = if context.class == ::Class 119 | nil 120 | else 121 | Key.extract_obj_id context 122 | end 123 | end 124 | 125 | def class_name 126 | @class_name ||= Key.extract_class_name context 127 | end 128 | 129 | # An array of the parts we use for the key 130 | def parts 131 | @parts ||= Key.extract_obj_id @_parts 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/lock_and_cache/version.rb: -------------------------------------------------------------------------------- 1 | module LockAndCache 2 | VERSION = '6.0.1' 3 | end 4 | -------------------------------------------------------------------------------- /lock_and_cache.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'lock_and_cache/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "lock_and_cache" 8 | spec.version = LockAndCache::VERSION 9 | spec.authors = ["Seamus Abshere"] 10 | spec.email = ["seamus@abshere.net"] 11 | spec.summary = %q{Lock and cache methods.} 12 | spec.description = %q{Lock and cache methods, in case things should only be calculated once across processes.} 13 | spec.homepage = "https://github.com/seamusabshere/lock_and_cache" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_runtime_dependency 'activesupport' 22 | spec.add_runtime_dependency 'redis' 23 | 24 | spec.add_development_dependency 'pry' 25 | spec.add_development_dependency 'bundler', '~> 1.6' 26 | spec.add_development_dependency 'rake', '~> 10.0' 27 | spec.add_development_dependency 'rspec' 28 | spec.add_development_dependency 'thread' 29 | spec.add_development_dependency 'yard' 30 | spec.add_development_dependency 'redcarpet' 31 | end 32 | -------------------------------------------------------------------------------- /spec/lock_and_cache/key_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class KeyTestId 4 | def id 5 | 'id' 6 | end 7 | end 8 | class KeyTestLockAndCacheKey 9 | def lock_and_cache_key 10 | 'lock_and_cache_key' 11 | end 12 | end 13 | class KeyTest1 14 | def lock_and_cache_key 15 | KeyTestLockAndCacheKey.new 16 | end 17 | end 18 | describe LockAndCache::Key do 19 | describe 'parts' do 20 | it "has a known issue differentiating between {a: 1} and [[:a, 1]]" do 21 | expect(described_class.new(a: 1).send(:parts)).to eq(described_class.new([[:a, 1]]).send(:parts)) 22 | end 23 | 24 | now = Time.now 25 | today = Date.today 26 | { 27 | [1] => [1], 28 | ['you'] => ['you'], 29 | [['you']] => [['you']], 30 | [['you'], "person"] => [['you'], "person"], 31 | [['you'], {:silly=>:person}] => [['you'], [[:silly, :person]] ], 32 | [now] => [now.to_s], 33 | [[now]] => [[now.to_s]], 34 | [today] => [today.to_s], 35 | [[today]] => [[today.to_s]], 36 | { hi: 'you' } => [[:hi, 'you']], 37 | { hi: 123 } => [[:hi, 123]], 38 | { hi: 123.0 } => [[:hi, 123.0]], 39 | { hi: now } => [[:hi, now.to_s]], 40 | { hi: today } => [[:hi, today.to_s]], 41 | [KeyTestId.new] => ['id'], 42 | [[KeyTestId.new]] => [['id']], 43 | { a: KeyTestId.new } => [[:a, "id"]], 44 | [{ a: KeyTestId.new }] => [[[:a, "id"]]], 45 | [[{ a: KeyTestId.new }]] => [[ [[:a, "id"]] ]], 46 | [[{ a: [ KeyTestId.new ] }]] => [[[[:a, ["id"]]]]], 47 | [[{ a: { b: KeyTestId.new } }]] => [[ [[ :a, [[:b, "id"]] ]] ]], 48 | [[{ a: { b: [ KeyTestId.new ] } }]] => [[ [[ :a, [[:b, ["id"]]] ]] ]], 49 | [KeyTestLockAndCacheKey.new] => ['lock_and_cache_key'], 50 | [[KeyTestLockAndCacheKey.new]] => [['lock_and_cache_key']], 51 | { a: KeyTestLockAndCacheKey.new } => [[:a, "lock_and_cache_key"]], 52 | [{ a: KeyTestLockAndCacheKey.new }] => [[[:a, "lock_and_cache_key"]]], 53 | [[{ a: KeyTestLockAndCacheKey.new }]] => [[ [[:a, "lock_and_cache_key"]] ]], 54 | [[{ a: [ KeyTestLockAndCacheKey.new ] }]] => [[[[:a, ["lock_and_cache_key"]]]]], 55 | [[{ a: { b: KeyTestLockAndCacheKey.new } }]] => [[ [[ :a, [[:b, "lock_and_cache_key"]] ]] ]], 56 | [[{ a: { b: [ KeyTestLockAndCacheKey.new ] } }]] => [[ [[ :a, [[:b, ["lock_and_cache_key"]]] ]] ]], 57 | 58 | [[{ a: { b: [ KeyTest1.new ] } }]] => [[ [[ :a, [[:b, ["lock_and_cache_key"]]] ]] ]], 59 | }.each do |i, o| 60 | it "turns #{i} into #{o}" do 61 | expect(described_class.new(i).send(:parts)).to eq(o) 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/lock_and_cache_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class Foo 4 | include LockAndCache 5 | 6 | def initialize(id) 7 | @id = id 8 | @count = 0 9 | @count_exp = 0 10 | @click_single_hash_arg_as_options = 0 11 | @click_last_hash_as_options = 0 12 | end 13 | 14 | def click 15 | lock_and_cache do 16 | @count += 1 17 | end 18 | end 19 | 20 | def cached_rand 21 | lock_and_cache do 22 | rand 23 | end 24 | end 25 | 26 | def click_null 27 | lock_and_cache do 28 | nil 29 | end 30 | end 31 | 32 | def click_exp 33 | lock_and_cache(expires: 1) do 34 | @count_exp += 1 35 | end 36 | end 37 | 38 | # foo will be treated as option, so this is cacheable 39 | def click_single_hash_arg_as_options 40 | lock_and_cache(foo: rand, expires: 1) do 41 | @click_single_hash_arg_as_options += 1 42 | end 43 | end 44 | 45 | # foo will be treated as part of cache key, so this is uncacheable 46 | def click_last_hash_as_options 47 | lock_and_cache({foo: rand}, expires: 1) do 48 | @click_last_hash_as_options += 1 49 | end 50 | end 51 | 52 | def lock_and_cache_key 53 | @id 54 | end 55 | end 56 | 57 | class FooId 58 | include LockAndCache 59 | def click 60 | lock_and_cache do 61 | nil 62 | end 63 | end 64 | def id 65 | @id ||= rand 66 | end 67 | end 68 | 69 | class FooClass 70 | class << self 71 | include LockAndCache 72 | def click 73 | lock_and_cache do 74 | nil 75 | end 76 | end 77 | def id 78 | raise "called id" 79 | end 80 | end 81 | end 82 | 83 | require 'set' 84 | $clicking = Set.new 85 | class Bar 86 | include LockAndCache 87 | 88 | def initialize(id) 89 | @id = id 90 | @count = 0 91 | @mutex = Mutex.new 92 | end 93 | 94 | def unsafe_click 95 | @mutex.synchronize do 96 | # puts "clicking bar #{@id} - #{$clicking.to_a} - #{$clicking.include?(@id)} - #{@id == $clicking.to_a[0]}" 97 | raise "somebody already clicking Bar #{@id}" if $clicking.include?(@id) 98 | $clicking << @id 99 | end 100 | sleep 1 101 | @count += 1 102 | $clicking.delete @id 103 | @count 104 | end 105 | 106 | def click 107 | lock_and_cache do 108 | unsafe_click 109 | end 110 | end 111 | 112 | def slow_click 113 | lock_and_cache do 114 | sleep 1 115 | end 116 | end 117 | 118 | def lock_and_cache_key 119 | @id 120 | end 121 | end 122 | 123 | class Sleeper 124 | include LockAndCache 125 | 126 | def initialize 127 | @id = SecureRandom.hex 128 | end 129 | 130 | def poke 131 | lock_and_cache heartbeat_expires: 2 do 132 | sleep 133 | end 134 | end 135 | 136 | def lock_and_cache_key 137 | @id 138 | end 139 | end 140 | 141 | describe LockAndCache do 142 | before do 143 | LockAndCache.flush_locks 144 | LockAndCache.flush_cache 145 | end 146 | 147 | it 'has a version number' do 148 | expect(LockAndCache::VERSION).not_to be nil 149 | end 150 | 151 | describe "caching" do 152 | let(:foo) { Foo.new(rand.to_s) } 153 | it "works" do 154 | expect(foo.click).to eq(1) 155 | expect(foo.click).to eq(1) 156 | end 157 | 158 | it "can be cleared" do 159 | expect(foo.click).to eq(1) 160 | foo.lock_and_cache_clear :click 161 | expect(foo.click).to eq(2) 162 | end 163 | 164 | it "can be expired" do 165 | expect(foo.click_exp).to eq(1) 166 | expect(foo.click_exp).to eq(1) 167 | sleep 1.5 168 | expect(foo.click_exp).to eq(2) 169 | end 170 | 171 | it "can cache null" do 172 | expect(foo.click_null).to eq(nil) 173 | expect(foo.click_null).to eq(nil) 174 | end 175 | 176 | it "treats single hash arg as options" do 177 | expect(foo.click_single_hash_arg_as_options).to eq(1) 178 | expect(foo.click_single_hash_arg_as_options).to eq(1) 179 | sleep 1.1 180 | expect(foo.click_single_hash_arg_as_options).to eq(2) 181 | end 182 | 183 | it "treats last hash as options" do 184 | expect(foo.click_last_hash_as_options).to eq(1) 185 | expect(foo.click_last_hash_as_options).to eq(2) # it's uncacheable to prove we're not using as part of options 186 | expect(foo.click_last_hash_as_options).to eq(3) 187 | end 188 | 189 | it "calls #lock_and_cache_key" do 190 | expect(foo).to receive(:lock_and_cache_key) 191 | foo.click 192 | end 193 | 194 | it "calls #lock_and_cache_key to differentiate" do 195 | a = Foo.new 1 196 | b = Foo.new 2 197 | expect(a.cached_rand).not_to eq(b.cached_rand) 198 | end 199 | end 200 | 201 | describe 'self-identification in context mode' do 202 | it "calls #id for non-class" do 203 | foo_id = FooId.new 204 | expect(foo_id).to receive(:id) 205 | foo_id.click 206 | end 207 | it "calls class name for non-class" do 208 | foo_id = FooId.new 209 | expect(FooId).to receive(:name) 210 | foo_id.click 211 | end 212 | it "uses class name for class" do 213 | expect(FooClass).to receive(:name) 214 | expect(FooClass).not_to receive(:id) 215 | FooClass.click 216 | end 217 | end 218 | 219 | describe "locking" do 220 | let(:bar) { Bar.new(rand.to_s) } 221 | 222 | it "it blows up normally (simple thread)" do 223 | a = Thread.new do 224 | bar.unsafe_click 225 | end 226 | b = Thread.new do 227 | bar.unsafe_click 228 | end 229 | expect do 230 | a.join 231 | b.join 232 | end.to raise_error(/somebody/) 233 | end 234 | 235 | it "it blows up (pre-existing thread pool, more reliable)" do 236 | pool = Thread.pool 2 237 | Thread::Pool.abort_on_exception = true 238 | expect do 239 | pool.process do 240 | bar.unsafe_click 241 | end 242 | pool.process do 243 | bar.unsafe_click 244 | end 245 | pool.shutdown 246 | end.to raise_error(/somebody/) 247 | end 248 | 249 | it "doesn't blow up if you lock it (simple thread)" do 250 | a = Thread.new do 251 | bar.click 252 | end 253 | b = Thread.new do 254 | bar.click 255 | end 256 | a.join 257 | b.join 258 | end 259 | 260 | it "doesn't blow up if you lock it (pre-existing thread pool, more reliable)" do 261 | pool = Thread.pool 2 262 | Thread::Pool.abort_on_exception = true 263 | pool.process do 264 | bar.click 265 | end 266 | pool.process do 267 | bar.click 268 | end 269 | pool.shutdown 270 | end 271 | 272 | it "can set a wait time" do 273 | pool = Thread.pool 2 274 | Thread::Pool.abort_on_exception = true 275 | begin 276 | old_max = LockAndCache.max_lock_wait 277 | LockAndCache.max_lock_wait = 0.5 278 | expect do 279 | pool.process do 280 | bar.slow_click 281 | end 282 | pool.process do 283 | bar.slow_click 284 | end 285 | pool.shutdown 286 | end.to raise_error(LockAndCache::TimeoutWaitingForLock) 287 | ensure 288 | LockAndCache.max_lock_wait = old_max 289 | end 290 | end 291 | 292 | it 'unlocks if a process dies' do 293 | child = nil 294 | begin 295 | sleeper = Sleeper.new 296 | child = fork do 297 | sleeper.poke 298 | end 299 | sleep 0.1 300 | expect(sleeper.lock_and_cache_locked?(:poke)).to eq(true) # the other process has it 301 | Process.kill 'KILL', child 302 | expect(sleeper.lock_and_cache_locked?(:poke)).to eq(true) # the other (dead) process still has it 303 | sleep 2 304 | expect(sleeper.lock_and_cache_locked?(:poke)).to eq(false) # but now it should be cleared because no heartbeat 305 | ensure 306 | Process.kill('KILL', child) rescue Errno::ESRCH 307 | end 308 | end 309 | 310 | it "pays attention to heartbeats" do 311 | child = nil 312 | begin 313 | sleeper = Sleeper.new 314 | child = fork do 315 | sleeper.poke 316 | end 317 | sleep 0.1 318 | expect(sleeper.lock_and_cache_locked?(:poke)).to eq(true) # the other process has it 319 | sleep 2 320 | expect(sleeper.lock_and_cache_locked?(:poke)).to eq(true) # the other process still has it 321 | sleep 2 322 | expect(sleeper.lock_and_cache_locked?(:poke)).to eq(true) # the other process still has it 323 | sleep 2 324 | expect(sleeper.lock_and_cache_locked?(:poke)).to eq(true) # the other process still has it 325 | ensure 326 | Process.kill('TERM', child) rescue Errno::ESRCH 327 | end 328 | end 329 | 330 | end 331 | 332 | describe 'standalone' do 333 | it 'works like you expect' do 334 | count = 0 335 | expect(LockAndCache.lock_and_cache('hello') { count += 1 }).to eq(1) 336 | expect(count).to eq(1) 337 | expect(LockAndCache.lock_and_cache('hello') { count += 1 }).to eq(1) 338 | expect(count).to eq(1) 339 | end 340 | 341 | it 'really caches' do 342 | expect(LockAndCache.lock_and_cache('hello') { :red }).to eq(:red) 343 | expect(LockAndCache.lock_and_cache('hello') { raise(Exception.new("stop")) }).to eq(:red) 344 | end 345 | 346 | it 'caches errors (briefly)' do 347 | count = 0 348 | expect { 349 | LockAndCache.lock_and_cache('hello') { count += 1; raise("stop") } 350 | }.to raise_error(/stop/) 351 | expect(count).to eq(1) 352 | expect { 353 | LockAndCache.lock_and_cache('hello') { count += 1; raise("no no not me") } 354 | }.to raise_error(/LockAndCache.*stop/) 355 | expect(count).to eq(1) 356 | sleep 1 357 | expect { 358 | LockAndCache.lock_and_cache('hello') { count += 1; raise("retrying") } 359 | }.to raise_error(/retrying/) 360 | expect(count).to eq(2) 361 | end 362 | 363 | it "can be queried for cached?" do 364 | expect(LockAndCache.cached?('hello')).to be_falsy 365 | LockAndCache.lock_and_cache('hello') { nil } 366 | expect(LockAndCache.cached?('hello')).to be_truthy 367 | end 368 | 369 | it 'allows expiry' do 370 | count = 0 371 | expect(LockAndCache.lock_and_cache('hello', expires: 1) { count += 1 }).to eq(1) 372 | expect(count).to eq(1) 373 | expect(LockAndCache.lock_and_cache('hello') { count += 1 }).to eq(1) 374 | expect(count).to eq(1) 375 | sleep 1.1 376 | expect(LockAndCache.lock_and_cache('hello') { count += 1 }).to eq(2) 377 | expect(count).to eq(2) 378 | end 379 | 380 | it "allows float expiry" do 381 | expect{LockAndCache.lock_and_cache('hello', expires: 1.5) {}}.not_to raise_error 382 | end 383 | 384 | it 'can be nested' do 385 | expect(LockAndCache.lock_and_cache('hello') do 386 | LockAndCache.lock_and_cache('world') do 387 | LockAndCache.lock_and_cache('privyet') do 388 | 123 389 | end 390 | end 391 | end).to eq(123) 392 | end 393 | 394 | it "requires a key" do 395 | expect do 396 | LockAndCache.lock_and_cache do 397 | raise "this won't happen" 398 | end 399 | end.to raise_error(/need/) 400 | end 401 | 402 | it 'allows checking locks' do 403 | expect(LockAndCache.locked?(:sleeper)).to be_falsey 404 | t = Thread.new do 405 | LockAndCache.lock_and_cache(:sleeper) { sleep 1 } 406 | end 407 | sleep 0.2 408 | expect(LockAndCache.locked?(:sleeper)).to be_truthy 409 | t.join 410 | end 411 | 412 | it 'allows clearing' do 413 | count = 0 414 | expect(LockAndCache.lock_and_cache('hello') { count += 1 }).to eq(1) 415 | expect(count).to eq(1) 416 | LockAndCache.clear('hello') 417 | expect(LockAndCache.lock_and_cache('hello') { count += 1 }).to eq(2) 418 | expect(count).to eq(2) 419 | end 420 | 421 | it 'allows clearing (complex keys)' do 422 | count = 0 423 | expect(LockAndCache.lock_and_cache('hello', {world: 1}, expires: 100) { count += 1 }).to eq(1) 424 | expect(count).to eq(1) 425 | LockAndCache.clear('hello', world: 1) 426 | expect(LockAndCache.lock_and_cache('hello', {world: 1}, expires: 100) { count += 1 }).to eq(2) 427 | expect(count).to eq(2) 428 | end 429 | 430 | it 'allows multi-part keys' do 431 | count = 0 432 | expect(LockAndCache.lock_and_cache(['hello', 1, { target: 'world' }]) { count += 1 }).to eq(1) 433 | expect(count).to eq(1) 434 | expect(LockAndCache.lock_and_cache(['hello', 1, { target: 'world' }]) { count += 1 }).to eq(1) 435 | expect(count).to eq(1) 436 | end 437 | 438 | it 'treats a single hash arg as a cache key (not as options)' do 439 | count = 0 440 | LockAndCache.lock_and_cache(hello: 'world', expires: 100) { count += 1 } 441 | expect(count).to eq(1) 442 | LockAndCache.lock_and_cache(hello: 'world', expires: 100) { count += 1 } 443 | expect(count).to eq(1) 444 | LockAndCache.lock_and_cache(hello: 'world', expires: 200) { count += 1 } # expires is being treated as part of cache key 445 | expect(count).to eq(2) 446 | end 447 | 448 | it "correctly identifies options hash" do 449 | count = 0 450 | LockAndCache.lock_and_cache({ hello: 'world' }, expires: 1, ignored: rand) { count += 1 } 451 | expect(count).to eq(1) 452 | LockAndCache.lock_and_cache({ hello: 'world' }, expires: 1, ignored: rand) { count += 1 } # expires is not being treated as part of cache key 453 | expect(count).to eq(1) 454 | sleep 1.1 455 | LockAndCache.lock_and_cache({ hello: 'world' }) { count += 1 } 456 | expect(count).to eq(2) 457 | end 458 | end 459 | 460 | describe "shorter expiry for null results" do 461 | it "optionally caches null for less time" do 462 | count = 0 463 | LockAndCache.lock_and_cache('hello', nil_expires: 1, expires: 2) { count += 1; nil } 464 | expect(count).to eq(1) 465 | LockAndCache.lock_and_cache('hello', nil_expires: 1, expires: 2) { count += 1; nil } 466 | expect(count).to eq(1) 467 | sleep 1.1 # this is enough to expire 468 | LockAndCache.lock_and_cache('hello', nil_expires: 1, expires: 2) { count += 1; nil } 469 | expect(count).to eq(2) 470 | end 471 | 472 | it "normally caches null for the same amount of time" do 473 | count = 0 474 | expect(LockAndCache.lock_and_cache('hello', expires: 1) { count += 1; nil }).to be_nil 475 | expect(count).to eq(1) 476 | expect(LockAndCache.lock_and_cache('hello', expires: 1) { count += 1; nil }).to be_nil 477 | expect(count).to eq(1) 478 | sleep 1.1 479 | expect(LockAndCache.lock_and_cache('hello', expires: 1) { count += 1; nil }).to be_nil 480 | expect(count).to eq(2) 481 | end 482 | 483 | it "caches non-null for normal time" do 484 | count = 0 485 | LockAndCache.lock_and_cache('hello', nil_expires: 1, expires: 2) { count += 1; true } 486 | expect(count).to eq(1) 487 | LockAndCache.lock_and_cache('hello', nil_expires: 1, expires: 2) { count += 1; true } 488 | expect(count).to eq(1) 489 | sleep 1.1 490 | LockAndCache.lock_and_cache('hello', nil_expires: 1, expires: 2) { count += 1; true } 491 | expect(count).to eq(1) 492 | sleep 1 493 | LockAndCache.lock_and_cache('hello', nil_expires: 1, expires: 2) { count += 1; true } 494 | expect(count).to eq(2) 495 | end 496 | end 497 | 498 | 499 | end 500 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'lock_and_cache' 3 | 4 | require 'timeout' 5 | 6 | require 'redis' 7 | LockAndCache.lock_storage = Redis.new db: 3 8 | LockAndCache.cache_storage = Redis.new db: 4 9 | 10 | require 'thread/pool' 11 | 12 | require 'pry' 13 | --------------------------------------------------------------------------------