From 239fa23111721f8f140f23a5b9d9c61e08c50b47 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 24 Jul 2026 20:01:01 +0200 Subject: [PATCH 1/2] fix(discord): pace #info edits to stop per-channel rate-limit warnings The #info refresh tick re-renders several messages and PATCHes every changed one back-to-back into the same channel. ServerInfo carries a live in-game clock, transition countdown and wipe age, so at least one edit fires every tick and the render gate can never suppress it. That sub-second burst exhausts Discord's per-channel edit bucket, so Discord.Net preemptively throttles the tail and logs a "[Rest] Rate limit triggered ... Remaining: 4s" warning almost every tick (scaling with player activity). Add a process-wide, per-channel ChannelEditPacer that holds successive edits to the same channel at least one second apart; an edit to an idle channel (the steady state) is not delayed. Wire it into ServerInfoRefresher immediately before each edit, after the render-gate check so suppressed renders are not paced. Per-channel keying means different servers' #info channels do not inflate each other's waits. The pacing decision lives in a pure Reserve method so it is tested without real timers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../DiscordServiceCollectionExtensions.cs | 1 + .../Posting/ChannelEditPacer.cs | 59 ++++++++++++++++ .../Posting/IChannelEditPacer.cs | 19 ++++++ .../Reconciler/ServerInfoRefresher.cs | 7 +- .../Posting/ChannelEditPacerTests.cs | 68 +++++++++++++++++++ .../Reconciler/ServerInfoRefresherTests.cs | 51 ++++++++++++-- 6 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 src/RustPlusBot.Discord/Posting/ChannelEditPacer.cs create mode 100644 src/RustPlusBot.Discord/Posting/IChannelEditPacer.cs create mode 100644 tests/RustPlusBot.Discord.Tests/Posting/ChannelEditPacerTests.cs 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..085d50e7 --- /dev/null +++ b/src/RustPlusBot.Discord/Posting/ChannelEditPacer.cs @@ -0,0 +1,59 @@ +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. +/// +/// Supplies the current time. +/// The minimum spacing between successive edits to one channel. +public sealed class ChannelEditPacer(IClock clock, TimeSpan minGap) : IChannelEditPacer +{ + /// 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)) + { + } + + /// + 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..5b948dd2 --- /dev/null +++ b/tests/RustPlusBot.Discord.Tests/Posting/ChannelEditPacerTests.cs @@ -0,0 +1,68 @@ +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 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; } From 81729224428395bf438f8030bd0dd28e23ce48b9 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 24 Jul 2026 20:44:04 +0200 Subject: [PATCH 2/2] fix(discord): reject a negative edit-pacing gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A negative minGap made ChannelEditPacer.Reserve compute plannedSend == now on every call, silently disabling pacing — the exact failure this change guards against. Validate in the constructor (zero stays legal as an explicit "disable"). Addresses PR review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Posting/ChannelEditPacer.cs | 27 ++++++++++++++----- .../Posting/ChannelEditPacerTests.cs | 16 +++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/RustPlusBot.Discord/Posting/ChannelEditPacer.cs b/src/RustPlusBot.Discord/Posting/ChannelEditPacer.cs index 085d50e7..053469de 100644 --- a/src/RustPlusBot.Discord/Posting/ChannelEditPacer.cs +++ b/src/RustPlusBot.Discord/Posting/ChannelEditPacer.cs @@ -10,10 +10,11 @@ namespace RustPlusBot.Discord.Posting; /// 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. /// -/// Supplies the current time. -/// The minimum spacing between successive edits to one channel. -public sealed class ChannelEditPacer(IClock clock, TimeSpan minGap) : IChannelEditPacer +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(); @@ -23,6 +24,18 @@ 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) { @@ -42,17 +55,17 @@ public async Task PaceAsync(ulong channelId, CancellationToken cancellationToken /// The wait before the reserved slot; when the channel is idle. public TimeSpan Reserve(ulong channelId) { - var now = clock.UtcNow; + 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); + 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 plannedSend = nextAllowed - _minGap; var wait = plannedSend - now; return wait > TimeSpan.Zero ? wait : TimeSpan.Zero; } diff --git a/tests/RustPlusBot.Discord.Tests/Posting/ChannelEditPacerTests.cs b/tests/RustPlusBot.Discord.Tests/Posting/ChannelEditPacerTests.cs index 5b948dd2..8a49a6ac 100644 --- a/tests/RustPlusBot.Discord.Tests/Posting/ChannelEditPacerTests.cs +++ b/tests/RustPlusBot.Discord.Tests/Posting/ChannelEditPacerTests.cs @@ -9,6 +9,22 @@ public sealed class ChannelEditPacerTests 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() {