Cut testIntegration runtime(from ~86s to ~57s) and fix a service ECA cache race - #1805
Conversation
Two of EntityTestSuite's transaction-timeout tests slept 20s and 10s with a much larger margin than needed; scaled both down 5x while keeping the same relative safety margin. Also found and fixed a real concurrency bug in ServiceEcaUtil: readConfig() had a non-atomic check-then-act guard, and ServiceDispatcher's constructor calls it unconditionally on every dispatcher it creates. With dozens of test dispatchers constructed within milliseconds of each other at startup, several threads could race to rebuild the shared ECA rule cache at once, corrupting the plain HashMap/LinkedList structures mergeEcaDefinitions mutates in place. Any service call iterating those rules concurrently (evalRules) could then throw a NullPointerException from a corrupted LinkedList node. Fixed by serializing all writes to the cache behind a lock, and switching the per-service/per-event collections to ConcurrentHashMap/CopyOnWriteArrayList so the hot, unsynchronized evalRules() read path stays safe even while a write is in progress. That in turn let the artificial 5s wait in secas_test_se.xml (previously load-bearing, masking the race) come down to 500ms. Full run: ~86s down to ~57s.
|
I was looking to speed up "./gradlew testIntegration" - see if it's getting stuck anywhere, and try to shave off something like 30-40% of the run time. Few months back, the testIntegration run used to take around 2 minute 30+ seconds. And after upgrade from JUnit3 to JUnit5 Jupiter, the run time has significantly reduced to 1 minute 25 seconds. And after this change it reduced to 56 to 58 seconds. Starting point for this round: a full testIntegration run took about 86.2 seconds of actual test execution (measured from the first to the last timestamped log line, so server startup through shutdown, not counting the few extra seconds of surrounding Gradle task overhead). Digging into the slowdown I parsed the run log's timestamps looking for the biggest gaps between consecutive lines, and worked out a per-suite duration by diffing consecutive "Results for test suite: X" markers. That turned up:
What I changed
Scaled both timeout/sleep pairs down 5x, keeping the same 2x safety margin:
Ran it multiple times after the change, 667/667 passing every time. This alone brought entitytests down from ~31s to ~7s.
Next up I dropped the 5s ECA wait in secas_test_se.xml to 500ms, half expecting it to just turn out "too aggressive". Instead it exposed a real, pre-existing concurrency bug - 8 unrelated suites (WorkEffortTests, ProductionRunTests, AccountingTests, ScrumTests, and a few more) started failing with: java.lang.NullPointerException: Cannot read field "next" because "this.next" is null Traced it back and confirmed the root cause with actual log evidence, not just a hunch:
Fix is in framework/service/src/main/java/org/apache/ofbiz/service/eca/ServiceEcaUtil.java:
Verified by re-running the exact conditions that broke before (500ms wait) twice: 667/667 passing, 0 NPEs, both times. Duplicate secas.xml loads dropped from 4 to 2, and those remaining 2 are sequential/non-overlapping - a separate, legitimate reload path, not a race. checkstyleMain and checkstyleTest both pass clean too. Since the race is fixed now, I kept the wait in secas_test_se.xml at 500ms (down from 5000ms) - it's not masking anything anymore. Result 86.2s baseline -> 57.1s after both fixes, ~33.8% reduction, all 667 tests passing. Run-to-run timing does jump around a bit from normal system noise - I saw as much as 15-18s of variance between two identical unmodified baseline runs, for reference. Two full local gradlew testIntegration runs bracket this change. Before this PR (but after the earlier JUnit Jupiter 5 migration): Tests: 667, Passed: 667, Failed: 0, Errors: 0, Skipped: 0 After this Branch/PR: Tests: 667, Passed: 667, Failed: 0, Errors: 0, Skipped: 0 So on top of what the Jupiter migration already saved, this changes gets testIntegration down from ~1m25s to ~56-58s. I am merging this into trunk now and will keep watching the performance issues if it's reported by someone on the mailing list. I will rollback my changes from this PR and will plan out more improvements in the approach I took here. |
Drives addEcaDefinitions() writers and an evalRules() reader concurrently against the same service/event, using duplicate-equal rules so every write hits the remove-then-add dedup path. Verified this fails against the pre-fix ServiceEcaUtil.java (bf5e6bb) with a ConcurrentModificationException, and passes against the fix from PR #1805.
Two of EntityTestSuite's transaction-timeout tests slept 20s and 10s with a much larger margin than needed; scaled both down 5x while keeping the same relative safety margin.
Also found and fixed a real concurrency bug in ServiceEcaUtil: readConfig() had a non-atomic check-then-act guard, and ServiceDispatcher's constructor calls it unconditionally on every dispatcher it creates. With dozens of test dispatchers constructed within milliseconds of each other at startup, several threads could race to rebuild the shared ECA rule cache at once, corrupting the plain HashMap/LinkedList structures mergeEcaDefinitions mutates in place. Any service call iterating those rules concurrently (evalRules) could then throw a NullPointerException from a corrupted LinkedList node.
Fixed by serializing all writes to the cache behind a lock, and switching the per-service/per-event collections to ConcurrentHashMap/CopyOnWriteArrayList so the hot, unsynchronized evalRules() read path stays safe even while a write is in progress. That in turn let the artificial 5s wait in secas_test_se.xml (previously load-bearing, masking the race) come down to 500ms.
Full run: ~86s down to ~57s.