Skip to content

Commit 0f3ae13

Browse files
committed
Toy server: support the maintenance-notification opt-in, and sending events
Deliberately on RedisServer rather than a bespoke test subclass. The opt-in is an ordinary command, so any test should be able to use it, and a server that never sends a notification is the normal case - every OSS, Valkey and Garnet build behaves that way, and so will our own docker topology. The interesting behaviour is all client-side, so the fake should not be a special place. CLIENT MAINT_NOTIFICATIONS <ON|OFF> [parameter value ...] per the contract: a bare ON is valid and means "server defaults", moving-endpoint-type is the only parameter defined so far, and its five values are validated. Unknown parameters are refused rather than ignored - the client is asking the server to do something specific, and silently not doing it is worse than saying no. State is per connection, including a count, since re-arming after a reconnect is a requirement and a count is what distinguishes that from having opted in once. MaintenanceNotifications selects how the server answers: accept, reject as an unknown subcommand (what a server that never heard of it does), or reject as disabled (what one with the feature flag off does). A client has to survive all three, so a test has to be able to ask for all three. Sending covers MOVING with or without an address, the shard-scoped and slot-scoped families, explicit or generated sequence ids - the contract never defines those, so repeating one deliberately is part of what the fake owes us - and a raw-push hook for malformed frames. Notifications go only to connections that opted in; a raw push is not gated, which is the contrast the tests assert. Two bugs the tests caught: the count returned was clients *visited* rather than sent to, because ForAllClients' Action overload returns one per client regardless; and an assertion comparing against ClientCount was racy, since under RESP2 the subscription connection can register between the send and the read.
1 parent 322688f commit 0f3ae13

4 files changed

