Skip to content

test(amber): add ClusterListenerSpec against a single-node cluster - #7323

Open
aglinxinyuan wants to merge 3 commits into
apache:mainfrom
aglinxinyuan:test-cluster-listener
Open

test(amber): add ClusterListenerSpec against a single-node cluster#7323
aglinxinyuan wants to merge 3 commits into
apache:mainfrom
aglinxinyuan:test-cluster-listener

Conversation

@aglinxinyuan

Copy link
Copy Markdown
Contributor

What changes were proposed in this PR?

ClusterListener was the last file in the engine at 0%. It is worth covering rather than skipping, because the count it maintains is what the frontend's cluster badge renders: updateClusterStatus recomputes numWorkerNodesInCluster on every membership event and pushes a ClusterStatusUpdateEvent to every open session. A listener that stops subscribing, or stops fanning out, leaves every client showing a stale node count with nothing failing.

The suite runs a real single-node cluster. AmberRuntime.pekkoConfig selects the cluster provider with artery on port 0, so joining the node to itself makes it the leader and produces genuine MemberUp events. That is not a convenience: Member is private[cluster] and cannot be synthesized, so a real join is the only way to reach the event path at all.

Three tests — the member-address reply, the recompute-and-fan-out on a membership event, and the catch-all arm.

The catch-all test goes through TestActorRef.receive, not !. This one is worth explaining, because the obvious version does not work:

An earlier draft sent the stray message with ! and asserted the listener still answered. It stayed green with the catch-all replaced by a throw — supervision restarts the actor, and a restarted listener answers the next request exactly like one that never failed. TestActorRef.receive invokes receive directly and lets the exception reach the caller, so "did not throw" means what it says.

The subscribe path is mutation-checked the same way: dropping cluster.subscribe from preStart turns the fan-out test red.

Two ordering hazards are handled explicitly, both found by failures rather than by reasoning:

  1. Listeners are stopped at the end of each case. One left running stays subscribed and keeps iterating SessionState.getAllSessionStates on every membership event — an earlier draft died with ConcurrentModificationException as soon as a later case registered a session.
  2. The mock session is removed inside the case that created it. ScalaMock scopes expectations per test while the SessionState registry is JVM-global, so a leftover session gets called by a later listener against an expired mock (Unexpected call: Session.getAsyncRemote).

Both of those stem from a real production race, which this PR does not attempt to fix. SessionState's registry is a plain unsynchronized mutable.HashMap:

private val sessionIdToSessionState = new mutable.HashMap[String, SessionState]()
def getAllSessionStates: Iterable[SessionState] = sessionIdToSessionState.values

and updateClusterStatus iterates it from the cluster-event thread while websocket open/close mutate it from container threads. A node joining or leaving while a user opens a tab can throw inside the listener. Happy to open a separate issue for it.

Left uncovered deliberately: the MemberRemoved recovery arm, which walks WorkflowService.getAllWorkflowServices and calls notifyNodeFailure or forcefullyStop on each live execution — that needs real Amber clients, i.e. integration scope.

No production file is touched.

Any related issues, documentation, discussions?

Closes #7321

How was this PR tested?

sbt "WorkflowExecutionService/testOnly org.apache.texera.amber.clustering.ClusterListenerSpec"

Run three times consecutively in one invocation, because the first draft was order-dependent and I wanted the fix demonstrated rather than assumed:

[info] Tests: succeeded 3, failed 0, canceled 0, ignored 0, pending 0
[info] Tests: succeeded 3, failed 0, canceled 0, ignored 0, pending 0
[info] Tests: succeeded 3, failed 0, canceled 0, ignored 0, pending 0

Test/scalafmtCheck and Test/scalafix --check both [success].

Was this PR authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Opus 5)

ClusterListener was the last file in the engine sitting at 0%. It is
worth covering because the count it maintains is what the frontend's
cluster badge renders: updateClusterStatus recomputes
numWorkerNodesInCluster on every membership event and pushes a
ClusterStatusUpdateEvent to every open session, so a listener that stops
subscribing or stops fanning out leaves every client showing a stale
node count with nothing failing.

The suite runs a real single-node cluster. AmberRuntime.pekkoConfig
selects the cluster provider with artery on port 0, so joining the node
to itself produces genuine MemberUp events. That is not incidental:
Member is private[cluster] and cannot be synthesized, so a real join is
the only way to reach the event path at all.

Three tests: the member-address reply, the recompute-and-fan-out on a
membership event, and the catch-all arm.

The catch-all test goes through TestActorRef.receive rather than `!`.
This matters -- an earlier draft using `!` stayed green with the
catch-all replaced by a `throw`, because supervision restarts the actor
and a restarted listener answers the next request exactly like one that
never failed. TestActorRef.receive lets the exception reach the caller,
so "did not throw" means what it says. The subscribe path is likewise
mutation-checked: dropping cluster.subscribe from preStart turns the
fan-out test red.

