feat(job-array-one-based-index): atomic reply-and-release of in-flight submissions (task 2.2)

In-flight registry entries carry is_array and array_id; two array
entries with the same array identity overlap. inflight_reply_and_remove()
sends the reply and unlinks the entry under the registry lock (xce_reply
is non-blocking) and is used on every post-registration path, inside the
async one-shot reply guard; jobsubmit_async_ctx_release no longer touches
the registry, so a retry registered after the reply cannot be deleted by
the original's release. Test-only seams under XCE_TESTING make the
atomicity observable deterministically.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-10-01 09:59:45 +00:00
parent 36c2c243d6
commit 81bfaf1f50
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
4 changed files with 413 additions and 32 deletions

View file

@ -22,6 +22,17 @@
* Log observation: this test binary never calls tlog_init(), so ERROR()
* falls through tlog's early-print path straight to STDOUT_FILENO (the
* established precedent of test_worker_join.c's capture_stdout_during()).
*
* Task 2.2 (Requirements 7.7, 7.10, 7.11): answering a registered
* submission and removing its in-flight registry entry are one step under
* the registry lock. The fixture's persist has no WAL (wal_fd -1 and a
* wal_path that cannot be created), so an admitted submission's local WAL
* append fails synchronously and its commit continuation runs the async
* failure path (reply once, then release the async context) inside
* handle_jobsubmit -- deterministic, no thread. jobsubmit_test_reply_hook
* observes the state between the reply and the unlink;
* jobsubmit_test_release_hook registers a retry between the reply and the
* context release.
*/
/* rtti.h must come first -- it defines 'var' and 'typeinfo_t' used by kvmgr.h
@ -176,6 +187,12 @@ static void jsv_setup(void)
0, xce_hook_init(&g_jsv_c.hooksys, XCE_CONTROLLER_HOOK_MAX));
g_jsv_c.jobmgr = makeInstance(&xkvmgr_type, &int_params);
TEST_ASSERT_NOT_NULL(g_jsv_c.jobmgr);
/* No WAL: an admitted submission's local append fails with -EIO (see
* the file header). */
g_jsv_c.persist.wal_fd = -1;
snprintf(g_jsv_c.persist.wal_path, sizeof(g_jsv_c.persist.wal_path),
"/nonexistent-xce-jsv/wal");
}
static void jsv_drain(jsv_reply_obs_t* obs);
@ -185,6 +202,8 @@ static void jsv_teardown(void)
jsv_reply_obs_t drain = { 0 };
jsv_drain(&drain); /* free any leftover packets */
jobsubmit_test_reply_hook = NULL;
jobsubmit_test_release_hook = NULL;
inflight_submits_reset_for_test();
if (g_jsv_c.jobmgr)
dereference(g_jsv_c.jobmgr);
@ -433,6 +452,253 @@ static void test_jobsubmit_invalid_classes_einval(void)
jsv_teardown();
}
/* ── Task 2.2: reply-and-remove atomicity ────────────────────────────── */
/* The array identity of jsv_elem(k): tag cleared, low 15 bits cleared. */
#define JSV_ARRAY_ID jsv_id(0, JSV_BLOCK)
typedef struct {
int calls;
int trylock_rc;
bool reply_queued;
int entry_present;
uint64_t base;
uint64_t size;
int is_array;
uint64_t array_id;
} jsv_hook_obs_t;
static jsv_hook_obs_t g_jsv_hook;
/* Runs inside inflight_reply_and_remove, after xce_reply, before the unlink,
* with the registry lock held: records (1) that the lock is held, (2) that
* the reply is already on the endpoint's outq, (3) that the entry is still
* registered (lock-free accessor, since the lock is held). */
static void jsv_reply_hook(void)
{
g_jsv_hook.calls += 1;
g_jsv_hook.trylock_rc = jobsubmit_inflight_trylock_for_test();
g_jsv_hook.reply_queued = (g_jsv_ep.outq.head != NULL);
g_jsv_hook.entry_present = jobsubmit_inflight_overlaps_locked_for_test(
g_jsv_hook.base, g_jsv_hook.size, g_jsv_hook.is_array,
g_jsv_hook.array_id);
}
static void jsv_expect_atomic_reply(uint64_t first, uint64_t size, int is_array,
uint64_t array_id, const char* what)
{
jsv_reply_obs_t obs = { 0 };
char* out = NULL;
char msg[200];
int rc;
jsv_setup();
memset(&g_jsv_hook, 0, sizeof(g_jsv_hook));
g_jsv_hook.base = first;
g_jsv_hook.size = size;
g_jsv_hook.is_array = is_array;
g_jsv_hook.array_id = array_id;
jobsubmit_test_reply_hook = jsv_reply_hook;
jsv_arrange(first, size, false);
rc = jsv_submit_capture(&out);
free(out);
snprintf(msg, sizeof(msg), "%s: the reply goes through the hook once",
what);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_jsv_hook.calls, msg);
snprintf(msg, sizeof(msg), "%s: registry lock held across the reply", what);
TEST_ASSERT_EQUAL_INT_MESSAGE(EBUSY, g_jsv_hook.trylock_rc, msg);
snprintf(msg, sizeof(msg), "%s: reply already queued inside the hook",
what);
TEST_ASSERT_TRUE_MESSAGE(g_jsv_hook.reply_queued, msg);
snprintf(msg, sizeof(msg), "%s: entry still registered inside the hook",
what);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_jsv_hook.entry_present, msg);
snprintf(msg, sizeof(msg), "%s: entry gone after the reply", what);
TEST_ASSERT_EQUAL_INT_MESSAGE(
0,
jobsubmit_inflight_overlaps_for_test(first, size, is_array, array_id),
msg);
(void)rc; /* the async failure path returns 0 from the handler */
jsv_drain(&obs);
snprintf(msg, sizeof(msg), "%s: exactly one reply", what);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, obs.n_replies, msg);
snprintf(msg, sizeof(msg), "%s: commit-failure status", what);
TEST_ASSERT_EQUAL_INT_MESSAGE(-EIO, obs.last_status, msg);
TEST_ASSERT_FALSE(jsv_job_exists(first));
jsv_teardown();
}
/* Single-job path: commit failure answered and released atomically. */
static void test_jobsubmit_reply_release_atomic_single(void)
{
jsv_expect_atomic_reply(jsv_id(0, 9400), 1, 0, 0, "single job");
}
/* Array path (handle_jobsubmit_array): the same, for an array of 3. */
static void test_jobsubmit_reply_release_atomic_array(void)
{
jsv_expect_atomic_reply(jsv_elem(1), 3, 1, JSV_ARRAY_ID, "array of 3");
}
/* One-element array (the single-job WAL path with a tagged ID). */
static void test_jobsubmit_reply_release_atomic_one_element_array(void)
{
jsv_expect_atomic_reply(jsv_elem(1), 1, 1, JSV_ARRAY_ID,
"one-element array");
}
static uint64_t g_jsv_retry_base, g_jsv_retry_size;
static int g_jsv_retry_added;
/* Runs when the original's async context is released, i.e. after its reply:
* a retry of the same (base, size) registers here, as it may once the
* original is answered (7.7). */
static void jsv_release_hook_register_retry(void)
{
if (g_jsv_retry_added == 0)
g_jsv_retry_added = jobsubmit_inflight_try_add_for_test(
g_jsv_retry_base, g_jsv_retry_size)
? 1
: -1;
}
static void jsv_expect_retry_survives(uint64_t first, uint64_t size,
int is_array, uint64_t array_id,
const char* what)
{
char* out = NULL;
char msg[200];
jsv_setup();
g_jsv_retry_base = first;
g_jsv_retry_size = size;
g_jsv_retry_added = 0;
jobsubmit_test_release_hook = jsv_release_hook_register_retry;
jsv_arrange(first, size, false);
(void)jsv_submit_capture(&out);
free(out);
snprintf(msg, sizeof(msg),
"%s: the retry registers after the original's reply", what);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_jsv_retry_added, msg);
snprintf(msg, sizeof(msg),
"%s: the original's context release leaves the retry "
"registered",
what);
TEST_ASSERT_EQUAL_INT_MESSAGE(
1,
jobsubmit_inflight_overlaps_for_test(first, size, is_array, array_id),
msg);
jsv_teardown();
}
static void test_jobsubmit_retry_survives_release_single(void)
{
jsv_expect_retry_survives(jsv_id(0, 9410), 1, 0, 0, "single job");
}
static void test_jobsubmit_retry_survives_release_array(void)
{
jsv_expect_retry_survives(jsv_elem(1), 3, 1, JSV_ARRAY_ID, "array of 3");
}
/* Registry overlap rule: element ranges intersect, or both entries are
* arrays with the same array identity. */
static void test_jobsubmit_inflight_overlap_rule(void)
{
const uint64_t other_block = (uint64_t)9 << N_ARRAY_IDX_BITS;
const uint64_t other_array = jsv_id(0, other_block);
jsv_setup();
/* X|1..X|2 registered as an array */
TEST_ASSERT_EQUAL_INT(1,
jobsubmit_inflight_try_add_for_test(jsv_elem(1), 2));
TEST_ASSERT_EQUAL_INT_MESSAGE(
1,
jobsubmit_inflight_overlaps_for_test(jsv_elem(2), 1, 1, JSV_ARRAY_ID),
"intersecting element range overlaps");
TEST_ASSERT_EQUAL_INT_MESSAGE(
1,
jobsubmit_inflight_overlaps_for_test(jsv_elem(5), 1, 1, JSV_ARRAY_ID),
"same array identity, disjoint elements: overlaps (7.10, 7.11)");
TEST_ASSERT_EQUAL_INT_MESSAGE(
0,
jobsubmit_inflight_overlaps_for_test(
jsv_id(TAG_JOB_ARRAY, other_block | 1), 3, 1, other_array),
"a different array identity does not overlap");
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, jobsubmit_inflight_overlaps_for_test(jsv_id(0, 9420), 1, 0, 0),
"an ordinary job outside the range does not overlap");
/* the registry refuses a second same-identity array */
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, jobsubmit_inflight_try_add_for_test(jsv_elem(5), 1),
"a same-identity array cannot register while the first is");
/* an ordinary entry is compared by range only */
TEST_ASSERT_EQUAL_INT(
1, jobsubmit_inflight_try_add_for_test(jsv_id(0, 9421), 1));
TEST_ASSERT_EQUAL_INT(
0, jobsubmit_inflight_overlaps_for_test(jsv_id(0, 9422), 1, 0, 0));
jsv_teardown();
}
/* 7.11: while X|1..X|2 is registered and unanswered, a submission X|5 with
* the same array identity is answered duplicate-submission (-EEXIST) and
* the first stays registered; once the first is answered (with a failure)
* a new submission with that identity is admitted (7.7). */
static void test_jobsubmit_same_array_identity_while_registered(void)
{
jsv_reply_obs_t obs = { 0 };
char* out = NULL;
int rc;
jsv_setup();
memset(&g_jsv_hook, 0, sizeof(g_jsv_hook));
jobsubmit_test_reply_hook = jsv_reply_hook;
TEST_ASSERT_EQUAL_INT(1,
jobsubmit_inflight_try_add_for_test(jsv_elem(1), 2));
jsv_arrange(jsv_elem(5), 1, false);
rc = jsv_submit_capture(&out);
free(out);
jsv_expect_rejected(rc, -EEXIST, jsv_elem(5), 1,
"same array identity while registered");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_jsv_hook.calls,
"a submission refused at registration "
"has no entry to release");
TEST_ASSERT_EQUAL_INT_MESSAGE(
1,
jobsubmit_inflight_overlaps_for_test(jsv_elem(1), 2, 1, JSV_ARRAY_ID),
"the first submission stays registered");
jsv_teardown();
/* answered: A fails its commit, then B with the same identity is
* admitted (it reaches the WAL and fails there too, not -EEXIST) */
jsv_setup();
jsv_arrange(jsv_elem(1), 2, false);
(void)jsv_submit_capture(&out);
free(out);
jsv_drain(&obs);
TEST_ASSERT_EQUAL_INT(-EIO, obs.last_status);
memset(&obs, 0, sizeof(obs));
jsv_arrange(jsv_elem(5), 1, false);
(void)jsv_submit_capture(&out);
free(out);
jsv_drain(&obs);
TEST_ASSERT_EQUAL_INT(1, obs.n_replies);
TEST_ASSERT_EQUAL_INT_MESSAGE(-EIO, obs.last_status,
"after the first is answered, a same-"
"identity submission is admitted");
jsv_teardown();
}
void test_jobsubmit_validate(void)
{
UnitySetTestFile(__FILE__);
@ -441,4 +707,11 @@ void test_jobsubmit_validate(void)
RUN_TEST(test_jobsubmit_index0_valid_fields_start0);
RUN_TEST(test_jobsubmit_index0_size0_start0);
RUN_TEST(test_jobsubmit_invalid_classes_einval);
RUN_TEST(test_jobsubmit_reply_release_atomic_single);
RUN_TEST(test_jobsubmit_reply_release_atomic_array);
RUN_TEST(test_jobsubmit_reply_release_atomic_one_element_array);
RUN_TEST(test_jobsubmit_retry_survives_release_single);
RUN_TEST(test_jobsubmit_retry_survives_release_array);
RUN_TEST(test_jobsubmit_inflight_overlap_rule);
RUN_TEST(test_jobsubmit_same_array_identity_while_registered);
}