Lines changed: 412 additions & 0 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
using System;
2+
using System.Linq;
3+
using System.Threading.Tasks;
4+
using Xunit;
5+
using static StackExchange.Redis.Server.RedisServer;
6+
7+
namespace StackExchange.Redis.Tests;
8+
9+
/// <summary>
10+
/// The server half of the maintenance-notification contract, exercised directly. The client does not opt in
11+
/// yet, so these drive the command as a caller would - which is also how any other test will be able to opt
12+
/// in once the client does, since this is ordinary server functionality rather than a special test server.
13+
/// </summary>
14+
public class MaintenanceOptInServerTests(ITestOutputHelper log)
15+
{
16+
private static InProcessTestServer CreateServer(ITestOutputHelper log) => new(log);
17+
18+
private static async Task<RedisResult> OptInAsync(IConnectionMultiplexer conn, InProcessTestServer server, params object[] args)
19+
=> await conn.GetServer(server.DefaultEndPoint).ExecuteAsync("client", args.Prepend("maint_notifications").ToArray());
20+
21+
[Fact]
22+
public async Task BareOnIsAcceptedAndRecorded()
23+
{
24+
// "CLIENT MAINT_NOTIFICATIONS ON" with no parameters is explicitly valid, and means "server defaults"
25+
using var server = CreateServer(log);
26+
await using var conn = await server.ConnectAsync(defaultOnly: true);
27+
28+
Assert.Equal("OK", (string?)await OptInAsync(conn, server, "on"));
29+
30+
var client = Assert.Single(OptedIn(server));
31+
Assert.Null(client.MovingEndpointType); // server defaults, not a value we invented
32+
Assert.Equal(1, client.MaintenanceNotificationOptInCount);
33+
}
34+
35+
[Theory]
36+
[InlineData("internal-ip")]
37+
[InlineData("internal-fqdn")]
38+
[InlineData("external-ip")]
39+
[InlineData("external-fqdn")]
40+
[InlineData("none")]
41+
public async Task EveryDefinedEndpointTypeIsAccepted(string endpointType)
42+
{
43+
using var server = CreateServer(log);
44+
await using var conn = await server.ConnectAsync(defaultOnly: true);
45+
46+
Assert.Equal("OK", (string?)await OptInAsync(conn, server, "on", "moving-endpoint-type", endpointType));
47+
Assert.Equal(endpointType, Assert.Single(OptedIn(server)).MovingEndpointType);
48+
}
49+
50+
[Fact]
51+
public async Task OffClearsTheOptIn()
52+
{
53+
using var server = CreateServer(log);
54+
await using var conn = await server.ConnectAsync(defaultOnly: true);
55+
56+
await OptInAsync(conn, server, "on", "moving-endpoint-type", "external-fqdn");
57+
Assert.Single(OptedIn(server));
58+
59+
Assert.Equal("OK", (string?)await OptInAsync(conn, server, "off"));
60+
Assert.Empty(OptedIn(server));
61+
}
62+
63+
[Theory]
64+
[InlineData("sideways")] // not on/off
65+
[InlineData("on", "moving-endpoint-type", "sideways")] // undefined endpoint type
66+
[InlineData("on", "not-a-parameter", "value")] // unknown parameter
67+
[InlineData("on", "moving-endpoint-type")] // parameter with no value
68+
public async Task MalformedOptInIsRejected(params string[] args)
69+
{
70+
using var server = CreateServer(log);
71+
await using var conn = await server.ConnectAsync(defaultOnly: true);
72+
73+
var ex = await Assert.ThrowsAsync<RedisServerException>(
74+
async () => await OptInAsync(conn, server, args.Cast<object>().ToArray()));
75+
log.WriteLine(ex.Message);
76+
Assert.Empty(OptedIn(server));
77+
}
78+
79+
[Theory]
80+
[InlineData(MaintenanceNotificationSupport.UnknownSubcommand)]
81+
[InlineData(MaintenanceNotificationSupport.Disabled)]
82+
public async Task UnsupportingServerRejectsTheOptIn(MaintenanceNotificationSupport support)
83+
{
84+
// the two ways a real server refuses: it has never heard of the subcommand (OSS, Valkey, Garnet), or
85+
// it knows it and has the feature flag off. A client has to survive both
86+
using var server = CreateServer(log);
87+
server.MaintenanceNotifications = support;
88+
await using var conn = await server.ConnectAsync(defaultOnly: true);
89+
90+
var ex = await Assert.ThrowsAsync<RedisServerException>(
91+
async () => await OptInAsync(conn, server, "on"));
92+
log.WriteLine($"{support}: {ex.Message}");
93+
Assert.Empty(OptedIn(server));
94+
}
95+
96+
[Fact]
97+
public async Task NotificationsGoOnlyToConnectionsThatOptedIn()
98+
{
99+
// a real server sends to the connections that asked; sending to everything would let a client pass a
100+
// test it should fail, by receiving notifications it never subscribed to
101+
using var server = CreateServer(log);
102+
await using var optedIn = await server.ConnectAsync(defaultOnly: true);
103+
await using var notOptedIn = await server.ConnectAsync(defaultOnly: true);
104+
105+
await OptInAsync(optedIn, server, "on", "moving-endpoint-type", "external-fqdn");
106+
var subscribed = OptedIn(server).Count();
107+
log.WriteLine($"{subscribed} of {server.ClientCount} connections opted in");
108+
109+
var sent = server.SendShardNotification(null, MaintenanceNotificationKind.Migrating, timeSeconds: 5);
110+
Assert.Equal(subscribed, sent);
111+
Assert.NotEqual(server.ClientCount, sent);
112+
}
113+
114+
[Fact]
115+
public async Task SequenceIdsAdvanceAndCanBeRepeated()
116+
{
117+
// the contract never defines these, so a client's use of them is its own invention - which means being
118+
// able to repeat one deliberately is part of what the fake owes us
119+
using var server = CreateServer(log);
120+
await using var conn = await server.ConnectAsync(defaultOnly: true);
121+
await OptInAsync(conn, server, "on");
122+
123+
var first = server.NextMaintenanceSequenceId;
124+
Assert.Equal(1, server.SendShardNotification(null, MaintenanceNotificationKind.Migrating, 5));
125+
Assert.Equal(first + 1, server.NextMaintenanceSequenceId);
126+
127+
// and an explicit id does not advance the counter, so a replay stays a replay
128+
Assert.Equal(1, server.SendShardNotification(null, MaintenanceNotificationKind.Migrating, 5, sequenceId: first));
129+
Assert.Equal(first + 1, server.NextMaintenanceSequenceId);
130+
}
131+
132+
[Fact]
133+
public async Task RawPushIsNotGatedByOptIn()
134+
{
135+
// the malformed-payload hook, and the contrast is the point: with nobody opted in, a notification
136+
// reaches no one while a raw push still lands. Asserted as gated-versus-not rather than against
137+
// ClientCount, which is read at a different instant - under RESP2 the subscription connection can
138+
// register in between, so comparing totals is a race rather than a property
139+
using var server = CreateServer(log);
140+
await using var conn = await server.ConnectAsync(defaultOnly: true);
141+
142+
var gated = server.SendShardNotification(null, MaintenanceNotificationKind.Migrating, timeSeconds: 5);
143+
var ungated = server.SendRawPush(null, "NOT_A_REAL_KIND", "nonsense");
144+
log.WriteLine($"notification reached {gated}, raw push reached {ungated}");
145+
146+
Assert.Equal(0, gated);
147+
Assert.True(ungated > 0, "a raw push should reach connections that never opted in");
148+
}
149+
150+
private static System.Collections.Generic.IEnumerable<Server.RedisClient> OptedIn(InProcessTestServer server)
151+
{
152+
var found = new System.Collections.Generic.List<Server.RedisClient>();
153+
server.ForAllClients(c =>
154+
{
155+
if (c.MaintenanceNotifications) found.Add(c);
156+
});
157+
return found;
158+
}
159+
}