Two ordering hazards are handled explicitly, both found by failures
rather than reasoning. Listeners are stopped at the end of each case,
because one left running keeps iterating SessionState.getAllSessionStates
and throws ConcurrentModificationException as soon as a later case
registers a session. And the mock session is removed inside the case that
created it, because ScalaMock scopes expectations per test while the
SessionState registry is JVM-global -- a leftover session gets called by
a later listener against an expired mock.

Left uncovered deliberately: the MemberRemoved recovery arm, which walks
every live execution and calls notifyNodeFailure or forcefullyStop, so it
needs real Amber clients.

Verified across three consecutive runs. No production file is touched.
Copilot AI lite review requested due to automatic review settings August 5, 2026 03:27
@github-actions github-actions Bot added the engine label Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Automated Reviewer Suggestions

Based on the git blame history of the changed files, we recommend the following reviewers:

  • No candidates found from git blame history.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a new ScalaTest spec for Amber’s ClusterListener, exercising its real Pekko Cluster membership event handling in a single-node (self-joined) cluster to cover previously-uncovered logic that drives the frontend’s cluster badge worker count.

Changes:

  • Introduces ClusterListenerSpec that boots a real single-node Pekko Cluster and validates GetAvailableNodeAddresses behavior.
  • Verifies MemberUp/initial-state replay triggers updateClusterStatus to recompute numWorkerNodesInCluster and fan out ClusterStatusUpdateEvent to registered websocket sessions.
  • Adds a catch-all test using TestActorRef.receive to ensure unrecognized messages don’t crash the actor.
Suppressed comments (1)

amber/src/test/scala/org/apache/texera/amber/clustering/ClusterListenerSpec.scala:182

  • sent.toSeq is read while another thread may be appending to sent (see mockSession), which can cause races or inconsistent snapshots. Take a synchronized snapshot before parsing/filtering.
            val counts = sent.toSeq
              .map(objectMapper.readTree)
              .filter(_.get("type").asText() == "ClusterStatusUpdateEvent")
              .map(_.get("numWorkers").asInt())
            // Every open session is told, and told the recomputed number.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +166 to +190
