async_storage 0.0.1
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.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.travis.yml +6 -0
- data/Gemfile +13 -0
- data/Gemfile.lock +50 -0
- data/LICENSE.txt +21 -0
- data/README.md +94 -0
- data/Rakefile +8 -0
- data/async_storage.gemspec +32 -0
- data/bin/console +21 -0
- data/bin/setup +8 -0
- data/lib/async_storage.rb +35 -0
- data/lib/async_storage/bath_actions.rb +19 -0
- data/lib/async_storage/config.rb +84 -0
- data/lib/async_storage/json.rb +42 -0
- data/lib/async_storage/naming.rb +74 -0
- data/lib/async_storage/redis_pool.rb +30 -0
- data/lib/async_storage/repo.rb +196 -0
- data/lib/async_storage/util/strings.rb +18 -0
- data/lib/async_storage/version.rb +5 -0
- metadata +94 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: ffa57bbe53ad587cdd628ce556aaf679ed9b4ff02f5a9b8c4217be4b6ffa495d
|
4
|
+
data.tar.gz: 5557aa324800a20d599a94ddcbf21894f8ffa3ccc97672f7e0329d5283090d84
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: f6f33f4f1d4728c5933c7b312daa602a14eb2408f40168a8b5a3f490e8853e1b84464cc698fb6a3c6721f4bc0b72fb1dae0c4341bec2c481ce46f1acb5128718
|
7
|
+
data.tar.gz: 0d976a57d701639a1e46b3f9d236b3e983ac6a652e96e6ac481475f1293326251e0f4e1045ed945027ed6cd26f552fa9ec5793b2337fa6179ebc380f6fb42651
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
source 'https://rubygems.org'
|
4
|
+
|
5
|
+
# Specify your gem's dependencies in async_storage.gemspec
|
6
|
+
gemspec
|
7
|
+
|
8
|
+
gem 'rake', '~> 12.0'
|
9
|
+
gem 'rspec', '~> 3.0'
|
10
|
+
gem 'connection_pool', '~> 2.2.3'
|
11
|
+
gem 'pry', '~> 0.13.1'
|
12
|
+
gem 'awesome_print', '~> 1.8.0'
|
13
|
+
gem 'dotenv', '~> 2.7.6'
|
data/Gemfile.lock
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
async_storage (0.1.0)
|
5
|
+
multi_json (> 0.0.0)
|
6
|
+
redis (> 0.0.0)
|
7
|
+
|
8
|
+
GEM
|
9
|
+
remote: https://rubygems.org/
|
10
|
+
specs:
|
11
|
+
awesome_print (1.8.0)
|
12
|
+
coderay (1.1.3)
|
13
|
+
connection_pool (2.2.3)
|
14
|
+
diff-lcs (1.4.4)
|
15
|
+
dotenv (2.7.6)
|
16
|
+
method_source (1.0.0)
|
17
|
+
multi_json (1.15.0)
|
18
|
+
pry (0.13.1)
|
19
|
+
coderay (~> 1.1)
|
20
|
+
method_source (~> 1.0)
|
21
|
+
rake (12.3.3)
|
22
|
+
redis (4.2.2)
|
23
|
+
rspec (3.10.0)
|
24
|
+
rspec-core (~> 3.10.0)
|
25
|
+
rspec-expectations (~> 3.10.0)
|
26
|
+
rspec-mocks (~> 3.10.0)
|
27
|
+
rspec-core (3.10.0)
|
28
|
+
rspec-support (~> 3.10.0)
|
29
|
+
rspec-expectations (3.10.0)
|
30
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
31
|
+
rspec-support (~> 3.10.0)
|
32
|
+
rspec-mocks (3.10.0)
|
33
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
34
|
+
rspec-support (~> 3.10.0)
|
35
|
+
rspec-support (3.10.0)
|
36
|
+
|
37
|
+
PLATFORMS
|
38
|
+
ruby
|
39
|
+
|
40
|
+
DEPENDENCIES
|
41
|
+
async_storage!
|
42
|
+
awesome_print (~> 1.8.0)
|
43
|
+
connection_pool (~> 2.2.3)
|
44
|
+
dotenv (~> 2.7.6)
|
45
|
+
pry (~> 0.13.1)
|
46
|
+
rake (~> 12.0)
|
47
|
+
rspec (~> 3.0)
|
48
|
+
|
49
|
+
BUNDLED WITH
|
50
|
+
2.1.4
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2020 Marcos G. Zimmermann
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,94 @@
|
|
1
|
+
# AsyncStorage
|
2
|
+
|
3
|
+
Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/async_storage`. To experiment with that code, run `bin/console` for an interactive prompt.
|
4
|
+
|
5
|
+
TODO: Delete this and the text above, and describe your gem
|
6
|
+
|
7
|
+
## Installation
|
8
|
+
|
9
|
+
Add this line to your application's Gemfile:
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
gem 'async_storage'
|
13
|
+
```
|
14
|
+
|
15
|
+
And then execute:
|
16
|
+
|
17
|
+
$ bundle install
|
18
|
+
|
19
|
+
Or install it yourself as:
|
20
|
+
|
21
|
+
$ gem install async_storage
|
22
|
+
|
23
|
+
## Usage
|
24
|
+
|
25
|
+
** Idea about the API of this gem. Update accordingly before release it
|
26
|
+
|
27
|
+
Define global configurations
|
28
|
+
```ruby
|
29
|
+
# Configurations
|
30
|
+
AsyncStorage.configuration do |config|
|
31
|
+
config.redis = ConnectionPool.new(size: 10, timeout: 1) do
|
32
|
+
Redis.new(url: ENV.fetch('REDIS_URL', 'redis://0.0.0.0:6379'))
|
33
|
+
end
|
34
|
+
config.namespace = 'async_storage' # Default to 'async_storage'
|
35
|
+
config.expires_in = 3_600 # Default to nil
|
36
|
+
end
|
37
|
+
```
|
38
|
+
|
39
|
+
Useful methods to get, set and check data
|
40
|
+
|
41
|
+
```ruby
|
42
|
+
# app/resolvers/user_tweets_resolver.rb
|
43
|
+
class UserTweetsResolver
|
44
|
+
def call(user_id)
|
45
|
+
# Return JSON friendly object
|
46
|
+
{ 'user_id' => user_id, 'tweets' => Twitter::API.tweets(user_id).as_json }
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
AsyncStorage[UserTweetResolver].get('123') # Try to retrieve data. If does not exist enqueue a Background Job and return nil
|
51
|
+
AsyncStorage[UserTweetResolver].get!('123') # Try to retrieve data. If does not exist imediate call the Resolver and return data
|
52
|
+
AsyncStorage[UserTweetResolver, namespace: current_site.id].get(9) # Create a new Set using site id namepace
|
53
|
+
AsyncStorage[UserTweetResolver, expires_in: 60].get(9) # Overwrite global expires_in
|
54
|
+
```
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
class Site
|
58
|
+
# site.cache.user_tweets.get(@user.id)
|
59
|
+
def cache
|
60
|
+
Cache.new(self.slug)
|
61
|
+
end
|
62
|
+
|
63
|
+
class Cache
|
64
|
+
RESOLVERS = {
|
65
|
+
user_tweets: UserTweetsResolver,
|
66
|
+
}.freeze
|
67
|
+
|
68
|
+
RESOLVERS.each do |method, resolver|
|
69
|
+
define_method method do
|
70
|
+
AsyncStorage[resolver, namespace: @namespace]
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def initialize(namespace)
|
75
|
+
@namespace = namespace
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
```
|
80
|
+
|
81
|
+
## Development
|
82
|
+
|
83
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
84
|
+
|
85
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
86
|
+
|
87
|
+
## Contributing
|
88
|
+
|
89
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/marcosgz/async_storage.
|
90
|
+
|
91
|
+
|
92
|
+
## License
|
93
|
+
|
94
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'lib/async_storage/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = 'async_storage'
|
7
|
+
spec.version = AsyncStorage::VERSION
|
8
|
+
spec.authors = ['Marcos G. Zimmermann']
|
9
|
+
spec.email = ['mgzmaster@gmail.com']
|
10
|
+
|
11
|
+
spec.summary = 'Asynchronous key-value storage system'
|
12
|
+
spec.description = 'Asynchronous key-value storage system'
|
13
|
+
spec.homepage = 'https://github.com/marcosgz/async_storage'
|
14
|
+
spec.license = 'MIT'
|
15
|
+
spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0')
|
16
|
+
|
17
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
18
|
+
spec.metadata['source_code_uri'] = 'https://github.com/marcosgz/async_storage'
|
19
|
+
spec.metadata['changelog_uri'] = 'https://github.com/marcosgz/async_storage/blob/master/CHANGELOG.md'
|
20
|
+
|
21
|
+
# Specify which files should be added to the gem when it is released.
|
22
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
23
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
24
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
25
|
+
end
|
26
|
+
spec.bindir = 'exe'
|
27
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
28
|
+
spec.require_paths = ['lib']
|
29
|
+
|
30
|
+
spec.add_dependency 'redis', '> 0.0.0'
|
31
|
+
spec.add_dependency 'multi_json', '> 0.0.0'
|
32
|
+
end
|
data/bin/console
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'bundler/setup'
|
4
|
+
require 'dotenv/load'
|
5
|
+
require 'pry'
|
6
|
+
require 'awesome_print'
|
7
|
+
require 'async_storage'
|
8
|
+
require 'connection_pool'
|
9
|
+
|
10
|
+
AsyncStorage.configure do |config|
|
11
|
+
config.redis = ConnectionPool.new(size: 2, timeout: 0.5) do
|
12
|
+
Redis.new(url: ENV.fetch('REDIS_URL', 'redis://0.0.0.0:6379'))
|
13
|
+
end
|
14
|
+
config.expires_in = 120
|
15
|
+
end
|
16
|
+
|
17
|
+
class DummyResolver
|
18
|
+
def call(*values); values; end
|
19
|
+
end
|
20
|
+
|
21
|
+
Pry.start
|
data/bin/setup
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'async_storage/version'
|
4
|
+
require 'async_storage/config'
|
5
|
+
require 'async_storage/redis_pool'
|
6
|
+
require 'async_storage/json'
|
7
|
+
require 'async_storage/repo'
|
8
|
+
require 'async_storage/bath_actions'
|
9
|
+
|
10
|
+
module AsyncStorage
|
11
|
+
class Error < StandardError; end
|
12
|
+
class InvalidConfig < Error; end
|
13
|
+
|
14
|
+
module_function
|
15
|
+
|
16
|
+
def [](klass, **options)
|
17
|
+
Repo.new(klass, **options)
|
18
|
+
end
|
19
|
+
|
20
|
+
def config
|
21
|
+
@config ||= Config.new
|
22
|
+
end
|
23
|
+
|
24
|
+
def configure(&block)
|
25
|
+
return unless block_given?
|
26
|
+
|
27
|
+
config.instance_eval(&block)
|
28
|
+
@redis_pool = nil
|
29
|
+
config
|
30
|
+
end
|
31
|
+
|
32
|
+
def redis_pool
|
33
|
+
@redis_pool ||= RedisPool.new(config.redis)
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AsyncStorage
|
4
|
+
module_function
|
5
|
+
|
6
|
+
def flush_all
|
7
|
+
keys.inject(0) do |total, (key, cli)|
|
8
|
+
total + cli.del(key)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def keys
|
13
|
+
Enumerator.new do |yielder|
|
14
|
+
redis_pool.with do |cli|
|
15
|
+
cli.keys("#{config.namespace}:*").each { |key| yielder.yield(key, cli) }
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# frizen_string_literal: true
|
2
|
+
|
3
|
+
require 'redis'
|
4
|
+
require 'yaml'
|
5
|
+
|
6
|
+
module AsyncStorage
|
7
|
+
class Config
|
8
|
+
class << self
|
9
|
+
private
|
10
|
+
|
11
|
+
def attribute_accessor(field, validator: nil, normalizer: nil, default: nil)
|
12
|
+
normalizer ||= :"normalize_#{field}"
|
13
|
+
validator ||= :"validate_#{field}"
|
14
|
+
|
15
|
+
define_method(field) do
|
16
|
+
unless instance_variable_defined?(:"@#{field}")
|
17
|
+
fallback = config_from_yaml[field.to_s] || default
|
18
|
+
return if fallback.nil?
|
19
|
+
|
20
|
+
send(:"#{field}=", fallback.respond_to?(:call) ? fallback.call : fallback)
|
21
|
+
end
|
22
|
+
instance_variable_get(:"@#{field}")
|
23
|
+
end
|
24
|
+
|
25
|
+
define_method(:"#{field}=") do |value|
|
26
|
+
value = send(normalizer, field, value) if respond_to?(normalizer, true)
|
27
|
+
send(validator, field, value) if respond_to?(validator, true)
|
28
|
+
|
29
|
+
instance_variable_set(:"@#{field}", value)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Path to the YAML file with configs
|
35
|
+
attr_accessor :config_path
|
36
|
+
|
37
|
+
# Redis/ConnectionPool instance of a valid list of configs to build a new redis connection
|
38
|
+
attribute_accessor :redis, default: nil
|
39
|
+
|
40
|
+
# Namespace used to group group data stored by this package
|
41
|
+
attribute_accessor :namespace, default: 'async_storage'
|
42
|
+
|
43
|
+
# The global TTL for the redis storage. Keep nil if you don't want to expire objects.
|
44
|
+
attribute_accessor :expires_in, default: nil
|
45
|
+
|
46
|
+
def config_path=(value)
|
47
|
+
@config_from_yaml = nil
|
48
|
+
@config_path = value
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def normalize_namespace(_attribute, value)
|
54
|
+
return value.to_s if value.is_a?(Symbol)
|
55
|
+
|
56
|
+
value
|
57
|
+
end
|
58
|
+
|
59
|
+
def validate_namespace(attribute, value)
|
60
|
+
return if value.is_a?(String) && !value.empty?
|
61
|
+
|
62
|
+
raise InvalidConfig, format(
|
63
|
+
"The %<value>p for %<attr>s is not valid. It can't be blank",
|
64
|
+
value: value,
|
65
|
+
attr: attribute,
|
66
|
+
)
|
67
|
+
end
|
68
|
+
|
69
|
+
def normalize_expires_in(_attr, value)
|
70
|
+
ttl = value.to_i
|
71
|
+
return unless ttl > 0
|
72
|
+
|
73
|
+
ttl
|
74
|
+
end
|
75
|
+
|
76
|
+
def config_from_yaml
|
77
|
+
@config_from_yaml ||= begin
|
78
|
+
config_path ? YAML.load_file(config_path) : {}
|
79
|
+
rescue Errno::ENOENT, Errno::ESRCH
|
80
|
+
{}
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'multi_json'
|
4
|
+
|
5
|
+
module AsyncStorage
|
6
|
+
module JSON
|
7
|
+
module_function
|
8
|
+
|
9
|
+
# Parses JSON data.
|
10
|
+
#
|
11
|
+
# @param data [String] JSON data
|
12
|
+
# @param options [Hash] Options hash for `MultiJson.load`
|
13
|
+
# @return [Object] Parsed JSON
|
14
|
+
# @raise [MultiJson::ParseError] MultiJson error classes
|
15
|
+
def load(data, **options)
|
16
|
+
MultiJson.load(data, **options)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Generates JSON.
|
20
|
+
#
|
21
|
+
# @param object [Object] Object to convert to JSON
|
22
|
+
# @param options [Hash] Options hash for `MultiJson.dump` and additional options below
|
23
|
+
# @return [String] Generated JSON
|
24
|
+
# @raise [MultiJson::DecodeError] MultiJson error classes
|
25
|
+
def dump(object, **options)
|
26
|
+
object = as_json(object)
|
27
|
+
|
28
|
+
MultiJson.dump(object, **options)
|
29
|
+
end
|
30
|
+
|
31
|
+
def as_json(value)
|
32
|
+
case value
|
33
|
+
when Hash
|
34
|
+
value.transform_values { |val| as_json(val) }
|
35
|
+
when Enumerable
|
36
|
+
value.map { |val| as_json(val) }
|
37
|
+
else
|
38
|
+
value
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
require 'async_storage/util/strings'
|
5
|
+
require 'async_storage/json'
|
6
|
+
|
7
|
+
module AsyncStorage
|
8
|
+
class Naming
|
9
|
+
include Util::Strings
|
10
|
+
|
11
|
+
SET = {
|
12
|
+
head: 'h',
|
13
|
+
body: 'b',
|
14
|
+
none: '_null_',
|
15
|
+
}.freeze
|
16
|
+
|
17
|
+
attr_reader :class_name, :class_args
|
18
|
+
attr_accessor :prefix
|
19
|
+
|
20
|
+
def initialize(klass, *args)
|
21
|
+
@class_name = normalize_class(klass.name)
|
22
|
+
@class_args = normalize_args(args)
|
23
|
+
end
|
24
|
+
|
25
|
+
def head
|
26
|
+
"#{base}:#{SET[:head]}"
|
27
|
+
end
|
28
|
+
|
29
|
+
def body
|
30
|
+
"#{base}:#{SET[:body]}"
|
31
|
+
end
|
32
|
+
|
33
|
+
def to_s
|
34
|
+
format(
|
35
|
+
'#<AsyncStorage::Naming head=%<head>p body=%<body>p>',
|
36
|
+
head: head,
|
37
|
+
body: body,
|
38
|
+
)
|
39
|
+
end
|
40
|
+
|
41
|
+
def eql?(other)
|
42
|
+
return false unless other.is_a?(self.class)
|
43
|
+
|
44
|
+
[head, body] == [other.head, other.body]
|
45
|
+
end
|
46
|
+
alias == eql?
|
47
|
+
|
48
|
+
protected
|
49
|
+
|
50
|
+
def base
|
51
|
+
[ns, prefix, class_name, class_args].compact.join(':')
|
52
|
+
end
|
53
|
+
|
54
|
+
def normalize_class(name)
|
55
|
+
if name.nil? || name.empty?
|
56
|
+
raise ArgumentError, 'Anonymous class is not allowed'
|
57
|
+
end
|
58
|
+
|
59
|
+
underscore(name, ':')
|
60
|
+
end
|
61
|
+
|
62
|
+
def normalize_args(args)
|
63
|
+
return SET[:none] if args.empty?
|
64
|
+
|
65
|
+
Digest::SHA256.hexdigest(
|
66
|
+
AsyncStorage::JSON.dump(args, mode: :compat),
|
67
|
+
)
|
68
|
+
end
|
69
|
+
|
70
|
+
def ns
|
71
|
+
AsyncStorage.config.namespace
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'redis'
|
4
|
+
require 'forwardable'
|
5
|
+
|
6
|
+
module AsyncStorage
|
7
|
+
class RedisPool
|
8
|
+
extend Forwardable
|
9
|
+
def_delegator :@connection, :with
|
10
|
+
|
11
|
+
module ConnectionPoolLike
|
12
|
+
def with
|
13
|
+
yield self
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(connection)
|
18
|
+
if connection.respond_to?(:with)
|
19
|
+
@connection = connection
|
20
|
+
else
|
21
|
+
if connection.respond_to?(:client)
|
22
|
+
@connection = connection
|
23
|
+
else
|
24
|
+
@connection = ::Redis.new(*[connection].compact)
|
25
|
+
end
|
26
|
+
@connection.extend(ConnectionPoolLike)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,196 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'async_storage/naming'
|
4
|
+
|
5
|
+
module AsyncStorage
|
6
|
+
class Repo
|
7
|
+
CTRL = {
|
8
|
+
enqueued: '0',
|
9
|
+
executed: '1',
|
10
|
+
missing: nil,
|
11
|
+
}.freeze
|
12
|
+
|
13
|
+
attr_reader :resolver_class
|
14
|
+
|
15
|
+
# @param resolver_class [Class] A class with the call method
|
16
|
+
# @param options [Hash] A hash with config
|
17
|
+
# @option expires_in [Nil, Integer] Time in seconds
|
18
|
+
# @raise [ArgumentError] When the resolver_class does not respond with `call' instance method
|
19
|
+
def initialize(resolver_class, **options)
|
20
|
+
validate_resolver_class!(resolver_class)
|
21
|
+
@resolver_class = resolver_class
|
22
|
+
@options = options
|
23
|
+
end
|
24
|
+
|
25
|
+
# Store a fresh content into redis. This method is invoked by a background job.
|
26
|
+
#
|
27
|
+
# @param options [Hash] List of options to be passed along the initializer
|
28
|
+
# @option [Class] klass The resolver class
|
29
|
+
# @option [Array] args An array with the resolver arguments
|
30
|
+
# @return [Hash, NilClass] the result from class resolver
|
31
|
+
def self.ack(klass:, args:, **options)
|
32
|
+
new(klass, **options).refresh!(*args)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Async get value with a given key
|
36
|
+
#
|
37
|
+
# @return [Object, NilClass] Return both stale or fresh object. If does not exist async call the retriever and return nil
|
38
|
+
def get(*args)
|
39
|
+
connection(*args) do |redis, naming|
|
40
|
+
raw_head = redis.get(naming.head)
|
41
|
+
case raw_head
|
42
|
+
when CTRL[:executed], CTRL[:enqueued]
|
43
|
+
read(redis, naming.body) # Try to deliver stale content
|
44
|
+
when CTRL[:missing]
|
45
|
+
return update!(redis, naming, *args) unless async?
|
46
|
+
|
47
|
+
perform_async(*args) # Enqueue background job to resolve content
|
48
|
+
redis.set(naming.head, CTRL[:enqueued])
|
49
|
+
read(redis, naming.body) # Try to deliver stale content
|
50
|
+
else
|
51
|
+
raise AsyncStorage::Error, format('the key %<k>s have an invalid value. Only "1" or "0" values are expected. And we got %<v>p', v: raw_head, k: naming.head)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Sync get value with a given value
|
57
|
+
#
|
58
|
+
# @return [Object] Return the result from resolver
|
59
|
+
def get!(*args)
|
60
|
+
connection(*args) do |redis, naming|
|
61
|
+
raw_head = redis.get(naming.head)
|
62
|
+
case raw_head
|
63
|
+
when CTRL[:executed]
|
64
|
+
read(redis, naming.body) || begin
|
65
|
+
update!(redis, naming, *args) unless redis.exists?(naming.body)
|
66
|
+
end
|
67
|
+
when CTRL[:missing], CTRL[:enqueued]
|
68
|
+
update!(redis, naming, *args)
|
69
|
+
else
|
70
|
+
raise AsyncStorage::Error, format('the key %<k>s have an invalid value. Only "1" or "0" values are expected. And we got %<v>p', v: raw_head, k: naming.head)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Expire object the object with a given key. The stale object will not be removed
|
76
|
+
#
|
77
|
+
# @return [Boolean] True or False according to the object existence
|
78
|
+
def invalidate(*args)
|
79
|
+
connection(*args) do |redis, naming|
|
80
|
+
redis.del(naming.head) == 1
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
# Delete object with a given key.
|
85
|
+
#
|
86
|
+
# @return [Boolean] True or False according to the object existence
|
87
|
+
def invalidate!(*args)
|
88
|
+
connection(*args) do |redis, naming|
|
89
|
+
redis.multi do |cli|
|
90
|
+
cli.del(naming.body)
|
91
|
+
cli.del(naming.head)
|
92
|
+
end.include?(1)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Invalidate object with the given key and update content according to the strategy
|
97
|
+
#
|
98
|
+
# @return [Object, NilClass] Stale object or nil when it does not exist
|
99
|
+
def refresh(*args)
|
100
|
+
value = get(*args)
|
101
|
+
invalidate(*args)
|
102
|
+
value
|
103
|
+
end
|
104
|
+
|
105
|
+
# Fetch data from resolver and store it into redis
|
106
|
+
#
|
107
|
+
# @return [Object] Return the result from resolver
|
108
|
+
def refresh!(*args)
|
109
|
+
connection(*args) { |redis, naming| update!(redis, naming, *args) }
|
110
|
+
end
|
111
|
+
|
112
|
+
# Check if a fresh value exist.
|
113
|
+
#
|
114
|
+
# @return [Boolean] True or False according the object existence
|
115
|
+
def exist?(*args)
|
116
|
+
connection(*args) { |redis, naming| redis.exists?(naming.head) && redis.exists?(naming.body) }
|
117
|
+
end
|
118
|
+
|
119
|
+
# Check if object with a given key is stale
|
120
|
+
#
|
121
|
+
# @return [NilClass, Boolean] Return nil if the object does not exist or true/false according to the object freshness state
|
122
|
+
def stale?(*args)
|
123
|
+
connection(*args) { |redis, naming| redis.exists?(naming.body) && redis.ttl(naming.head) < 0 }
|
124
|
+
end
|
125
|
+
|
126
|
+
# Check if a fresh object exists into the storage
|
127
|
+
#
|
128
|
+
# @return [Boolean] true/false according to the object existence and freshness
|
129
|
+
def fresh?(*args)
|
130
|
+
connection(*args) { |redis, naming| redis.exists?(naming.body) && redis.ttl(naming.head) > 0 }
|
131
|
+
end
|
132
|
+
|
133
|
+
private
|
134
|
+
|
135
|
+
def async?
|
136
|
+
false
|
137
|
+
end
|
138
|
+
|
139
|
+
def perform_async(*args)
|
140
|
+
# @TODO Enqueue a real background job here. It's only working on sync mode
|
141
|
+
# redis.set(name.head, CTRL[:enqueued])
|
142
|
+
refresh!(*args)
|
143
|
+
end
|
144
|
+
|
145
|
+
def update!(redis, naming, *args)
|
146
|
+
payload = resolver_class.new.(*args)
|
147
|
+
|
148
|
+
json = AsyncStorage::JSON.dump(payload, mode: :compat)
|
149
|
+
naming = build_naming(*args)
|
150
|
+
redis.multi do |cli|
|
151
|
+
cli.set(naming.body, json)
|
152
|
+
cli.set(naming.head, CTRL[:executed])
|
153
|
+
cli.expire(naming.head, expires_in) if expires_in
|
154
|
+
end
|
155
|
+
AsyncStorage::JSON.load(json)
|
156
|
+
end
|
157
|
+
|
158
|
+
def read(redis, key)
|
159
|
+
return unless key
|
160
|
+
|
161
|
+
raw = redis.get(key)
|
162
|
+
return unless raw
|
163
|
+
|
164
|
+
AsyncStorage::JSON.load(raw)
|
165
|
+
end
|
166
|
+
|
167
|
+
def expires_in
|
168
|
+
@options[:expires_in] || AsyncStorage.config.expires_in
|
169
|
+
end
|
170
|
+
|
171
|
+
def connection(*args)
|
172
|
+
return unless block_given?
|
173
|
+
|
174
|
+
naming = build_naming(*args)
|
175
|
+
AsyncStorage.redis_pool.with { |redis| yield(redis, naming) }
|
176
|
+
end
|
177
|
+
|
178
|
+
def build_naming(*args)
|
179
|
+
naming = AsyncStorage::Naming.new(resolver_class, *args)
|
180
|
+
naming.prefix = @options[:namespace] if @options[:namespace]
|
181
|
+
naming
|
182
|
+
end
|
183
|
+
|
184
|
+
def validate_resolver_class!(klass)
|
185
|
+
unless klass.is_a?(Class)
|
186
|
+
raise(ArgumentError, format('%<c>p is not a valid resolver class.', c: klass))
|
187
|
+
end
|
188
|
+
|
189
|
+
unless klass.instance_methods.include?(:call)
|
190
|
+
raise(ArgumentError, format('%<c>p must have call instance method.', c: klass))
|
191
|
+
end
|
192
|
+
|
193
|
+
true
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AsyncStorage
|
4
|
+
module Util
|
5
|
+
module Strings
|
6
|
+
module_function
|
7
|
+
|
8
|
+
def underscore(string, module_sep = '/')
|
9
|
+
string
|
10
|
+
.gsub(/::/, module_sep)
|
11
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
12
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
13
|
+
.tr('-', '_')
|
14
|
+
.downcase
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
metadata
ADDED
@@ -0,0 +1,94 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: async_storage
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Marcos G. Zimmermann
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-11-09 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: redis
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.0.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.0.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: multi_json
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 0.0.0
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.0.0
|
41
|
+
description: Asynchronous key-value storage system
|
42
|
+
email:
|
43
|
+
- mgzmaster@gmail.com
|
44
|
+
executables: []
|
45
|
+
extensions: []
|
46
|
+
extra_rdoc_files: []
|
47
|
+
files:
|
48
|
+
- ".gitignore"
|
49
|
+
- ".rspec"
|
50
|
+
- ".travis.yml"
|
51
|
+
- Gemfile
|
52
|
+
- Gemfile.lock
|
53
|
+
- LICENSE.txt
|
54
|
+
- README.md
|
55
|
+
- Rakefile
|
56
|
+
- async_storage.gemspec
|
57
|
+
- bin/console
|
58
|
+
- bin/setup
|
59
|
+
- lib/async_storage.rb
|
60
|
+
- lib/async_storage/bath_actions.rb
|
61
|
+
- lib/async_storage/config.rb
|
62
|
+
- lib/async_storage/json.rb
|
63
|
+
- lib/async_storage/naming.rb
|
64
|
+
- lib/async_storage/redis_pool.rb
|
65
|
+
- lib/async_storage/repo.rb
|
66
|
+
- lib/async_storage/util/strings.rb
|
67
|
+
- lib/async_storage/version.rb
|
68
|
+
homepage: https://github.com/marcosgz/async_storage
|
69
|
+
licenses:
|
70
|
+
- MIT
|
71
|
+
metadata:
|
72
|
+
homepage_uri: https://github.com/marcosgz/async_storage
|
73
|
+
source_code_uri: https://github.com/marcosgz/async_storage
|
74
|
+
changelog_uri: https://github.com/marcosgz/async_storage/blob/master/CHANGELOG.md
|
75
|
+
post_install_message:
|
76
|
+
rdoc_options: []
|
77
|
+
require_paths:
|
78
|
+
- lib
|
79
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
80
|
+
requirements:
|
81
|
+
- - ">="
|
82
|
+
- !ruby/object:Gem::Version
|
83
|
+
version: 2.3.0
|
84
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
85
|
+
requirements:
|
86
|
+
- - ">="
|
87
|
+
- !ruby/object:Gem::Version
|
88
|
+
version: '0'
|
89
|
+
requirements: []
|
90
|
+
rubygems_version: 3.1.2
|
91
|
+
signing_key:
|
92
|
+
specification_version: 4
|
93
|
+
summary: Asynchronous key-value storage system
|
94
|
+
test_files: []
|