Files
l5y 5a73e212a3 web: optimize caching (#744)
* web: optimize caching

* web: address review comments

* web: address review comments

* web: run rufo
2026-04-14 23:29:54 +02:00

166 lines
5.9 KiB
Ruby

# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "spec_helper"
RSpec.describe PotatoMesh::App::ApiCache do
after { described_class.invalidate_all }
describe ".fetch" do
it "returns a hash with :value and :etag on cache miss" do
result = described_class.fetch("test:key", ttl_seconds: 60) { "value_a" }
expect(result).to be_a(Hash)
expect(result[:value]).to eq("value_a")
expect(result[:etag]).to match(/\A[0-9a-f]+\z/)
end
it "returns the cached value within the TTL" do
described_class.fetch("test:ttl", ttl_seconds: 60) { "first" }
result = described_class.fetch("test:ttl", ttl_seconds: 60) { "second" }
expect(result[:value]).to eq("first")
end
it "recomputes the value after the TTL expires" do
described_class.fetch("test:expired", ttl_seconds: 0) { "first" }
sleep 0.01
result = described_class.fetch("test:expired", ttl_seconds: 0) { "second" }
expect(result[:value]).to eq("second")
end
it "caches different keys independently" do
described_class.fetch("key:a", ttl_seconds: 60) { "alpha" }
described_class.fetch("key:b", ttl_seconds: 60) { "beta" }
a = described_class.fetch("key:a", ttl_seconds: 60) { "stale" }
b = described_class.fetch("key:b", ttl_seconds: 60) { "stale" }
expect(a[:value]).to eq("alpha")
expect(b[:value]).to eq("beta")
end
it "stores a pre-computed weak ETag matching the value digest" do
result = described_class.fetch("test:etag", ttl_seconds: 60) { '{"ok":true}' }
expected_digest = Digest::MD5.hexdigest('{"ok":true}')
expect(result[:etag]).to eq(expected_digest)
end
it "returns the same ETag on cache hit without recomputing" do
first = described_class.fetch("test:etag-hit", ttl_seconds: 60) { "body" }
second = described_class.fetch("test:etag-hit", ttl_seconds: 60) { "other" }
expect(second[:etag]).to eq(first[:etag])
end
end
describe ".invalidate_all" do
it "clears all cached entries" do
described_class.fetch("inv:x", ttl_seconds: 60) { "x" }
described_class.fetch("inv:y", ttl_seconds: 60) { "y" }
expect(described_class.size).to eq(2)
described_class.invalidate_all
expect(described_class.size).to eq(0)
result = described_class.fetch("inv:x", ttl_seconds: 60) { "fresh" }
expect(result[:value]).to eq("fresh")
end
end
describe ".invalidate" do
it "removes only the specified keys" do
described_class.fetch("sel:a", ttl_seconds: 60) { "a" }
described_class.fetch("sel:b", ttl_seconds: 60) { "b" }
described_class.fetch("sel:c", ttl_seconds: 60) { "c" }
described_class.invalidate("sel:a", "sel:c")
expect(described_class.size).to eq(1)
result = described_class.fetch("sel:b", ttl_seconds: 60) { "stale" }
expect(result[:value]).to eq("b")
result = described_class.fetch("sel:a", ttl_seconds: 60) { "new_a" }
expect(result[:value]).to eq("new_a")
end
end
describe ".invalidate_prefix" do
it "removes entries whose keys start with any of the given prefixes" do
described_class.fetch("api:nodes:200:", ttl_seconds: 60) { "n" }
described_class.fetch("api:nodes:1000:", ttl_seconds: 60) { "n2" }
described_class.fetch("api:messages:200:", ttl_seconds: 60) { "m" }
described_class.fetch("api:stats:0", ttl_seconds: 60) { "s" }
described_class.invalidate_prefix("api:nodes:", "api:stats:")
expect(described_class.size).to eq(1)
result = described_class.fetch("api:messages:200:", ttl_seconds: 60) { "stale" }
expect(result[:value]).to eq("m")
end
it "is a no-op when no keys match" do
described_class.fetch("api:nodes:x", ttl_seconds: 60) { "n" }
described_class.invalidate_prefix("api:telemetry:")
expect(described_class.size).to eq(1)
end
end
describe "MAX_ENTRIES eviction" do
it "evicts the oldest entry when the store exceeds MAX_ENTRIES" do
max = described_class::MAX_ENTRIES
# Fill the cache to capacity
max.times do |i|
described_class.fetch("fill:#{i}", ttl_seconds: 60) { "v#{i}" }
end
expect(described_class.size).to eq(max)
# Adding one more should evict the oldest
described_class.fetch("fill:overflow", ttl_seconds: 60) { "new" }
expect(described_class.size).to eq(max)
# The first entry should have been evicted
result = described_class.fetch("fill:0", ttl_seconds: 60) { "recomputed" }
expect(result[:value]).to eq("recomputed")
end
end
describe ".size" do
it "reports the number of cached entries" do
expect(described_class.size).to eq(0)
described_class.fetch("sz:1", ttl_seconds: 60) { "v" }
expect(described_class.size).to eq(1)
end
end
describe "error handling" do
it "does not cache the value when the block raises" do
expect {
described_class.fetch("err:raise", ttl_seconds: 60) { raise "boom" }
}.to raise_error(RuntimeError, "boom")
expect(described_class.size).to eq(0)
end
it "allows a subsequent fetch after a block error" do
begin
described_class.fetch("err:retry", ttl_seconds: 60) { raise "first" }
rescue RuntimeError
# expected
end
result = described_class.fetch("err:retry", ttl_seconds: 60) { "recovered" }
expect(result[:value]).to eq("recovered")
end
end
end