Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public static IServiceCollection AddDiscordBot(this IServiceCollection services)
}));
services.AddSingleton<IUserDmSender, DiscordUserDmSender>();
services.AddSingleton<RenderGate>();
services.AddSingleton<IChannelEditPacer, ChannelEditPacer>();
services.AddSingleton<DiscordChannelMessenger>();
services.AddHostedService<DiscordBotService>();

Expand Down
72 changes: 72 additions & 0 deletions src/RustPlusBot.Discord/Posting/ChannelEditPacer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Collections.Concurrent;
using RustPlusBot.Abstractions.Time;

namespace RustPlusBot.Discord.Posting;

/// <summary>
/// Per-channel edit pacer. Discord meters message edits per channel; the workspace refreshes several
/// #info messages back-to-back on one tick, and a zero-gap burst exhausts that bucket, so Discord.Net
/// preemptively waits on the tail edits and logs a rate-limit warning each time. This spreads the
/// burst: successive edits to the same channel are held at least <c>minGap</c> apart. An edit to an
/// idle channel (the common steady-state case) is not delayed at all.
/// </summary>
public sealed class ChannelEditPacer : IChannelEditPacer
{
private readonly IClock _clock;
private readonly TimeSpan _minGap;

/// <summary>The earliest instant the next edit to each channel may be sent.</summary>
private readonly ConcurrentDictionary<ulong, DateTimeOffset> _nextAllowed = new();

/// <summary>Uses the default one-second spacing, comfortably under Discord's per-channel edit limit.</summary>
/// <param name="clock">Supplies the current time.</param>
public ChannelEditPacer(IClock clock) : this(clock, TimeSpan.FromSeconds(1))
{
}

/// <summary>Creates a pacer with an explicit minimum spacing between edits to a channel.</summary>
/// <param name="clock">Supplies the current time.</param>
/// <param name="minGap">The minimum spacing between successive edits to one channel; zero disables pacing.</param>
/// <exception cref="ArgumentOutOfRangeException">The gap is negative.</exception>
public ChannelEditPacer(IClock clock, TimeSpan minGap)
{
ArgumentNullException.ThrowIfNull(clock);
ArgumentOutOfRangeException.ThrowIfLessThan(minGap, TimeSpan.Zero);
_clock = clock;
_minGap = minGap;
}

/// <inheritdoc />
public async Task PaceAsync(ulong channelId, CancellationToken cancellationToken)
{
var wait = Reserve(channelId);
if (wait > TimeSpan.Zero)
{
await Task.Delay(wait, cancellationToken).ConfigureAwait(false);
}
}

/// <summary>
/// Atomically reserves this call's edit slot for the channel and returns how long the caller must
/// wait before sending it. Pure with respect to the clock, so the pacing decision is testable
/// without real delays.
/// </summary>
/// <param name="channelId">The Discord channel the edit targets.</param>
/// <returns>The wait before the reserved slot; <see cref="TimeSpan.Zero" /> when the channel is idle.</returns>
public TimeSpan Reserve(ulong channelId)
{
var now = _clock.UtcNow;

// Reserve the slot after this one, starting from whichever is later: now, or the slot a
// concurrent/prior edit already claimed. The value returned is that next slot.
var nextAllowed = _nextAllowed.AddOrUpdate(
channelId,
now + _minGap,
(_, reserved) => (reserved > now ? reserved : now) + _minGap);

// This call's own slot is one gap before the slot it just reserved for the following edit.
var plannedSend = nextAllowed - _minGap;
var wait = plannedSend - now;
return wait > TimeSpan.Zero ? wait : TimeSpan.Zero;
}
}
19 changes: 19 additions & 0 deletions src/RustPlusBot.Discord/Posting/IChannelEditPacer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace RustPlusBot.Discord.Posting;

