Skip to content

fix(world): resolve chunk-processing-pipeline deadlock on unrequested neighbours (#5150) - #5348

Merged
Cervator merged 8 commits into
developfrom
soloturn-performance
Aug 1, 2026
Merged

fix(world): resolve chunk-processing-pipeline deadlock on unrequested neighbours (#5150)#5348
Cervator merged 8 commits into
developfrom
soloturn-performance

Conversation

@soloturn

@soloturn soloturn commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Investigates and fixes the chunk-processing-pipeline deadlock behind #5150 (chunks that never finish loading after a bulk reload), plus two pre-existing test bugs the fix exposed, and an unrelated test-infra OOM found while verifying.

Root cause

Multi-chunk pipeline stages (e.g. LightMerger, which needs the full 3x3x3 neighbourhood to merge light) only submit a chunk once every required neighbour has been assigned to that stage or later. At the edge of any loaded region, some neighbours were never requested by anyone - not in the chunk cache, not in the pipeline - so they can never be assigned, and the chunk waiting on them sat in chunkProcessingInfoMap forever. This isn't limited to bulk reloads (it happens during ordinary client-spawn loading too), and widening the loaded region doesn't help - the wider region just gets its own outer shell with the same problem. It's also not wasted recompute: light data is never persisted to disk, so a reloaded chunk genuinely needs a full light-merge, same as a fresh one.

Fix

ChunkProcessingPipeline now tracks positions blocked on a missing requirement. The reactor thread polls with a timeout instead of blocking indefinitely, and counts consecutive empty polls. Only once the whole pipeline has been idle for ~10s does it force still-blocked positions past their current stage with a pass-through task, instead of the real stage task. For light merging, a skipped chunk keeps whatever lighting its earlier per-chunk stages produced; when a neighbour loads later, that neighbour's own merge corrects the shared face.

The idle gate (rather than skipping the instant a requirement is found missing) matters because "will never arrive" and "hasn't been requested yet, but will be in a moment" look identical at a single point in time - see the next section.

Verified with the new ManyUsersChunkLoadTest: 200/200 bulk-reloaded chunks across 8 regions now complete in ~14s; before this fix, the pipeline deadlocked and never completed.

Two pre-existing test bugs this uncovered

Both were masked before this fix, because neighbour chunks near a region's edge could never actually finish processing - so in practice only the requested chunk ever completed, which stopped being true once the fix let neighbours complete too.

  1. ChunkRegionFuture.onChunkRelevant() counted any relevant chunk toward region completion, not just chunks inside the requested region. For a single-chunk request, a neighbour/padding chunk becoming relevant could resolve the future before the actually-requested chunk existed in the chunk cache. Fixed to only count chunks inside the requested region (ExampleTest.testWorldProvider was failing on this).
  2. LocalChunkProviderTest (5 of its test methods) assumed only the requested chunk would ever complete in a given update() call, and asserted on world/block lifecycle events by fixed list index. Since requestCreatingOrLoadingArea() requests a full 3x3x3 neighbourhood, other chunks can complete, generate events, or unload in the same window. Rewrote assertions to wait for and filter on the specific chunk/position under test instead of assuming ordering.

Also included

  • LocalChunkProvider: avoid loading each chunk's ChunkStore from disk twice (once in the async pipeline task, again on the main thread for restoreEntities()).
  • LocalChunkProvider: clear stale loadedChunkStores/generateQueuedEntities entries in purgeWorld()/dispose(), and on the early-return path in processReadyChunk(), so a world-purge can't resurrect entities from a deleted world via a leftover entry.
  • Test JVM heap raised 1024m -> 4096m (terasology-metrics.gradle.kts). Running many MTE integration tests together (each spins up a full TerasologyEngine) hit Java heap space well before running out of the machine's actual RAM - the limit was just a stale hardcoded value, unrelated to available memory.

New diagnostic test, and a new diagnostic tag/task to keep it off CI

ManyUsersChunkLoadTest simulates 8 clients + host (9 TerasologyEngines total), each forcing a distinct region relevant, then bulk-reloads all 200 chunks at once with persistent entities in them - the scenario #5150 describes. It isn't a pass/fail regression test by design - it reports timings via TestReporter for manual comparison.

It was originally tagged flaky, which put it in integrationTestFlaky - the bucket Jenkinsfile runs (non-blocking, via warnError) on every single build. In practice it's been failing there on every recent CI run: UncheckedTimeoutException from the plain helper.runUntil(ListenableFuture) overload, which has no way to override its hardcoded 30-second game-time budget (a separate mechanism from the setSafetyTimeoutMs() wall-clock safety net this test does raise, for its later reload-polling loop). Making 8 regions concurrently relevant under CI contention apparently doesn't fit that fixed 30s game-time window, even though it comfortably does locally - a capacity gap, not flakiness, and not something this PR's fix touches.

Rather than leave a diagnostic tool failing Jenkins on every build (or just deleting the signal), added a new diagnostic tag/task pair in engine-tests/build.gradle.kts, mirroring the existing filesystemSideEffectTest opt-in pattern:

  • @Tag("diagnostic") (replacing @Tag("flaky")) on ManyUsersChunkLoadTest
  • excludeTags(diagnostic) added to test, unitTest, integrationTest, and integrationTestFlaky, so it no longer runs automatically anywhere
  • new integrationTestDiagnostic task (includeTags(diagnostic)) for manual/local runs: gradlew :engine-tests:integrationTestDiagnostic --tests ManyUsersChunkLoadTest

This also answers the CI-scheduling question this PR originally raised about the flaky bucket running on every build - reusable for any future test with the same "manual tool, not a CI gate" nature, without needing a scheduled-job change to Jenkinsfile.

Test plan

  • ChunkProcessingPipelineTest (5/5, including multiRequirementsChunksWillGeneratedSuccess, which regressed under an earlier immediate-skip version of this fix and drove the idle-gate design)
  • ExampleTest (5/5)
  • LocalChunkProviderTest (5/5)
  • ManyUsersChunkLoadTest standalone via integrationTestDiagnostic --tests "*ManyUsersChunkLoadTest*" (1/1, 0 failures, 1m9s)
  • integrationTestFlaky --tests "*ManyUsersChunkLoadTest*" now finds no matching tests (confirms the exclusion)
  • Full engine-tests regression sweep (test + integrationTest): 1730 tests, 0 failures, 0 errors

@github-actions github-actions Bot added the Type: Bug Issues reporting and PRs fixing problems label Jul 29, 2026
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fb6d0f27-f2bd-4950-8f84-82b67d777837

📥 Commits

Reviewing files that changed from the base of the PR and between bb93624 and c346598.

📒 Files selected for processing (2)
  • engine-tests/build.gradle.kts
  • engine-tests/src/test/java/org/terasology/engine/integrationenvironment/ManyUsersChunkLoadTest.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • engine-tests/src/test/java/org/terasology/engine/integrationenvironment/ManyUsersChunkLoadTest.java

📝 Walkthrough

Summary by CodeRabbit

  • Performance

    • Improved chunk loading efficiency by reusing already-loaded data during asynchronous processing.
    • Reduced pipeline stalls and improved progress when neighboring chunks are unavailable.
  • Bug Fixes

    • Prevented unrelated or invalid chunks from being tracked as relevant.
    • Improved cleanup during shutdown and world resets to avoid stale chunk state.
  • Tests

    • Added an opt-in diagnostic test suite for concurrent chunk loading and reload performance.
    • Improved deterministic chunk lifecycle testing.
    • Increased available memory for heavier integration tests.

Walkthrough

The change updates chunk pipeline progress, preserves loaded chunk stores across asynchronous processing, filters region readiness, adds a multi-user chunk reload diagnostic, stabilizes local-provider tests, and increases the test JVM heap limit.

Changes

Chunk Loading Reliability

Layer / File(s) Summary
Pipeline idle-stage unblocking
engine/src/main/java/org/terasology/engine/world/chunks/pipeline/ChunkProcessingPipeline.java
Tracks blocked positions and schedules pass-through stages after repeated idle reactor polls.
ChunkStore handoff and cleanup
engine/src/main/java/org/terasology/engine/world/chunks/localChunkProvider/LocalChunkProvider.java
Carries loaded ChunkStore instances into ready-chunk processing and clears stale state during cache hits, disposal, and world purging.
Region loading diagnostics
engine-tests/src/main/java/org/terasology/engine/integrationenvironment/ChunkRegionFuture.java, engine-tests/src/test/java/org/terasology/engine/integrationenvironment/ManyUsersChunkLoadTest.java, engine-tests/build.gradle.kts
Restricts readiness counting to valid requested chunks, adds concurrent relevance and persistent-entity reload timing measurements, and provides an opt-in diagnostic test task.
Deterministic chunk-provider tests
engine-tests/src/test/java/org/terasology/engine/world/chunks/localChunkProvider/LocalChunkProviderTest.java, build-logic/src/main/kotlin/terasology-metrics.gradle.kts
Waits for chunk readiness, finds lifecycle events by type and position, and raises the test JVM maximum heap to 4096 MB.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ManyUsersChunkLoadTest
  participant ChunkRegionFuture
  participant LocalChunkProvider
  participant ChunkProcessingPipeline
  ManyUsersChunkLoadTest->>ChunkRegionFuture: request region readiness
  ChunkRegionFuture->>LocalChunkProvider: count in-region relevant chunks
  LocalChunkProvider->>ChunkProcessingPipeline: process chunk stages
  ChunkProcessingPipeline->>LocalChunkProvider: advance blocked positions after idle polling
  LocalChunkProvider-->>ManyUsersChunkLoadTest: report chunk readiness and reload completion
Loading

Possibly related PRs

Suggested reviewers: cervator

Poem

A rabbit watched the chunks align,
As blocked stages moved in time.
Stores crossed safely from load to ready,
While tests grew stable, clear, and steady.
Diagnostic hops now measure the way. 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the primary change: fixing the chunk-processing-pipeline deadlock.
Description check ✅ Passed The description directly explains the deadlock, the fix, related test changes, diagnostics, and verification results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch soloturn-performance

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@engine/src/main/java/org/terasology/engine/world/chunks/localChunkProvider/LocalChunkProvider.java`:
- Around line 95-99: Update unloadChunkInternal() so its early-cancellation path
removes the position from both loadedChunkStores and generateQueuedEntities
after stopping processing. Ensure cancellation cleanup occurs before returning,
preventing orphaned ChunkStore entries and stale entity restoration on later
requests.

In
`@engine/src/main/java/org/terasology/engine/world/chunks/pipeline/ChunkProcessingPipeline.java`:
- Around line 51-78: Replace the pipeline-wide consecutiveIdlePolls mechanism in
ChunkProcessingPipeline with per-position blocked-duration tracking for entries
in blockedPositions. Record when each position first becomes blocked, and update
skipBlockedStages() to skip a position once its own blocked time reaches the
configured idle threshold, regardless of completions from unrelated stages;
remove or stop using the global idle counter and clear tracking when positions
unblock or are removed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 44a974e4-ba53-4991-b753-24f26c52b871

📥 Commits

Reviewing files that changed from the base of the PR and between 8f97a54 and 2da61b7.

📒 Files selected for processing (6)
  • build-logic/src/main/kotlin/terasology-metrics.gradle.kts
  • engine-tests/src/main/java/org/terasology/engine/integrationenvironment/ChunkRegionFuture.java
  • engine-tests/src/test/java/org/terasology/engine/integrationenvironment/ManyUsersChunkLoadTest.java
  • engine-tests/src/test/java/org/terasology/engine/world/chunks/localChunkProvider/LocalChunkProviderTest.java
  • engine/src/main/java/org/terasology/engine/world/chunks/localChunkProvider/LocalChunkProvider.java
  • engine/src/main/java/org/terasology/engine/world/chunks/pipeline/ChunkProcessingPipeline.java

Comment on lines +95 to +99
// The ChunkStore loaded (from disk) for a chunk in createOrLoadChunk()'s async task, carried
// through to processReadyChunk() so it isn't read from disk, decompressed, and re-parsed a
// second time there just to reach restoreEntities() - mirrors how generateQueuedEntities
// above already carries a freshly *generated* chunk's entities across the same handoff.
private final Map<Vector3ic, ChunkStore> loadedChunkStores = new ConcurrentHashMap<>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

loadedChunkStores isn't cleaned up when in-flight loading is cancelled — leak and possible stale-entity restoration.

dispose()/purgeWorld() now clear loadedChunkStores, but the much more common cancellation path — unloadChunkInternal() calling loadingPipeline.stopProcessingAt(pos) when a position becomes irrelevant while still being asynchronously loaded — never removes the entry. If createOrLoadChunk() already ran loadedChunkStores.put(chunk.getPosition(), chunkStore) (line 148) before cancellation, that entry is now orphaned: chunkProcessingInfoMap no longer tracks the position, so processReadyChunk() (the only other place that removes it) will never run for it.

Two consequences:

  • A retained ChunkStore leaks for the life of the provider every time a load-in-progress is cancelled before completion (common during normal exploration/view-distance changes).
  • If that position is later re-requested and this time takes the generate branch (chunkStore == null), processReadyChunk() will pick up the stale entry via loadedChunkStores.remove(chunkPos) (line 187), restore entities from the old, unrelated store, and skip processing the freshly-generated entities in generateQueuedEntities entirely.

generateQueuedEntities has the identical pre-existing gap, so ideally both should be cleaned up together in unloadChunkInternal()'s early-cancel branch:

🐛 Proposed fix
     private boolean unloadChunkInternal(Vector3ic pos) {
         if (loadingPipeline.isPositionProcessing(pos)) {
             // Chunk hasn't been finished or changed, so just drop it.
             loadingPipeline.stopProcessingAt(pos);
+            loadedChunkStores.remove(pos);
+            generateQueuedEntities.remove(pos);
             return false;
         }

Also applies to: 148-148, 180-187

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@engine/src/main/java/org/terasology/engine/world/chunks/localChunkProvider/LocalChunkProvider.java`
around lines 95 - 99, Update unloadChunkInternal() so its early-cancellation
path removes the position from both loadedChunkStores and generateQueuedEntities
after stopping processing. Ensure cancellation cleanup occurs before returning,
preventing orphaned ChunkStore entries and stale entity restoration on later
requests.

Comment on lines +51 to +78
/**
* How long the reactor waits for a stage to complete before checking whether the pipeline has
* gone idle. See {@link #skipBlockedStages()}.
*/
private static final long POLL_INTERVAL_MS = 5000;
/**
* Consecutive idle polls (i.e. {@code POLL_INTERVAL_MS * IDLE_POLLS_BEFORE_SKIP} of pipeline-wide
* silence) before positions with an unmet requirement are forced past their current stage.
*/
private static final int IDLE_POLLS_BEFORE_SKIP = 2;

private final List<ChunkTaskProvider> stages = Lists.newArrayList();
private final Thread reactor;
private final ChunkExecutorCompletionService chunkProcessor;
private final ThreadPoolExecutor executor;
private final Function<Vector3ic, Chunk> chunkProvider;
private final Map<Vector3ic, ChunkProcessingInfo> chunkProcessingInfoMap = Maps.newConcurrentMap();
/**
* Positions currently waiting on a requirement that isn't available yet. A position lands here
* whether the requirement is merely late (still being processed, or not yet requested but about
* to be) or will genuinely never arrive (outside anything ever requested) - those two cases look
* identical at the moment a requirement is found missing. {@link #skipBlockedStages()} is what
* tells them apart, by waiting to see whether the whole pipeline stays quiet long enough that
* "still coming" stops being plausible.
*/
private final Set<Vector3ic> blockedPositions = Sets.newConcurrentHashSet();
/** Reactor-thread-only; counts consecutive poll timeouts with nothing completing anywhere. */
private int consecutiveIdlePolls;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Idle detection is pipeline-wide, not per-position — may never fire under sustained load.

consecutiveIdlePolls resets to 0 whenever any future completes anywhere in the pipeline (line 118), and only increments when chunkProcessor.poll() times out with nothing completing at all (lines 112-117). Under continuous churn — many concurrent users repeatedly triggering loads/reloads (the exact scenario ManyUsersChunkLoadTest exercises) — some task somewhere is very likely to complete within every 5s poll window, so the pipeline may never register IDLE_POLLS_BEFORE_SKIP consecutive idle polls even though a specific boundary chunk has been sitting in blockedPositions the entire time. In that case skipBlockedStages() never runs and the position stalls indefinitely — reproducing the original deadlock precisely under the busy conditions this fix is meant to address.

Consider tracking idleness per blocked position (e.g., a timestamp of when each position was first found blocked) rather than a single pipeline-wide idle counter, so a position can be skipped once it has been stuck long enough, independent of unrelated activity elsewhere.

Also applies to: 110-118, 197-244

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@engine/src/main/java/org/terasology/engine/world/chunks/pipeline/ChunkProcessingPipeline.java`
around lines 51 - 78, Replace the pipeline-wide consecutiveIdlePolls mechanism
in ChunkProcessingPipeline with per-position blocked-duration tracking for
entries in blockedPositions. Record when each position first becomes blocked,
and update skipBlockedStages() to skip a position once its own blocked time
reaches the configured idle threshold, regardless of completions from unrelated
stages; remove or stop using the global idle counter and clear tracking when
positions unblock or are removed.

soloturn added 7 commits July 30, 2026 22:57
createOrLoadChunk() already loads a chunk's ChunkStore from disk (gzip
+ protobuf) inside the async pipeline task to build the Chunk, then
discarded it. processReadyChunk() loaded it a second time on the main
thread purely to call restoreEntities(), doubling the I/O/decompress/
parse cost for every loaded chunk.

Carry the already-loaded ChunkStore from the async task to
processReadyChunk() via a new loadedChunkStores map, mirroring the
existing generateQueuedEntities handoff used for freshly generated
chunks.

Refs #5150
Unlike generateQueuedEntities (which is always overwritten by the same
branch before it's read again), loadedChunkStores is only written on
the "loaded from disk" branch. A position loaded before purgeWorld()
that never got consumed (early return in processReadyChunk, or a
canceled/interrupted load) leaves a stale entry behind. After
purgeWorld() deletes the world, the same position re-enters via the
"generate fresh" branch instead - which never touches this map - so
the stale ChunkStore survives and gets wrongly matched to the new
chunk, resurrecting entities from the deleted world.

Remove the entry on the early-return path, and clear both
loadedChunkStores and generateQueuedEntities in purgeWorld()/dispose()
before any new loads can be scheduled.
Simulates 8 clients + host forcing 8 well-separated ~5x5-chunk regions
relevant concurrently, populates persistent entities in each, then
bulk-reloads all 200 chunk positions at once - the scenario reported
in #5150 as causing multi-second main-thread stalls after loading a
saved world.

Not a pass/fail regression test; it reports timings via TestReporter
for manual comparison across runs.

Refs #5150
… unrequested neighbours

Root cause: multi-chunk stages (e.g. LightMerger, which needs the full
3x3x3 neighbourhood to merge light) only submit once every required
neighbour position has been assigned to that stage or later. At the
edge of any loaded region, some of those neighbours were never
requested by anyone - not by the chunk cache, not by this pipeline -
so they can never be assigned. The chunk waiting on them sat in
chunkProcessingInfoMap forever. Widening the loaded region does not
fix this: the wider region just gets its own outer shell with the
same problem.

Confirmed this is not limited to bulk reloads - it also happens during
completely normal client-spawn loading - and that it is a genuine
structural boundary, not wasted recompute: light data is never
persisted to disk (only blockData/extraData round-trip through
ChunkSerializer), so a reloaded chunk needs a full light-merge same as
a freshly generated one.

Fix: track positions blocked on a missing requirement in
blockedPositions. The reactor no longer blocks indefinitely on
chunkProcessor.take(); it polls with a timeout and counts consecutive
empty polls. Only once the whole pipeline has been idle for
IDLE_POLLS_BEFORE_SKIP consecutive polls (~10s) does it force every
still-blocked position past its current stage via skipStage(), which
runs a pass-through task instead of the real stage task. For light
merging that means the chunk's outward faces keep whatever lighting
the earlier per-chunk stages produced; when a neighbour loads later,
that neighbour's own merge propagates across the shared face and
corrects it.

The idle gate (rather than skipping the instant a requirement is
found missing) is necessary because "will never arrive" and "hasn't
been requested yet, but will be in a moment" look identical at a
single point in time - see ChunkProcessingPipelineTest below.

Verified with ManyUsersChunkLoadTest: 200/200 bulk-reloaded chunks
across 8 regions now complete in ~14s; before this fix the pipeline
deadlocked and never completed.

Refs #5150
…requested region

onChunkRelevant() counted every relevant chunk towards
loadedChunks.size() >= chunks.volume(), regardless of whether that
chunk was actually inside the requested region. For a single-chunk
request (volume 1, e.g. forceAndWaitForGeneration(new Vector3i())),
any other relevant chunk - a padding/neighbour chunk outside the
request - could satisfy the count and resolve the future before the
actually-requested chunk was in the chunk cache.

This was previously masked: before the ChunkProcessingPipeline fix,
padding/neighbour chunks near the edge of a region could never
complete their own stages, so they could never win this race. Once
that fix let those chunks complete, ExampleTest.testWorldProvider
started failing - the future resolved on a neighbour, and the actual
requested chunk was asserted on before it necessarily existed.

Fix: only count a chunk towards completion if it is inside the
requested region.
…er completes

requestCreatingOrLoadingArea() requests the full 3x3x3 neighbourhood a
chunk needs for its own light merge, so more than just the centre
chunk can become ready, generate world/block events, or get unloaded
in a single update(). These tests implicitly assumed otherwise:

- A single chunkProvider.update() call was assumed sufficient to make
  the requested chunk ready. update() only processes as many ready
  chunks as fit its per-tick time budget, so with more chunks in
  flight one call is not always enough. Replaced with
  updateUntilChunkReady(), which loops update() until the specific
  chunk under test is ready.
- World/block lifecycle events were asserted by fixed list index
  (.get(0), .get(1)), assuming the requested chunk's event always
  arrived first. Replaced with filtering by chunk position (for
  events that carry one) or by existence of a matching event (for
  block lifecycle events, which carry none).
- testUnloadChunkAndDeactivationBlock additionally waited for the
  first BeforeDeactivateBlocks/BeforeChunkUnload from *any* chunk,
  which - with the empty RelevanceSystem this test relies on to make
  every cached chunk unload-eligible - could be a neighbour chunk
  instead of the requested one. Split into two waits: first for the
  requested chunk's own BeforeChunkUnload (position-filterable), then
  for block deactivation from any chunk (not attributable to a
  position, per issue #3244).

These assumptions held only because, before the ChunkProcessingPipeline
fix, neighbour chunks near a region's edge could never actually finish
processing - so in practice only the centre chunk ever completed,
which is exactly what stopped being true once that fix let neighbours
complete too.
Running many MTE integration tests together in one Test task JVM
(each spins up a full TerasologyEngine, some create multiple) hit
Java heap space OOM well before running out of the machine's actual
RAM, because tasks.withType<Test> hardcoded -Xmx1024m. Kept at 4096m
rather than higher in case CI runners are more heap-constrained than
a local dev machine.
@soloturn
soloturn force-pushed the soloturn-performance branch from 577eff6 to bb93624 Compare July 30, 2026 20:57

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
build-logic/src/main/kotlin/terasology-metrics.gradle.kts (1)

73-73: 🧹 Nitpick | 🔵 Trivial

Heap bump applies to every Test task, incl. any parallel workers.

This applies uniformly to unitTest, integrationTest, and integrationTestFlaky tasks. If Gradle test workers run in parallel (max-workers/maxParallelForks), each JVM can now claim up to 4GB, so total CI memory demand scales accordingly. Worth confirming CI runners have headroom for the expected parallelism.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@build-logic/src/main/kotlin/terasology-metrics.gradle.kts` at line 73, Review
the jvmArgs configuration applied to every Test task in
terasology-metrics.gradle.kts, including unitTest, integrationTest, and
integrationTestFlaky, and avoid imposing a 4GB maximum heap on each parallel
worker. Scope the increased heap only to the task(s) that require it, or use a
lower/shared limit consistent with CI runner capacity while preserving required
test execution.
engine-tests/src/test/java/org/terasology/engine/world/chunks/localChunkProvider/LocalChunkProviderTest.java (1)

147-167: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated event-filtering assertion pattern across 4 tests.

The "filter captured events by type + chunkPos, then findFirst/assert present" block is repeated near-verbatim in testGenerateSingleChunk, testGenerateSingleChunkWithBlockLifeCycle, testLoadSingleChunk, and testLoadSingleChunkWithBlockLifecycle. Consider extracting a small generic helper, e.g. assertEventForChunk(List<Event> events, Class<T> type, Vector3ic pos, String message), to reduce duplication and keep future changes to this filtering logic in one place.

♻️ Sketch of a shared helper
private static <T extends ChunkEvent> void assertEventForChunk(
        List<Event> events, Class<T> type, Vector3ic chunkPosition, String message) {
    boolean present = events.stream()
            .filter(type::isInstance)
            .map(type::cast)
            .anyMatch(e -> e.getChunkPos().equals(chunkPosition));
    Assertions.assertTrue(present, message);
}

Also applies to: 182-202, 238-246, 268-276

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@engine-tests/src/test/java/org/terasology/engine/world/chunks/localChunkProvider/LocalChunkProviderTest.java`
around lines 147 - 167, Extract the repeated event-filtering logic from the
affected tests into a generic helper such as assertEventForChunk, accepting
captured events, event type, chunk position, and assertion message. Use the
helper in testGenerateSingleChunk, testGenerateSingleChunkWithBlockLifeCycle,
testLoadSingleChunk, and testLoadSingleChunkWithBlockLifecycle while preserving
each test’s existing event types and messages.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@engine/src/main/java/org/terasology/engine/world/chunks/localChunkProvider/LocalChunkProvider.java`:
- Around line 177-182: Update the duplicate-ready branch in processReadyChunk so
it also removes the generateQueuedEntities entry for chunkPos before returning,
alongside the existing loadedChunkStores cleanup.

In
`@engine/src/main/java/org/terasology/engine/world/chunks/pipeline/ChunkProcessingPipeline.java`:
- Around line 176-195: Update stopProcessingAt to remove each unloaded or
cancelled position from blockedPositions when its ChunkProcessingInfo is
removed, ensuring stale entries cannot accumulate. Keep processChunkInfo’s
blocked-position tracking unchanged.

---

Nitpick comments:
In `@build-logic/src/main/kotlin/terasology-metrics.gradle.kts`:
- Line 73: Review the jvmArgs configuration applied to every Test task in
terasology-metrics.gradle.kts, including unitTest, integrationTest, and
integrationTestFlaky, and avoid imposing a 4GB maximum heap on each parallel
worker. Scope the increased heap only to the task(s) that require it, or use a
lower/shared limit consistent with CI runner capacity while preserving required
test execution.

In
`@engine-tests/src/test/java/org/terasology/engine/world/chunks/localChunkProvider/LocalChunkProviderTest.java`:
- Around line 147-167: Extract the repeated event-filtering logic from the
affected tests into a generic helper such as assertEventForChunk, accepting
captured events, event type, chunk position, and assertion message. Use the
helper in testGenerateSingleChunk, testGenerateSingleChunkWithBlockLifeCycle,
testLoadSingleChunk, and testLoadSingleChunkWithBlockLifecycle while preserving
each test’s existing event types and messages.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c8a69cab-c9a7-4a2d-9e1b-de55a6ffe50a

📥 Commits

Reviewing files that changed from the base of the PR and between 577eff6 and bb93624.

📒 Files selected for processing (6)
  • build-logic/src/main/kotlin/terasology-metrics.gradle.kts
  • engine-tests/src/main/java/org/terasology/engine/integrationenvironment/ChunkRegionFuture.java
  • engine-tests/src/test/java/org/terasology/engine/integrationenvironment/ManyUsersChunkLoadTest.java
  • engine-tests/src/test/java/org/terasology/engine/world/chunks/localChunkProvider/LocalChunkProviderTest.java
  • engine/src/main/java/org/terasology/engine/world/chunks/localChunkProvider/LocalChunkProvider.java
  • engine/src/main/java/org/terasology/engine/world/chunks/pipeline/ChunkProcessingPipeline.java

Comment on lines 177 to 182
private void processReadyChunk(final Chunk chunk) {
Vector3ic chunkPos = chunk.getPosition();
if (chunkCache.get(chunkPos) != null) {
loadedChunkStores.remove(chunkPos);
return; // TODO move it in pipeline;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Duplicate-ready branch cleans up loadedChunkStores but not generateQueuedEntities.

When chunkCache.get(chunkPos) != null, the code removes the loadedChunkStores entry before returning, but leaves any generateQueuedEntities entry for the same position (populated when createOrLoadChunk took the generate branch) orphaned. It will leak until the next dispose()/purgeWorld() call.

🛠️ Proposed fix
     private void processReadyChunk(final Chunk chunk) {
         Vector3ic chunkPos = chunk.getPosition();
         if (chunkCache.get(chunkPos) != null) {
             loadedChunkStores.remove(chunkPos);
+            generateQueuedEntities.remove(chunkPos);
             return; // TODO move it in pipeline;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private void processReadyChunk(final Chunk chunk) {
Vector3ic chunkPos = chunk.getPosition();
if (chunkCache.get(chunkPos) != null) {
loadedChunkStores.remove(chunkPos);
return; // TODO move it in pipeline;
}
private void processReadyChunk(final Chunk chunk) {
Vector3ic chunkPos = chunk.getPosition();
if (chunkCache.get(chunkPos) != null) {
loadedChunkStores.remove(chunkPos);
generateQueuedEntities.remove(chunkPos);
return; // TODO move it in pipeline;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@engine/src/main/java/org/terasology/engine/world/chunks/localChunkProvider/LocalChunkProvider.java`
around lines 177 - 182, Update the duplicate-ready branch in processReadyChunk
so it also removes the generateQueuedEntities entry for chunkPos before
returning, alongside the existing loadedChunkStores cleanup.

Comment on lines +176 to 195
boolean blocked = false;
for (Vector3ic pos : requirements) {
Chunk chunk = getChunkBy(info.getChunkTaskProvider(), pos);
if (chunk != null) {
requiredChunks.add(chunk);
} else {
return;
// Could be being processed but not far enough along yet, could be not requested yet
// but about to be, or could be a position nothing will ever request. Those look
// identical here; skipBlockedStages() is what tells them apart.
blocked = true;
break;
}
}
if (blocked) {
blockedPositions.add(info.getPosition());
return;
}
blockedPositions.remove(info.getPosition());
info.setCurrentFuture(runTask(chunkTask, requiredChunks));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

blockedPositions entries aren't removed when a blocked position is unloaded/cancelled.

processChunkInfo adds to blockedPositions (line 190) when a requirement is missing, but the only other place a position leaves chunkProcessingInfoMap mid-flight — stopProcessingAt (lines 342-359, unchanged) — never calls blockedPositions.remove(pos). Until skipBlockedStages() next fires (which clears the entire set unconditionally), a stale position sits in blockedPositions even though its ChunkProcessingInfo is already gone.

Combined with the pipeline-wide idle-detection gap above, a server with continuous chunk churn (players moving through the world, triggering frequent loads/unloads at region edges) may never see skipBlockedStages() fire, so this set can grow without bound for the life of the pipeline.

🛠️ Proposed fix
     public void stopProcessingAt(Vector3ic pos) {
         ChunkProcessingInfo removed = chunkProcessingInfoMap.remove(pos);
         if (removed == null) {
             return;
         }
+        blockedPositions.remove(pos);
 
         removed.getExternalFuture().cancel(true);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@engine/src/main/java/org/terasology/engine/world/chunks/pipeline/ChunkProcessingPipeline.java`
around lines 176 - 195, Update stopProcessingAt to remove each unloaded or
cancelled position from blockedPositions when its ChunkProcessingInfo is
removed, ensuring stale entries cannot accumulate. Keep processChunkInfo’s
blocked-position tracking unchanged.

@Cervator

Copy link
Copy Markdown
Member

Going to test and tinker with this a bit, due to a potential connection made with something else :-)

Self-assigned in an attempt to start showing better state that way.

@agent-refr

Copy link
Copy Markdown
Collaborator

Independent verification on different hardware — Dionysus, an aged Win10 desktop running a 55-module workspace on JDK 17. (For cross-reference: the MTE chunk-stall notes we left on #5352 came from a different and weaker machine of ours, a laptop called Phoenix. Same people, different boxes — the hardware gap turns out to matter below.)

To vary the test and the fix separately, I cherry-picked 80a6285b6 (the diagnostic, test-only, 181 lines) onto clean develop and ran it there, then ran the same test on the branch:

TestReporter metric develop + test only this PR
baseline_solo_region_ms 902 479
connect_8_clients_ms 51695 50482
concurrent_8_regions_ms 4135 4672
reload_200_chunks_with_entities_ms never — TimeoutException 61968

The first three phases agree within noise, so the runs are comparable right up to the phase of interest, and the bulk reload is the only thing that differs: it deadlocks without the fix and completes with it. That is #5150 reproduced and fixed on a machine other than yours. With the fix, the backlog also drains monotonically (152 -> 142 -> 132 -> 123 -> 113 -> 106 chunks remain) instead of ceasing partway.

Worth noting the failure mode is TimeoutException, not OutOfMemoryError, and 9 engines connected fine here at the old -Xmx1024m. So on this box the heap bump is not what separates pass from fail — the pipeline change is. (The heap raise still looks right on its own merits, just not load-bearing for this scenario.)

Two notes on ChunkProcessingPipeline

The idle gate is bounded by silence, not by elapsed time. consecutiveIdlePolls resets on any completion anywhere in the pipeline, so under sustained load blocked positions may not be released until everything else has drained. The conservative choice is right — acting the instant a requirement is missing would cut short a neighbour that is genuinely still coming, and your multiRequirementsChunksWillGeneratedSuccess regression makes that concrete. But it does mean the unblock latency is not POLL_INTERVAL_MS * IDLE_POLLS_BEFORE_SKIP; that is only the quiet-pipeline case. On a busy engine the wait is unbounded in principle, which is a little uncomfortable against MTE's 30s game-time / 60s real-time safety budgets. A second, longer absolute ceiling for "blocked for a very long time regardless of pipeline noise" might be worth it.

skipStage does not null-check the chunk. It passes info.getChunk() straight into Collections.singletonList(chunk); skipBlockedStages guards on getCurrentFuture() == null but not on the chunk. Probably unreachable, since a position blocked on requirements has already cleared generation — but it is the one path where a null would turn a recovery into an NPE on the reactor thread, which is the thread you least want to lose.

Threading otherwise looks sound: processChunkInfo and skipBlockedStages both run only on the reactor, and reactor.interrupt() appears only in shutdown(), so blockedPositions is effectively reactor-confined despite the concurrent set.

One cost worth stating in the PR body

Full :engine-tests:test went 10m07s -> 13m30s (+33%) here, same command and same --rerun both times. I read that as the ChunkRegionFuture fix doing its job rather than a regression: MTE now waits for all 27 requested chunks instead of resolving once any 27 of the padded 125 arrive, so some tests were previously passing without the chunks they asked for. Probably worth the correctness, but people will notice, and it is better coming from the PR than being discovered later.

The obvious lever afterwards is addMargin requesting 125 chunks to obtain 27 — the file's own FIXMEs already point there, including "Is the complete relevance region not actually loaded‽" and the wish for a RelevanceSystem interface taking radii directly. Separate change, but that is where the time goes back.

On the CI scheduling question

Agreed that running the whole flaky bucket every build is mostly noise. One wrinkle before moving it to a weekly job: ManyUsersChunkLoadTest is flaky-tagged, and that tag appears only in integrationTestFlaky (includeTags("MteTest & flaky", ...)), so it never runs under :engine-tests:test. Moving the bucket to a schedule means the only in-tree thing that exercises #5150 stops running on PRs entirely. Might be worth splitting "heavy but deterministic" from "genuinely unreliable" so this one can stay on PR builds, or leaving it untagged and letting its own timings carry it.

Low-confidence, flagging rather than claiming

A full :engine-tests:test run on this branch produced 5 failures, all the same @BeforeEach NPE at ModuleManagerTest.java:47 (new ModuleManager("")), where develop ran clean. It passes in isolation on this branch, and the class mocks the PathManager singleton, so it looks order-sensitive rather than caused by anything here — one run per branch cannot separate "perturbed ordering" from "flaky". Mentioning only in case the same thing shows up in CI.

@Cervator

Copy link
Copy Markdown
Member

Again talking to myself via agent reply, but reading that last bit reminds me of past efforts to fan out heavy tests in Jenkins to parallel build agents.

Build the initial code and do some basics -> Save the workspace state (could be a quick local Docker one-off build stored in a cluster-local image registry) -> Spin up multiple agents using that image and assign a chunk of a test suite each -> Fan back in when all are done ("diamond" job/parallelism pattern) and continue to write the analytics to storage.

It would take some manual tuning over time to not wildly mismatch test suites with dedicated agent time, but on the other hand in Kubernetes with the new node pools with dedicated heavy agent machines that spin down more rapidly rather than get stuck it should be fairly doable and not add a lot of cost.

@agent-refr

Copy link
Copy Markdown
Collaborator

Following up on my own verification above, because the Jenkins result on this PR (PR-5348 build 3) is more interesting than a plain flaky red, and it partly qualifies what I said.

CI is not reaching the scenario this PR fixes. The failure is at ManyUsersChunkLoadTest.java:96runUntil(Futures.allAsList(futures)), the concurrent 8 regions step — not the bulk reload. Reported entries stop at connect_8_clients_ms, with no concurrent_8_regions_ms. So the 200-chunk reload that #5150 is about never executes there. That means the red does not contradict the before/after I posted, but it also means CI is currently providing zero evidence for the fix.

The shape looks like a stall, not slowness. Comparing the phases against my Dionysus run on this same branch:

Phase Dionysus (this PR) Jenkins (this PR)
baseline_solo_region_ms 479 2534
connect_8_clients_ms 50482 59339
concurrent_8_regions_ms 4672 timeout

The agent is ~5x slower on the baseline and only ~1.2x slower on connect, so a phase that takes 4.7s here should be nowhere near the 60s real-time DEFAULT_SAFETY_TIMEOUT that MainLoop.runUntil enforces. And the log tail is Chunk processing took too long this tick (41/24ms). 1 chunks remain. / (28/24ms). 2 chunks remain. — down to one or two outstanding chunks that never arrive. That is the deadlock signature rather than a machine simply being slow.

Which raises the question I cannot answer from the CI log. With roughly a minute spent in that phase and an idle gate of POLL_INTERVAL_MS * IDLE_POLLS_BEFORE_SKIP = 10s, skipBlockedStages() had plenty of time to fire. So either it fired and did not help, or those positions never entered blockedPositions at all — i.e. they are stuck via some path other than the missing-requirement branch in processChunkInfo, which the new recovery would then not cover.

I could not distinguish those from the build log, and want to be explicit about why rather than guess: skipStage logs at logger.debug, and CI runs at INFO. "Skipping stage" is absent from the log, but so is ChunkRegionFuture's "Got chunk" — both DEBUG, both invisible for the same mundane reason. Absence there is not evidence.

Cheap way to settle it: enable org.terasology.engine.world.chunks.pipeline.ChunkProcessingPipeline at DEBUG for this test. If Skipping stage appears and it still times out, the recovery fires but is insufficient for this case. If it never appears while chunks sit unfinished, the stuck positions are not reaching blockedPositions and the guard needs to cover another path. That is a one-line logback change and it turns this red into a real datapoint either way.

Two smaller suggestions.

The reload phase — the actual #5150 repro — is gated behind two expensive setup phases, so anything that makes connect or concurrent-regions slow prevents it from ever running. Splitting the reload into its own test (or making the setup cheaper, e.g. fewer users for the reachability path) would let CI verify the thing this PR is for, even on a slow agent.

And since this is a diagnostic rather than a pass/fail regression test, it probably wants an explicit generous setSafetyTimeoutMs instead of inheriting the 60s default, which it is currently sitting right on top of — connect_8_clients_ms alone came in at 59339ms.

Worth noting it has now failed consistently for three builds, so this is not flakiness in the usual sense. A permanently red test inside a non-blocking bucket is arguably worse than an unreliable one, since it trains people to stop reading the bucket — which bears on the scheduling question in your description.

@soloturn

Copy link
Copy Markdown
Contributor Author

ManyUsersChunkLoadTest is not supposed to be run regularly, but on demand. i used it in the hope to make it slow - but it got a deadlock. increasing the time permitted to load considerable exposed the deadlock.

@Cervator

Copy link
Copy Markdown
Member

I think we should move such tests to a new "benchmark" testing dir then, just to make it obvious - those are good for isolating local test runs specific to performance, and optionally that sort of weekly dedicated run in Jenkins 👍

That sort of testing categorization has been on my list for ages, but never quite makes it near the top (same with the overly elaborate parallel test running with dedicated agents). I think we might have a few other old benchmark tests scattered about too.

manySimulatedUsersLoadDifferentAreasConcurrently was tagged "flaky", which
puts it in integrationTestFlaky - the bucket Jenkins runs (non-blocking) on
every single build. It's been failing there on every recent build
(UncheckedTimeoutException at a 30s game-time wait with no override, hit
under CI contention that doesn't reproduce locally - a capacity gap, not
flakiness), while its own Javadoc already says it's "not a pass/fail
regression test - it reports timings for manual comparison".

Replaced @tag("flaky") with a new @tag("diagnostic") category, reusable for
future tests with the same "manual tool, not a CI gate" nature:

- excludeTags(diagnostic) added to test, unitTest, integrationTest, and
  integrationTestFlaky, so it no longer runs automatically anywhere
- new integrationTestOnDemand-style task, integrationTestDiagnostic,
  mirroring the existing filesystemSideEffectTest opt-in pattern
  (includeTags(diagnostic), same "task nobody calls automatically" shape)

Verified: `integrationTestFlaky --tests "*ManyUsersChunkLoadTest*"` now
finds no tests; `integrationTestDiagnostic --tests "*ManyUsersChunkLoadTest*"`
runs it standalone (1 test, 0 failures, 1m9s locally).

Co-Authored-By: soloturn <soloturn@gmail.com>
soloturn added a commit to soloturn/yggdrasil that referenced this pull request Jul 31, 2026
… branch

Real mistake this session: a local branch named "soloturn" shared history
with MovingBlocks/Terasology#5348's actual head branch ("soloturn-performance")
but had a different name. `ws push` derives the remote branch name from the
local one, so pushing there created a brand-new unrelated branch instead of
updating the PR - silently, with no warning anywhere in the flow.

`ws review <comp> <cr#>` now compares the locally checked-out branch against
the CR's real head branch (via the existing gp_review_head_branch provider
function) and warns on mismatch, before the branch-drift check below it -
drift against the wrong branch isn't meaningful anyway. Same best-effort
posture as the drift check: an unresolvable head branch degrades to silence.

Verified against the actual incident: checking out "soloturn" and running
`ws review terasology 5348` now warns immediately; checking out the correct
"soloturn-performance" stays silent.

Co-Authored-By: soloturn <soloturn@gmail.com>
@agent-refr

Copy link
Copy Markdown
Collaborator

Re-validated on Dionysus after rebasing all 8 commits onto current develop (i.e. with #5352 merged). Clean rebase, no conflicts.

:engine-tests:test: 0 failures. Previously this branch showed 5, all the same @BeforeEach NPE in ModuleManagerTest — I flagged those as low-confidence and possibly flaky. They were neither: they were the module-attribution bug, and #5352 fixed them. Nothing to chase here.

integrationTestDiagnostic --tests ManyUsersChunkLoadTest: passes, and the reload phase moved a lot:

TestReporter metric this branch, pre-rebase rebased onto develop + #5352
baseline_solo_region_ms 479 1049
connect_8_clients_ms 50482 53740
concurrent_8_regions_ms 4672 17970
reload_200_chunks_with_entities_ms 61968 14305

That is a 4.3x improvement on the bulk reload, landing on roughly the ~14s you reported. It also makes sense mechanically: that phase reloads chunks with persistent entities, and entity deserialization goes through module attribution — exactly what #5352 repaired. So the 62s I measured earlier was carrying that bug, and the numbers in my first comment understate this PR against a fixed baseline.

Full-suite time came down with it, 13m30s to 11m10s.

Two caveats I would not want read past. These are single runs, so concurrent_8_regions_ms going 4.7s -> 18.0s is not something I would attribute to anything yet — it may just be contention on this box. And the earlier "+33% suite slowdown" figure I posted compared runs that both predate #5352 and included those 5 failures, so treat it as stale rather than as a measured cost of this PR.

No blocking findings from me. The two small things from my review above still stand as optional (the idle gate being bounded by pipeline silence rather than elapsed time, and the missing null guard on info.getChunk() in skipStage), and I would happily see them as follow-ups rather than hold this up — we are scoping a dedicated chunk performance and safety pass to pick those up along with the addMargin 125-chunks-for-27 overshoot.

@Cervator Cervator left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did more local testing and review, including a pre-merge test with a rebase to save us some Jenkins. All seems good and fixes are confirmed 👍

@Cervator
Cervator merged commit 501ac0a into develop Aug 1, 2026
12 of 13 checks passed
@Cervator
Cervator deleted the soloturn-performance branch August 1, 2026 15:55
Cervator added a commit to Terasology/ItemPipes that referenced this pull request Aug 1, 2026
@testinstance(PER_CLASS) was added to dodge a chunk-generation stall: every test method built its own engine, and only the first one in a class could finish generating its world — chunk delivery reached 27/27 for the first and then stopped at 7/27 and 4/27 for the next two.

MovingBlocks/Terasology#5348 fixed that at the source: multi-chunk pipeline stages waited on neighbours nobody had requested, so chunks at a region edge sat blocked forever. With it in, all three tests pass without sharing an engine — verified three times.

Sharing one engine per class is still a reasonable thing to want, since building one costs about 20 seconds. Removing it here anyway: it was written as a workaround, it says so, and leaving workarounds in place after their cause is fixed is how a codebase stops being able to tell you anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pXw6g6JWNGP6eAyDJ1Cf8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Type: Bug Issues reporting and PRs fixing problems

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants