fix(scheduler): route sim reconciliation's on_dispatch() through the allocator thread (bug-1837)

sim_reconcile_unaccounted_jobs() (sim_loop.c) was calling
picker->picker_iface->on_dispatch() directly from the sim driver thread,
violating plugin.h's documented "allocator thread only" contract for
Post-allocation hooks. Dormant in practice only because builtin_fcfs.c
happens to lock internally around every hook -- a defensive measure the
contract text doesn't actually require of picker plugins.

Since scheduler-sim-live-allocation, sim mode spawns the same real
sched_job_picker()/sched_allocator() thread pair normal mode uses, so a
real allocator thread now exists to route through. Added a
reconcile_req/reconcile_ack cbuf pair on xce_sched_core_t and a reserved
XCE_SCHED_RECONCILE_WAKE sentinel pushed through jbuf to wake the
allocator thread without touching jbuf's pinned capacity-1/item-type
invariant (Req 5 AC 7). sched_allocator() services the request and calls
on_dispatch() itself; sim_reconcile_invoke_on_dispatch() blocks on the ack.
Falls back to a direct call when no allocator thread is live for a given
core (standalone unit tests), which is safe by construction.

TDD: new test spawns a stand-in allocator-relay thread and asserts
on_dispatch() fires there, not on the caller's thread -- confirmed RED
against the old direct-call code, GREEN after. Four other test files with
hand-rolled sched_allocator() stubs needed the same relay branch (found by
an actual hang at meson's timeout). Full scheduler suite: 85/85, zero
regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-09-21 12:58:57 +00:00
parent 94115b0024
commit 4f94638a1d
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
15 changed files with 541 additions and 67 deletions

View file

@ -25,6 +25,8 @@
**Correction (2026-09-21, bug-1838, superseding the 2026-09-11 revision above):** the 2026-09-11 widening (capture "regardless of the job's current state," including PEND) turned out to be the root cause of a live-observed over-dispatch: a 22-slot host took 44 concurrent single-slot jobs during replay (bug-1836), because a PEND job's recorded DISPATCH decision was force-applied by reconciliation with no capacity check against current resource state. bug-1836 first closed the immediate hole by adding a capacity gate (`sim_reconciled_alloc_fits_capacity()`) to reconciliation itself — but investigation into why a PEND job could ever reach this force-apply path at all found that the underlying justification for the 2026-09-11 widening no longer holds: the since-completed `scheduler-sim-live-allocation` spec gave simulation mode a genuine, capacity-checked live picker/allocator thread pair (`sched_job_picker()`/`sched_allocator()`, `sched_election.c`) that both drivers now spawn identically — "one core, two consumers" (`.kiro/steering/scheduler-driver-equivalence.md`) is now actually true for dispatch, not just aspirational. A freshly-PEND job now correctly reaches that SAME live capacity-checked path simulation mode itself runs, exactly like normal mode; reconciliation force-applying a stale recorded decision for it is redundant at best and a second, competing source of truth at worst. The trigger is therefore narrowed back to RUN-only (`JOB_IS_RUN(job->state)`, mutually excluding DONE/EXIT for free under this codebase's 4-state job model): reconciliation's remaining, genuinely necessary scope is a job already confirmed RUN (via a separately-recorded `JOB_STATE_CHANGE`/`JOB_UPDATE` event) whose allocation data was never captured — structurally invisible to the live picker, since `next_job()` never offers a RUN job. This does NOT reopen the 2026-09-11 revision's OTHER change (synchronous RUN-transition-at-capture for the jobs reconciliation still does handle) — that remains correct and unchanged; only the ELIGIBILITY condition for entering capture at all is revised. See `sim_reconcile_unaccounted_jobs()`'s own updated header comment (`sim_loop.c`) for the complete before/after rationale, and Requirement 1's own 2026-09-21 correction (requirements.md) for the corresponding Acceptance Criteria update.
**Correction (2026-09-21, bug-1837, user-directed):** the `on_dispatch()` call bug-1759/bug-1779's own correction above added (2026-09-07) called `picker_iface->on_dispatch()` directly from `sim_reconcile_unaccounted_jobs()`'s own caller thread (`sched_sim_driver_run()`'s driver thread) — but `plugin.h` documents `on_dispatch()`/`on_pend()` as "Post-allocation hooks (MUST) -- allocator thread, after each allocation attempt," a threading-contract violation an `/xce-audit-invariants scheduler` run flagged as dormant/inert (real only because `builtin_fcfs.c`'s own internal locking happened to make every call site safe, a defensive measure plugin.h's contract text does not actually require of picker plugins in general). Now that `scheduler-sim-live-allocation` gives simulation mode the SAME real `sched_job_picker()`/`sched_allocator()` thread pair normal mode spawns (see the 2026-09-21 bug-1838 correction above), a real allocator thread genuinely exists to route this call through instead of calling it directly: `sim_reconcile_invoke_on_dispatch()` (`sim_loop.c`) relays the request to `sched_allocator()` (`sched_election.c`) via two dedicated cbufs on `xce_sched_core_t` (`core.h`) -- `reconcile_req`/`reconcile_ack` -- waking it through a reserved jbuf sentinel (`XCE_SCHED_RECONCILE_WAKE`) rather than widening jbuf's own pinned item type/capacity (`test_core_jbuf_capacity_pin.c`, Req 5 AC 7). A standalone caller with no live allocator thread for its core (e.g. `test_sim_reconcile_unaccounted.c`'s own direct-call unit tests) still gets a direct call, safe by construction since only one thread exists to touch picker state in that case. This is a threading-mechanism correction only -- reconciliation's own trigger condition, capacity gate, and RUN-only eligibility (both corrections above) are unchanged.
## Boundary Commitments
### This Spec Owns

View file

@ -1,13 +1,13 @@
{
"version": 1,
"meta": {
"lastScanned": "2026-09-21T09:31:36.646Z",
"fileCount": 668,
"lastScanned": "2026-09-21T11:41:46.809Z",
"fileCount": 670,
"hits": 0,
"misses": 0,
"renderedHash": "56a6cca5dea3c105bb5d64b41019c5e07814ffd9ab069c9aab6f152e32fce1f2",
"storeUpdatedAt": "2026-09-21T09:31:36.655Z",
"rootHash": "cca4c863d5077424"
"renderedHash": "9704b1ab4907098decf513d0818bd0fb1c4f301de23b9de49ce6882672c6da25",
"storeUpdatedAt": "2026-09-21T11:41:46.817Z",
"rootHash": "e3297d48be0b28ab"
},
"files": {
".agent-shell/transcripts/2026-08-11-11-39-59.md": {
@ -3225,11 +3225,11 @@
},
".kiro/specs/scheduler-sim-eager-reconciliation/design.md": {
"description": "Design Document",
"tokens": 7078,
"hash": "388510e52bf7c634",
"size": 28419,
"mtimeMs": 1789980075150.255,
"updatedAt": "2026-09-21T08:41:15.194Z",
"tokens": 7534,
"hash": "00d77598996cc78b",
"size": 30247,
"mtimeMs": 1789990906771.3093,
"updatedAt": "2026-09-21T11:41:46.809Z",
"source": "hook"
},
".kiro/specs/scheduler-sim-eager-reconciliation/requirements.md": {
@ -9944,38 +9944,38 @@
},
"src/scheduler/include/sim.h": {
"description": "if !defined(_SIM_H_)",
"tokens": 4213,
"hash": "2832b81c98fbb84a",
"size": 15797,
"mtimeMs": 1789118690779.5962,
"updatedAt": "2026-09-11T09:24:50.824Z",
"tokens": 4579,
"hash": "ec049ebdf9d49f43",
"size": 17169,
"mtimeMs": 1789987410813.0005,
"updatedAt": "2026-09-21T10:43:30.855Z",
"source": "hook"
},
"src/scheduler/src/sim_loop.c": {
"description": "include <errno.h>",
"tokens": 22511,
"hash": "6bacd553e27e51b7",
"size": 84414,
"mtimeMs": 1789977028368.3345,
"updatedAt": "2026-09-21T07:50:28.417Z",
"tokens": 23299,
"hash": "667da1ed4512bed0",
"size": 87369,
"mtimeMs": 1789990380544.1218,
"updatedAt": "2026-09-21T11:33:00.598Z",
"source": "hook"
},
"src/scheduler/testsuite/test_sim_reconcile_unaccounted.c": {
"description": "Declares documents",
"tokens": 34635,
"hash": "ae87004ae3cee9a6",
"size": 129879,
"mtimeMs": 1789979940956.2974,
"updatedAt": "2026-09-21T08:39:00.991Z",
"tokens": 36877,
"hash": "6fdeab919a508b53",
"size": 138288,
"mtimeMs": 1789987855400.8535,
"updatedAt": "2026-09-21T10:50:55.452Z",
"source": "hook"
},
"src/scheduler/testsuite/test_sim_contention_parity.c": {
"description": "Declares calls",
"tokens": 21740,
"hash": "d824c6b350e69b1d",
"size": 81524,
"mtimeMs": 1789978567598.1646,
"updatedAt": "2026-09-21T08:16:07.642Z",
"tokens": 22061,
"hash": "2e6fd463398ac8c0",
"size": 82727,
"mtimeMs": 1789989606580.9124,
"updatedAt": "2026-09-21T11:20:06.629Z",
"source": "hook"
},
"reports/sep06-sep12-2026.md": {
@ -11566,11 +11566,11 @@
},
"src/scheduler/include/core.h": {
"description": "if !defined(_CORE_H_)",
"tokens": 11223,
"hash": "cc63291ebec75cc8",
"size": 42083,
"mtimeMs": 1789969624960.9233,
"updatedAt": "2026-09-21T05:47:05.008Z",
"tokens": 11793,
"hash": "6dac6d713ac66270",
"size": 44221,
"mtimeMs": 1789987351656.3103,
"updatedAt": "2026-09-21T10:42:31.689Z",
"source": "hook"
},
"src/scheduler/include/sched/alloc_cache.h": {
@ -11701,11 +11701,11 @@
},
"src/scheduler/testsuite/test_sim_e2e_original_motivation.c": {
"description": "",
"tokens": 18078,
"hash": "76a64665f421db65",
"size": 67790,
"mtimeMs": 1789733394844.816,
"updatedAt": "2026-09-18T12:09:54.894Z",
"tokens": 19514,
"hash": "1886f99720ba5ae9",
"size": 73176,
"mtimeMs": 1789989633059.8286,
"updatedAt": "2026-09-21T11:20:33.100Z",
"source": "hook"
},
"src/scheduler/testsuite/test_sim_resource_contention.c": {
@ -11764,11 +11764,11 @@
},
"src/scheduler/src/sched_election.c": {
"description": "Declares Core",
"tokens": 2964,
"hash": "0833041a4dd5a46e",
"size": 11113,
"mtimeMs": 1789750266742.442,
"updatedAt": "2026-09-18T16:51:06.779Z",
"tokens": 3249,
"hash": "6552587ac2699257",
"size": 12182,
"mtimeMs": 1789987391435.319,
"updatedAt": "2026-09-21T10:43:11.470Z",
"source": "hook"
},
"src/scheduler/testsuite/test_picker_core_loop_boundary.c": {
@ -12219,11 +12219,11 @@
},
"src/scheduler/testsuite/test_sim_verify.c": {
"description": "Declares mismatch",
"tokens": 18569,
"hash": "ce46afa8186ebac0",
"size": 69631,
"mtimeMs": 1789978226655.7988,
"updatedAt": "2026-09-21T08:10:26.695Z",
"tokens": 18890,
"hash": "222d4fed39c1fccc",
"size": 70834,
"mtimeMs": 1789989575780.3364,
"updatedAt": "2026-09-21T11:19:35.816Z",
"source": "hook"
},
"src/scheduler/src/sim_verify.c": {
@ -12234,6 +12234,24 @@
"mtimeMs": 1789983096595.9946,
"updatedAt": "2026-09-21T09:31:36.645Z",
"source": "hook"
},
"src/scheduler/src/core.c": {
"description": "include <errno.h>",
"tokens": 4495,
"hash": "428af28e7a5fd8ae",
"size": 16853,
"mtimeMs": 1789987377556.307,
"updatedAt": "2026-09-21T10:42:57.599Z",
"source": "hook"
},
"src/scheduler/testsuite/test_sim_round_boundary_reconciliation.c": {
"description": "",
"tokens": 9980,
"hash": "4bede92fa2d3e1a7",
"size": 37423,
"mtimeMs": 1789989591378.6973,
"updatedAt": "2026-09-21T11:19:51.419Z",
"source": "hook"
}
}
}

View file

@ -1,7 +1,7 @@
# anatomy.md
> Auto-maintained by OpenWolf. Last scanned: 2026-09-21T09:31:36.646Z
> Files: 668 tracked | Anatomy hits: 0 | Misses: 0
> Auto-maintained by OpenWolf. Last scanned: 2026-09-21T11:41:46.809Z
> Files: 670 tracked | Anatomy hits: 0 | Misses: 0
## ./
@ -541,7 +541,7 @@
## .kiro/specs/scheduler-sim-eager-reconciliation/
- `design.md` — Design Document (~7078 tok)
- `design.md` — Design Document (~7534 tok)
- `requirements.md` — Requirements Document (~3361 tok)
- `research.md` — Research & Design Decisions (~5166 tok)
- `spec.json` (~135 tok)
@ -957,10 +957,10 @@
## src/scheduler/include/
- `core.h` — if !defined(_CORE_H_) (~11223 tok)
- `core.h` — if !defined(_CORE_H_) (~11793 tok)
- `plugin.h` — if !defined(_PLUGIN_H_) (~5194 tok)
- `scheduler.h` — if !defined(_SCHEDULER_H_) (~6140 tok)
- `sim.h` — if !defined(_SIM_H_) (~4213 tok)
- `sim.h` — if !defined(_SIM_H_) (~4579 tok)
## src/scheduler/include/sched/
@ -972,6 +972,7 @@
- `alloc_cache.c` — Declares xce_cache_key_t (~3176 tok)
- `core_clock.c` — include <stdarg.h> (~3441 tok)
- `core_events.c` — include <errno.h> (~16984 tok)
- `core.c` — include <errno.h> (~4495 tok)
- `driver.c` — include <errno.h> (~11968 tok)
- `handler.c` — include <errno.h> (~5162 tok)
- `host_iter.c` — Declares entry (~1718 tok)
@ -983,12 +984,12 @@
- `repl_internal.h` — if !defined(_REPL_INTERNAL_H_) (~7304 tok)
- `repl.c` — include <ctype.h> (~7114 tok)
- `resource.c` — include <errno.h> (~9725 tok)
- `sched_election.c` — Declares Core (~2964 tok)
- `sched_election.c` — Declares Core (~3249 tok)
- `schedobj.c` — include <errno.h> (~5328 tok)
- `scheduler.c` — include <errno.h> (~17581 tok)
- `sim_alloc.c` — include <stdlib.h> (~2585 tok)
- `sim_limit.c` — include <stdlib.h> (~2102 tok)
- `sim_loop.c` — include <errno.h> (~22511 tok)
- `sim_loop.c` — include <errno.h> (~23299 tok)
- `sim_verify.c` — include <stdio.h> (~4694 tok)
- `sim.c` — include <errno.h> (~7432 tok)
@ -1028,16 +1029,17 @@
- `test_seqjob_rewrap_equivalence.c` — Declares char (~6448 tok)
- `test_sim_alloc_limit_fanout.c` — Declares char (~4716 tok)
- `test_sim_contention_limit_driver_parity.c` — Declares char (~6514 tok)
- `test_sim_contention_parity.c` — Declares calls (~21740 tok)
- `test_sim_contention_parity.c` — Declares calls (~22061 tok)
- `test_sim_contention_resource_driver_parity.c` — Declares char (~7336 tok)
- `test_sim_e2e_limit_visibility.c` — Declares char (~8076 tok)
- `test_sim_e2e_original_motivation.c` (~18078 tok)
- `test_sim_e2e_original_motivation.c` (~19514 tok)
- `test_sim_equivalence.c` — Declares BOTH (~11161 tok)
- `test_sim_live_allocation.c` — Declares already (~9156 tok)
- `test_sim_reconcile_unaccounted.c` — Declares documents (~34635 tok)
- `test_sim_reconcile_unaccounted.c` — Declares documents (~36877 tok)
- `test_sim_resource_contention.c` — Declares is (~10149 tok)
- `test_sim_round_boundary_reconciliation.c` (~9980 tok)
- `test_sim_session_ordering.c` (~10531 tok)
- `test_sim_verify.c` — Declares mismatch (~18569 tok)
- `test_sim_verify.c` — Declares mismatch (~18890 tok)
- `test_snapshot_save_load.c` (~29478 tok)
- `test.c` — define UNIT_TEST 1 (~7044 tok)

File diff suppressed because one or more lines are too long

View file

@ -5600,3 +5600,29 @@
| 02:31 | Edited src/scheduler/testsuite/test_sim_live_allocation.c | expanded (+22 lines) | ~556 |
| 02:31 | Edited src/scheduler/testsuite/test_sim_live_allocation.c | 4→3 lines | ~28 |
| 02:31 | Edited src/scheduler/src/sim_verify.c | removed 13 lines | ~17 |
| 02:57 | Session end: 71 writes across 14 files (controller.capnp, typemap.h, test_e2e_capnp.c, core.h, scheduler.h) | 51 reads | ~91046 tok |
## Session: 2026-09-21 02:57
| Time | Action | File(s) | Outcome | ~Tokens |
|------|--------|---------|---------|--------|
| 03:41 | Edited src/scheduler/include/core.h | modified relay() | ~448 |
| 03:42 | Edited src/scheduler/include/core.h | expanded (+10 lines) | ~182 |
| 03:42 | Edited src/scheduler/src/core.c | expanded (+11 lines) | ~197 |
| 03:42 | Edited src/scheduler/src/core.c | 3→5 lines | ~46 |
| 03:43 | Edited src/scheduler/src/sched_election.c | added 1 condition(s) | ~357 |
| 03:43 | Edited src/scheduler/include/sim.h | modified struct() | ~486 |
| 03:44 | Edited src/scheduler/src/sim_loop.c | added 1 condition(s) | ~708 |
| 03:44 | Edited src/scheduler/src/sim_loop.c | on_dispatch() → sim_reconcile_invoke_on_dispatch() | ~33 |
| 03:45 | Edited src/scheduler/src/sim_loop.c | added 1 condition(s) | ~188 |
| 03:45 | Edited src/scheduler/src/sim_loop.c | modified 16() | ~125 |
| 03:49 | Edited src/scheduler/testsuite/test_sim_reconcile_unaccounted.c | 7→8 lines | ~43 |
| 03:50 | Edited src/scheduler/testsuite/test_sim_reconcile_unaccounted.c | added 1 condition(s) | ~2281 |
| 03:50 | Edited src/scheduler/testsuite/test_sim_reconcile_unaccounted.c | 2→4 lines | ~65 |
| 04:19 | Edited src/scheduler/testsuite/test_sim_verify.c | added 2 condition(s) | ~381 |
| 04:19 | Edited src/scheduler/testsuite/test_sim_round_boundary_reconciliation.c | added 2 condition(s) | ~381 |
| 04:20 | Edited src/scheduler/testsuite/test_sim_contention_parity.c | added 2 condition(s) | ~381 |
| 04:20 | Edited src/scheduler/testsuite/test_sim_e2e_original_motivation.c | added 2 condition(s) | ~381 |
| 04:26 | Edited src/scheduler/src/sim_loop.c | added 1 condition(s) | ~128 |
| 04:33 | Edited src/scheduler/src/sim_loop.c | modified sim_reconcile_invoke_on_dispatch() | ~90 |
| 04:41 | Edited .kiro/specs/scheduler-sim-eager-reconciliation/design.md | modified Correction() | ~557 |

View file

@ -165,6 +165,16 @@ typedef void (*xce_unsatisfiable_populate_cb_t)(xce_sched_core_t* core,
xce_sched_obj_t* obj,
int reason_category);
/* Reserved jbuf sentinel for the Recorded-Decision Reconciliation
* allocator-thread relay (bug-1837) -- see xce_sched_core_s's reconcile_req/
* reconcile_ack fields' own doc comment below for the full protocol. Never a
* real jobid (job ids are assigned by the controller's monotonic id
* generator, starting near 1, nowhere near UINT64_MAX) and distinct from
* jbuf's own pre-existing 0 "end of session" sentinel -- same reserved-value
* idiom this codebase already uses elsewhere for an analogous "not a real
* value" marker (see SCHED_SIM_START_TS_LATEST, sim.h). */
#define XCE_SCHED_RECONCILE_WAKE ((uint64_t)-1)
struct xce_sched_core_s {
mylock_t lock;
int stop;
@ -198,6 +208,26 @@ struct xce_sched_core_s {
pthread_t allocator;
cbuf_t wbuf; /* action to trigger scheduling */
cbuf_t jbuf; /* job picked for allocation */
/* Recorded-Decision Reconciliation allocator-thread relay (bug-1837):
* plugin.h documents on_dispatch()/on_pend() as allocator-thread-only
* hooks, but sim_loop.c's sim_reconcile_unaccounted_jobs() (the driver
* thread) needs to invoke on_dispatch() for a job it reconstructs from
* replay history -- there is no fresh allocation to run through the
* normal jbuf/next_job() pipeline for it (the job is already RUN per
* history, not a live candidate), so it cannot flow through jbuf's
* existing real-jobid protocol. These two dedicated cbufs let the driver
* hand the call off to the real sched_allocator() thread (sched_
* election.c) instead of calling it directly: reconcile_req carries the
* jobid to dispatch, reconcile_ack echoes on_dispatch()'s return code
* once applied. Deliberately SEPARATE from jbuf, not a widened jbuf item
* type -- jbuf's item size/capacity are a pinned determinism invariant
* (test_core_jbuf_capacity_pin.c, Req 5 AC 7) that must never change.
* See XCE_SCHED_RECONCILE_WAKE below and sched_allocator()'s matching
* branch (sched_election.c) for the full handoff protocol. Normal mode
* never populates reconcile_req (only sim_loop.c's reconciliation does),
* so this is a true no-op for normal mode's allocator thread. */
cbuf_t reconcile_req; /* jobid for on_dispatch, allocator-thread relay */
cbuf_t reconcile_ack; /* on_dispatch()'s rc, echoed back once applied */
cbuf_t decisions; /* scheduler decisions */
/* Req 5 AC 6 (scheduler-core spec, task 4.4): driver-registered
* decision callback + opaque driver context, set via

View file

@ -93,6 +93,25 @@ typedef struct {
bool interactive;
uint64_t snapshot_ts;
xce_sched_repl_state_t* repl_state;
/* bug-1837 fix: set true by sched_sim_driver_run() (sim_loop.c),
* immediately after it successfully spawns the real sched_allocator()
* thread (sched_election.c) -- both the write here and every read in
* sim_reconcile_unaccounted_jobs() happen on the SAME thread (the one
* running sched_sim_driver_run()'s own round loop), in program order, so
* this is plain single-threaded state, not a cross-thread flag; no
* atomics needed. Tells sim_reconcile_invoke_on_dispatch() (sim_loop.c)
* whether a real allocator thread exists to relay on_dispatch() through
* (core->reconcile_req/reconcile_ack, core.h) or whether this xce_sched_
* sim_driver_t was only ever driven directly (no thread pair spawned at
* all -- e.g. test_sim_reconcile_unaccounted.c's own "call sim_
* reconcile_unaccounted_jobs() standalone" Testing Strategy), in which
* case calling on_dispatch() directly is safe by construction: there is
* only ever one thread touching picker state in that case, so plugin.h's
* allocator-thread-only contract has nothing to violate. Stays false
* forever for a bare xce_sched_sim_driver_t built via fixture_init()-
* style direct core init, matching sched_sim_driver_start()'s own
* memset(sim, 0, sizeof(*sim)) default. */
bool allocator_thread_live;
} xce_sched_sim_driver_t;
/*

View file

@ -162,6 +162,17 @@ int xce_sched_core_init(xce_sched_core_t* core)
* indirection for this value without first re-deriving the
* determinism argument in AC 7. */
cbuf_init(&(core->jbuf), 1, sizeof(uint64_t));
/* bug-1837 fix: Recorded-Decision Reconciliation's allocator-thread
* relay -- see core.h's own doc comment on these two fields for the full
* protocol. Capacity 1 each, matching jbuf's own one-request-in-flight
* discipline (this is not the jbuf determinism invariant itself, so not
* subject to test_core_jbuf_capacity_pin.c's pin -- just a sized-for-
* purpose choice, since only one reconciliation request is ever
* outstanding at a time by construction). */
cbuf_init(&(core->reconcile_req), 1, sizeof(uint64_t));
cbuf_init(&(core->reconcile_ack), 1, sizeof(int));
cbuf_init(&(core->decisions), ndecisions, sizeof(xce_decision_t*));
core->resmgr = makeInstance(&xkvmgr_type, &resmgr_params);
@ -196,6 +207,8 @@ void xce_sched_core_fini(xce_sched_core_t* core)
}
cbuf_fini(&(core->decisions));
cbuf_fini(&(core->reconcile_ack));
cbuf_fini(&(core->reconcile_req));
cbuf_fini(&(core->jbuf));
cbuf_fini(&(core->wbuf));

View file

@ -202,6 +202,27 @@ void* sched_allocator(void* arg)
continue;
}
if (jobid == XCE_SCHED_RECONCILE_WAKE) {
/* bug-1837: Recorded-Decision Reconciliation relay
* (sim_reconcile_unaccounted_jobs(), sim_loop.c) -- see core.h's
* reconcile_req/reconcile_ack doc comment for the full
* handshake. Not a real job: no plugin_job_election()/
* sched_make_allocation() call, no decision built, nothing
* pushed to core->decisions -- purely a threading-contract
* relay so on_dispatch() genuinely runs here, on the allocator
* thread, exactly as plugin.h's Post-allocation hooks contract
* requires, instead of being called directly by the driver
* thread. */
uint64_t reconcile_jobid = 0;
int reconcile_rc;
cbuf_get(&(core->reconcile_req), &reconcile_jobid);
reconcile_rc = job_picker->picker_iface->on_dispatch(
job_picker, reconcile_jobid);
cbuf_put(&(core->reconcile_ack), &reconcile_rc);
continue;
}
job = sched_get_job(core, jobid);
if (job == NULL) {
fmt_jobid(jobidString, sizeof(jobidString), jobid);

View file

@ -764,6 +764,56 @@ static void sim_reconcile_toggle_prior_charges(xce_sched_core_t* core,
iter_destroy(&it);
}
/*
* sim_reconcile_invoke_on_dispatch - bug-1837 fix: invoke picker->
* picker_iface->on_dispatch() on behalf of Recorded-Decision Reconciliation
* (sim_reconcile_unaccounted_jobs(), below), routed through the real
* allocator thread when one is live, per plugin.h's documented Post-
* allocation hooks contract ("allocator thread, after each allocation
* attempt"). Direct-call fallback when `sim->allocator_thread_live` is
* false: no thread pair was ever spawned against this xce_sched_sim_driver_t
* (e.g. a standalone unit test that calls sim_reconcile_unaccounted_jobs()
* directly, never sched_sim_driver_run() -- test_sim_reconcile_unaccounted.c's
* own documented Testing Strategy), so there is provably only one thread in
* the whole process touching picker state and nothing to race.
*
* Protocol when a real allocator thread IS live (core.h's reconcile_req/
* reconcile_ack doc comment has the full rationale): push `jobid` onto
* core->reconcile_req, THEN push the reserved XCE_SCHED_RECONCILE_WAKE
* sentinel onto core->jbuf (never a real jobid, never jbuf's own 0
* end-of-session marker) to wake sched_allocator() (sched_election.c) out of
* its cbuf_get(&core->jbuf) block. Ordering matters: cbuf_put()'s own
* mutex-protected write to reconcile_req happens-before the jbuf push that
* follows it (same thread, program order), and cbuf_get()'s own
* mutex-protected read on the allocator thread happens-after the matching
* cbuf_put() (POSIX mutex unlock->lock ordering) -- so by the time the
* allocator thread's cbuf_get(&core->jbuf) returns the wake sentinel,
* reconcile_req is guaranteed to already hold this call's jobid. Blocks on
* core->reconcile_ack until sched_allocator() echoes on_dispatch()'s return
* code back -- exactly one request in flight at a time, matching jbuf's own
* capacity-1 rendezvous discipline (never widened; a completely separate
* pair of cbufs carries this protocol instead).
*/
static int sim_reconcile_invoke_on_dispatch(xce_sched_sim_driver_t* sim,
xce_sched_plugin_t* picker,
uint64_t jobid)
{
if (!sim->allocator_thread_live) {
return picker->picker_iface->on_dispatch(picker, jobid);
}
cbuf_put(&(sim->core.reconcile_req), &jobid);
{
uint64_t wake = XCE_SCHED_RECONCILE_WAKE;
int rc = 0;
cbuf_put(&(sim->core.jbuf), &wake);
cbuf_get(&(sim->core.reconcile_ack), &rc);
return rc;
}
}
/*
* sim_reconcile_unaccounted_jobs - Recorded-Decision Reconciliation
* (scheduler-sim-decision-parity spec, design.md's "Recorded-Decision
@ -1163,7 +1213,7 @@ static void sim_reconcile_unaccounted_jobs(xce_sched_sim_driver_t* sim,
* confirmed dispatched, so a later round's sim_picker_thread_fn()
* never re-offers (and re-PENDs) it. */
if (picker != NULL) {
picker->picker_iface->on_dispatch(picker, job->jobid);
sim_reconcile_invoke_on_dispatch(sim, picker, job->jobid);
}
found = 1;
@ -1349,6 +1399,13 @@ int sched_sim_driver_run(xce_sched_sim_driver_t* sim,
return -rc;
}
/* bug-1837 fix: recorded exactly once, right here, before the round loop
* below can possibly run -- see xce_sched_sim_driver_t's own doc comment
* (sim.h) on this field for why this is plain single-threaded state
* (same thread sets it and later reads it, in program order) rather than
* a cross-thread flag needing atomics. */
sim->allocator_thread_live = true;
/* Req 5 AC 16 (task 11.5, "What-if branching"): once --sim-alt-events
* is set, the ONGOING event/session stream comes from
* sim->alt_events_file instead of sim->replay_file -- the initial

View file

@ -531,6 +531,30 @@ void* sched_allocator(void* arg)
continue;
}
if (jobid == XCE_SCHED_RECONCILE_WAKE) {
/* bug-1837: this file's stand-in sched_allocator() must service
* the same Recorded-Decision Reconciliation relay protocol the
* real sched_election.c implementation does (see that file's
* matching branch for the full rationale) -- this file's own
* sched_sim_driver_run() calls set sim->allocator_thread_live,
* so sim_reconcile_unaccounted_jobs() routes on_dispatch()
* through jbuf/reconcile_req/reconcile_ack exactly as
* production does; without this branch, a reconciliation
* success here would hang forever waiting on an ack this stub
* never sends. */
uint64_t reconcile_jobid = 0;
int reconcile_rc = 0;
cbuf_get(&(core->reconcile_req), &reconcile_jobid);
(void)sched_resolve_job_picker(core, &picker);
if (picker != NULL) {
reconcile_rc = picker->picker_iface->on_dispatch(
picker, reconcile_jobid);
}
cbuf_put(&(core->reconcile_ack), &reconcile_rc);
continue;
}
job = sched_get_job(core, jobid);
if (job == NULL) {
continue;

View file

@ -483,6 +483,30 @@ void* sched_allocator(void* arg)
continue;
}
if (jobid == XCE_SCHED_RECONCILE_WAKE) {
/* bug-1837: this file's stand-in sched_allocator() must service
* the same Recorded-Decision Reconciliation relay protocol the
* real sched_election.c implementation does (see that file's
* matching branch for the full rationale) -- this file's own
* sched_sim_driver_run() calls set sim->allocator_thread_live,
* so sim_reconcile_unaccounted_jobs() routes on_dispatch()
* through jbuf/reconcile_req/reconcile_ack exactly as
* production does; without this branch, a reconciliation
* success here would hang forever waiting on an ack this stub
* never sends. */
uint64_t reconcile_jobid = 0;
int reconcile_rc = 0;
cbuf_get(&(core->reconcile_req), &reconcile_jobid);
(void)sched_resolve_job_picker(core, &picker);
if (picker != NULL) {
reconcile_rc = picker->picker_iface->on_dispatch(
picker, reconcile_jobid);
}
cbuf_put(&(core->reconcile_ack), &reconcile_rc);
continue;
}
job = sched_get_job(core, jobid);
if (job == NULL) {
continue;

View file

@ -90,6 +90,7 @@
*/
#include <errno.h>
#include <limits.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
@ -2524,6 +2525,192 @@ test_reconcile_removes_reconciled_job_from_picker_candidates(void)
"PASS\n");
}
/* ---------------------------------------------------------------------- */
/* Test (bug-1837, TDD): plugin.h documents on_dispatch()/on_pend() as */
/* "Post-allocation hooks (MUST) -- allocator thread, after each allocation */
/* attempt". Recorded-Decision Reconciliation used to call on_dispatch() */
/* directly from ITS OWN caller thread (sim_reconcile_unaccounted_jobs()'s */
/* thread -- normally sched_sim_driver_run()'s driver thread in production), */
/* violating that contract by construction. Dormant in production only */
/* because builtin_fcfs.c happens to guard every hook with its own internal */
/* lock -- a defensive measure plugin.h's contract text does not actually */
/* require of picker plugins in general ("the picker thread holds no lock */
/* the allocator thread needs"). */
/* */
/* Fix: once a real allocator thread is live for a core (xce_sched_sim_ */
/* driver_t.allocator_thread_live, sim.h), sim_reconcile_invoke_on_dispatch()*/
/* (sim_loop.c) routes the call through it via core->reconcile_req/ */
/* core->jbuf's XCE_SCHED_RECONCILE_WAKE sentinel/core->reconcile_ack */
/* (core.h), and sched_election.c's sched_allocator() is what actually */
/* invokes on_dispatch() on that thread. */
/* */
/* This test proves it directly, independent of builtin_fcfs's own internal */
/* locking: a minimal test-owned picker plugin records pthread_self() from */
/* inside its on_dispatch() implementation, and a one-shot thread standing */
/* in for sched_allocator() (relay_one_reconcile_request(), below) services */
/* the relay protocol. RED before the bug-1837 fix (on_dispatch() ran on */
/* this test's OWN thread -- g_dispatch_calling_tid would equal */
/* pthread_self(), and g_seen_jbuf_wake_value would never be observed since */
/* nothing was ever pushed onto jbuf at all), GREEN after. */
/* ---------------------------------------------------------------------- */
static pthread_t g_dispatch_calling_tid;
static int g_dispatch_call_count;
static uint64_t g_seen_jbuf_wake_value;
static int thread_recording_on_dispatch(void* picker, uint64_t jobid)
{
(void)picker;
(void)jobid;
g_dispatch_calling_tid = pthread_self();
g_dispatch_call_count++;
return 0;
}
static int noop_picker_session_hook(void* picker)
{
(void)picker;
return 0;
}
static xce_sched_job_t* noop_picker_next_job_hook(void* picker)
{
(void)picker;
return NULL;
}
static int noop_picker_jobid_hook(void* picker, uint64_t jobid)
{
(void)picker;
(void)jobid;
return 0;
}
static xce_job_picker_iface_t g_thread_recording_picker_iface = {
.init = NULL,
.fini = NULL,
.begin_session = noop_picker_session_hook,
.next_job = noop_picker_next_job_hook,
.end_session = noop_picker_session_hook,
.on_dispatch = thread_recording_on_dispatch,
.on_pend = noop_picker_jobid_hook,
.on_queue_update = NULL,
.on_job_push = NULL,
.on_job_state_change = NULL,
.on_job_update = NULL,
.on_config = NULL,
.on_job_rusage = NULL,
};
static xce_sched_plugin_t g_thread_recording_plugin
= { .name = (char*)"test_reconcile_thread_picker",
.picker_iface = &g_thread_recording_picker_iface };
/* One-shot stand-in for sched_election.c's real sched_allocator() --
* services exactly one Recorded-Decision Reconciliation relay request
* (core->jbuf's XCE_SCHED_RECONCILE_WAKE sentinel, then core->reconcile_req,
* then acks via core->reconcile_ack) and returns, mirroring that function's
* own SIM_RECONCILE_JBUF_WAKE branch exactly (see its own comment,
* sched_election.c, for the full protocol this mirrors). Deliberately not a
* forever loop: this test only ever sends one request. No TEST_ASSERT_*
* calls in here -- Unity's assertion macros longjmp against the MAIN test
* thread's context, unsafe to invoke from a spawned thread; this function
* only records observations into statics, asserted on after pthread_join()
* back on the main test thread. */
static void* relay_one_reconcile_request(void* arg)
{
xce_sched_core_t* core = (xce_sched_core_t*)arg;
uint64_t wake_val = 0;
uint64_t reconcile_jobid = 0;
xce_sched_plugin_t* job_picker = NULL;
int reconcile_rc = 0;
cbuf_get(&(core->jbuf), &wake_val);
g_seen_jbuf_wake_value = wake_val;
cbuf_get(&(core->reconcile_req), &reconcile_jobid);
(void)sched_resolve_job_picker(core, &job_picker);
if (job_picker != NULL) {
reconcile_rc = job_picker->picker_iface->on_dispatch(job_picker,
reconcile_jobid);
}
cbuf_put(&(core->reconcile_ack), &reconcile_rc);
return NULL;
}
static void
test_reconcile_invokes_on_dispatch_on_allocator_thread_not_caller(void)
{
xce_sched_sim_driver_t sim;
xce_sched_job_t* job;
xce_job_alloc_t* jalloc;
replay_session_t* decoded;
int residx;
pthread_t relay_tid;
int prc;
fixture_init(&sim);
g_dispatch_calling_tid = pthread_self(); /* sentinel: only meaningful if
* overwritten below */
g_dispatch_call_count = 0;
g_seen_jbuf_wake_value = 0;
TEST_ASSERT_EQUAL_INT(
0, plugin_register_core(&(sim.core), &g_thread_recording_plugin));
sim.core.job_pick_plugin = g_thread_recording_plugin.name;
residx = register_slots_resource(&(sim.core));
add_worker(&(sim.core), "worker1", residx, 1000.0f);
job = add_job(&(sim.core), 7200ULL, JOB_STAT_RUN, NULL);
TEST_ASSERT_NULL(job->accounts);
jalloc = make_test_job_alloc_with_host(7200ULL, "worker1", "slots", 2.0f);
decoded = make_decoded_session(1000ULL, make_dispatch_entry_from_alloc(jalloc));
/* bug-1837 fix: mark a real allocator thread as live for this core, and
* actually spawn its stand-in BEFORE triggering reconciliation -- cbuf's
* own mutex+condvar makes the exact spawn/relay-vs-post ordering
* irrelevant (see relay_one_reconcile_request()'s own comment). */
sim.allocator_thread_live = true;
prc = pthread_create(&relay_tid, NULL, relay_one_reconcile_request,
&(sim.core));
TEST_ASSERT_EQUAL_INT(0, prc);
sim_reconcile_unaccounted_jobs(&sim, decoded, NULL);
pthread_join(relay_tid, NULL);
TEST_ASSERT_NOT_NULL_MESSAGE(
job->accounts, "fixture sanity: reconciliation must still have found "
"and captured the matching recorded decision");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_dispatch_call_count,
"on_dispatch() must be invoked exactly "
"once");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(
XCE_SCHED_RECONCILE_WAKE, g_seen_jbuf_wake_value,
"bug-1837: reconciliation must wake the allocator thread via the "
"reserved jbuf sentinel, never by pushing the real jobid onto jbuf "
"directly (that would incorrectly route it through the live "
"allocation chain)");
TEST_ASSERT_FALSE_MESSAGE(
pthread_equal(g_dispatch_calling_tid, pthread_self()),
"bug-1837: on_dispatch() must run on the allocator thread, never on "
"sim_reconcile_unaccounted_jobs()'s own caller thread, once a real "
"allocator thread is live for this core -- plugin.h's documented "
"Post-allocation hooks contract (\"allocator thread, after each "
"allocation attempt\")");
TEST_ASSERT_TRUE_MESSAGE(
pthread_equal(g_dispatch_calling_tid, relay_tid),
"on_dispatch() must run specifically on the spawned allocator-relay "
"thread, not some other thread");
printf("test_reconcile_invokes_on_dispatch_on_allocator_thread_not_"
"caller: PASS\n");
}
/* ---------------------------------------------------------------------- */
/* Task 4.2 fixtures (scheduler-sim-live-allocation spec, "Sim-Mode */
/* Reconciliation Narrowing" component, Requirement 3.4/3.5): register a */
@ -2816,6 +3003,8 @@ int main(void)
RUN_TEST(
test_reconcile_info_worker_and_summary_hide_completed_job_after_stray_dispatch);
RUN_TEST(test_reconcile_removes_reconciled_job_from_picker_candidates);
RUN_TEST(
test_reconcile_invokes_on_dispatch_on_allocator_thread_not_caller);
RUN_TEST(test_reconcile_registers_global_limit_usage_for_reconciled_job);
RUN_TEST(
test_reconcile_does_not_reregister_limit_for_already_decided_job);