/// <summary>
/// Spaces out our own message edits to a channel so a burst never exhausts Discord's per-channel
/// edit bucket (which otherwise makes Discord.Net preemptively throttle the tail of the burst and
/// log a rate-limit warning). Shared process-wide, so every writer to a channel is paced against
/// the same clock.
/// </summary>
public interface IChannelEditPacer
{
/// <summary>
/// Reserves the next edit slot for the channel and, if that slot is in the future, waits until it
/// is due. Call immediately before editing; the first edit to an idle channel returns at once.
/// </summary>
/// <param name="channelId">The Discord channel the edit targets.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A task that completes when the caller may send its edit.</returns>
Task PaceAsync(ulong channelId, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ namespace RustPlusBot.Features.Workspace.Reconciler;
/// <param name="renderers">All registered message renderers.</param>
/// <param name="gate">Suppresses PATCHes for renders that did not change.</param>
/// <param name="reconciler">The full reconcile, used to self-heal a missing or stale message.</param>
/// <param name="pacer">Spaces this tick's edits so the burst does not exhaust the channel's edit bucket.</param>
internal sealed class ServerInfoRefresher(
IWorkspaceStore store,
IWorkspaceGateway gateway,
IEnumerable<IMessageRenderer> renderers,
RenderGate gate,
IWorkspaceReconciler reconciler) : IServerInfoRefresher
IWorkspaceReconciler reconciler,
IChannelEditPacer pacer) : IServerInfoRefresher
{
/// <summary>The #info message keys this path refreshes, in declaration order.</summary>
private static readonly string[] Keys =
Expand Down Expand Up @@ -69,6 +71,9 @@ public async Task RefreshAsync(ulong guildId, Guid serverId, CancellationToken c

try
{
// Space this edit from the previous one to the same channel so the tick's burst of #info
// edits does not exhaust Discord's per-channel edit bucket (an idle channel is not delayed).
await pacer.PaceAsync(record.DiscordChannelId, cancellationToken).ConfigureAwait(false);
await gateway
.EditMessageAsync(guildId, record.DiscordChannelId, record.DiscordMessageId, payload,
cancellationToken)
Expand Down
84 changes: 84 additions & 0 deletions tests/RustPlusBot.Discord.Tests/Posting/ChannelEditPacerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using RustPlusBot.Abstractions.Time;
using RustPlusBot.Discord.Posting;

namespace RustPlusBot.Discord.Tests.Posting;

public sealed class ChannelEditPacerTests
{
private const ulong ChannelA = 500;
private const ulong ChannelB = 501;
private static readonly TimeSpan Gap = TimeSpan.FromSeconds(1);

[Fact]
public void A_negative_gap_is_rejected()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ChannelEditPacer(new TestClock(), TimeSpan.FromSeconds(-1)));
}

[Fact]
public void A_zero_gap_disables_pacing_without_throwing()
{
var pacer = new ChannelEditPacer(new TestClock(), TimeSpan.Zero);

Assert.Equal(TimeSpan.Zero, pacer.Reserve(ChannelA));
Assert.Equal(TimeSpan.Zero, pacer.Reserve(ChannelA));
}

[Fact]
public void First_edit_to_a_channel_waits_zero()
{
var pacer = new ChannelEditPacer(new TestClock(), Gap);

Assert.Equal(TimeSpan.Zero, pacer.Reserve(ChannelA));
}

[Fact]
public void Successive_edits_in_the_same_instant_are_staggered_by_the_gap()
{
var pacer = new ChannelEditPacer(new TestClock(), Gap);

Assert.Equal(TimeSpan.Zero, pacer.Reserve(ChannelA));
Assert.Equal(Gap, pacer.Reserve(ChannelA));
Assert.Equal(Gap * 2, pacer.Reserve(ChannelA));
}

[Fact]
public void An_edit_after_the_gap_has_elapsed_waits_zero()
{
var clock = new TestClock();
var pacer = new ChannelEditPacer(clock, Gap);

pacer.Reserve(ChannelA);
clock.UtcNow = clock.UtcNow.Add(Gap);

Assert.Equal(TimeSpan.Zero, pacer.Reserve(ChannelA));
}

[Fact]
public void An_edit_partway_through_the_gap_waits_only_the_remainder()
{
var clock = new TestClock();
var pacer = new ChannelEditPacer(clock, Gap);

pacer.Reserve(ChannelA);
clock.UtcNow = clock.UtcNow.Add(TimeSpan.FromMilliseconds(400));

Assert.Equal(TimeSpan.FromMilliseconds(600), pacer.Reserve(ChannelA));
}

[Fact]
public void Channels_are_paced_independently()
{
var pacer = new ChannelEditPacer(new TestClock(), Gap);

pacer.Reserve(ChannelA);

Assert.Equal(TimeSpan.Zero, pacer.Reserve(ChannelB));
}

private sealed class TestClock : IClock
{
public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UnixEpoch;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public async Task First_refresh_edits_the_message()
var gateway = Substitute.For<IWorkspaceGateway>();
var reconciler = Substitute.For<IWorkspaceReconciler>();
var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer],
new RenderGate(), reconciler);
new RenderGate(), reconciler, Pacer());

await refresher.RefreshAsync(GuildId, ServerId, default);

Expand All @@ -58,7 +58,7 @@ public async Task Unchanged_render_is_not_sent_twice()
var gateway = Substitute.For<IWorkspaceGateway>();
var reconciler = Substitute.For<IWorkspaceReconciler>();
var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer],
new RenderGate(), reconciler);
new RenderGate(), reconciler, Pacer());

