atomic_cache 0.1.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +51 -0
- data/.ruby_version +1 -0
- data/.travis.yml +26 -0
- data/CODE_OF_CONDUCT.md +46 -0
- data/Gemfile +6 -0
- data/LICENSE +201 -0
- data/README.md +49 -0
- data/Rakefile +6 -0
- data/atomic_cache.gemspec +36 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/docs/ARCH.md +34 -0
- data/docs/INTERFACES.md +45 -0
- data/docs/MODEL_SETUP.md +31 -0
- data/docs/PROJECT_SETUP.md +68 -0
- data/docs/USAGE.md +106 -0
- data/docs/img/quick_retry_graph.png +0 -0
- data/lib/atomic_cache.rb +11 -0
- data/lib/atomic_cache/atomic_cache_client.rb +197 -0
- data/lib/atomic_cache/concerns/global_lmt_cache_concern.rb +111 -0
- data/lib/atomic_cache/default_config.rb +62 -0
- data/lib/atomic_cache/key/keyspace.rb +98 -0
- data/lib/atomic_cache/key/last_mod_time_key_manager.rb +95 -0
- data/lib/atomic_cache/storage/dalli.rb +46 -0
- data/lib/atomic_cache/storage/instance_memory.rb +37 -0
- data/lib/atomic_cache/storage/memory.rb +67 -0
- data/lib/atomic_cache/storage/shared_memory.rb +40 -0
- data/lib/atomic_cache/storage/store.rb +31 -0
- data/lib/atomic_cache/version.rb +5 -0
- metadata +185 -0
@@ -0,0 +1,111 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/core_ext/object'
|
4
|
+
require 'active_support/concern'
|
5
|
+
require_relative '../atomic_cache_client'
|
6
|
+
require_relative '../default_config'
|
7
|
+
|
8
|
+
module AtomicCache
|
9
|
+
|
10
|
+
# this concern provides a single LMT for the whole model
|
11
|
+
module GlobalLMTCacheConcern
|
12
|
+
extend ActiveSupport::Concern
|
13
|
+
|
14
|
+
ATOMIC_CACHE_CONCERN_MUTEX = Mutex.new
|
15
|
+
|
16
|
+
class_methods do
|
17
|
+
|
18
|
+
def AtomicCache
|
19
|
+
init_atomic_cache
|
20
|
+
@atomic_cache
|
21
|
+
end
|
22
|
+
|
23
|
+
def cache_version(version)
|
24
|
+
ATOMIC_CACHE_CONCERN_MUTEX.synchronize do
|
25
|
+
@atomic_cache_version = version
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def cache_class(kls)
|
30
|
+
ATOMIC_CACHE_CONCERN_MUTEX.synchronize do
|
31
|
+
@atomic_cache_class = kls
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def cache_key_storage(storage)
|
36
|
+
ATOMIC_CACHE_CONCERN_MUTEX.synchronize do
|
37
|
+
@atomic_cache_key_storage = storage
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def cache_value_storage(storage)
|
42
|
+
ATOMIC_CACHE_CONCERN_MUTEX.synchronize do
|
43
|
+
@atomic_cache_value_storage = storage
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def default_cache_class
|
48
|
+
self.to_s.downcase
|
49
|
+
end
|
50
|
+
|
51
|
+
def cache_keyspace(*ks)
|
52
|
+
init_atomic_cache
|
53
|
+
@default_cache_keyspace.child(ks)
|
54
|
+
end
|
55
|
+
|
56
|
+
def expire_cache(at=Time.now)
|
57
|
+
init_atomic_cache
|
58
|
+
@timestamp_manager.last_modified_time = at
|
59
|
+
end
|
60
|
+
|
61
|
+
def last_modified_time
|
62
|
+
init_atomic_cache
|
63
|
+
@timestamp_manager.last_modified_time
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def init_atomic_cache
|
69
|
+
return if @atomic_cache.present?
|
70
|
+
ATOMIC_CACHE_CONCERN_MUTEX.synchronize do
|
71
|
+
cache_class = @atomic_cache_class || default_cache_class
|
72
|
+
prefix = [cache_class]
|
73
|
+
prefix.unshift(DefaultConfig.instance.namespace) if DefaultConfig.instance.namespace.present?
|
74
|
+
prefix.push("v#{@atomic_cache_version}") if @atomic_cache_version.present?
|
75
|
+
@default_cache_keyspace = Keyspace.new(namespace: prefix, root: cache_class)
|
76
|
+
|
77
|
+
@timestamp_manager = LastModTimeKeyManager.new(
|
78
|
+
keyspace: @default_cache_keyspace,
|
79
|
+
storage: @atomic_cache_key_storage || DefaultConfig.instance.key_storage,
|
80
|
+
timestamp_formatter: DefaultConfig.instance.timestamp_formatter,
|
81
|
+
)
|
82
|
+
|
83
|
+
@atomic_cache = AtomicCacheClient.new(
|
84
|
+
default_options: DefaultConfig.instance.default_options,
|
85
|
+
logger: DefaultConfig.instance.logger,
|
86
|
+
metrics: DefaultConfig.instance.metrics,
|
87
|
+
timestamp_manager: @timestamp_manager,
|
88
|
+
storage: @atomic_cache_value_storage || DefaultConfig.instance.cache_storage
|
89
|
+
)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def AtomicCache
|
95
|
+
self.class.AtomicCache
|
96
|
+
end
|
97
|
+
|
98
|
+
def cache_keyspace(ns)
|
99
|
+
self.class.cache_keyspace(ns)
|
100
|
+
end
|
101
|
+
|
102
|
+
def expire_cache(at=Time.now)
|
103
|
+
self.class.expire_cache(ns)
|
104
|
+
end
|
105
|
+
|
106
|
+
def last_modified_time
|
107
|
+
self.class.last_modified_time
|
108
|
+
end
|
109
|
+
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'singleton'
|
4
|
+
|
5
|
+
module AtomicCache
|
6
|
+
class DefaultConfig
|
7
|
+
include Singleton
|
8
|
+
|
9
|
+
CONFIG_MUTEX = Mutex.new
|
10
|
+
|
11
|
+
FLOAT_TIME_FORMATTER = Proc.new { |time| time.to_f }
|
12
|
+
TIMESTAMP_SEC_FORMATTER = Proc.new { |time| time.to_i }
|
13
|
+
TIMESTAMP_MS_FORMATTER = Proc.new { |time| (time.to_f * 1000).to_i }
|
14
|
+
ISO8601_FORMATTER = Proc.new { |time| time.iso8601 }
|
15
|
+
DEFAULT_TIME_FORMATTER = FLOAT_TIME_FORMATTER
|
16
|
+
DEFAULT_SEPARATOR = ':'
|
17
|
+
|
18
|
+
# [required] config
|
19
|
+
attr_accessor :key_storage # storage adapter instance for keys
|
20
|
+
attr_accessor :cache_storage # storage adapter instance for cached values
|
21
|
+
|
22
|
+
# [optional] config
|
23
|
+
attr_accessor :default_options # default options for all fetch requests
|
24
|
+
attr_accessor :namespace
|
25
|
+
attr_accessor :logger
|
26
|
+
attr_accessor :metrics
|
27
|
+
attr_accessor :timestamp_formatter
|
28
|
+
attr_accessor :separator
|
29
|
+
|
30
|
+
def initialize
|
31
|
+
reset
|
32
|
+
end
|
33
|
+
|
34
|
+
# Change all configured values back to default
|
35
|
+
def reset
|
36
|
+
@cache_client = nil
|
37
|
+
@default_options = {}
|
38
|
+
@namespace = nil
|
39
|
+
@logger = nil
|
40
|
+
@metrics = nil
|
41
|
+
@timestamp_formatter = DEFAULT_TIME_FORMATTER
|
42
|
+
@separator = DEFAULT_SEPARATOR
|
43
|
+
end
|
44
|
+
|
45
|
+
# Change all configured values back to default
|
46
|
+
def self.reset
|
47
|
+
self.instance.reset
|
48
|
+
end
|
49
|
+
|
50
|
+
# Quickly configure config singleton instance
|
51
|
+
# @yield mutate config
|
52
|
+
# @yieldparam [AtomicCache::DefaultConfig] config instance
|
53
|
+
def self.configure
|
54
|
+
if block_given?
|
55
|
+
manager = self.instance
|
56
|
+
CONFIG_MUTEX.synchronize do
|
57
|
+
yield(manager)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'murmurhash3'
|
4
|
+
require 'active_support/concern'
|
5
|
+
require 'active_support/core_ext/object'
|
6
|
+
require 'active_support/core_ext/hash'
|
7
|
+
require_relative '../default_config'
|
8
|
+
|
9
|
+
module AtomicCache
|
10
|
+
class Keyspace
|
11
|
+
|
12
|
+
DEFAULT_SEPARATOR = ':'
|
13
|
+
LOCK_VALUE = 1
|
14
|
+
PRIMITIVE_CLASSES = [Numeric, String, Symbol]
|
15
|
+
|
16
|
+
attr_reader :namespace, :root
|
17
|
+
|
18
|
+
# @param namespace [Object, Array<Object>] segment(s) with which to prefix the keyspace
|
19
|
+
# @param root [Object] logical 'root' or primary identifier for this keyspace
|
20
|
+
# @param separator [String] character or string to separate keyspace segments
|
21
|
+
# @param timestamp_formatter [Proc] function to turn Time -> String
|
22
|
+
def initialize(namespace:, root: nil, separator: nil, timestamp_formatter: nil)
|
23
|
+
@namespace = []
|
24
|
+
@namespace = normalize_segments(namespace) if namespace.present?
|
25
|
+
@separator = separator || DefaultConfig.instance.separator
|
26
|
+
@timestamp_formatter = timestamp_formatter || DefaultConfig.instance.timestamp_formatter
|
27
|
+
@root = root || namespace.last
|
28
|
+
end
|
29
|
+
|
30
|
+
# Create a new Keyspace, extending the namespace with the given segments and
|
31
|
+
# retaining this keyspace's root and separator
|
32
|
+
#
|
33
|
+
# @param namespace [Array<Object>] segments to concat onto this keyspace
|
34
|
+
# @return [AtomicCache::Keyspace] child keyspace
|
35
|
+
def child(namespace)
|
36
|
+
throw ArgumentError.new("Prefix must be an Array but was #{namespace.class}") unless namespace.is_a?(Array)
|
37
|
+
joined_namespacees = @namespace.clone.concat(namespace)
|
38
|
+
self.class.new(namespace: joined_namespacees)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Get a string key for this keyspace, optionally suffixed by the given value
|
42
|
+
#
|
43
|
+
# @param suffix [String, Symbol, Numeric] an optional suffix
|
44
|
+
# @return [String] a key
|
45
|
+
def key(suffix=nil)
|
46
|
+
flattened_key(suffix)
|
47
|
+
end
|
48
|
+
|
49
|
+
def last_mod_time_key
|
50
|
+
@last_mod_time_key ||= flattened_key('lmt')
|
51
|
+
end
|
52
|
+
|
53
|
+
def lock_key
|
54
|
+
@lock_key ||= flattened_key('lock')
|
55
|
+
end
|
56
|
+
|
57
|
+
# the key the last_known_key is stored at, thus key key.
|
58
|
+
def last_known_key_key
|
59
|
+
@last_known_key ||= flattened_key('lkk')
|
60
|
+
end
|
61
|
+
|
62
|
+
protected
|
63
|
+
|
64
|
+
def flattened_key(suffix=nil)
|
65
|
+
segments = @namespace
|
66
|
+
segments = @namespace.clone.push(suffix) if suffix.present?
|
67
|
+
segments.join(@separator)
|
68
|
+
end
|
69
|
+
|
70
|
+
def normalize_segments(segments)
|
71
|
+
if segments.is_a? Array
|
72
|
+
segments.map { |seg| expand_segment(seg) }
|
73
|
+
elsif sgs.nil?
|
74
|
+
[]
|
75
|
+
else
|
76
|
+
[expand_segment(segments)]
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def expand_segment(segment)
|
81
|
+
case segment
|
82
|
+
when Symbol, String, Numeric
|
83
|
+
segment
|
84
|
+
when DateTime, Time
|
85
|
+
@timestamp_formatter.call(segment)
|
86
|
+
else
|
87
|
+
hexhash(segment)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def hexhash(segment)
|
92
|
+
# if the segment is sortable, sort it before hashing so that collections
|
93
|
+
# come out with the same hash
|
94
|
+
segment = segment.sort if segment.respond_to?(:sort)
|
95
|
+
MurmurHash3::V128.str_hash(Marshal.dump(segment)).pack('L*').unpack('H*').first
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/core_ext/object'
|
4
|
+
require 'active_support/core_ext/hash'
|
5
|
+
|
6
|
+
module AtomicCache
|
7
|
+
class LastModTimeKeyManager
|
8
|
+
extend Forwardable
|
9
|
+
|
10
|
+
LOCK_VALUE = 1
|
11
|
+
|
12
|
+
# @param keyspace [AtomicCache::Keyspace] keyspace to store timestamp info at
|
13
|
+
# @param storage [Object] cache storage adapter
|
14
|
+
# @param timestamp_formatter [Proc] function to turn Time -> String
|
15
|
+
def initialize(keyspace: nil, storage: nil, timestamp_formatter: nil)
|
16
|
+
@timestamp_keyspace = keyspace
|
17
|
+
@storage = storage || DefaultConfig.instance.key_storage
|
18
|
+
@timestamp_formatter = timestamp_formatter || DefaultConfig.instance.timestamp_formatter
|
19
|
+
|
20
|
+
raise ArgumentError.new("`storage` required but none given") unless @storage.present?
|
21
|
+
raise ArgumentError.new("`root_keyspace` required but none given") unless @storage.present?
|
22
|
+
end
|
23
|
+
|
24
|
+
# get the key at the given keyspace, suffixed by the current timestamp
|
25
|
+
#
|
26
|
+
# @param keyspace [AtomicCache::Keyspace] keyspace to namespace this key with
|
27
|
+
# @return [String] a timestamped key
|
28
|
+
def current_key(keyspace)
|
29
|
+
keyspace.key(last_modified_time)
|
30
|
+
end
|
31
|
+
|
32
|
+
# get a key at the given keyspace, suffixed by given timestamp
|
33
|
+
#
|
34
|
+
# @param keyspace [AtomicCache::Keyspace] keyspace to namespace this key with
|
35
|
+
# @param timestamp [String, Numeric, Time] timestamp
|
36
|
+
# @return [String] a timestamped key
|
37
|
+
def next_key(keyspace, timestamp)
|
38
|
+
keyspace.key(self.format(timestamp))
|
39
|
+
end
|
40
|
+
|
41
|
+
# promote a key and timestamp after a successful re-generation of a cache keyspace
|
42
|
+
#
|
43
|
+
# @param keyspace [AtomicCache::Keyspace] keyspace to promote within
|
44
|
+
# @param last_known_key [String] a key with a known value to refer other processes to
|
45
|
+
# @param timestamp [String, Numeric, Time] the timestamp with which the last_known_key was updated at
|
46
|
+
def promote(keyspace, last_known_key:, timestamp:)
|
47
|
+
key = keyspace.last_known_key_key
|
48
|
+
@storage.set(key, last_known_key)
|
49
|
+
@storage.set(last_modified_time_key, self.format(timestamp))
|
50
|
+
end
|
51
|
+
|
52
|
+
# prevent other processes from modifying the given keyspace
|
53
|
+
#
|
54
|
+
# @param keyspace [AtomicCache::Keyspace] keyspace to lock
|
55
|
+
# @param ttl [Numeric] the duration in ms to lock (auto expires after duration is up)
|
56
|
+
# @param options [Hash] options to pass to the storage adapter
|
57
|
+
def lock(keyspace, ttl, options=nil)
|
58
|
+
@storage.add(keyspace.lock_key, LOCK_VALUE, ttl, options)
|
59
|
+
end
|
60
|
+
|
61
|
+
# remove existing lock to allow other processes to update keyspace
|
62
|
+
#
|
63
|
+
# @param keyspace [AtomicCache::Keyspace] keyspace to lock
|
64
|
+
def unlock(keyspace)
|
65
|
+
@storage.delete(keyspace.lock_key)
|
66
|
+
end
|
67
|
+
|
68
|
+
def last_known_key(keyspace)
|
69
|
+
@storage.read(keyspace.last_known_key_key)
|
70
|
+
end
|
71
|
+
|
72
|
+
def last_modified_time_key
|
73
|
+
@lmtk ||= @timestamp_keyspace.key('lmt')
|
74
|
+
end
|
75
|
+
|
76
|
+
def last_modified_time
|
77
|
+
@storage.read(last_modified_time_key)
|
78
|
+
end
|
79
|
+
|
80
|
+
def last_modified_time=(timestamp=Time.now)
|
81
|
+
@storage.set(last_modified_time_key, self.format(timestamp))
|
82
|
+
end
|
83
|
+
|
84
|
+
protected
|
85
|
+
|
86
|
+
def format(time)
|
87
|
+
if time.is_a?(Time)
|
88
|
+
@timestamp_formatter.call(time)
|
89
|
+
else
|
90
|
+
time
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
require_relative 'store'
|
5
|
+
|
6
|
+
module AtomicCache
|
7
|
+
module Storage
|
8
|
+
|
9
|
+
# A light wrapper over the Dalli client
|
10
|
+
class Dalli < Store
|
11
|
+
extend Forwardable
|
12
|
+
|
13
|
+
ADD_SUCCESS = 'STORED'
|
14
|
+
ADD_UNSUCCESSFUL = 'NOT_STORED'
|
15
|
+
ADD_EXISTS = 'EXISTS'
|
16
|
+
|
17
|
+
def_delegators :@dalli_client, :delete
|
18
|
+
|
19
|
+
def initialize(dalli_client)
|
20
|
+
@dalli_client = dalli_client
|
21
|
+
end
|
22
|
+
|
23
|
+
def add(key, new_value, ttl, user_options=nil)
|
24
|
+
opts = user_options&.clone || {}
|
25
|
+
opts[:raw] = true
|
26
|
+
|
27
|
+
# dalli expects time in seconds
|
28
|
+
# https://github.com/petergoldstein/dalli/blob/b8f4afe165fb3e07294c36fb1c63901b0ed9ce10/lib/dalli/client.rb#L27
|
29
|
+
# TODO: verify this unit is being treated correctly through the system
|
30
|
+
response = @dalli_client.add(key, new_value, ttl, opts)
|
31
|
+
response.start_with?(ADD_SUCCESS)
|
32
|
+
end
|
33
|
+
|
34
|
+
def read(key, user_options=nil)
|
35
|
+
user_options ||= {}
|
36
|
+
@dalli_client.read(key, user_options)
|
37
|
+
end
|
38
|
+
|
39
|
+
def set(key, value, user_options=nil)
|
40
|
+
user_options ||= {}
|
41
|
+
@dalli_client.set(key, value, user_options)
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'memory'
|
4
|
+
|
5
|
+
module AtomicCache
|
6
|
+
module Storage
|
7
|
+
|
8
|
+
# A storage adapter which keeps all values in memory, private to the instance
|
9
|
+
class InstanceMemory < Memory
|
10
|
+
|
11
|
+
def initialize(*args)
|
12
|
+
reset
|
13
|
+
super(*args)
|
14
|
+
end
|
15
|
+
|
16
|
+
def reset
|
17
|
+
@store = {}
|
18
|
+
end
|
19
|
+
|
20
|
+
def store
|
21
|
+
@store
|
22
|
+
end
|
23
|
+
|
24
|
+
def store_op(key, user_options=nil)
|
25
|
+
if !key.present?
|
26
|
+
desc = if key.nil? then 'Nil' else 'Empty' end
|
27
|
+
raise ArgumentError.new("#{desc} key given for storage operation") unless key.present?
|
28
|
+
end
|
29
|
+
|
30
|
+
normalized_key = key.to_sym
|
31
|
+
user_options ||= {}
|
32
|
+
yield(normalized_key, user_options)
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|