atomic_cache 0.1.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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