This is an automated email from the ASF dual-hosted git repository.

aglinxinyuan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new 945849ce4f test(amber): add unit tests for deploy strategies (#4723)
945849ce4f is described below

commit 945849ce4f7ba77091e930b53b0b35440e33b994
Author: Yicong Huang <[email protected]>
AuthorDate: Sat May 2 20:13:47 2026 -0700

    test(amber): add unit tests for deploy strategies (#4723)
    
    ### What changes were proposed in this PR?
    
    Adds scalatest coverage for the three deploy strategies in
    
`amber/src/main/scala/org/apache/texera/amber/engine/architecture/deploysemantics/deploystrategy/{OneOnEach,RoundRobinDeployment,RandomDeployment}.scala`.
    Three known divergences are pinned in the spec with explanatory
    comments:
    
    - `OneOnEach.initialize` does not reset the iteration cursor — reusing
    the same instance with a new array continues counting from the prior
    position.
    - The empty-array fault surfaces with three different exception types:
    `IndexOutOfBoundsException` (OneOnEach), `ArithmeticException`
    (RoundRobinDeployment, divide-by-zero on the modulo), and
    `IllegalArgumentException` (RandomDeployment, `Random.nextInt(0)`). A
    future fix that aligns the empty-array contract across strategies will
    deliberately break these specs.
    
    ### Any related issues, documentation, discussions?
    
    Closes #4722.
    
    ### How was this PR tested?
    
    ```
    sbt scalafmtCheckAll
    sbt "WorkflowExecutionService/testOnly 
org.apache.texera.amber.engine.architecture.deploysemantics.deploystrategy.DeployStrategiesSpec"
    ```
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (claude-opus-4-7)
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
    Co-authored-by: Xinyuan Lin <[email protected]>
---
 .../deploystrategy/DeployStrategiesSpec.scala      | 148 +++++++++++++++++++++
 1 file changed, 148 insertions(+)

diff --git 
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/deploysemantics/deploystrategy/DeployStrategiesSpec.scala
 
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/deploysemantics/deploystrategy/DeployStrategiesSpec.scala
new file mode 100644
index 0000000000..3d35752c40
--- /dev/null
+++ 
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/deploysemantics/deploystrategy/DeployStrategiesSpec.scala
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package 
org.apache.texera.amber.engine.architecture.deploysemantics.deploystrategy
+
+import org.apache.pekko.actor.Address
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+class DeployStrategiesSpec extends AnyFlatSpec with Matchers {
+
+  // Use the "pekko" protocol to match Amber's real node addresses
+  // (e.g. AmberConfig.masterNodeAddr); "akka" diverges from production and
+  // can mislead anyone who debugs a failure by comparing addresses.
+  private val nodeA = Address("pekko", "sys", "host-a", 2552)
+  private val nodeB = Address("pekko", "sys", "host-b", 2552)
+  private val nodeC = Address("pekko", "sys", "host-c", 2552)
+
+  // ----- OneOnEach -----
+
+  "OneOnEach" should "hand out each address exactly once in array order" in {
+    val strategy = OneOnEach()
+    strategy.initialize(Array(nodeA, nodeB, nodeC))
+    strategy.next() shouldBe nodeA
+    strategy.next() shouldBe nodeB
+    strategy.next() shouldBe nodeC
+  }
+
+  it should "raise IndexOutOfBoundsException once the array is exhausted" in {
+    val strategy = OneOnEach()
+    strategy.initialize(Array(nodeA))
+    strategy.next() shouldBe nodeA
+    assertThrows[IndexOutOfBoundsException](strategy.next())
+  }
+
+  it should "raise IndexOutOfBoundsException immediately when initialized with 
an empty array" in {
+    val strategy = OneOnEach()
+    strategy.initialize(Array.empty[Address])
+    assertThrows[IndexOutOfBoundsException](strategy.next())
+  }
+
+  it should "preserve its iteration cursor across re-initialization (current 
behavior)" in {
+    // Pin: initialize() replaces the array reference but does NOT reset the
+    // index, so a re-initialized strategy continues counting from the prior
+    // position. A future fix that zeroes index inside initialize will break
+    // this spec on purpose so the contract change is reviewed.
+    val strategy = OneOnEach()
+    strategy.initialize(Array(nodeA, nodeB))
+    strategy.next() shouldBe nodeA
+    strategy.initialize(Array(nodeC))
+    // index is still 1 from the previous run; the new single-element array
+    // is therefore reported as exhausted.
+    assertThrows[IndexOutOfBoundsException](strategy.next())
+  }
+
+  "OneOnEach.apply" should "produce a fresh, independent instance" in {
+    val s1 = OneOnEach()
+    val s2 = OneOnEach()
+    s1 should not be theSameInstanceAs(s2)
+  }
+
+  // ----- RoundRobinDeployment -----
+
+  "RoundRobinDeployment" should "rotate addresses in a repeating cycle" in {
+    val strategy = RoundRobinDeployment()
+    strategy.initialize(Array(nodeA, nodeB, nodeC))
+    strategy.next() shouldBe nodeA
+    strategy.next() shouldBe nodeB
+    strategy.next() shouldBe nodeC
+    strategy.next() shouldBe nodeA
+    strategy.next() shouldBe nodeB
+  }
+
+  it should "always return the only address when the array has length 1" in {
+    val strategy = RoundRobinDeployment()
+    strategy.initialize(Array(nodeA))
+    for (_ <- 1 to 5) strategy.next() shouldBe nodeA
+  }
+
+  it should "raise ArithmeticException on next() with an empty array (current 
behavior)" in {
+    // Pin: RoundRobinDeployment.next does `index = (index + 1) % length`,
+    // which divides by zero when length == 0 and crashes with
+    // ArithmeticException before any address is returned. Other strategies
+    // raise IndexOutOfBoundsException for the same situation, so this is a
+    // contract divergence — pinning the current behavior so a future fix
+    // that aligns the empty-array error type will need to update this spec.
+    val strategy = RoundRobinDeployment()
+    strategy.initialize(Array.empty[Address])
+    assertThrows[ArithmeticException](strategy.next())
+  }
+
+  "RoundRobinDeployment.apply" should "produce a fresh, independent instance" 
in {
+    val s1 = RoundRobinDeployment()
+    val s2 = RoundRobinDeployment()
+    s1 should not be theSameInstanceAs(s2)
+  }
+
+  // ----- RandomDeployment -----
+
+  "RandomDeployment" should "always return one of the available addresses" in {
+    val strategy = RandomDeployment()
+    val pool = Array(nodeA, nodeB, nodeC)
+    strategy.initialize(pool)
+    val poolSet = pool.toSet
+    for (_ <- 1 to 50) {
+      poolSet should contain(strategy.next())
+    }
+  }
+
+  it should "always return the only address when the array has length 1" in {
+    val strategy = RandomDeployment()
+    strategy.initialize(Array(nodeA))
+    for (_ <- 1 to 5) strategy.next() shouldBe nodeA
+  }
+
+  it should "raise IllegalArgumentException on next() with an empty array 
(current behavior)" in {
+    // Pin: RandomDeployment.next() calls Random.nextInt(0), which throws
+    // IllegalArgumentException with bound must be positive. Same root issue
+    // as the empty-array case for RoundRobinDeployment: each strategy reports
+    // the empty-array fault with a different exception type. Pinning this
+    // separately so a unification fix shows up here.
+    val strategy = RandomDeployment()
+    strategy.initialize(Array.empty[Address])
+    assertThrows[IllegalArgumentException](strategy.next())
+  }
+
+  "RandomDeployment.apply" should "produce a fresh, independent instance" in {
+    val s1 = RandomDeployment()
+    val s2 = RandomDeployment()
+    s1 should not be theSameInstanceAs(s2)
+  }
+}

Reply via email to