test(scheduler-sim-decision-parity): add resource-contention replay fixture (task 1.2)
Adds a dual-use .replay fixture (3 jobs competing for insufficient worker capacity) that later tasks need to prove capacity-aware parity under contention: a real recorded normal-mode session (built by driving the genuine sched_check_host_satisfy()/sched_commit_job_alloc() capacity/ accounting chain, not mocked) that is also directly replayable through the real simulation driver. Two tests: fixture shape (job B genuinely denied while A+C hold the full 10.0-unit capacity, verified against the decoded recorded decision stream) and sim-driver replayability. RED-phase proven: raising WORKER_CAPACITY to eliminate contention makes the shape assertion fail as expected before reverting to the genuine-contention value. 44/45 scheduler tests pass; the 1 failure is pre-existing bug-1560 (unrelated). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
3106c82ed0
commit
f11f0df3bd
3 changed files with 913 additions and 1 deletions
|
|
@ -726,6 +726,29 @@ sched_sim_equivalence_test = executable(
|
|||
dependencies: [scheduler_test_deps],
|
||||
install: false)
|
||||
|
||||
# Resource-contention replay fixture (scheduler-sim-decision-parity spec,
|
||||
# task 1.2, Req 2.1/2.2): existing fixtures in this directory never have
|
||||
# scarce capacity, so none can exercise or prove capacity-check correctness.
|
||||
# This file records ONE real normal-mode session (real builtin_fcfs picker +
|
||||
# a capacity-aware stand-in allocator that drives the REAL
|
||||
# sched_check_host_satisfy()/sched_charge_account()/sched_commit_job_alloc()
|
||||
# (resource.c) against a real, finite-capacity worker) where three jobs
|
||||
# compete for 10.0 units of one resource -- two dispatch, one is genuinely
|
||||
# denied capacity the other two, now running, are consuming. First file in
|
||||
# this directory to combine resource.c with the full driver/sim/recording
|
||||
# stack (test_resource_accounting.c, task 1.1, deliberately pairs resource.c
|
||||
# with only core.c -- see that file's own header comment for why). The
|
||||
# resulting `.replay` file is dual-use: it IS a genuine normal-mode
|
||||
# recording, and this file's own second test proves it is also directly
|
||||
# replayable via the real, unmodified simulation driver -- see this file's
|
||||
# own header comment for the full rationale.
|
||||
sched_sim_resource_contention_test = executable(
|
||||
'sched_sim_resource_contention_test',
|
||||
['test_sim_resource_contention.c'],
|
||||
include_directories:[incdir],
|
||||
dependencies: [scheduler_test_deps],
|
||||
install: false)
|
||||
|
||||
# Contract test for the interactive simulation REPL's execution-control slice
|
||||
# (scheduler-core spec, task 12.1, Req 5 AC 22): starting simulation with
|
||||
# --interactive pauses before any event is processed, and a `step N` command
|
||||
|
|
@ -1050,6 +1073,9 @@ test('scheduler sim record output test',
|
|||
test('scheduler sim equivalence test',
|
||||
sched_sim_equivalence_test, suite: 'scheduler',
|
||||
env: ['ASAN_OPTIONS=detect_leaks=0'])
|
||||
test('scheduler sim resource contention test',
|
||||
sched_sim_resource_contention_test, suite: 'scheduler',
|
||||
env: ['ASAN_OPTIONS=detect_leaks=0'])
|
||||
test('scheduler repl execution control test',
|
||||
sched_repl_execution_control_test, suite: 'scheduler',
|
||||
env: ['ASAN_OPTIONS=detect_leaks=0'])
|
||||
|
|
|
|||
883
src/scheduler/testsuite/test_sim_resource_contention.c
Normal file
883
src/scheduler/testsuite/test_sim_resource_contention.c
Normal file
|
|
@ -0,0 +1,883 @@
|
|||
/*
|
||||
* test_sim_resource_contention.c - resource-contention replay fixture
|
||||
* (scheduler-sim-decision-parity spec, task 1.2, Req 2.1/2.2).
|
||||
*
|
||||
* Why this fixture exists: every existing `.replay` fixture in this
|
||||
* directory (test_sim_equivalence.c/test_repl_equivalence.c/
|
||||
* test_sim_verify.c/etc.) uses jobs with a trivially-satisfiable resreq
|
||||
* ("type == any") against a core that never has a worker with limited
|
||||
* capacity -- capacity is never actually scarce, so no existing fixture can
|
||||
* exercise or prove capacity-check correctness (design.md's own File
|
||||
* Structure Plan entry for this task: "required because existing fixtures
|
||||
* evidently don't exercise this path"). This file builds ONE recorded
|
||||
* normal-mode session where THREE jobs compete for a worker with real,
|
||||
* insufficient total capacity, so at least one job is genuinely denied
|
||||
* capacity a currently-running job is holding.
|
||||
*
|
||||
* Fixture-construction technique: the SAME dual-phase pattern
|
||||
* test_sim_equivalence.c already establishes (real normal-mode recording via
|
||||
* sched_record_start()/sched_record_stop() driving the REAL builtin_fcfs
|
||||
* picker + a "stand-in allocator" thread standing in for scheduler.c's own
|
||||
* sched_allocator() -- that file's own header comment explains why a
|
||||
* stand-in is used instead of the real allocator: the real one pulls in
|
||||
* resreq-DSL compilation/plugin machinery this whole test-file family
|
||||
* deliberately avoids). This file's stand-in allocator differs from
|
||||
* test_sim_equivalence.c's own (which unconditionally PENDs every
|
||||
* candidate): it drives the REAL, unmodified resource.c capacity-check and
|
||||
* accounting primitives directly -- sched_check_host_satisfy() (genuine
|
||||
* total-vs-inuse-vs-wanted arithmetic against a real xce_sched_worker_t) and
|
||||
* sched_charge_account()/sched_commit_job_alloc() (task 1.1's relocated
|
||||
* accounting functions, now xce_sched_core_t*-based) -- against a real
|
||||
* worker added via sched_add_worker() with a real, finite capacity (10.0
|
||||
* units of a resource, residx set directly the same way
|
||||
* test_resource_accounting.c's own make_test_account() does, bypassing
|
||||
* resmgr name resolution -- orthogonal to what capacity contention proves).
|
||||
* This means every DISPATCH/PEND decision this fixture records is a
|
||||
* genuine, real-arithmetic outcome of real production capacity-check code,
|
||||
* not fabricated -- only the resreq-DSL-to-`wanted`-vector compilation step
|
||||
* is replaced by a fixed per-job lookup table (JOB A/B/C's "wants" below),
|
||||
* exactly the same scope-reduction test_sim_equivalence.c's own header
|
||||
* comment documents for its own simplification.
|
||||
*
|
||||
* The resulting `.replay` file R is dual-use, per this task's own
|
||||
* requirement text ("usable both as a normal-mode recording source and as a
|
||||
* simulation-mode replay input"): it IS a genuine normal-mode recording (produced by
|
||||
* sched_record_start()/sched_record_stop() around a real
|
||||
* xce_sched_driver_run_normal_mode_round() call), and this file's own
|
||||
* test_contention_fixture_is_replayable_via_simulation_driver() below
|
||||
* additionally proves it is directly consumable by the REAL, unmodified
|
||||
* sched_sim_driver_run() as replay input. A later task (4.1, out of this
|
||||
* task's boundary) reuses this same two-phase shape to assert resInUse
|
||||
* parity between a normal-mode recording and its simulation-mode replay;
|
||||
* this task's own job is only to build the fixture and prove its own shape
|
||||
* (multiple jobs, insufficient combined capacity, at least one round where
|
||||
* one job is denied capacity a running job holds) -- not to run that later
|
||||
* comparison.
|
||||
*
|
||||
* Scenario: worker "worker-a" has 10.0 total units of one resource. Job A
|
||||
* (submitted first, sub_time=1000) wants 8.0 -- satisfied, dispatched, and
|
||||
* charged into worker-a's resInUse. Job B (sub_time=1001) wants 5.0 -- 8.0
|
||||
* (A's charge, now-running) + 5.0 (B's want) = 13.0 > 10.0 total, so B is
|
||||
* genuinely DENIED capacity that job A, now running, is consuming (this
|
||||
* task's own Observable Completion, proven by decoding the recorded
|
||||
* decision stream below, not merely asserted from this file's own input
|
||||
* constants). Job C (sub_time=1002) wants 2.0 -- exactly the remaining
|
||||
* 10.0-8.0=2.0 headroom, so it also dispatches, proving the fixture is a
|
||||
* genuine multi-job contention scenario (8.0+5.0+2.0=15.0 > 10.0 combined,
|
||||
* while the two jobs that DO dispatch, A+C, exactly saturate the 10.0
|
||||
* total) rather than a degenerate single-denied-job case.
|
||||
*
|
||||
* White-box #include set: identical to test_sim_equivalence.c's own
|
||||
* combined normal-mode-driver + full-simulation-stack set, PLUS
|
||||
* ../src/resource.c (this file's own new dependency: the real
|
||||
* sched_check_host_satisfy()/sched_charge_account()/sched_commit_job_alloc()
|
||||
* capacity-and-accounting primitives under real exercise here). No existing
|
||||
* test file in this directory white-box #includes resource.c together with
|
||||
* the full sim/driver stack -- test_resource_accounting.c (task 1.1) only
|
||||
* pairs resource.c with core.c, deliberately staying below the
|
||||
* driver/picker/recording layer (see that file's own header comment for
|
||||
* why); this file is the first to need both together, since a genuine
|
||||
* recorded fixture needs the real recording pipeline AND real capacity
|
||||
* arithmetic in the same run.
|
||||
*
|
||||
* Stub surface: the union of test_sim_equivalence.c's own stubs (kill_sched_*
|
||||
* family, sched_account_jobs -- scheduler.c itself is still not white-box
|
||||
* included, so this thin ~20-line wrapper stays a no-op exactly as it does
|
||||
* there; harmless here since this fixture's single round never depends on
|
||||
* round-boundary re-accounting of a PRIOR round's charges, only its own
|
||||
* mid-round sequential charging), sched_publish_dispatch/
|
||||
* sched_publish_pend_reason/xcecli_reply_schedadmin (driver.c link-time
|
||||
* dependencies, never invoked), plus ONE new stub this file's addition of
|
||||
* resource.c requires: sched_ctx_get_uncomit_host() (resource.c's
|
||||
* sched_try_alloc_on_host_()'s own link-time dependency -- that function is
|
||||
* compiled as part of resource.c but never called by this file, which only
|
||||
* calls the public sched_check_host_satisfy()/sched_charge_account()/
|
||||
* sched_commit_job_alloc() entry points directly). Two stubs
|
||||
* test_resource_accounting.c's own resource.c-only inclusion needed
|
||||
* (plugin_prepare_allocation, sched_fill_limit_requests) are deliberately
|
||||
* NOT redefined here: this file's combined include set already provides
|
||||
* REAL definitions for both (plugin_prepare_allocation from plugin.c,
|
||||
* already part of test_sim_equivalence.c's own set; sched_fill_limit_requests
|
||||
* from resource.c itself, this file's own new addition) -- redefining
|
||||
* either here would be a duplicate-symbol link error, not a harmless
|
||||
* no-op. sched_worker_release_host_ctx is likewise NOT stubbed here (every
|
||||
* other file in this directory stubs it because none of them white-box
|
||||
* #include resource.c, which defines it for real) -- this file gets the
|
||||
* real one via resource.c and must not shadow it.
|
||||
*/
|
||||
#include <errno.h>
|
||||
#include <limits.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <unity/unity.h>
|
||||
|
||||
#include "controller.capnp.h"
|
||||
#include "kvmgr.h"
|
||||
#include "preason.h"
|
||||
#include "scheduler.h"
|
||||
#include "xstring.h"
|
||||
|
||||
/* Pull in the real functions under test into this TU (white-box #include,
|
||||
* same convention every other file in this directory establishes -- see
|
||||
* this file's own header comment for the exact combined set and why it is
|
||||
* novel). */
|
||||
#include "../src/core.c"
|
||||
#include "../src/core_clock.c"
|
||||
#include "../src/core_events.c"
|
||||
#include "../src/core_session.c"
|
||||
#include "../src/decision.c"
|
||||
#include "../src/driver.c"
|
||||
#include "../src/plugin.c"
|
||||
#include "../src/plugins/builtin_fcfs.c"
|
||||
#include "../src/recording.c"
|
||||
#include "../src/recording_events.c"
|
||||
#include "../src/recording_session.c"
|
||||
#include "../src/repl.c"
|
||||
#include "../src/repl_break.c"
|
||||
#include "../src/repl_info.c"
|
||||
#include "../src/repl_params.c"
|
||||
#include "../src/resource.c"
|
||||
#include "../src/sim.c"
|
||||
#include "../src/sim_loop.c"
|
||||
#include "../src/sim_picker.c" /* bug-1567: sched_sim_driver_run() now calls sim_bootstrap_picker() (sim_loop.c-cap sibling) as an extern -- undefined reference at link time otherwise */
|
||||
#include "../src/sim_record.c"
|
||||
#include "../src/sim_verify.c"
|
||||
#include "../src/snapshot.c"
|
||||
#include "../src/snapshot_load.c"
|
||||
#include "../src/snapshot_wire.c"
|
||||
|
||||
void kill_sched_job(void* arg) { (void)arg; }
|
||||
void kill_sched_queue(void* arg) { (void)arg; }
|
||||
void kill_sched_worker(void* arg) { (void)arg; }
|
||||
void kill_sched_user(void* arg) { (void)arg; }
|
||||
void kill_sched_project(void* arg) { (void)arg; }
|
||||
void kill_sched_resval(xce_val_t* val) { (void)val; }
|
||||
|
||||
/* xce_sched_driver_run_normal_mode_round()'s (driver.c) linker dependency --
|
||||
* a no-op stand-in, same rationale test_sim_equivalence.c's own copy
|
||||
* documents (this file drives its own resource accounting directly via
|
||||
* this file's own capacity_aware_allocator_thread_fn(), calling the real
|
||||
* sched_check_host_satisfy()/sched_charge_account() -- scheduler.c's own
|
||||
* sched_account_jobs() thin wrapper is not white-box #included here). */
|
||||
int sched_account_jobs(xce_sched_core_t* core)
|
||||
{
|
||||
(void)core;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* xce_sched_driver_on_decision()'s (driver.c) publish-side linker
|
||||
* dependencies -- never actually invoked: this test never registers a
|
||||
* decision callback, so every decision is simply recorded and dropped.
|
||||
* Same rationale test_sim_equivalence.c's own copy documents. */
|
||||
int sched_publish_dispatch(xce_scheduler_t* sched, xce_job_dispatch_t* d)
|
||||
{
|
||||
(void)sched;
|
||||
(void)d;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int sched_publish_pend_reason(xce_scheduler_t* sched, xce_preason_set_t* r)
|
||||
{
|
||||
(void)sched;
|
||||
(void)r;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* handle_deferred_admin_record()'s (driver.c) linker dependency -- never
|
||||
* invoked (this test never enqueues XCE_SCHED_OP_ADMIN_RECORD). */
|
||||
int xcecli_reply_schedadmin(xcecli_ctx_t c, uint64_t seq,
|
||||
sched_admin_reply_t* reply)
|
||||
{
|
||||
(void)c;
|
||||
(void)seq;
|
||||
(void)reply;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* resource.c's sched_try_alloc_on_host_()'s own link-time dependency --
|
||||
* never actually invoked (this file calls sched_check_host_satisfy()/
|
||||
* sched_charge_account()/sched_commit_job_alloc() directly, never
|
||||
* sched_try_alloc_on_hosts()/sched_try_alloc_on_host_() itself). Same
|
||||
* link-satisfying-only stub test_resource_accounting.c's own resource.c
|
||||
* inclusion already establishes needing. */
|
||||
int sched_ctx_get_uncomit_host(xvec_t** v, xce_sched_ctx_t* c, int i)
|
||||
{
|
||||
(void)v;
|
||||
(void)c;
|
||||
(void)i;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Real reimplementation of schedobj.c's sched_resolve_job_picker -- same
|
||||
* copy every white-box test in this directory that does not link
|
||||
* schedobj.c already establishes. */
|
||||
int sched_resolve_job_picker(xce_sched_core_t* core, xce_sched_plugin_t** out)
|
||||
{
|
||||
xce_sched_plugin_t* plugin = NULL;
|
||||
int rc = 0;
|
||||
|
||||
if ((core == NULL) || (out == NULL)) {
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
*out = NULL;
|
||||
|
||||
if (core->job_pick_plugin == NULL) {
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
lock(core->plugins, RTTI_LOCK_READ);
|
||||
rc = key_find(core->plugins, core->job_pick_plugin, (void**)&plugin);
|
||||
unlock(core->plugins);
|
||||
|
||||
if (rc <= 0) {
|
||||
return (rc < 0) ? rc : -ENOENT;
|
||||
}
|
||||
|
||||
if ((plugin == NULL) || (plugin->picker_iface == NULL)
|
||||
|| (plugin->picker_iface->next_job == NULL)
|
||||
|| (plugin->picker_iface->begin_session == NULL)
|
||||
|| (plugin->picker_iface->end_session == NULL)
|
||||
|| (plugin->picker_iface->on_dispatch == NULL)
|
||||
|| (plugin->picker_iface->on_pend == NULL)) {
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
*out = plugin;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
void setUp(void) { return; }
|
||||
void tearDown(void) { return; }
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Fixture constants */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
#define WORKER_NAME "worker-a"
|
||||
#define SLOTS_RESIDX 0
|
||||
#define WORKER_CAPACITY 10.0f
|
||||
|
||||
#define JOBA_ID 9101ULL
|
||||
#define JOBB_ID 9102ULL
|
||||
#define JOBC_ID 9103ULL
|
||||
#define JOBA_WANT 8.0f
|
||||
#define JOBB_WANT 5.0f
|
||||
#define JOBC_WANT 2.0f
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Fixture helpers */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
static void make_tmp_dir(char* out, size_t out_len, const char* suffix)
|
||||
{
|
||||
char tmpl[PATH_MAX];
|
||||
|
||||
snprintf(tmpl, sizeof(tmpl), "/tmp/sched_simcontention%s_XXXXXX", suffix);
|
||||
TEST_ASSERT_NOT_NULL(mkdtemp(tmpl));
|
||||
TEST_ASSERT_TRUE(strlen(tmpl) < out_len);
|
||||
strcpy(out, tmpl);
|
||||
}
|
||||
|
||||
/* Real xce_sched_core_init() + real builtin_fcfs registration (same
|
||||
* technique test_sim_equivalence.c's own fixture_init() establishes) PLUS a
|
||||
* real xce_sched_worker_t added directly via sched_add_worker() with a
|
||||
* finite total capacity -- this file's own contention source. Adding the
|
||||
* worker directly (not through a WORKERS_PUSH event) matches
|
||||
* test_resource_accounting.c's own documented convention: "sched_add_worker()
|
||||
* itself does not [initialize resvals/resInUse] -- callers own it, same as
|
||||
* every other white-box fixture in this directory that adds a worker
|
||||
* directly."
|
||||
*/
|
||||
static void fixture_init(xce_scheduler_t* sched, char* record_dir)
|
||||
{
|
||||
xce_sched_plugin_t* fcfs;
|
||||
xce_sched_worker_t* w;
|
||||
int rc;
|
||||
int idx[1] = { SLOTS_RESIDX };
|
||||
float val[1] = { WORKER_CAPACITY };
|
||||
|
||||
memset(sched, 0, sizeof(*sched));
|
||||
mylock_init(&(sched->record_lock));
|
||||
|
||||
rc = xce_sched_core_init(&(sched->core));
|
||||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||
|
||||
sched->core.pend_updates[0] = (dataq_t*)calloc(1, sizeof(dataq_t));
|
||||
dataq_init(sched->core.pend_updates[0], NULL);
|
||||
sched->core.pend_updates[1] = (dataq_t*)calloc(1, sizeof(dataq_t));
|
||||
dataq_init(sched->core.pend_updates[1], NULL);
|
||||
|
||||
fcfs = builtin_fcfs_new_plugin();
|
||||
TEST_ASSERT_NOT_NULL(fcfs);
|
||||
rc = plugin_register_core(&(sched->core), fcfs);
|
||||
TEST_ASSERT_TRUE(rc >= 0);
|
||||
|
||||
sched->core.job_pick_plugin = (char*)"builtin_fcfs";
|
||||
sched->record_dir = record_dir;
|
||||
|
||||
w = (xce_sched_worker_t*)calloc(1, sizeof(*w));
|
||||
TEST_ASSERT_NOT_NULL(w);
|
||||
w->name = strdup(WORKER_NAME);
|
||||
TEST_ASSERT_NOT_NULL(w->name);
|
||||
|
||||
rc = zarray_init(&(w->resvals), 8, NULL);
|
||||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||
rc = zarray_init(&(w->resInUse), 8, NULL);
|
||||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||
|
||||
rc = put_resvals(&(w->resvals), 1, idx, val);
|
||||
TEST_ASSERT_TRUE(rc >= 0);
|
||||
|
||||
rc = sched_add_worker(&(sched->core), w);
|
||||
TEST_ASSERT_TRUE(rc >= 0);
|
||||
}
|
||||
|
||||
/* Builds one heap-owned, deep-freeable xce_queue_t -- same technique
|
||||
* test_sim_equivalence.c's own make_queue() establishes. */
|
||||
static xce_queue_t* make_queue(const char* name, int32_t priority)
|
||||
{
|
||||
xce_queue_t* q = (xce_queue_t*)calloc(1, sizeof(*q));
|
||||
|
||||
q->queue = strdup(name);
|
||||
q->status = QUEUE_STAT_OPEN;
|
||||
q->priority = priority;
|
||||
|
||||
return q;
|
||||
}
|
||||
|
||||
/* Builds one heap-owned, deep-freeable xce_job_t -- same technique
|
||||
* test_sim_equivalence.c's own make_job() establishes. */
|
||||
static xce_job_t* make_job(uint64_t jobid, uint64_t sub_time, const char* queue,
|
||||
const char* user)
|
||||
{
|
||||
xce_job_t* j = (xce_job_t*)calloc(1, sizeof(*j));
|
||||
|
||||
j->jobid = jobid;
|
||||
j->state = JOB_STAT_PEND;
|
||||
j->sub_time = sub_time;
|
||||
j->queue_time = sub_time;
|
||||
j->start_time = 0;
|
||||
|
||||
j->submit = (job_submit_data_t*)calloc(1, sizeof(*(j->submit)));
|
||||
j->submit->queue = strdup(queue);
|
||||
j->submit->user = strdup(user);
|
||||
j->submit->project_name = strdup("contention_proj");
|
||||
j->submit->resreq = strdup("type == any");
|
||||
j->submit->mintasks = 1;
|
||||
j->submit->maxtasks = 1;
|
||||
|
||||
return j;
|
||||
}
|
||||
|
||||
static void enqueue_queue_update(xce_scheduler_t* sched, const char* name,
|
||||
int32_t priority)
|
||||
{
|
||||
xce_sched_update_request_t* req
|
||||
= (xce_sched_update_request_t*)calloc(1, sizeof(*req));
|
||||
|
||||
req->op = XCE_SCHED_OP_UPDATE_QUEUE;
|
||||
req->u.queueupdate.queue = make_queue(name, priority);
|
||||
|
||||
dataq_enq(sched->core.pend_updates[0], &(req->link));
|
||||
}
|
||||
|
||||
static void enqueue_jobs_push(xce_scheduler_t* sched, xce_job_t** jobs,
|
||||
int njobs)
|
||||
{
|
||||
xce_sched_update_request_t* req
|
||||
= (xce_sched_update_request_t*)calloc(1, sizeof(*req));
|
||||
|
||||
req->op = XCE_SCHED_OP_UPDATE_JOB;
|
||||
req->u.jobupdate.njobs = njobs;
|
||||
req->u.jobupdate.jobs = jobs;
|
||||
|
||||
dataq_enq(sched->core.pend_updates[0], &(req->link));
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Picker thread -- identical to test_sim_equivalence.c's own */
|
||||
/* picker_thread_fn(): resolves and drives the REAL builtin_fcfs picker. */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
static void* picker_thread_fn(void* arg)
|
||||
{
|
||||
xce_sched_core_t* core = (xce_sched_core_t*)arg;
|
||||
xce_sched_plugin_t* job_picker = NULL;
|
||||
int rc;
|
||||
int go = 0;
|
||||
uint64_t jobid;
|
||||
|
||||
rc = sched_resolve_job_picker(core, &job_picker);
|
||||
if (rc < 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cbuf_get(&(core->wbuf), &go);
|
||||
|
||||
job_picker->picker_iface->begin_session(job_picker);
|
||||
|
||||
for (;;) {
|
||||
xce_sched_job_t* job = job_picker->picker_iface->next_job(job_picker);
|
||||
|
||||
if (job == NULL) {
|
||||
break;
|
||||
}
|
||||
|
||||
jobid = job->jobid;
|
||||
cbuf_put(&(core->jbuf), &jobid);
|
||||
}
|
||||
|
||||
jobid = 0;
|
||||
cbuf_put(&(core->jbuf), &jobid);
|
||||
|
||||
job_picker->picker_iface->end_session(job_picker);
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Capacity-aware stand-in allocator -- unlike test_sim_equivalence.c's own */
|
||||
/* stand_in_allocator_thread_fn() (which unconditionally PENDs), this one */
|
||||
/* drives the REAL sched_check_host_satisfy()/sched_charge_account()/ */
|
||||
/* sched_commit_job_alloc() (resource.c) against the real worker capacity */
|
||||
/* fixture_init() set up -- see this file's own header comment for why a */
|
||||
/* per-job "want" lookup table stands in for full resreq-DSL compilation. */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
static float lookup_want(uint64_t jobid)
|
||||
{
|
||||
if (jobid == JOBA_ID) {
|
||||
return JOBA_WANT;
|
||||
}
|
||||
if (jobid == JOBB_ID) {
|
||||
return JOBB_WANT;
|
||||
}
|
||||
if (jobid == JOBC_ID) {
|
||||
return JOBC_WANT;
|
||||
}
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
/* Builds the wire-shaped xce_job_alloc_t a DISPATCH decision carries -- same
|
||||
* field shape test_sim_verify.c's own make_test_alloc()/make_test_host_alloc()/
|
||||
* make_test_job_alloc() establish. */
|
||||
static xce_job_alloc_t* make_dispatch_alloc(uint64_t jobid, const char* host,
|
||||
float value)
|
||||
{
|
||||
xce_alloc_t* res = (xce_alloc_t*)calloc(1, sizeof(*res));
|
||||
xce_host_alloc_t* h = (xce_host_alloc_t*)calloc(1, sizeof(*h));
|
||||
xce_host_alloc_t** hosts = (xce_host_alloc_t**)calloc(1, sizeof(*hosts));
|
||||
xce_alloc_t** allocs = (xce_alloc_t**)calloc(1, sizeof(*allocs));
|
||||
xce_job_alloc_t* j = (xce_job_alloc_t*)calloc(1, sizeof(*j));
|
||||
|
||||
TEST_ASSERT_NOT_NULL(res);
|
||||
TEST_ASSERT_NOT_NULL(h);
|
||||
TEST_ASSERT_NOT_NULL(hosts);
|
||||
TEST_ASSERT_NOT_NULL(allocs);
|
||||
TEST_ASSERT_NOT_NULL(j);
|
||||
|
||||
res->resname = strdup("slots");
|
||||
res->reserve = strdup("run");
|
||||
res->instid = 1;
|
||||
res->value = value;
|
||||
res->from = 0;
|
||||
res->duration = 0;
|
||||
|
||||
allocs[0] = res;
|
||||
|
||||
h->host = strdup(host);
|
||||
h->nallocs = 1;
|
||||
h->allocs = allocs;
|
||||
|
||||
hosts[0] = h;
|
||||
|
||||
j->jobid = jobid;
|
||||
j->nhosts = 1;
|
||||
j->allocs = hosts;
|
||||
j->nres_shared = 0;
|
||||
j->allocs_shared = NULL;
|
||||
|
||||
return j;
|
||||
}
|
||||
|
||||
/* Builds the accounting-charge shape sched_charge_account()/
|
||||
* sched_commit_job_alloc() consume -- same field shape
|
||||
* test_resource_accounting.c's own make_test_account() establishes,
|
||||
* simplified to one host charge (no shared-resource charge needed here). */
|
||||
static xce_job_account_t* make_charge_account(const char* host, float value)
|
||||
{
|
||||
xce_job_account_t* acct = (xce_job_account_t*)calloc(1, sizeof(*acct));
|
||||
|
||||
TEST_ASSERT_NOT_NULL(acct);
|
||||
|
||||
acct->nres_shared = 0;
|
||||
acct->accounts_shared = NULL;
|
||||
|
||||
acct->nhosts = 1;
|
||||
acct->accounts = (xce_host_account_t**)calloc(1, sizeof(*acct->accounts));
|
||||
TEST_ASSERT_NOT_NULL(acct->accounts);
|
||||
acct->accounts[0]
|
||||
= (xce_host_account_t*)calloc(1, sizeof(xce_host_account_t));
|
||||
TEST_ASSERT_NOT_NULL(acct->accounts[0]);
|
||||
acct->accounts[0]->host = strdup(host);
|
||||
acct->accounts[0]->naccount = 1;
|
||||
acct->accounts[0]->accounts
|
||||
= (xce_res_account_t**)calloc(1, sizeof(*acct->accounts[0]->accounts));
|
||||
TEST_ASSERT_NOT_NULL(acct->accounts[0]->accounts);
|
||||
acct->accounts[0]->accounts[0]
|
||||
= (xce_res_account_t*)calloc(1, sizeof(xce_res_account_t));
|
||||
TEST_ASSERT_NOT_NULL(acct->accounts[0]->accounts[0]);
|
||||
acct->accounts[0]->accounts[0]->residx = SLOTS_RESIDX;
|
||||
acct->accounts[0]->accounts[0]->used = value;
|
||||
acct->accounts[0]->accounts[0]->reserved = 0.0;
|
||||
|
||||
return acct;
|
||||
}
|
||||
|
||||
static void* capacity_aware_allocator_thread_fn(void* arg)
|
||||
{
|
||||
xce_scheduler_t* sched = (xce_scheduler_t*)arg;
|
||||
xce_sched_core_t* core = &(sched->core);
|
||||
xce_sched_plugin_t* picker = NULL;
|
||||
uint64_t jobid;
|
||||
|
||||
if (sched_resolve_job_picker(core, &picker) < 0) {
|
||||
xce_decision_t* decision = NULL;
|
||||
|
||||
cbuf_get(&(core->jbuf), &jobid); /* drain the lone 0 sentinel */
|
||||
cbuf_put(&(core->decisions), &decision);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
xce_decision_t* decision;
|
||||
xce_sched_job_t* job;
|
||||
xce_sched_worker_t* w;
|
||||
xvec_t uncomitted;
|
||||
xvec_idval_t wanted;
|
||||
xvec_idval_t unmeet;
|
||||
float want;
|
||||
int rc;
|
||||
|
||||
cbuf_get(&(core->jbuf), &jobid);
|
||||
if (jobid == 0) {
|
||||
decision = NULL;
|
||||
cbuf_put(&(core->decisions), &decision);
|
||||
break;
|
||||
}
|
||||
|
||||
job = sched_get_job(core, jobid);
|
||||
w = sched_get_worker(core, (char*)WORKER_NAME);
|
||||
TEST_ASSERT_NOT_NULL(job);
|
||||
TEST_ASSERT_NOT_NULL(w);
|
||||
|
||||
want = lookup_want(jobid);
|
||||
|
||||
/* uncomitted -- no in-flight, not-yet-committed reservations across
|
||||
* this fixture's strictly-sequential, single-threaded evaluation of
|
||||
* one candidate at a time (each job's charge is fully committed via
|
||||
* sched_commit_job_alloc() before the next candidate is checked), so
|
||||
* a fresh, zero-initialized vector is correct here, matching
|
||||
* sched_check_host_satisfy()'s own documented caller-owned
|
||||
* contract. */
|
||||
rc = xvec_init(&uncomitted, 1, sizeof(float));
|
||||
TEST_ASSERT_TRUE(rc >= 0);
|
||||
|
||||
rc = xvec_idval_init(&wanted, 1);
|
||||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||
rc = xvec_idval_store(&wanted, SLOTS_RESIDX, want);
|
||||
TEST_ASSERT_TRUE(rc >= 0);
|
||||
|
||||
rc = sched_check_host_satisfy(sched, w, &uncomitted, &wanted, &unmeet);
|
||||
|
||||
xvec_fini(&uncomitted);
|
||||
xvec_idval_fini(&wanted);
|
||||
xvec_idval_fini(&unmeet);
|
||||
|
||||
if (rc == 1) {
|
||||
/* Satisfied -- charge the real accounting state (resource.c,
|
||||
* task 1.1's relocated sched_charge_account()/
|
||||
* sched_commit_job_alloc()) so the NEXT candidate in this same
|
||||
* round sees updated resInUse, then report DISPATCH via the
|
||||
* real make_dispatch_decision() constructor -- mirrors
|
||||
* scheduler.c's real sched_allocator()'s own
|
||||
* on_dispatch/commit/decision sequence. */
|
||||
job->accounts = make_charge_account(WORKER_NAME, want);
|
||||
sched_commit_job_alloc(core, job);
|
||||
|
||||
job->allocs = make_dispatch_alloc(jobid, WORKER_NAME, want);
|
||||
|
||||
picker->picker_iface->on_dispatch(picker, jobid);
|
||||
decision = make_dispatch_decision(job->allocs);
|
||||
} else {
|
||||
/* Denied -- this job is genuinely blocked by capacity a
|
||||
* currently-running job (already charged above, earlier this
|
||||
* same round) is consuming. */
|
||||
picker->picker_iface->on_pend(picker, jobid);
|
||||
decision = make_pend_decision(make_pendreason(jobid));
|
||||
}
|
||||
|
||||
if (decision != NULL) {
|
||||
cbuf_put(&(core->decisions), &decision);
|
||||
}
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Runs one complete normal-mode round (real picker thread + this file's own
|
||||
* capacity-aware allocator thread) and blocks until it completes -- same
|
||||
* design as test_sim_equivalence.c's own run_one_real_session(). */
|
||||
static void run_one_real_session(xce_scheduler_t* sched)
|
||||
{
|
||||
pthread_t picker_tid;
|
||||
pthread_t allocator_tid;
|
||||
int rc;
|
||||
|
||||
rc = pthread_create(&picker_tid, NULL, picker_thread_fn, &(sched->core));
|
||||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||
|
||||
rc = pthread_create(&allocator_tid, NULL, capacity_aware_allocator_thread_fn,
|
||||
sched);
|
||||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||
|
||||
xce_sched_driver_run_normal_mode_round(sched);
|
||||
|
||||
rc = pthread_join(picker_tid, NULL);
|
||||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||
rc = pthread_join(allocator_tid, NULL);
|
||||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Decoding */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
/* This fixture pushes exactly 2 events in one batch (1 QUEUE_UPDATE + 1
|
||||
* UPDATE_JOB carrying all 3 jobs) before the round runs -- both
|
||||
* sched_record_start()'s own ReplayHeader+initial-SnapshotEntry pair and
|
||||
* those 2 events come before the ReplaySession message, same convention
|
||||
* test_sim_equivalence.c's own N_MSGS_BEFORE_SESSION documents (that file
|
||||
* has 3 events instead of this file's 2, since it pushes 2 queues). */
|
||||
#define N_MSGS_BEFORE_SESSION 4 /* ReplayHeader, SnapshotEntry, 2 events */
|
||||
|
||||
/* Opens `path`, skips exactly `n_skip` capnp messages structurally, then
|
||||
* decodes the NEXT message as a ReplaySession -- same technique
|
||||
* test_sim_equivalence.c's own decode_session_after_skip() establishes. */
|
||||
static replay_session_t* decode_session_after_skip(const char* path, int n_skip)
|
||||
{
|
||||
FILE* f;
|
||||
struct capn c;
|
||||
ReplaySession_ptr p;
|
||||
replay_session_t* dst = NULL;
|
||||
int i;
|
||||
|
||||
f = fopen(path, "r");
|
||||
TEST_ASSERT_NOT_NULL(f);
|
||||
|
||||
for (i = 0; i < n_skip; i++) {
|
||||
TEST_ASSERT_EQUAL_INT(0, capn_init_fp(&c, f, 0));
|
||||
capn_free(&c);
|
||||
}
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, capn_init_fp(&c, f, 0));
|
||||
p.p = capn_getp(capn_root(&c), 0, 1);
|
||||
decode_ReplaySession_ptr(&dst, p);
|
||||
capn_free(&c);
|
||||
|
||||
fclose(f);
|
||||
|
||||
return dst;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* The fixture's own shape proof (task 1.2's own Observable Completion). */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* test_contention_fixture_denies_capacity_to_a_job_a_running_job_holds -
|
||||
* builds the fixture (real recorded normal-mode session, 3 jobs, 1
|
||||
* capacity-limited worker), then decodes the recorded ReplaySession and
|
||||
* proves -- from the ACTUAL decoded decision stream, not merely this file's
|
||||
* own input constants -- that job A and job C dispatched (consuming the
|
||||
* worker's full 10.0-unit capacity between them) while job B was denied,
|
||||
* genuinely blocked by capacity job A (running) holds.
|
||||
*/
|
||||
static void
|
||||
test_contention_fixture_denies_capacity_to_a_job_a_running_job_holds(void)
|
||||
{
|
||||
char record_dir[PATH_MAX];
|
||||
xce_scheduler_t sched;
|
||||
char r_path[PATH_MAX];
|
||||
xce_job_t** jobs;
|
||||
replay_session_t* r_session = NULL;
|
||||
int rc;
|
||||
int i;
|
||||
int n_dispatch = 0;
|
||||
int n_pend = 0;
|
||||
int pend_jobid_ok = 0;
|
||||
float dispatched_total = 0.0f;
|
||||
|
||||
make_tmp_dir(record_dir, sizeof(record_dir), "_r");
|
||||
fixture_init(&sched, record_dir);
|
||||
|
||||
rc = sched_record_start(&sched, "contention.replay");
|
||||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||
TEST_ASSERT_TRUE(strlen(sched.record_file_path) < sizeof(r_path));
|
||||
strcpy(r_path, sched.record_file_path);
|
||||
|
||||
enqueue_queue_update(&sched, "normal", 1);
|
||||
|
||||
jobs = (xce_job_t**)calloc(3, sizeof(xce_job_t*));
|
||||
jobs[0] = make_job(JOBA_ID, 1000ULL, "normal", "alice");
|
||||
jobs[1] = make_job(JOBB_ID, 1001ULL, "normal", "bob");
|
||||
jobs[2] = make_job(JOBC_ID, 1002ULL, "normal", "carol");
|
||||
enqueue_jobs_push(&sched, jobs, 3);
|
||||
|
||||
run_one_real_session(&sched);
|
||||
|
||||
/* Sanity check on the recorded batch shape (1 queue update + 1 batched
|
||||
* jobs-push = 2 events) -- pins N_MSGS_BEFORE_SESSION's own assumption. */
|
||||
TEST_ASSERT_EQUAL_UINT64(2ULL, sched.record_event_count);
|
||||
|
||||
rc = sched_record_stop(&sched);
|
||||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||
|
||||
r_session = decode_session_after_skip(r_path, N_MSGS_BEFORE_SESSION);
|
||||
TEST_ASSERT_NOT_NULL(r_session);
|
||||
|
||||
/* Non-tautology guard: a fixture with fewer than 3 recorded decisions,
|
||||
* or one that is all-DISPATCH/all-PEND, would not prove multi-job
|
||||
* contention. */
|
||||
TEST_ASSERT_EQUAL_INT(3, r_session->n_decisions);
|
||||
|
||||
for (i = 0; i < r_session->n_decisions; i++) {
|
||||
replay_decision_t* d = r_session->decisions[i];
|
||||
|
||||
if (d->body_which == ReplayDecision_body_dispatch) {
|
||||
uint64_t jobid;
|
||||
float value;
|
||||
|
||||
TEST_ASSERT_NOT_NULL(d->body.dispatch);
|
||||
TEST_ASSERT_NOT_NULL(d->body.dispatch->alloc);
|
||||
jobid = d->body.dispatch->alloc->jobid;
|
||||
TEST_ASSERT_TRUE((jobid == JOBA_ID) || (jobid == JOBC_ID));
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(1, d->body.dispatch->alloc->nhosts);
|
||||
TEST_ASSERT_EQUAL_STRING(
|
||||
WORKER_NAME, d->body.dispatch->alloc->allocs[0]->host);
|
||||
value = d->body.dispatch->alloc->allocs[0]->allocs[0]->value;
|
||||
|
||||
if (jobid == JOBA_ID) {
|
||||
TEST_ASSERT_EQUAL_FLOAT(JOBA_WANT, value);
|
||||
} else {
|
||||
TEST_ASSERT_EQUAL_FLOAT(JOBC_WANT, value);
|
||||
}
|
||||
|
||||
dispatched_total += value;
|
||||
n_dispatch++;
|
||||
} else if (d->body_which == ReplayDecision_body_pend) {
|
||||
TEST_ASSERT_NOT_NULL(d->body.pend);
|
||||
if (d->body.pend->jobid == JOBB_ID) {
|
||||
pend_jobid_ok = 1;
|
||||
}
|
||||
n_pend++;
|
||||
} else {
|
||||
TEST_FAIL_MESSAGE("unexpected decision kind in contention "
|
||||
"fixture");
|
||||
}
|
||||
}
|
||||
|
||||
/* This task's own Observable Completion: exactly one job (B) is denied
|
||||
* capacity, exactly two (A, C) dispatch. */
|
||||
TEST_ASSERT_EQUAL_INT(2, n_dispatch);
|
||||
TEST_ASSERT_EQUAL_INT(1, n_pend);
|
||||
TEST_ASSERT_TRUE_MESSAGE(pend_jobid_ok,
|
||||
"the denied decision must genuinely be job B, "
|
||||
"not merely any PEND");
|
||||
|
||||
/* The combined-capacity proof: all three jobs' wants exceed total
|
||||
* capacity (insufficient for all of them at once), while the two jobs
|
||||
* that DID dispatch fit exactly within it -- i.e. job B was denied
|
||||
* capacity that jobs A and C, both running, are genuinely consuming. */
|
||||
TEST_ASSERT_TRUE((JOBA_WANT + JOBB_WANT + JOBC_WANT) > WORKER_CAPACITY);
|
||||
TEST_ASSERT_TRUE(dispatched_total <= WORKER_CAPACITY);
|
||||
TEST_ASSERT_TRUE((dispatched_total + JOBB_WANT) > WORKER_CAPACITY);
|
||||
|
||||
free_ReplaySession_ptr(&r_session);
|
||||
|
||||
printf("test_contention_fixture_denies_capacity_to_a_job_a_running_job_"
|
||||
"holds: PASS\n");
|
||||
}
|
||||
|
||||
/*
|
||||
* test_contention_fixture_is_replayable_via_simulation_driver - this task's
|
||||
* own "dual-use" requirement: the SAME `.replay` file the normal-mode
|
||||
* recording above produced is directly consumable by the REAL, unmodified
|
||||
* sched_sim_driver_run() as simulation-mode replay input, with no
|
||||
* conversion step. (Whether simulation mode's own resInUse/decision output
|
||||
* correctly reflects this fixture's contention is the later comparison
|
||||
* task 4.1 proves -- out of this task's own boundary; this test only proves
|
||||
* the file is a valid, replayable simulation-mode input.)
|
||||
*/
|
||||
static void test_contention_fixture_is_replayable_via_simulation_driver(void)
|
||||
{
|
||||
char record_dir[PATH_MAX];
|
||||
xce_scheduler_t sched;
|
||||
char r_path[PATH_MAX];
|
||||
xce_job_t** jobs;
|
||||
sched_sim_args_t sim_args;
|
||||
xce_sched_sim_driver_t sim;
|
||||
int rc;
|
||||
|
||||
make_tmp_dir(record_dir, sizeof(record_dir), "_dualuse");
|
||||
fixture_init(&sched, record_dir);
|
||||
|
||||
rc = sched_record_start(&sched, "contention_dualuse.replay");
|
||||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||
TEST_ASSERT_TRUE(strlen(sched.record_file_path) < sizeof(r_path));
|
||||
strcpy(r_path, sched.record_file_path);
|
||||
|
||||
enqueue_queue_update(&sched, "normal", 1);
|
||||
|
||||
jobs = (xce_job_t**)calloc(3, sizeof(xce_job_t*));
|
||||
jobs[0] = make_job(JOBA_ID, 1000ULL, "normal", "alice");
|
||||
jobs[1] = make_job(JOBB_ID, 1001ULL, "normal", "bob");
|
||||
jobs[2] = make_job(JOBC_ID, 1002ULL, "normal", "carol");
|
||||
enqueue_jobs_push(&sched, jobs, 3);
|
||||
|
||||
run_one_real_session(&sched);
|
||||
|
||||
rc = sched_record_stop(&sched);
|
||||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||
|
||||
memset(&sim_args, 0, sizeof(sim_args));
|
||||
sim_args.replay_path = r_path;
|
||||
sim_args.sim_start_ts = SCHED_SIM_START_TS_LATEST;
|
||||
sim_args.sim_speed = 0; /* frozen -- deterministic, no wall-clock skew */
|
||||
|
||||
memset(&sim, 0, sizeof(sim));
|
||||
rc = sched_sim_driver_run(&sim, &sim_args);
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(
|
||||
0, rc,
|
||||
"the same file the normal-mode recording produced must be directly "
|
||||
"replayable by the real, unmodified simulation driver");
|
||||
|
||||
printf("test_contention_fixture_is_replayable_via_simulation_driver: "
|
||||
"PASS\n");
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_contention_fixture_denies_capacity_to_a_job_a_running_job_holds);
|
||||
RUN_TEST(test_contention_fixture_is_replayable_via_simulation_driver);
|
||||
return UNITY_END();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue