Skip to content

Commit a1673ea

Browse files
authored
Close the test-suite gaps from #3234 (#3235)
* Close the test-suite gaps from #3234 Four separate things, all small, none of which the suite could tell us about because each was either hidden behind a gate or only visible as a flake. **LatencyTests could not run at all.** GetHistory and GetLatest issue `server.Execute("debug", "sleep", "0.5")`, and DEBUG is not in the exclusion list in Message.RequiresDatabase - so it requires a database, while IServer.Execute has none to give and refuses outright. Both tests failed deterministically the moment RunLongRunning was set; nothing sets it, so nobody noticed. Routed through IDatabase instead, which keeps the database and works. There is only one endpoint in this configuration, so it still lands on the server being measured. Note this leaves a real limitation in place: IServer.Execute cannot issue any command that RequiresDatabase says needs one, even where the subcommand plainly does not - DEBUG SLEEP needs no database, DEBUG OBJECT does, and the command token alone cannot tell them apart. Worth its own decision rather than a change smuggled in here. **UnroutableRedirect's remaining race.** The target is made unaddressable by SetAnnouncedAddress *after* AddEmptyNode has already published it, leaving a window where a topology refresh can see it still announcing an address, route straight to it, and leave no redirect to test. Under RESP2 the subscription connection completing can trigger exactly such a refresh. Added an AddEmptyNode overload that fixes the announced address before publication, so the node is never observable half-configured. An overload rather than an optional parameter: StackExchange.Redis.Server is a shipped package. This one matters more than a flake normally would: as the test's own comment says, when the target becomes routable "every other test in this class silently stops testing anything" - so it fails open, and a green run proved less than it appeared to. 15/15 clean after. **CountKeys was flaky by construction.** It flushed two dedicated databases fire-and-forget on a connection it disposed immediately, then wrote and counted on a different one; nothing ordered the flushes against the writes, so a loaded run counted leftovers. Awaited instead. 10/10 clean. **AGENTS.md described the test override wrongly** - as a gitignored TestConfig.json. It is RedisTestConfig.json, it is tracked, and it is read as an embedded resource, so editing it needs a rebuild and dirties the tree rather than acting as a private override. Also documents RunLongRunning, since that is what hides the gated tests above. * Review: target the primary explicitly, and refuse a contradictory announced address - LatencyTests now takes the server via GetAnyPrimary and sends DEBUG SLEEP with DemandMaster, so both provably reach the same node. My previous comment said there was only one endpoint; that is true of this configuration today - the replica is not discovered as an endpoint, and GetEndPoints()[0] is the primary - but resting on it was incidental, and this makes the guarantee explicit. - AddEmptyNode's new overload threw away an explicit announced address for a DnsEndPoint, because ApplyNameOnlyIdentity runs afterwards and forces Empty for name-only identities. Refuse that combination instead of quietly doing something else, and say so in the docs. - Points at #3236 for the IServer.Execute limitation, which turns out to be a v3 regression affecting far more than DEBUG.
1 parent b734861 commit a1673ea

5 files changed