await refresher.RefreshAsync(GuildId, ServerId, default);
await refresher.RefreshAsync(GuildId, ServerId, default);
Expand All @@ -75,7 +75,7 @@ public async Task Changed_render_is_sent_again()
var gateway = Substitute.For<IWorkspaceGateway>();
var reconciler = Substitute.For<IWorkspaceReconciler>();
var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer],
new RenderGate(), reconciler);
new RenderGate(), reconciler, Pacer());

await refresher.RefreshAsync(GuildId, ServerId, default);
renderer.Title = "v2";
Expand All @@ -95,7 +95,7 @@ public async Task Missing_message_row_escalates_to_full_reconcile()
.Returns((ProvisionedMessage?)null);
var gateway = Substitute.For<IWorkspaceGateway>();
var reconciler = Substitute.For<IWorkspaceReconciler>();
var refresher = new ServerInfoRefresher(store, gateway, [renderer], new RenderGate(), reconciler);
var refresher = new ServerInfoRefresher(store, gateway, [renderer], new RenderGate(), reconciler, Pacer());

await refresher.RefreshAsync(GuildId, ServerId, default);

Expand All @@ -122,7 +122,7 @@ public async Task Edit_failure_escalates_and_invalidates_the_gate()
var reconciler = Substitute.For<IWorkspaceReconciler>();
var gate = new RenderGate();
var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer], gate,
reconciler);
reconciler, Pacer());

await refresher.RefreshAsync(GuildId, ServerId, default);

Expand All @@ -141,7 +141,7 @@ public async Task Empty_payload_is_skipped_without_editing()
var gateway = Substitute.For<IWorkspaceGateway>();
var reconciler = Substitute.For<IWorkspaceReconciler>();
var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer],
new RenderGate(), reconciler);
new RenderGate(), reconciler, Pacer());

await refresher.RefreshAsync(GuildId, ServerId, default);

Expand All @@ -164,13 +164,50 @@ public async Task Refreshes_all_three_info_messages()
WorkspaceMessageKeys.ServerTeam);
var gateway = Substitute.For<IWorkspaceGateway>();
var refresher = new ServerInfoRefresher(store, gateway, renderers, new RenderGate(),
Substitute.For<IWorkspaceReconciler>());
Substitute.For<IWorkspaceReconciler>(), Pacer());

await refresher.RefreshAsync(GuildId, ServerId, default);

Assert.All(renderers.Cast<StubRenderer>(), r => Assert.Equal(1, r.Calls));
}

[Fact]
public async Task Each_edit_is_paced_before_it_is_sent()
{
var renderer = new StubRenderer(WorkspaceMessageKeys.ServerInfo, "v1");
var gateway = Substitute.For<IWorkspaceGateway>();
var pacer = Pacer();
var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer],
new RenderGate(), Substitute.For<IWorkspaceReconciler>(), pacer);

await refresher.RefreshAsync(GuildId, ServerId, default);

Received.InOrder(() =>
{
_ = pacer.PaceAsync(ChannelId, Arg.Any<CancellationToken>());
_ = gateway.EditMessageAsync(GuildId, ChannelId, MessageId, Arg.Any<MessagePayload>(),
Arg.Any<CancellationToken>());
});
}

[Fact]
public async Task A_skipped_unchanged_render_is_not_paced()
{
var renderer = new StubRenderer(WorkspaceMessageKeys.ServerInfo, "v1");
var gateway = Substitute.For<IWorkspaceGateway>();
var pacer = Pacer();
var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer],
new RenderGate(), Substitute.For<IWorkspaceReconciler>(), pacer);

await refresher.RefreshAsync(GuildId, ServerId, default);
await refresher.RefreshAsync(GuildId, ServerId, default);

// Second render is identical, so the gate suppresses the edit — and the pace with it.
await pacer.Received(1).PaceAsync(ChannelId, Arg.Any<CancellationToken>());
}

private static IChannelEditPacer Pacer() => Substitute.For<IChannelEditPacer>();

private sealed class StubRenderer(string key, string title) : IMessageRenderer
{
public int Calls { get; private set; }
Expand Down
Loading