From adda158d818be3994d5a0fe6e11e550196db943e Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 21 Aug 2026 11:02:17 +0000 Subject: [PATCH] ssr rpc: send reads in the dotted method form so bridge reaches hivemind --- dotnet/EcencyApi.Tests/SsrRpcTests.cs | 40 +++++++++++++++++-- dotnet/EcencyApi/Handlers/SsrRpc.cs | 10 +++-- .../EcencyApi/Infrastructure/HiveRpcClient.cs | 31 +++++++++++++- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/dotnet/EcencyApi.Tests/SsrRpcTests.cs b/dotnet/EcencyApi.Tests/SsrRpcTests.cs index 71a97d77..027d4194 100644 --- a/dotnet/EcencyApi.Tests/SsrRpcTests.cs +++ b/dotnet/EcencyApi.Tests/SsrRpcTests.cs @@ -27,6 +27,7 @@ private sealed class RpcStub : IAsyncDisposable public int DelayMs; public bool RpcError; public bool NullResult; + public volatile string LastMethod = ""; public RpcStub() { @@ -53,10 +54,27 @@ private async Task Loop() { reqBody = await reader.ReadToEndAsync(); } - var method = JsonNode.Parse(reqBody)?["params"]?[1]?.GetValue() ?? "?"; + // hived semantics: the legacy `call` envelope resolves only hived + // APIs, so a bridge read sent that way is an RPC error; the dotted + // form (`bridge.get_post`) is routed to hivemind. + var req = JsonNode.Parse(reqBody); + var rawMethod = req?["method"]?.GetValue() ?? "?"; + string method; + var legacyBridge = false; + if (rawMethod == "call") + { + var api = req?["params"]?[0]?.GetValue() ?? "?"; + method = req?["params"]?[1]?.GetValue() ?? "?"; + legacyBridge = api == "bridge"; + } + else + { + method = rawMethod.Contains('.') ? rawMethod[(rawMethod.IndexOf('.') + 1)..] : rawMethod; + } + LastMethod = rawMethod; if (DelayMs > 0) await Task.Delay(DelayMs); - var body = RpcError - ? "{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"message\":\"stub error\"}}" + var body = RpcError || legacyBridge + ? "{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"message\":\"" + (legacyBridge ? "Assert Exception:api_itr != data._registered_apis.end(): Could not find API bridge" : "stub error") + "\"}}" : NullResult ? "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":null}" : "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"method\":\"" + method + "\",\"n\":" + n + ",\"text\":\"caf\\u00e9 \\ud83d\"}}"; @@ -109,6 +127,22 @@ public async Task Concurrent_misses_for_one_key_make_one_upstream_call() Assert.Contains("\"method\":\"get_post\"", bodies[0]); } + [Fact] + public async Task Reads_use_the_dotted_method_form_so_bridge_reaches_hivemind() + { + await using var stub = new RpcStub(); + Use(stub); + var r = await SsrRpc.Resolve(Post, P("a", "b")); + Assert.Equal(SsrRpc.Outcome.Miss, r.Outcome); + Assert.Equal("bridge.get_post", stub.LastMethod); + var props = await SsrRpc.Resolve(Props, new JsonArray()); + Assert.Equal(SsrRpc.Outcome.Miss, props.Outcome); + Assert.Equal("condenser_api.get_dynamic_global_properties", stub.LastMethod); + // The legacy envelope would have been refused for bridge, as hived does. + var legacy = new HiveRpcClient(new[] { stub.Url }, timeoutMs: 1000, failoverThreshold: 1); + await Assert.ThrowsAsync(() => legacy.Call("bridge", "get_post", P("a", "b"))); + } + [Fact] public async Task Second_call_is_a_hit_with_the_same_bytes_and_params_order_does_not_matter() { diff --git a/dotnet/EcencyApi/Handlers/SsrRpc.cs b/dotnet/EcencyApi/Handlers/SsrRpc.cs index 1c2c4e32..811c1d03 100644 --- a/dotnet/EcencyApi/Handlers/SsrRpc.cs +++ b/dotnet/EcencyApi/Handlers/SsrRpc.cs @@ -349,10 +349,12 @@ private static async Task Fill(MethodPolicy policy, JsonNode @params, string key } } 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()); + // The dotted method form: hived's legacy `call` dispatcher has no API + // named `bridge` (found on alpha: every bridge read failed with + // "Could not find API bridge"), while `bridge.get_post` is routed to + // hivemind. The node already hangs off the request body and cannot be + // re-parented into the envelope, so it travels as a clone. + var result = await Client.CallMethod($"{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); diff --git a/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs b/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs index ca68c629..e58447fe 100644 --- a/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs +++ b/dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs @@ -66,10 +66,13 @@ public RpcException(string message) : base(message) { } /// valid 200 with a usable array, so shape validation passes and the latency EWMA /// keeps such a node ranked first — silently blanking every metadata-derived /// feature (portfolio engine/chain token visibility) with no error and no log. - public async Task Call(string api, string method, JsonNode @params, + public Task Call(string api, string method, JsonNode @params, Func? validateResult = null, Func? preferResult = null) { + // The legacy `call` envelope the Node service always sent. hived resolves + // it for its own APIs (condenser_api, database_api); hivemind's `bridge` + // is not one of them, so bridge reads must use CallMethod. var request = new JsonObject { ["id"] = Interlocked.Increment(ref _seq), @@ -77,6 +80,32 @@ public RpcException(string message) : base(message) { } ["method"] = "call", ["params"] = new JsonArray(api, method, @params), }; + return Send(request, method, validateResult, preferResult); + } + + /// + /// The modern JSON-RPC form, `"method": "bridge.get_post"` with the params + /// as given, which jussi/HAF route to hived or hivemind by prefix. Needed + /// for every hivemind (`bridge`) read; works for condenser_api too. + /// + public Task CallMethod(string qualifiedMethod, JsonNode @params, + Func? validateResult = null, + Func? preferResult = null) + { + var request = new JsonObject + { + ["id"] = Interlocked.Increment(ref _seq), + ["jsonrpc"] = "2.0", + ["method"] = qualifiedMethod, + ["params"] = @params, + }; + return Send(request, qualifiedMethod, validateResult, preferResult); + } + + private async Task Send(JsonObject request, string method, + Func? validateResult, + Func? preferResult) + { // JsJson: a lone-surrogate username from a client token must serialize // (JSON.stringify semantics) instead of throwing in the writer. var body = JsJson.Stringify(request);