diff --git a/README.md b/README.md index b891cbdf..2598026a 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,12 @@ docker run -it --rm -p 4000:4000 \ | `HELIUS_API_KEY` | optional Helius API key added as an extra Solana RPC fallback | | `ETH_RPC_URLS` / `BNB_RPC_URLS` / `SOL_RPC_URLS` / `BTC_ESPLORA_URLS` | optional comma-separated endpoint lists overriding the built-in chain provider pools | | `Logging__LogLevel__Default` | log level (default `Warning`; set `Information` for per-request logs) | +| `SSR_INTERNAL_SECRET` | shared header secret that switches on the internal SSR RPC cache routes (`/private-api/ssr/*`); unset = they answer like unknown routes | +| `SSR_CACHE_BYTES` | byte budget of the SSR RPC cache, LRU beyond it (default 512 MiB) | +| `SSR_RPC_BUDGET_MS` | wall-clock budget for one SSR RPC lookup before it answers 504 while the fill completes (default `1500`) | +| `SSR_RPC_NODE_TIMEOUT_MS` | per-node timeout of the SSR RPC cache's own client, one attempt per node (default `1200`) | +| `SSR_RPC_NODES` | comma-separated node pool for that client (default: the shared pool) | +| `SSR_RPC_MAX_FILLS` / `SSR_RPC_MAX_QUEUED_FILLS` | bound on upstream fills in progress (default `64`) and on fills waiting for that bound (default `256`); beyond the latter a miss fails fast | ## Swarm diff --git a/dotnet/EcencyApi.Tests/EcencyApi.Tests.csproj b/dotnet/EcencyApi.Tests/EcencyApi.Tests.csproj index 097b09b1..49f5bb96 100644 --- a/dotnet/EcencyApi.Tests/EcencyApi.Tests.csproj +++ b/dotnet/EcencyApi.Tests/EcencyApi.Tests.csproj @@ -17,6 +17,11 @@ + + + + + diff --git a/dotnet/EcencyApi.Tests/SsrRpcTests.cs b/dotnet/EcencyApi.Tests/SsrRpcTests.cs new file mode 100644 index 00000000..13a7afbe --- /dev/null +++ b/dotnet/EcencyApi.Tests/SsrRpcTests.cs @@ -0,0 +1,499 @@ +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using EcencyApi.Handlers; +using EcencyApi.Infrastructure; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace EcencyApi.Tests; + +/// +/// The SSR RPC cache against a loopback stub node: one upstream call per key +/// under concurrency, hits until the TTL runs out, the allowlist and header +/// gates answering like an unknown route, the budget turning a slow upstream +/// into a 504 while the fill still completes, and the byte budget evicting. +/// +[Collection("ssr-rpc")] +public class SsrRpcTests +{ + /// Loopback JSON-RPC node answering any method with a result that + /// names the method and the hit number, after an optional delay. + private sealed class RpcStub : IAsyncDisposable + { + private readonly HttpListener _listener = new(); + public string Url { get; } + public int Hits; + public int DelayMs; + public bool RpcError; + public bool NullResult; + + public RpcStub() + { + var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + l.Start(); + var port = ((IPEndPoint)l.LocalEndpoint).Port; + l.Stop(); + Url = $"http://127.0.0.1:{port}/"; + _listener.Prefixes.Add(Url); + _listener.Start(); + _ = Loop(); + } + + private async Task Loop() + { + while (_listener.IsListening) + { + HttpListenerContext ctx; + try { ctx = await _listener.GetContextAsync(); } + catch { return; } + var n = Interlocked.Increment(ref Hits); + string reqBody; + using (var reader = new StreamReader(ctx.Request.InputStream)) + { + reqBody = await reader.ReadToEndAsync(); + } + var method = JsonNode.Parse(reqBody)?["params"]?[1]?.GetValue() ?? "?"; + if (DelayMs > 0) await Task.Delay(DelayMs); + var body = RpcError + ? "{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"message\":\"stub error\"}}" + : NullResult + ? "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":null}" + : "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"method\":\"" + method + "\",\"n\":" + n + ",\"text\":\"caf\\u00e9 \\ud83d\"}}"; + var bytes = Encoding.UTF8.GetBytes(body); + ctx.Response.StatusCode = 200; + ctx.Response.ContentType = "application/json"; + ctx.Response.ContentLength64 = bytes.Length; + try { await ctx.Response.OutputStream.WriteAsync(bytes); ctx.Response.Close(); } catch { } + } + } + + public async ValueTask DisposeAsync() + { + _listener.Stop(); + _listener.Close(); + await Task.CompletedTask; + } + } + + private static readonly SsrRpc.MethodPolicy Post = SsrRpc.Allowlist["bridge.get_post"]; + private static readonly SsrRpc.MethodPolicy Props = SsrRpc.Allowlist["condenser_api.get_dynamic_global_properties"]; + + private static void Use(RpcStub stub, long cacheBytes = 1 << 20, int budgetMs = 1500, int maxFills = 64, int maxQueued = 256) + { + SsrRpc.Client = new HiveRpcClient(new[] { stub.Url }, timeoutMs: 1000, failoverThreshold: 1); + SsrRpc.Cache = new BytesCache(cacheBytes); + SsrRpc.BudgetMs = budgetMs; + SsrRpc.FillGate = new SemaphoreSlim(maxFills, maxFills); + SsrRpc.MaxQueuedFills = maxQueued; + SsrRpc.SecretDigest = null; + SsrRpc.ResetForTests(); + } + + private static JsonObject P(string author, string permlink) => + new() { ["author"] = author, ["permlink"] = permlink }; + + [Fact] + public async Task Concurrent_misses_for_one_key_make_one_upstream_call() + { + await using var stub = new RpcStub { DelayMs = 200 }; + Use(stub); + var tasks = Enumerable.Range(0, 12).Select(_ => SsrRpc.Resolve(Post, P("a", "b"))).ToArray(); + var results = await Task.WhenAll(tasks); + Assert.Equal(1, stub.Hits); + Assert.Single(results.Where(r => r.Outcome == SsrRpc.Outcome.Miss)); + Assert.Equal(11, results.Count(r => r.Outcome == SsrRpc.Outcome.Coalesced)); + var bodies = results.Select(r => Encoding.UTF8.GetString(r.Bytes)).Distinct().ToArray(); + Assert.Single(bodies); + Assert.Contains("\"method\":\"get_post\"", bodies[0]); + } + + [Fact] + public async Task Second_call_is_a_hit_with_the_same_bytes_and_params_order_does_not_matter() + { + await using var stub = new RpcStub(); + Use(stub); + var first = await SsrRpc.Resolve(Post, P("a", "b")); + var reordered = new JsonObject { ["permlink"] = "b", ["author"] = "a" }; + var second = await SsrRpc.Resolve(Post, reordered); + Assert.Equal(SsrRpc.Outcome.Miss, first.Outcome); + Assert.Equal(SsrRpc.Outcome.Hit, second.Outcome); + Assert.Equal(first.Bytes, second.Bytes); + Assert.Equal(1, stub.Hits); + // A different post is a different key. + var other = await SsrRpc.Resolve(Post, P("a", "c")); + Assert.Equal(SsrRpc.Outcome.Miss, other.Outcome); + Assert.Equal(2, stub.Hits); + } + + [Fact] + public async Task Bytes_are_the_upstream_result_serialized_once_lone_surrogate_included() + { + await using var stub = new RpcStub(); + Use(stub); + var r = await SsrRpc.Resolve(Post, P("a", "b")); + var text = Encoding.UTF8.GetString(r.Bytes); + // The result object itself, not the JSON-RPC envelope. + Assert.StartsWith("{\"method\":\"get_post\"", text); + Assert.DoesNotContain("jsonrpc", text); + // JsJson re-emits the lone surrogate as an escape instead of throwing. + Assert.Contains("\\ud83d", text); + } + + [Fact] + public async Task Ttl_expiry_goes_upstream_again() + { + await using var stub = new RpcStub(); + Use(stub); + var shortLived = Props with { TtlMs = 150 }; + Assert.Equal(SsrRpc.Outcome.Miss, (await SsrRpc.Resolve(shortLived, new JsonArray())).Outcome); + Assert.Equal(SsrRpc.Outcome.Hit, (await SsrRpc.Resolve(shortLived, new JsonArray())).Outcome); + await Task.Delay(300); + Assert.Equal(SsrRpc.Outcome.Miss, (await SsrRpc.Resolve(shortLived, new JsonArray())).Outcome); + Assert.Equal(2, stub.Hits); + } + + [Fact] + public async Task Budget_exceeded_answers_timeout_while_the_fill_still_lands_in_the_cache() + { + await using var stub = new RpcStub { DelayMs = 400 }; + Use(stub, budgetMs: 100); + var r = await SsrRpc.Resolve(Post, P("slow", "post")); + Assert.Equal(SsrRpc.Outcome.Timeout, r.Outcome); + await Task.Delay(600); + SsrRpc.BudgetMs = 1500; + var again = await SsrRpc.Resolve(Post, P("slow", "post")); + Assert.Equal(SsrRpc.Outcome.Hit, again.Outcome); + Assert.Equal(1, stub.Hits); + } + + [Fact] + public async Task Rpc_level_error_is_reported_not_cached() + { + await using var stub = new RpcStub { RpcError = true }; + Use(stub); + var r = await SsrRpc.Resolve(Post, P("a", "b")); + Assert.Equal(SsrRpc.Outcome.RpcError, r.Outcome); + Assert.Equal("stub error", r.Error); + var again = await SsrRpc.Resolve(Post, P("a", "b")); + Assert.Equal(SsrRpc.Outcome.RpcError, again.Outcome); + Assert.Equal(2, stub.Hits); + } + + [Fact] + public async Task Unreachable_node_is_reported_as_unavailable() + { + SsrRpc.Client = new HiveRpcClient(new[] { "http://127.0.0.1:9/" }, timeoutMs: 500, failoverThreshold: 1); + SsrRpc.Cache = new BytesCache(1 << 20); + SsrRpc.BudgetMs = 1500; + SsrRpc.ResetForTests(); + var r = await SsrRpc.Resolve(Post, P("a", "b")); + Assert.Equal(SsrRpc.Outcome.Unavailable, r.Outcome); + } + + [Fact] + public async Task Fills_in_progress_are_bounded_so_distinct_keys_queue_instead_of_piling_up() + { + await using var stub = new RpcStub { DelayMs = 250 }; + Use(stub, maxFills: 1); + var started = Environment.TickCount64; + var a = SsrRpc.Resolve(Post, P("a", "1")); + var b = SsrRpc.Resolve(Post, P("a", "2")); + var results = await Task.WhenAll(a, b); + Assert.All(results, r => Assert.Equal(SsrRpc.Outcome.Miss, r.Outcome)); + // The second fill waited for the first: two delays back to back. + Assert.True(Environment.TickCount64 - started >= 450, "fills ran concurrently despite the bound"); + Assert.Equal(2, stub.Hits); + } + + [Fact] + public async Task Under_pressure_expired_entries_go_before_live_ones() + { + var cache = new BytesCache(100); + cache.Set("live-old", new byte[40], 60_000); + cache.Set("expired", new byte[40], 50); + await Task.Delay(120); + // Over budget now: the expired entry must be the one that goes, even + // though the live entry is the least recently used. + cache.Set("new", new byte[40], 60_000); + Assert.True(cache.TryGet("live-old", out _)); + Assert.False(cache.TryGet("expired", out _)); + Assert.True(cache.TryGet("new", out _)); + } + + [Fact] + public async Task Every_over_budget_set_purges_expired_entries_first_not_only_once_in_a_while() + { + var cache = new BytesCache(120); + cache.Set("live-a", new byte[40], 60_000); + cache.Set("short-1", new byte[40], 50); + await Task.Delay(120); + cache.Set("live-b", new byte[40], 60_000); // over budget: short-1 (expired) goes + Assert.False(cache.TryGet("short-1", out _)); + Assert.True(cache.TryGet("live-a", out _)); + // A second expiry moments later, then more pressure: it must go before + // the live entry at the LRU head, even though a purge just ran. + cache.Set("short-2", new byte[40], 50); + await Task.Delay(120); + cache.Set("live-c", new byte[40], 60_000); + Assert.False(cache.TryGet("short-2", out _)); + Assert.True(cache.TryGet("live-a", out _)); + Assert.True(cache.TryGet("live-b", out _)); + Assert.True(cache.TryGet("live-c", out _)); + Assert.Equal(120, cache.Bytes); + } + + [Fact] + public async Task Keys_that_share_an_expiry_millisecond_are_indexed_and_purged_independently() + { + var cache = new BytesCache(100); + // Same TTL set back to back, keys that differ only in characters a + // culture-aware comparison can treat as ignorable. + cache.Set("k-a", new byte[30], 50); + cache.Set("k-\u00ADa", new byte[30], 50); + cache.Set("k-A", new byte[30], 50); + Assert.Equal(3, cache.Count); + await Task.Delay(120); + cache.Set("live", new byte[40], 60_000); // over budget: all three expired must go + Assert.Equal(1, cache.Count); + Assert.Equal(40, cache.Bytes); + Assert.True(cache.TryGet("live", out _)); + } + + [Fact] + public async Task Rpc_requires_structured_params_and_keys_the_call_on_them() + { + await using var stub = new RpcStub(); + Use(stub); + // Missing params answers like an unknown route and never reaches upstream. + var missing = Request("POST", "/private-api/ssr/rpc", null, "{\"api\":\"bridge\",\"method\":\"get_post\"}"); + await SsrRpc.Rpc(missing); + Assert.Equal(404, missing.Response.StatusCode); + Assert.Equal(0, stub.Hits); + // An array and an object are both legitimate shapes and distinct keys. + var arr = await SsrRpc.Resolve(Props, new JsonArray()); + var obj = await SsrRpc.Resolve(Props, new JsonObject()); + Assert.Equal(SsrRpc.Outcome.Miss, arr.Outcome); + Assert.Equal(SsrRpc.Outcome.Miss, obj.Outcome); + Assert.Equal(2, stub.Hits); + } + + [Fact] + public async Task Queued_fills_are_bounded_and_a_fill_that_outlived_the_budget_in_the_queue_is_dropped() + { + await using var stub = new RpcStub { DelayMs = 400 }; + Use(stub, budgetMs: 100, maxFills: 1, maxQueued: 1); + // First fill holds the gate; second queues; third is over the queue bound. + var a = SsrRpc.Resolve(Post, P("q", "1")); + await Task.Delay(50); + var b = SsrRpc.Resolve(Post, P("q", "2")); + await Task.Delay(20); + var c = SsrRpc.Resolve(Post, P("q", "3")); + var ra = await a; var rb = await b; var rc = await c; + Assert.True(ra.Outcome == SsrRpc.Outcome.Timeout, $"a: {ra.Outcome} {ra.Error}"); + Assert.Equal(SsrRpc.Outcome.Timeout, rb.Outcome); + Assert.Equal(SsrRpc.Outcome.Unavailable, rc.Outcome); + Assert.Equal("fill queue full", rc.Error); + // The queued second fill waited past the budget for the gate, so it never + // calls upstream: one upstream hit in total, and its key is not cached. + await Task.Delay(900); + Assert.Equal(1, stub.Hits); + SsrRpc.BudgetMs = 1500; + Assert.Equal(SsrRpc.Outcome.Hit, (await SsrRpc.Resolve(Post, P("q", "1"))).Outcome); + Assert.Equal(SsrRpc.Outcome.Miss, (await SsrRpc.Resolve(Post, P("q", "2"))).Outcome); + } + + [Fact] + public async Task A_fresh_reader_that_coalesces_onto_a_queued_fill_keeps_it_alive() + { + await using var stub = new RpcStub { DelayMs = 200 }; + Use(stub, budgetMs: 150, maxFills: 1); + var a = SsrRpc.Resolve(Post, P("k", "1")); // holds the gate until ~200ms + await Task.Delay(10); + var b = SsrRpc.Resolve(Post, P("k", "2")); // queued at ~10ms, its reader gives up at ~160ms + await Task.Delay(180); + var c = SsrRpc.Resolve(Post, P("k", "2")); // fresh reader at ~190ms, coalesces + await Task.WhenAll(a, b, c); + Assert.Equal(SsrRpc.Outcome.Timeout, a.Result.Outcome); + Assert.Equal(SsrRpc.Outcome.Timeout, b.Result.Outcome); + // Judged by the fresh attach, not by the original enqueue: the fill ran. + await Task.Delay(500); + Assert.Equal(2, stub.Hits); + SsrRpc.BudgetMs = 1500; + Assert.Equal(SsrRpc.Outcome.Hit, (await SsrRpc.Resolve(Post, P("k", "2"))).Outcome); + } + + [Fact] + public async Task With_the_secret_configured_both_routes_serve_the_matching_header_and_nothing_else() + { + await using var stub = new RpcStub(); + Use(stub); + SsrRpc.SecretDigest = SsrRpc.Digest("right-secret"); + try + { + var ok = Request("POST", "/private-api/ssr/rpc", "right-secret", + "{\"api\":\"bridge\",\"method\":\"get_post\",\"params\":{\"author\":\"a\",\"permlink\":\"b\"}}"); + await SsrRpc.Rpc(ok); + Assert.True(ok.Response.StatusCode == 200, $"status {ok.Response.StatusCode}: {ResponseText(ok)}"); + Assert.Equal("MISS", ok.Response.Headers["X-Ssr-Cache"].ToString()); + Assert.StartsWith("{\"method\":\"get_post\"", ResponseText(ok)); + + var again = Request("POST", "/private-api/ssr/rpc", "right-secret", + "{\"api\":\"bridge\",\"method\":\"get_post\",\"params\":{\"permlink\":\"b\",\"author\":\"a\"}}"); + await SsrRpc.Rpc(again); + Assert.Equal("HIT", again.Response.Headers["X-Ssr-Cache"].ToString()); + Assert.Equal(1, stub.Hits); + + var stats = Request("GET", "/private-api/ssr/stats", "right-secret"); + await SsrRpc.Stats(stats); + Assert.Equal(200, stats.Response.StatusCode); + Assert.Contains("\"bridge.get_post\"", ResponseText(stats)); + Assert.Contains("\"hit\":1", ResponseText(stats)); + + foreach (var header in new[] { "wrong-secret", "right-secret-but-longer", "", null }) + { + var denied = Request("POST", "/private-api/ssr/rpc", header, + "{\"api\":\"bridge\",\"method\":\"get_post\",\"params\":{}}"); + await SsrRpc.Rpc(denied); + Assert.Equal(404, denied.Response.StatusCode); + var deniedStats = Request("GET", "/private-api/ssr/stats", header); + await SsrRpc.Stats(deniedStats); + Assert.Equal(200, deniedStats.Response.StatusCode); + Assert.DoesNotContain("methods", ResponseText(deniedStats)); + } + Assert.Equal(1, stub.Hits); + } + finally + { + SsrRpc.SecretDigest = null; + } + } + + [Fact] + public async Task A_null_result_is_served_as_json_null_with_a_json_content_type() + { + await using var stub = new RpcStub { NullResult = true }; + Use(stub); + SsrRpc.SecretDigest = SsrRpc.Digest("s"); + try + { + var ctx = Request("POST", "/private-api/ssr/rpc", "s", + "{\"api\":\"bridge\",\"method\":\"get_post\",\"params\":{\"author\":\"none\",\"permlink\":\"none\"}}"); + await SsrRpc.Rpc(ctx); + Assert.True(ctx.Response.StatusCode == 200, $"status {ctx.Response.StatusCode}: {ResponseText(ctx)}"); + Assert.StartsWith("application/json", ctx.Response.ContentType); + Assert.Equal("null", ResponseText(ctx)); + } + finally + { + SsrRpc.SecretDigest = null; + } + } + + [Fact] + public void Byte_budget_evicts_least_recently_used_and_refuses_oversize() + { + var cache = new BytesCache(100); + cache.Set("a", new byte[40], 60_000); + cache.Set("b", new byte[40], 60_000); + Assert.True(cache.TryGet("a", out _)); // a is now most recent + cache.Set("c", new byte[40], 60_000); // evicts b + Assert.True(cache.TryGet("a", out _)); + Assert.False(cache.TryGet("b", out _)); + Assert.True(cache.TryGet("c", out _)); + Assert.Equal(80, cache.Bytes); + cache.Set("huge", new byte[101], 60_000); + Assert.False(cache.TryGet("huge", out _)); + Assert.Equal(2, cache.Count); + } + + [Fact] + public async Task Expired_entry_is_dropped_on_read() + { + var cache = new BytesCache(1000); + cache.Set("k", new byte[10], 50); + Assert.True(cache.TryGet("k", out _)); + await Task.Delay(120); + Assert.False(cache.TryGet("k", out _)); + Assert.Equal(0, cache.Count); + Assert.Equal(0, cache.Bytes); + } + + [Fact] + public void Canonical_key_sorts_object_keys_at_every_level_and_keeps_array_order() + { + var a = JsonNode.Parse("{\"z\":1,\"a\":{\"y\":[2,{\"d\":1,\"c\":2}],\"b\":null}}"); + var b = JsonNode.Parse("{\"a\":{\"b\":null,\"y\":[2,{\"c\":2,\"d\":1}]},\"z\":1}"); + Assert.Equal(SsrRpc.CacheKey(Post, a), SsrRpc.CacheKey(Post, b)); + var c = JsonNode.Parse("{\"a\":{\"b\":null,\"y\":[{\"c\":2,\"d\":1},2]},\"z\":1}"); + Assert.NotEqual(SsrRpc.CacheKey(Post, a), SsrRpc.CacheKey(Post, c)); + } + + private static DefaultHttpContext Request(string method, string path, string? header, string? body = null) + { + var ctx = new DefaultHttpContext(); + ctx.Request.Method = method; + ctx.Request.Path = path; + if (header != null) ctx.Request.Headers[SsrRpc.HeaderName] = header; + if (body != null) + { + ctx.Request.ContentType = "application/json"; + var bytes = Encoding.UTF8.GetBytes(body); + ctx.Request.Body = new MemoryStream(bytes); + ctx.Request.ContentLength = bytes.Length; + } + ctx.Response.Body = new MemoryStream(); + return ctx; + } + + private static string ResponseText(HttpContext ctx) + { + ctx.Response.Body.Position = 0; + return new StreamReader(ctx.Response.Body).ReadToEnd(); + } + + [Fact] + public async Task Without_the_secret_configured_both_routes_answer_like_unknown_routes() + { + // SSR_INTERNAL_SECRET is not set in the test environment. + Assert.Null(Config.SsrInternalSecret); + var post = Request("POST", "/private-api/ssr/rpc", "anything", "{\"api\":\"bridge\",\"method\":\"get_post\"}"); + await SsrRpc.Rpc(post); + Assert.Equal(404, post.Response.StatusCode); + Assert.Contains("Cannot POST /private-api/ssr/rpc", ResponseText(post)); + + var get = Request("GET", "/private-api/ssr/stats", "anything"); + await SsrRpc.Stats(get); + Assert.Equal(200, get.Response.StatusCode); + Assert.Contains("text/html", get.Response.ContentType); + Assert.DoesNotContain("methods", ResponseText(get)); + } + + [Fact] + public void Authorized_requires_the_configured_secret_and_a_matching_header() + { + // With no secret configured nothing authorizes, header or not. + SsrRpc.SecretDigest = null; + Assert.False(SsrRpc.Authorized(Request("POST", "/x", null))); + Assert.False(SsrRpc.Authorized(Request("POST", "/x", ""))); + Assert.False(SsrRpc.Authorized(Request("POST", "/x", "guess"))); + } + + [Fact] + public void Allowlist_is_read_only_and_names_every_method_the_consumer_routes() + { + foreach (var key in new[] + { + "bridge.get_ranked_posts", "bridge.get_account_posts", "bridge.get_post", "bridge.get_discussion", + "bridge.get_profile", "bridge.get_profiles", "bridge.get_community", "bridge.list_communities", + "condenser_api.get_accounts", "condenser_api.get_content", + "condenser_api.get_dynamic_global_properties", "condenser_api.get_trending_tags", + }) + { + Assert.True(SsrRpc.Allowlist.ContainsKey(key), key); + Assert.True(SsrRpc.Allowlist[key].TtlMs > 0, key); + } + Assert.False(SsrRpc.Allowlist.ContainsKey("condenser_api.broadcast_transaction")); + Assert.False(SsrRpc.Allowlist.ContainsKey("database_api.get_accounts")); + } +} diff --git a/dotnet/EcencyApi/Config.cs b/dotnet/EcencyApi/Config.cs index 0a7c55bf..46f04a43 100644 --- a/dotnet/EcencyApi/Config.cs +++ b/dotnet/EcencyApi/Config.cs @@ -30,5 +30,42 @@ public static class Config public static string CaptchaMode { get; } = (Env("CAPTCHA_MODE") ?? "hard").Trim().ToLowerInvariant(); + // ---- SSR RPC cache (Handlers/SsrRpc.cs) ---- + // Shared secret the web tier sends on every call. Unset = the routes are + // switched off and answer exactly like unknown routes. + public static string? SsrInternalSecret { get; } = NonEmpty(Env("SSR_INTERNAL_SECRET")); + + // Total bytes of cached responses kept in memory (LRU beyond that). + public static long SsrCacheBytes { get; } = + long.TryParse(Env("SSR_CACHE_BYTES"), out var b) && b >= 0 ? b : 512L * 1024 * 1024; + + // Wall-clock budget for one lookup. The web tier gives up on the proxy a + // little later and falls back to its own node pool, so this must stay + // under that; a lookup that outlives it still completes and fills the cache. + public static int SsrBudgetMs { get; } = + int.TryParse(Env("SSR_RPC_BUDGET_MS"), out var ms) && ms > 0 ? ms : 1500; + + // Per-node timeout for the cache's own RPC client (one attempt per node). + public static int SsrNodeTimeoutMs { get; } = + int.TryParse(Env("SSR_RPC_NODE_TIMEOUT_MS"), out var nt) && nt > 0 ? nt : 1200; + + // Optional node pool for the cache's RPC client, comma-separated; defaults + // to the shared pool. Lets a deployment put its own node first. + public static string[]? SsrRpcNodes { get; } = + NonEmpty(Env("SSR_RPC_NODES"))?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + is { Length: > 0 } nodes ? nodes : null; + + // Upper bound on upstream fills in progress at once. A fill outlives the + // request budget on purpose (it still lands in the cache), so without a + // bound a slow pool plus many distinct keys would pile up detached calls. + public static int SsrMaxConcurrentFills { get; } = + int.TryParse(Env("SSR_RPC_MAX_FILLS"), out var f) && f > 0 ? f : 64; + + // Upper bound on fills waiting for that gate; beyond it a miss fails fast. + public static int SsrMaxQueuedFills { get; } = + int.TryParse(Env("SSR_RPC_MAX_QUEUED_FILLS"), out var q) && q > 0 ? q : 256; + + private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private static string? Env(string name) => Environment.GetEnvironmentVariable(name); } diff --git a/dotnet/EcencyApi/Handlers/Routes.cs b/dotnet/EcencyApi/Handlers/Routes.cs index e4d40a51..bcff2f2c 100644 --- a/dotnet/EcencyApi/Handlers/Routes.cs +++ b/dotnet/EcencyApi/Handlers/Routes.cs @@ -16,7 +16,7 @@ namespace EcencyApi.Handlers; /// - unmatched GET/HEAD -> 200 + the template page; /// - unmatched other methods -> 404 + Express finalhandler "Cannot METHOD /path". /// -public static class Routes +public static partial class Routes { public static void Map(WebApplication app) { @@ -168,6 +168,10 @@ public static void Map(WebApplication app) app.MapPost("/private-api/chats-update", PrivateApi.ChatsUpdate); app.MapPost("/private-api/channel-add", PrivateApi.ChannelAdd); + // ---- SSR RPC cache (internal, header-gated; see SsrRpc.cs) ---- + app.MapPost("/private-api/ssr/rpc", SsrRpc.Rpc); + app.MapGet("/private-api/ssr/stats", SsrRpc.Stats); + // ---- Health check for docker swarm ---- app.MapGet("/healthcheck.json", async ctx => { @@ -181,28 +185,34 @@ public static void Map(WebApplication app) // ---- Fallback ---- // GET/HEAD -> the template page (Express .get("*", fallbackHandler)). // Everything else -> Express's default finalhandler 404. - app.MapFallback(async ctx => + app.MapFallback(Fallback); + } + + /// + /// The unmatched-route response, also used by gated routes to answer exactly + /// like a route that does not exist. + /// + public static async Task Fallback(HttpContext ctx) + { + var method = ctx.Request.Method; + if (HttpMethods.IsGet(method) || HttpMethods.IsHead(method)) { - var method = ctx.Request.Method; - if (HttpMethods.IsGet(method) || HttpMethods.IsHead(method)) + ctx.Response.StatusCode = 200; + ctx.Response.ContentType = "text/html; charset=utf-8"; + if (!HttpMethods.IsHead(method)) { - ctx.Response.StatusCode = 200; - ctx.Response.ContentType = "text/html; charset=utf-8"; - if (!HttpMethods.IsHead(method)) - { - await ctx.Response.WriteAsync(TemplateHtml); - } - return; + await ctx.Response.WriteAsync(TemplateHtml); } + return; + } - var message = $"Cannot {method} {EscapeHtml(EncodeUrl(ctx.Request.Path.Value ?? "/"))}"; - var html = - "\n\n\n\n" + - "Error\n\n\n
" + message + "
\n\n\n"; - ctx.Response.StatusCode = 404; - ctx.Response.ContentType = "text/html; charset=utf-8"; - await ctx.Response.WriteAsync(html); - }); + var message = $"Cannot {method} {EscapeHtml(EncodeUrl(ctx.Request.Path.Value ?? "/"))}"; + var html = + "\n\n\n\n" + + "Error\n\n\n
" + message + "
\n\n\n"; + ctx.Response.StatusCode = 404; + ctx.Response.ContentType = "text/html; charset=utf-8"; + await ctx.Response.WriteAsync(html); } /// diff --git a/dotnet/EcencyApi/Handlers/SsrRpc.cs b/dotnet/EcencyApi/Handlers/SsrRpc.cs new file mode 100644 index 00000000..25711300 --- /dev/null +++ b/dotnet/EcencyApi/Handlers/SsrRpc.cs @@ -0,0 +1,449 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using EcencyApi.Infrastructure; + +namespace EcencyApi.Handlers; + +/// +/// Internal read-through cache for the Hive RPC reads the web tier makes while +/// rendering pages on the server. +/// +/// Every renderer process used to fetch the same accounts, profiles, +/// communities and per-tag feeds straight from public nodes, each at full +/// upstream latency and with nothing shared between processes. This route +/// answers those reads from one cache per host: the upstream `result` is +/// serialized once (JsJson, lone-surrogate safe) and the bytes are written to +/// every reader unchanged; concurrent misses for one key make one upstream +/// call (single-flight, the same pattern as the chain balance fetch). +/// +/// Boundaries, all deliberate: +/// - Allowlisted read methods only, with a TTL each. Anything else, and any +/// call without the shared internal header, answers exactly like a route +/// that does not exist (Routes.Fallback), so nothing is learnable from +/// outside and the parity harness sees no new behavior. +/// - The response is the raw `result`, so the consumer sees exactly what a +/// direct node call returns. No envelope, no reshaping. +/// - A lookup that outlives the budget answers 504 while the upstream call +/// completes in the background and fills the cache; an RPC-level error +/// answers 502. Either way the consumer falls back to its own node pool. +/// - No per-request logging (invariant 6); counters on /stats instead. +/// +public static partial class SsrRpc +{ + internal sealed record MethodPolicy(string Api, string Method, int TtlMs) + { + public string Key => $"{Api}.{Method}"; + } + + // TTLs follow how fast each read legitimately changes for a page render: + // dynamic props every block, a created feed within seconds, a post's + // votes/payout within tens of seconds, a profile or community rarely. + internal static readonly IReadOnlyDictionary Allowlist = new[] + { + new MethodPolicy("bridge", "get_ranked_posts", 15_000), + new MethodPolicy("bridge", "get_account_posts", 30_000), + new MethodPolicy("bridge", "get_post", 30_000), + new MethodPolicy("bridge", "get_discussion", 30_000), + new MethodPolicy("bridge", "get_profile", 60_000), + new MethodPolicy("bridge", "get_profiles", 60_000), + new MethodPolicy("bridge", "get_community", 300_000), + new MethodPolicy("bridge", "list_communities", 300_000), + new MethodPolicy("condenser_api", "get_accounts", 30_000), + new MethodPolicy("condenser_api", "get_content", 30_000), + new MethodPolicy("condenser_api", "get_dynamic_global_properties", 3_000), + new MethodPolicy("condenser_api", "get_trending_tags", 300_000), + }.ToDictionary(p => p.Key, p => p); + + internal const string HeaderName = "X-Ecency-Internal"; + + // The configured secret, held as a SHA-256 digest so the comparison below is + // over two equal-length values (FixedTimeEquals returns early on a length + // mismatch, which would otherwise leak the secret's length). Replaceable for + // tests, which cannot set process environment before Config initializes. + internal static byte[]? SecretDigest = Digest(Config.SsrInternalSecret); + + internal static byte[]? Digest(string? secret) => + secret is null ? null : SHA256.HashData(Encoding.UTF8.GetBytes(secret)); + + // Replaceable for tests (loopback stub nodes, a small cache budget). + internal static HiveRpcClient Client = new( + Config.SsrRpcNodes ?? HiveClients.DefaultNodes.ToArray(), + timeoutMs: Config.SsrNodeTimeoutMs, + failoverThreshold: 1); + + internal static BytesCache Cache = new(Config.SsrCacheBytes); + + internal static int BudgetMs = Config.SsrBudgetMs; + + // Bounds detached fills: a fill outlives the request budget on purpose, so + // a slow pool plus many distinct keys must not pile up unbounded calls. + internal static SemaphoreSlim FillGate = new(Config.SsrMaxConcurrentFills, Config.SsrMaxConcurrentFills); + + // Bounds the fills WAITING for the gate as well: beyond this many, a new + // miss fails fast instead of queueing work no reader will wait for. + internal static int MaxQueuedFills = Config.SsrMaxQueuedFills; + private static int _queuedFills; + + private sealed class Pending + { + public required Task Task; + // Last time a reader attached to this fill (created it or coalesced onto + // it). A queued fill is dropped only when nobody has attached within the + // budget, since every earlier reader has given up by then. Attaching and + // the expiry decision both happen under `lock (this)`, so a reader can + // never attach to a fill that has just decided to expire: it finds + // Expired set and starts a fresh fill instead. + public long LastAttachMs = Environment.TickCount64; + public bool Expired; + + public bool TryAttach() + { + lock (this) + { + if (Expired) return false; + LastAttachMs = Environment.TickCount64; + return true; + } + } + + public bool TryExpire(int budgetMs) + { + lock (this) + { + if (Environment.TickCount64 - LastAttachMs <= budgetMs) return false; + Expired = true; + return true; + } + } + } + + private static readonly ConcurrentDictionary InFlight = new(); + + internal sealed class Counter + { + public long Hit, Miss, Coalesced, Error, Timeout; + // Upstream latency EWMA for misses, milliseconds. + public double UpstreamMs; + private readonly object _lock = new(); + + public void RecordUpstream(double ms) + { + lock (_lock) UpstreamMs = UpstreamMs == 0 ? ms : UpstreamMs * 0.8 + ms * 0.2; + } + + public double ReadUpstreamMs() + { + lock (_lock) return UpstreamMs; + } + } + + private static readonly ConcurrentDictionary Counters = new(); + + internal static Counter CounterFor(string key) => Counters.GetOrAdd(key, _ => new Counter()); + + internal static void ResetForTests() + { + InFlight.Clear(); + Counters.Clear(); + } + + // ---- auth ---------------------------------------------------------------- + + internal static bool Authorized(HttpContext ctx) + { + var expected = SecretDigest; + if (expected is null) return false; + if (!ctx.Request.Headers.TryGetValue(HeaderName, out var values)) return false; + var presented = values.ToString(); + if (presented.Length == 0) return false; + return CryptographicOperations.FixedTimeEquals(Digest(presented), expected); + } + + // ---- key ----------------------------------------------------------------- + + /// + /// One key per distinct call: method plus params with object keys sorted at + /// every level, so two call sites that build the same params in a different + /// order share an entry. + /// + internal static string CacheKey(MethodPolicy policy, JsonNode? @params) => + policy.Key + ":" + JsJson.Stringify(Canonical(@params)); + + internal static JsonNode? Canonical(JsonNode? node) + { + switch (node) + { + case JsonObject obj: + var sorted = new JsonObject(); + foreach (var key in obj.Select(kv => kv.Key).OrderBy(k => k, StringComparer.Ordinal)) + { + sorted[key] = Canonical(obj[key]); + } + return sorted; + case JsonArray arr: + var copy = new JsonArray(); + foreach (var item in arr) copy.Add(Canonical(item)); + return copy; + default: + return node?.DeepClone(); + } + } + + // ---- core ---------------------------------------------------------------- + + internal enum Outcome { Hit, Miss, Coalesced, Timeout, RpcError, Unavailable } + + internal readonly record struct Resolution(Outcome Outcome, byte[] Bytes, string? Error); + + /// + /// Answer one allowlisted call from the cache, or from one shared upstream + /// call, within the budget. Pure of HTTP so it can be exercised directly. + /// `params` is the exact node the upstream call carries (an array for + /// condenser methods, an object for bridge), and the key is derived from + /// that same node. + /// + internal static async Task Resolve(MethodPolicy policy, JsonNode @params) + { + var key = CacheKey(policy, @params); + var counter = CounterFor(policy.Key); + + if (Cache.TryGet(key, out var cached)) + { + Interlocked.Increment(ref counter.Hit); + return new Resolution(Outcome.Hit, cached, null); + } + + // One wall-clock budget for the whole lookup, retry included. + var deadline = Environment.TickCount64 + BudgetMs; + var retried = false; + again: + var coalesced = true; + Pending pending; + while (true) + { + if (InFlight.TryGetValue(key, out var existing)) + { + if (existing.TryAttach()) + { + pending = existing; + break; + } + // Expired while queued: clear it and start a fresh fill. + InFlight.TryRemove(new KeyValuePair(key, existing)); + continue; + } + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var created = new Pending { Task = tcs.Task }; + var winner = InFlight.GetOrAdd(key, created); + if (ReferenceEquals(winner, created)) + { + coalesced = false; + pending = created; + _ = Fill(policy, @params, key, counter, tcs, created); + break; + } + if (winner.TryAttach()) + { + pending = winner; + break; + } + InFlight.TryRemove(new KeyValuePair(key, winner)); + } + + if (coalesced) Interlocked.Increment(ref counter.Coalesced); + else Interlocked.Increment(ref counter.Miss); + + var remaining = (int)Math.Max(0, deadline - Environment.TickCount64); + var finished = await Task.WhenAny(pending.Task, Task.Delay(remaining)); + if (!ReferenceEquals(finished, pending.Task)) + { + Interlocked.Increment(ref counter.Timeout); + return new Resolution(Outcome.Timeout, Array.Empty(), "budget exceeded"); + } + + try + { + var bytes = await pending.Task; + return new Resolution(coalesced ? Outcome.Coalesced : Outcome.Miss, bytes, null); + } + catch (FillRejectedException) when (coalesced && !retried) + { + // The fill this reader attached to was judged expired (or refused) before + // the reader's own wait began, which can only happen if the reader was + // descheduled for longer than the budget between attaching and waiting. + // Its budget has not been spent on anything yet, so start over once. + retried = true; + goto again; + } + catch (HiveRpcClient.RpcException e) + { + Interlocked.Increment(ref counter.Error); + return new Resolution(Outcome.RpcError, Array.Empty(), e.Message); + } + catch (Exception e) + { + Interlocked.Increment(ref counter.Error); + return new Resolution(Outcome.Unavailable, Array.Empty(), e.Message); + } + } + + // The one upstream call behind a key. Runs to completion even when every + // waiter has given up, so the cache still gets filled for the next reader. + internal sealed class FillRejectedException : Exception + { + public FillRejectedException(string message) : base(message) { } + } + + private static async Task Fill(MethodPolicy policy, JsonNode @params, string key, Counter counter, + TaskCompletionSource tcs, Pending pending) + { + var acquired = false; + var queued = false; + try + { + // A reader can miss the cache, lose the race to another fill that + // then completes and leaves, and only now win GetOrAdd: the value is + // already cached, so serve it rather than calling upstream again. + if (Cache.TryGet(key, out var cached)) + { + tcs.TrySetResult(cached); + return; + } + // A free slot is taken at once; otherwise the fill joins a bounded + // queue. Only fills actually waiting count against the bound. + if (FillGate.Wait(0)) + { + acquired = true; + } + else + { + if (Interlocked.Increment(ref _queuedFills) > MaxQueuedFills) + { + Interlocked.Decrement(ref _queuedFills); + throw new FillRejectedException("fill queue full"); + } + queued = true; + await FillGate.WaitAsync(); + acquired = true; + // Nobody has attached within the budget: every reader gave up + // while this sat in the queue, and calling upstream now would + // only be stale traffic for nobody. A fresh reader that coalesced + // in the meantime keeps the fill alive. + if (pending.TryExpire(BudgetMs)) + { + throw new FillRejectedException("fill expired in queue"); + } + } + var started = Environment.TickCount64; + // Call() places params inside its own request envelope; a node that + // already hangs off the request body cannot be re-parented, so it + // travels as a clone. + var result = await Client.Call(policy.Api, policy.Method, @params.DeepClone()); + var bytes = Encoding.UTF8.GetBytes(result is null ? "null" : JsJson.Stringify(result)); + counter.RecordUpstream(Environment.TickCount64 - started); + Cache.Set(key, bytes, policy.TtlMs); + tcs.TrySetResult(bytes); + } + catch (Exception e) + { + tcs.TrySetException(e); + _ = tcs.Task.Exception; // observe: every waiter may already be gone + } + finally + { + if (queued) Interlocked.Decrement(ref _queuedFills); + if (acquired) FillGate.Release(); + InFlight.TryRemove(new KeyValuePair(key, pending)); + } + } + + // ---- handlers ------------------------------------------------------------ + // + // The body is the JSON serialization of the upstream `result`, always with + // an application/json content type: an object, an array, a string, a number + // or `null` exactly as JSON. This is a new internal contract read with + // res.json() by one consumer, not a pipe of an upstream HTTP body, so the + // Express res.send quirks the proxied routes preserve (null as an empty + // body, strings as text/html, numbers as text) do not apply here. + + public static async Task Rpc(HttpContext ctx) + { + if (!Authorized(ctx)) + { + await Routes.Fallback(ctx); + return; + } + + var body = await ctx.ReadBody(); + var api = body.Str("api"); + var method = body.Str("method"); + // params must be present and structured (condenser methods take an + // array, bridge methods an object); the key and the upstream call are + // derived from that same node, so nothing is invented for either. + var @params = body.Field("params"); + if (api is null || method is null || @params is not (JsonObject or JsonArray) + || !Allowlist.TryGetValue($"{api}.{method}", out var policy)) + { + await Routes.Fallback(ctx); + return; + } + + var resolution = await Resolve(policy, @params); + ctx.Response.Headers["X-Ssr-Cache"] = resolution.Outcome.ToString().ToUpperInvariant(); + switch (resolution.Outcome) + { + case Outcome.Hit: + case Outcome.Miss: + case Outcome.Coalesced: + ctx.Response.StatusCode = 200; + ctx.Response.ContentType = "application/json; charset=utf-8"; + ctx.Response.ContentLength = resolution.Bytes.Length; + await ctx.Response.Body.WriteAsync(resolution.Bytes); + return; + case Outcome.Timeout: + await ctx.SendJson(504, new JsonObject { ["error"] = "Upstream Timeout" }); + return; + default: + await ctx.SendJson(502, new JsonObject { ["error"] = resolution.Error ?? "Upstream Error" }); + return; + } + } + + public static async Task Stats(HttpContext ctx) + { + if (!Authorized(ctx)) + { + await Routes.Fallback(ctx); + return; + } + + var methods = new JsonObject(); + foreach (var kv in Counters.OrderBy(kv => kv.Key, StringComparer.Ordinal)) + { + var c = kv.Value; + methods[kv.Key] = new JsonObject + { + ["hit"] = Interlocked.Read(ref c.Hit), + ["miss"] = Interlocked.Read(ref c.Miss), + ["coalesced"] = Interlocked.Read(ref c.Coalesced), + ["error"] = Interlocked.Read(ref c.Error), + ["timeout"] = Interlocked.Read(ref c.Timeout), + ["upstream_ms"] = Math.Round(c.ReadUpstreamMs(), 1), + }; + } + ctx.Response.Headers.CacheControl = "no-store"; + await ctx.SendJson(200, new JsonObject + { + ["cache"] = new JsonObject + { + ["bytes"] = Cache.Bytes, + ["count"] = Cache.Count, + ["budget"] = Cache.Budget, + }, + ["budget_ms"] = BudgetMs, + ["methods"] = methods, + }); + } +} diff --git a/dotnet/EcencyApi/Infrastructure/BytesCache.cs b/dotnet/EcencyApi/Infrastructure/BytesCache.cs new file mode 100644 index 00000000..26fce2df --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/BytesCache.cs @@ -0,0 +1,133 @@ +namespace EcencyApi.Infrastructure; + +/// +/// Bounded in-process cache of serialized responses, keyed by string, with a +/// per-entry TTL and least-recently-used eviction under a total-bytes budget. +/// +/// Exists for the SSR RPC cache: the values are whole JSON payloads (feeds run +/// to hundreds of KB) that are served to many readers unchanged, so they are +/// stored once as UTF-8 bytes and written straight to the response. MemCache +/// is not used for these on purpose: it deep-clones a JsonNode on every set and +/// every get, has no size bound, and would force a JsJson.Stringify per hit. +/// +/// Thread-safe via one lock; every operation is O(1) apart from eviction, +/// which removes as many tail entries as the budget requires. +/// +public sealed class BytesCache +{ + private sealed class Entry + { + public required byte[] Bytes; + public required long ExpiresAtMs; + public required LinkedListNode Node; + } + + private readonly Dictionary _map = new(); + // Head = least recently used, tail = most recently used. + private readonly LinkedList _lru = new(); + // Expiry order, so that under pressure every expired entry anywhere in + // the list is dropped before a live one is evicted, at O(log n) apiece. + // Keys are compared ordinally, the same way the dictionary compares them: + // they carry client-supplied JSON, and a culture-sensitive tie-break could + // collate two distinct keys as equal and desynchronize the two structures. + private static readonly IComparer<(long ExpiresAtMs, string Key)> ExpiryOrder = + Comparer<(long ExpiresAtMs, string Key)>.Create((a, b) => + { + var byTime = a.ExpiresAtMs.CompareTo(b.ExpiresAtMs); + return byTime != 0 ? byTime : string.CompareOrdinal(a.Key, b.Key); + }); + + private readonly SortedSet<(long ExpiresAtMs, string Key)> _byExpiry = new(ExpiryOrder); + private readonly object _lock = new(); + private long _bytes; + + public BytesCache(long budgetBytes) + { + Budget = Math.Max(0, budgetBytes); + } + + public long Budget { get; } + + public long Bytes { get { lock (_lock) return _bytes; } } + + public int Count { get { lock (_lock) return _map.Count; } } + + private static long NowMs => Environment.TickCount64; + + /// Fresh entry or nothing; an expired entry is dropped on the way. + public bool TryGet(string key, out byte[] bytes) + { + lock (_lock) + { + if (_map.TryGetValue(key, out var entry)) + { + if (entry.ExpiresAtMs > NowMs) + { + _lru.Remove(entry.Node); + _lru.AddLast(entry.Node); + bytes = entry.Bytes; + return true; + } + RemoveLocked(key, entry); + } + } + bytes = Array.Empty(); + return false; + } + + /// + /// Store a value for . A value larger than the whole + /// budget is not stored (it would evict everything for one reader). + /// + public void Set(string key, byte[] bytes, int ttlMs) + { + if (ttlMs <= 0 || bytes.Length > Budget) return; + lock (_lock) + { + if (_map.TryGetValue(key, out var existing)) + { + RemoveLocked(key, existing); + } + var node = _lru.AddLast(key); + var entry = new Entry { Bytes = bytes, ExpiresAtMs = NowMs + ttlMs, Node = node }; + _map[key] = entry; + _byExpiry.Add((entry.ExpiresAtMs, key)); + _bytes += bytes.Length; + if (_bytes > Budget) + { + PurgeExpiredLocked(); + } + while (_bytes > Budget && _lru.First is { } oldest && oldest != node) + { + RemoveLocked(oldest.Value, _map[oldest.Value]); + } + } + } + + // Every expired entry, wherever it sits in the LRU, goes before any live one. + private void PurgeExpiredLocked() + { + var now = NowMs; + while (_byExpiry.Count > 0) + { + var (expiresAt, key) = _byExpiry.Min; + if (expiresAt > now) break; + if (_map.TryGetValue(key, out var entry)) + { + RemoveLocked(key, entry); + } + else + { + _byExpiry.Remove((expiresAt, key)); + } + } + } + + private void RemoveLocked(string key, Entry entry) + { + _map.Remove(key); + _lru.Remove(entry.Node); + _byExpiry.Remove((entry.ExpiresAtMs, key)); + _bytes -= entry.Bytes.Length; + } +} diff --git a/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs b/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs index 151280fd..ca68c629 100644 --- a/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs +++ b/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs @@ -335,7 +335,7 @@ public static class HiveClients // portfolio engine/chain layers came back empty for everyone. GetAccounts // also routes around such a node at runtime, but keeping them out of the pool // means correctness here does not depend on that fallback firing. - public static readonly HiveRpcClient Default = new(new[] + public static readonly IReadOnlyList DefaultNodes = new[] { "https://api.hive.blog", "https://api.deathwing.me", @@ -345,5 +345,7 @@ public static class HiveClients "https://hive-api.3speak.tv", "https://api.syncad.com", "https://api.c0ff33a.uk", - }); + }; + + public static readonly HiveRpcClient Default = new(DefaultNodes.ToArray()); } diff --git a/dotnet/docker-compose.yml b/dotnet/docker-compose.yml index 20b863e6..829bd56c 100644 --- a/dotnet/docker-compose.yml +++ b/dotnet/docker-compose.yml @@ -28,6 +28,15 @@ services: - SOL_RPC_URLS - BTC_ESPLORA_URLS - Logging__LogLevel__Default + # SSR RPC cache (Handlers/SsrRpc.cs): the shared header secret switches the + # routes on; the rest tune the cache budget, lookup budget and node pool. + - SSR_INTERNAL_SECRET + - SSR_CACHE_BYTES + - SSR_RPC_BUDGET_MS + - SSR_RPC_NODE_TIMEOUT_MS + - SSR_RPC_NODES + - SSR_RPC_MAX_FILLS + - SSR_RPC_MAX_QUEUED_FILLS ports: - "4000:4000" # Bound container logs so they can never grow unchecked on the host