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 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
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
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: []