diff --git a/src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs b/src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs index 1cbc19c8..41ab09bb 100644 --- a/src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs @@ -33,6 +33,7 @@ public static IServiceCollection AddDiscordBot(this IServiceCollection services) })); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddHostedService(); diff --git a/src/RustPlusBot.Discord/Posting/ChannelEditPacer.cs b/src/RustPlusBot.Discord/Posting/ChannelEditPacer.cs new file mode 100644 index 00000000..053469de --- /dev/null +++ b/src/RustPlusBot.Discord/Posting/ChannelEditPacer.cs @@ -0,0 +1,72 @@ +using System.Collections.Concurrent; +using RustPlusBot.Abstractions.Time; + +namespace RustPlusBot.Discord.Posting; + +/// +/// 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 minGap apart. An edit to an +/// idle channel (the common steady-state case) is not delayed at all. +/// +public sealed class ChannelEditPacer : IChannelEditPacer +{ + private readonly IClock _clock; + private readonly TimeSpan _minGap; + + /// The earliest instant the next edit to each channel may be sent. + private readonly ConcurrentDictionary _nextAllowed = new(); + + /// Uses the default one-second spacing, comfortably under Discord's per-channel edit limit. + /// Supplies the current time. + public ChannelEditPacer(IClock clock) : this(clock, TimeSpan.FromSeconds(1)) + { + } + + /// Creates a pacer with an explicit minimum spacing between edits to a channel. + /// Supplies the current time. + /// The minimum spacing between successive edits to one channel; zero disables pacing. + /// The gap is negative. + public ChannelEditPacer(IClock clock, TimeSpan minGap) + { + ArgumentNullException.ThrowIfNull(clock); + ArgumentOutOfRangeException.ThrowIfLessThan(minGap, TimeSpan.Zero); + _clock = clock; + _minGap = minGap; + } + + /// + public async Task PaceAsync(ulong channelId, CancellationToken cancellationToken) + { + var wait = Reserve(channelId); + if (wait > TimeSpan.Zero) + { + await Task.Delay(wait, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// 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. + /// + /// The Discord channel the edit targets. + /// The wait before the reserved slot; when the channel is idle. + 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; + } +} diff --git a/src/RustPlusBot.Discord/Posting/IChannelEditPacer.cs b/src/RustPlusBot.Discord/Posting/IChannelEditPacer.cs new file mode 100644 index 00000000..3ef6dcbd --- /dev/null +++ b/src/RustPlusBot.Discord/Posting/IChannelEditPacer.cs @@ -0,0 +1,19 @@ +namespace RustPlusBot.Discord.Posting; + +/// +/// 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. +/// +public interface IChannelEditPacer +{ + /// + /// 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. + /// + /// The Discord channel the edit targets. + /// A cancellation token. + /// A task that completes when the caller may send its edit. + Task PaceAsync(ulong channelId, CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/ServerInfoRefresher.cs b/src/RustPlusBot.Features.Workspace/Reconciler/ServerInfoRefresher.cs index ba7bef60..f135136b 100644 --- a/src/RustPlusBot.Features.Workspace/Reconciler/ServerInfoRefresher.cs +++ b/src/RustPlusBot.Features.Workspace/Reconciler/ServerInfoRefresher.cs @@ -11,12 +11,14 @@ namespace RustPlusBot.Features.Workspace.Reconciler; /// All registered message renderers. /// Suppresses PATCHes for renders that did not change. /// The full reconcile, used to self-heal a missing or stale message. +/// Spaces this tick's edits so the burst does not exhaust the channel's edit bucket. internal sealed class ServerInfoRefresher( IWorkspaceStore store, IWorkspaceGateway gateway, IEnumerable renderers, RenderGate gate, - IWorkspaceReconciler reconciler) : IServerInfoRefresher + IWorkspaceReconciler reconciler, + IChannelEditPacer pacer) : IServerInfoRefresher { /// The #info message keys this path refreshes, in declaration order. private static readonly string[] Keys = @@ -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) diff --git a/tests/RustPlusBot.Discord.Tests/Posting/ChannelEditPacerTests.cs b/tests/RustPlusBot.Discord.Tests/Posting/ChannelEditPacerTests.cs new file mode 100644 index 00000000..8a49a6ac --- /dev/null +++ b/tests/RustPlusBot.Discord.Tests/Posting/ChannelEditPacerTests.cs @@ -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(() => + 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; + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ServerInfoRefresherTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ServerInfoRefresherTests.cs index 93563d69..5598bbcd 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ServerInfoRefresherTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ServerInfoRefresherTests.cs @@ -43,7 +43,7 @@ public async Task First_refresh_edits_the_message() var gateway = Substitute.For(); var reconciler = Substitute.For(); var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer], - new RenderGate(), reconciler); + new RenderGate(), reconciler, Pacer()); await refresher.RefreshAsync(GuildId, ServerId, default); @@ -58,7 +58,7 @@ public async Task Unchanged_render_is_not_sent_twice() var gateway = Substitute.For(); var reconciler = Substitute.For(); 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); @@ -75,7 +75,7 @@ public async Task Changed_render_is_sent_again() var gateway = Substitute.For(); var reconciler = Substitute.For(); 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"; @@ -95,7 +95,7 @@ public async Task Missing_message_row_escalates_to_full_reconcile() .Returns((ProvisionedMessage?)null); var gateway = Substitute.For(); var reconciler = Substitute.For(); - 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); @@ -122,7 +122,7 @@ public async Task Edit_failure_escalates_and_invalidates_the_gate() var reconciler = Substitute.For(); var gate = new RenderGate(); var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer], gate, - reconciler); + reconciler, Pacer()); await refresher.RefreshAsync(GuildId, ServerId, default); @@ -141,7 +141,7 @@ public async Task Empty_payload_is_skipped_without_editing() var gateway = Substitute.For(); var reconciler = Substitute.For(); var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer], - new RenderGate(), reconciler); + new RenderGate(), reconciler, Pacer()); await refresher.RefreshAsync(GuildId, ServerId, default); @@ -164,13 +164,50 @@ public async Task Refreshes_all_three_info_messages() WorkspaceMessageKeys.ServerTeam); var gateway = Substitute.For(); var refresher = new ServerInfoRefresher(store, gateway, renderers, new RenderGate(), - Substitute.For()); + Substitute.For(), Pacer()); await refresher.RefreshAsync(GuildId, ServerId, default); Assert.All(renderers.Cast(), 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(); + var pacer = Pacer(); + var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer], + new RenderGate(), Substitute.For(), pacer); + + await refresher.RefreshAsync(GuildId, ServerId, default); + + Received.InOrder(() => + { + _ = pacer.PaceAsync(ChannelId, Arg.Any()); + _ = gateway.EditMessageAsync(GuildId, ChannelId, MessageId, Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public async Task A_skipped_unchanged_render_is_not_paced() + { + var renderer = new StubRenderer(WorkspaceMessageKeys.ServerInfo, "v1"); + var gateway = Substitute.For(); + var pacer = Pacer(); + var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer], + new RenderGate(), Substitute.For(), 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()); + } + + private static IChannelEditPacer Pacer() => Substitute.For(); + private sealed class StubRenderer(string key, string title) : IMessageRenderer { public int Calls { get; private set; }