Azure / Azure/Microsoft.Azure.StackExchangeRedis

Excessive HELLO on stable connections under ConfigureForAzureWithTokenCredentialAsync (Entra token) — ~1 HELLO/connection/second, ~11–22% of all commands

Open
#93 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
C#
Stars
21
Forks
24
PR merge metrics
No merged PRs in 30d

Description

**Prepared for submission to** `Azure/Microsoft.Azure.StackExchangeRedis` (and possibly `StackExchange/StackExchange.Redis`).
**Date:** 2026-07-06.

## Summary

When a `ConnectionMultiplexer` authenticates to **Azure Managed Redis** with
`ConfigurationOptions.ConfigureForAzureWithTokenCredentialAsync(TokenCredential)` (Entra ID / managed identity),
the client re-issues the **full `HELLO` handshake on stable, long-lived connections at a rate of ~1 per
connection per second**. On a busy client this is **~11–22% of all commands** sent to the server
(`cmdstat_hello`). The **identical** connection authenticated with a **static access-key password** does **not**
exhibit this — `HELLO` occurs only at connection establishment.

This appears to be a defect: a persistent connection should send `HELLO` **once** (at handshake), and token
**re-authentication** should be an `AUTH` command near token expiry (which the extension does implement). We
observe neither expiry-driven nor `AUTH`-based re-auth — instead a continuous stream of `HELLO` on connections
that are hours old and never dropped.

## Environment

| | |
|---|---|
| `Microsoft.Azure.StackExchangeRedis` | **3.3.1** (latest) |
| `StackExchange.Redis` | **2.13.17** |
| `Azure.Identity` | 1.17.1 |
| Runtime | .NET 8 / .NET 10, Linux (Azure Container Apps) |
| Cache | **Azure Managed Redis**, Balanced B20, **OSS clustering policy**, port 10000, TLS, Redis 7.4 |
| Auth | Microsoft Entra ID via managed identity (`DefaultAzureCredential`); access keys normally disabled |
| Protocol | Reproduced on **both RESP2 and RESP3** |

## Expected behavior

Per the Microsoft Learn guidance ("Use Microsoft Entra for cache authentication with Azure Managed Redis"), the
extension holds persistent connections and **proactively re-authenticates ~before token expiry** (token lifetime
~hours). A persistent connection should therefore emit:
- `HELLO` **once** per physical connection (handshake), and
- `AUTH` **occasionally** (near token expiry, ~hourly), for in-place re-auth.

## Actual behavior

- `cmdstat_hello` grows at **~1 × (physical connection count) per second**, indefinitely, on connections that are
**not** being dropped/recreated.
- `cmdstat_auth` is **0** — the documented `AUTH`-based re-auth is not what's firing.
- Swapping **only** the credential to a static access-key password drops `cmdstat_hello` to ~0 (handshake only).

### Measurements (our production-shaped system, single-shard B20)

- **A/B across a fleet of clients, all else constant** (RESP2, same hosts, idle ~550 cmd/s):
- Entra token: **`cmdstat_hello` ≈ 77/s** (~11% of commands at idle; **~21% under load**), `cmdstat_auth` 0/s, new connections ~1.5/s, connection `age` = **hours**.
- Static access key: **`cmdstat_hello` ≈ 0.1/s**, `cmdstat_auth` 0/s.
- **Minimal single-client reproducer** (shared cache, low baseline): Entra token **5.14 HELLO/s** vs key **0.92 HELLO/s** (the 0.92 is shared-cache baseline + this client's initial handshake; on a dedicated idle cache the key run is ~0). One `ConnectionMultiplexer` here holds ~4 physical connections (OSS-cluster endpoint + shard node, × interactive+subscription on RESP2), i.e. ~1.25 HELLO/connection/s under the token credential.

### What we ruled out (evidence)

1. **Not token expiry.** The acquired token is valid ~24h; the extension logs `Acquired token with expiration: <+24h>` and there are **zero** `"Current token expired"` warnings while HELLO churns.
2. **Not the documented re-auth.** `AzureCacheOptionsProviderWithToken.ReauthenticateConnectionsAsync` issues `server.ExecuteAsync("AUTH", [User, Password])` (i.e. re-auth is `AUTH`, not `HELLO`), and it only runs on the ~2-minute heartbeat when the token is within ~5 min of expiry. `cmdstat_auth` measured **0/s** — consistent with a healthy 24h token, and confirming this path is **not** the source of the HELLO.
3. **Not reconnects.** `total_connections_received` grows ~1.5/s while HELLO is ~77/s; connections in `CLIENT LIST` are hours old (`age`).
4. **Not the SE.Redis heartbeat.** `cmdstat_ping` ≈ 1.5/s (≈ 1 per connection per ~46s); HELLO is ~46× that.
5. **The HELLO is AUTH-bearing.** `MONITOR` shows **0** HELLO during a window in which `cmdstat_hello` increased by 428 (over 6s) on the same node — i.e. Redis is redacting the command as it does for `AUTH`. So the churn is `HELLO … AUTH` (a full handshake carrying credentials), repeatedly, on established connections.

## Minimal reproduction

A self-contained reproducer (no product dependencies) is attached: `HelloRepro` (Program.cs + HelloRepro.csproj,
~120 lines; `README.md` documents the protocol). Against an otherwise-idle Azure Managed Redis:

```bash
# Entra token (managed identity in Azure; 'cli' locally after `az login`)
dotnet run -c Release -- --endpoint ..redis.azure.net:10000 --auth mi --seconds 60
# static access key
dotnet run -c Release -- --endpoint ..redis.azure.net:10000 --auth key --key --seconds 60
```

It connects one `ConnectionMultiplexer`, keeps it lightly active (one `GET`/s), and reports the server's
`cmdstat_hello` / `cmdstat_auth` delta rate. Expected: the `mi` run shows many HELLO/s (~1 × connection count),
the `key` run ~0. (Reproduces on both `--protocol resp2` and `resp3`.)

## Impact

On a single-shard cache, HELLO consuming ~11–22% of the per-node command budget is the **binding throughput
constraint** — it directly caps achievable ops/s. Our only mitigation today is to authenticate that connection
with a static access key (which reverses the recommended passwordless/managed-identity posture and requires key
rotation), or to accept the ~22% tax.

## Questions / ask

1. Is `HELLO` being re-issued on established connections (~1/connection/s) under a `TokenCredential`-backed
`DefaultOptionsProvider` **expected**? If so, what drives that cadence, and is it configurable?
2. If not expected: the handshake should occur once per physical connection, and token refresh should use `AUTH`
near expiry (as `ReauthenticateConnectionsAsync` already does) — the continuous `HELLO` looks like the client
re-running the full handshake unnecessarily. Could this be a `StackExchange.Redis` connection-maintenance
interaction with the dynamic `DefaultOptionsProvider` (`User`/`Password` re-read → re-handshake)?
3. Is there a supported way to keep managed-identity auth **and** avoid the per-connection HELLO churn (e.g. a
fix, a configuration flag, or a newer version)? `3.3.1` is the latest on NuGet as of this writing.

Happy to provide `CLIENT LIST`/`INFO commandstats` captures, the full investigation notes, or run the reproducer
against a build with a candidate fix.

Reproduction:

`// HelloRepro — minimal reproducer + "20%-HELLO test protocol" for the excessive-HELLO issue on
// Azure Managed Redis when authenticating with Microsoft.Azure.StackExchangeRedis (Entra token credential).
//
// SYMPTOM: a client authenticated via ConfigureForAzureWithTokenCredentialAsync(TokenCredential) causes
// StackExchange.Redis to re-issue the full HELLO handshake on STABLE, long-lived connections at ~1 per
// connection per second — showing up as ~11-22% of all commands to the server (cmdstat_hello). The SAME
// connection authenticated with a static access-key password emits HELLO only at connection-establishment
// (~0/s steady). It is NOT token expiry (a healthy multi-hour token) and NOT the documented Az.SE.Redis
// re-auth (that is an AUTH command; cmdstat_auth stays 0).
//
// PROTOCOL: run this against an OTHERWISE-IDLE cache twice, same everything except --auth, and compare
// cmdstat_hello/s:
// dotnet run -- --endpoint ..redis.azure.net:10000 --auth mi
// dotnet run -- --endpoint ..redis.azure.net:10000 --auth key --key
// Buggy library => mi HELLO/s >> key HELLO/s (mi churns ~1/conn/s; key ~0).
// FIXED library => mi HELLO/s ≈ key HELLO/s ≈ ~0 (only the initial handshake). <-- the gate for RD-022.
//
// --auth values: mi (DefaultAzureCredential) | cli (AzureCliCredential, for local az-login) | key (static key)
// Prereqs for mi/cli: the identity must be a Redis Data user on the cache. For key: access-key auth enabled.

using Azure.Identity;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using System.Text.RegularExpressions;

string? endpoint = null, key = null;
string auth = "mi", protocol = "resp2";
int seconds = 60;
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--endpoint": endpoint = args[++i]; break;
case "--auth": auth = args[++i]; break;
case "--key": key = args[++i]; break;
case "--protocol": protocol = args[++i]; break;
case "--seconds": seconds = int.Parse(args[++i]); break;
default: Console.Error.WriteLine($"unknown arg: {args[i]}"); return 2;
}
}
if (string.IsNullOrWhiteSpace(endpoint))
{
Console.Error.WriteLine("required: --endpoint [--auth mi|cli|key] [--key ] [--protocol resp2|resp3] [--seconds N]");
return 2;
}

using var loggerFactory = LoggerFactory.Create(b =>
b.AddSimpleConsole(o => { o.SingleLine = true; o.TimestampFormat = "HH:mm:ss "; })
.SetMinimumLevel(LogLevel.Information));

var cfg = ConfigurationOptions.Parse(endpoint);
cfg.AbortOnConnectFail = false;
cfg.ClientName = $"hello-repro-{auth}";
cfg.LoggerFactory = loggerFactory; // surfaces Az.SE.Redis token-lifecycle logs (proves the token is healthy, not expiring)
cfg.Protocol = protocol.Equals("resp3", StringComparison.OrdinalIgnoreCase) ? RedisProtocol.Resp3 : RedisProtocol.Resp2;

switch (auth.ToLowerInvariant())
{
case "mi": await cfg.ConfigureForAzureWithTokenCredentialAsync(new DefaultAzureCredential()); break;
case "cli": await cfg.ConfigureForAzureWithTokenCredentialAsync(new AzureCliCredential()); break;
case "key":
if (string.IsNullOrWhiteSpace(key)) { Console.Error.WriteLine("--key required for --auth key"); return 2; }
cfg.Password = key; // static access-key auth (default AMR user)
break;
default: Console.Error.WriteLine($"unknown --auth '{auth}' (use mi|cli|key)"); return 2;
}

Console.WriteLine($"connecting endpoint={endpoint} auth={auth} protocol={cfg.Protocol} window={seconds}s");
await using var mux = await ConnectionMultiplexer.ConnectAsync(cfg);
var db = mux.GetDatabase();
await db.PingAsync();
Console.WriteLine($"connected endpoints={mux.GetEndPoints().Length} (cmdstat_hello is GLOBAL — run on an idle cache so deltas are attributable to this client)");

long Calls(string cmd)
{
long total = 0;
foreach (var ep in mux.GetEndPoints())
{
var server = mux.GetServer(ep);
if (!server.IsConnected) continue;
var info = (string?)(server.Execute("INFO", "commandstats")) ?? "";
foreach (var line in info.Split('\n'))
if (line.StartsWith($"cmdstat_{cmd}:", StringComparison.Ordinal))
{
var m = Regex.Match(line, @"calls=(\d+)");
if (m.Success) total += long.Parse(m.Groups[1].Value);
}
}
return total;
}

long hello0 = Calls("hello"), auth0 = Calls("auth");
var sw = System.Diagnostics.Stopwatch.StartNew();
// Keep the connection lightly active, as a real app would (idle connections still churn HELLO; light traffic
// makes the per-activity re-handshake visible without dominating cmdstat).
while (sw.Elapsed.TotalSeconds < seconds)
{
await db.StringGetAsync("hello-repro:probe");
await Task.Delay(1000);
}
sw.Stop();
long hello1 = Calls("hello"), auth1 = Calls("auth");
double w = sw.Elapsed.TotalSeconds;

Console.WriteLine();
Console.WriteLine($"=== RESULT auth={auth} protocol={cfg.Protocol} window={w:N0}s ===");
Console.WriteLine($" cmdstat_hello : +{hello1 - hello0,-7} = {(hello1 - hello0) / w,7:N2}/s");
Console.WriteLine($" cmdstat_auth : +{auth1 - auth0,-7} = {(auth1 - auth0) / w,7:N2}/s");
Console.WriteLine();
Console.WriteLine(" Interpretation:");
Console.WriteLine(" - BUGGY library: --auth mi/cli shows HELLO ~1/connection/s (many/s); --auth key shows ~0/s.");
Console.WriteLine(" - FIXED library: --auth mi/cli HELLO/s falls to ~key's (~0/s, only the initial handshake).");
Console.WriteLine(" - cmdstat_auth should be ~0 in all cases (Az.SE.Redis re-auth is AUTH and only fires near token expiry).");
return 0;
`

Contributor guide

Open the contributing guide

Research direction

Start with the attached HelloRepro files, especially Program.cs, HelloRepro.csproj, and README.md, and run the documented managed-identity versus access-key commands on an otherwise idle cache. Trace ConfigureForAzureWithTokenCredentialAsync and ReauthenticateConnectionsAsync, then compare cmdstat_hello and cmdstat_auth; done means managed-identity authentication no longer produces continuous HELLO churn on stable connections.

Written by the indexing model from the issue text.

Assessment

Tech stack
azure, csharp, redis
Domain
authentication, databases
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.