Skip to content

Commit f921197

Browse files
committed
rpc pool: rank nodes per call class, not on one blended latency EWMA
The tracker kept one latency EWMA per node across every call shape, so the ranking was learned from the calls that dominate by count (point reads) and then used to pick a node for feed-shaped queries, whose cost is several times higher and varies by an order of magnitude between nodes. Latency is now kept per (node, call class). Health stays node-wide: a node that is not answering is not answering for any class. The SSR cache classifies every allowlisted method and the three feed-shaped reads are heavy; SSR_RPC_CALL_CLASSES=0 collapses it back to one profile without a rebuild. Stats gain per-node heavy_ewma_ms/heavy_samples plus a per-method class.
1 parent 7a56a1e commit f921197

10 files changed

Lines changed: 494 additions & 73 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ Handlers are `public static async Task Name(HttpContext ctx)` methods on static
6868

6969
## Upstream node failover
7070

71-
`NodeHealthTracker` (adopted from the vision-web SDK) holds per-node health state: 429 responses park a node for `Retry-After` (or an escalating window), recent failures deprioritize it, and a latency EWMA orders the pool best-first with config order as tiebreak. Two clients build on it:
71+
`NodeHealthTracker` (adopted from the vision-web SDK) holds per-node health state: 429 responses park a node for `Retry-After` (or an escalating window), recent failures deprioritize it, and a latency EWMA orders the pool best-first with config order as tiebreak. That EWMA is kept per **call class** (`CallClass.Cheap` / `CallClass.Heavy`) because upstream cost is bimodal: a point read costs a fraction of a feed-shaped query, while which node is quickest differs between the two. A caller says which class a call belongs to and the pool is ordered from that class's profile. Everything about *whether* a node is answering (consecutive failures, failure parking, rate-limit parking, half-open admission) stays node-wide. Two clients build on it:
7272