Lines changed: 58 additions & 15 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ Expected servers (defaults in `tests/StackExchange.Redis.Tests/Helpers/TestConfi
9595
- `7000`-`7005` cluster nodes
9696
- `7010`/`7011` + `26379`-`26381` sentinel
9797

98-
Tests skip as *inconclusive* when their required server is absent (e.g. cluster tests skip with "Unable to connect to server"). Override hosts/ports for local runs with a `tests/StackExchange.Redis.Tests/TestConfig.json` (gitignored). A stray container squatting on `6379` is a common failure: it makes the primary reachable but leaves no replica/cluster, so replica/cluster tests fail or skip — clear it before bringing the compose up.
98+
Tests skip as *inconclusive* when their required server is absent (e.g. cluster tests skip with "Unable to connect to server"). Override hosts/ports for local runs by editing `tests/StackExchange.Redis.Tests/RedisTestConfig.json` — note that it is **tracked**, and is read as an *embedded resource* rather than from disk, so changes need a rebuild and show up as a dirty working tree rather than acting as a private override. `RunLongRunning` there also unlocks the tests gated behind `Skip.UnlessLongRunning()`, which CI never runs. A stray container squatting on `6379` is a common failure: it makes the primary reachable but leaves no replica/cluster, so replica/cluster tests fail or skip — clear it before bringing the compose up.
9999

100100
To probe these servers ad hoc, the local user may have `resp-cli` installed — a `dotnet` global tool that is functionally similar to `redis-cli` (same basic flags: `-p`, `-a`, `-n`, `--tls`, `-3`). Prefer `resp-cli` when it's available; fall back to `redis-cli` otherwise.
101101

‎tests/StackExchange.Redis.Tests/DatabaseTests.cs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,11 @@ public async Task CountKeys()
7676
Skip.IfMissingDatabase(conn, db1Id);
7777
Skip.IfMissingDatabase(conn, db2Id);
7878
var server = GetAnyPrimary(conn);
79-
server.FlushDatabase(db1Id, CommandFlags.FireAndForget);
80-
server.FlushDatabase(db2Id, CommandFlags.FireAndForget);
79+
80+
// awaited, not fire-and-forget: the writes below happen on a *different* connection, so nothing
81+
// orders them against these flushes, and an unlucky run counts the leftovers too
82+
await server.FlushDatabaseAsync(db1Id);
83+
await server.FlushDatabaseAsync(db2Id);
8184
}
8285
await using (var conn = Create(defaultDatabase: db2Id))
8386
{

‎tests/StackExchange.Redis.Tests/LatencyTests.cs‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,18 @@ public async Task GetLatest()
4141
Skip.UnlessLongRunning();
4242
await using var conn = Create(allowAdmin: true);
4343

44-
var server = conn.GetServer(conn.GetEndPoints()[0]);
44+
var server = GetAnyPrimary(conn);
4545
server.ConfigSet("latency-monitor-threshold", 50);
4646
server.LatencyReset();
4747
var arr = server.LatencyLatest();
4848
Assert.Empty(arr);
4949

5050
var now = await server.TimeAsync();
51-
server.Execute("debug", "sleep", "0.5"); // cause something to be slow
51+
// Via IDatabase, not IServer: DEBUG is not in Message.RequiresDatabase's exclusion list, so
52+
// IServer.Execute - which has no database to offer - refuses it outright (see #3236).
53+
// DemandMaster so this provably lands on the same node being measured above, rather than
54+
// relying on the test topology happening to expose a single endpoint.
55+
conn.GetDatabase().Execute("debug", ["sleep", "0.5"], CommandFlags.DemandMaster); // cause something to be slow
5256

5357
arr = await server.LatencyLatestAsync();
5458
var item = Assert.Single(arr);
@@ -64,14 +68,18 @@ public async Task GetHistory()
6468
Skip.UnlessLongRunning();
6569
await using var conn = Create(allowAdmin: true);
6670

67-
var server = conn.GetServer(conn.GetEndPoints()[0]);
71+
var server = GetAnyPrimary(conn);
6872
server.ConfigSet("latency-monitor-threshold", 50);
6973
server.LatencyReset();
7074
var arr = server.LatencyHistory("command");
7175
Assert.Empty(arr);
7276

7377
var now = await server.TimeAsync();
74-
server.Execute("debug", "sleep", "0.5"); // cause something to be slow
78+
// Via IDatabase, not IServer: DEBUG is not in Message.RequiresDatabase's exclusion list, so
79+
// IServer.Execute - which has no database to offer - refuses it outright (see #3236).
80+
// DemandMaster so this provably lands on the same node being measured above, rather than
81+
// relying on the test topology happening to expose a single endpoint.
82+
conn.GetDatabase().Execute("debug", ["sleep", "0.5"], CommandFlags.DemandMaster); // cause something to be slow
7583

7684
arr = await server.LatencyHistoryAsync("command");
7785
var item = Assert.Single(arr);

‎tests/StackExchange.Redis.Tests/UnroutableRedirectUnitTests.cs‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,16 @@ private static InProcessTestServer CreateServer(ITestOutputHelper log, out EndPo
4141
var server = CreateServer(log, out var targetEndpoint);
4242
var conn = await server.ConnectAsync(defaultOnly: true);
4343

44-
var target = server.AddEmptyNode(targetEndpoint);
45-
46-
// the target must be unaddressable by *every* form, not merely un-named. Hostnames are preferred and
44+
// The target must be unaddressable by *every* form, not merely un-named. Hostnames are preferred and
4745
// it announces none, so the endpoint field is "?" - but the complement rule would then put its address
48-
// in the metadata, and a topology refresh landing between here and the command would discover it there
49-
// and route straight to it, leaving no redirect to test. Marking the address unknown as well removes
50-
// that race by construction rather than relying on the refresh not happening: under RESP2 the
51-
// subscription connection completing can trigger one, which is why this only ever failed on RESP2
52-
server.SetAnnouncedAddress(target, AnnouncedAddress.Empty);
46+
// in the metadata, and a topology refresh discovering it there would route straight to it, leaving no
47+
// redirect to test. Under RESP2 the subscription connection completing can trigger such a refresh,
48+
// which is why this only ever failed there.
49+
//
50+
// Announced at construction rather than set afterwards: setting it on the next line leaves the node
51+
// visible, briefly, still announcing an address - a window a refresh landing at the wrong moment can
52+
// see. That was the remaining flake.
53+
var target = server.AddEmptyNode(targetEndpoint, AnnouncedAddress.Empty);
5354

5455
server.Migrate((RedisKey)Key, target);
5556
return (conn, server);

‎toys/StackExchange.Redis.Server/RedisServer.cs‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,11 +299,42 @@ public enum NodeFlags
299299
/// existing key, so it cannot produce a node whose identity form differs from its peers'.
300300
/// </summary>
301301
public EndPoint AddEmptyNode(EndPoint endpoint, NodeFlags flags = NodeFlags.None)
302+
=> AddEmptyNodeCore(endpoint, flags, announced: null);
303+
304+
/// <summary>
305+
/// Add an empty node, fixing what it announces as its own address before it becomes visible.
306+
/// </summary>
307+
/// <remarks>
308+
/// <para>
309+
/// Setting this afterwards, via <see cref="SetAnnouncedAddress"/>, leaves a window in which a topology
310+
/// read can observe the node still announcing an address - which is enough to make a client route
311+
/// straight to it rather than take the redirect a test was trying to provoke.
312+
/// </para>
313+
/// <para>
314+
/// A <see cref="DnsEndPoint"/> takes a name-only identity, which cannot also announce an address;
315+
/// asking for one throws rather than quietly ignoring the argument.
316+
/// </para>
317+
/// </remarks>
318+
public EndPoint AddEmptyNode(EndPoint endpoint, AnnouncedAddress announced, NodeFlags flags = NodeFlags.None)
319+
=> AddEmptyNodeCore(endpoint, flags, announced);
320+
321+
private EndPoint AddEmptyNodeCore(EndPoint endpoint, NodeFlags flags, AnnouncedAddress? announced)
302322
{
303323
if (endpoint is null) throw new ArgumentNullException(nameof(endpoint));
324+
if (announced is { } requested && requested != AnnouncedAddress.Empty && endpoint is DnsEndPoint)
325+
{
326+
// ApplyNameOnlyIdentity below would overwrite it; refuse the contradiction rather than
327+
// silently doing something else
328+
throw new ArgumentException(
329+
$"A name-only node cannot announce {requested}: {Format.ToString(endpoint)}", nameof(announced));
330+
}
331+
304332
var node = new Node(this, endpoint, flags);
333+
if (announced is { } value) node.Announced = value;
305334
node.UpdateSlots([]); // explicit empty range (rather than implicit "all nodes")
306335
ApplyNameOnlyIdentity(endpoint, node);
336+
337+
// published last, so nothing above is observable in a half-configured state
307338
if (!_nodes.TryAdd(endpoint, node))
308339
{
309340
throw new ArgumentException($"Node already exists: {Format.ToString(endpoint)}", nameof(endpoint));

0 commit comments

Comments
 (0)