Search code examples
javamultithreadingkotlinoptimizationoptaplanner

Optaplanner multithreading exception: "The externalObject ... has no known workingObject"


TLDR: Enabling multithreading in Optaplanner is supposed to be a one-liner, but it throws an exception

I'm trying to optimize a damage calculation using configurable loadouts in a videogame. For context, a player may configure each item they own with a "reforge," which adds stats for strength or crit damage. The final damage calculation must be maximized as a combination of strength and crit damage. For this reason, I am using Optaplanner to allocate reforges to items.

However, enabling multithreading through <moveThreadCount>AUTO</moveThreadCount> in the XML solver config throws an exception (that does not occur in single-threaded execution):

Caused by: java.lang.IllegalStateException: The externalObject (ReforgeProblemFact(id=897f4bab-80e0-4eb9-a1d7-974f7cddfd9e, name=Fierce, rarity=COMMON, strength=4, critDamage=0)) with planningId ((class net.javaman.optaplanner_reproducible.ReforgeProblemFact,897f4bab-80e0-4eb9-a1d7-974f7cddfd9e)) has no known workingObject (null).
Maybe the workingObject was never added because the planning solution doesn't have a @ProblemFactCollectionProperty annotation on a member with instances of the externalObject's class (class net.javaman.optaplanner_reproducible.ReforgeProblemFact).

This SO question is similar, but its answer doesn't fix the exception in this example.

I removed packages and imports in the code below. Full GitHub repository link

Project structure:

src/main/
    kotlin/
        net/javaman/optaplanner_reproducible/
            Rarity.kt
            ReforgeProblemFact.kt        
            ItemPlanningEntity.kt
            ReforgePlanningSolution.kt
            MaximizeDamageConstraintProvider.kt
            Main.kt
    resources/
        reforgeSolverConfig.xml

Rarity.kt:

enum class Rarity {
    COMMON,
    RARE,
    LEGENDARY
}

ReforgeProblemFact.kt:

data class ReforgeProblemFact(
    @PlanningId
    val id: UUID,
    val name: String,
    val rarity: Rarity,
    val strength: Int,
    val critDamage: Int
)

ItemPlanningEntity.kt:

@PlanningEntity
data class ItemPlanningEntity @JvmOverloads constructor(
    @PlanningId
    val id: UUID? = null,
    val rarity: Rarity? = null,
    @PlanningVariable(valueRangeProviderRefs = ["reforgeRange"])
    var reforge: ReforgeProblemFact? = null,
    @ValueRangeProvider(id = "reforgeRange")
    @ProblemFactCollectionProperty
    val availableReforges: List<ReforgeProblemFact>? = null
)

ReforgePlanningSolution.kt:

@PlanningSolution
class ReforgePlanningSolution @JvmOverloads constructor(
    @PlanningEntityCollectionProperty
    val availableItems: List<ItemPlanningEntity>? = null,
    @PlanningScore
    val score: HardSoftScore? = null,
)

MaximizeDamageConstraintProvider.kt:

class MaximizeDamageConstraintProvider : ConstraintProvider {
    override fun defineConstraints(factory: ConstraintFactory): Array<Constraint> = arrayOf(maximizeDamage(factory))

    // This approach does not take full advantage of incremental solving,
    // but it is necessary to compute strength and critDamage together in the same equation
    private fun maximizeDamage(factory: ConstraintFactory) = factory.from(ItemPlanningEntity::class.java)
        .map(ItemPlanningEntity::reforge) // Get each item's reforge
        .groupBy({ 0 }, toList { reforge: ReforgeProblemFact? -> reforge }) // Compile into one List<ReforgeProblemFact>
        .reward("damage", HardSoftScore.ONE_SOFT) { _, reforges: List<ReforgeProblemFact?> ->
            val strengthSum = reforges.stream().collect(Collectors.summingInt { reforge -> reforge?.strength ?: 0 })
            val critDamageSum = reforges.stream().collect(Collectors.summingInt { reforge -> reforge?.critDamage ?: 0 })
            (100 + strengthSum) * (100 + critDamageSum)
        }
}

Main.kt:

class Main {
    companion object {
        private val allReforges = listOf(
            ReforgeProblemFact(UUID.randomUUID(), "Clean", Rarity.COMMON, 0, 3),
            ReforgeProblemFact(UUID.randomUUID(), "Fierce", Rarity.COMMON, 4, 0),
            ReforgeProblemFact(UUID.randomUUID(), "Shiny", Rarity.COMMON, 2, 1),
            ReforgeProblemFact(UUID.randomUUID(), "Clean", Rarity.RARE, 1, 3),
            ReforgeProblemFact(UUID.randomUUID(), "Fierce", Rarity.RARE, 5, 0),
            ReforgeProblemFact(UUID.randomUUID(), "Shiny", Rarity.RARE, 3, 2),
            ReforgeProblemFact(UUID.randomUUID(), "Clean", Rarity.LEGENDARY, 1, 4),
            ReforgeProblemFact(UUID.randomUUID(), "Fierce", Rarity.LEGENDARY, 6, 0),
            ReforgeProblemFact(UUID.randomUUID(), "Shiny", Rarity.LEGENDARY, 4, 2),
        )
        private val solverManager: SolverManager<ReforgePlanningSolution, UUID> = SolverManager.create(
            SolverConfig.createFromXmlResource("reforgeSolverConfig.xml")
        )

        @JvmStatic
        fun main(args: Array<String>) {
            val availableItems = generateAvailableItems(
                mapOf(
                    Rarity.COMMON to 4,
                    Rarity.RARE to 3,
                    Rarity.LEGENDARY to 1
                )
            )
            val solverJob = solverManager.solve(UUID.randomUUID(), ReforgePlanningSolution(availableItems))
            val solution = solverJob.finalBestSolution
            solution.availableItems!!
                .map { it.reforge!! }
                .forEach { println(it.rarity.name + " " + it.name) }
        }

        private fun generateAvailableItems(itemCounts: Map<Rarity, Int>): MutableList<ItemPlanningEntity> {
            val availableItems = mutableListOf<ItemPlanningEntity>()
            for (itemCount in itemCounts) {
                for (count in 0 until itemCount.value) {
                    val rarity = itemCount.key
                    availableItems.add(
                        ItemPlanningEntity(
                            UUID.randomUUID(),
                            rarity,
                            null,
                            allReforges.filter { it.rarity == rarity }
                        )
                    )
                }
            }
            return availableItems
        }
    }
}

Solution

  • I revisited the similar SO question. After trying a few different versions of the answer, it finally worked. Here's a more detailed explanation than the other post:

    Each PlanningEntity's ProblemFactCollectionProperty must be part of the main ProblemFactCollectionProperty in the PlanningSolution. This means that both the entity and solution should have their problem facts defined. Here's what fixed it for me:

    Keep ItemPlanningEntity.kt the same.

    Include a global ProblemFactCollectionProperty in ReforgePlanningSolution.kt:

    @PlanningSolution
    class ReforgePlanningSolution @JvmOverloads constructor(
        @PlanningEntityCollectionProperty
        val availableItems: List<ItemPlanningEntity>? = null,
        @ProblemFactCollectionProperty
        val allReforges: List<ReforgeProblemFact>? = null,
        @PlanningScore
        val score: HardSoftScore? = null
    )
    

    Define the global collection when instantiating the solution in Main.kt:

    val solverJob = solverManager.solve(UUID.randomUUID(), ReforgePlanningSolution(availableItems, allReforges))