This is an automated email from the ASF dual-hosted git repository.
wilfred-s pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/yunikorn-core.git
The following commit(s) were added to refs/heads/master by this push:
new 27b930d0 [YUNIKORN-3338] Add golden decision-trace regression tests
for the scheduler (#1110)
27b930d0 is described below
commit 27b930d09c4355bfa3abecacef34d96e09802ee1
Author: Tigerquoll <[email protected]>
AuthorDate: Tue Aug 4 19:19:32 2026 +1000
[YUNIKORN-3338] Add golden decision-trace regression tests for the
scheduler (#1110)
The existing scheduler tests assert individual outcomes - this ask was
allocated, that queue is over its limit. None of them assert the scheduler's
observable output as a whole: which application gets which allocation, on
which node, in what order, and what is handed back to the resource manager
for release. Most interesting scheduler changes are ordering changes, and a
change to ask ordering, node scoring, headroom accounting, victim selection
or placeholder replacement can leave every existing assertion green while
changing where work actually lands.
Add two tests that pin that output. Both drive a fixed workload through the
real RMProxy and partition API and compare the ordered sequence of decisions
the resource manager observes through UpdateAllocation - applicationID,
allocationKey and nodeID for every allocation, applicationID, allocationKey
and terminationType for every release, and rejections - verbatim against a
golden JSON file.
TestGoldenDecisionTrace runs six sequential phases against a single cluster,
each building on the state the previous one left: priority ordering within
an
application, removal of a still-pending ask, gang placeholder replacement,
restart and allocation recovery, application failure with pending asks
outstanding, and priority-driven preemption across sibling queues. The node
capacity is derived from the per-phase ask sizes and the headroom each phase
leaves is asserted, so the recovery and failure-cleanup phases genuinely
distinguish correct behaviour from incorrect rather than passing regardless.
TestGoldenDecisionTraceScenarios runs six independent workloads, each on its
own cluster and configuration, isolating a single decision: the FIFO and
Fair
application sort policies, the binpacking and fair node sort policies, gang
placeholder replacement, and preemption. Each policy pair shares an
identical
workload, so the difference between the two goldens is precisely the
policy's
effect.
The workload's own preconditions - the set of allocations replayed on
recovery, and the headroom each phase leaves behind - are hard assertions.
They state what the script needs in order to mean anything, so they must
fail
rather than yield even when goldens are being regenerated; only observations
of behaviour give way. Barriers assert an allocation is present before
waiting
for it to leave, so they cannot be satisfied by an allocation that was never
placed.
Trace capture in the shared mock resource manager callback is opt-in, so the
rest of the package - including BenchmarkScheduling - does not pay for data
it
never reads. Trace lengths are asserted exactly rather than as a lower
bound,
and every assertion is preceded by a quiescence round trip, because polling
a
length cannot establish that no further decision is on its way. The round
trip
uses a queue no scenario schedules in, so it stays outside what the
scenarios
measure.
Goldens are regenerated by setting UPDATE_GOLDEN, which reports the
divergence
it observes, rewrites the affected files, and always ends red so that a
regeneration run cannot be mistaken for a verification run.
This is a test-only change; no product code is modified.
Generated by Author with assistance from Claude Code.
Signed-off-by: Tigerquoll <[email protected]>
Closes: #1110
Signed-off-by: Wilfred Spiegelenburg <[email protected]>
---
pkg/scheduler/tests/golden_trace_scenarios_test.go | 516 +++++++++
pkg/scheduler/tests/golden_trace_test.go | 1163 ++++++++++++++++++++
pkg/scheduler/tests/mock_rm_callback_test.go | 110 ++
pkg/scheduler/tests/testdata/golden_trace.json | 72 ++
.../testdata/golden_trace_app_ordering_fair.json | 22 +
.../testdata/golden_trace_app_ordering_fifo.json | 22 +
.../tests/testdata/golden_trace_gang.json | 17 +
.../testdata/golden_trace_node_binpacking.json | 22 +
.../tests/testdata/golden_trace_node_fair.json | 22 +
.../tests/testdata/golden_trace_preemption.json | 22 +
10 files changed, 1988 insertions(+)
diff --git a/pkg/scheduler/tests/golden_trace_scenarios_test.go
b/pkg/scheduler/tests/golden_trace_scenarios_test.go
new file mode 100644
index 00000000..17a0f8f8
--- /dev/null
+++ b/pkg/scheduler/tests/golden_trace_scenarios_test.go
@@ -0,0 +1,516 @@
+/*
+ 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 tests
+
+import (
+ "testing"
+
+ "gotest.tools/v3/assert"
+
+ "github.com/apache/yunikorn-scheduler-interface/lib/go/si"
+)
+
+//
---------------------------------------------------------------------------------------------
+// TestGoldenDecisionTraceScenarios is the per-behaviour companion to
TestGoldenDecisionTrace (see
+// golden_trace_test.go). That test scripts one long mixed workload on a
single fixed configuration;
+// this one runs a set of short, independent workloads, each on its own
configuration, so that a
+// single scheduling decision is the only thing the resulting trace can be
reporting on.
+//
+// Two things motivate the split. First, configuration coverage:
TestGoldenDecisionTrace pins the
+// default leaf application-sort policy and the default node-sort policy only.
The sort policies are
+// user-facing, configurable contracts - "binpacking" is documented as packing
work onto loaded nodes
+// and "fair" as spreading it - and nothing in the existing scheduler tests
pins the observable,
+// end-to-end consequence of picking one over the other. A refactor of the
comparators, of node
+// scoring, or of the order in which candidates are walked can leave the unit
tests around those
+// comparators green while changing where work actually lands.
+//
+// Second, diagnosability: policy pairs are captured as two goldens over an
IDENTICAL workload, so the
+// diff between the pair is precisely the policy's effect. When one member of
a pair fails and the
+// other does not, the failure has already been localised to that policy's
decision path.
+//
+// The gang and preemption scenarios are deliberately narrower re-runs of
behaviour that
+// TestGoldenDecisionTrace also touches. There they are embedded in a long
workload with accumulated
+// queue and node state; here each runs on a fresh, exactly-sized cluster. If
the shared trace breaks
+// and these do not, the cause is interaction with prior state rather than the
mechanism itself.
+//
+// Determinism follows the same rules as TestGoldenDecisionTrace: fixed
strictly-increasing ask
+// createTimes rather than wall-clock times, no background scheduling loop, an
observable barrier
+// between submitting asks and stepping the scheduler, and WARN-level logging.
+// The candidate lists being sorted here are built by iterating Go maps, so
every scenario is
+// constructed to avoid ties in the comparator that decides it:
application-sort ties are avoided by
+// giving the competing applications distinct submission times and distinct
usage, and node-sort score
+// ties fall back to a NodeID comparison (nodeRef.Less), for which the node
IDs below are chosen to
+// order unambiguously.
+//
+// As with TestGoldenDecisionTrace, a failure here means a scheduling decision
changed. Regenerate a
+// golden with UPDATE_GOLDEN=1 only for a change that is intended, and call
out the trace diff in
+// review.
+//
+// The gang and preemption scenarios repeat, on a fresh cluster, the
choreography that phases 3 and 6
+// of TestGoldenDecisionTrace run against accumulated state. That repetition
is not extracted into a
+// helper the two files share, because a shared helper would be a single point
of failure for both
+// goldens: a mistake in it would move both traces together, and "the shared
trace broke and the
+// scenario did not, so the cause is interaction with prior state" - the whole
reason these scenarios
+// exist - would stop being a conclusion anyone could draw. What is shared
between the two files is
+// infrastructure that does not decide anything (building SI messages, the
trace probe, the golden
+// comparison), not the scripts themselves.
+//
---------------------------------------------------------------------------------------------
+
+// scenario application, queue and node identifiers, all fixed rather than
time- or counter-derived so
+// the traces are stable.
+const (
+ scLeaf = "root.leaf"
+ // every scenario config carries this leaf, and no scenario ever
submits work to it: it exists so
+ // that traceProbe.sync can park its barrier applications outside the
queue whose decision the
+ // scenario is isolating. See sync() in golden_trace_test.go.
+ scBarrier = "root.barrier"
+
+ scAppEarly = "sc-app-early"
+ scAppLate = "sc-app-late"
+ scAppPack = "sc-app-pack"
+ scAppGang = "sc-app-gang"
+ scAppLow = "sc-app-low"
+ scAppHigh = "sc-app-high"
+
+ // node IDs are chosen so that lexical order - the node-sort score
tiebreak in nodeRef.Less - is
+ // unambiguous: a sorts before b.
+ scNode = "sc-node:1"
+ scNodeA = "sc-node-a:1"
+ scNodeB = "sc-node-b:1"
+
+ scQueueLow = "root.plow"
+ scQueueHigh = "root.phigh"
+)
+
+// goldenScenario is one behaviour: a scheduler configuration, a scripted
workload, and the golden
+// file its resulting decision trace is pinned to. The trace is read from
ms.mockRM after run returns.
+// barrierQueue names a leaf queue of config that traceProbe.sync can park its
inert barrier
+// applications in, and which the scenario itself never schedules in.
+type goldenScenario struct {
+ name string
+ goldenPath string
+ config string
+ barrierQueue string
+ run func(t *testing.T, ms *mockScheduler, seq *createTimeSeq,
probe *traceProbe)
+}
+
+// leafSortConfig builds a partition whose measured leaf uses the given
application.sort.policy. The
+// barrier leaf alongside it deliberately carries no sort policy of its own:
nothing is ever scheduled
+// there, and giving it one would suggest otherwise.
+func leafSortConfig(appSortPolicy string) string {
+ return `
+partitions:
+ - name: default
+ queues:
+ - name: root
+ submitacl: "*"
+ queues:
+ - name: leaf
+ properties:
+ application.sort.policy: ` + appSortPolicy + `
+ - name: barrier
+`
+}
+
+// nodeSortConfig builds a partition with the given nodesortpolicy type.
+func nodeSortConfig(nodeSortPolicy string) string {
+ return `
+partitions:
+ - name: default
+ nodesortpolicy:
+ type: ` + nodeSortPolicy + `
+ queues:
+ - name: root
+ submitacl: "*"
+ queues:
+ - name: leaf
+ - name: barrier
+`
+}
+
+const scGangConfig = `
+partitions:
+ - name: default
+ queues:
+ - name: root
+ submitacl: "*"
+ queues:
+ - name: leaf
+ - name: barrier
+`
+
+// scPreemptConfig mirrors the preemption setup in TestGoldenDecisionTrace:
preemption enabled, and the
+// two sibling leaves carry explicit guaranteed resources, which preemption
requires (see
+// configs.DefaultPreemptionDelay: "guaranteed resources must be set to
trigger preemption").
+const scPreemptConfig = `
+partitions:
+ - name: default
+ preemption:
+ enabled: true
+ queues:
+ - name: root
+ submitacl: "*"
+ queues:
+ - name: barrier
+ - name: plow
+ resources:
+ guaranteed:
+ memory: 0
+ vcore: 0
+ max:
+ memory: 100000
+ vcore: 100000
+ - name: phigh
+ resources:
+ guaranteed:
+ memory: 1000
+ vcore: 1000
+ max:
+ memory: 100000
+ vcore: 100000
+`
+
+func goldenScenarios() []goldenScenario {
+ return []goldenScenario{
+ {
+ name: "app-ordering-fifo",
+ goldenPath:
"testdata/golden_trace_app_ordering_fifo.json",
+ config: leafSortConfig("fifo"),
+ barrierQueue: scBarrier,
+ run: runAppOrdering,
+ },
+ {
+ name: "app-ordering-fair",
+ goldenPath:
"testdata/golden_trace_app_ordering_fair.json",
+ config: leafSortConfig("fair"),
+ barrierQueue: scBarrier,
+ run: runAppOrdering,
+ },
+ {
+ name: "node-binpacking",
+ goldenPath:
"testdata/golden_trace_node_binpacking.json",
+ config: nodeSortConfig("binpacking"),
+ barrierQueue: scBarrier,
+ run: runNodePlacement,
+ },
+ {
+ name: "node-fair",
+ goldenPath: "testdata/golden_trace_node_fair.json",
+ config: nodeSortConfig("fair"),
+ barrierQueue: scBarrier,
+ run: runNodePlacement,
+ },
+ {
+ name: "gang",
+ goldenPath: "testdata/golden_trace_gang.json",
+ config: scGangConfig,
+ barrierQueue: scBarrier,
+ run: runGang,
+ },
+ {
+ name: "preemption",
+ goldenPath: "testdata/golden_trace_preemption.json",
+ config: scPreemptConfig,
+ barrierQueue: scBarrier,
+ run: runPreemption,
+ },
+ }
+}
+
+func TestGoldenDecisionTraceScenarios(t *testing.T) {
+ useWarnLogging(t)
+
+ for _, sc := range goldenScenarios() {
+ t.Run(sc.name, func(t *testing.T) {
+ seq := newCreateTimeSeq()
+
+ ms := &mockScheduler{}
+ defer ms.Stop()
+ err := ms.Init(sc.config, false, false)
+ assert.NilError(t, err, "RegisterResourceManager
failed")
+ // ms.Init resets the log level, see warnLogging.
+ warnLogging()
+ ms.mockRM.enableTrace()
+
+ probe := newTraceProbe(t, ms, sc.barrierQueue)
+ sc.run(t, ms, seq, probe)
+
+ // each scenario is a single behaviour, so a mismatch
needs no phase attribution.
+ compareOrUpdateGoldenAt(t, sc.goldenPath,
ms.mockRM.getTrace(), nil, probe.updating)
+ })
+ }
+}
+
+// runAppOrdering isolates the leaf queue's application-sort decision: when
two applications in the
+// same leaf both have work pending and only one can be served, which one goes
first. Every ask is
+// priority 5, so the applications always tie on priority - which is the
leading comparison for both
+// policies once priority sorting is on, and the trailing one when it is off -
and the ordering is
+// therefore decided by the policy's other key: submission time for FIFO
+// (sortApplicationsByPriorityAndSubmissionTime), allocated-usage ratio for
Fair
+// (sortApplicationsByPriorityAndFairness).
+//
+// The node is first pre-loaded so that the two applications hold DIFFERENT
amounts and their usage
+// order DISAGREES with their submission order - otherwise both policies would
pick the same winner
+// and the trace would not distinguish them:
+// - sc-app-early submits first (smaller createTime, so the earlier
submission time) and is
+// pre-loaded with 2 allocations, giving it the higher usage;
+// - sc-app-late submits second and is pre-loaded with 1 allocation, giving
it the lower usage.
+//
+// Each application then submits one more ask, competing for a single
remaining slot:
+// - FIFO: earlier submission wins -> sc-app-early (e-3).
+// - Fair: lower usage wins -> sc-app-late (l-2).
+//
+// The first three trace entries (e-1, e-2, l-1) are the shared, uncontested
pre-load; the 4th entry is
+// the contested slot, and is the single entry where the two goldens differ.
+func runAppOrdering(t *testing.T, ms *mockScheduler, seq *createTimeSeq, probe
*traceProbe) {
+ // the node holds the 3-allocation pre-load (30 memory) plus exactly
one contested slot (10) = 40/4.
+ err := ms.proxy.UpdateNode(&si.NodeRequest{
+ Nodes: []*si.NodeInfo{goldenNodeInfo(scNode, 40, 4)},
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedNode(t, scNode, goldenTimeout)
+
+ err = ms.addApp(scAppEarly, scLeaf, goldenPart)
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedApplication(t, scAppEarly, goldenTimeout)
+ err = ms.addApp(scAppLate, scLeaf, goldenPart)
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedApplication(t, scAppLate, goldenTimeout)
+
+ // Pre-load step 1: only sc-app-early has pending asks, so both land
without any inter-application
+ // comparison being needed (e-1 then e-2, by createTime). An
application's submission time is
+ // lowered to the createTime of its earliest ask
(Application.AddAllocationAsk), so this also fixes
+ // sc-app-early's submission time to e-1's pinned createTime.
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Allocations: []*si.Allocation{
+ goldenAsk("e-1", scAppEarly, 5, 10, 1, seq.get()),
+ goldenAsk("e-2", scAppEarly, 5, 10, 1, seq.get()),
+ },
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsks(t, ms, scAppEarly, "e-1", "e-2")
+ ms.scheduler.MultiStepSchedule(3)
+ probe.assertLen(2, "after the first pre-load step")
+
+ // Pre-load step 2: only sc-app-late has a pending ask, so l-1 also
lands uncontested. sc-app-late's
+ // submission time is now later than sc-app-early's, and its usage (10)
lower than early's (20).
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Allocations: []*si.Allocation{
+ goldenAsk("l-1", scAppLate, 5, 10, 1, seq.get()),
+ },
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsks(t, ms, scAppLate, "l-1")
+ ms.scheduler.MultiStepSchedule(2)
+ probe.assertLen(3, "after the second pre-load step")
+
+ // Contested slot: one more ask from each application, but room for
only one. The policy decides.
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Allocations: []*si.Allocation{
+ goldenAsk("e-3", scAppEarly, 5, 10, 1, seq.get()),
+ goldenAsk("l-2", scAppLate, 5, 10, 1, seq.get()),
+ },
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsks(t, ms, scAppEarly, "e-3")
+ waitForAsks(t, ms, scAppLate, "l-2")
+ ms.scheduler.MultiStepSchedule(2)
+ probe.assertLen(4, "after the contested slot was decided")
+}
+
+// runNodePlacement isolates the partition node-sort decision: given more than
one node that can hold
+// an ask, which one is offered first. A single application submits 4 equal
asks (25 memory / 25 vcore)
+// against two identical, initially empty 100/100 nodes.
+//
+// Nodes are held in score order (baseNodeCollection, ordered by nodeRef.Less)
and the policy supplies
+// the score, with the iteration always running lowest score first:
+// - binpacking scores a node as the fraction of it still free, so the
most-loaded node comes first
+// and all 4 asks pack onto one node until it is full: a1..a4 all on
sc-node-a.
+// - fair scores a node as the fraction of it already used, so the
least-loaded node comes first and
+// the asks alternate: a1 -> a, a2 -> b, a3 -> a, a4 -> b.
+//
+// Both nodes start identical, so the very first placement is a score tie and
is settled by the NodeID
+// tiebreak (sc-node-a before sc-node-b); every later placement is settled by
score. Note that the two
+// resulting traces differ only in the nodeID column - the allocation order is
the same in both - which
+// is exactly the kind of change an assertion on "was this ask allocated"
cannot see.
+func runNodePlacement(t *testing.T, ms *mockScheduler, seq *createTimeSeq,
probe *traceProbe) {
+ err := ms.proxy.UpdateNode(&si.NodeRequest{
+ Nodes: []*si.NodeInfo{
+ goldenNodeInfo(scNodeA, 100, 100),
+ goldenNodeInfo(scNodeB, 100, 100),
+ },
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedNode(t, scNodeA, goldenTimeout)
+ ms.mockRM.waitForAcceptedNode(t, scNodeB, goldenTimeout)
+
+ err = ms.addApp(scAppPack, scLeaf, goldenPart)
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedApplication(t, scAppPack, goldenTimeout)
+
+ // four same-priority asks; strictly-increasing createTimes fix their
order within the application
+ // (Allocation.LessThan orders by priority descending then createTime
ascending), so a1..a4 are
+ // attempted in that order and the node column is the only free
variable.
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Allocations: []*si.Allocation{
+ goldenAsk("a1", scAppPack, 5, 25, 25, seq.get()),
+ goldenAsk("a2", scAppPack, 5, 25, 25, seq.get()),
+ goldenAsk("a3", scAppPack, 5, 25, 25, seq.get()),
+ goldenAsk("a4", scAppPack, 5, 25, 25, seq.get()),
+ },
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsks(t, ms, scAppPack, "a1", "a2", "a3", "a4")
+
+ ms.scheduler.MultiStepSchedule(5)
+ probe.assertLen(4, "after all four asks were placed")
+}
+
+// runGang isolates the gang placeholder replacement handshake - placeholder
allocated, RM asked to
+// release it, real allocation issued once the RM confirms - on a cluster with
no other state. The node
+// is sized (20 memory / 2 vcore) to hold the placeholder and the real task
simultaneously, which is
+// what the replacement needs while both are briefly live.
+func runGang(t *testing.T, ms *mockScheduler, seq *createTimeSeq, probe
*traceProbe) {
+ err := ms.proxy.UpdateNode(&si.NodeRequest{
+ Nodes: []*si.NodeInfo{goldenNodeInfo(scNode, 20, 2)},
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedNode(t, scNode, goldenTimeout)
+
+ err = ms.addApp(scAppGang, scLeaf, goldenPart)
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedApplication(t, scAppGang, goldenTimeout)
+
+ phAsk := goldenAsk("g-placeholder", scAppGang, 6, 10, 1, seq.get())
+ phAsk.TaskGroupName = "tg"
+ phAsk.Placeholder = true
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Allocations: []*si.Allocation{phAsk},
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsks(t, ms, scAppGang, "g-placeholder")
+ ms.scheduler.MultiStepSchedule(2)
+ probe.assertLen(1, "after allocating the gang placeholder")
+
+ realAsk := goldenAsk("g-real", scAppGang, 6, 10, 1, seq.get())
+ realAsk.TaskGroupName = "tg"
+ realAsk.Placeholder = false
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Allocations: []*si.Allocation{realAsk},
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsks(t, ms, scAppGang, "g-real")
+ ms.scheduler.MultiStepSchedule(2)
+ // the core notifies the RM the placeholder should be released.
+ probe.assertLen(2, "after the core asked the RM to release the
placeholder")
+
+ placeholderGone := requireAllocationOnNode(t, ms, scNode,
"g-placeholder")
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Releases: &si.AllocationReleasesRequest{
+ AllocationsToRelease: []*si.AllocationRelease{
+ createAllocationRelease(scAppGang, goldenPart,
"g-placeholder", si.TerminationType_PLACEHOLDER_REPLACED),
+ },
+ },
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ // no scheduling step completes the replacement: the confirmation is
processed on the
+ // allocation-event goroutine and
ClusterContext.processAllocationReleases notifies the RM of the
+ // real allocation directly.
+ probe.assertLen(3, "after the replacement completed")
+ placeholderGone()
+}
+
+// runPreemption isolates priority-driven preemption across sibling queues on
an otherwise empty
+// cluster. A single exactly-sized node is filled by two low-priority
allocations (AllowPreemptSelf); a
+// higher-priority ask (AllowPreemptOther) in the sibling queue cannot fit and
triggers preemption of
+// exactly one victim, after which the freed capacity lets the high-priority
ask complete. The two
+// low-priority asks carry distinct, pinned createTimes, which is the final
tiebreak in
+// sortVictimsForPreemption, so victim selection is deterministic rather than
dependent on a tie.
+func runPreemption(t *testing.T, ms *mockScheduler, seq *createTimeSeq, probe
*traceProbe) {
+ err := ms.proxy.UpdateNode(&si.NodeRequest{
+ Nodes: []*si.NodeInfo{goldenNodeInfo(scNode, 100, 100)},
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedNode(t, scNode, goldenTimeout)
+
+ err = ms.addApp(scAppLow, scQueueLow, goldenPart)
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedApplication(t, scAppLow, goldenTimeout)
+ err = ms.addApp(scAppHigh, scQueueHigh, goldenPart)
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedApplication(t, scAppHigh, goldenTimeout)
+
+ lowAsk1 := goldenAsk("low-1", scAppLow, 1, 50, 50, seq.get())
+ lowAsk1.PreemptionPolicy = &si.PreemptionPolicy{AllowPreemptSelf: true}
+ lowAsk2 := goldenAsk("low-2", scAppLow, 1, 50, 50, seq.get())
+ lowAsk2.PreemptionPolicy = &si.PreemptionPolicy{AllowPreemptSelf: true}
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Allocations: []*si.Allocation{lowAsk1, lowAsk2},
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsks(t, ms, scAppLow, "low-1", "low-2")
+ ms.scheduler.MultiStepSchedule(3)
+ // both low-priority allocations must land before the high-priority ask
is introduced, or there is
+ // nothing to preempt and the scenario proves nothing.
+ probe.assertLen(2, "after filling the node")
+
+ highAsk := goldenAsk("high-1", scAppHigh, 9, 50, 50, seq.get())
+ highAsk.PreemptionPolicy = &si.PreemptionPolicy{AllowPreemptOther: true}
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Allocations: []*si.Allocation{highAsk},
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsks(t, ms, scAppHigh, "high-1")
+ ms.scheduler.MultiStepSchedule(4)
+ // preemption fires: the core notifies the RM to release the chosen
victim.
+ probe.assertLen(3, "after preemption chose a victim")
+
+ victimKey := preemptionVictim(t, ms.mockRM.getTrace())
+ victimGone := requireAllocationOnNode(t, ms, scNode, victimKey)
+
+ // the RM confirms the preemption (mirrors the shim actually
terminating the pod).
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Releases: &si.AllocationReleasesRequest{
+ AllocationsToRelease: []*si.AllocationRelease{
+ createAllocationRelease(scAppLow, goldenPart,
victimKey, si.TerminationType_PREEMPTED_BY_SCHEDULER),
+ },
+ },
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ // the barrier between the RM's confirmation and the scheduling step
that has to observe the freed
+ // capacity: it establishes that the victim actually left, not merely
that it is absent.
+ victimGone()
+ ms.scheduler.MultiStepSchedule(2)
+ // freed capacity lets the reserved high-priority ask complete.
+ probe.assertLen(4, "after the high-priority ask completed")
+}
diff --git a/pkg/scheduler/tests/golden_trace_test.go
b/pkg/scheduler/tests/golden_trace_test.go
new file mode 100644
index 00000000..1d13f5cb
--- /dev/null
+++ b/pkg/scheduler/tests/golden_trace_test.go
@@ -0,0 +1,1163 @@
+/*
+ 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 tests
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "reflect"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "gotest.tools/v3/assert"
+
+ "github.com/apache/yunikorn-core/pkg/common"
+ "github.com/apache/yunikorn-core/pkg/common/configs"
+ "github.com/apache/yunikorn-core/pkg/log"
+ "github.com/apache/yunikorn-core/pkg/scheduler/objects"
+ siCommon "github.com/apache/yunikorn-scheduler-interface/lib/go/common"
+ "github.com/apache/yunikorn-scheduler-interface/lib/go/si"
+)
+
+//
---------------------------------------------------------------------------------------------
+// TestGoldenDecisionTrace is a decision-regression guard for the scheduler.
+//
+// It drives a fixed workload through the real RMProxy/partition API and pins
the exact, ordered
+// sequence of scheduling decisions as the resource manager observes them
through UpdateAllocation:
+// (applicationID, allocationKey, nodeID) for every new allocation,
(applicationID, allocationKey,
+// terminationType) for every release, and (applicationID, allocationKey) for
every rejected
+// allocation. That sequence is stored in testdata/golden_trace.json and
compared verbatim.
+//
+// Why pin the trace at all: the existing scheduler tests assert individual
outcomes ("this ask was
+// allocated", "this queue is over its limit"). What they do not assert is the
scheduler's observable
+// output as a whole - which application gets which allocation, on which node,
in which order, and
+// which allocations are handed back to the RM for release. A change to ask
ordering, node iteration,
+// headroom accounting, preemption victim selection or placeholder replacement
can leave every
+// existing assertion satisfied while still changing what the cluster actually
does. This test makes
+// that class of change visible.
+//
+// A failure here is the test working, not the test being brittle. Either the
change altered
+// scheduling behaviour unintentionally - in which case fix the change - or it
altered it
+// deliberately, in which case regenerate the golden and call out the trace
diff in review so the new
+// decision sequence is reviewed as a behaviour change rather than accepted as
noise. Do not
+// regenerate it casually. A regenerating run deliberately ends RED (see
compareOrUpdateGoldenAt) so
+// that regeneration can never be mistaken for verification.
+//
+// Regenerating:
+//
+// UPDATE_GOLDEN=1 go test ./pkg/scheduler/tests/ -run
'^TestGoldenDecisionTrace'
+//
+// A behaviour change that is worth regenerating for is usually one that
changes how many decisions a
+// phase produces, so the exact-length phase barriers below have to give way
for it: under
+// UPDATE_GOLDEN they report what the run actually produced and carry on
instead of failing. The same
+// goes for the workload's observations OF that behaviour - traceProbe.pin,
used for statements like
+// "the low-priority recovery ask is still waiting" or "the failed
application's asks are gone" -
+// because a golden being regenerated is precisely the claim that the
behaviour those describe has
+// changed.
+//
+// What does NOT give way, in either mode, is the workload's own
PRECONDITIONS: the state a phase has
+// to be in before its result means anything - the set of allocations replayed
on recovery, and the
+// headroom left on node-main that phases 4 and 5 make their asks compete for.
Those are plain
+// assert.Assert, so a regenerating run stops on them instead of writing a
golden. The distinction is
+// what a failure would mean. A broken observation says the scheduler now
decides something else,
+// which is what a new golden is for. A broken precondition says the script no
longer sets up the
+// situation it describes, so the golden recorded from it would pin a
different experiment under the
+// same name - and be green while doing it. The fix for the second is to
repair the workload, never
+// to bless the golden.
+//
+// A golden test is only as good as its determinism, so the workload removes
the usual sources of
+// run-to-run variation:
+// - fixed application and node IDs;
+// - pinned strictly-increasing creation times on every submitted ask
instead of wall-clock times;
+// - scheduling advanced by explicit MultiStepSchedule calls rather than the
background scheduling
+// loop, with an observable barrier (waitForAsks and friends) between
submitting work and driving
+// the scheduler, because RMProxy.UpdateAllocation only enqueues;
+// - no two asks within one application sharing the same (priority,
createTime) pair.
+//
+// That last point matters because on a genuine tie the comparator that orders
an application's
+// requests contradicts itself. Requests are held in priority-descending, then
creation-time-ascending
+// order (Allocation.LessThan), whose equal-priority branch is
+//
+// return a.createTime.After(other.createTime) ||
a.createTime.Equal(other.createTime)
+//
+// so for two asks sharing a (priority, createTime) LessThan is true in BOTH
directions: each of them
+// orders before the other. sortedRequests.insert then has two branches that
disagree about where such
+// an ask belongs - the fast path appends it AFTER the ask it ties with, the
binary probe (sort.Search)
+// inserts it BEFORE - and nothing in the ordering rule decides which of those
runs. So a tie does not
+// fall back to some weaker but still stated order; it leaves the position
undefined, and the list has
+// no order it can be said to be sorted into while the tie is in it. The
workload gives no two asks in
+// one application the same (priority, createTime), so the expected order
follows from the rule alone.
+//
+// The one place where creation times are NOT pinned is the recovery path
exercised by phase 4: the
+// core hands allocations back to the RM through
Allocation.NewSIFromAllocation, which copies neither
+// AllocationTags nor Priority, so an allocation replayed from that snapshot
is rebuilt by
+// NewAllocationFromSI with createTime = time.Now() and priority 0. Phase 4 is
therefore written so
+// that nothing in the pinned trace depends on the ordering of the recovered
allocations - see the
+// comment there.
+//
---------------------------------------------------------------------------------------------
+
+const (
+ goldenPath = "testdata/golden_trace.json"
+
+ // application IDs, fixed rather than time- or counter-derived so the
trace is stable.
+ appPriority = "golden-app-priority"
+ appRemove = "golden-app-remove"
+ appGang = "golden-app-gang"
+ appFailer = "golden-app-failer"
+ appLow = "golden-app-low"
+ appHigh = "golden-app-high"
+
+ nodeMain = "golden-node-main:1"
+ nodePreempt = "golden-node-preempt:1"
+ queueMain = "root.main"
+ queueLow = "root.low"
+ queueHigh = "root.high"
+ // the barrier applications traceProbe.sync submits are parked here: a
leaf no phase of the
+ // workload schedules in, so the barrier mechanism is not part of what
the golden measures.
+ queueBarrier = "root.barrier"
+ goldenRMID = "rm:123"
+ goldenPart = "default"
+ goldenTimeout = 5000
+)
+
+// node-main's capacity ledger.
+//
+// Phases 4 and 5 only decide anything because node-main runs out of room at
exactly the right point,
+// so its capacity is derived from what the earlier phases place on it rather
than picked round. Every
+// figure below feeds both the asks the phases submit and the headroom they
assert afterwards, so an
+// ask whose size changes without its entry here changes a headroom assertion,
and that assertion says
+// what to recompute.
+//
+// phase 1 appPriority p-high/p-mid/p-low 3 x 10/1 -> 30 memory / 3
vcore
+// phase 2 appRemove r-1/r-3/r-4 3 x 5/1 -> 45 memory / 6
vcore
+// phase 3 appGang g-placeholder 10/1 -> 55 memory / 7
vcore (peak 65/8 while
+// then g-real 10/1 the placeholder
and the real
+// allocation are
both live)
+// phase 4 recovered usage -> 55 memory / 7
vcore
+// free against 105/10 -> 50 memory / 3
vcore
+// p-recover-high 30/1 -> 85 memory / 8
vcore
+// free, so p-recover-low (30/1) does not fit -> 20 memory / 2
vcore
+// phase 5 f-1 and f-2 (2 x 10/1) fit exactly in that remaining 20/2 -
which is what makes the
+// golden able to tell "the failed application's asks were
discarded" apart from "they
+// could not have been allocated anyway"
+//
+// Phase 6 does not depend on node-main's headroom at all: its asks request 50
vcore each and
+// node-main only has 10 in total, so FitInNode excludes it whatever the
memory situation.
+const (
+ phase1Asks = 3 // p-high, p-mid, p-low
+ phase1AskMem = 10
+ phase1AskVcore = 1
+
+ phase2Asks = 3 // r-1, r-3, r-4: r-2 is removed while still pending
+ phase2AskMem = 5
+ phase2AskVcore = 1
+
+ phase3Asks = 1 // g-real: the placeholder it replaced is released
again before the restart
+ phase3AskMem = 10
+ phase3AskVcore = 1
+
+ recoverAskMem = 30 // p-recover-high and p-recover-low, only one of
which can fit
+ recoverAskVcore = 1
+
+ failAskMem = 10 // f-1 and f-2, both of which would fit if they were
not discarded
+ failAskVcore = 1
+
+ // what the restart in phase 4 has to recover, and account for
+ liveAtRestart = phase1Asks + phase2Asks + phase3Asks
+ recoveredMem = phase1Asks*phase1AskMem + phase2Asks*phase2AskMem +
phase3Asks*phase3AskMem
+ recoveredVcore = phase1Asks*phase1AskVcore + phase2Asks*phase2AskVcore
+ phase3Asks*phase3AskVcore
+
+ // node-main holds the recovered usage, exactly one of the two phase-4
asks, and both phase-5
+ // asks - and nothing more.
+ nodeMainCapMem = recoveredMem + recoverAskMem + 2*failAskMem
+ nodeMainCapVcore = recoveredVcore + recoverAskVcore + 2*failAskVcore
+)
+
+const goldenConfig = `
+partitions:
+ - name: default
+ preemption:
+ enabled: true
+ queues:
+ - name: root
+ submitacl: "*"
+ queues:
+ - name: main
+ - name: barrier
+ - name: low
+ resources:
+ guaranteed:
+ memory: 0
+ vcore: 0
+ max:
+ memory: 100000
+ vcore: 100000
+ - name: high
+ resources:
+ guaranteed:
+ memory: 1000
+ vcore: 1000
+ max:
+ memory: 100000
+ vcore: 100000
+`
+
+// createTimeSeq produces a strictly-increasing, fixed (non-wall-clock)
createTime sequence for every
+// ask in the workload. Creation time is part of the request ordering, so it
has to be pinned rather
+// than taken from the clock. Starting far in the past additionally guarantees
that the 30s default
+// preemption delay (configs.DefaultPreemptionDelay) has always already
elapsed whenever the test
+// runs: the precondition check is `now.Before(createTime+delay)`, and "now"
is trivially far past
+// these fixed timestamps.
+type createTimeSeq struct {
+ next int64
+}
+
+func newCreateTimeSeq() *createTimeSeq {
+ return &createTimeSeq{next: 1700000000}
+}
+
+func (c *createTimeSeq) get() int64 {
+ c.next++
+ return c.next
+}
+
+// goldenAsk builds an SI ask/allocation with a pinned creation time and
explicit priority.
+// Resource type keys follow the common convention (memory, vcore).
+func goldenAsk(key, appID string, priority int32, mem, vcore int64, createTime
int64) *si.Allocation {
+ return &si.Allocation{
+ AllocationKey: key,
+ ApplicationID: appID,
+ Priority: priority,
+ ResourcePerAlloc: &si.Resource{
+ Resources: map[string]*si.Quantity{
+ siCommon.Memory: {Value: mem},
+ siCommon.CPU: {Value: vcore},
+ },
+ },
+ AllocationTags: map[string]string{
+ siCommon.CreationTime: strconv.FormatInt(createTime,
10),
+ },
+ }
+}
+
+// goldenNodeInfo builds a node registration of the given size.
recovery_test.go's createNodeInfo is
+// not reused here because it hardcodes 100 memory / 20 vcore, and every
capacity in these workloads
+// is chosen to make a specific scheduling decision come out one way rather
than the other.
+func goldenNodeInfo(nodeID string, mem, vcore int64) *si.NodeInfo {
+ return &si.NodeInfo{
+ NodeID: nodeID,
+ Attributes: map[string]string{},
+ SchedulableResource: &si.Resource{
+ Resources: map[string]*si.Quantity{
+ siCommon.Memory: {Value: mem},
+ siCommon.CPU: {Value: vcore},
+ },
+ },
+ Action: si.NodeInfo_CREATE,
+ }
+}
+
+//
---------------------------------------------------------------------------------------------
+// Barriers.
+//
+// RMProxy.UpdateAllocation only ENQUEUES the request: the scheduler drains
pendingAllocEvents on its
+// own goroutine (Scheduler.handleAllocEvent), so there is no happens-before
between UpdateAllocation
+// returning and the asks being registered on the application. Driving
MultiStepSchedule straight
+// afterwards therefore relies on the 100ms per-cycle sleep in
MultiStepSchedule - which is commented
+// in the product code as only working in tests - to have let the drain
finish. On a loaded machine
+// that can slip, and the failure surfaces as a timeout in a later wait rather
than as a behaviour
+// diff. Every UpdateAllocation in the golden-trace tests is followed by one
of these barriers before
+// the scheduler is stepped, so the workload advances on observed state.
+//
---------------------------------------------------------------------------------------------
+
+// waitForAsks blocks until every listed allocationKey is registered as a
request on the application.
+// Application.AddAllocationAsk populates sa.requests and sa.sortedRequests
under the same lock that
+// GetAllocationAsk takes, so seeing the request means the ask is fully
schedulable.
+func waitForAsks(t *testing.T, ms *mockScheduler, appID string, keys
...string) {
+ t.Helper()
+ waitForAskCondition(t, ms, appID, "registered", caller(), keys,
func(app *objects.Application) bool {
+ for _, key := range keys {
+ if app.GetAllocationAsk(key) == nil {
+ return false
+ }
+ }
+ return true
+ })
+}
+
+// waitForAsksGone blocks until none of the listed allocationKeys is
registered on the application.
+func waitForAsksGone(t *testing.T, ms *mockScheduler, appID string, keys
...string) {
+ t.Helper()
+ waitForAskCondition(t, ms, appID, "removed", caller(), keys, func(app
*objects.Application) bool {
+ for _, key := range keys {
+ if app.GetAllocationAsk(key) != nil {
+ return false
+ }
+ }
+ return true
+ })
+}
+
+// waitForAskCondition is the shared body of waitForAsks and waitForAsksGone.
from is passed in rather
+// than taken here because caller() unwinds a fixed number of frames: taken at
this depth it would
+// name the wrapper above rather than the workload step that is actually
waiting.
+func waitForAskCondition(t *testing.T, ms *mockScheduler, appID, what, from
string, keys []string, cond func(*objects.Application) bool) {
+ t.Helper()
+ err := common.WaitForCondition(10*time.Millisecond,
time.Duration(goldenTimeout)*time.Millisecond, func() bool {
+ app := ms.getApplication(appID)
+ if app == nil {
+ return false
+ }
+ return cond(app)
+ })
+ assert.NilError(t, err, "timed out waiting for asks %v of %s to be %s,
called from: %s", keys, appID, what, from)
+}
+
+// requireAllocationOnNode asserts that the node holds the allocation now, and
returns a function that
+// blocks until it has left again. Used around telling the core that the RM
has released an allocation
+// (a placeholder replacement or a preemption victim): that release is
processed on the
+// allocation-event goroutine, so the capacity it frees is not visible to the
caller until the node has
+// actually dropped it.
+//
+// Waiting for absence on its own would prove nothing. node.GetAllocation
returns nil for a misspelled
+// key, for a key belonging to a different application, and for an allocation
that was never placed, so
+// the first evaluation of such a wait succeeds and it returns having observed
no transition at all -
+// the same defect the trace-length barriers avoid by draining rather than
polling (see
+// traceProbe.sync). Establishing that the allocation is there first turns the
wait into a transition:
+// it was on the node, the release was sent, and now it is gone.
+//
+// The two halves are one call returning a closure rather than two calls
because their order is not
+// interchangeable. The presence check has to happen before the release is
handed to the core, and by
+// the time the wait runs the allocation may legitimately be gone already.
+func requireAllocationOnNode(t *testing.T, ms *mockScheduler, nodeID,
allocationKey string) func() {
+ t.Helper()
+ node := ms.getNode(nodeID)
+ assert.Assert(t, node != nil, "node %s not found, called from: %s",
nodeID, caller())
+ assert.Assert(t, node.GetAllocation(allocationKey) != nil,
+ "allocation %s is not on node %s to begin with, so waiting for
it to leave would prove nothing, called from: %s",
+ allocationKey, nodeID, caller())
+
+ return func() {
+ t.Helper()
+ err := common.WaitForCondition(10*time.Millisecond,
time.Duration(goldenTimeout)*time.Millisecond, func() bool {
+ n := ms.getNode(nodeID)
+ return n != nil && n.GetAllocation(allocationKey) == nil
+ })
+ assert.NilError(t, err, "timed out waiting for allocation %s to
leave node %s, called from: %s", allocationKey, nodeID, caller())
+ }
+}
+
+// warnLogging drops the default log level to WARN. The golden tests are
normally run repeatedly to
+// check determinism and info-level logging dominates their runtime.
+//
+// It has to be re-applied after every ms.Init: RegisterResourceManager passes
the registration's
+// extra config to configs.SetConfigMap, whose "logging" callback (registered
in configs.go) calls
+// log.UpdateLoggingConfig(GetConfigMap()). That map carries no log.level key,
so the level falls
+// back to Info and any level set before Init is lost.
+func warnLogging() {
+ log.UpdateLoggingConfig(map[string]string{"log.level": "WARN"})
+}
+
+// useWarnLogging applies warnLogging for the duration of the test. Logging
configuration is
+// process-global and shared with every other test in this package, so the
configuration that was in
+// effect when this test started is snapshotted here and put back on cleanup.
Reading the config map
+// at cleanup time instead would restore whatever this test's own ms.Init
installed, which is the
+// product default rather than what the previous test left behind.
+func useWarnLogging(t *testing.T) {
+ t.Helper()
+ before := make(map[string]string)
+ for key, value := range configs.GetConfigMap() {
+ before[key] = value
+ }
+ t.Cleanup(func() {
+ log.UpdateLoggingConfig(before)
+ })
+ warnLogging()
+}
+
+// updatingGolden reports whether this run regenerates the golden files
instead of verifying them.
+// UPDATE_GOLDEN is parsed as a boolean rather than compared against "1" so
that the obvious spellings
+// all work. Anything that is neither empty nor a boolean is a typo, and
silently downgrading a typo
+// to a verification run is how a maintainer comes to believe they regenerated
a golden that they did
+// not, so it fails instead.
+func updatingGolden(t *testing.T) bool {
+ t.Helper()
+ value, set := os.LookupEnv("UPDATE_GOLDEN")
+ if !set || value == "" {
+ return false
+ }
+ update, err := strconv.ParseBool(value)
+ if err != nil {
+ t.Fatalf("UPDATE_GOLDEN=%q is not a boolean: set it to 1 (or
true) to regenerate the goldens, or unset it to verify them", value)
+ }
+ return update
+}
+
+//
---------------------------------------------------------------------------------------------
+// Phase bookkeeping.
+//
+// A golden mismatch is reported against the phase that produced the offending
entry. The phase
+// boundaries are recorded from the test goroutine at the point where the
trace length has already
+// been asserted, so the mapping is a property of the workload rather than of
when a callback
+// happened to fire.
+//
---------------------------------------------------------------------------------------------
+
+// traceMark records the half-open range of full-trace entries [start,end)
that a phase accounts for.
+// A phase that produces no decisions has start == end and therefore owns no
entry; the mismatch
+// listing calls those out at their boundary rather than through phaseOf.
+type traceMark struct {
+ phase string
+ start int
+ end int
+}
+
+//
---------------------------------------------------------------------------------------------
+// traceProbe reads the decision trace of one running mockScheduler and pins
its length at each point
+// in the workload where the number of decisions taken so far is known.
+//
+// The equality is the point: a ">= n" barrier is satisfied early by an
unexpected extra decision, so
+// a behaviour change drifts into a later wait and presents as a timeout
instead of a diff. Requiring
+// equality pins that the scheduler produced no decision beyond the ones the
workload accounts for at
+// this stage, and localises any surplus to the step that produced it.
+//
+// For that equality to mean anything the trace has to be quiescent, which a
poll for a length cannot
+// establish: at a point where the workload expects no new decision the poll's
first evaluation
+// already succeeds, and it returns before a decision still travelling towards
the RM could land. So
+// every read is preceded by sync(), which drives an observable round trip
through both queues that
+// carry decisions.
+//
---------------------------------------------------------------------------------------------
+type traceProbe struct {
+ t *testing.T
+ ms *mockScheduler
+ // leaf queue the barrier applications of sync() are parked in. It must
be a queue no workload
+ // schedules in, so that the barrier mechanism stays outside what the
goldens measure - see sync().
+ queue string
+
+ updating bool // regenerating the goldens rather than verifying them
+ barriers int // barrier applications submitted so far, used to keep
their IDs unique
+
+ offset int // full-trace offset, see restarted
+ marks []traceMark
+}
+
+func newTraceProbe(t *testing.T, ms *mockScheduler, queue string) *traceProbe {
+ t.Helper()
+ return &traceProbe{t: t, ms: ms, queue: queue, updating:
updatingGolden(t)}
+}
+
+// sync blocks until every decision the scheduler has already taken is
recorded in the trace.
+//
+// It works by submitting an application that does nothing and waiting for the
acceptance to come
+// back, which is a round trip through both of the queues a decision can be
travelling on:
+//
+// - the application add is enqueued on Scheduler.pendingAllocEvents, the
same queue every
+// UpdateAllocation lands on, and that queue is drained in order by a
single goroutine. So by the
+// time the add is processed, every allocation request submitted before it
has been processed to
+// completion - including the RM notifications it produced, because
ClusterContext's
+// notifyRMNewAllocation and notifyRMAllocationReleased block on a reply
channel;
+// - the acceptance is then enqueued on RMProxy.pendingRMEvents, the queue
those notifications
+// travel on, and that queue is likewise drained in order by a single
goroutine. So by the time
+// the mock RM records the acceptance, it has already recorded every
decision enqueued ahead of
+// it.
+//
+// The barrier applications are parked in a leaf queue of their own
(root.barrier, and the equivalent
+// in each scenario config) that no workload submits work to. A barrier
application is inert - it holds
+// no asks, so it has no pending resources, its queue has none either, and
both Queue.sortQueues and
+// filterOnPendingResources drop them before any sorting or allocation attempt
sees them - but that
+// inertness must not be what keeps them out of the measurement. These goldens
pin, among other things,
+// the order in which a leaf serves its applications; putting the barriers in
the very queue whose
+// ordering is being pinned would make an implementation detail of the barrier
mechanism a term in the
+// result, and a change to that filtering would then move goldens that have
nothing to do with it.
+// Barriers never appear in the trace either way, which records allocations,
releases and rejections
+// only.
+func (p *traceProbe) sync() {
+ p.t.Helper()
+ p.barriers++
+ appID := fmt.Sprintf("golden-barrier-%d", p.barriers)
+ err := p.ms.addApp(appID, p.queue, goldenPart)
+ assert.NilError(p.t, err, "failed to submit barrier application %s",
appID)
+ p.ms.mockRM.waitForAcceptedApplication(p.t, appID, goldenTimeout)
+}
+
+// assertLen requires the trace to hold exactly n entries at this point in the
workload, and returns
+// the number it actually holds. where describes the place in the workload the
assertion sits, so that
+// a failure names that place rather than the helper that raised it.
+//
+// When regenerating it reports the difference and returns the observed count
instead of failing: a
+// change to the number of decisions a phase produces is precisely the kind of
change regeneration
+// exists for, so these barriers must not stand in its way. They keep their
teeth on every other run.
+func (p *traceProbe) assertLen(n int, where string) int {
+ p.t.Helper()
+ p.sync()
+ trace := p.ms.mockRM.getTrace()
+ switch {
+ case len(trace) == n:
+ case p.updating:
+ p.t.Logf("regenerating: expected exactly %d decision(s) %s,
this run produced %d - update the count at that call site:\n%s",
+ n, where, len(trace), indentTrace(trace))
+ default:
+ p.t.Fatalf("expected exactly %d decision(s) %s, got %d:\n%s",
n, where, len(trace), indentTrace(trace))
+ }
+ return len(trace)
+}
+
+// endPhase pins the number of decisions the trace holds once the named phase
is complete, and
+// remembers where that boundary falls in the full trace.
+func (p *traceProbe) endPhase(phase string, n int) {
+ p.t.Helper()
+ got := p.assertLen(n, "by the end of "+phase)
+ start := 0
+ if len(p.marks) > 0 {
+ start = p.marks[len(p.marks)-1].end
+ }
+ p.marks = append(p.marks, traceMark{phase: phase, start: start, end:
p.offset + got})
+}
+
+// pin asserts an OBSERVATION of the behaviour the golden records - the
recovery ask that must still
+// be waiting, the asks a failed application must have discarded. Like the
trace lengths these describe
+// the behaviour being pinned, so they report and carry on when regenerating
rather than blocking the
+// regeneration of the golden that is supposed to replace them.
+//
+// A precondition - the state a phase has to be in for its result to mean
anything - does not belong
+// here. One that no longer holds does not describe a new behaviour, it says
the run set up a different
+// situation than the one the phase is named for, and regenerating from it
produces a green golden that
+// pins the wrong experiment. Those are asserted with assert.Assert instead,
so that they fail in both
+// modes; see the capacity-ledger check and the two headroom checks in phases
4 and 5.
+func (p *traceProbe) pin(ok bool, format string, args ...interface{}) {
+ p.t.Helper()
+ if ok {
+ return
+ }
+ if p.updating {
+ p.t.Logf("regenerating: "+format, args...)
+ return
+ }
+ p.t.Fatalf(format, args...)
+}
+
+// restarted tells the probe that the mock RM has been replaced by a scheduler
restart, keeping the
+// full-trace indices continuous: captured is the number of entries the
previous mock RM recorded, so
+// the post-restart trace is a suffix of the full trace rather than the whole
of it.
+func (p *traceProbe) restarted(captured int) {
+ p.offset = captured
+}
+
+// TestGoldenDecisionTrace scripts a tie-free workload over the scheduler
behaviours whose output
+// order is most load-bearing, and most easily changed by accident:
+// - multiple priorities within a single application: allocation order must
follow priority, not
+// submission order
+// - removing an ask while it and its siblings are still pending: the
removal must retire that one
+// request without reaching the RM and without disturbing the order in
which the application's
+// remaining requests are then served
+// - gang scheduling: a placeholder allocation replaced by a real one, which
mutates an
+// application's requests during the same scheduling cycle that walks them
+// - scheduler restart and allocation recovery: the usage the recovered
allocations represent must
+// be accounted for on the node, which is pinned by making the
post-recovery asks compete for the
+// capacity that correct accounting leaves free
+// - an application that fails while still holding pending asks: cleanup
must discard them so later
+// cycles neither allocate them nor crash, pinned on a node that still has
room for them
+// - priority-driven preemption across sibling queues: victim selection, and
the reserved
+// high-priority ask completing once the victim's capacity is freed
+//
+// Deliberately not covered: the placeholder-replacement revert in
Application.tryPlaceholderAllocate,
+// where marking the placeholder released fails because a second actor
released or preempted it
+// concurrently. Reaching that branch needs two goroutines racing on the same
placeholder inside one
+// scheduling cycle, which this single-threaded, step-driven harness cannot
script without
+// introducing exactly the timing nondeterminism a golden test must not have.
+func TestGoldenDecisionTrace(t *testing.T) { //nolint:funlen
+ useWarnLogging(t)
+
+ seq := newCreateTimeSeq()
+
+ ms := &mockScheduler{}
+ defer ms.Stop()
+ err := ms.Init(goldenConfig, false, false)
+ assert.NilError(t, err, "RegisterResourceManager failed")
+ warnLogging()
+ ms.mockRM.enableTrace()
+
+ pt := newTraceProbe(t, ms, queueBarrier)
+
+ err = ms.proxy.UpdateNode(&si.NodeRequest{
+ Nodes: []*si.NodeInfo{goldenNodeInfo(nodeMain, nodeMainCapMem,
nodeMainCapVcore)},
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedNode(t, nodeMain, goldenTimeout)
+
+ //
-----------------------------------------------------------------------------------------
+ // Phase 1: multiple priorities within one application. The asks are
submitted out of priority
+ // order, so the resulting trace distinguishes priority ordering from
submission order. The
+ // priorities (5, 3, 1) are pairwise distinct, so no ordering tie has
to be broken.
+ //
-----------------------------------------------------------------------------------------
+ err = ms.addApp(appPriority, queueMain, goldenPart)
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedApplication(t, appPriority, goldenTimeout)
+
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Allocations: []*si.Allocation{
+ goldenAsk("p-mid", appPriority, 3, phase1AskMem,
phase1AskVcore, seq.get()),
+ goldenAsk("p-high", appPriority, 5, phase1AskMem,
phase1AskVcore, seq.get()),
+ goldenAsk("p-low", appPriority, 1, phase1AskMem,
phase1AskVcore, seq.get()),
+ },
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsks(t, ms, appPriority, "p-mid", "p-high", "p-low")
+ ms.scheduler.MultiStepSchedule(4)
+ pt.endPhase("phase 1 priority order", phase1Asks)
+
+ //
-----------------------------------------------------------------------------------------
+ // Phase 2: remove an ask while it and its siblings are all still
pending. appRemove submits four
+ // asks at four distinct priorities and NO scheduling cycle is run
before the middle one (r-2,
+ // priority 5) is removed, so the removal lands on a request list that
is entirely pending. Two
+ // things are then pinned: the removal produces no RM-visible decision
of its own (the trace
+ // length is unchanged across it), and the three survivors are
subsequently served in priority
+ // order with r-2 simply absent - the removal must not reorder or lose
any of them.
+ //
-----------------------------------------------------------------------------------------
+ err = ms.addApp(appRemove, queueMain, goldenPart)
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedApplication(t, appRemove, goldenTimeout)
+
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Allocations: []*si.Allocation{
+ goldenAsk("r-1", appRemove, 7, phase2AskMem,
phase2AskVcore, seq.get()),
+ goldenAsk("r-2", appRemove, 5, phase2AskMem,
phase2AskVcore, seq.get()),
+ goldenAsk("r-3", appRemove, 3, phase2AskMem,
phase2AskVcore, seq.get()),
+ goldenAsk("r-4", appRemove, 1, phase2AskMem,
phase2AskVcore, seq.get()),
+ },
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsks(t, ms, appRemove, "r-1", "r-2", "r-3", "r-4")
+
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Releases: &si.AllocationReleasesRequest{
+ AllocationsToRelease: []*si.AllocationRelease{
+ createAllocationRelease(appRemove, goldenPart,
"r-2", si.TerminationType_STOPPED_BY_RM),
+ },
+ },
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsksGone(t, ms, appRemove, "r-2")
+ // the other three must have survived the removal untouched.
+ waitForAsks(t, ms, appRemove, "r-1", "r-3", "r-4")
+ // removing a still-pending ask is not an RM-visible decision: nothing
was ever allocated or
+ // released at the RM level for it, so the trace is still exactly what
phase 1 left behind. Seeing
+ // the request gone is not enough to conclude that, because the removal
is still being processed
+ // when it becomes observable, so assertLen drains both decision queues
before it counts.
+ pt.assertLen(phase1Asks, "after removing the still-pending r-2")
+
+ ms.scheduler.MultiStepSchedule(4)
+ pt.endPhase("phase 2 pending ask removal", phase1Asks+phase2Asks)
+
+ //
-----------------------------------------------------------------------------------------
+ // Phase 3: gang scheduling - a placeholder allocation replaced by the
real one. The shape of
+ // the exchange mirrors smoke_test.go's
TestDupReleasesInGangScheduling: the core allocates the
+ // placeholder, then on seeing the real ask asks the RM to release the
placeholder, and only
+ // once the RM confirms does the real allocation appear. All three
steps are RM-visible, so the
+ // golden pins the full replacement handshake and its ordering.
+ //
-----------------------------------------------------------------------------------------
+ err = ms.addApp(appGang, queueMain, goldenPart)
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedApplication(t, appGang, goldenTimeout)
+
+ phAsk := goldenAsk("g-placeholder", appGang, 6, phase3AskMem,
phase3AskVcore, seq.get())
+ phAsk.TaskGroupName = "tg"
+ phAsk.Placeholder = true
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Allocations: []*si.Allocation{phAsk},
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsks(t, ms, appGang, "g-placeholder")
+ ms.scheduler.MultiStepSchedule(2)
+ pt.assertLen(7, "after allocating the gang placeholder")
+
+ realAsk := goldenAsk("g-real", appGang, 6, phase3AskMem,
phase3AskVcore, seq.get())
+ realAsk.TaskGroupName = "tg"
+ realAsk.Placeholder = false
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Allocations: []*si.Allocation{realAsk},
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsks(t, ms, appGang, "g-real")
+ ms.scheduler.MultiStepSchedule(2)
+ // the core notifies the RM the placeholder should be released.
+ pt.assertLen(8, "after the core asked the RM to release the
placeholder")
+
+ placeholderGone := requireAllocationOnNode(t, ms, nodeMain,
"g-placeholder")
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Releases: &si.AllocationReleasesRequest{
+ AllocationsToRelease: []*si.AllocationRelease{
+ createAllocationRelease(appGang, goldenPart,
"g-placeholder", si.TerminationType_PLACEHOLDER_REPLACED),
+ },
+ },
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ // no scheduling step is needed to complete the replacement: the
confirmation is processed on the
+ // allocation-event goroutine, and
ClusterContext.processAllocationReleases notifies the RM of the
+ // real allocation directly (see the "placeholder swap & preemption"
branch there).
+ pt.endPhase("phase 3 gang placeholder replacement", 9)
+ placeholderGone()
+
+ //
-----------------------------------------------------------------------------------------
+ // Phase 4: restart the scheduler and recover. Re-register the node and
the three applications
+ // that still hold live allocations (appPriority, appRemove, appGang),
replay those allocations
+ // through the recovery path, then make two post-recovery asks compete
for what correct recovery
+ // accounting leaves free on node-main.
+ //
+ // The recovered usage is 55 memory / 7 vcore (30/3 from appPriority,
15/3 from appRemove, 10/1
+ // from appGang's g-real), so 50 memory / 3 vcore of node-main's 105/10
is free. The two asks are
+ // 30 memory / 1 vcore each: exactly one of them fits. Correct
accounting therefore allocates
+ // p-recover-high (priority 9) and leaves p-recover-low (priority 7)
pending. If recovery lost the
+ // usage, both fit and both allocate; if it double-counted, neither
does. Either way the trace
+ // changes and the golden fails - which is what makes this phase pin
recovery rather than merely
+ // survive it. See the capacity ledger above for where 105/10 comes
from.
+ //
+ // Note what is NOT pinned here: the recovered allocations' own
createTime and priority.
+ // Allocation.NewSIFromAllocation copies neither AllocationTags nor
Priority, so what the RM
+ // snapshot carries back into NewAllocationFromSI is rebuilt with
createTime = time.Now() and
+ // priority 0. Nothing in this phase depends on their relative order -
they are all allocated
+ // already, and tryAllocate skips allocated requests - so the
wall-clock times that enter here
+ // cannot reach the trace.
+ //
-----------------------------------------------------------------------------------------
+ // the ledger has to leave room for one of the two asks and not for
both, whatever else changes.
+ assert.Assert(t, recoverAskMem <= nodeMainCapMem-recoveredMem &&
2*recoverAskMem > nodeMainCapMem-recoveredMem,
+ "the capacity ledger must leave room on node-main for exactly
one of the two %d memory recovery asks, it leaves %d",
+ recoverAskMem, nodeMainCapMem-recoveredMem)
+
+ preRestartTrace := ms.mockRM.getTrace()
+ oldMockRM := ms.mockRM
+ ms.serviceContext.StopAll()
+
+ err = ms.Init(goldenConfig, false, false)
+ assert.NilError(t, err, "RegisterResourceManager failed on restart")
+ warnLogging()
+ ms.mockRM.enableTrace()
+ pt.restarted(len(preRestartTrace))
+
+ err = ms.proxy.UpdateNode(&si.NodeRequest{
+ Nodes: []*si.NodeInfo{goldenNodeInfo(nodeMain, nodeMainCapMem,
nodeMainCapVcore)},
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedNode(t, nodeMain, goldenTimeout)
+
+ for _, appID := range []string{appPriority, appRemove, appGang} {
+ err = ms.addApp(appID, queueMain, goldenPart)
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedApplication(t, appID, goldenTimeout)
+ }
+
+ part := ms.scheduler.GetClusterContext().GetPartition(ms.partitionName)
+ assert.Assert(t, part != nil, "partition not found after restart")
+ // the per-node history is append-only (see
mockRMCallback.UpdateAllocation) and still contains
+ // g-placeholder even though it was replaced (released) before the
restart. Only allocations still
+ // present in the Allocations map (which IS pruned on release) are
actually live; replay those,
+ // not the raw per-node history.
+ live := oldMockRM.getAllocations()
+ var liveOnNodeMain []*si.Allocation
+ for _, alloc := range oldMockRM.getNodeAllocations(nodeMain) {
+ if _, ok := live[alloc.AllocationKey]; ok {
+ liveOnNodeMain = append(liveOnNodeMain, alloc)
+ }
+ }
+ // a precondition, not an observation: this is the set of allocations
the recovery under test is
+ // given to replay, so a run that replays a different set is not this
phase.
+ assert.Assert(t, len(liveOnNodeMain) == liveAtRestart,
+ "expected %d live allocations on %s to recover, found %d:
phases 1 to 3 no longer leave what the capacity ledger says they do, recompute
phase1Asks/phase2Asks/phase3Asks",
+ liveAtRestart, nodeMain, len(liveOnNodeMain))
+ err = registerAllocations(part, liveOnNodeMain)
+ assert.NilError(t, err, "failed to replay recovered allocations")
+
+ // the headroom the rest of this phase and all of phase 5 depend on,
checked against the node
+ // rather than against the comment above it. A precondition, and a hard
one in both modes: this is
+ // what makes the two recovery asks compete for one slot, so a run that
starts phase 4 with a
+ // different amount free is measuring something other than what the
phase is named for - and a
+ // golden regenerated from it would look exactly like a golden
regenerated from a real behaviour
+ // change.
+ free := ms.getNode(nodeMain).GetAvailableResource()
+ assert.Assert(t, free.Resources[siCommon.Memory] ==
nodeMainCapMem-recoveredMem && free.Resources[siCommon.CPU] ==
nodeMainCapVcore-recoveredVcore,
+ "recovery left %v free on %s, the capacity ledger says %d
memory / %d vcore: either recovery accounting changed or the ledger is stale",
+ free, nodeMain, nodeMainCapMem-recoveredMem,
nodeMainCapVcore-recoveredVcore)
+
+ recoveredApp := ms.getApplication(appPriority)
+ assert.Assert(t, recoveredApp != nil, "recovered app-priority not
found")
+
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Allocations: []*si.Allocation{
+ goldenAsk("p-recover-high", appPriority, 9,
recoverAskMem, recoverAskVcore, seq.get()),
+ goldenAsk("p-recover-low", appPriority, 7,
recoverAskMem, recoverAskVcore, seq.get()),
+ },
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsks(t, ms, appPriority, "p-recover-high", "p-recover-low")
+ ms.scheduler.MultiStepSchedule(3)
+ pt.endPhase("phase 4 restart and recovery", 1)
+ // the trace pins that only one allocation was made; this pins that the
one left behind is the
+ // low-priority ask and that it is still waiting. Application.requests
keeps allocated asks
+ // around - tryAllocate skips them via IsAllocated - so the ask merely
being present says nothing.
+ recoverLow := recoveredApp.GetAllocationAsk("p-recover-low")
+ pt.pin(recoverLow != nil && !recoverLow.IsAllocated(),
+ "p-recover-low should still be waiting: recovered usage left
room for only one of the two asks")
+
+ // p-recover-low has done its job. It is withdrawn here because phase 6
introduces a second, much
+ // larger node, and a request left pending across that would be
allocated there and change both
+ // the trace and the capacity phase 6 depends on.
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Releases: &si.AllocationReleasesRequest{
+ AllocationsToRelease: []*si.AllocationRelease{
+ createAllocationRelease(appPriority,
goldenPart, "p-recover-low", si.TerminationType_STOPPED_BY_RM),
+ },
+ },
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsksGone(t, ms, appPriority, "p-recover-low")
+
+ //
-----------------------------------------------------------------------------------------
+ // Phase 5: an application fails while still holding pending asks. No
scheduling cycle is run
+ // between submitting the asks and failing the application, so they are
pending but never
+ // attempted - a deterministic way to reach the ask cleanup done on
failure without relying on
+ // any timer or timeout trigger.
+ //
+ // What is pinned here is exactly two things: the asks are no longer
registered on the application,
+ // and no decision about them reaches the RM - the cycles run below
produce no trace entry, and
+ // nothing crashes walking a failed application's requests.
+ //
+ // node-main is left with exactly 20 memory / 2 vcore at this point and
f-1 and f-2 are 10/1 each,
+ // so both of them WOULD be allocated by the cycles run below had the
cleanup not happened. That
+ // is what lets the trace assertion carry weight here rather than
merely restating that a full
+ // node cannot take more work, and that headroom is checked against the
node as a hard
+ // precondition rather than asserted by this comment.
+ //
+ // What is NOT pinned is the resource accounting behind that cleanup.
Application.cleanupAsks,
+ // which runs on entering Failed, clears sa.requests and
sa.sortedRequests and nothing else - it
+ // does not give back the application's pending resource or reset its
askMaxPriority the way
+ // removeAsksInternal("") does - so both still carry the two discarded
asks when the state change
+ // returns. Neither assertion below looks at them, and no later phase
depends on them: the
+ // terminated-application callback takes the application out of its
queue shortly afterwards, and
+ // Queue.RemoveApplication decrements the queue by whatever pending the
application is still
+ // carrying, which happens to be exactly the amount that was never
given back. Pinning the
+ // accounting would need assertions this workload does not make.
+ //
-----------------------------------------------------------------------------------------
+ free = ms.getNode(nodeMain).GetAvailableResource()
+ assert.Assert(t, free.Resources[siCommon.Memory] == 2*failAskMem &&
free.Resources[siCommon.CPU] == 2*failAskVcore,
+ "phase 5 needs %s to have room for both of its %d memory / %d
vcore asks and no more, it has %v: recompute the capacity ledger",
+ nodeMain, failAskMem, failAskVcore, free)
+
+ err = ms.addApp(appFailer, queueMain, goldenPart)
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedApplication(t, appFailer, goldenTimeout)
+
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Allocations: []*si.Allocation{
+ goldenAsk("f-1", appFailer, 2, failAskMem,
failAskVcore, seq.get()),
+ goldenAsk("f-2", appFailer, 4, failAskMem,
failAskVcore, seq.get()),
+ },
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsks(t, ms, appFailer, "f-1", "f-2")
+
+ failerApp := ms.getApplication(appFailer)
+ assert.Assert(t, failerApp != nil, "app-failer not found")
+
+ // NOT a duplicated line: reaching Failed takes two FailApplication
events. The first moves the
+ // application to Failing, the second Failing -> Failed (see the
FailApplication transitions in
+ // application_state.go). The ask cleanup being pinned here happens on
entering Failed, so a
+ // single call would leave the asks in place and the assertions below
would fail.
+ assert.NilError(t, failerApp.FailApplication("induced failure for
golden-trace test"))
+ assert.NilError(t, failerApp.FailApplication("induced failure for
golden-trace test"))
+ assert.Equal(t, failerApp.CurrentState(), objects.Failed.String())
+ // the pending asks must be gone, and scheduling afterwards must
produce no allocation for them
+ // (no crash, no trace entries) even though the node could still hold
both.
+ ms.scheduler.MultiStepSchedule(3)
+ pt.pin(failerApp.GetAllocationAsk("f-1") == nil, "failure cleanup
should have cleared f-1")
+ pt.pin(failerApp.GetAllocationAsk("f-2") == nil, "failure cleanup
should have cleared f-2")
+ pt.endPhase("phase 5 failed application ask cleanup", 1)
+
+ //
-----------------------------------------------------------------------------------------
+ // Phase 6: priority-driven preemption across sibling queues. root.low
and root.high each have
+ // explicit (non-nil) guaranteed resources configured, which preemption
requires (see
+ // configs.DefaultPreemptionDelay: "guaranteed resources must be set to
trigger preemption").
+ // A dedicated, exactly-sized node makes the victim and the outcome
unambiguous: two
+ // low-priority allocations from appLow (AllowPreemptSelf) fill it
completely, then a
+ // higher-priority ask from appHigh (AllowPreemptOther) in the sibling
queue cannot fit and
+ // triggers preemption of exactly one of them. The two low-priority
asks carry distinct, pinned
+ // createTimes, which is also the final tiebreak in
sortVictimsForPreemption - so victim
+ // selection is deterministic rather than dependent on a priority tie.
+ //
+ // Queues are not node-pinned, so node-main is a candidate for these
asks too. It is excluded not
+ // by headroom but by shape: all three asks request 50 vcore and
node-main has only 10 in total,
+ // so FitInNode rejects it for them however much memory happens to be
free.
+ //
-----------------------------------------------------------------------------------------
+ err = ms.proxy.UpdateNode(&si.NodeRequest{
+ Nodes: []*si.NodeInfo{goldenNodeInfo(nodePreempt, 100, 100)},
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedNode(t, nodePreempt, goldenTimeout)
+
+ err = ms.addApp(appLow, queueLow, goldenPart)
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedApplication(t, appLow, goldenTimeout)
+ err = ms.addApp(appHigh, queueHigh, goldenPart)
+ assert.NilError(t, err)
+ ms.mockRM.waitForAcceptedApplication(t, appHigh, goldenTimeout)
+
+ lowAsk1 := goldenAsk("low-1", appLow, 1, 50, 50, seq.get())
+ lowAsk1.PreemptionPolicy = &si.PreemptionPolicy{AllowPreemptSelf: true}
+ lowAsk2 := goldenAsk("low-2", appLow, 1, 50, 50, seq.get())
+ lowAsk2.PreemptionPolicy = &si.PreemptionPolicy{AllowPreemptSelf: true}
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Allocations: []*si.Allocation{lowAsk1, lowAsk2},
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsks(t, ms, appLow, "low-1", "low-2")
+ ms.scheduler.MultiStepSchedule(3)
+ // both low-priority allocations must land before the high-priority ask
is introduced, or the
+ // preemption target is ambiguous (nothing yet to preempt).
+ pt.assertLen(3, "after filling the preemption node")
+
+ highAsk := goldenAsk("high-1", appHigh, 9, 50, 50, seq.get())
+ highAsk.PreemptionPolicy = &si.PreemptionPolicy{AllowPreemptOther: true}
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Allocations: []*si.Allocation{highAsk},
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ waitForAsks(t, ms, appHigh, "high-1")
+ ms.scheduler.MultiStepSchedule(4)
+ // preemption fires: the core notifies the RM to release the chosen
victim.
+ pt.assertLen(4, "after preemption chose a victim")
+
+ victimKey := preemptionVictim(t, ms.mockRM.getTrace())
+ victimGone := requireAllocationOnNode(t, ms, nodePreempt, victimKey)
+
+ // the RM confirms the preemption (mirrors the shim actually
terminating the pod).
+ err = ms.proxy.UpdateAllocation(&si.AllocationRequest{
+ Releases: &si.AllocationReleasesRequest{
+ AllocationsToRelease: []*si.AllocationRelease{
+ createAllocationRelease(appLow, goldenPart,
victimKey, si.TerminationType_PREEMPTED_BY_SCHEDULER),
+ },
+ },
+ RmID: goldenRMID,
+ })
+ assert.NilError(t, err)
+ // the only barrier between the RM's confirmation and the scheduling
step below that has to observe
+ // the freed capacity, so it has to establish that the victim actually
left rather than that it is
+ // not there.
+ victimGone()
+ ms.scheduler.MultiStepSchedule(2)
+ // freed capacity lets the reserved high-priority ask complete.
+ pt.endPhase("phase 6 preemption", 5)
+
+ //
-----------------------------------------------------------------------------------------
+ // Assemble the full trace (pre-restart + post-restart mock RM
instances) and compare to golden.
+ //
-----------------------------------------------------------------------------------------
+ fullTrace := append(append([]traceEntry{}, preRestartTrace...),
ms.mockRM.getTrace()...)
+ compareOrUpdateGoldenAt(t, goldenPath, fullTrace, pt.marks, pt.updating)
+}
+
+// preemptionVictim reads the victim the core chose out of the last trace
entry, which the caller has
+// just pinned as the preemption notification. The identity of the victim is
pinned by the golden; all
+// this needs to establish is that the entry is a release of one of the two
candidates.
+//
+// Both checks are fatal in both modes, because the key read here is fed
straight back into the release
+// that confirms the preemption. An empty trace leaves nothing to feed back. A
last entry that is an
+// allocation rather than a release means the key names something that is not
a victim - and quite
+// possibly an allocation of a different application - so the release request
built from it would be
+// addressed to the wrong work, the wait for the victim to leave the node
would be waiting for
+// something that was never going to leave it, and a regenerating run would
write a golden that pins a
+// preemption handshake nobody performed. Neither is a behaviour change a new
golden should record.
+func preemptionVictim(t *testing.T, trace []traceEntry) string {
+ t.Helper()
+ if len(trace) == 0 {
+ t.Fatal("no decision to read a preemption victim from")
+ }
+ victim := trace[len(trace)-1]
+ assert.Assert(t, victim.TerminationType != "" && (victim.AllocationKey
== "low-1" || victim.AllocationKey == "low-2"),
+ "expected the last decision to be the release of low-1 or
low-2, got %s", formatTraceEntry(victim))
+ return victim.AllocationKey
+}
+
+// compareOrUpdateGoldenAt compares the captured trace against the golden JSON
file at path. marks may
+// be nil; when set it is used to attribute a mismatch to the phase that
produced it.
+//
+// When updating it (re)writes the golden file instead of asserting, and then
fails: a regenerating run
+// must never be green. A green regeneration is indistinguishable from a run
that actually verified
+// something, which is how a real regression gets written into a golden
unnoticed, and how every golden
+// here would silently stop asserting if UPDATE_GOLDEN ever leaked into a CI
environment. Only
+// regenerate for a behaviour change that is intended and reviewed (see the
doc comment on
+// TestGoldenDecisionTrace).
+func compareOrUpdateGoldenAt(t *testing.T, path string, trace []traceEntry,
marks []traceMark, updating bool) {
+ t.Helper()
+
+ data, err := json.MarshalIndent(trace, "", " ")
+ assert.NilError(t, err, "failed to marshal trace")
+ data = append(data, '\n')
+
+ if updating {
+ assert.NilError(t, os.WriteFile(path, data, 0o600), "failed to
write golden file")
+ t.Errorf("golden %s regenerated (%d entries); re-run without
UPDATE_GOLDEN to verify", path, len(trace))
+ return
+ }
+
+ golden, err := os.ReadFile(path)
+ assert.NilError(t, err, "failed to read golden file %s (run with
UPDATE_GOLDEN=1 to create it)", path)
+
+ var goldenTrace []traceEntry
+ assert.NilError(t, json.Unmarshal(golden, &goldenTrace), "failed to
parse golden file %s", path)
+
+ if !reflect.DeepEqual(goldenTrace, trace) {
+ t.Fatal(traceMismatch(path, goldenTrace, trace, marks))
+ }
+}
+
+// traceMismatch renders a golden mismatch as a side-by-side listing.
assert.DeepEqual's own output is
+// a "--- x / +++ y" diff that says neither which side is the golden nor which
golden file it came
+// from - and six of the seven goldens in this package share this helper.
+//
+// The two sides can differ in length even though the run's own length is
pinned phase by phase: those
+// barriers pin what this run produced, not what the file on disk holds, so a
golden that was written
+// for a longer or shorter workload still lands here. That is what the <none>
rows are for.
+func traceMismatch(path string, golden, trace []traceEntry, marks []traceMark)
string {
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "scheduling decisions do not match the golden file.\n")
+ fmt.Fprintf(&sb, " golden file: %s (%d entries)\n", path, len(golden))
+ fmt.Fprintf(&sb, " this run: %d entries\n\n", len(trace))
+
+ length := len(golden)
+ if len(trace) > length {
+ length = len(trace)
+ }
+ // the phase column is only present for the shared trace, which is the
only one long enough for
+ // the attribution to be worth anything.
+ phases := make([]string, length)
+ expected := make([]string, length)
+ actual := make([]string, length)
+ phaseWidth, expectedWidth := 0, 0
+ for i := range phases {
+ phases[i] = phaseOf(marks, i)
+ expected[i], actual[i] = "<none>", "<none>"
+ if i < len(golden) {
+ expected[i] = formatTraceEntry(golden[i])
+ }
+ if i < len(trace) {
+ actual[i] = formatTraceEntry(trace[i])
+ }
+ phaseWidth = max(phaseWidth, len(phases[i]))
+ expectedWidth = max(expectedWidth, len(expected[i]))
+ }
+ for i := 0; i < length; i++ {
+ // a phase that produced no decisions owns no row, so it is
named at its boundary instead -
+ // without it a phase can only ever be mentioned here by the
phases either side of it.
+ for _, mark := range marks {
+ if mark.start == i && mark.end == i {
+ fmt.Fprintf(&sb, " --- %s: no decisions
---\n", mark.phase)
+ }
+ }
+ marker := " "
+ if expected[i] != actual[i] {
+ marker = "!!"
+ }
+ row := fmt.Sprintf(" %s %3d ", marker, i)
+ if phaseWidth > 0 {
+ row += fmt.Sprintf("%-*s ", phaseWidth, phases[i])
+ }
+ fmt.Fprintf(&sb, "%sgolden: %-*s got: %s\n", row,
expectedWidth, expected[i], actual[i])
+ }
+
+ fmt.Fprintf(&sb, "\nA failure here means a scheduling decision changed.
If that change is intended and\n")
+ fmt.Fprintf(&sb, "reviewed, regenerate with UPDATE_GOLDEN=1 and call
out the trace diff in review. The\n")
+ fmt.Fprintf(&sb, "regenerating run fails on purpose; re-run without
UPDATE_GOLDEN to verify.\n")
+ return sb.String()
+}
+
+// phaseOf names the workload phase that accounts for full-trace entry i. A
phase owns the half-open
+// range [start,end), so a phase that produced no decisions owns no entry and
never names one - see
+// traceMismatch, which calls those out at their boundary instead.
+func phaseOf(marks []traceMark, i int) string {
+ for _, mark := range marks {
+ if i >= mark.start && i < mark.end {
+ return mark.phase
+ }
+ }
+ if len(marks) == 0 {
+ return ""
+ }
+ return "after " + marks[len(marks)-1].phase
+}
+
+func formatTraceEntry(entry traceEntry) string {
+ switch {
+ case entry.Rejected:
+ return fmt.Sprintf("reject %s of %s", entry.AllocationKey,
entry.ApplicationID)
+ case entry.TerminationType != "":
+ return fmt.Sprintf("release %s of %s (%s)",
entry.AllocationKey, entry.ApplicationID, entry.TerminationType)
+ default:
+ return fmt.Sprintf("alloc %s of %s on %s", entry.AllocationKey,
entry.ApplicationID, entry.NodeID)
+ }
+}
+
+func indentTrace(trace []traceEntry) string {
+ var sb strings.Builder
+ for i, entry := range trace {
+ fmt.Fprintf(&sb, " %3d %s\n", i, formatTraceEntry(entry))
+ }
+ return sb.String()
+}
+
+// TestGoldenTracePhaseAttribution pins how trace entries are attributed to
workload phases, including
+// the case the shared workload actually produces: phase 5 allocates and
releases nothing, so it is a
+// zero-width phase sitting between two phases that do produce decisions. It
must not swallow the
+// first entry of the phase after it, and the phase before it must not swallow
the boundary.
+func TestGoldenTracePhaseAttribution(t *testing.T) {
+ marks := []traceMark{
+ {phase: "one", start: 0, end: 2},
+ {phase: "two", start: 2, end: 2}, // produced nothing
+ {phase: "three", start: 2, end: 4},
+ }
+ assert.Equal(t, phaseOf(marks, 0), "one")
+ assert.Equal(t, phaseOf(marks, 1), "one")
+ assert.Equal(t, phaseOf(marks, 2), "three")
+ assert.Equal(t, phaseOf(marks, 3), "three")
+ assert.Equal(t, phaseOf(marks, 4), "after three")
+ assert.Equal(t, phaseOf(nil, 0), "")
+
+ // the zero-width phase is unnameable through phaseOf by construction,
so the mismatch listing has
+ // to name it at its boundary or it can never be mentioned at all.
+ listing := traceMismatch("testdata/none.json", nil, []traceEntry{
+ {ApplicationID: "app", AllocationKey: "a-1", NodeID: "node-1"},
+ {ApplicationID: "app", AllocationKey: "a-2", NodeID: "node-1"},
+ {ApplicationID: "app", AllocationKey: "a-3", NodeID: "node-1"},
+ {ApplicationID: "app", AllocationKey: "a-4", NodeID: "node-1"},
+ }, marks)
+ assert.Assert(t, strings.Contains(listing, "--- two: no decisions
---"), "listing does not name the zero-width phase:\n%s", listing)
+}
diff --git a/pkg/scheduler/tests/mock_rm_callback_test.go
b/pkg/scheduler/tests/mock_rm_callback_test.go
index 3bc8c63d..c219c55a 100644
--- a/pkg/scheduler/tests/mock_rm_callback_test.go
+++ b/pkg/scheduler/tests/mock_rm_callback_test.go
@@ -30,6 +30,33 @@ import (
"github.com/apache/yunikorn-scheduler-interface/lib/go/si"
)
+// traceEntry is a single, append-only observation of a scheduling decision as
delivered to the
+// RM via UpdateAllocation. It is one of three kinds:
+// - a "new allocation" entry: ApplicationID/AllocationKey/NodeID set,
TerminationType empty;
+// - a "release" entry: ApplicationID/AllocationKey/TerminationType set,
NodeID empty;
+// - a "rejection" entry: ApplicationID/AllocationKey/Rejected set, the rest
empty.
+//
+// See golden_trace_test.go for how this is used to build a golden
decision-trace.
+//
+// All three kinds carry the application ID because an allocation key only
identifies the work, not
+// whose work it is: a regression that releases the right key against the
wrong application - which is
+// exactly what a mistake in preemption victim selection or in placeholder
replacement looks like -
+// is invisible in a trace that records the key alone. The partition is
deliberately NOT recorded:
+// every workload here runs on the single "default" partition, so the column
would be constant, and
+// its value is the normalised name, which would write the RM ID into every
golden line.
+//
+// A rejection records that the core refused the allocation, not why. The
reason
+// (si.RejectedAllocation.Reason) is a free-text message built from whatever
error the partition
+// returned, so pinning it in a golden would turn any rewording of an error
string into a failed
+// decision-regression test - which is the opposite of what these goldens are
for.
+type traceEntry struct {
+ ApplicationID string `json:"applicationID,omitempty"`
+ AllocationKey string `json:"allocationKey"`
+ NodeID string `json:"nodeID,omitempty"`
+ TerminationType string `json:"terminationType,omitempty"`
+ Rejected bool `json:"rejected,omitempty"`
+}
+
type mockRMCallback struct {
mock.ResourceManagerCallback
acceptedApplications map[string]bool
@@ -41,6 +68,24 @@ type mockRMCallback struct {
releasedPhs map[string]*si.AllocationRelease
appStates map[string]string
+ // traceEnabled turns the capture below on. It is off by default
because this mock is shared with
+ // every other test in the package, including BenchmarkScheduling,
which drives 10k allocations
+ // per run through this callback: capturing them would add an append
per allocation, inside the
+ // callback's write lock, on the path being measured. Only the
golden-trace tests call
+ // enableTrace.
+ traceEnabled bool
+
+ // trace is an ordered, append-only record of every allocation, release
and rejection observed via
+ // UpdateAllocation, in the exact order the RMProxy delivered them.
RMProxy.handleRMEvents
+ // (rmproxy.go) drains its pending event queue from a single goroutine,
so the callbacks arrive
+ // in the order the events were enqueued - but enqueue order is not by
itself decision order,
+ // because events reach that queue from more than one goroutine (the
scheduling goroutine and the
+ // allocation-event goroutine both enqueue). What makes the captured
order the decision order is
+ // the workload: the golden-trace tests advance scheduling from a
single goroutine calling
+ // MultiStepSchedule and drain both queues between batches
(traceProbe.sync), so at most one batch
+ // of decisions is ever in flight. Used by golden_trace_test.go for the
golden-trace test.
+ trace []traceEntry
+
locking.RWMutex
}
@@ -88,16 +133,81 @@ func (m *mockRMCallback) UpdateAllocation(response
*si.AllocationResponse) error
nodeAllocations = append(nodeAllocations, alloc)
m.nodeAllocations[alloc.NodeID] = nodeAllocations
}
+ if m.traceEnabled {
+ m.trace = append(m.trace, traceEntry{
+ ApplicationID: alloc.ApplicationID,
+ AllocationKey: alloc.AllocationKey,
+ NodeID: alloc.NodeID,
+ })
+ }
}
for _, alloc := range response.Released {
delete(m.Allocations, alloc.AllocationKey)
if alloc.TerminationType ==
si.TerminationType_PLACEHOLDER_REPLACED {
m.releasedPhs[alloc.AllocationKey] = alloc
}
+ if m.traceEnabled {
+ m.trace = append(m.trace, traceEntry{
+ ApplicationID: alloc.ApplicationID,
+ AllocationKey: alloc.AllocationKey,
+ TerminationType: alloc.TerminationType.String(),
+ })
+ }
+ }
+ // Rejections arrive on this same callback, from
RMProxy.processRMRejectedAllocationEvent. They are
+ // captured because a decision trace that records only what WAS
allocated cannot tell a rejected
+ // ask apart from one that was simply never scheduled: both show up as
a missing row, and those two
+ // are different regressions - a quota or validation change against an
ordering change. Recorded as
+ // an entry rather than checked here because this callback runs on the
RMProxy's event goroutine,
+ // where a failed assertion could not stop the test anyway; as a trace
entry it lands in the golden
+ // comparison and in the phase-length barriers, both of which run on
the test goroutine.
+ for _, alloc := range response.RejectedAllocations {
+ if m.traceEnabled {
+ m.trace = append(m.trace, traceEntry{
+ ApplicationID: alloc.ApplicationID,
+ AllocationKey: alloc.AllocationKey,
+ Rejected: true,
+ })
+ }
}
return nil
}
+// enableTrace turns on decision-trace capture for this mock. Called by the
golden-trace tests only,
+// see the traceEnabled field.
+func (m *mockRMCallback) enableTrace() {
+ m.Lock()
+ defer m.Unlock()
+ m.traceEnabled = true
+}
+
+// getTrace returns a copy of the ordered decision trace captured so far. Safe
for concurrent use.
+func (m *mockRMCallback) getTrace() []traceEntry {
+ m.RLock()
+ defer m.RUnlock()
+
+ trace := make([]traceEntry, len(m.trace))
+ copy(trace, m.trace)
+ return trace
+}
+
+// getNodeAllocations returns the allocations this RM was told about for the
given node.
+// Note that unlike Allocations this history is append-only: nodeAllocations
is never pruned when an
+// allocation is released, so it is a record of what was placed on the node
rather than of what is
+// still live there.
+//
+// The returned slice is a fresh slice, so it can be walked while the mock
keeps recording, but its
+// elements are the *si.Allocation pointers the mock was handed, not copies:
they are shared with the
+// caller's other readers and must be treated as read-only.
+func (m *mockRMCallback) getNodeAllocations(nodeID string) []*si.Allocation {
+ m.RLock()
+ defer m.RUnlock()
+
+ allocations := make([]*si.Allocation, len(m.nodeAllocations[nodeID]))
+ copy(allocations, m.nodeAllocations[nodeID])
+ return allocations
+}
+
func (m *mockRMCallback) UpdateNode(response *si.NodeResponse) error {
m.Lock()
defer m.Unlock()
diff --git a/pkg/scheduler/tests/testdata/golden_trace.json
b/pkg/scheduler/tests/testdata/golden_trace.json
new file mode 100644
index 00000000..ba84f58b
--- /dev/null
+++ b/pkg/scheduler/tests/testdata/golden_trace.json
@@ -0,0 +1,72 @@
+[
+ {
+ "applicationID": "golden-app-priority",
+ "allocationKey": "p-high",
+ "nodeID": "golden-node-main:1"
+ },
+ {
+ "applicationID": "golden-app-priority",
+ "allocationKey": "p-mid",
+ "nodeID": "golden-node-main:1"
+ },
+ {
+ "applicationID": "golden-app-priority",
+ "allocationKey": "p-low",
+ "nodeID": "golden-node-main:1"
+ },
+ {
+ "applicationID": "golden-app-remove",
+ "allocationKey": "r-1",
+ "nodeID": "golden-node-main:1"
+ },
+ {
+ "applicationID": "golden-app-remove",
+ "allocationKey": "r-3",
+ "nodeID": "golden-node-main:1"
+ },
+ {
+ "applicationID": "golden-app-remove",
+ "allocationKey": "r-4",
+ "nodeID": "golden-node-main:1"
+ },
+ {
+ "applicationID": "golden-app-gang",
+ "allocationKey": "g-placeholder",
+ "nodeID": "golden-node-main:1"
+ },
+ {
+ "applicationID": "golden-app-gang",
+ "allocationKey": "g-placeholder",
+ "terminationType": "PLACEHOLDER_REPLACED"
+ },
+ {
+ "applicationID": "golden-app-gang",
+ "allocationKey": "g-real",
+ "nodeID": "golden-node-main:1"
+ },
+ {
+ "applicationID": "golden-app-priority",
+ "allocationKey": "p-recover-high",
+ "nodeID": "golden-node-main:1"
+ },
+ {
+ "applicationID": "golden-app-low",
+ "allocationKey": "low-1",
+ "nodeID": "golden-node-preempt:1"
+ },
+ {
+ "applicationID": "golden-app-low",
+ "allocationKey": "low-2",
+ "nodeID": "golden-node-preempt:1"
+ },
+ {
+ "applicationID": "golden-app-low",
+ "allocationKey": "low-2",
+ "terminationType": "PREEMPTED_BY_SCHEDULER"
+ },
+ {
+ "applicationID": "golden-app-high",
+ "allocationKey": "high-1",
+ "nodeID": "golden-node-preempt:1"
+ }
+]
diff --git a/pkg/scheduler/tests/testdata/golden_trace_app_ordering_fair.json
b/pkg/scheduler/tests/testdata/golden_trace_app_ordering_fair.json
new file mode 100644
index 00000000..ce90fd72
--- /dev/null
+++ b/pkg/scheduler/tests/testdata/golden_trace_app_ordering_fair.json
@@ -0,0 +1,22 @@
+[
+ {
+ "applicationID": "sc-app-early",
+ "allocationKey": "e-1",
+ "nodeID": "sc-node:1"
+ },
+ {
+ "applicationID": "sc-app-early",
+ "allocationKey": "e-2",
+ "nodeID": "sc-node:1"
+ },
+ {
+ "applicationID": "sc-app-late",
+ "allocationKey": "l-1",
+ "nodeID": "sc-node:1"
+ },
+ {
+ "applicationID": "sc-app-late",
+ "allocationKey": "l-2",
+ "nodeID": "sc-node:1"
+ }
+]
diff --git a/pkg/scheduler/tests/testdata/golden_trace_app_ordering_fifo.json
b/pkg/scheduler/tests/testdata/golden_trace_app_ordering_fifo.json
new file mode 100644
index 00000000..84b574dd
--- /dev/null
+++ b/pkg/scheduler/tests/testdata/golden_trace_app_ordering_fifo.json
@@ -0,0 +1,22 @@
+[
+ {
+ "applicationID": "sc-app-early",
+ "allocationKey": "e-1",
+ "nodeID": "sc-node:1"
+ },
+ {
+ "applicationID": "sc-app-early",
+ "allocationKey": "e-2",
+ "nodeID": "sc-node:1"
+ },
+ {
+ "applicationID": "sc-app-late",
+ "allocationKey": "l-1",
+ "nodeID": "sc-node:1"
+ },
+ {
+ "applicationID": "sc-app-early",
+ "allocationKey": "e-3",
+ "nodeID": "sc-node:1"
+ }
+]
diff --git a/pkg/scheduler/tests/testdata/golden_trace_gang.json
b/pkg/scheduler/tests/testdata/golden_trace_gang.json
new file mode 100644
index 00000000..6cfbcd1c
--- /dev/null
+++ b/pkg/scheduler/tests/testdata/golden_trace_gang.json
@@ -0,0 +1,17 @@
+[
+ {
+ "applicationID": "sc-app-gang",
+ "allocationKey": "g-placeholder",
+ "nodeID": "sc-node:1"
+ },
+ {
+ "applicationID": "sc-app-gang",
+ "allocationKey": "g-placeholder",
+ "terminationType": "PLACEHOLDER_REPLACED"
+ },
+ {
+ "applicationID": "sc-app-gang",
+ "allocationKey": "g-real",
+ "nodeID": "sc-node:1"
+ }
+]
diff --git a/pkg/scheduler/tests/testdata/golden_trace_node_binpacking.json
b/pkg/scheduler/tests/testdata/golden_trace_node_binpacking.json
new file mode 100644
index 00000000..87ef4c51
--- /dev/null
+++ b/pkg/scheduler/tests/testdata/golden_trace_node_binpacking.json
@@ -0,0 +1,22 @@
+[
+ {
+ "applicationID": "sc-app-pack",
+ "allocationKey": "a1",
+ "nodeID": "sc-node-a:1"
+ },
+ {
+ "applicationID": "sc-app-pack",
+ "allocationKey": "a2",
+ "nodeID": "sc-node-a:1"
+ },
+ {
+ "applicationID": "sc-app-pack",
+ "allocationKey": "a3",
+ "nodeID": "sc-node-a:1"
+ },
+ {
+ "applicationID": "sc-app-pack",
+ "allocationKey": "a4",
+ "nodeID": "sc-node-a:1"
+ }
+]
diff --git a/pkg/scheduler/tests/testdata/golden_trace_node_fair.json
b/pkg/scheduler/tests/testdata/golden_trace_node_fair.json
new file mode 100644
index 00000000..3cdca204
--- /dev/null
+++ b/pkg/scheduler/tests/testdata/golden_trace_node_fair.json
@@ -0,0 +1,22 @@
+[
+ {
+ "applicationID": "sc-app-pack",
+ "allocationKey": "a1",
+ "nodeID": "sc-node-a:1"
+ },
+ {
+ "applicationID": "sc-app-pack",
+ "allocationKey": "a2",
+ "nodeID": "sc-node-b:1"
+ },
+ {
+ "applicationID": "sc-app-pack",
+ "allocationKey": "a3",
+ "nodeID": "sc-node-a:1"
+ },
+ {
+ "applicationID": "sc-app-pack",
+ "allocationKey": "a4",
+ "nodeID": "sc-node-b:1"
+ }
+]
diff --git a/pkg/scheduler/tests/testdata/golden_trace_preemption.json
b/pkg/scheduler/tests/testdata/golden_trace_preemption.json
new file mode 100644
index 00000000..d1a459a8
--- /dev/null
+++ b/pkg/scheduler/tests/testdata/golden_trace_preemption.json
@@ -0,0 +1,22 @@
+[
+ {
+ "applicationID": "sc-app-low",
+ "allocationKey": "low-1",
+ "nodeID": "sc-node:1"
+ },
+ {
+ "applicationID": "sc-app-low",
+ "allocationKey": "low-2",
+ "nodeID": "sc-node:1"
+ },
+ {
+ "applicationID": "sc-app-low",
+ "allocationKey": "low-2",
+ "terminationType": "PREEMPTED_BY_SCHEDULER"
+ },
+ {
+ "applicationID": "sc-app-high",
+ "allocationKey": "high-1",
+ "nodeID": "sc-node:1"
+ }
+]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]