mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-06-11 08:34:45 +02:00
5a73e212a3
* web: optimize caching * web: address review comments * web: address review comments * web: run rufo
166 lines
5.9 KiB
Ruby
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
|