7373
- `HiveRpcClient` (Hive JSON-RPC): RPC-level errors (JSON `error` field) surface immediately without failover — they're application errors, not node health. The typed helpers additionally validate the result *shape* (`get_accounts` → array, `get_dynamic_global_properties` → object): a 200 with valid JSON but no usable result is a node failure that fails over — without this, a node serving malformed 200s is recorded as healthy and stays ranked first (observed in production as multi-hour windows of token-validation 401s).
7474
- `EngineRpcClient` (Hive-Engine): one instance per pool — the `/contracts` RPC pool and the history-API pool. The portfolio `Find` calls are fixed-shape queries that always yield a `result` array on a healthy node, so an error payload or non-JSON body *is* a node failure and rolls over to the next node. The raw passthroughs (`engine-api`, `engine-account-history`) fail over only on transport errors and 429/5xx; other responses belong to the caller's query and pipe as-is.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ docker run -it --rm -p 4000:4000 \
6060
| `SSR_RPC_NODE_TIMEOUT_MS` | per-node timeout of the SSR RPC cache's own client, one attempt per node (default `1200`) |
6161
| `SSR_RPC_NODES` | comma-separated node pool for that client (default: the shared pool) |
6262
| `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 |
63+
| `SSR_RPC_CALL_CLASSES` | order the node pool from a per-call-class latency profile (default on); `0`, `false` or `off` files every read under one profile |
6364

6465
## Swarm
6566

dotnet/EcencyApi.Tests/HiveRpcFailoverTests.cs

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public class HiveRpcFailoverTests
1717
private sealed class StubNode : IAsyncDisposable
1818
{
1919
private readonly HttpListener _listener = new();
20-
private readonly Func<int> _handler; // returns HTTP status; 200 => valid RPC result
20+
private readonly Func<string, int> _handler; // returns HTTP status; 200 => valid RPC result
2121
public string Url { get; }
2222
public int Hits;
2323

@@ -31,7 +31,14 @@ private sealed class StubNode : IAsyncDisposable
3131
"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":[{\"name\":\"served-by\",\"port\":\"" + Url
3232
+ "\",\"posting_json_metadata\":" + (ServesMetadata ? "\"{\\\"profile\\\":{}}\"" : "\"\"") + "}]}";
3333

34-
public StubNode(Func<int> handler)
34+
public StubNode(Func<int> handler) : this(_ => handler())
35+
{
36+
}
37+
38+
/// <param name="handler">Given the qualified method of the request, returns
39+
/// the scripted status. Lets one node answer point reads quickly and feed
40+
/// queries slowly, which is the shape the call-class split exists for.</param>
41+
public StubNode(Func<string, int> handler)
3542
{
3643
_handler = handler;
3744
var port = GetFreePort();
@@ -50,7 +57,12 @@ private async Task Loop()
5057
catch { return; }
5158

5259
Interlocked.Increment(ref Hits);
53-
var status = _handler();
60+
string requestBody;
61+
using (var reader = new StreamReader(ctx.Request.InputStream))
62+
{
63+
requestBody = await reader.ReadToEndAsync();
64+
}
65+
var status = _handler(MethodOf(requestBody));
5466
byte[] body;
5567
if (status == 200)
5668
{
@@ -113,6 +125,25 @@ private async Task Loop()
113125
}
114126
}
115127

128+
/// <summary>The qualified method of a JSON-RPC request, in either the
129+
/// dotted form or the legacy `call` envelope.</summary>
130+
private static string MethodOf(string body)
131+
{
132+
try
133+
{
134+
var req = JsonNode.Parse(body);
135+
var method = req?["method"]?.GetValue<string>() ?? "";
136+
return method == "call"
137+
? (req?["params"]?[0]?.GetValue<string>() ?? "") + "." +
138+
(req?["params"]?[1]?.GetValue<string>() ?? "")
139+
: method;
140+
}
141+
catch
142+
{
143+
return "";
144+
}
145+
}
146+
116147
private static int GetFreePort()
117148
{
118149
var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
@@ -472,6 +503,65 @@ public async Task ProvenSlowNode_IsDemotedByLatencyEwma()
472503
Assert.True(fast.Hits >= 1);
473504
}
474505

506+
// The call-class split, end to end: upstream cost is bimodal, so a node can be
507+
// the right choice for point reads and the wrong one for feed queries. With a
508+
// single latency profile per node the ranking is learned from whichever class
509+
// dominates by count and then used to pick a node for the other.
510+
[Fact]
511+
public async Task ANodeSlowOnlyOnFeedQueries_KeepsThePointReadsAndLosesTheFeeds()
512+
{
513+
// -2 answers 200 after 1.5s, above the 1s unproven prior; 200 is immediate.
514+
await using var mixed = new StubNode(m => m.StartsWith("bridge.", StringComparison.Ordinal) ? -2 : 200);
515+
await using var spare = new StubNode(_ => 200);
516+
517+
var client = new HiveRpcClient(new[] { mixed.Url, spare.Url }, timeoutMs: 5000, failoverThreshold: 1);
518+
519+
// Both classes start unproven, so config order sends them to the first node.
520+
for (var i = 0; i < 3; i++)
521+
{
522+
await client.Call("condenser_api", "get_accounts", new JsonArray(), callClass: CallClass.Cheap);
523+
await client.CallMethod("bridge.get_ranked_posts", new JsonObject(), callClass: CallClass.Heavy);
524+
}
525+
Assert.Equal(6, mixed.Hits);
526+
Assert.Equal(0, spare.Hits);
527+
528+
// Its heavy profile is now trusted and above the prior, so the next feed
529+
// query explores the node nothing is known about...
530+
await client.CallMethod("bridge.get_ranked_posts", new JsonObject(), callClass: CallClass.Heavy);
531+
Assert.Equal(1, spare.Hits);
532+
533+
// ...while point reads stay where they are measured to be quick.
534+
await client.Call("condenser_api", "get_accounts", new JsonArray(), callClass: CallClass.Cheap);
535+
Assert.Equal(7, mixed.Hits);
536+
Assert.Equal(1, spare.Hits);
537+
538+
// Same node, two profiles, learned from their own samples only.
539+
var view = client.HealthSnapshot()[0]!;
540+
Assert.Equal(4, view["samples"]!.GetValue<int>());
541+
Assert.Equal(3, view["heavy_samples"]!.GetValue<int>());
542+
Assert.True(view["ewma_ms"]!.GetValue<double>() < 1000);
543+
Assert.True(view["heavy_ewma_ms"]!.GetValue<double>() > 1000);
544+
}
545+
546+
[Fact]
547+
public async Task ACallerThatMakesOnePointReadShape_LeavesTheHeavyProfileEmpty()
548+
{
549+
// The default class: a client whose calls are all one shape keeps exactly
550+
// one profile per node, as it did before classes existed.
551+
await using var only = new StubNode(() => 200);
552+
553+
var client = new HiveRpcClient(new[] { only.Url }, timeoutMs: 1500);
554+
for (var i = 0; i < 3; i++)
555+
{
556+
await client.Call("condenser_api", "get_accounts", new JsonArray());
557+
}
558+
559+
var view = client.HealthSnapshot()[0]!;
560+
Assert.Equal(3, view["samples"]!.GetValue<int>());
561+
Assert.Equal(0, view["heavy_samples"]!.GetValue<int>());
562+
Assert.Null(view["heavy_ewma_ms"]);
563+
}
564+
475565
[Fact]
476566
public async Task MalformedResultNode_FailsOverWithoutRetry()
477567
{
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
using EcencyApi.Infrastructure;
2+
using Xunit;
3+
4+
namespace EcencyApi.Tests;
5+
6+
/// <summary>
7+
/// The health tracker's per-call-class latency, driven directly with an
8+
/// injected clock: latency is the only thing that splits by class, everything
9+
/// that decides whether a node is answering at all stays node-wide.
10+
/// </summary>
11+
public class NodeCallClassTests
12+
{
13+
private static (NodeHealthTracker Tracker, Action<long> Advance) Build(int nodes)
14+
{
15+
long now = 0;
16+
return (new NodeHealthTracker(nodes, () => now), ms => now += ms);
17+
}
18+
19+
private static double? Ewma(NodeHealthTracker t, int node, CallClass cls) =>
20+
t.Snapshot()[node].Latency.First(l => l.Class == cls).EwmaMs;
21+
22+
private static int Samples(NodeHealthTracker t, int node, CallClass cls) =>
23+
t.Snapshot()[node].Latency.First(l => l.Class == cls).Samples;
24+
25+
[Fact]
26+
public void EachClassKeepsItsOwnLatencyProfile()
27+
{
28+
var (t, _) = Build(1);
29+
for (var i = 0; i < 3; i++) t.RecordSuccess(0, 100, CallClass.Cheap);
30+
for (var i = 0; i < 3; i++) t.RecordSuccess(0, 1500, CallClass.Heavy);
31+
32+
Assert.Equal(100, Ewma(t, 0, CallClass.Cheap));
33+
Assert.Equal(1500, Ewma(t, 0, CallClass.Heavy));
34+
Assert.Equal(3, Samples(t, 0, CallClass.Cheap));
35+
Assert.Equal(3, Samples(t, 0, CallClass.Heavy));
36+
}
37+
38+
[Fact]
39+
public void ANodeQuickOnPointReadsAndSlowOnFeedQueries_LeadsOnlyTheCheapOrdering()
40+
{
41+
// The whole point of the split: node 0 wins the cheap ranking on its own
42+
// measurements and must NOT carry that win into the heavy ranking, where
43+
// it is slower than a node nothing is known about.
44+
var (t, _) = Build(2);
45+
for (var i = 0; i < 3; i++)
46+
{
47+
t.RecordSuccess(0, 100, CallClass.Cheap);
48+
t.RecordSuccess(0, 1500, CallClass.Heavy);
49+
}
50+
51+
Assert.Equal(new[] { 0, 1 }, t.OrderedNodeIndices(CallClass.Cheap));
52+
Assert.Equal(new[] { 1, 0 }, t.OrderedNodeIndices(CallClass.Heavy));
53+
}
54+
55+
[Fact]
56+
public void OneClassGoingStale_LeavesTheOtherProfileAlone()
57+
{
58+
// Staleness is per class. A class the traffic has moved away from
59+
// becoming unproven again is exploration, not a penalty: the node keeps
60+
// its other profile and all of its health.
61+
var (t, advance) = Build(2);
62+
advance(1_000); // a profile stamped at tick 0 reads as never stamped
63+
for (var i = 0; i < 3; i++)
64+
{
65+
t.RecordSuccess(0, 100, CallClass.Cheap);
66+
t.RecordSuccess(0, 1500, CallClass.Heavy);
67+
}
68+
69+
advance(6 * 60_000);
70+
t.RecordSuccess(0, 120, CallClass.Cheap);
71+
72+
Assert.Equal(1, Samples(t, 0, CallClass.Cheap)); // reset and re-learning
73+
Assert.Equal(120, Ewma(t, 0, CallClass.Cheap));
74+
Assert.Equal(3, Samples(t, 0, CallClass.Heavy)); // untouched
75+
Assert.Equal(1500, Ewma(t, 0, CallClass.Heavy));
76+
// ...but stale, so it no longer orders anything: node 0 scores the prior
77+
// for heavy, so config order breaks the tie with the untried node.
78+
Assert.Equal(new[] { 0, 1 }, t.OrderedNodeIndices(CallClass.Heavy));
79+
}
80+
81+
[Fact]
82+
public void ATimeoutIsALatencySample_ForTheClassThatTimedOut()
83+
{
84+
// Floored above the unproven prior so a node that never answers a heavy
85+
// query cannot outrank nodes never tried for one. The cheap profile learns
86+
// nothing from it, because nothing cheap was measured.
87+
var (t, _) = Build(2);
88+
for (var i = 0; i < 3; i++) t.RecordFailure(0, 300, CallClass.Heavy, timedOut: true);
89+
90+
Assert.True(Ewma(t, 0, CallClass.Heavy) > 1000);
91+
Assert.Equal(3, Samples(t, 0, CallClass.Heavy));
92+
Assert.Null(Ewma(t, 0, CallClass.Cheap));
93+
Assert.Equal(0, Samples(t, 0, CallClass.Cheap));
94+
}
95+
96+
[Fact]
97+
public void AFailureParkedNode_IsSkippedForEveryClass()
98+
{
99+
// "Not answering" is not a per-class property: a parked node is out of
100+
// both orderings while any other node can take the call.
101+
var (t, _) = Build(2);
102+
for (var i = 0; i < 3; i++) t.RecordFailure(0, 300, CallClass.Heavy, timedOut: true);
103+
104+
Assert.Equal(new[] { 1 }, t.OrderedNodeIndices(CallClass.Heavy));
105+
Assert.Equal(new[] { 1 }, t.OrderedNodeIndices(CallClass.Cheap));
106+
}
107+
108+
[Fact]
109+
public void ARateLimitedNode_SortsLastForEveryClass()
110+
{
111+
var (t, _) = Build(2);
112+
for (var i = 0; i < 3; i++) t.RecordSuccess(0, 10, CallClass.Cheap);
113+
for (var i = 0; i < 3; i++) t.RecordSuccess(0, 10, CallClass.Heavy);
114+
t.RecordRateLimited(0, 5_000);
115+
116+
Assert.Equal(new[] { 1, 0 }, t.OrderedNodeIndices(CallClass.Cheap));
117+
Assert.Equal(new[] { 1, 0 }, t.OrderedNodeIndices(CallClass.Heavy));
118+
}
119+
120+
[Fact]
121+
public void ARecentFailureOnOneClass_DemotesTheNodeForBoth()
122+
{
123+
// Deliberate. It is also the narrow scope of the split: only the latency
124+
// score is per class. A node that just failed is a node that just failed,
125+
// whatever the call was, so it sorts behind clean nodes for everything.
126+
var (t, _) = Build(2);
127+
for (var i = 0; i < 3; i++) t.RecordSuccess(0, 10, CallClass.Cheap);
128+
t.RecordFailure(0, 50, CallClass.Heavy);
129+
130+
Assert.Equal(new[] { 1, 0 }, t.OrderedNodeIndices(CallClass.Cheap));
131+
Assert.Equal(new[] { 1, 0 }, t.OrderedNodeIndices(CallClass.Heavy));
132+
}
133+
134+
[Fact]
135+
public void ASuccessOnOneClass_ClearsNodeWideFailureState()
136+
{
137+
var (t, _) = Build(2);
138+
for (var i = 0; i < 3; i++) t.RecordFailure(0, 300, CallClass.Heavy, timedOut: true);
139+
Assert.Equal(new[] { 1 }, t.OrderedNodeIndices(CallClass.Cheap));
140+
141+
t.RecordSuccess(0, 20, CallClass.Cheap);
142+
143+
Assert.Equal(2, t.OrderedNodeIndices(CallClass.Cheap).Count);
144+
Assert.Equal(0, t.Snapshot()[0].FailureParkedForMs);
145+
}
146+
}

dotnet/EcencyApi.Tests/SsrRpcTests.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ public async ValueTask DisposeAsync()
9595
}
9696

9797
private static readonly SsrRpc.MethodPolicy Post = SsrRpc.Allowlist["bridge.get_post"];
98+
private static readonly SsrRpc.MethodPolicy Ranked = SsrRpc.Allowlist["bridge.get_ranked_posts"];
9899
private static readonly SsrRpc.MethodPolicy Props = SsrRpc.Allowlist["condenser_api.get_dynamic_global_properties"];
99100

100101
private static void Use(RpcStub stub, long cacheBytes = 1 << 20, int budgetMs = 1500, int maxFills = 64, int maxQueued = 256)
@@ -106,6 +107,7 @@ private static void Use(RpcStub stub, long cacheBytes = 1 << 20, int budgetMs =
106107
SsrRpc.MaxQueuedFills = maxQueued;
107108
SsrRpc.SecretDigest = null;
108109
SsrRpc.Now = () => Environment.TickCount64;
110+
SsrRpc.CallClasses = true;
109111
SsrRpc.ResetForTests();
110112
}
111113

@@ -630,4 +632,51 @@ public void Allowlist_is_read_only_and_names_every_method_the_consumer_routes()
630632
Assert.False(SsrRpc.Allowlist.ContainsKey("condenser_api.broadcast_transaction"));
631633
Assert.False(SsrRpc.Allowlist.ContainsKey("database_api.get_accounts"));
632634
}
635+
636+
[Fact]
637+
public void Allowlist_classifies_the_feed_shaped_reads_as_heavy()
638+
{
639+
// A page of feed rows or a whole comment tree, built per request by
640+
// hivemind; the rest are point reads. The pool is ordered from the
641+
// latency profile of the class, so a misclassified method is ranked on
642+
// measurements of a different call shape.
643+
var heavy = SsrRpc.Allowlist.Values
644+
.Where(p => p.Class == CallClass.Heavy)
645+
.Select(p => p.Key)
646+
.OrderBy(k => k, StringComparer.Ordinal)
647+
.ToArray();
648+
Assert.Equal(
649+
new[] { "bridge.get_account_posts", "bridge.get_discussion", "bridge.get_ranked_posts" },
650+
heavy);
651+
}
652+
653+
[Fact]
654+
public async Task A_heavy_read_is_measured_in_the_heavy_profile()
655+
{
656+
await using var stub = new RpcStub();
657+
Use(stub);
658+
659+
await SsrRpc.Resolve(Ranked, new JsonObject { ["sort"] = "trending", ["tag"] = "" });
660+
await SsrRpc.Resolve(Post, P("a", "b"));
661+
662+
var view = SsrRpc.Client.HealthSnapshot()[0]!;
663+
Assert.Equal(1, view["samples"]!.GetValue<int>());
664+
Assert.Equal(1, view["heavy_samples"]!.GetValue<int>());
665+
}
666+
667+
[Fact]
668+
public async Task Call_classes_off_files_every_read_under_the_cheap_profile()
669+
{
670+
// The kill switch restores the single-profile ordering this service had
671+
// before call classes existed, without a rebuild.
672+
await using var stub = new RpcStub();
673+
Use(stub);
674+
SsrRpc.CallClasses = false;
675+
676+
await SsrRpc.Resolve(Ranked, new JsonObject { ["sort"] = "trending", ["tag"] = "" });
677+
678+
var view = SsrRpc.Client.HealthSnapshot()[0]!;
679+
Assert.Equal(1, view["samples"]!.GetValue<int>());
680+
Assert.Equal(0, view["heavy_samples"]!.GetValue<int>());
681+
}
633682
}

dotnet/EcencyApi/Config.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ public static class Config
6565
public static int SsrMaxQueuedFills { get; } =
6666
int.TryParse(Env("SSR_RPC_MAX_QUEUED_FILLS"), out var q) && q > 0 ? q : 256;
6767

68+
/// <summary>
69+
/// Whether the cache orders the node pool per call class (see CallClass) or
70+
/// files every read under one profile, which is how it behaved before classes
71+
/// existed. On by default; set to 0 to collapse it on a running deployment
72+
/// without a rebuild. Break-glass, so the off spellings are permissive.
73+
/// </summary>
74+
public static bool SsrCallClasses { get; } =
75+
Env("SSR_RPC_CALL_CLASSES")?.Trim().ToLowerInvariant() is not ("0" or "false" or "off");
76+
6877
private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
6978

7079
private static string? Env(string name) => Environment.GetEnvironmentVariable(name);

0 commit comments

Comments
 (0)