tigerquoll opened a new pull request, #1110:
URL: https://github.com/apache/yunikorn-core/pull/1110
### Description
Test-only. **No product code is touched.**
The scheduler's existing tests assert individual outcomes — "this ask was
allocated", "this queue is
over its limit". None assert its output *as a whole*: who gets what, on
which node, in what order,
and what is handed back 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 lands.
This adds two tests that make that class of change visible. Both drive a
fixed workload through the
real `RMProxy`/partition API and pin the exact, ordered sequence of
decisions the RM observes via
`UpdateAllocation` — `(applicationID, allocationKey, nodeID)` per
allocation, `(applicationID,
allocationKey, terminationType)` per release — compared verbatim against a
golden JSON file.
#### What's added
| File | |
|---|---|
| `golden_trace_test.go` | `TestGoldenDecisionTrace` — one mixed workload +
shared helpers |
| `golden_trace_scenarios_test.go` | `TestGoldenDecisionTraceScenarios` — 6
isolated sub-tests |
| `testdata/golden_trace*.json` | 7 goldens (14 entries for the mixed
workload, 3–4 each for scenarios) |
| `mock_rm_callback_test.go` | additive: `traceEntry`, an opt-in `trace`
field, `enableTrace()`, `getTrace()`, `getNodeAllocations()` |
Capture is opt-in — the mock is shared with the whole package, including
`BenchmarkScheduling`, so
capturing unconditionally would put an append inside the callback's write
lock on a measured path.
Two words are used throughout, for two different things that both happen to
number six:
* **Phases** — the six *sequential* stages inside `TestGoldenDecisionTrace`.
One cluster, each phase
building on the state the last left, one 14-entry golden for all of them.
* **Scenarios** — the six *independent* sub-tests of
`TestGoldenDecisionTraceScenarios`. Each gets a
fresh cluster, its own config and its own small golden.
They are not the same six. `gang` and `preemption` name both a phase and a
scenario, deliberately —
see the duplication note below.
**The mixed workload's phases** are: priority ordering within an app,
pending-ask removal, gang
placeholder replacement, restart + recovery, app failure with pending asks,
and priority preemption.
The whole pinned trace is short enough to read:
```
p-high, p-mid, p-low -> golden-node-main:1
r-1, r-3, r-4 -> golden-node-main:1 (r-2 removed while
pending)
g-placeholder -> main:1 ; g-placeholder :: PLACEHOLDER_REPLACED ; g-real ->
main:1
p-recover-high -> golden-node-main:1 (p-recover-low
does not fit)
low-1, low-2 -> preempt:1 ; low-2 :: PREEMPTED_BY_SCHEDULER ; high-1 ->
preempt:1
```
Three of those six phases only pin anything because the workload is sized to
make them decide
something:
* **Pending-ask removal** — four asks at four distinct priorities, no
scheduling cycle before the
middle one (`r-2`) is removed. The golden pins that the survivors are
still served in priority
order and that the removal never reaches the RM.
* **Restart and recovery** — recovered usage 55/7 against a 105/10 node
leaves 50/3 free; the two
post-recovery asks are 30/1 each, so exactly one fits. Lost usage
allocates both, double-counted
usage neither.
* **Failed-application ask cleanup** — the node still has 20/2 free and the
failed app's asks are
10/1 each, so they *would* be allocated had cleanup not happened.
Those figures aren't comments: `node-main`'s capacity is computed from the
per-phase ask sizes and
each phase's headroom is asserted, so resizing an earlier ask fails with a
message naming what to
recompute rather than quietly hollowing out a later phase.
**The scenarios** each isolate a single decision:
| Scenario | What it pins |
|---|---|
| `app-ordering-fifo` / `app-ordering-fair` | Two apps tie on priority for
one remaining slot; the policy's second key decides (submission time vs usage
ratio). Pre-loaded so the policies pick different winners. |
| `node-binpacking` / `node-fair` | Four equal asks, two identical nodes.
Binpacking packs, fair alternates. Traces differ **only** in the node column. |
| `gang` | Placeholder → real handshake on an otherwise empty cluster. |
| `preemption` | Victim selection, release, and reschedule, on an otherwise
empty cluster. |
Policy pairs share an identical workload, so the diff between a pair *is*
the policy's effect.
The `gang` and `preemption` scenarios repeat choreography the mixed workload
also runs, rather than
sharing a helper with it. That duplication is the point: a shared helper
would move both traces
together on a mistake, and "the shared trace broke but the scenario didn't,
so it's prior-state
interaction" would stop being a conclusion anyone could draw.
#### Determinism
* Fixed app/node IDs, pinned strictly-increasing creation times on every ask.
* Scheduling advanced by explicit `MultiStepSchedule`, never the background
loop.
* Trace lengths asserted **exactly**, not "at least": a `>= n` barrier is
satisfied early by a
surplus decision, which then drifts into a later wait and presents as a
timeout instead of a diff.
* Every assertion is preceded by a quiescence round trip
(`traceProbe.sync`), because polling a
length cannot prove *absence*: where no new decision is expected, the
first evaluation already
succeeds. Costs nothing measurable — 7.9s → 7.7s.
* WARN-level logging, re-applied after each `ms.Init` (which resets it) and
restored from a snapshot
on cleanup, since it is process-global.
* Candidate lists come from Go map iteration, so each scenario avoids ties
in the comparator that
decides it (distinct submission times *and* usage for app-sort; node IDs
that order unambiguously
under `nodeRef.Less`'s `NodeID` fallback).
One exception: creation times are **not** pinned across the restart —
`NewSIFromAllocation` copies
neither `AllocationTags` nor `Priority`, so replayed allocations get
`time.Now()` and priority 0.
Nothing pinned depends on their order; they are already allocated, and
`tryAllocate` skips those.
#### Scope and limitations
These pin decision *sequences* for the behaviours above on small clusters.
They are **not** a general
conformance suite — a green run does not mean "scheduling is unchanged". Not
covered:
* **Reservations lifecycle** — reservation happens internally during
preemption, but reserve/unreserve
is never pinned and reservation timeout is never reached.
* **Ordering of recovered allocations among themselves** — the recovery
phase pins that recovered
*usage* is accounted for, not that recovered requests sort like live ones;
the recovery path loses
the createTime and priority that would make such an assertion meaningful.
* **The partition column** — single-partition workloads make it a constant
carrying the normalised
name, which would write the RM ID into every golden line.
* **User and group quota limits** — no config sets `limits:`; all apps run
as one user, no groups.
* **Required-node / daemonset asks** — no ask carries a required-node tag.
* **Unschedulable-ask backoff** — `backoffDeadline` is never armed; no queue
sets a backoff threshold.
* **Placeholder timeout and gang failure paths** — only the successful
placeholder→real replacement.
* **Guaranteed-resource-driven preemption and preemption fencing** — both
preemption scenarios are
priority-driven; no queue uses `FencePreemptionPolicy`.
* **Nested queue hierarchies** — every config is `root` plus one level of
leaves.
* **Multiple partitions** — everything runs on the single `default`
partition.
* **Custom resource types** — only `memory` and `vcore`.
* **Foreign allocations** — none registered.
* **Queue placement rules** — apps name their target queue directly.
* **The placeholder-replacement revert** in
`Application.tryPlaceholderAllocate` — reaching it needs
two goroutines racing on one placeholder within a cycle, which a
step-driven harness can't script
without the timing nondeterminism a golden test must not have.
#### Running and regenerating
```
go test ./pkg/scheduler/tests/ -run '^TestGoldenDecisionTrace' -count=1 #
both tests
```
Raise `-count` to check determinism. A mismatch prints a side-by-side
listing naming the golden file,
marking differing rows and, for the mixed workload, attributing each row to
the phase that produced
it. A phase that produces no decisions owns no row, so it is named at its
boundary instead.
Regenerate only when a change *intentionally* alters scheduling decisions:
```
# all seven goldens
UPDATE_GOLDEN=1 go test ./pkg/scheduler/tests/ -run
'^TestGoldenDecisionTrace' -count=1
# a single scenario
UPDATE_GOLDEN=1 go test ./pkg/scheduler/tests/ -run
'^TestGoldenDecisionTraceScenarios$/^node-fair$' -count=1
```
Under `UPDATE_GOLDEN` the length barriers report their divergence and carry
on rather than failing
ahead of the rewrite, so the run's log doubles as a summary of what changed;
the workload's own
preconditions still fail hard. The value is parsed as a boolean; an
unparseable one fails rather than
silently verifying.
**The run always ends RED** — a green one would be indistinguishable from a
run that verified
something. So:
1. `git diff pkg/scheduler/tests/testdata/` — that diff **is** the behaviour
change.
2. Account for every changed line; one you can't explain is a side-effect,
not noise.
3. Update the trace-length arguments the run reported, so the barriers pin
the new behaviour.
4. Re-run **without** `UPDATE_GOLDEN` and confirm green — the regenerating
run says nothing about
correctness.
5. Include the golden diff in the PR, so the new sequence is reviewed as a
behaviour change.
Only goldens matched by `-run` are rewritten, and on an unmodified tree they
come back byte-identical
— so a dirty `git status` afterwards always means a real delta.
### Type of change
- [x] Improvement
### Jira issue
Jira ID : https://issues.apache.org/jira/browse/YUNIKORN-3338
- [x] I have created a Jira issue for this pull request.
- [x] The Jira ID is part of the title of this pull request.
### AI Tooling
Generated by Author with assistance from Claude Code.
If an AI tool was used:
- [x] The PR includes the phrase "Generated by \<tool>", where \<tool> is
the name of the AI tool used.
- [x] My use of AI contributions follows the ASF legal policy.
Check https://www.apache.org/legal/generative-tooling.html for details.
### How has this been tested?
- [x] New unit tests were added to cover new or changed code paths.
- [x] `make test_all` was run, and no failures reported.
- [ ] A pull request will be opened for new e2e tests
(apache/yunikorn-k8shim repository). — not
applicable: no product code changes, so there is no new behaviour for e2e
to cover.
Beyond `make test_all`:
| Check | Result |
|---|---|
| `git diff origin/master --name-only` | 10 files, test and testdata only |
| `-run '^TestGoldenDecisionTrace' -count=10` | ok, 74.2s |
| `go test ./pkg/scheduler/tests/ -count=1` | ok, 48.3s |
| `go test ./pkg/scheduler/tests/ -race -count=1` | ok, 49.7s |
| `-bench BenchmarkScheduling -benchtime=1x` | ok, unaffected by trace
capture |
Adds ~7.3s to a package run: 41.0s without these tests, 48.3s with them.
A golden test that never fails is decoration, so each perturbation below was
applied, observed, and
reverted in full:
1. **Recovery replay skipped** → restart phase fails: node reports 105/10
free against a 50/3 ledger,
and both post-recovery asks allocate instead of one.
2. **`LessThan` priority flipped** → reverses phase 1 (`p-high`↔`p-low`) and
phase 2 (`r-1`↔`r-4`);
order-only, invisible to any length assertion.
3. **`LessThan` createTime flipped** → 5 of 6 scenarios fail with `a1..a4`
reversed; `gang` correctly
stays green, never holding two comparable pending asks.
4. **`nodeRef.Less` by descending score** → the node pair fails *by swapping
into each other's
golden*: binpacking spreads, fair packs.
5. **`sortApplicationsByPriorityAndSubmissionTime` reversed** → only
`app-ordering-fifo` fails
(`e-3`→`l-2` on the contested slot); `app-ordering-fair` correctly stays
green.
6. **Failure cleanup bypassed** → 3 decisions where 1 was pinned: both
abandoned asks allocate,
confirming the node genuinely had room.
7. **Regeneration, both directions** → with (1) applied, `UPDATE_GOLDEN`
rewrites the golden (md5
`2a62d152…`→`98240929…`, 14→16 entries) and still ends red; reverting (1)
restores it byte-for-byte.
### Questions:
- [ ] The change needs documentation, a pull request for
apache/yunikorn-site repository will be created.
- [ ] There is breaking changes for older versions: jira is tagged with
`release-notes` label.
- [ ] The licenses files needs to be updated.
None apply: the change is test-only, adds no user-facing behaviour, and
introduces no dependencies.
### Screenshots or other details
A mismatch names the golden file, marks differing rows with `!!`, and
attributes each row to the
phase that produced it (a phase producing no decisions is named at its
boundary instead):
```
scheduling decisions do not match the golden file.
golden file: testdata/golden_trace.json (14 entries)
this run: 14 entries
6 phase 3 gang placeholder replacement golden: alloc g-placeholder
... got: alloc g-placeholder ...
!! 7 phase 3 gang placeholder replacement golden: release g-placeholder
of ... got: release g-placeholder of golden-app-gang ...
9 phase 4 restart and recovery golden: alloc p-recover-high
... got: alloc p-recover-high ...
--- phase 5 failed application ask cleanup: no decisions ---
10 phase 6 preemption golden: alloc low-1 ...
got: alloc low-1 ...
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]