Skip to content

Every #map refresh re-downloads the full map JPEG to read the monument list (~99.6% of Rust+ socket traffic is redundant) #86

Description

@HandyS11

Summary

IRustServerQuery.GetMonumentsAsync is served by the Rust+ GetMap endpoint, which returns the entire map JPEG alongside the monument list. MapComposer calls it on every compose, and MapHostedService composes once per MapRefreshInterval (30s). The result is a ~683 KB download every 30 seconds, forever, to read a static list that the bot has already fetched and discarded earlier in the same connected window.

#85 fixed the same defect for GetMapDimensionsAsync. This issue covers the remaining caller.

Measured on a live bot (after #85, develop @ ff7ef7f)

Per-second bytes_received on the Rust+ socket, 309 s window:

23:12:07 BURST +698476      23:14:48 BURST +698875
23:12:39 BURST +636276      23:15:21 BURST +698875
23:13:10 BURST +699361      23:15:53 BURST +698875 (spanned 3s)
23:13:42 BURST +665004      23:16:27 BURST +698875
23:14:15 BURST +699118 (spanned 2s)   23:16:59 BURST +699031

TOTAL = 7,016,744 B over 309 s | 10 bursts | 22,707 B/s | 77 MB/h
  • 10 bursts / 309 s = one per 30.9 s — matches MapRefreshInterval: "00:00:30" exactly.
  • ~683 KB per burst.
  • 10 x ~699,000 = 6.99 MB of the 7.02 MB total.

Waste ratio

Bytes / 309 s Rate
Redundant map re-downloads 6,990,000 76.7 MB/h
Everything else (marker polls, heartbeat, team poll, chat) 26,744 0.31 MB/h
Redundant share 99.6 %

Marker polls on this server are 106–545 B each. The useful traffic is a rounding error next to the map re-downloads.

For scale: 1.85 GB/day on one server, and it scales linearly with the number of connected servers.

Root cause

RustPlusApi.Data.ServerMap (2.0.0-beta.8) bundles everything into one response:

public sealed record ServerMap
{
    public uint? Height { get; init; }
    public uint? Width { get; init; }
    public int? OceanMargin { get; init; }
    public Color Background { get; init; }
    public List<ServerMapMonument>? Monuments { get; init; }
    public byte[]? JpgImage { get; init; }   // <- the ~683 KB
}

There is one endpoint, GetMapAsync, and three wrappers in RustPlusSocketSource.cs call it — so all three pay for the JPEG, but only one wants it:

Wrapper Line Wants Cached? Callers
GetMapImageAsync 742 the JPEG yes — BaseMapCache holds it until disconnect RustPlusBaseMapSource:12
GetMapDimensionsAsync 634 Width/Height/OceanMargin yes — fixed in #85, served from LiveSocket.Dimensions MapComposer:69, RustPlusBaseMapSource:18, ServerTeamMessageRenderer:78
GetMonumentsAsync 708 Monuments no MapComposer:90

MapComposer.cs:90 is gated on layers.Monuments || layers.Rigs || layers.Tunnels. Those default to on (MapLayerSet.AllOn; MapSettings.Tunnels = true) and ServerMapSettings is empty on a default install — so in practice it always fires.

The data is already in memory and thrown away

ConnectionSupervisor.GetRigPositionsAsync fetches the exact same list once per connected window at marker-poll start, keeps only the two oil rigs, and discards the rest:

var monuments = await connection.GetMonumentsAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false);
foreach (var m in monuments)
{
    RigKind? kind = m.Token switch
    {
        "oil_rig_small" => RigKind.Small,
        "large_oil_rig" => RigKind.Large,
        _ => null,
    };
    ...
}
return rigs;   // everything else dropped

So the bot pays ~683 KB every 30 s to re-fetch a list it already downloaded once and threw away.

Proposed fix

Same shape as #85:

  1. Add a MonumentsHolder to LiveSocket, beside the existing DimensionsHolder.
  2. Populate it from the single connect-time fetch in GetRigPositionsAsync (which already has the full list).
  3. Serve ConnectionSupervisor.GetMonumentsAsync from the holder, falling back to one socket fetch if a reader beats the marker poll.

Monuments are static for a wipe, and the connected window is torn down and re-resolved on reconnect — exactly when a new map can appear. Same invariant that makes the #85 dimensions cache correct.

Worth considering as a follow-up: since GetMap yields dimensions, monuments and the image in a single response, one fetch per window could populate all three caches, dropping the connected-window cost to a single GetMap call instead of the current two (GetMapImageAsync for BaseMapCache + GetMonumentsAsync for rigs).

Expected impact

  • Full-map downloads: ~120/hour to 1 per connected window.
  • Rust+ inbound: 77 MB/h to ~0.3 MB/h (~99.6% reduction), i.e. 1.85 GB/day to ~10 MB/day per server.

Historical context

Before #85, the same server sustained 1,125,184,125 bytes over ~5h26m = 202 MB/h, with bursts every ~9 s (dimensions from the team panel + dimensions from the composer + monuments from the composer, all per-cycle). #85 removed the dimensions calls and cut this to 77 MB/h. This issue is the remainder.

How to reproduce / measure

With a bot connected to a Rust server:

ss -tnpi dst <server-ip> | grep -oE 'bytes_received:[0-9]+'

Sample once per second and diff. Bursts of ~683 KB appear on exactly the MapRefreshInterval cadence.

Secondary observation (separate, lower priority)

PATCH channels/.../messages/... rate-limit warnings fire ~1.8/min against the server.team info-panel message. RenderGate already suppresses identical re-renders, so these are genuine content changes (live player positions and marker ages), not redundant writes — but the edit rate still exceeds Discord's per-channel bucket and may deserve its own coalescing interval. Not part of this issue.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions