39 KiB
Design Document
Overview
Purpose: This feature delivers a single, shared implementation of structural equality for the scheduler's allocation and pending-reason data structures, published as public libxce API, that can explain why two objects differ, together with a reproducible allocation start time (from) so that from can take part in equality.
Users: Scheduler engineers (today: --sim-verify replay checking and three scheduler test files) and, in future, controller engineers (persist.c, resync.c, job.c already carry these structs) who need to ask "do these two objects represent the same thing, and if not, where do they differ".
Impact:
- Moves the three
staticallocation comparators out ofsrc/scheduler/src/sim_verify.cintosrc/lib/xcealloc.c.alloc_eqnow comparesfromand excludes onlyinstid. - Adds a new full-structural-match pending-reason comparator to
src/lib/preason.c. No full-match comparator exists today; bug-1845 narrowed the simulator's to main-reason-only. - Gives every comparator an optional
char** whyout-parameter that returns a newly allocated description of the first difference, owned by the caller.sim_log_mismatchprints it (resolves bug-1881). - Changes the scheduler to stamp
fromwith the scheduling session's start time, a single clock reading shared with the recordedbeginRound, instead oftime(NULL). That a replayed session starts at exactly the recordedbeginRoundcomes from the separate specscheduler-sim-discrete-clock(removes--sim-speed; the simulator clock moves only at recorded timestamps). - Switches the simulator and three white-box test consumers to the public API. The three test files' checks become stricter: full-match pending reasons (Decision 7) and
from-inclusive allocations. sim_decision_eqandsim_log_mismatchstay in the scheduler, andsim_decision_eq's PEND branch keeps a simulator-local main-reason-only comparison (bug-1845). No wire format or struct layout changes.
Goals
- Publish
alloc_eq,host_alloc_eq,job_alloc_eqinxcealloc.handjob_preason_eqinpreason.h, each with an optional first-difference description. - Make
job_preason_eqa full structural match (main reason + host-based reasons + resource-based reasons, in order). - Make
fromreproducible under replay, then compare it. - Make
--sim-verifymismatch logs name the differing field and values. - Make the existing consumers call the public API instead of
sim_verify.c's privatestaticcomparators. (The three test files keep their white-box#include "../src/sim_verify.c": they also white-box-includesim_loop.c, which callssim_decision_eq/sim_log_mismatch.) - Keep
--sim-verifyfree of false mismatches for recordings made after this change. - Add libxce unit tests that pin the
instidexclusion,frominclusion, full-match preason, description, and NULL-handling semantics.
Non-Goals
- Do not move
sim_decision_eq,sim_log_mismatch, or thedescribe_*helpers (simulator-specific; depend on capnp/replay types andERROR()). - Do not change the simulator's PEND outcome check from main-reason-only to full-match — that would reintroduce bug-1845's false mismatches on cache-hit PENDs (see Decision 3).
- Do not introduce any new comparison call site beyond the existing four consumers.
- Do not touch the controller's persistence/resync code paths, or how the controller/worker interpret
from. - Do not change
make_alloc()'s signature or its owntime(NULL)default (controller and many tests call it); the scheduler overwritesfromafter construction (Decision 8). - Do not touch
merge_job_alloc()/same_alloc_slot()(bug-1863);alloc_eqneither replaces nor reusessame_alloc_slot(see Decision 6). (Their NULL-dereference defect, bug-1882, is already fixed inf9146864.) - Do not remove the test files' white-box
#include "../src/sim_verify.c". - Do not make recordings made before this change verify cleanly; their wall-clock
fromvalues are an accepted limitation. - Do not change the simulator's clock or
--sim-speed; that is specscheduler-sim-discrete-clock, which this spec depends on.
Boundary Commitments
This Spec Owns
- The public equality functions
alloc_eq,host_alloc_eq,job_alloc_eq(xcealloc.{c,h}) andjob_preason_eq(preason.{c,h}), their equality contracts and their difference-description format. - The shared NULL-safe string-equality helper both modules need (see Decision 5).
- The scheduler's stamping of
from(sched_make_primary_alloc_()in scheduler.c, from the session core) and the single normal-mode session-start reading shared bycore->session_start, the recordedbeginRound, andfrom(driver.c). - The migration of
sim_verify.cand the three white-box test files onto those functions, and the difference text insim_log_mismatch. - libxce unit tests for the new functions (
test_xcealloc.c,test_preason.c) and scheduler tests forfromstamping.
Out of Boundary
- The decision-level comparison and mismatch reporting structure that remain in
sim_verify.c, including the main-reason-only PEND outcome check (simulator-local). - The
.replaywire format and thereplay_decision_t/xce_decision_ttypes. - Any change to the controller's consumption of these structs.
merge_job_alloc()and itssame_alloc_slot()slot-matching helper inxcealloc.c.- The simulator clock and
--sim-speed(specscheduler-sim-discrete-clock).
Allowed Dependencies
src/include/xce/types.h(the struct definitions — unchanged).xcealloc.c/xcealloc.h,preason.c/preason.h, andutils.{c,h}(the modules that already own construction of these structs, plus the shared string helper).string.h(strcmp) andstdio.h(vasprintf, a GNU extension:xcealloc.c/preason.cdefine_GNU_SOURCEbefore their first include, asclient_failover.c:14does) for comparisons and descriptions.- Scheduler:
driver.c,scheduler.c,sim_verify.c,sim_loop.c(thesim_decision_eq/sim_log_mismatchcall sites only). - Spec
scheduler-sim-discrete-clock: after it, a replayed session'score->session_startequals the recordedbeginRoundexactly.
Revalidation Triggers
- A change to the
xce_alloc_t/xce_host_alloc_t/xce_job_alloc_t/xce_job_preason_tstruct layouts must re-check the field lists, the exclusion documentation, and the description text. - A change to
same_alloc_slot()'s matching rule, or a proposal to merge it withalloc_eq, must re-check Decision 6 (the two answer different questions and disagree oninstid,from, andvalue). - A change to the pending-reason construction paths (cache-hit vs. full-computation) that alters whether detail lists are populated must re-check the simulator's main-reason-only PEND rationale (bug-1845) and the full-match
job_preason_eqcontract. - Any new allocation path that does not go through
sched_make_primary_alloc_(), a second clock read between session start and recording, or a change toscheduler-sim-discrete-clock's clock guarantee, must re-check Decision 8.
Architecture
Existing Architecture Analysis
src/scheduler/src/sim_verify.c (474 lines) owns two layers:
- Type-level comparators — five
staticfunctions (sim_verify_str_eq,sim_verify_alloc_eq,sim_verify_host_alloc_eq,sim_verify_job_alloc_eq,sim_verify_job_preason_eq) that walk two libxce structs field-by-field and return only 0/1. They have zero dependency on the scheduler, capnp, or the replay layer.sim_verify_alloc_eqexcludesfrom(bug-1838-followup) andinstid(bug-1844).sim_verify_job_preason_eqcompares onlymain_reason(bug-1845), a simulator replay asymmetry rather than an object-identity property. - Decision-level dispatcher + reporting —
sim_decision_eq(dispatches onxce_decision_t.opvsReplayDecision_body_which),sim_log_mismatch, and twodescribe_*helpers. These depend onreplay_decision_t, capnp enums, andERROR().sim_log_mismatchprints only jobid/nhosts for each side, so a mismatch below that level is invisible (bug-1881).
The four allocation/pending-reason structs are defined in src/include/xce/types.h and already constructed/deep-copied by src/lib/xcealloc.c (make_alloc, make_host_alloc, make_job_alloc, dup_job_alloc) and src/lib/preason.c (add_job_preason, make_pendreason). The controller consumes these same structs in persist.c, resync.c, and job.c with no equality API available.
Since this design was first drafted, bug-1863 (aea34738) added merge_job_alloc() to xcealloc.c with a private same_alloc_slot() helper that matches two xce_alloc_t records by resname, instid, and reserve, to decide whether they are the same reservation slot to merge. It is a near neighbour of alloc_eq but has a different contract (see Decision 6).
How from is set today. make_alloc() (xcealloc.c) stamps from = time(NULL). The scheduler's only production constructor call is in sched_make_allocs() (scheduler.c), called by the seqjob/parjob allocators' my_alloc() inside a scheduling session. from is on the wire (controller.capnp from @4: UInt64 # alloc start time) and dup_alloc() copies it. Neither the controller nor the worker reads an allocation's from today.
Which core sched_make_allocs() sees. Its first argument is plugin->sched. In normal mode that is the real scheduler, whose core runs the sessions. In simulation it is not: sim_alloc.c (sim_bootstrap_seqjob_allocator()/..._parjob_...) hands the allocators a throwaway xce_scheduler_t wrapper with its own xce_sched_core_init()'d core, aliasing only resmgr; that core never begins a session, so its session_start is 0. The honest session core is the core argument threaded through sched_make_allocation() → sched_make_primary_alloc_(core, ctx) (sim->core in simulation, sched->core in normal mode). Every allocation — PRIMARY, each primary of a COMPOUND, each alternative of an ALTER — is produced by one sched_make_primary_alloc_() call.
How session time is read today. Two separate clock reads per session:
xce_core_begin_session()setscore->session_start = xce_core_now(core)(core_session.c:429).- The recording then reads the clock again for
beginRound:driver.c:1030afterplugin_begin_session/sched_account_jobs, andsim_loop.cbeforexce_core_begin_session.
In simulation, sim_advance_virtual_clock(sim, begin_round) re-anchors the virtual clock to the recorded beginRound. At --sim-speed ≥ 1 the clock keeps advancing from there, so session_start lands slightly after begin_round. At --sim-speed 0 it is frozen and exact. The simulator never sleeps, so --sim-speed paces nothing; it only adds drift. Spec scheduler-sim-discrete-clock removes it, which makes session_start == begin_round in every replay.
Three scheduler test files already reuse two of the leaf comparators — but via white-box #include "../src/sim_verify.c" + direct calls to the static functions: test_sim_equivalence.c (lines 894, 902), test_repl_equivalence.c (lines 734, 742), and test_repl_limit_table.c (lines 2662, 2670).
Architecture Pattern & Boundary Map
flowchart LR
subgraph scheduler
SV["sim_verify.c\n(sim_decision_eq, sim_log_mismatch, describe_*,\nmain-reason-only PEND outcome check)"]
DR["driver.c\nbeginRound = core.session_start"]
SC["scheduler.c\nalloc->from = session_start / 1e9"]
CS["core_session.c\nsession_start"]
DC["spec scheduler-sim-discrete-clock\nreplayed session_start == recorded beginRound"]
TE["test_sim_equivalence.c / test_repl_equivalence.c / test_repl_limit_table.c"]
TSV["test_sim_verify.c"]
end
subgraph libxce
XA["xcealloc.{c,h}\nalloc_eq / host_alloc_eq / job_alloc_eq (+why)"]
PR["preason.{c,h}\njob_preason_eq (full match, +why)"]
UT["utils.{c,h}\nstr_eq (NULL-safe)"]
end
SV --> XA
TE --> XA
TE --> PR
XA --> UT
PR --> UT
DR --> CS
SC --> CS
DC -.-> CS
- Domain boundary: the library owns struct-level equality and its explanation (full structural match); the simulator owns decision-level comparison and reporting, including its one deliberate loosening (main-reason-only PEND). The scheduler core owns the session-start value that makes
fromreproducible. - Steering compliance: no changes to
types.hlayouts or the wire schema;fromkeeps its unit and meaning ("alloc start time"), now defined as the start of the deciding session. Persistence:fromalready round-trips through the snapshot (dup_job_alloc) and the controller's capnp encoding, so no new save/restore field is introduced.
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| Language | C (gnu17) | equality functions, from stamping, tests |
unchanged |
| Build | Meson via xce-build |
compile libxce + scheduler, run src/lib/testsuite and src/scheduler/testsuite |
unchanged |
| Test | Unity | test_xcealloc.c, test_preason.c, scheduler tests |
existing harnesses |
File Structure Plan
Modified Files
src/lib/utils.c/src/include/utils.h— add a NULL-safestr_eq(const char*, const char*)helper (see Decision 5).src/lib/xcealloc.c/src/include/xcealloc.h— addalloc_eq,host_alloc_eq,job_alloc_eqwith the optionalchar** whydescription.src/lib/preason.c/src/include/preason.h— addjob_preason_eq(full match) with the optional description.src/scheduler/src/driver.c— recordbeginRoundfromsched->core.session_startinstead of a secondxce_core_now()read.src/scheduler/src/scheduler.c— insched_make_primary_alloc_(core, ctx), when the allocator returnsrc > 0withctx->alloc != NULL, setfrom = core->session_start / 1000000000ULLon everyxce_alloc_tinctx->alloc(per-host and shared). Not insched_make_allocs(): in simulation itsschedissim_alloc.c's wrapper, whose core has no session.src/scheduler/src/sim_verify.c— delete the fivestaticcomparators;sim_decision_eqgains an optionalchar** whyand callsjob_alloc_eqfor DISPATCH; keep a simulator-localstaticmain-reason-only helper for PEND;sim_log_mismatchgains aconst char* whyparameter and appends it. Update the header comments that describefromas excluded.src/scheduler/src/sim_loop.c— update bothexterndeclarations; in the per-decision loop callsim_decision_eq(expected, d, &why), passwhytosim_log_mismatch, thenfree(why); the leftover-recorded loop (recorded decisions with no live counterpart) passes the string literal"no live decision", which it does not free.src/lib/testsuite/test_xcealloc.c,src/lib/testsuite/test_preason.c— unit tests.src/scheduler/testsuite/test_sim_equivalence.c,test_repl_equivalence.c,test_repl_limit_table.c— replace direct calls withjob_alloc_eq(..., NULL)/job_preason_eq(..., NULL)(or take the description, print it on failure, and free it); add#include "xcealloc.h"/#include "preason.h"; comment at eachjob_preason_eqcall why full match is valid (Decision 7); keep the white-box#include "../src/sim_verify.c".src/scheduler/testsuite/test_sim_verify.c— add thewhyargument (NULL) to its 16 directsim_decision_eq()calls; drop the DISPATCH alloc-level tests now owned by libxce; inverttest_dispatch_decisions_differing_from_timestamp_still_match(afromdifference is now a mismatch, andwhynamesfrom); add a test that the mismatch description names the differing field; keep PEND/decision-level and integration tests.- Hand-built recorded DISPATCH fixtures that
--sim-verifyor an equivalence test compares against a live replay — they build the recorded allocation withmake_alloc()(from = time(NULL)) or a literalfrom, which no longer matches the replay'ssession_start / 1e9: set the recordedfromto the fixture session'sbegin_round / 1000000000. Audit every test with a hand-builtReplayDecision_body_dispatch:test_sim_live_allocation.c(themake_alloc()fixture near line 726,begin_round = 2000→from = 0),test_sim_contention_parity.c,test_sim_resource_contention.c,test_sim_round_boundary_reconciliation.c,test_sim_reconcile_unaccounted.c,test_sim_verify.c; fixtures that are never compared against a live allocation need no change. - A scheduler test (new or in an existing session/allocation test file) — allocations made in one session carry
session_start / 1e9asfrom, including in simulation (thesim_alloc.cwrapper path), and the recordedbeginRoundequalssession_start. src/scheduler/testsuite/test_sim_live_allocation.c,test_sim_reconcile_unaccounted.c, and the header comments oftest_sim_equivalence.c/test_repl_equivalence.c— update comments that namesim_verify_*_eqor describefromas excluded.
System Flows
flowchart TD
A["sim_decision_eq(expected, actual, why)"] --> B{"actual->op"}
B -->|DISPATCH| C["job_alloc_eq(expected alloc, actual alloc, why)"]
B -->|PEND| D["jobid equal AND main_reason equal (sim-local, NOT job_preason_eq); why names which"]
B -->|PREEMPT| E["log + report mismatch (unchanged)"]
C --> F["host_alloc_eq per host"] --> G["alloc_eq per resource (incl. from, excl. instid)"]
D --> H["main-reason only, detail ignored (bug-1845)"]
C --> M["sim_loop: sim_log_mismatch(..., why); free(why)"]
D --> M
from stamping per session:
sequenceDiagram
participant Drv as driver.c
participant Core as core_session.c
participant Sch as scheduler.c sched_make_primary_alloc_(core, ctx)
participant Rec as recording
Drv->>Core: xce_core_begin_session()
Core-->>Core: session_start = xce_core_now()<br/>(replay: == recorded beginRound, via scheduler-sim-discrete-clock)
Drv->>Rec: beginRound = session_start
Sch->>Sch: allocator builds ctx->alloc (make_alloc: from = time(NULL))
Sch->>Sch: every alloc in ctx->alloc: from = core->session_start / 1e9
Requirements Traceability
| Requirement | Summary | Components | Interfaces |
|---|---|---|---|
| 1.1–1.3 | allocation equality for xce_alloc_t/xce_host_alloc_t/xce_job_alloc_t |
xcealloc.c |
alloc_eq, host_alloc_eq, job_alloc_eq |
| 1.4 | exclude instid only; document difference from merge_job_alloc |
xcealloc.c/.h |
alloc_eq field list + header comment |
| 1.5–1.7 | NULL handling, malformed-record precondition, read-only | all four functions | const params |
| 2.1–2.3 | pending-reason full structural equality | preason.c |
job_preason_eq |
| 3.1–3.6 | first-difference description, caller-owned | all four functions | char** why |
| 4.1–4.2, 4.4–4.5 | reproducible from |
driver.c, scheduler.c sched_make_primary_alloc_() |
core->session_start |
| 4.3 | replayed session start == recorded beginRound |
spec scheduler-sim-discrete-clock |
— |
| 5.1 | simulator uses libxce allocation comparison | sim_verify.c |
job_alloc_eq |
| 5.2 | simulator-local main-reason-only PEND check | sim_verify.c |
local static helper |
| 5.3 | mismatch log names the difference (bug-1881) | sim_verify.c, sim_loop.c |
sim_decision_eq(..., why), sim_log_mismatch(..., why) |
| 5.4 | zero-mismatch self-replay | integration tests | — |
| 6.1–6.3 | single shared implementation; test files use full-match preason; include stays | sim_verify.c + 3 test files |
public API |
| 6.4–6.6 | libxce + scheduler tests, no other behavior change | test files | — |
| 6.7 | stale comment references updated | scheduler sources/tests | — |
Components and Interfaces
libxce
Allocation equality (xcealloc.c)
| Field | Detail |
|---|---|
| Intent | Public structural equality for the allocation structs, with an optional first-difference description |
| Requirements | 1.1–1.7, 3.1–3.6 |
/* src/include/xcealloc.h */
extern int alloc_eq(const xce_alloc_t* a, const xce_alloc_t* b, char** why);
extern int host_alloc_eq(const xce_host_alloc_t* a, const xce_host_alloc_t* b,
char** why);
extern int job_alloc_eq(const xce_job_alloc_t* a, const xce_job_alloc_t* b,
char** why);
- Preconditions: a non-zero entry count implies a non-NULL entry array (a malformed record is not handled). NULL top-level arguments, NULL list entries, and NULL string fields are handled.
whymay be NULL;*whyon entry is ignored (never read or freed). - Postconditions: returns non-zero iff the two arguments are equal per the documented contract; does not mutate either argument. If
why != NULL: on equal,*why = NULL; on not-equal,*whyis amalloc'd, NUL-terminated description of the first difference (see Data Models), sized by the function and never truncated; the caller owns it and releases it withfree(). If that allocation fails,*why = NULLand the return value is unaffected. - Invariants:
alloc_eq(a, b)answers "same allocation decision", comparesfrom, and ignoresinstid; the header comment states that this differs frommerge_job_alloc()'s slot matching, whereinstidis significant;host_alloc_eq/job_alloc_eqare order-sensitive (both sides come from the same deterministic construction, so array order is expected to match, not just multiset). The result never depends onwhyor on whether the description could be allocated.
Pending-reason equality (preason.c)
| Field | Detail |
|---|---|
| Intent | Public full structural equality for xce_job_preason_t, with an optional first-difference description |
| Requirements | 2.1–2.3, 3.1–3.6 |
/* src/include/preason.h */
extern int job_preason_eq(const xce_job_preason_t* a,
const xce_job_preason_t* b,
char** why);
- Postconditions: returns non-zero iff
main_reasonmatches, the host-based reason list matches in order (each entry by host name + reason), and the resource-based reason list matches in order (each entry by resource name + host name + reason); NULL-safe per the shared convention;whyas for the allocation functions. - Invariants: this is a full match — it is the honest "are these two pending reasons identical" predicate, not the simulator's replay loosening (see Decision 3).
Shared string helper (utils.c)
/* src/include/utils.h */
extern int str_eq(const char* a, const char* b);
- Postconditions: returns non-zero iff
aandbare both NULL, or both non-NULL andstrcmp(a, b) == 0; zero otherwise.
Scheduler
Recording (driver.c)
- Normal mode:
sched_record_session_begin(sched, sched->core.session_start)replaces the separatexce_core_now()read, so the recordedbeginRoundis the session start. - Simulation: no change here. Spec
scheduler-sim-discrete-clockguaranteescore->session_start == begin_roundfor a replayed session (clock frozen between recorded timestamps).
from stamping (scheduler.c)
- In
sched_make_primary_alloc_(core, ctx), after the allocator returnsrc > 0: a smallstatichelper walksctx->alloc(everyallocs[i]->allocs[j]and everyallocs_shared[k], skipping NULL entries) and setsfrom = core->session_start / 1000000000ULL(seconds, floor). A COMPOUND's primaries are each stamped beforemerge_job_alloc()folds them, and all carry the same session value, so the merged allocation is consistent. - Why here and not in
sched_make_allocs():coreis the session core in both modes;sched_make_allocs()'sschedissim_alloc.c's session-less wrapper in simulation.
Simulator verification (sim_verify.c)
int sim_decision_eq(const replay_decision_t* expected, const xce_decision_t* actual, char** why): DISPATCH callsjob_alloc_eq(..., why); PEND sets*whytojobid A != Bormain_reason A != B; kind mismatch setskind DISPATCH != PEND; a NULL side setsno recorded decision at this index/no live decision; PREEMPT setsPREEMPT comparison not supported. Same ownership contract as the libxce functions.void sim_log_mismatch(uint64_t begin_round, int decision_index, const replay_decision_t* expected, const xce_decision_t* actual, const char* why): appends-- diff: <why>to its existing line whenwhy != NULL, otherwise the line is unchanged. It does not compare again and does not freewhy;sim_loop.c, which already callssim_decision_eq()right before it, owns and frees the string.
Data Models
No struct is added or modified. The equality contract and the description format are the only new "models".
alloc_eqcompares, in order:resname,reserve,value(exact==, documented bit-exact-safe because both sides are exact copies of the samedouble),duration,from. Excluded:instid(per-processnextid++counter).host_alloc_eqcompares:host, thennallocs, thenallocs[i]in order.job_alloc_eqcompares:jobid,nhosts,nres_shared, thenallocs[i]in order, thenallocs_shared[i]in order.job_preason_eqcompares:main_reason; thennhost_reasonsandhost_based_reasons[i]in order, each entry byhostandreason; thennres_reasonsandres_based_reasons[i]in order, each entry byresource,host, andreason. Nothing is excluded.- Description format (first difference only, path from the outermost struct compared):
- scalar:
allocs[1](host=h1).allocs[0](resname=mem).value: 2 != 3 - string:
allocs[0](host=h0).host: "h0" != "h2" - NULL:
allocs_shared[0]: NULL != non-NULL - length:
nhosts: 1 != 2 - path labels (
host=,resname=) are taken from the first argument (a), or frombwhena's entry or string is NULL, or?when both are. jobidformatted with libxce's ownfmt_jobid()(utils.h), so it reads like the rest of the scheduler log;fromas integer seconds;valuewith%.17gso an exact-compare difference is always visible.
- scalar:
Error Handling
These are read-only functions with no I/O and no failure path for the comparison itself. The only allocation is the optional description; if it fails, *why is NULL and the result is unchanged (vasprintf leaves its output undefined on failure, so the implementation sets *why = NULL explicitly). A nested call's description is re-wrapped with the parent's path segment and the inner string freed. The only "error" case is NULL input, which is handled by the shared convention (identical pointers including both-NULL → equal; exactly one NULL → not-equal) rather than an error return. A malformed record (non-zero count, NULL array) is a documented precondition violation, not a handled case. No logging is added; the description is built with vasprintf (glibc; _GNU_SOURCE defined at the top of xcealloc.c/preason.c) and returned to the caller.
Testing Strategy
- Unit (libxce):
test_xcealloc.c— match/mismatch on each compared field with its expected description,instidignored,fromcompared, ordered-list sensitivity, list-length mismatch, NULL handling at top level, nested entries, and string fields, NULLwhy,*why == NULLon equal, a failed description allocation (--wraponvasprintformallocin a dedicated test binary:*whyNULL, same result); every test frees the returned description, so ASAN/LeakSanitizer proves ownership; an existingmerge_job_alloctest still passes.test_preason.c— full-match: main-reason, host-based and resource-based lists per field + order + length, NULL handling, description cases; plusstr_eqNULL/NULL and NULL/non-NULL. - Unit (scheduler):
test_sim_verify.ckeepssim_decision_eq-level tests (DISPATCH/PEND match and mismatch, kind mismatch, PREEMPT always-false, NULL defense), asserts the description for each, and drops the alloc-level tests now owned by libxce. A session-level test asserts every allocation of one session (including a COMPOUND merge and the simulation wrapper path) carriessession_start / 1e9, and that the recordedbeginRoundequalssession_start. - Integration (scheduler):
test_sim_verify.c's--sim-verifyround-trips (zero-mismatch self-replay, exact-mismatch altered replay, reconcile cases); the self-replay reports zero mismatches. These tests, andtest_sim_equivalence.c/test_repl_equivalence.c/test_repl_limit_table.c/test_sim_live_allocation.c, already run the simulator atsim_speed = 0(clock frozen), sosession_start == begin_roundholds for them beforescheduler-sim-discrete-clocklands; only the live--sim-verifycheck at the default speed waits for it. - Regression:
test_sim_equivalence.c,test_repl_equivalence.c,test_repl_limit_table.cstay green under the stricter checks (full-match preason,fromcompared); the hand-built recorded DISPATCH fixtures listed in the File Structure Plan are updated to carry the session'sfrom. A failure after that is investigated as a real divergence. - Live: record a session on a local cluster, replay it with
--sim-verifyat the default speed: zero mismatches.
Design Decisions
Decision 1: Co-locate equality with construction
- Context: where to put the four functions.
- Alternatives: a single new
xce_equality.{c,h}module; or one function per struct's existing owner module. - Selected: co-locate —
allocfamily inxcealloc.c,job_preason_eqinpreason.c. - Rationale: matches the existing precedent (
dup_job_alloclives besidemake_*inxcealloc.c;add_job_preason/make_pendreasoninpreason.c); avoids a new module for four small functions. - Trade-offs: none material.
Decision 2: Naming (alloc_eq, not xce_alloc_eq)
- Context: public API names.
- Alternatives:
xce_-prefixed names;_equalssuffix. - Selected:
alloc_eq/host_alloc_eq/job_alloc_eq/job_preason_eq. - Rationale: follows the existing unprefixed lowercase convention (
make_alloc,dup_job_alloc,erase_job_preason), with the_eqsuffix as the standard C equality idiom. Thejob_alloc/job_preasonstems match the struct names. - Trade-offs:
alloc_eqis slightly generic, but it is unambiguous in a header (xcealloc.h) that already owns the unprefixedallocfamily.
Decision 3: job_preason_eq is a full structural match; the simulator keeps its own main-reason-only PEND check
- Context: the current
sim_verify_job_preason_eqcompares onlymain_reason, deliberately (bug-1845): the simulator has no alloc cache, so its full-detail PEND can never match a recorded cache-hit's main-reason-only shape, and requiring detail to match produced systematic false mismatches. That narrowing is a property of recorded-vs-live replay comparison, not of "are these two pending-reason objects identical". - Alternatives: (a) publish a full-deep
job_preason_eqand keep the main-reason-only comparison as a simulator-local static helper (selected); (b) publish main-reason-onlyjob_preason_eq(rejected — leaks a replay artifact into a general-purpose equality API and contradicts the identity semantics a controller engineer would expect). - Selected:
job_preason_eq(a, b, why)returns non-zero iffmain_reasonmatches and both detail lists match in order, field-for-field. The simulator'ssim_decision_eqPEND branch keeps a smallstaticmain-reason-only helper (documented with the bug-1845 rationale) instead of callingjob_preason_eq. - Rationale: a public "same pending reason" predicate must be identity-honest — a controller comparing two pending reasons will want the detail to matter. The replay asymmetry is a scheduler-only concern and stays where it belongs.
- Trade-offs: two PEND-comparison shapes now exist (full-match in libxce, main-reason-only in sim_verify.c). This is intentional: they answer different questions ("identical objects" vs. "same replay outcome"). The sim-local helper is
staticand small, so the duplication is negligible and clearly scoped.
Decision 4: Preserve NULL and order semantics
- Context: NULL handling and list-order sensitivity.
- Selected: keep today's behavior — identical pointer (including both NULL) → equal; exactly one NULL → not-equal; ordered (not multiset) list comparison. A malformed record (non-zero count, NULL array) is not handled — it is a documented precondition, matching today's comparators.
- Rationale: both sides of every current comparison come from the same deterministic construction, so order-sensitivity is correct. The new full-match
job_preason_equses the same convention.
Decision 5: Promote the NULL-safe string comparison to a shared str_eq in utils.{c,h}
- Context: with
job_preason_eqnow a full match, bothxcealloc.c(host/resname/reserve strings) andpreason.c(host/resource strings) need NULL-safe string comparison. Keeping itstaticin one file forces the other to duplicate it. - Alternatives: (a) shared
str_eqinutils.{c,h}(selected); (b) duplicate astatichelper in each of the two files. - Selected: add
int str_eq(const char* a, const char* b)toutils.{c,h}, beside the existingfloat_equalhelper. - Rationale: two consumers and a third on the horizon (controller) justify a shared helper.
utils.halready centralizes small generic C helpers (fmt_jobid,float_equal). - Trade-offs: a new public symbol in
utils.h; negligible surface.
Decision 6: alloc_eq and same_alloc_slot stay separate
- Context: bug-1863 added a private
same_alloc_slot(a, b)toxcealloc.c(resname+instid+reserve), used bymerge_job_alloc()to decide whether a source allocation should be added into an existing destination entry.alloc_eqwill live in the same file and also comparesxce_alloc_trecords. - Alternatives: (a) keep them separate and document the difference (selected); (b) implement
same_alloc_sloton top ofalloc_eqor vice versa. - Selected: keep them separate.
alloc_eqanswers "do these two records describe the same allocation decision" (resname, reserve, value, duration, from;instidexcluded).same_alloc_slotanswers "are these the same reservation slot to accumulate into" (resname, instid, reserve;valueexcluded because merging adds values). Thealloc_eqheader comment names the difference. - Rationale: the two predicates disagree on
instid,value,duration, andfrom, so neither can be expressed through the other without changing behavior.merge_job_alloc()is out of scope. - Follow-up: none. bug-1882 (a NULL
resnamereachingsame_alloc_slot()'sstrcmp()after a failedstrdup()) is fixed inf9146864:make_alloc()/make_host_alloc()no longer return records with NULL strings.
Decision 7: The three test files adopt the full-match job_preason_eq
- Context:
test_sim_equivalence.c,test_repl_equivalence.c, andtest_repl_limit_table.ccompare two decodedreplay_decision_tentries and today call the main-reason-onlysim_verify_job_preason_eq. - Alternatives: (a) switch them to the full-match
job_preason_eq(selected); (b) give them their own main-reason-only helper. - Selected: (a). None of these tests registers
unsatisfiable_lookup_cb(the alloc cache), so both sides of every pending-reason comparison are produced by the same full per-host computation and their detail lists are expected to match. The bug-1845 asymmetry (recorded cache-hit vs. live full detail) does not apply. - Rationale: the stricter check catches detail divergence the old one missed, at no false-positive cost for these fixtures. Each call site gets a short comment stating why full match is valid there.
- Trade-offs: if a future test records with the alloc cache active, it must use a main-reason-only comparison instead; the call-site comment and the Revalidation Trigger on pending-reason construction paths cover this.
Decision 8: from is the deciding session's start, from one clock reading
- Context:
alloc_eqmust comparefrom, butmake_alloc()stampstime(NULL), which a replay cannot reproduce (bug-1838-followup). The scheduler already has a virtualized session start (core->session_start), but it is a different clock read from the recordedbeginRound, and in simulation at--sim-speed≥ 1 it drifts past the recordedbeginRound. - Alternatives: (a)
from = session_start / 1e9, with the recordedbeginRoundtaken fromsession_start; the simulator clock stops drifting (specscheduler-sim-discrete-clock) so a replayedsession_startis exactly the recordedbeginRound(selected); (a′) as (a), but with an explicitxce_core_begin_session_at(core, begin_round)in the simulator instead of removing the drift (superseded: the user chose to remove--sim-speed, which fixes the drift for every clock reader, not justfrom); (b) keep excludingfromfrom equality (rejected by the user:fromis part of what was decided); (c)from = xce_core_now()at allocation time (rejected: at--sim-speed≥ 1 it drifts with replay pace, and a second-boundary crossing produces a false mismatch); (d) changemake_alloc()'s signature to takefrom(rejected: churns the controller and ~20 test callers for no benefit; the scheduler is the only producer that needs a different value). - Selected: (a). The recording's
beginRoundis taken fromcore->session_start;sched_make_primary_alloc_(core, ctx)overwritesfromon the allocator's result using the session core (notsched_make_allocs(), whoseschedis a session-less wrapper in simulation); the simulator side comes fromscheduler-sim-discrete-clock. - Rationale: every allocation of one session gets the same
from, which a replay reproduces bit-for-bit.fromchanges meaning only slightly — from "second the record was built" to "second the deciding session started", normally the same second and at most one session's duration earlier. - Trade-offs: hand-built test fixtures that stand in for a recording must now carry the session's
from. RecordedbeginRoundmoves a few milliseconds earlier (toxce_core_begin_session, beforeplugin_begin_session/sched_account_jobs). Recordings made before this change carry wall-clockfromvalues and may show a false DISPATCH mismatch when an allocation'stime(NULL)fell in a later second than its session start; the difference description namesfrom, so these are recognisable.make_alloc()keepstime(NULL)for its other callers.
Decision 9: First-difference description returned through char** why, owned by the caller
- Context: a yes/no comparator leaves a mismatch undiagnosable (bug-1881).
- Alternatives: (a) optional
char** why: the function allocates a string sized to fit and hands ownership to the caller (selected, per the user); (b) caller bufferchar* why, size_t whylen(superseded: the caller must guess a size, and a long path through many hosts gets truncated); (c) a separate*_diff()function family; (d) a list of every difference; (e) a callback per difference. - Selected: (a). One code path computes both the result and the explanation, so they cannot disagree; callers that do not care pass
NULL. On not-equal the function sets*whyto avasprintf'd string; on equal, or if the allocation fails,*why = NULL. The caller frees withfree(). - Rationale: the function knows the length; the caller only decides whether to ask. Allocation happens only on the not-equal path with
whyrequested, i.e. only when something is about to be reported, so the equal fast path stays allocation-free. The first difference is the one that matters for a deterministic replay; later ones usually follow from it. - Trade-offs: every caller that asks must free (ASAN/LeakSanitizer in the test suites catches a missed
free); only the first difference is reported.