Skip to content

Reduce server hitching: dead dispatcher config, per-packet allocation, and unindexed queries - #1386

Merged
jgillich merged 18 commits into
psforever:masterfrom
GeekOfWires:perf/server-hitch-reduction
Jul 24, 2026
Merged

Reduce server hitching: dead dispatcher config, per-packet allocation, and unindexed queries#1386
jgillich merged 18 commits into
psforever:masterfrom
GeekOfWires:perf/server-hitch-reduction

Conversation

@GeekOfWires

Copy link
Copy Markdown
Contributor

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:

  1. The dispatcher configuration never applied to anything. akka.conf assigns
    dispatchers to 30+ actor paths and dispatchers.conf defines them all carefully, but no
    actor 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.
  2. Hot paths copied whole collections. The blockmap copied every sector in the zone per
    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,000
    entries per allocation and release.
  3. Nothing was indexed. CREATE INDEX appeared 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"]
  end
Loading
  • Select dispatchers explicitly at each spawn site — socket actors onto the pinned
    network-listener, middleware and sessions onto world-session, login onto
    login-session, and each zone onto its own pool. The zone lookup is guarded with
    hasPath and falls back to the default pool with a warning rather than refusing to boot.
  • That guard immediately found a bug: the TR sanctuary dropship zone is tzdrtr but its
    dispatcher was spelled tzsdrtr, so it had no pool. Every other zone matched exactly.
  • Configure akka.actor.default-dispatcher (which everything had silently been using) and
    enlarge 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.
  • Raise maxActiveConnections from 5. It caps concurrent SQL statements for the whole
    server, not player connections, and the driver queues rather than rejecting, so five slots
    showed up purely as latency.
  • Give the packaged server an explicit heap and G1 with a pause target; it previously passed
    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"]
Loading
  • Rewrite Md5Mac over primitive arrays. It ran on every packet in both directions while
    holding state in ArrayBuffer[Byte] (boxing every byte), reading four bytes at a known
    offset by allocating and walking an iterator ~128 times per block, and deep-copying its
    entire state on each reset() — twice per packet.
  • Drain up to a configurable number of bundles per run of the packet bundler. It previously
    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.
  • Hoist codecs and error strings out of the encode/decode path: MultiPacketEx.sizeCodec
    rebuilt its codec tree per sub-packet, CommonFieldData's two codecs were reconstructed
    per object in every ObjectCreateMessage, and the unimplemented-opcode path rendered the
    whole payload to hex eagerly because Err takes its message by value.

Zone and event fan-out

flowchart TB
  UP["PlayerStateMessageUpstream<br/>several per second, per player"] --> UB["SessionData.updateBlockMap"]
  UB --> SEC["blockMap.sector, 550 m draw range, about 144 sectors"]
  SEC --> OLD["Before<br/>copy every sector in the zone into a List<br/>then resolve each index, O index per lookup"]
  SEC --> NEW["After<br/>index an IndexedSeq directly"]
  OLD --> SG1["SectorGroup<br/>eagerly materialise all 10 entity lists"]
  NEW --> SG2["SectorGroup<br/>condense only the categories actually read"]
Loading
  • Index blockmap sectors directly instead of copying the zone per query.
  • Condense sector-group entity lists on demand rather than materialising all ten.
  • Reuse the sector query in the SOI recalculation, which ran it twice per facility every
    five seconds.
  • Make BundledEnvelope.unwind linear; it was O(n²) and ran twice because a non-elided
    assert repeated it.

GUID allocation

  • Replace list walks with indexed access in GenericPool and ExclusivePool. The generic
    pool 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

  • TaskWorkflow synchronised on a boxed Boolean, i.e. the cached TRUE/FALSE
    singletons, 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.
  • The container move path sent two Resumes for one Wait on ask timeout, releasing an
    unrelated concurrent move's guard.
  • A per-session thread pool was never shut down, and a fixed-rate task never cancelled.

Database

Measured on the real loadCampaignKdaData query 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"]
  end
Loading
  • V018__PerformanceIndexes.sql indexes the foreign keys read during login and character
    select. killactivity matters most: it is an append-only kill log read on every login, so
    login cost grew with every kill the server had ever recorded.

Unrelated but required

  • LocalActionTest was missing a Default import, which broke Test/compile for the whole
    project 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/master and benchmarked against the new one in
the 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]
Loading
payload before after ratio
440 B (MTU-sized) 39.5 µs 1.70 µs ~23x
64 B 18.9 µs 0.62 µs ~30x

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]
Loading
xychart-beta
    title "SectorGroup construct then read livePlayerList.size, microseconds"
    x-axis ["before", "after"]
    y-axis "microseconds" 0 --> 30
    bar [26.0, 4.74]
Loading
case before after ratio
blockmap resolution, 6,724-sector zone, 144 indices 642 µs 16.3 µs ~39x
sector group over 144 sectors 26.0 µs 4.74 µs at least 5.7x

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.unwind was O(n²) and the non-elided assert ran it a second time. The
interesting 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]
Loading
bundle size before after ratio
10 955 ns 156 ns ~6x
50 14.5 µs 883 ns ~16x
200 193 µs 4.06 µs ~48x

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]
Loading
case before after ratio
ExclusivePool Get+Return, 16,000-number pool 28.2 µs 41.7 ns ~680x
GenericPool.first, 59,152 numbers allocated 1.80 s 1.53 ms ~1100-1300x

GenericPool.first is not a typo. With the configured pools nearly full it took roughly two
seconds 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 every ObjectCreateMessage.

