Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions dotnet/EcencyApi.Tests/SsrRpcTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
public int DelayMs;
public bool RpcError;
public bool NullResult;
public volatile string LastMethod = "";

public RpcStub()
{
Expand All @@ -53,10 +54,27 @@
{
reqBody = await reader.ReadToEndAsync();
}
var method = JsonNode.Parse(reqBody)?["params"]?[1]?.GetValue<string>() ?? "?";
// 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>() ?? "?";
string method;
var legacyBridge = false;
if (rawMethod == "call")
{
var api = req?["params"]?[0]?.GetValue<string>() ?? "?";
method = req?["params"]?[1]?.GetValue<string>() ?? "?";
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\"}}";
Expand Down Expand Up @@ -102,13 +120,29 @@
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));

Check warning on line 123 in dotnet/EcencyApi.Tests/SsrRpcTests.cs

View workflow job for this annotation

GitHub Actions / test

Do not use a Where clause to filter before calling Assert.Single. Use the overload of Assert.Single that accepts a filtering function. (https://xunit.net/xunit.analyzers/rules/xUnit2031)
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 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<HiveRpcClient.RpcException>(() => 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()
{
Expand Down Expand Up @@ -315,8 +349,8 @@
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);

Check warning on line 352 in dotnet/EcencyApi.Tests/SsrRpcTests.cs

View workflow job for this annotation

GitHub Actions / test

Test methods should not use blocking task operations, as they can cause deadlocks. Use an async test method and await instead. (https://xunit.net/xunit.analyzers/rules/xUnit1031)
Assert.Equal(SsrRpc.Outcome.Timeout, b.Result.Outcome);

Check warning on line 353 in dotnet/EcencyApi.Tests/SsrRpcTests.cs

View workflow job for this annotation

GitHub Actions / test

Test methods should not use blocking task operations, as they can cause deadlocks. Use an async test method and await instead. (https://xunit.net/xunit.analyzers/rules/xUnit1031)
// Judged by the fresh attach, not by the original enqueue: the fill ran.
await Task.Delay(500);
Assert.Equal(2, stub.Hits);
Expand Down
10 changes: 6 additions & 4 deletions dotnet/EcencyApi/Handlers/SsrRpc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
31 changes: 30 additions & 1 deletion dotnet/EcencyApi/Infrastructure/HiveRpcClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,46 @@ 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.</param>
public async Task<JsonNode?> Call(string api, string method, JsonNode @params,
public Task<JsonNode?> Call(string api, string method, JsonNode @params,
Func<JsonNode?, bool>? validateResult = null,
Func<JsonNode?, bool>? 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),
["jsonrpc"] = "2.0",
["method"] = "call",
["params"] = new JsonArray(api, method, @params),
};
return Send(request, method, validateResult, preferResult);
}

/// <summary>
/// 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.
/// </summary>
public Task<JsonNode?> CallMethod(string qualifiedMethod, JsonNode @params,
Func<JsonNode?, bool>? validateResult = null,
Func<JsonNode?, bool>? 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<JsonNode?> Send(JsonObject request, string method,
Func<JsonNode?, bool>? validateResult,
Func<JsonNode?, bool>? 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);
Expand Down
Loading