gemstack-cache 0.1.0
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/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +24 -0
- data/lib/gemstack/cache/memory_store.rb +69 -0
- data/lib/gemstack/cache/null_store.rb +18 -0
- data/lib/gemstack/cache/redis_store.rb +70 -0
- data/lib/gemstack/cache/store.rb +89 -0
- data/lib/gemstack/cache.rb +61 -0
- metadata +66 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 90c16e71a468d47bb391b18e7251767e2823459c1476557cc08a35f4d08ef88c
|
|
4
|
+
data.tar.gz: 66e2ecf9b8e4ab685ecca3d61cb829c5017c5073a5e097300170226630c455bc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7b42b82a30074d9a61ffb52916cbe4cc5f4c0d5de5995d6f7b98adb0cbfa578a6c1ed6ee80e1cd76ba491bb542cbfd0f9c1439c54609b53ff56beffe9da9c97e
|
|
7
|
+
data.tar.gz: 47ec9b459862b8181421f300510a7f7bae112b0bead5c4275513fdabeab99877e4798d1ad3613ce963fbbcc12d3fb84075e3a35dbbd4478b904b7b397a0c503e
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shoaib Malik
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# gemstack-cache
|
|
2
|
+
|
|
3
|
+
GemStack cache: GemStack.cache.fetch with memory, null and Redis stores.
|
|
4
|
+
|
|
5
|
+
Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
|
|
6
|
+
applications. All GemStack gems are developed together in that repository and released with the same
|
|
7
|
+
version.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Installed with the `gemstack` gem; you rarely need to add it yourself.
|
|
12
|
+
|
|
13
|
+
## Documentation
|
|
14
|
+
|
|
15
|
+
- [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/caching.md)
|
|
16
|
+
- [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
|
|
17
|
+
[Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
|
|
18
|
+
|
|
19
|
+
Source, issues and pull requests: [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack)
|
|
20
|
+
(this gem lives in `gems/gemstack-cache`).
|
|
21
|
+
|
|
22
|
+
## License
|
|
23
|
+
|
|
24
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Cache
|
|
5
|
+
# A thread-safe, per-process LRU cache with expiry. Values are stored
|
|
6
|
+
# marshalled, so callers never share (and can't mutate) cached objects —
|
|
7
|
+
# the same semantics as the Redis store. With several Puma workers each
|
|
8
|
+
# has its own copy; use Redis to share a cache across processes.
|
|
9
|
+
class MemoryStore < Store
|
|
10
|
+
Entry = Struct.new(:data, :expires_at)
|
|
11
|
+
|
|
12
|
+
def initialize(max_entries: 10_000, **)
|
|
13
|
+
super(**)
|
|
14
|
+
@max_entries = max_entries
|
|
15
|
+
@entries = {}
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def size = @entries.size
|
|
20
|
+
def clear = @mutex.synchronize { @entries.clear }
|
|
21
|
+
|
|
22
|
+
def increment(key, by = 1, expires_in: default_expires_in)
|
|
23
|
+
normalized = normalize(key)
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
entry = live_entry(normalized)
|
|
26
|
+
value = (entry ? Integer(load(entry.data)) : 0) + by
|
|
27
|
+
store(normalized, value, entry ? entry.expires_at : expires_at(expires_in))
|
|
28
|
+
value
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def read_entry(key)
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
entry = live_entry(key) or return MISSING
|
|
37
|
+
@entries[key] = @entries.delete(key) # mark as most recently used
|
|
38
|
+
load(entry.data)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def write_entry(key, value, expires_in)
|
|
43
|
+
data = dump(value)
|
|
44
|
+
@mutex.synchronize { store_data(key, data, expires_at(expires_in)) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def delete_entry(key) = @mutex.synchronize { !@entries.delete(key).nil? }
|
|
48
|
+
|
|
49
|
+
# Callers hold the mutex.
|
|
50
|
+
def live_entry(key)
|
|
51
|
+
entry = @entries[key]
|
|
52
|
+
return nil unless entry
|
|
53
|
+
return entry unless entry.expires_at && entry.expires_at <= monotonic
|
|
54
|
+
|
|
55
|
+
@entries.delete(key)
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def store(key, value, expires_at) = store_data(key, dump(value), expires_at)
|
|
60
|
+
|
|
61
|
+
def store_data(key, data, expires_at)
|
|
62
|
+
@entries.delete(key)
|
|
63
|
+
@entries[key] = Entry.new(data, expires_at)
|
|
64
|
+
@entries.shift while @entries.size > @max_entries # Hash order = LRU order
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Cache
|
|
5
|
+
# Caches nothing: fetch always runs its block. The default in tests, so
|
|
6
|
+
# tests never depend on state cached by earlier tests.
|
|
7
|
+
class NullStore < Store
|
|
8
|
+
def clear = true
|
|
9
|
+
def increment(_key, by = 1, **) = by
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def read_entry(_key) = MISSING
|
|
14
|
+
def write_entry(_key, _value, _expires_in) = true
|
|
15
|
+
def delete_entry(_key) = false
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Cache
|
|
5
|
+
# A cache shared by every process, through Redis. Uses the `redis-client`
|
|
6
|
+
# gem (add `gem "redis-client"` to the Gemfile) with a connection pool
|
|
7
|
+
# sized to the server's threads, or any client object you pass that
|
|
8
|
+
# responds to #call(*command) (a redis-client pool or connection).
|
|
9
|
+
#
|
|
10
|
+
# config.cache.store = :redis # REDIS_URL
|
|
11
|
+
# config.cache.store = GemStack::Cache::RedisStore.new(client: my_pool, namespace: "shop")
|
|
12
|
+
class RedisStore < Store
|
|
13
|
+
attr_reader :client
|
|
14
|
+
|
|
15
|
+
def initialize(url: nil, client: nil, pool_size: 5, **)
|
|
16
|
+
super(**)
|
|
17
|
+
@client = client || build_client(url, pool_size)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def increment(key, by = 1, expires_in: default_expires_in)
|
|
21
|
+
normalized = normalize(key)
|
|
22
|
+
value = client.call("INCRBY", normalized, by)
|
|
23
|
+
client.call("PEXPIRE", normalized, (Float(expires_in) * 1000).round, "NX") if expires_in
|
|
24
|
+
value
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Deletes only keys in this store's namespace (never FLUSHDB).
|
|
28
|
+
def clear
|
|
29
|
+
raise Error, "RedisStore#clear needs a namespace (config.cache.namespace)" unless namespace
|
|
30
|
+
|
|
31
|
+
cursor = "0"
|
|
32
|
+
loop do
|
|
33
|
+
cursor, keys = client.call("SCAN", cursor, "MATCH", "#{namespace}:*", "COUNT", 500)
|
|
34
|
+
client.call("UNLINK", *keys) unless keys.empty?
|
|
35
|
+
break if cursor == "0"
|
|
36
|
+
end
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def read_entry(key)
|
|
43
|
+
data = client.call("GET", key)
|
|
44
|
+
return MISSING if data.nil?
|
|
45
|
+
|
|
46
|
+
# Counters are stored as plain integers so INCRBY works on them.
|
|
47
|
+
data.match?(/\A-?\d+\z/) ? Integer(data) : load(data.b)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def write_entry(key, value, expires_in)
|
|
51
|
+
data = value.is_a?(Integer) ? value.to_s : dump(value)
|
|
52
|
+
if expires_in
|
|
53
|
+
client.call("SET", key, data, "PX", (Float(expires_in) * 1000).round)
|
|
54
|
+
else
|
|
55
|
+
client.call("SET", key, data)
|
|
56
|
+
end
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def delete_entry(key) = client.call("DEL", key).positive?
|
|
61
|
+
|
|
62
|
+
def build_client(url, pool_size)
|
|
63
|
+
require "redis-client"
|
|
64
|
+
RedisClient.config(url: url).new_pool(size: pool_size, timeout: 1.0)
|
|
65
|
+
rescue LoadError
|
|
66
|
+
raise ConfigurationError, 'the Redis cache store needs `gem "redis-client"` in the Gemfile'
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module Cache
|
|
7
|
+
# The store interface. Subclasses implement read_entry / write_entry /
|
|
8
|
+
# delete_entry / clear (and may override increment for atomicity).
|
|
9
|
+
#
|
|
10
|
+
# Keys may be strings, symbols, arrays of parts, or objects with
|
|
11
|
+
# #cache_key (GemStack models have one); they are namespaced and hashed
|
|
12
|
+
# when longer than MAX_KEY bytes. Values must be Marshal-able.
|
|
13
|
+
class Store
|
|
14
|
+
MAX_KEY = 200
|
|
15
|
+
MISSING = Object.new.freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :namespace, :default_expires_in
|
|
18
|
+
|
|
19
|
+
def initialize(namespace: nil, default_expires_in: nil)
|
|
20
|
+
@namespace = namespace&.to_s
|
|
21
|
+
@default_expires_in = default_expires_in
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns the cached value, or computes it with the block, stores and
|
|
25
|
+
# returns it. nil results are cached too. force: true recomputes.
|
|
26
|
+
def fetch(key, expires_in: default_expires_in, force: false)
|
|
27
|
+
normalized = normalize(key)
|
|
28
|
+
unless force
|
|
29
|
+
value = read_entry(normalized)
|
|
30
|
+
return value unless value.equal?(MISSING)
|
|
31
|
+
end
|
|
32
|
+
raise ArgumentError, "fetch needs a block on a cache miss" unless block_given?
|
|
33
|
+
|
|
34
|
+
value = yield
|
|
35
|
+
write_entry(normalized, value, expires_in)
|
|
36
|
+
value
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def read(key)
|
|
40
|
+
value = read_entry(normalize(key))
|
|
41
|
+
value.equal?(MISSING) ? nil : value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def write(key, value, expires_in: default_expires_in)
|
|
45
|
+
write_entry(normalize(key), value, expires_in)
|
|
46
|
+
value
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def exist?(key) = !read_entry(normalize(key)).equal?(MISSING)
|
|
50
|
+
def delete(key) = delete_entry(normalize(key))
|
|
51
|
+
|
|
52
|
+
# Not atomic in the base class; stores override it where they can.
|
|
53
|
+
def increment(key, by = 1, expires_in: default_expires_in)
|
|
54
|
+
normalized = normalize(key)
|
|
55
|
+
current = read_entry(normalized)
|
|
56
|
+
value = (current.equal?(MISSING) ? 0 : Integer(current)) + by
|
|
57
|
+
write_entry(normalized, value, expires_in)
|
|
58
|
+
value
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def decrement(key, by = 1, **) = increment(key, -by, **)
|
|
62
|
+
|
|
63
|
+
def normalize(key)
|
|
64
|
+
raw = key_part(key)
|
|
65
|
+
full = namespace ? "#{namespace}:#{raw}" : raw
|
|
66
|
+
full.bytesize > MAX_KEY ? "#{full[0, 100]}:sha256:#{Digest::SHA256.hexdigest(full)}" : full
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def key_part(key)
|
|
72
|
+
case key
|
|
73
|
+
when Array then key.map { |part| key_part(part) }.join("/")
|
|
74
|
+
when String, Symbol, Numeric then key.to_s
|
|
75
|
+
else key.respond_to?(:cache_key) ? key.cache_key.to_s : key.to_s
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def dump(value) = Marshal.dump(value)
|
|
80
|
+
def load(data) = Marshal.load(data) # rubocop:disable Security/MarshalLoad -- only data this process wrote
|
|
81
|
+
|
|
82
|
+
def expires_at(expires_in)
|
|
83
|
+
expires_in ? monotonic + Float(expires_in) : nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "gemstack/core"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
# Application caching (DECISIONS D-035):
|
|
7
|
+
#
|
|
8
|
+
# GemStack.cache.fetch("product:#{id}", expires_in: 300) { Product.find(id) }
|
|
9
|
+
# GemStack.cache.fetch([:stats, Date.today]) { expensive_report } # array keys
|
|
10
|
+
# GemStack.cache.increment("signups")
|
|
11
|
+
# GemStack.cache.delete("product:#{id}")
|
|
12
|
+
#
|
|
13
|
+
# Stores are swappable (config.cache.store): :memory (default), :null
|
|
14
|
+
# (default in test), :redis, or any object implementing the Store interface.
|
|
15
|
+
# Redis is never required.
|
|
16
|
+
module Cache
|
|
17
|
+
class Config < Settings
|
|
18
|
+
setting :store, default: -> { GemStack.env.test? ? :null : :memory }
|
|
19
|
+
# Prefix for every key ("shop:product:1"). Required for Redis#clear.
|
|
20
|
+
setting :namespace, default: -> { GemStack.config.name }
|
|
21
|
+
# Seconds; nil = no expiry unless given per call.
|
|
22
|
+
setting :default_expires_in, default: nil
|
|
23
|
+
# MemoryStore: maximum entries per process before the least recently used are evicted.
|
|
24
|
+
setting :max_entries, default: 10_000
|
|
25
|
+
setting :redis_url, default: -> { ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
|
|
26
|
+
setting :redis_pool_size, default: -> { Integer(ENV.fetch("GEMSTACK_MAX_THREADS", 5)) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def build(setting = GemStack.config.cache.store, config: GemStack.config.cache)
|
|
31
|
+
options = { namespace: config.namespace, default_expires_in: config.default_expires_in }
|
|
32
|
+
case setting
|
|
33
|
+
when :memory, "memory" then MemoryStore.new(max_entries: config.max_entries, **options)
|
|
34
|
+
when :null, "null" then NullStore.new(**options)
|
|
35
|
+
when :redis, "redis"
|
|
36
|
+
RedisStore.new(url: config.redis_url, pool_size: config.redis_pool_size, **options)
|
|
37
|
+
when Store then setting
|
|
38
|
+
else
|
|
39
|
+
raise ConfigurationError, "unknown cache store #{setting.inspect}" unless setting.respond_to?(:fetch)
|
|
40
|
+
|
|
41
|
+
setting
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
def cache
|
|
49
|
+
@cache ||= Cache.build
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
attr_writer :cache
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
require_relative "cache/store"
|
|
57
|
+
require_relative "cache/memory_store"
|
|
58
|
+
require_relative "cache/null_store"
|
|
59
|
+
require_relative "cache/redis_store"
|
|
60
|
+
|
|
61
|
+
GemStack::Config.namespace(:cache, GemStack::Cache::Config)
|
metadata
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: gemstack-cache
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Shoaib Malik
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: gemstack-core
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - '='
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.1.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - '='
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.1.0
|
|
26
|
+
email:
|
|
27
|
+
- gemstack26@gmail.com
|
|
28
|
+
executables: []
|
|
29
|
+
extensions: []
|
|
30
|
+
extra_rdoc_files: []
|
|
31
|
+
files:
|
|
32
|
+
- CHANGELOG.md
|
|
33
|
+
- LICENSE.txt
|
|
34
|
+
- README.md
|
|
35
|
+
- lib/gemstack/cache.rb
|
|
36
|
+
- lib/gemstack/cache/memory_store.rb
|
|
37
|
+
- lib/gemstack/cache/null_store.rb
|
|
38
|
+
- lib/gemstack/cache/redis_store.rb
|
|
39
|
+
- lib/gemstack/cache/store.rb
|
|
40
|
+
homepage: https://github.com/gemstack-rb/gemstack
|
|
41
|
+
licenses:
|
|
42
|
+
- MIT
|
|
43
|
+
metadata:
|
|
44
|
+
rubygems_mfa_required: 'true'
|
|
45
|
+
source_code_uri: https://github.com/gemstack-rb/gemstack/tree/main/gems/gemstack-cache
|
|
46
|
+
changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/gems/gemstack-cache/CHANGELOG.md
|
|
47
|
+
bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
|
|
48
|
+
documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
|
|
49
|
+
rdoc_options: []
|
|
50
|
+
require_paths:
|
|
51
|
+
- lib
|
|
52
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '4.0'
|
|
57
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '0'
|
|
62
|
+
requirements: []
|
|
63
|
+
rubygems_version: 4.0.20
|
|
64
|
+
specification_version: 4
|
|
65
|
+
summary: 'GemStack cache: GemStack.cache.fetch with memory, null and Redis stores'
|
|
66
|
+
test_files: []
|