toys/StackExchange.Redis.Server/RedisClient.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,22 @@ public bool TryReadRequest(ReadOnlySequence<byte> data, out long consumed)
9999
}
100100

101101
public RedisServer.Node Node => node;
102+
103+
/// <summary>
104+
/// Whether this connection has opted in to maintenance notifications, and with which endpoint type -
105+
/// per connection, because that is how the real opt-in is scoped, so a test can assert that every
106+
/// connection opted in rather than merely that one did.
107+
/// </summary>
108+
public bool MaintenanceNotifications { get; internal set; }
109+
110+
/// <summary>The <c>moving-endpoint-type</c> this connection asked for, or null for server defaults.</summary>
111+
public string MovingEndpointType { get; internal set; }
112+
113+
/// <summary>
114+
/// How many times this connection has sent the opt-in. Re-arming on reconnect is a requirement, and a
115+
/// count is what distinguishes "opted in once" from "opted in again after reconnecting".
116+
/// </summary>
117+
public int MaintenanceNotificationOptInCount { get; internal set; }
102118
public int SkipReplies { get; set; }
103119
public void SkipAllReplies() => SkipReplies = -1;
104120
internal bool ShouldSkipResponse()
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
using System;
2+
using System.Net;
3+
using RESPite;
4+
using RESPite.Messages;
5+
6+
namespace StackExchange.Redis.Server
7+
{
8+
/// <summary>
9+
/// Sending maintenance notifications. Deliberately on the server itself rather than on a bespoke test
10+
/// subclass: the opt-in is a normal command any test may use, and injecting a notification is a normal
11+
/// thing any test may want to do. A server that never sends one is the ordinary case - that is what every
12+
/// OSS build does - so the interesting behaviour is on the client side either way.
13+
/// </summary>
14+
public partial class RedisServer
15+
{
16+
/// <summary>
17+
/// The notification types defined by the maintenance-notification contract. <c>SMOVING</c> and
18+
/// <c>SFAILING_OVER</c> were proposed upstream but never landed, and are deliberately absent.
19+
/// </summary>
20+
public enum MaintenanceNotificationKind
21+
{
22+
/// <summary>This endpoint is being replaced; the payload names its successor.</summary>
23+
Moving,
24+
25+
/// <summary>A shard is migrating away from this node.</summary>
26+
Migrating,
27+
28+
/// <summary>The migration has completed.</summary>
29+
Migrated,
30+
31+
/// <summary>This node is failing over.</summary>
32+
FailingOver,
33+
34+
/// <summary>The failover has completed.</summary>
35+
FailedOver,
36+
37+
/// <summary>Slots are migrating (OSS cluster family).</summary>
38+
SlotMigrating,
39+
40+
/// <summary>Slots have migrated (OSS cluster family).</summary>
41+
SlotMigrated,
42+
}
43+
44+
private static string GetName(MaintenanceNotificationKind kind) => kind switch
45+
{
46+
MaintenanceNotificationKind.Moving => "MOVING",
47+
MaintenanceNotificationKind.Migrating => "MIGRATING",
48+
MaintenanceNotificationKind.Migrated => "MIGRATED",
49+
MaintenanceNotificationKind.FailingOver => "FAILING_OVER",
50+
MaintenanceNotificationKind.FailedOver => "FAILED_OVER",
51+
MaintenanceNotificationKind.SlotMigrating => "SMIGRATING",
52+
MaintenanceNotificationKind.SlotMigrated => "SMIGRATED",
53+
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
54+
};
55+
56+
private int _maintenanceSequence;
57+
58+
/// <summary>
59+
/// The sequence id given to the next notification, unless one is supplied explicitly. The contract does
60+
/// not define these, so a client's use of them is its own invention - which is worth being able to
61+
/// exercise, including by repeating one.
62+
/// </summary>
63+
public int NextMaintenanceSequenceId => _maintenanceSequence + 1;
64+
65+
/// <summary>
66+
/// Sends <c>MOVING</c> to one client, or to every client that opted in when <paramref name="client"/>
67+
/// is null. A null <paramref name="newEndpoint"/> is the documented no-address form, which a client
68+
/// must handle whether or not it asked for <c>none</c>.
69+
/// </summary>
70+
/// <returns>The number of clients the notification was sent to.</returns>
71+
public int SendMoving(RedisClient client, int timeSeconds, EndPoint newEndpoint, int? sequenceId = null)
72+
=> Send(client, MaintenanceNotificationKind.Moving, timeSeconds, sequenceId, newEndpoint, null);
73+
74+
/// <summary>
75+
/// Sends one of the shard-scoped notifications. <paramref name="timeSeconds"/> is a remaining-time
76+
/// delta and may legitimately be zero or negative for a connection that arrived mid-window.
77+
/// </summary>
78+
/// <returns>The number of clients the notification was sent to.</returns>
79+
public int SendShardNotification(RedisClient client, MaintenanceNotificationKind kind, int timeSeconds, string shardIds = null, int? sequenceId = null)
80+
=> Send(client, kind, timeSeconds, sequenceId, null, shardIds);
81+
82+
/// <summary>
83+
/// Sends a slot-scoped notification (<c>SMIGRATING</c> / <c>SMIGRATED</c>) carrying a slot list in the
84+
/// contract's comma-and-range form, e.g. <c>"123,456,789-1000"</c>.
85+
/// </summary>
86+
/// <returns>The number of clients the notification was sent to.</returns>
87+
public int SendSlotNotification(RedisClient client, MaintenanceNotificationKind kind, string slots, int? sequenceId = null)
88+
=> Send(client, kind, null, sequenceId, null, slots);
89+
90+
/// <summary>
91+
/// Sends an arbitrary push frame to a client, for the cases a well-formed notification cannot express:
92+
/// an unknown type, a malformed payload, extra trailing elements.
93+
/// </summary>
94+
/// <returns>The number of clients the frame was sent to.</returns>
95+
public int SendRawPush(RedisClient client, params string[] parts)
96+
{
97+
var frame = TypedRedisValue.Rent(parts.Length, out var span, RespPrefix.Push);
98+
for (int i = 0; i < parts.Length; i++)
99+
{
100+
span[i] = TypedRedisValue.BulkString(parts[i]);
101+
}
102+
return Dispatch(client, frame, requireOptIn: false);
103+
}
104+
105+
private int Send(
106+
RedisClient client,
107+
MaintenanceNotificationKind kind,
108+
int? timeSeconds,
109+
int? sequenceId,
110+
EndPoint newEndpoint,
111+
string extra)
112+
{
113+
// [type, seqID, ...] - the sequence id is an integer, which is precisely why these frames could
114+
// not be treated as pub/sub: element 1 is not a channel name
115+
int count = 2 + (timeSeconds.HasValue ? 1 : 0) + (newEndpoint is not null || kind == MaintenanceNotificationKind.Moving ? 1 : 0) + (extra is not null ? 1 : 0);
116+
var frame = TypedRedisValue.Rent(count, out var span, RespPrefix.Push);
117+
118+
int index = 0;
119+
span[index++] = TypedRedisValue.SimpleString(GetName(kind));
120+
span[index++] = TypedRedisValue.Integer(sequenceId ?? System.Threading.Interlocked.Increment(ref _maintenanceSequence));
121+
if (timeSeconds.HasValue) span[index++] = TypedRedisValue.Integer(timeSeconds.GetValueOrDefault());
122+
if (kind == MaintenanceNotificationKind.Moving)
123+
{
124+
// null rather than absent when there is no address: the client must cope with both
125+
span[index++] = newEndpoint is null
126+
? TypedRedisValue.BulkString(RedisValue.Null)
127+
: TypedRedisValue.BulkString(Format.ToString(newEndpoint));
128+
}
129+
else if (newEndpoint is not null)
130+
{
131+
span[index++] = TypedRedisValue.BulkString(Format.ToString(newEndpoint));
132+
}
133+
if (extra is not null) span[index] = TypedRedisValue.BulkString(extra);
134+
135+
return Dispatch(client, frame, requireOptIn: true);
136+
}
137+
138+
private int Dispatch(RedisClient client, in TypedRedisValue frame, bool requireOptIn)
139+
{
140+
if (client is not null)
141+
{
142+
client.AddOutbound(frame);
143+
return 1;
144+
}
145+
146+
// a real server sends only to connections that asked, so sending to all means all *opted-in*.
147+
// Counting the sends rather than the clients visited: the Action overload of ForAllClients returns
148+
// one per client regardless, which would report every connection as a recipient
149+
var copy = frame;
150+
return ForAllClients(
151+
requireOptIn,
152+
(target, gated) =>
153+
{
154+
if (gated && !target.MaintenanceNotifications) return 0;
155+
target.AddOutbound(copy);
156+
return 1;
157+
});
158+
}
159+
}
160+
}

0 commit comments

Comments
 (0)