View file

@ -375,6 +375,30 @@ void* sched_allocator(void* arg)
continue;
}
if (jobid == XCE_SCHED_RECONCILE_WAKE) {
/* bug-1837: this file's stand-in sched_allocator() must service
* the same Recorded-Decision Reconciliation relay protocol the
* real sched_election.c implementation does (see that file's
* matching branch for the full rationale) -- this file's own
* sched_sim_driver_run() calls set sim->allocator_thread_live,
* so sim_reconcile_unaccounted_jobs() routes on_dispatch()
* through jbuf/reconcile_req/reconcile_ack exactly as
* production does; without this branch, a reconciliation
* success here would hang forever waiting on an ack this stub
* never sends. */
uint64_t reconcile_jobid = 0;
int reconcile_rc = 0;
cbuf_get(&(core->reconcile_req), &reconcile_jobid);
(void)sched_resolve_job_picker(core, &picker);
if (picker != NULL) {
reconcile_rc = picker->picker_iface->on_dispatch(
picker, reconcile_jobid);
}
cbuf_put(&(core->reconcile_ack), &reconcile_rc);
continue;
}
job = sched_get_job(core, jobid);
if (job == NULL) {
continue;

View file

@ -391,6 +391,30 @@ void* sched_allocator(void* arg)
continue;
}
if (jobid == XCE_SCHED_RECONCILE_WAKE) {
/* bug-1837: this file's stand-in sched_allocator() must service
* the same Recorded-Decision Reconciliation relay protocol the
* real sched_election.c implementation does (see that file's
* matching branch for the full rationale) -- this file's own
* sched_sim_driver_run() calls set sim->allocator_thread_live,
* so sim_reconcile_unaccounted_jobs() routes on_dispatch()
* through jbuf/reconcile_req/reconcile_ack exactly as
* production does; without this branch, a reconciliation
* success here would hang forever waiting on an ack this stub
* never sends. */
uint64_t reconcile_jobid = 0;
int reconcile_rc = 0;
cbuf_get(&(core->reconcile_req), &reconcile_jobid);
(void)sched_resolve_job_picker(core, &picker);
if (picker != NULL) {
reconcile_rc = picker->picker_iface->on_dispatch(
picker, reconcile_jobid);
}
cbuf_put(&(core->reconcile_ack), &reconcile_rc);
continue;
}
job = sched_get_job(core, jobid);
if (job == NULL) {
continue;