Reduce server hitching: dead dispatcher config, per-packet allocation, and unindexed queries - #1386
Conversation
LocalActionTest referenced Default.GUID0 but imported only ExplosiveDeployable and GlobalDefinitions from net.psforever.objects, so Test/compile failed with "not found: value Default". This broke test compilation for the entire project, not just this suite -- no test could be run on master until it was fixed. Add Default to the existing import list. No test logic changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BlockMap.blocks was a ListBuffer[Sector], and both sector lookup helpers resolved indices through structure.toSeq. On a mutable ListBuffer, toSeq builds a fresh immutable.List -- a full copy of every sector in the zone -- and List.apply(index) is then an O(index) linked-list walk. For a standard 8192-square map at the default span size of 100 that is ~6,700 sectors copied per call, followed by an average ~3,300 pointer hops per index resolved. sectorsOnlyWithinBlockStructure resolves every index in the query, and sectorOnlyWithinBlockStructure is called once per changed sector index by move(), so a boundary crossing paid the copy repeatedly. Every blockmap entry point routes through these helpers -- sector(), addTo(), move(), actuallyRemoveFrom() -- and the per-player movement path hits them twice per upstream packet: once via SessionData.updateBlockMap and again via InteractsWithZone.zoneInteractions. This was therefore a steady, density-scaled source of CPU and young-generation garbage, and young-gen collections stall every session at once. blocks is only ever read after construction, never structurally modified, so it becomes an IndexedSeq and both helpers index it directly. The Sector objects themselves remain mutable as before; only the container changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SectorGroup eagerly materialised all ten of its entity categories at construction. For a multi-sector group each category ran flatMap -> toList -> distinct across every covered sector, and each sector accessor itself copied its backing ListBuffer via SectorListOf.list. Callers almost never want all ten. The per-movement-packet refresh in SessionData.updateBlockMap covers ~144 sectors at the default 550m draw range and the result is consumed almost entirely for one scalar, localSector.livePlayerList.size, so nine categories were condensed and discarded on every upstream packet from every player. The lists are now by-name constructor parameters retained as lazy vals, so each category is condensed on first access and memoised thereafter. This does move when a category is snapshotted, from construction to first read. The backing sector lists are mutable and updated by other actors, so that read was already unsynchronised; this shifts the window rather than introducing one, and removes it entirely for categories that are never consulted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UpdateSOI called zone.blockMap.sector(facility) twice per facility, once for livePlayerList and again for vehicleList, discarding the first result. The query spans every sector within the facility's SOIRadius, so the whole lookup and sector-group construction was performed twice for no benefit. This runs for every facility on the continent in a single message, every five seconds, which gives it a periodic-stutter shape. Hoist the result into a local. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
unwind accumulated with out :+ first. Appending to a List is O(length), making the whole traversal O(n^2). Bundles are built per recipient over the zone population -- deconstruction, emotes, deployable updates -- so the cost scaled with player count on paths that already fan out. The constructor assertion then called unwind a second time purely to compare sizes, doubling that work. assert is not elided here; the build sets no -Xelide-below. Accumulate by prepending and reverse once at the end, and replace the assertion with a direct O(n) test for a nested bundle. The size comparison was also unsound: a nested BundledEnvelope holding exactly one element unwound to the same size and passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two allocation sources on the hottest path in the server: MultiPacketEx.sizeCodec built its composed codecs inside encode and allocated its sizeTypes and guards lists inside decode. sizeCodec runs once per sub-packet of every outbound bundle and every inbound multi-packet, so each sub-packet paid for a fresh constant :: uintNL codec tree and two list allocations with hex literals parsed each time. scodec codecs are immutable and safe to share, so these are hoisted to private vals; the lists become Vectors for constant-time indexing. noDecoder in both opcode tables rendered the entire payload as hex into the failure message. Err takes its message by value, so that string -- twice the packet's length -- was constructed eagerly for every packet arriving on an unimplemented opcode, and many opcodes are still stubs. The opcode by itself identifies which marshaller is missing, so the hex dump is dropped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
composeTaskAndSubs synchronised on a local var of type Boolean. Scala boxes a primitive to call synchronized, and java.lang.Boolean.valueOf returns the cached TRUE and FALSE singletons, so every task bundle in every zone contended one of the same two JVM-wide monitors. Registering an avatar fans out across holsters, locker and inventory -- routinely 30-50 subtasks -- and each completion re-entered that monitor and ran an O(n) forall inside it, so a squad respawning serialised against a vehicle spawning and a corpse being looted. It was also not a correct mutex. The monitor is the boxed value, so its identity changed the moment the flag flipped from true to false, and two threads could be inside the critical section simultaneously and complete the promise twice. Use a dedicated lock object. The O(n) completion scan is left alone; it wants the barrier rewritten in terms of Future.sequence, which is a larger change than this fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two leaks of java.util.concurrent resources. HackCaptureActor.processBuildingsWithDelay scheduled a fixed-rate task and never cancelled it, so once its building iterator was exhausted the task kept waking every delayMillis, forever, doing nothing. The near identical loop in ChatOperations.processBuildingsWithDelay already captures its handle and cancels; this applies the same pattern. ChatOperations holds its own Executors.newScheduledThreadPool(2), and the class is constructed once per session. Nothing shut it down, so the threads outlived every session that used it. It already has a stop() that SessionData.stop() invokes for teardown, so the shutdown goes there. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cross-container move path sent Wait() to the destination and then
answered it with:
moveItemOver
.recover { case _: AskTimeoutException => ... Resume() }
.onComplete { _ => ... Resume() }
recover produces a successful future, so on an ask timeout the recover
sent a Resume and the onComplete immediately sent a second one. The
surplus Resume releases the guard belonging to an unrelated concurrent
move, allowing insertions to interleave with a move that is still in
flight -- while the item is removed from its source.
onComplete alone fires for both success and failure, so the recover
clause is redundant as well as harmful.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
akka.conf assigns dispatchers to more than thirty actor paths, and dispatchers.conf defines all of them carefully. None of it takes effect. No actor is created at any of those paths: the socket actors are named world-socket-<port> beneath socketPane, there is no world-session-router, and zone actors are named zone-<id> rather than <id>-actor. Grepping the source for world-udp-endpoint or session-router returns nothing. Config-based deployment also only applies to classic actors, and the network and session actors are spawned through the typed API, so these assignments could not apply even with corrected names. Consequently socket I/O, session logic and zone logic all share akka.actor.default-dispatcher, which was never configured and so ran on Akka's stock throughput of 5 -- against the 50 the intended world-session dispatcher specifies. The pinned thread that the comments call "extremely performance critical" was never allocated. Configure default-dispatcher to suit the load it is actually carrying, and enlarge the scheduler wheel, which holds a per-session outbound packet timer plus per-player and per-entity ticks and collides heavily at 512 buckets. This does not replace assigning dispatchers explicitly at each spawn site, which is the real fix and needs verifying against a thread dump on a running server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three deployment settings that were left at defaults unsuited to the workload. maxActiveConnections was 5 for the whole server. It caps concurrent SQL statements, not player connections -- clients never touch it -- and the driver queues excess queries rather than rejecting them, so the effect is latency. Character login alone issues around a dozen sequential queries, so a login wave and in-game writes starve each other. The packaged launcher passed no memory or collector options whatsoever, leaving the server on JVM defaults; in a container that means a fraction of host RAM. The -Xmx in .jvmopts governs sbt, not the packed script. Set an explicit heap and select G1 with a pause target, since stop-the-world pauses stall every connected session at once. docker-compose ran the server under JDK 8 while the Dockerfile builds with 11. JDK 8 defaults to ParallelGC, whose full collections are stop-the-world and can reach seconds. Align it with the Dockerfile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PostgreSQL does not index a REFERENCES column automatically, and V015 (outfits) was the only migration that had declared any index at all. Every per-avatar lookup was therefore a sequential scan. killactivity is the most damaging. It is an append-only kill log that grows for the life of the server, and loadCampaignKdaData reads it on every login filtering on killer_id or victim_id, so login cost grew in proportion to every kill the server had ever recorded. Two single-column indexes let the OR resolve as a bitmap union. The rest cover loadouts, vehicle loadouts, lockers, certifications and implants, all read while the client waits on the character select or loading screen, plus avatar.account_id for assembling an account's character list. Tables whose existing UNIQUE constraint or primary key already yields a btree with avatar_id leading -- friend, ignored, shortcut, avatarmodepermission, savedplayer, savedavatar -- are deliberately left alone; indexing them again would only cost write throughput. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
akka.conf and dispatchers.conf describe a careful thread allocation -- a pinned thread for the UDP read loop, an isolated pool for session work, a pool per zone so one busy continent cannot starve the others -- and none of it was ever applied. The deployment entries name paths no actor occupies, and config-based deployment only works for classic actors, while the network, session and zone actors are all spawned through the typed API. Everything therefore shared the default pool. Select the dispatcher explicitly where each actor is created: SocketActor -> network-listener (pinned; one thread per port) MiddlewareActor -> world-session SessionActor -> world-session LoginActor -> login-session ZoneActor -> <zone id>-zone-dispatcher The zone lookup is guarded with hasPath and falls back to the default pool with a warning, because DispatcherSelector.fromConfig throws on a missing key and an unconfigured zone should not stop the server booting. That guard immediately earned itself: comparing ZoneInfo against the config showed the TR sanctuary dropship zone is "tzdrtr" while the dispatcher was spelled "tzsdrtr", so that zone had no pool and every other zone matched exactly. Corrected in both files. Verified by spawning an actor with all thirty-five selectors -- the three fixed pools plus one per ZoneInfo entry -- against the real merged configuration, which resolves without error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three linear walks over Lists on the GUID allocation path, all replaced with indexed access. The algorithms and their results are unchanged. GenericPool.first and rand build a sorted list of every number already handed out and then index it by position in a loop. List.apply walks the chain, so each step cost O(index) and the scan was quadratic overall. This matters because the generic pool is where every failed request is redirected: once any fixed pool is exhausted, allocation falls back here and receives the whole hub -- tens of thousands of numbers -- so the pool actor's mailbox blocks for a long time at exactly the moment the server is already under pressure. Sorting into an Array keeps the same scan at O(n log n). ExclusivePool did the same on both of its operations: Get resolved numbers(index) on a List, and Return performed an O(n) indexOf. The configured pools are large -- deployables 16000, ammo and kits 13500 -- and unregistration arrives in bulk when a player dies or a vehicle is deconstructed, serialised through a single pool actor. An indexed copy and a reverse lookup make both constant time; neither structure is mutated after construction and SimplePool already rejects duplicates. Deliberately not changed: RandomSelector.Return still reports failure when no number is outstanding, which does leak that GUID. Making it succeed unconditionally would let a double return insert the same number into the pool twice and eventually issue one GUID to two entities, which is far worse than the leak. Correcting it properly means tracking allocation state explicitly rather than inferring it from a cursor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
processOutQueueBundle dispatches a single bundle per call and the timer called it once per tick, so each client's outbound throughput was fixed at one bundle per bundling delay -- roughly twenty 440 byte bundles a second, under 9 KB/s -- however much was queued behind it. Bursts are exactly when that hurts. A zone load, a base capture, or a squad arriving in render range queues hundreds of object creates, and they left at a rate that made the world visibly assemble in slow motion around the player. The bundling delay is meant to control how packets are coalesced, not to cap total bandwidth. Drain up to packet-bundling-drain-limit bundles per run, defaulting to 8. The budget doubles as the loop bound, so the loop always terminates after a fixed number of iterations even if a queue fails to shrink, and the value is exposed in configuration so it can be tuned down for constrained connections rather than being fixed in code. Verified that the new key resolves through pureconfig at runtime; a mismatch there would prevent the server from starting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Md5Mac runs over every inbound and every outbound packet, so it was the largest allocation source in the network layer, and the young-generation collections it drove stall every connected session at once. Three causes, all removed: State was held in ArrayBuffer[Byte], which is unspecialised and backed by Array[AnyRef], so every byte read and write boxed. It is now fixed primitive arrays allocated once per instance. mkInt allocated an iterator and dropped through it to read four bytes at a known offset, and hash() called it around 128 times per 64 byte block. It now reads the array directly. The four words derived from k2 are the same every round, so they are computed once at key setup rather than sixty-four times per block -- while preserving the ordering that leaves them zero during key expansion, which is load bearing. reset() rebuilt the entire state through a deep copy and is called twice per packet. It is now a fill plus an arraycopy from a template. The public API is unchanged and reset() still returns this, so the reset().updateFinal(...) form at the call sites is untouched. This code was verified live against the real server, so output has to be bit identical. CryptoTest, which asserts a known MAC vector, still passes with 26 expectations and no failures and was not modified. It was also checked differentially against the previous implementation over 6000 randomised cases -- varying keys, update counts, lengths straddling the 64 byte block boundary, output lengths and interleaved resets -- with no mismatches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both codec and codec2 take a single Boolean and constructed a fresh tree of about a dozen primitive codecs, the named combinators and an xmap/exmap closure on every call. There are only two possible results each. These sit under ObjectClass.selectDataCodec, so they are reached once per object in every ObjectCreateMessage and ObjectCreateDetailedMessage. Spawning, zoning and entering render range of other players emit hundreds of those back to back on the session thread, and each one paid full codec construction before a single bit was written. Memoise the two shapes. scodec codecs are immutable and safe to share. They are lazy so that initialisation order inside the object -- the implicit val calls the dispatcher during construction -- cannot observe a null. The remaining per-call codec construction in this area, notably selectDataCodec's own arms and the offset-parameterised appearance and player codecs, is left for a follow-up; those take an Int rather than a Boolean and want a small table rather than two values. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
If this is all a fancy way of saying zones will load much faster (among other things), then it worked. |
|
The expectation is that PR #1384 should improve zoning reliability and this should improve performance for zoning as well. Other areas include checking player kill performance via queries. |
|
Can confirm zoning speed increase. |
Reword the comments added by this branch so they describe the behaviour of the code as it now stands, rather than contrasting it with the code it replaced. A reader arriving at these files has no view of the previous implementation, so that framing carries no meaning for them; the before-and-after reasoning belongs in the commit messages, where it already is. No behavioural change: comments, configuration comments and one SQL comment block only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed the review note that several comments explain how the code changed rather than what it now does — fair point, and this PR was the worst offender for it. Since a reader arriving at these files has no view of the previous implementation, that framing carried no meaning for them; the before-and-after reasoning belongs in the commit messages, where it already was. Pushed as The sweep covered configuration and SQL as well as Scala, since the same habit had crept into A few representative rewrites:
What I deliberately kept: rationale that is still a live fact a reader needs — configured pool sizes, why One comment also gained detail rather than losing it. The |
Brings in upstream through 2f63d1c, most notably the server hitch reduction work (psforever#1386), the PropertyOverrideManager revert (psforever#1388) and the LLU/zone-lock fixes (psforever#1387). Conflict resolutions: - pekko.conf: psforever#1386 added `akka.actor.default-dispatcher` and `akka.scheduler` blocks. Git followed the akka.conf -> pekko.conf rename and merged them textually, which would have left both blocks under an `akka.*` prefix that Pekko never reads -- silently discarding the default-dispatcher sizing and the scheduler wheel tuning. Renamed both to `pekko.*` and updated the surrounding prose. - SocketPane / InterstellarClusterService: kept the Pekko namespace and took upstream's added `DispatcherSelector` import. - ContainableBehavior: took upstream's fix that drops the duplicate Resume(). That removed the only use of AskTimeoutException, so the import goes with it. - docker-compose.yml: kept the JDK 21 image this branch moved to, and carried over upstream's rationale for keeping the compose JVM aligned with the Dockerfile's. - SocketActor / SocketPane comments: the spawn-site dispatcher comments from psforever#1386 refer to the config file by name; updated akka.conf -> pekko.conf. Verified: `sbt compile` is green on JDK 21, upstream's drain-limit config, spawn-site dispatchers and V018 index migration all survive the merge, and the tree carries no Akka references outside the pre-existing `AkkaActor` alias in Default.scala. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What this is
A pass over the sources of lag spikes and hitching that connected clients experience. It
came out of a subsystem-by-subsystem read of the network layer, zone/event fan-out, actor
scheduling, GUID allocation and the persistence layer.
Three root causes account for most of it:
akka.confassignsdispatchers to 30+ actor paths and
dispatchers.confdefines them all carefully, but noactor occupies those paths, and config-based deployment does not apply to typed actors
in any case. Socket I/O, session logic and every zone therefore shared one unconfigured
default pool running at Akka's stock
throughput = 5.spatial query and then indexed the copy as a linked list; sector groups eagerly built ten
entity lists when callers wanted one; the GUID pools walked
Lists of up to 16,000entries per allocation and release.
CREATE INDEXappeared in exactly one of seventeen migrations.Each commit is self-contained and explains its own reasoning; they can be reviewed or
reverted individually.
Changes by area
Threading and configuration
flowchart LR subgraph before["Before - deployment config matched no actor"] direction TB SA1["SocketActor"] --> DD["akka.actor.default-dispatcher<br/>unconfigured, throughput 5"] MW1["MiddlewareActor"] --> DD SS1["SessionActor"] --> DD LA1["LoginActor"] --> DD ZA1["ZoneActor, 32 of them"] --> DD end subgraph after["After - selected at each spawn site"] direction TB SA2["SocketActor"] --> NL["network-listener<br/>pinned, one thread per port"] MW2["MiddlewareActor"] --> WS["world-session"] SS2["SessionActor"] --> WS LA2["LoginActor"] --> LS["login-session"] ZA2["ZoneActor, 32 of them"] --> ZD["one pool per zone<br/>z1-z10, c1-c6, i1-i4, home, tz"] endnetwork-listener, middleware and sessions ontoworld-session, login ontologin-session, and each zone onto its own pool. The zone lookup is guarded withhasPathand falls back to the default pool with a warning rather than refusing to boot.tzdrtrbut itsdispatcher was spelled
tzsdrtr, so it had no pool. Every other zone matched exactly.akka.actor.default-dispatcher(which everything had silently been using) andenlarge the scheduler wheel, which holds a per-session packet timer plus per-player and
per-entity ticks and collides heavily at the default 512 buckets.
maxActiveConnectionsfrom 5. It caps concurrent SQL statements for the wholeserver, not player connections, and the driver queues rather than rejecting, so five slots
showed up purely as latency.
no memory or collector options at all. Align the compose JDK with the Dockerfile — it ran
JDK 8, whose default ParallelGC does multi-second stop-the-world collections.
Per-packet cost
flowchart LR Q["outQueueBundled<br/>each bundle capped at the 440 byte MTU"] --> TICK{"bundler run<br/>every 50 ms"} TICK -->|"before: dequeue exactly 1"| B["about 20 bundles/s<br/>under 9 KB/s per client"] TICK -->|"after: drain up to 8"| A["about 160 bundles/s<br/>tunable per deployment"]Md5Macover primitive arrays. It ran on every packet in both directions whileholding state in
ArrayBuffer[Byte](boxing every byte), reading four bytes at a knownoffset by allocating and walking an iterator ~128 times per block, and deep-copying its
entire state on each
reset()— twice per packet.sent exactly one bundle per timer tick, capping every client near 9 KB/s regardless of
backlog, which is why zone loads and base captures visibly trickled in.
MultiPacketEx.sizeCodecrebuilt its codec tree per sub-packet,
CommonFieldData's two codecs were reconstructedper object in every
ObjectCreateMessage, and the unimplemented-opcode path rendered thewhole payload to hex eagerly because
Errtakes its message by value.Zone and event fan-out
five seconds.
BundledEnvelope.unwindlinear; it was O(n²) and ran twice because a non-elidedassertrepeated it.GUID allocation
GenericPoolandExclusivePool. The genericpool is the fallback for every exhausted pool and receives the whole hub, which made its
scan quadratic at exactly the moment the server is under pressure.
Correctness bugs that present as performance problems
TaskWorkflowsynchronised on a boxedBoolean, i.e. the cachedTRUE/FALSEsingletons, so every task bundle server-wide contended one of two JVM-global monitors. It
was also not a valid mutex: the monitor identity changed when the flag flipped, so two
threads could complete the promise.
Resumes for oneWaiton ask timeout, releasing anunrelated concurrent move's guard.
Database
Measured on the real
loadCampaignKdaDataquery against 500 avatars and a 200k-row kill log:flowchart TB subgraph noidx["Without V018 - 10.29 ms, 3917 buffers"] direction TB N1["Gather, 2 parallel workers"] --> N2["Parallel Seq Scan on killactivity"] N2 --> N3["filters every row in the kill log<br/>cost grows with the table forever"] end subgraph idx["With V018 - 2.47 ms, 636 buffers"] direction TB I1["Hash Join"] --> I2["Bitmap Heap Scan on killactivity"] I2 --> I3["BitmapOr"] I3 --> I4["Bitmap Index Scan<br/>killactivity_killer_id_idx"] I3 --> I5["Bitmap Index Scan<br/>killactivity_victim_id_idx"] endV018__PerformanceIndexes.sqlindexes the foreign keys read during login and characterselect.
killactivitymatters most: it is an append-only kill log read on every login, sologin cost grew with every kill the server had ever recorded.
Unrelated but required
LocalActionTestwas missing aDefaultimport, which brokeTest/compilefor the wholeproject on master. No test could run until this was fixed. Worth pulling out separately if
you'd rather land it on its own.
Measured impact
Each old implementation was taken from
psf/masterand benchmarked against the new one inthe same JVM with trials interleaved. Temurin 17, G1, 16 logical CPUs.
Read these numbers with their limits in mind. This ran on a working desktop, not a quiet
bench host, so absolute figures drifted 10–25% between rounds; every ratio reproduced
within a few percent across three rounds including a cold JVM, so the ratios are the
trustworthy output. There is no JMH in this project, so these are careful manual benchmarks
— warmup, auto-calibrated iteration counts, median of nine trials, checksum accumulation to
defeat dead-code elimination — not JMH-grade measurements. They also measure components in
isolation; none of them establish end-to-end server behaviour under real load.
All seven changes measured faster. No regressions, no ambiguous results.
Packet path — Md5Mac
Runs on every inbound and outbound packet, so this is the per-packet floor.
xychart-beta title "Md5Mac reset + updateFinal, microseconds per MAC" x-axis ["440 B before", "440 B after", "64 B before", "64 B after"] y-axis "microseconds" 0 --> 45 bar [39.5, 1.7, 18.9, 0.62]Old and new produce an identical MAC, verified in the harness as well as by the 6,000-case
differential run described under Verification.
Zone and movement path
The blockmap helper is called twice per movement packet per player; the sector group is
built once per movement packet.
xychart-beta title "Blockmap sector resolution, microseconds per lookup" x-axis ["before", "after"] y-axis "microseconds" 0 --> 700 bar [642, 16.3]xychart-beta title "SectorGroup construct then read livePlayerList.size, microseconds" x-axis ["before", "after"] y-axis "microseconds" 0 --> 30 bar [26.0, 4.74]The sector-group figure is a lower bound, and deliberately so. The harness could only
populate the live-player category; the other nine were empty, yet the old constructor still
walked all 144 sectors for each of them. A real zone has buildings, amenities and
environment entries in nearly every sector, so the production gap is larger than 5.7x. Do
not quote it as an upper bound.
Event bundling
BundledEnvelope.unwindwas O(n²) and the non-elidedassertran it a second time. Theinteresting result is that the gap widens with bundle size, which is what an O(n²) to O(n)
change should look like — and bundles are built per recipient over the zone population.
xychart-beta title "BundledEnvelope speedup by bundle size (times faster)" x-axis ["10 envelopes", "50 envelopes", "200 envelopes"] y-axis "times faster" 0 --> 55 bar [6.1, 16.5, 47.6]GUID allocation
xychart-beta title "ExclusivePool Get + Return, microseconds per operation" x-axis ["before", "after"] y-axis "microseconds" 0 --> 30 bar [28.2, 0.042]ExclusivePoolGet+Return, 16,000-number poolGenericPool.first, 59,152 numbers allocatedGenericPool.firstis not a typo. With the configured pools nearly full it took roughly twoseconds per call, on the pool actor's thread, blocking that mailbox — and this is the
path every allocation falls back to once any fixed pool is exhausted, which is precisely
when the server is already under load. It is charted only as a table because a bar for the
new value would not be visible next to the old one.
Codec acquisition
CommonFieldData's codecs are obtained once per object in everyObjectCreateMessage.codec(extra = false)codec2(extra = false)Reported as roughly 480-590 ns saved per acquisition rather than as a ratio. The new path
is a lazy-val field read, which is close enough to the harness's resolution floor that the
apparent ~165x is partly an artifact of how cheap it now is. The saving itself is real.
Database
Measured on the real
loadCampaignKdaDataquery, 500 avatars and a 200k-row kill log. Thisone is a genuine end-to-end query measurement rather than a microbenchmark.
xychart-beta title "Campaign KDA query on login, milliseconds" x-axis ["without V018", "with V018"] y-axis "milliseconds" 0 --> 12 bar [10.29, 2.47]The ratio matters less than the shape: without the indexes this is a sequential scan whose
cost grows with every kill the server ever records, so the figure above is what it looks like
on a young database.
Not measured
load, which a microbenchmark cannot capture. Needs a profiled server with real sessions.
from bundle size and tick interval, not an observation.
Verification
run to run, since the ActorTests are timing-sensitive, as
build.sbtnotes. No suite failsthat does not already fail on master. Baseline was established on master plus only the
import fix, because master's test sources do not compile without it.
Md5Machad to stay bit-identical, as this crypto is verified against the live server.CryptoTest's known MAC vector passes unmodified, and the rewrite was additionally checkeddifferentially against the previous implementation over 6,000 randomised cases — varying
keys, update counts, lengths straddling the 64-byte block boundary, output lengths and
interleaved resets — with no mismatches.
ZoneInfo) resolve against thereal merged configuration.
V018was applied over the full V001→V018 chain on both PostgreSQL 14 (what composedeclares) and 16 (what is actually deployed), and re-applying it is a clean no-op. Against
500 avatars and a 200k-row kill log, the campaign KDA query plans as a
BitmapOracrossthe two new indexes at 2.5 ms / 636 buffers, versus a parallel sequential scan at 10.3 ms /
3,917 buffers without them — and the sequential scan's cost grows with the table forever.
Relationship to other open pull requests
Recommendation: merge #1384 before or together with this one.
#1384 (Fix client instability / crashes when zoning frequently) carries the commit that
makes
smpHistoryLengthconfigurable and raises it above 100, because 100 entries wasalready too small during zone loads. This PR's bundler drain limit shortens the wall-clock
span that same history covers, so the two are coupled: merged together they are
complementary, and this one merged alone puts more pressure on a history that is already
undersized. If only this PR lands, lower
packet-bundling-drain-limitfrom 8 until #1384follows.
The mechanics are fine — this was checked rather than assumed. Merging
fix/zoning-playersinto this branch auto-merges with zero conflicts, and the combined result compiles.
That is despite four files being touched by both:
Config.scalapacketBundlingDrainLimitsmpHistoryLengthapplication.confMiddlewareActor.scalaprocessQueueRelatedApathInterstellarClusterService.scalaGetInstantActionSpawnPointtokenTwo further overlaps worth flagging to reviewers:
MiddlewareActor.scala. Not checked for conflictshere, and it touches the same reliable-delivery machinery the drain limit interacts with,
so it is worth sequencing deliberately rather than merging blind alongside this.
guid-pools/*.jsonconfiguration,so there is no code conflict with the
GenericPool/ExclusivePoolwork here. The two arecomplementary: that PR reshapes how pools are laid out, while this one removes the
quadratic scan that makes pool exhaustion catastrophic rather than merely slow.
Deliberately not included
PlayerStateis still published to the whole zone andfiltered per recipient, which is O(N²) in player count and is the largest remaining win.
The receive side is genuinely per-recipient stateful logic (visibility history, adaptive
delay), so the change is entirely in subscription lifecycle — subscribe each session to its
neighbouring sectors and re-subscribe on crossing. Getting that subtly wrong makes players
invisible to each other at sector boundaries, there is no test coverage for the path, and
it needs a live server and two clients walking a boundary to validate. It should land on
its own.
RandomSelector.Returnstill reports failure when no number is outstanding, leakingthat GUID. Making it succeed unconditionally would let a double return insert the same
number twice and eventually issue one GUID to two entities, which is worse. Fixing it
properly means tracking allocation state explicitly instead of inferring it from a cursor.
null-guarded the partially populated history ring, which throws and lets supervision drop
the client. That fix already exists, together with making the history configurable, in
Fix client instability / crashes when zoning frequently #1384, so it was removed from here rather than land the same change twice and conflict.
See the note on the drain limit below for why the two interact.
selectDataCodec's remaining per-call construction, and the event bus's use of actor pathsas channel names (which grows the subscriber classifier for the life of the process).
For reviewers — where the risk is
server to confirm actors land where intended. Config resolution is verified; actual thread
placement is not.
with the SMP retransmit history that a reviewer should weigh before this is deployed.
MiddlewareActorretains sentSlottedMetaPackets in a fixed 100-entry ring and answersthe client's
RelatedAretransmit requests from it. Draining up to eight bundles per runinstead of one means that ring wraps up to eight times faster in wall-clock terms, so the
window in which a retransmit can still be served shrinks accordingly. Per the protocol
notes, a reliable packet that can never be retransmitted blocks that channel's delivery
rather than degrading gracefully.
Fix client instability / crashes when zoning frequently #1384 makes
smpHistoryLengthconfigurable and raises it, because 100 entries wasalready too small during zone loads. These two want to land together, or this default wants
lowering until that one is in — see Relationship to other open pull requests above. The
drain limit is configurable precisely so it can be tuned down in the meantime.
It has also not been exercised against a real client on a constrained connection.
SectorGrouplaziness moves when a category is snapshotted, from construction to firstread. Those reads were already unsynchronised, so this shifts an existing window rather
than creating one, and removes it entirely for categories never consulted — but it is a
behavioural change worth a second opinion.
component microbenchmarks plus one real query measurement. They establish that each change
is faster in isolation, which is not the same as establishing what dominates a live server
under load, or that the aggregate effect on client-visible hitching is what we expect. A
profile against a populated server is still the thing that would settle that, and none of
these numbers substitute for it.