tina4ruby 3.13.23 → 3.13.24

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: baf95ed43a14f3bcfb80f371cfca547bb0605d7da81ec519c6d32d58287ad754
4
- data.tar.gz: afafda038012638d1a98da325e03c42ac4d0517693abf3832f3e313ad414982a
3
+ metadata.gz: 6453444cbb2ebb4df4715ae2033881f8ba8cc24560a4e652d829db21c00a305a
4
+ data.tar.gz: be65f8fe81474f6c6946c1712192ba1040d417383477e52a4ba32477356b52dd
5
5
  SHA512:
6
- metadata.gz: 6126339d81995e6d7199af287d4a8e909dd57f02f0ba58993f87e842c25716d40e80bad1af5a00a9c910a35e1025b7b5483986b29e57e983ee90ba4552b35540
7
- data.tar.gz: a5fe6d009ccc270ecb27075da49ed1a8361a1ef59cffcaa4634f9b39a84896caf52e064b2129210c25d683c2a7b40048145a61e8ef206906b9cdc26a2e2c2af5
6
+ metadata.gz: 50d0ce089a2c0d587839bfd1c6e9f4a59935fdcb46635ceb56151d78f64fde012a165e3eb74b050eacf96b820828e2f94af34ab5aba4fb818256abc9135cb5e0
7
+ data.tar.gz: 3c1410b3a134988fd5e1f3deff3d087dee14cb2555431eeac9a3de0bb6fefa72903965c23616090f6b44cad0765f59596d915044dae91e40b0043e39b186c257
@@ -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