atomic_cache 0.1.0.rc1

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