perf: replace HashSet/ToLowerInvariant with FrozenSet/FrozenDictionary in ChannelHealth; eliminate List<string> in GatewayUsageInfo.DisplayText (#175)

ChannelHealth:
- s_healthyStatuses/s_intermediateStatuses: HashSet<string> → FrozenSet<string>
  (O(1) lookup, no per-call allocation; consistent with existing FrozenDictionary
  pattern in NotificationCategorizer and OpenClawGatewayClient)
- DisplayText: Status.ToLowerInvariant() switch → FrozenDictionary<string,string>
  s_statusLabels lookup (eliminates the lowercase string allocation on every
  channel health update)

GatewayUsageInfo.DisplayText:
- List<string>/string.Join → nullable string?[4] accumulator + string.Join
  overload with offset+count (single array allocation; no List wrapper)

Tests (+8):
- ChannelHealth.DisplayText_CaseInsensitiveLabelLookup (6 cases: RUNNING,
  Connected, READY, NOT CONFIGURED, Connecting, STOPPED)
- GatewayUsageInfo.DisplayText_PreservesPartOrder_TokensBeforeCostBeforeRequests
- GatewayUsageInfo.DisplayText_ModelOnlyWithTokens_SeparatedBySeparator

Result: 594 Shared passed, 20 skipped; 122 Tray passed (was 586+122)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
github-actions[bot] 2026-04-20 01:36:31 -04:00 committed by GitHub
parent c9255f9d94
commit eeac8d5c7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 94 additions and 30 deletions

View File

@ -1,3 +1,5 @@
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Globalization;
namespace OpenClaw.Shared;
@ -108,11 +110,37 @@ public class ChannelHealth
public string? AuthAge { get; set; }
public string? Type { get; set; }
private static readonly HashSet<string> s_healthyStatuses =
new(StringComparer.OrdinalIgnoreCase) { "ok", "connected", "running", "active", "ready" };
// FrozenSet gives O(1) case-insensitive lookup with no per-call allocation;
// these sets are never mutated after startup so FrozenSet is the correct choice.
private static readonly FrozenSet<string> s_healthyStatuses =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "ok", "connected", "running", "active", "ready" }
.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
private static readonly HashSet<string> s_intermediateStatuses =
new(StringComparer.OrdinalIgnoreCase) { "stopped", "idle", "paused", "configured", "pending", "connecting", "reconnecting" };
private static readonly FrozenSet<string> s_intermediateStatuses =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "stopped", "idle", "paused", "configured", "pending", "connecting", "reconnecting" }
.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
// Maps each status string (case-insensitive) to its tray label; never mutated after startup.
private static readonly FrozenDictionary<string, string> s_statusLabels =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["ok"] = "[ON]",
["connected"] = "[ON]",
["running"] = "[ON]",
["active"] = "[ON]",
["linked"] = "[LINKED]",
["ready"] = "[READY]",
["connecting"] = "[...]",
["reconnecting"] = "[...]",
["error"] = "[ERR]",
["disconnected"] = "[ERR]",
["stale"] = "[STALE]",
["configured"] = "[OFF]",
["stopped"] = "[OFF]",
["not configured"] = "[N/A]",
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Returns true if the given status string represents a healthy/running channel.
@ -131,18 +159,8 @@ public class ChannelHealth
{
get
{
var label = Status.ToLowerInvariant() switch
{
"ok" or "connected" or "running" or "active" => "[ON]",
"linked" => "[LINKED]",
"ready" => "[READY]",
"connecting" or "reconnecting" => "[...]",
"error" or "disconnected" => "[ERR]",
"stale" => "[STALE]",
"configured" or "stopped" => "[OFF]",
"not configured" => "[N/A]",
_ => "[OFF]"
};
// FrozenDictionary lookup avoids allocating a lowercased copy of Status.
var label = s_statusLabels.GetValueOrDefault(Status, "[OFF]");
var detail = IsLinked && AuthAge != null ? $"linked · {AuthAge}" : Status;
if (Error != null) detail += $" ({Error})";
return $"{label} {Capitalize(Name)}: {detail}";
@ -285,20 +303,31 @@ public class GatewayUsageInfo
{
get
{
var parts = new List<string>();
if (TotalTokens > 0)
parts.Add($"Tokens: {ModelFormatting.FormatLargeNumber(TotalTokens)}");
if (CostUsd > 0)
parts.Add("$" + CostUsd.ToString("F2", CultureInfo.InvariantCulture));
if (RequestCount > 0)
parts.Add($"{RequestCount} requests");
if (!string.IsNullOrEmpty(Model))
parts.Add(Model);
if (parts.Count == 0 && !string.IsNullOrEmpty(ProviderSummary))
parts.Add(ProviderSummary);
return parts.Count > 0
? string.Join(" · ", parts)
: "No usage data";
// Avoid allocating a List<string> + string.Join: accumulate up to 4 nullable
// string slots and build the result with a single switch expression.
string? p0 = TotalTokens > 0
? $"Tokens: {ModelFormatting.FormatLargeNumber(TotalTokens)}"
: null;
string? p1 = CostUsd > 0
? "$" + CostUsd.ToString("F2", CultureInfo.InvariantCulture)
: null;
string? p2 = RequestCount > 0
? $"{RequestCount} requests"
: null;
string? p3 = !string.IsNullOrEmpty(Model) ? Model : null;
// If all four are null, fall back to ProviderSummary or "No usage data".
if (p0 is null && p1 is null && p2 is null && p3 is null)
return string.IsNullOrEmpty(ProviderSummary) ? "No usage data" : ProviderSummary!;
// Pack non-null slots into a fixed-size array and join — one allocation.
var parts = new string?[4];
int n = 0;
if (p0 is not null) parts[n++] = p0;
if (p1 is not null) parts[n++] = p1;
if (p2 is not null) parts[n++] = p2;
if (p3 is not null) parts[n++] = p3;
return string.Join(" · ", parts, 0, n);
}
}

View File

@ -235,6 +235,19 @@ public class ChannelHealthTests
Assert.Contains(": ok", health.DisplayText);
}
[Theory]
[InlineData("RUNNING", "[ON]")]
[InlineData("Connected", "[ON]")]
[InlineData("READY", "[READY]")]
[InlineData("NOT CONFIGURED", "[N/A]")]
[InlineData("Connecting", "[...]")]
[InlineData("STOPPED", "[OFF]")]
public void DisplayText_CaseInsensitiveLabelLookup(string status, string expectedLabel)
{
var health = new ChannelHealth { Name = "ch", Status = status };
Assert.StartsWith(expectedLabel, health.DisplayText);
}
[Theory]
[InlineData("ok", true)]
[InlineData("connected", true)]
@ -573,6 +586,28 @@ public class GatewayUsageInfoTests
CultureInfo.CurrentUICulture = originalUiCulture;
}
}
[Fact]
public void DisplayText_PreservesPartOrder_TokensBeforeCostBeforeRequests()
{
var usage = new GatewayUsageInfo { TotalTokens = 1000, CostUsd = 0.50, RequestCount = 10 };
var display = usage.DisplayText;
var tokensIdx = display.IndexOf("Tokens:", StringComparison.Ordinal);
var costIdx = display.IndexOf("$", StringComparison.Ordinal);
var reqIdx = display.IndexOf("requests", StringComparison.Ordinal);
Assert.True(tokensIdx < costIdx && costIdx < reqIdx,
$"Parts should appear in order tokens·cost·requests but got: {display}");
}
[Fact]
public void DisplayText_ModelOnlyWithTokens_SeparatedBySeparator()
{
var usage = new GatewayUsageInfo { TotalTokens = 5000, Model = "gpt-4" };
var display = usage.DisplayText;
Assert.Contains(" · ", display);
Assert.Contains("Tokens:", display);
Assert.Contains("gpt-4", display);
}
}
public class GatewayNodeInfoTests