withSession { sent =>
ClusterListener.numWorkerNodesInCluster = -1

// Creating the listener subscribes it with InitialStateAsEvents, so the already-Up member is
// replayed to it as a MemberUp. That is what drives updateClusterStatus here - no synthetic
// Member is needed, and none could be built (Member is private[cluster]).
withListener { _ =>
awaitAssert(
{
// The count is recomputed from live membership, not left at the sentinel.
ClusterListener.numWorkerNodesInCluster shouldBe 1

val counts = sent.toSeq
.map(objectMapper.readTree)
.filter(_.get("type").asText() == "ClusterStatusUpdateEvent")
.map(_.get("numWorkers").asInt())
// Every open session is told, and told the recomputed number.
counts should not be empty
counts.last shouldBe 1
},
15.seconds,
500.millis
)
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — fixed in da9b77b. The previous value is captured and restored in a local finally around that one case, so afterAll is now just a backstop rather than the only restore.

You're right that it matters here specifically: amber sets no Test / fork and suites share one JVM, so the sentinel really was observable to a sibling for the duration of the case.

Comment on lines +201 to +204
val listener = TestActorRef[ClusterListener](Props[ClusterListener]())

noException should be thrownBy listener.receive("not a cluster event")
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, and fixed in da9b77b — the TestActorRef is now stopped in a finally with watch / expectTerminated, the same as the other two cases.

You identified the inconsistency precisely: the other two cases go through a withListener helper that exists for exactly this reason, and this one bypassed it because it needs the TestActorRef for receive rather than !. So it subscribed in preStart like any other listener and then stayed alive — reintroducing the leftover-subscriber hazard the helper was written to avoid.

Re-verified across three consecutive runs after the change, since the ordering hazards in this suite are the kind that only show up on a second pass.

@aglinxinyuan
aglinxinyuan requested a review from mengw15 August 5, 2026 03:32
aglinxinyuan and others added 2 commits August 4, 2026 20:32
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Xinyuan Lin <xinyual3@uci.edu>
…s safe

Addresses review feedback, all three points of which were correct:

- The collected frames were a plain ArrayBuffer written from the
  listener's actor thread and read from the test thread inside
  awaitAssert. It is now a ConcurrentLinkedQueue, and the body receives a
  snapshot function rather than the live collection, so each assertion
  reads a consistent point-in-time view. Worth fixing on its own merits:
  this is the same unsynchronised cross-thread hand-off the spec exists
  to document on the production side.
- The numWorkerNodesInCluster sentinel is now restored in a local
  finally rather than only in afterAll, so a sibling suite in the shared
  JVM cannot observe -1 for longer than the one case needs it.
- The TestActorRef listener in the catch-all case is now stopped like
  the others. It subscribes in preStart just the same, and a listener
  left running keeps iterating the shared SessionState registry on every
  membership event -- exactly the hazard the rest of the suite avoids.

Re-verified across three consecutive runs. No production file is touched.
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

⚠️ Benchmark changes need a look

🟢 0 better · 🔴 3 worse · ⚪ 12 noise (<±5%) · 0 without baseline

Compared against main 86865f3 benchmarked on this same runner, so the delta is largely free of cross-runner hardware noise. The "7d avg" column still reflects the gh-pages dashboard. Treat <±5% as noise unless repeated.

Dashboard · Run

config throughput MB/s latency max Δ latest / 7d
🔴 bs=10 sw=10 sl=64 371 0.227 25,901/40,412/40,412 us 🔴 +14.9% / 🔴 +160.1%
bs=100 sw=10 sl=64 782 0.477 126,650/150,325/150,325 us ⚪ within ±5% / 🔴 +37.8%
bs=1000 sw=10 sl=64 887 0.541 1,130,707/1,165,320/1,165,320 us ⚪ within ±5% / 🔴 +12.8%
Baseline details

Latest main 86865f3 from same runner

config metric PR latest main 7d avg Δ latest Δ 7d
bs=10 sw=10 sl=64 throughput 371 tuples/sec 388 tuples/sec 767.32 tuples/sec -4.4% -51.6%
bs=10 sw=10 sl=64 MB/s 0.227 MB/s 0.237 MB/s 0.468 MB/s -4.2% -51.5%
bs=10 sw=10 sl=64 p50 25,901 us 24,562 us 12,772 us +5.5% +102.8%
bs=10 sw=10 sl=64 p95 40,412 us 35,178 us 15,538 us +14.9% +160.1%
bs=10 sw=10 sl=64 p99 40,412 us 35,178 us 18,948 us +14.9% +113.3%
bs=100 sw=10 sl=64 throughput 782 tuples/sec 793 tuples/sec 972.51 tuples/sec -1.4% -19.6%
bs=100 sw=10 sl=64 MB/s 0.477 MB/s 0.484 MB/s 0.594 MB/s -1.4% -19.6%
bs=100 sw=10 sl=64 p50 126,650 us 125,575 us 103,020 us +0.9% +22.9%
bs=100 sw=10 sl=64 p95 150,325 us 152,474 us 109,070 us -1.4% +37.8%
bs=100 sw=10 sl=64 p99 150,325 us 152,474 us 118,964 us -1.4% +26.4%
bs=1000 sw=10 sl=64 throughput 887 tuples/sec 891 tuples/sec 1,005 tuples/sec -0.4% -11.7%
bs=1000 sw=10 sl=64 MB/s 0.541 MB/s 0.544 MB/s 0.613 MB/s -0.6% -11.8%
bs=1000 sw=10 sl=64 p50 1,130,707 us 1,122,336 us 1,002,400 us +0.7% +12.8%
bs=1000 sw=10 sl=64 p95 1,165,320 us 1,167,270 us 1,039,228 us -0.2% +12.1%
bs=1000 sw=10 sl=64 p99 1,165,320 us 1,167,270 us 1,069,081 us -0.2% +9.0%
Raw CSV
config_idx,batch_size,schema_width,string_len,num_batches,total_ms,total_tuples,total_bytes,tuples_per_sec,mb_per_sec,lat_p50_us,lat_p95_us,lat_p99_us
0,10,10,64,20,538.71,200,128000,371,0.227,25900.90,40412.32,40412.32
1,100,10,64,20,2558.12,2000,1280000,782,0.477,126650.17,150324.65,150324.65
2,1000,10,64,20,22556.62,20000,12800000,887,0.541,1130707.35,1165319.88,1165319.88

@codecov-commenter

codecov-commenter commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.40%. Comparing base (86865f3) to head (da9b77b).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@             Coverage Diff              @@
##               main    #7323      +/-   ##
============================================
+ Coverage     83.37%   83.40%   +0.03%     
- Complexity     4129     4135       +6     
============================================
  Files          1166     1166              
  Lines         46424    46424              
  Branches       5174     5174              
============================================
+ Hits          38707    38722      +15     
+ Misses         6000     5976      -24     
- Partials       1717     1726       +9     
Flag Coverage Δ *Carryforward flag
access-control-service 70.00% <ø> (ø) Carriedforward from 86865f3
agent-service 83.65% <ø> (ø) Carriedforward from 86865f3
amber 80.77% <ø> (+0.08%) ⬆️
computing-unit-managing-service 43.60% <ø> (ø) Carriedforward from 86865f3
config-service 65.97% <ø> (ø) Carriedforward from 86865f3
file-service 69.05% <ø> (ø) Carriedforward from 86865f3
frontend 84.03% <ø> (ø) Carriedforward from 86865f3
notebook-migration-service 78.89% <ø> (ø) Carriedforward from 86865f3
pyamber 97.36% <ø> (ø) Carriedforward from 86865f3
workflow-compiling-service 26.31% <ø> (ø) Carriedforward from 86865f3

*This pull request uses carry forward flags. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mengw15 mengw15 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a ClusterListener spec against a single-node cluster

4 participants