case before after
codec(extra = false) 478-589 ns at the harness noise floor
codec2(extra = false) 484-591 ns at the harness noise floor

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 loadCampaignKdaData query, 500 avatars and a 200k-row kill log. This
one 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]
Loading
time buffers plan
without V018 10.29 ms 3,917 parallel seq scan, 2 workers
with V018 2.47 ms 636 bitmap index scan

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

  • Dispatcher assignment. Its effect is thread placement and contention under concurrent
    load, which a microbenchmark cannot capture. Needs a profiled server with real sessions.
  • The bundler drain limit. The 20 to 160 bundles/s figure quoted earlier is arithmetic
    from bundle size and tick interval, not an observation.

Verification

  • Full suite: 41 failures against a measured baseline band of 40–42 — the same code varies
    run to run, since the ActorTests are timing-sensitive, as build.sbt notes. No suite fails
    that 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.
  • Md5Mac had 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 checked
    differentially 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.
  • All 35 dispatcher selectors (three fixed pools plus one per ZoneInfo) resolve against the
    real merged configuration.
  • The new middleware config key resolves through pureconfig; a mismatch would prevent boot.
  • V018 was applied over the full V001→V018 chain on both PostgreSQL 14 (what compose
    declares) 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 BitmapOr across
    the 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 smpHistoryLength configurable and raises it above 100, because 100 entries was
already 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-limit from 8 until #1384
follows.

The mechanics are fine — this was checked rather than assumed. Merging fix/zoning-players
into this branch auto-merges with zero conflicts, and the combined result compiles.
That is despite four files being touched by both:

file this PR #1384
Config.scala adds packetBundlingDrainLimit adds smpHistoryLength
application.conf adds the drain limit key adds the history length key
MiddlewareActor.scala drain loop in processQueue history sizing and the RelatedA path
InterstellarClusterService.scala zone dispatcher selection GetInstantActionSpawnPoint token

Two further overlaps worth flagging to reviewers:

  • RelatedB Slot #1343 (RelatedB Slot) also modifies MiddlewareActor.scala. Not checked for conflicts
    here, 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 Number Pool Shuffling #1380 (GUID Number Pool Shuffling) changes only the guid-pools/*.json configuration,
    so there is no code conflict with the GenericPool/ExclusivePool work here. The two are
    complementary: 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

  • Sector-scoped movement fan-out. PlayerState is still published to the whole zone and
    filtered 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.Return still reports failure when no number is outstanding, leaking
    that 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.
  • Anything touching the SMP retransmit history. An earlier revision of this branch also
    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.
  • Off-mailbox scheduled closures, DB write coalescing and login query pipelining,
    selectDataCodec's remaining per-call construction, and the event bus's use of actor paths
    as channel names (which grows the subscriber classifier for the life of the process).

For reviewers — where the risk is

  • Dispatcher assignment is the highest-risk change and wants a thread dump on a running
    server to confirm actors land where intended. Config resolution is verified; actual thread
    placement is not.
  • The bundler drain limit changes outbound flow control, and it has a known interaction
    with the SMP retransmit history that a reviewer should weigh before this is deployed.
    MiddlewareActor retains sent SlottedMetaPackets in a fixed 100-entry ring and answers
    the client's RelatedA retransmit requests from it. Draining up to eight bundles per run
    instead 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 smpHistoryLength configurable and raises it, because 100 entries was
    already 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.
  • SectorGroup laziness moves when a category is snapshotted, from construction to first
    read. 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.
  • Components are measured; the server is not. The figures under Measured impact are
    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.

GeekOfWires and others added 17 commits July 22, 2026 05:15
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>
@ScrawnyRonnie

Copy link
Copy Markdown
Contributor

If this is all a fancy way of saying zones will load much faster (among other things), then it worked.

@GeekOfWires

GeekOfWires commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

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.

@Dethdeath

Copy link
Copy Markdown
Contributor

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>
@GeekOfWires

Copy link
Copy Markdown
Contributor Author

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 Improve comment etiquette for our previous work. It reworks every comment this branch added, across 19 files / 92 lines, and is comments only — no behavioural change, and the build was verified before committing.

The sweep covered configuration and SQL as well as Scala, since the same habit had crept into akka.conf, application.conf, build.sbt, docker-compose.yml and the V018 migration.

A few representative rewrites:

  • TaskWorkflow previously led with "the monitor must be a dedicated object … so every task bundle contended the same two JVM-wide monitors. It was also not a valid mutex …". It now states what the lock is — a per-bundle object guarding the completion flag so exactly one subtask resolves the promise — and then why the flag itself cannot serve as the monitor. That second part is a live property of java.lang.Boolean caching, not a fact about deleted code, so it stayed.
  • ContainableBehavior dropped "The previous form recovered an AskTimeoutException …" entirely. It now simply says one Resume answers the Wait, that onComplete covers both success and failure, and that a second Resume would release an unrelated concurrent move's guard.
  • akka.conf was a paragraph about deployment entries that "do not correspond to any actor". It now describes what the default dispatcher is sized for and points to the spawn sites where dispatchers are actually selected.
  • application.conf lost "the process previously sent exactly one bundle per run" and the note about the old connection-pool value.

What I deliberately kept: rationale that is still a live fact a reader needs — configured pool sizes, why Err builds its message eagerly, why scodec codecs are safe to share, and a handful of "rather than" phrasings that describe current Akka/driver/Postgres behaviour rather than our previous code.

One comment also gained detail rather than losing it. The InterstellarClusterService dispatcher note now names Zone.Nowhere as the case that actually takes the fallback path, which a local deployment confirmed, instead of the vaguer "not every zone is guaranteed to have an entry".

@jgillich
jgillich merged commit f2d81e8 into psforever:master Jul 24, 2026
1 of 2 checks passed
GeekOfWires added a commit to GeekOfWires/PSF-LoginServer that referenced this pull request Aug 3, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants