tina4ruby 3.13.23 → 3.13.26
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 +4 -4
- data/lib/tina4/cache_backends/base_backend.rb +58 -0
- data/lib/tina4/cache_backends/database_backend.rb +121 -0
- data/lib/tina4/cache_backends/file_backend.rb +140 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +103 -0
- data/lib/tina4/cache_backends/memory_backend.rb +81 -0
- data/lib/tina4/cache_backends/mongo_backend.rb +133 -0
- data/lib/tina4/cache_backends/redis_backend.rb +233 -0
- data/lib/tina4/cache_backends/valkey_backend.rb +16 -0
- data/lib/tina4/cache_backends.rb +91 -0
- data/lib/tina4/database.rb +95 -0
- data/lib/tina4/response_cache.rb +71 -243
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +1 -0
- metadata +25 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 37b3d1b2d32cd45ef6a97096e97bd5c6a39009ace98ead88dac0fdc89e786528
|
|
4
|
+
data.tar.gz: c02fc34b91be2c3dbad1f577b590be80d1eae9043803cd0fa2819ec465f47dce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6efda648bfaf41d5fba86200f2a3d063e186982cf1325721dcbc49660933592492526802e4c052850e710887dbefd10275001738a2fb149d3abb1bae620e9445
|
|
7
|
+
data.tar.gz: af138286dcc50de2e72defd69f535b9854ccbe5236b54c320a33b2a9e08487ff3ad557a90b6ae8486dd882c34c02d99d707b5abb8c344feb6a1e34b00a9d03d0
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
module CacheBackends
|
|
5
|
+
# Abstract cache backend (parity with Python _CacheBackend).
|
|
6
|
+
#
|
|
7
|
+
# A backend is a key/value store with TTL semantics and stats. Values are
|
|
8
|
+
# arbitrary JSON-serialisable objects; backends round-trip them through JSON
|
|
9
|
+
# for the network/file/db tiers so a value stored by one process can be read
|
|
10
|
+
# by another. The factory (Tina4::CacheBackends.create_backend) picks the
|
|
11
|
+
# right implementation from env vars and falls back to the file backend when
|
|
12
|
+
# a configured network/driver backend is unreachable.
|
|
13
|
+
class BaseBackend
|
|
14
|
+
# @param key [String]
|
|
15
|
+
# @return [Object, nil] cached value or nil on miss/expiry
|
|
16
|
+
def get(_key)
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param key [String]
|
|
21
|
+
# @param value [Object]
|
|
22
|
+
# @param ttl [Integer] seconds; <= 0 means no expiry
|
|
23
|
+
def set(_key, _value, _ttl)
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param key [String]
|
|
28
|
+
# @return [Boolean] true if the key existed
|
|
29
|
+
def delete(_key)
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Remove all entries owned by this cache.
|
|
34
|
+
def clear
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Hash] { hits:, misses:, size:, backend: }
|
|
39
|
+
def stats
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [String] backend name reported in stats
|
|
44
|
+
def name
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Whether this backend is actually usable (driver present + service
|
|
49
|
+
# reachable). Local backends are always available; network/driver backends
|
|
50
|
+
# override this so the factory can fall back to the file backend.
|
|
51
|
+
#
|
|
52
|
+
# @return [Boolean]
|
|
53
|
+
def available?
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "base_backend"
|
|
5
|
+
|
|
6
|
+
module Tina4
|
|
7
|
+
module CacheBackends
|
|
8
|
+
# Database backend (parity with Python _DatabaseBackend) — stores entries in
|
|
9
|
+
# a tina4_cache table in any Tina4-supported database. Zero extra
|
|
10
|
+
# infrastructure; reuses the Database layer. Its own connection has query
|
|
11
|
+
# caching disabled to avoid recursion (a cache lookup must not itself try to
|
|
12
|
+
# cache through this same backend).
|
|
13
|
+
class DatabaseBackend < BaseBackend
|
|
14
|
+
def initialize(url: nil, max_entries: 1000)
|
|
15
|
+
@max_entries = max_entries
|
|
16
|
+
@hits = 0
|
|
17
|
+
@misses = 0
|
|
18
|
+
@db = nil
|
|
19
|
+
@available = false
|
|
20
|
+
|
|
21
|
+
# The database backend reads TINA4_CACHE_URL (interpreted as a SQL URL),
|
|
22
|
+
# falling back to the app's own TINA4_DATABASE_URL — cache in the DB you
|
|
23
|
+
# already run, no extra connection var needed.
|
|
24
|
+
url ||= env_nonempty("TINA4_CACHE_URL") ||
|
|
25
|
+
env_nonempty("TINA4_DATABASE_URL") ||
|
|
26
|
+
"sqlite://data/tina4.db"
|
|
27
|
+
|
|
28
|
+
# The cache's own DB connection must not itself cache (no recursion).
|
|
29
|
+
prev_auto = ENV["TINA4_AUTO_CACHING"]
|
|
30
|
+
prev_db = ENV["TINA4_DB_CACHE"]
|
|
31
|
+
ENV["TINA4_AUTO_CACHING"] = "false"
|
|
32
|
+
ENV["TINA4_DB_CACHE"] = "false"
|
|
33
|
+
begin
|
|
34
|
+
@db = Tina4::Database.new(url)
|
|
35
|
+
@db.execute(
|
|
36
|
+
"CREATE TABLE IF NOT EXISTS tina4_cache " \
|
|
37
|
+
"(cache_key VARCHAR(255) PRIMARY KEY, value TEXT, expires_at DOUBLE PRECISION)"
|
|
38
|
+
)
|
|
39
|
+
@available = true
|
|
40
|
+
rescue StandardError
|
|
41
|
+
@available = false
|
|
42
|
+
ensure
|
|
43
|
+
restore_env("TINA4_AUTO_CACHING", prev_auto)
|
|
44
|
+
restore_env("TINA4_DB_CACHE", prev_db)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def available?
|
|
49
|
+
@available
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def get(key)
|
|
53
|
+
row = @db.fetch_one("SELECT value, expires_at FROM tina4_cache WHERE cache_key = ?", [key])
|
|
54
|
+
unless row
|
|
55
|
+
@misses += 1
|
|
56
|
+
return nil
|
|
57
|
+
end
|
|
58
|
+
exp = row["expires_at"] || row[:expires_at]
|
|
59
|
+
if exp && exp.to_f > 0 && Time.now.to_f > exp.to_f
|
|
60
|
+
@db.execute("DELETE FROM tina4_cache WHERE cache_key = ?", [key])
|
|
61
|
+
@misses += 1
|
|
62
|
+
return nil
|
|
63
|
+
end
|
|
64
|
+
@hits += 1
|
|
65
|
+
raw = row["value"] || row[:value]
|
|
66
|
+
begin
|
|
67
|
+
JSON.parse(raw)
|
|
68
|
+
rescue JSON::ParserError, TypeError
|
|
69
|
+
raw
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def set(key, value, ttl)
|
|
74
|
+
exp = ttl > 0 ? Time.now.to_f + ttl : 0
|
|
75
|
+
serialized = JSON.generate(value)
|
|
76
|
+
@db.execute("DELETE FROM tina4_cache WHERE cache_key = ?", [key])
|
|
77
|
+
@db.execute(
|
|
78
|
+
"INSERT INTO tina4_cache (cache_key, value, expires_at) VALUES (?, ?, ?)",
|
|
79
|
+
[key, serialized, exp]
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def delete(key)
|
|
84
|
+
existed = !@db.fetch_one("SELECT 1 AS x FROM tina4_cache WHERE cache_key = ?", [key]).nil?
|
|
85
|
+
@db.execute("DELETE FROM tina4_cache WHERE cache_key = ?", [key])
|
|
86
|
+
existed
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def clear
|
|
90
|
+
@hits = 0
|
|
91
|
+
@misses = 0
|
|
92
|
+
@db.execute("DELETE FROM tina4_cache")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def stats
|
|
96
|
+
row = @db.fetch_one("SELECT COUNT(*) AS c FROM tina4_cache")
|
|
97
|
+
c = row ? (row["c"] || row[:c]) : 0
|
|
98
|
+
{ hits: @hits, misses: @misses, size: c.to_i, backend: "database" }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def name
|
|
102
|
+
"database"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def env_nonempty(key)
|
|
108
|
+
v = ENV[key]
|
|
109
|
+
v && !v.empty? ? v : nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def restore_env(key, value)
|
|
113
|
+
if value.nil?
|
|
114
|
+
ENV.delete(key)
|
|
115
|
+
else
|
|
116
|
+
ENV[key] = value
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "digest"
|
|
6
|
+
require_relative "base_backend"
|
|
7
|
+
|
|
8
|
+
module Tina4
|
|
9
|
+
module CacheBackends
|
|
10
|
+
# File-based cache — stores entries as JSON files in data/cache/ (parity
|
|
11
|
+
# with Python _FileBackend). Always available; used as the graceful-degrade
|
|
12
|
+
# target when a configured network backend is unreachable.
|
|
13
|
+
class FileBackend < BaseBackend
|
|
14
|
+
def initialize(cache_dir: "data/cache", max_entries: 1000)
|
|
15
|
+
@dir = cache_dir
|
|
16
|
+
@max_entries = max_entries
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@hits = 0
|
|
19
|
+
@misses = 0
|
|
20
|
+
FileUtils.mkdir_p(@dir)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def get(key)
|
|
24
|
+
path = key_path(key)
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
unless File.exist?(path)
|
|
27
|
+
@misses += 1
|
|
28
|
+
return nil
|
|
29
|
+
end
|
|
30
|
+
begin
|
|
31
|
+
data = JSON.parse(File.read(path))
|
|
32
|
+
expires_at = data["expires_at"]
|
|
33
|
+
if expires_at && Time.now.to_f > expires_at
|
|
34
|
+
File.delete(path) rescue nil
|
|
35
|
+
@misses += 1
|
|
36
|
+
return nil
|
|
37
|
+
end
|
|
38
|
+
@hits += 1
|
|
39
|
+
data["value"]
|
|
40
|
+
rescue JSON::ParserError, SystemCallError
|
|
41
|
+
@misses += 1
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def set(key, value, ttl)
|
|
48
|
+
expires_at = ttl > 0 ? Time.now.to_f + ttl : nil
|
|
49
|
+
entry = { "key" => key, "value" => value, "expires_at" => expires_at }
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
FileUtils.mkdir_p(@dir)
|
|
52
|
+
begin
|
|
53
|
+
files = Dir.glob(File.join(@dir, "*.json")).sort_by { |f| File.mtime(f) }
|
|
54
|
+
while files.size >= @max_entries
|
|
55
|
+
File.delete(files.shift) rescue nil
|
|
56
|
+
end
|
|
57
|
+
rescue SystemCallError
|
|
58
|
+
end
|
|
59
|
+
begin
|
|
60
|
+
File.write(key_path(key), JSON.generate(entry))
|
|
61
|
+
rescue SystemCallError
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def delete(key)
|
|
67
|
+
path = key_path(key)
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
if File.exist?(path)
|
|
70
|
+
File.delete(path) rescue nil
|
|
71
|
+
true
|
|
72
|
+
else
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def clear
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
@hits = 0
|
|
81
|
+
@misses = 0
|
|
82
|
+
Dir.glob(File.join(@dir, "*.json")).each { |f| File.delete(f) rescue nil }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def stats
|
|
87
|
+
@mutex.synchronize do
|
|
88
|
+
now = Time.now.to_f
|
|
89
|
+
count = 0
|
|
90
|
+
Dir.glob(File.join(@dir, "*.json")).each do |f|
|
|
91
|
+
begin
|
|
92
|
+
data = JSON.parse(File.read(f))
|
|
93
|
+
exp = data["expires_at"]
|
|
94
|
+
if exp && now > exp
|
|
95
|
+
File.delete(f) rescue nil
|
|
96
|
+
else
|
|
97
|
+
count += 1
|
|
98
|
+
end
|
|
99
|
+
rescue JSON::ParserError, SystemCallError
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
{ hits: @hits, misses: @misses, size: count, backend: "file" }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def name
|
|
107
|
+
"file"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Actively delete expired entries and return the number removed.
|
|
111
|
+
# (The file backend is the only one that supports an explicit sweep —
|
|
112
|
+
# network/db backends expire lazily via TTL.)
|
|
113
|
+
#
|
|
114
|
+
# @return [Integer]
|
|
115
|
+
def sweep
|
|
116
|
+
removed = 0
|
|
117
|
+
now = Time.now.to_f
|
|
118
|
+
@mutex.synchronize do
|
|
119
|
+
Dir.glob(File.join(@dir, "*.json")).each do |f|
|
|
120
|
+
begin
|
|
121
|
+
data = JSON.parse(File.read(f))
|
|
122
|
+
if data["expires_at"] && now > data["expires_at"]
|
|
123
|
+
File.delete(f) rescue nil
|
|
124
|
+
removed += 1
|
|
125
|
+
end
|
|
126
|
+
rescue JSON::ParserError, SystemCallError
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
removed
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def key_path(key)
|
|
136
|
+
File.join(@dir, "#{Digest::SHA256.hexdigest(key)}.json")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "digest"
|
|
6
|
+
require_relative "base_backend"
|
|
7
|
+
|
|
8
|
+
module Tina4
|
|
9
|
+
module CacheBackends
|
|
10
|
+
# Memcached backend using the zero-dependency text protocol over TCP
|
|
11
|
+
# (parity with Python _MemcachedBackend). Keys are SHA-256 hashed to stay
|
|
12
|
+
# within memcached's 250-char / no-space key constraints. Memcached has no
|
|
13
|
+
# auth, so credentials are ignored.
|
|
14
|
+
class MemcachedBackend < BaseBackend
|
|
15
|
+
PREFIX = "tina4:cache:"
|
|
16
|
+
|
|
17
|
+
def initialize(url: "memcached://localhost:11211", max_entries: 1000)
|
|
18
|
+
cleaned = url.sub(%r{^memcached://}, "").sub(%r{^memcache://}, "")
|
|
19
|
+
parts = cleaned.split("/").first.to_s.split(":")
|
|
20
|
+
@host = parts[0].nil? || parts[0].empty? ? "localhost" : parts[0]
|
|
21
|
+
@port = parts[1] && !parts[1].empty? ? parts[1].to_i : 11_211
|
|
22
|
+
@max_entries = max_entries
|
|
23
|
+
@hits = 0
|
|
24
|
+
@misses = 0
|
|
25
|
+
@available = command("version\r\n", "\r\n").start_with?("VERSION")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def available?
|
|
29
|
+
@available
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def get(key)
|
|
33
|
+
resp = command("get #{mc_key(key)}\r\n", "END\r\n")
|
|
34
|
+
if resp.start_with?("VALUE")
|
|
35
|
+
begin
|
|
36
|
+
header, rest = resp.split("\r\n", 2)
|
|
37
|
+
nbytes = header.split[3].to_i
|
|
38
|
+
@hits += 1
|
|
39
|
+
return JSON.parse(rest[0, nbytes])
|
|
40
|
+
rescue StandardError
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
@misses += 1
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def set(key, value, ttl)
|
|
48
|
+
data = JSON.generate(value)
|
|
49
|
+
exptime = ttl > 0 ? ttl : 0
|
|
50
|
+
payload = "set #{mc_key(key)} 0 #{exptime} #{data.bytesize}\r\n#{data}\r\n"
|
|
51
|
+
command(payload, "\r\n")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def delete(key)
|
|
55
|
+
command("delete #{mc_key(key)}\r\n", "\r\n").start_with?("DELETED")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def clear
|
|
59
|
+
@hits = 0
|
|
60
|
+
@misses = 0
|
|
61
|
+
command("flush_all\r\n", "\r\n")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def stats
|
|
65
|
+
size = 0
|
|
66
|
+
resp = command("stats\r\n", "END\r\n")
|
|
67
|
+
resp.split("\r\n").each do |line|
|
|
68
|
+
if line.start_with?("STAT curr_items ")
|
|
69
|
+
size = line.split[2].to_i
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
{ hits: @hits, misses: @misses, size: size, backend: "memcached" }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def name
|
|
76
|
+
"memcached"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def mc_key(key)
|
|
82
|
+
PREFIX + Digest::SHA256.hexdigest(key)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def command(payload, terminator)
|
|
86
|
+
sock = TCPSocket.new(@host, @port)
|
|
87
|
+
sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [5, 0].pack("l_2"))
|
|
88
|
+
sock.write(payload)
|
|
89
|
+
buf = +""
|
|
90
|
+
until buf.include?(terminator)
|
|
91
|
+
chunk = sock.recv(4096)
|
|
92
|
+
break if chunk.nil? || chunk.empty?
|
|
93
|
+
|
|
94
|
+
buf << chunk
|
|
95
|
+
end
|
|
96
|
+
sock.close
|
|
97
|
+
buf
|
|
98
|
+
rescue StandardError
|
|
99
|
+
""
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_backend"
|
|
4
|
+
|
|
5
|
+
module Tina4
|
|
6
|
+
module CacheBackends
|
|
7
|
+
# Thread-safe in-memory LRU cache with TTL (parity with Python
|
|
8
|
+
# _MemoryBackend). Default backend — zero dependencies.
|
|
9
|
+
class MemoryBackend < BaseBackend
|
|
10
|
+
def initialize(max_entries: 1000)
|
|
11
|
+
@max_entries = max_entries
|
|
12
|
+
@store = {} # key => [value, expires_at] ; Ruby Hash preserves insertion order
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@hits = 0
|
|
15
|
+
@misses = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def get(key)
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
entry = @store[key]
|
|
21
|
+
if entry.nil?
|
|
22
|
+
@misses += 1
|
|
23
|
+
return nil
|
|
24
|
+
end
|
|
25
|
+
value, expires_at = entry
|
|
26
|
+
if expires_at && monotonic > expires_at
|
|
27
|
+
@store.delete(key)
|
|
28
|
+
@misses += 1
|
|
29
|
+
return nil
|
|
30
|
+
end
|
|
31
|
+
@hits += 1
|
|
32
|
+
# Move to end (most recently used)
|
|
33
|
+
@store.delete(key)
|
|
34
|
+
@store[key] = entry
|
|
35
|
+
value
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def set(key, value, ttl)
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
expires_at = ttl > 0 ? monotonic + ttl : nil
|
|
42
|
+
@store.delete(key)
|
|
43
|
+
@store[key] = [value, expires_at]
|
|
44
|
+
while @store.size > @max_entries
|
|
45
|
+
@store.delete(@store.keys.first)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def delete(key)
|
|
51
|
+
@mutex.synchronize { !@store.delete(key).nil? }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def clear
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
@store.clear
|
|
57
|
+
@hits = 0
|
|
58
|
+
@misses = 0
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def stats
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
now = monotonic
|
|
65
|
+
@store.reject! { |_k, (_v, exp)| exp && now > exp }
|
|
66
|
+
{ hits: @hits, misses: @misses, size: @store.size, backend: "memory" }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def name
|
|
71
|
+
"memory"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def monotonic
|
|
77
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "base_backend"
|
|
6
|
+
|
|
7
|
+
module Tina4
|
|
8
|
+
module CacheBackends
|
|
9
|
+
# MongoDB backend backed by a TTL collection (parity with Python
|
|
10
|
+
# _MongoBackend). Requires the `mongo` gem — reuses the same connection
|
|
11
|
+
# style as the Mongo session handler. The cache lives in collection
|
|
12
|
+
# tina4_cache with a TTL index on expires_at. The database name is taken
|
|
13
|
+
# from the URL path (mongodb://host:27017/<db>) or defaults to tina4_cache.
|
|
14
|
+
class MongoBackend < BaseBackend
|
|
15
|
+
def initialize(url: "mongodb://localhost:27017", max_entries: 1000)
|
|
16
|
+
@max_entries = max_entries
|
|
17
|
+
@hits = 0
|
|
18
|
+
@misses = 0
|
|
19
|
+
@coll = nil
|
|
20
|
+
begin
|
|
21
|
+
require "mongo"
|
|
22
|
+
Mongo::Logger.logger.level = Logger::FATAL if defined?(Mongo::Logger)
|
|
23
|
+
db_name = database_from_url(url)
|
|
24
|
+
client_opts = { server_selection_timeout: 5, database: db_name }
|
|
25
|
+
# Credentials from env when not embedded in the URL (parity with DB layer).
|
|
26
|
+
unless url.include?("@")
|
|
27
|
+
mu = env_nonempty("TINA4_CACHE_USERNAME")
|
|
28
|
+
mp = env_nonempty("TINA4_CACHE_PASSWORD")
|
|
29
|
+
client_opts[:user] = mu if mu
|
|
30
|
+
client_opts[:password] = mp if mp
|
|
31
|
+
end
|
|
32
|
+
client = Mongo::Client.new(url, **client_opts)
|
|
33
|
+
@coll = client[:tina4_cache]
|
|
34
|
+
@coll.indexes.create_one({ expires_at: 1 }, expire_after: 0)
|
|
35
|
+
client.database.command(ping: 1)
|
|
36
|
+
rescue LoadError, StandardError
|
|
37
|
+
@coll = nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def available?
|
|
42
|
+
!@coll.nil?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def get(key)
|
|
46
|
+
if @coll.nil?
|
|
47
|
+
@misses += 1
|
|
48
|
+
return nil
|
|
49
|
+
end
|
|
50
|
+
begin
|
|
51
|
+
doc = @coll.find(_id: key).first
|
|
52
|
+
unless doc
|
|
53
|
+
@misses += 1
|
|
54
|
+
return nil
|
|
55
|
+
end
|
|
56
|
+
exp = doc["expires_at"]
|
|
57
|
+
if exp && exp < Time.now.utc
|
|
58
|
+
@coll.delete_one(_id: key)
|
|
59
|
+
@misses += 1
|
|
60
|
+
return nil
|
|
61
|
+
end
|
|
62
|
+
@hits += 1
|
|
63
|
+
JSON.parse(doc["value"])
|
|
64
|
+
rescue StandardError
|
|
65
|
+
@misses += 1
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def set(key, value, ttl)
|
|
71
|
+
return if @coll.nil?
|
|
72
|
+
|
|
73
|
+
begin
|
|
74
|
+
doc = { _id: key, value: JSON.generate(value) }
|
|
75
|
+
doc[:expires_at] = Time.now.utc + ttl if ttl > 0
|
|
76
|
+
@coll.replace_one({ _id: key }, doc, upsert: true)
|
|
77
|
+
rescue StandardError
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def delete(key)
|
|
82
|
+
return false if @coll.nil?
|
|
83
|
+
|
|
84
|
+
begin
|
|
85
|
+
@coll.delete_one(_id: key).deleted_count > 0
|
|
86
|
+
rescue StandardError
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def clear
|
|
92
|
+
@hits = 0
|
|
93
|
+
@misses = 0
|
|
94
|
+
return if @coll.nil?
|
|
95
|
+
|
|
96
|
+
begin
|
|
97
|
+
@coll.delete_many({})
|
|
98
|
+
rescue StandardError
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def stats
|
|
103
|
+
size = 0
|
|
104
|
+
unless @coll.nil?
|
|
105
|
+
begin
|
|
106
|
+
size = @coll.count_documents({})
|
|
107
|
+
rescue StandardError
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
{ hits: @hits, misses: @misses, size: size, backend: "mongodb" }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def name
|
|
114
|
+
"mongodb"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def env_nonempty(key)
|
|
120
|
+
v = ENV[key]
|
|
121
|
+
v && !v.empty? ? v : nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def database_from_url(url)
|
|
125
|
+
uri = URI.parse(url)
|
|
126
|
+
path = (uri.path || "").sub(%r{^/}, "")
|
|
127
|
+
path.empty? ? "tina4_cache" : path
|
|
128
|
+
rescue URI::InvalidURIError
|
|
129
|
+
"tina4_cache"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|