feat(job-array-single-wal-record): implement single-record array append/commit

Adds persist_submit_job_array_async(): encodes one JobSubmit-shaped
payload (base jobid, array size, one shared spec), appends exactly
one WAL_JOB_ARRAY_SUBMIT entry, and registers exactly one commit
continuation regardless of array size -- reuses the existing
job_commit_adapter_ctx_t/job_commit_resolve_adapter machinery, no
per-element loop. Not yet wired into handle_jobsubmit (task 2.2).

Also fixes a test-helper bug (WAL offset back-computed from the
post-append cursor, only valid for zero-payload entries) that
corrupted state across tests when exercising a nonzero-payload
array-submit entry.

Task 2.1 of job-array-single-wal-record.
This commit is contained in:
Rongsong Shen 2026-07-14 09:50:00 +00:00
parent 76862bd9aa
commit 7c4afbbb83
4 changed files with 559 additions and 1 deletions

View file

@ -4925,6 +4925,159 @@ int persist_submit_job_payloads_async(persist_t* p, xlist_t* payloads,
return first_rc;
}
/* job-array-single-wal-record task 2.1: serialize a job-array submission
* (base jobid + array size + one shared job spec) to a malloc'd Cap'n Proto
* buffer, reusing the SAME capnp struct (JobSubmit) the client's own submit
* request uses -- mirrors capnp_encode_job above, which mirrors encode_Job/
* new_Job/write_Job for a single job, except here via encode_JobSubmit/
* new_JobSubmit/write_JobSubmit. The encoded payload is O(1) in array_size:
* arraySize is a fixed-width uint64 field, not a per-element list -- so this
* function's output size never grows with array_size (Req 7.1). Caller must
* free(*out) on success. */
static int capnp_encode_job_array(const xce_job_submit_t* s, uint8_t** out,
size_t* out_len)
{
struct capn c;
struct capn_segment* cs;
struct JobSubmit cjs;
JobSubmit_ptr jp;
capn_init_malloc(&c);
cs = capn_root(&c).seg;
encode_JobSubmit(cs, &cjs, (xce_job_submit_t*)s);
jp = new_JobSubmit(cs);
write_JobSubmit(&cjs, jp);
capn_setp(capn_root(&c), 0, jp.p);
*out_len = capn_size(&c);
*out = malloc(*out_len);
if (!*out) { capn_free(&c); return -ENOMEM; }
capn_write_mem(&c, *out, *out_len, 0);
capn_free(&c);
return 0;
}
/* job-array-single-wal-record task 2.1: append-only, single-record
* counterpart of append_job_wal_entry_local for a WHOLE job array. Encodes
* ONE JobSubmit-shaped payload (base_jobid, array_size, base_spec->submit)
* and appends it as exactly one WAL_JOB_ARRAY_SUBMIT entry via the SAME
* primary-append primitive append_job_wal_entry_local already uses
* (ha_primary_log_append / persist_wal_async) -- never one entry per array
* element, regardless of array_size (Req 1.1, 1.2, 1.3). base_jobid/
* array_size are taken as explicit parameters rather than read from
* base_spec->jobid/base_spec->array_size, so the caller's own
* already-range-claimed values are always what gets committed, independent
* of whatever same-shaped fields the client's own wire request happened to
* carry. */
static int append_job_array_wal_entry_local(persist_t* p, uint64_t base_jobid,
uint64_t array_size,
const xce_job_submit_t* base_spec,
uint64_t* out_lsn)
{
xce_job_submit_t wire = { .jobid = base_jobid,
.array_size = array_size,
.submit = base_spec->submit };
uint8_t* buf = NULL;
size_t sz = 0;
int rc;
uint64_t lsn;
if (capnp_encode_job_array(&wire, &buf, &sz) != 0)
return -ENOMEM;
/* Snapshot the LSN this entry will receive BEFORE the append (same
* convention as append_job_wal_entry_local / submit_job_wal_entry_and_
* commit: persist_wal_async / ha_primary_log_append assigns
* hdr.lsn = current_lsn then increments). */
pthread_mutex_lock(&p->lock);
lsn = p->current_lsn;
pthread_mutex_unlock(&p->lock);
if (p->cluster != NULL)
rc = ha_primary_log_append(p->cluster, WAL_JOB_ARRAY_SUBMIT, buf,
(uint32_t)sz, NULL);
else
rc = persist_wal_async(p, WAL_JOB_ARRAY_SUBMIT, buf, (uint32_t)sz);
free(buf);
if (rc == 0)
*out_lsn = lsn;
return rc;
}
/* job-array-single-wal-record task 2.1 (design.md §WAL Array Submission,
* Service Interface): encodes and appends ONE array-shaped WAL entry
* (WAL_JOB_ARRAY_SUBMIT) and registers ONE commit continuation for it,
* regardless of array_size -- the single-record sibling of
* persist_submit_job_payloads_async's per-payload loop, specialized to
* exactly one payload. No job_t/xce_job_t object is constructed here or
* anywhere on this call path -- array expansion into individual job
* entries happens later, in the shared apply seam (task 3.x's
* restore_job_array), never here.
*
* Mirrors persist_submit_job_payloads_async's contract exactly:
* - a local encode/append failure resolves `fn` synchronously with that
* failure and returns the same negative rc;
* - the LSN-0 / engine-not-wired edge cases (documented on
* persist_submit_job_payloads_async above) resolve `fn` inline with
* rc=0;
* - otherwise `fn` is registered via commit_submit_async and invoked
* exactly once, asynchronously, once the entry's commit outcome is
* known. */
int persist_submit_job_array_async(persist_t* p, uint64_t base_jobid,
uint64_t array_size,
const xce_job_submit_t* base_spec,
uint64_t* out_lsn,
persist_job_commit_notify_fn fn, void* ctx)
{
uint64_t lsn = 0;
int rc;
if (p == NULL || base_spec == NULL || base_spec->submit == NULL ||
out_lsn == NULL || fn == NULL || array_size <= 1)
return -EINVAL;
rc = append_job_array_wal_entry_local(p, base_jobid, array_size,
base_spec, &lsn);
if (rc != 0) {
fn(ctx, 0, rc);
return rc;
}
*out_lsn = lsn;
/* LSN-0 / engine-not-wired edge (see persist_submit_job_payloads_async's
* doc comment above for the full rationale -- identical here,
* specialized to one entry instead of a loop). */
if (lsn == 0 || p->repl.ce == NULL) {
fn(ctx, lsn, 0);
return 0;
}
job_commit_adapter_ctx_t* a = calloc(1, sizeof(*a));
if (a == NULL) {
fn(ctx, lsn, -ENOMEM);
return -ENOMEM;
}
a->fn = fn;
a->ctx = ctx;
int reg_rc = commit_submit_async(p->repl.ce, lsn,
job_commit_resolve_adapter, a);
if (reg_rc != 0) {
free(a);
ERROR("persist_submit_job_array_async: lsn=%lu appended but "
"commit_submit_async registration failed (rc=%d) -- the "
"entry remains durable and will still be caught up "
"eventually via replay, but this caller will not be told "
"its outcome via this path",
(unsigned long)lsn, reg_rc);
fn(ctx, lsn, reg_rc);
return reg_rc;
}
return 0;
}
void persist_register_pending_apply(persist_t* p, uint64_t lsn,
pending_apply_notify_fn on_applied,
void* ctx)

View file

@ -1724,6 +1724,364 @@ static void test_config_ack_wire_path_completes_barrier(void)
printf("test_config_ack_wire_path_completes_barrier: PASS\n");
}
/* ════════════════════════════════════════════════════════════════════════
* job-array-single-wal-record task 2.1 — persist_submit_job_array_async:
* the single-record array append and commit path (Req 1.1, 1.2, 1.3, 4.2,
* 9.2). Fixture mirrors test_fact_hook_append_is_retained_and_nonblocking
* above (standalone persist_t, no cluster_t -- p->cluster stays NULL so
* the append routes through the persist_wal_async fallback branch inside
* append_job_array_wal_entry_local; real tmpdir WAL; wired commit engine
* with 2 stub standbys; ACKs driven through repl_on_ack). commit_engine_init
* here never wires a vtaskmgr pool (ce->pool stays NULL), so
* commit_resolve_pending's continuation dispatch is the synchronous,
* same-thread inline fallback (cerebrum precedent: "the NO-POOL
* inline-dispatch fallback calls fn() synchronously ON THE CALLING
* THREAD") -- a covering repl_on_ack call resolves a registered
* continuation before it returns, so no polling is needed anywhere below.
*
* Deliberately calls persist_submit_job_array_async() DIRECTLY -- job.c's
* handle_jobsubmit is not wired to this function yet (that is task 2.2's
* boundary, not this one's); nothing in job.c/handle_jobsubmit is touched
* here.
* ════════════════════════════════════════════════════════════════════════ */
static persist_t g_jap; /* stub persist for array-submit tests */
static commit_engine_t g_jace;
static int g_jaep_a, g_jaep_b; /* dummy standby endpoints */
static char g_jap_tmpdir[256];
static void jap_setup(uint32_t sync_timeout_ms)
{
persist_config_t cfg;
memset(&g_jap, 0, sizeof(g_jap));
cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
cfg.sync_timeout_ms = sync_timeout_ms;
snprintf(g_jap_tmpdir, sizeof(g_jap_tmpdir), "/tmp/xce_jap21_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(g_jap_tmpdir));
persist_set_workdir(&g_jap, g_jap_tmpdir, "ctrl");
TEST_ASSERT_EQUAL_INT(0, persist_init(&g_jap, &cfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(&g_jap));
/* 3-member cluster: 2 stub standbys, engine wired, ACKs withheld until
* a test explicitly delivers them via repl_on_ack. */
TEST_ASSERT_TRUE(
repl_add_standby(&g_jap.repl, &g_jaep_a, "jasb_a", 7420) >= 0);
TEST_ASSERT_TRUE(
repl_add_standby(&g_jap.repl, &g_jaep_b, "jasb_b", 7421) >= 0);
TEST_ASSERT_EQUAL_INT(0, commit_engine_init(&g_jace, &g_jap, 3, 8));
repl_set_commit_engine(&g_jap.repl, &g_jace);
repl_broadcast_send_hook = cbr_drop_send_hook;
/* Reserved-0 contract: a fresh store starts current_lsn=1 -- the first
* array record lands at LSN 1 with no priming append needed. */
TEST_ASSERT_EQUAL_UINT64(1, g_jap.current_lsn);
}
static void jap_teardown(void)
{
repl_broadcast_send_hook = NULL;
commit_engine_fini(&g_jace);
persist_stop(&g_jap);
persist_fini(&g_jap);
/* tmpdir left on disk — /tmp is ephemeral */
}
/* Minimal job_submit_data_t + xce_job_submit_t base spec -- same shape as
* cbrp_arrange_jobsubmit (test_commit_barrier_reply.c) and
* test_data_job_array_variant_round_trip (test_e2e_capnp.c); start_info
* left NULL, which that round-trip test already proves
* encode_JobStartInfo_ptr handles safely. */
static job_submit_data_t g_jap_submit;
static xce_job_submit_t g_jap_spec;
static void jap_arrange_base_spec(void)
{
memset(&g_jap_submit, 0, sizeof(g_jap_submit));
g_jap_submit.queue = (char *)"normal";
g_jap_submit.user = (char *)"tester";
g_jap_submit.mintasks = 1;
g_jap_submit.maxtasks = 1;
g_jap_submit.start_info = NULL;
g_jap_submit.job_name = (char *)"array-job";
memset(&g_jap_spec, 0, sizeof(g_jap_spec));
/* Deliberately NOT set to a test's real base_jobid/array_size: proves
* persist_submit_job_array_async uses its own explicit base_jobid/
* array_size PARAMETERS, never these same-shaped struct fields. */
g_jap_spec.jobid = 0;
g_jap_spec.array_size = 0;
g_jap_spec.submit = &g_jap_submit;
}
/* Read the WAL entry header located at explicit segment offset `at` back
* from the active segment file. Unlike read_last_wal_hdr in
* test_ha_role_lifecycle.c (which back-computes `seg_offset -
* sizeof(hdr)` and is only ever used for zero-payload WAL_NOOP entries,
* where that arithmetic happens to land on the header), this helper takes
* the header's start offset EXPLICITLY -- the caller snapshots
* p->seg_offset immediately before the append it wants to read back.
* Back-computing from the post-append p->seg_offset is only valid when
* payload_len == 0; every WAL_JOB_ARRAY_SUBMIT entry here carries a
* nonzero capnp payload, so `seg_offset - sizeof(hdr)` would land inside
* the payload bytes, not the header (caught by task 2.1 RED: read garbage
* decoded as a bogus type). */
static int jap_read_wal_hdr_at(persist_t *p, off_t at, wal_entry_hdr_t *out)
{
if (at < 0 || p->seg_offset < (uint64_t)at + sizeof(wal_entry_hdr_t))
return -ENOENT;
int fd = open(p->seg_path, O_RDONLY);
if (fd < 0)
return -errno;
ssize_t r = pread(fd, out, sizeof(*out), at);
close(fd);
if (r != (ssize_t)sizeof(*out))
return -EIO;
return 0;
}
/* Read the header AND payload bytes of the WAL entry located at explicit
* segment offset `at` back from the active segment file. Caller must
* free(*out_payload) (NULL/0-len payload leaves *out_payload NULL). */
static int jap_read_wal_entry_at(persist_t *p, off_t at,
wal_entry_hdr_t *out_hdr,
uint8_t **out_payload)
{
int rc = jap_read_wal_hdr_at(p, at, out_hdr);
if (rc != 0)
return rc;
if (out_hdr->payload_len == 0) {
*out_payload = NULL;
return 0;
}
int fd = open(p->seg_path, O_RDONLY);
if (fd < 0)
return -errno;
off_t payload_at = at + (off_t)sizeof(wal_entry_hdr_t);
uint8_t *buf = (uint8_t *)malloc(out_hdr->payload_len);
TEST_ASSERT_NOT_NULL(buf);
ssize_t r = pread(fd, buf, out_hdr->payload_len, payload_at);
close(fd);
if (r != (ssize_t)out_hdr->payload_len) {
free(buf);
return -EIO;
}
*out_payload = buf;
return 0;
}
/* Notify-callback capture (persist_job_commit_notify_fn). */
typedef struct {
int call_count;
uint64_t last_lsn;
int last_rc;
} jap_notify_obs_t;
static jap_notify_obs_t g_jap_obs;
static void jap_notify_reset(void)
{
memset(&g_jap_obs, 0, sizeof(g_jap_obs));
}
static void jap_notify_fn(void *ctx, uint64_t lsn, int rc)
{
jap_notify_obs_t *obs = (jap_notify_obs_t *)ctx;
obs->call_count++;
obs->last_lsn = lsn;
obs->last_rc = rc;
}
/* ── One WAL append, one commit continuation, decodable back to the same
* (base_jobid, array_size, submit spec) — Req 1.1, 1.2, 1.3, 4.2, 9.2 ── */
static void test_job_array_submit_appends_exactly_one_wal_entry_and_one_continuation(void)
{
const uint64_t base_jobid = 9500;
const uint64_t array_size = 5;
uint64_t lsn_before, out_lsn = 0;
int rc;
off_t entry_off;
wal_entry_hdr_t hdr;
uint8_t *payload = NULL;
/* Arrange */
jap_setup(2000);
jap_arrange_base_spec();
jap_notify_reset();
lsn_before = g_jap.current_lsn;
entry_off = (off_t)g_jap.seg_offset;
/* Act: append the array record; the continuation is registered but not
* yet resolved (no covering ACK delivered yet). */
rc = persist_submit_job_array_async(&g_jap, base_jobid, array_size,
&g_jap_spec, &out_lsn,
jap_notify_fn, &g_jap_obs);
/* Assert: exactly one WAL entry was appended -- current_lsn advanced by
* exactly 1, regardless of array_size=5 */
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
"a valid array submission must append and register successfully");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(lsn_before, out_lsn,
"the array record must land at the pre-append current_lsn");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(lsn_before + 1, g_jap.current_lsn,
"exactly ONE WAL entry must be appended for the whole array, "
"not one per element");
/* ...and it is the ONE entry on disk, typed WAL_JOB_ARRAY_SUBMIT,
* decodable back to the same (base_jobid, array_size, submit spec) --
* NOT g_jap_spec's own (deliberately mismatched) jobid/array_size
* fields, proving the function used its explicit parameters */
TEST_ASSERT_EQUAL_INT(0,
jap_read_wal_entry_at(&g_jap, entry_off, &hdr, &payload));
TEST_ASSERT_EQUAL_UINT32_MESSAGE((uint32_t)WAL_JOB_ARRAY_SUBMIT, hdr.type,
"the array record's WAL type must be WAL_JOB_ARRAY_SUBMIT");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(out_lsn, hdr.lsn,
"the on-disk entry's lsn must match the returned out_lsn");
{
struct capn cctx;
TEST_ASSERT_EQUAL_INT(0,
capn_init_mem(&cctx, payload, hdr.payload_len, 0));
JobSubmit_ptr jp;
jp.p = capn_getp(capn_root(&cctx), 0, 1);
TEST_ASSERT_TRUE(jp.p.type != CAPN_NULL);
struct JobSubmit wire;
memset(&wire, 0, sizeof(wire));
read_JobSubmit(&wire, jp);
xce_job_submit_t decoded;
memset(&decoded, 0, sizeof(decoded));
decode_JobSubmit(&decoded, &wire);
TEST_ASSERT_EQUAL_UINT64_MESSAGE(base_jobid, decoded.jobid,
"decoded base jobid must match the base_jobid PARAMETER");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(array_size, decoded.array_size,
"decoded array_size must match the array_size PARAMETER");
TEST_ASSERT_NOT_NULL(decoded.submit);
TEST_ASSERT_EQUAL_STRING("normal", decoded.submit->queue);
TEST_ASSERT_EQUAL_STRING("tester", decoded.submit->user);
free_JobSubmit(&decoded);
capn_free(&cctx);
}
free(payload);
/* ...exactly one continuation is registered -- not yet resolved before
* any ACK arrives */
TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_jap_obs.call_count,
"the notify callback must not fire before a covering commit");
/* ...and fires EXACTLY ONCE, with rc==0, once a covering majority ACK
* resolves the single registered continuation */
TEST_ASSERT_EQUAL_INT(0, persist_flush(&g_jap));
repl_on_ack(&g_jap.repl, &g_jaep_a, out_lsn);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_jap_obs.call_count,
"the notify callback must fire EXACTLY ONCE for the whole array, "
"not once per element");
TEST_ASSERT_EQUAL_UINT64(out_lsn, g_jap_obs.last_lsn);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_jap_obs.last_rc,
"a majority-committed array record must resolve rc=0");
jap_teardown();
printf("test_job_array_submit_appends_exactly_one_wal_entry_and_one_continuation: PASS\n");
}
/* ── Encoded payload size does not depend on array_size (Req 7.1, 7.2) ──── */
static void test_job_array_submit_payload_size_independent_of_array_size(void)
{
wal_entry_hdr_t hdr_small, hdr_large;
uint64_t out_lsn = 0;
int rc;
off_t off_small, off_large;
/* Arrange */
jap_setup(2000);
jap_arrange_base_spec();
jap_notify_reset();
/* Act: array_size=2, then array_size=10000, SAME base spec -- both
* entries encoded through the identical code path in the same fixture
* so their on-disk payload lengths are directly comparable. */
off_small = (off_t)g_jap.seg_offset;
rc = persist_submit_job_array_async(&g_jap, 9600, 2, &g_jap_spec,
&out_lsn, jap_notify_fn, &g_jap_obs);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, jap_read_wal_hdr_at(&g_jap, off_small, &hdr_small));
jap_notify_reset();
off_large = (off_t)g_jap.seg_offset;
rc = persist_submit_job_array_async(&g_jap, 20000, 10000, &g_jap_spec,
&out_lsn, jap_notify_fn, &g_jap_obs);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, jap_read_wal_hdr_at(&g_jap, off_large, &hdr_large));
/* Assert: the encoded payload's size does not depend on array_size --
* arraySize is a fixed-width uint64 field, not a per-element list */
TEST_ASSERT_EQUAL_UINT32_MESSAGE(hdr_small.payload_len,
hdr_large.payload_len,
"the WAL_JOB_ARRAY_SUBMIT payload size must be IDENTICAL for "
"array_size=2 and array_size=10000 -- only the fixed-width "
"arraySize field differs, never the encoded byte length");
/* Cleanup: resolve both pending continuations with one covering ACK
* before teardown (hygiene — avoids leaving unresolved slots for
* commit_engine_fini to silently drop). */
TEST_ASSERT_EQUAL_INT(0, persist_flush(&g_jap));
repl_on_ack(&g_jap.repl, &g_jaep_a, hdr_large.lsn);
jap_teardown();
printf("test_job_array_submit_payload_size_independent_of_array_size: PASS\n");
}
/* ── Invalid-argument rejection (defensive input validation) ────────────── */
static void test_job_array_submit_rejects_array_size_one_or_less(void)
{
uint64_t out_lsn = 0;
int rc;
/* Arrange */
jap_setup(2000);
jap_arrange_base_spec();
jap_notify_reset();
/* Act / Assert: array_size<=1 is a single-job submission's shape, not
* this function's precondition (design.md: "array_size > 1") -- reject
* before any WAL append, no continuation registered, no callback fired */
rc = persist_submit_job_array_async(&g_jap, 9700, 1, &g_jap_spec,
&out_lsn, jap_notify_fn, &g_jap_obs);
TEST_ASSERT_EQUAL_INT_MESSAGE(-EINVAL, rc,
"array_size == 1 must be rejected -- not this function's job");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(1, g_jap.current_lsn,
"a rejected submission must never append a WAL entry");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_jap_obs.call_count,
"a rejected submission's callback contract is 'never called', not "
"'called with an error' — the caller never registered anything");
rc = persist_submit_job_array_async(&g_jap, 9700, 0, &g_jap_spec,
&out_lsn, jap_notify_fn, &g_jap_obs);
TEST_ASSERT_EQUAL_INT_MESSAGE(-EINVAL, rc,
"array_size == 0 must be rejected");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(1, g_jap.current_lsn,
"a rejected submission must never append a WAL entry");
jap_teardown();
printf("test_job_array_submit_rejects_array_size_one_or_less: PASS\n");
}
/* ── Test group entry point ──────────────────────────────────────────────── */
void test_commit_barrier_rollout(void)
@ -1767,5 +2125,10 @@ void test_commit_barrier_rollout(void)
/* Task 5.2 remediation r1 — production wire-path ACK ingestion
* (completes the unlanded half of ha-config-bootstrap task 8.1) */
RUN_TEST(test_config_ack_wire_path_completes_barrier);
/* job-array-single-wal-record task 2.1 — persist_submit_job_array_async:
* single-record array append/commit path (Req 1.1, 1.2, 1.3, 4.2, 9.2) */
RUN_TEST(test_job_array_submit_appends_exactly_one_wal_entry_and_one_continuation);
RUN_TEST(test_job_array_submit_payload_size_independent_of_array_size);
RUN_TEST(test_job_array_submit_rejects_array_size_one_or_less);
UNITY_END();
}

View file

@ -10,6 +10,13 @@
#include "wal_types.h" /* wal_entry_hdr_t, wal_entry_type — P2-4: shared with repl.h */
#include "repl.h" /* replicator_t — P2-4: embedded in persist_t */
#include "xlist.h" /* xlist_t — persist_submit_wal_and_commit job batch */
#include "xce/types.h" /* xce_job_submit_t — job-array-single-wal-record task 2.1:
* persist_submit_job_array_async's base_spec parameter.
* Header-guarded (_XCE_TYPES_H_), no circular dependency
* on persist.h — safe to pull in this early; the later,
* pre-existing #include "xce/types.h" further down this
* file (GC retention predicates) is now a harmless
* no-op re-inclusion. */
/* Forward declaration */
typedef struct cluster_s cluster_t;
@ -1077,6 +1084,41 @@ int persist_submit_job_payloads_async(persist_t* p, xlist_t* payloads,
persist_job_commit_notify_fn fn,
void* ctx);
/* job-array-single-wal-record task 2.1 (design.md §WAL Array Submission,
* Service Interface): encodes and appends the WHOLE array as ONE
* WAL_JOB_ARRAY_SUBMIT entry (base_jobid, array_size, one shared
* base_spec->submit) and registers ONE commit continuation for it,
* regardless of array_size -- the single-record sibling of
* persist_submit_job_payloads_async's per-payload loop. No job_t/xce_job_t
* object is constructed here or anywhere on this call path (mirrors the
* "nothing exists until apply" invariant persist_submit_job_payloads_async
* already documents) -- array expansion into individual job entries happens
* later, in the shared apply seam (job-array-single-wal-record task 3.x).
*
* Preconditions: array_size > 1 (array_size <= 1 returns -EINVAL; a
* single-job submission stays on the unmodified persist_submit_wal_and_
* commit / persist_submit_job_payloads_async path); base_spec != NULL and
* base_spec->submit != NULL; the caller has already range-claimed
* [base_jobid, base_jobid + array_size) (job.c's inflight_range_try_add) --
* not re-validated here, same division of responsibility as the payload-
* loop sibling above.
*
* Postconditions: on success, exactly one WAL entry is durably appended
* locally (*out_lsn set to its LSN) and exactly one continuation is
* registered; `fn` is invoked exactly once with the outcome -- either
* synchronously, right here (a local encode/append failure, the once-per-
* cluster-lifetime LSN-0 edge, or an engine-not-wired context), or later,
* asynchronously, once the entry's commit outcome is known. Returns 0 once
* the entry is durably appended locally and its outcome resolution path is
* established (sync or async); a negative rc otherwise -- `fn` has already
* been invoked (or is already guaranteed to be invoked later) by the time
* this function returns, regardless of its return value. */
int persist_submit_job_array_async(persist_t* p, uint64_t base_jobid,
uint64_t array_size,
const xce_job_submit_t* base_spec,
uint64_t* out_lsn,
persist_job_commit_notify_fn fn, void* ctx);
/* raft-apply-decoupling task 3.2: producer-side hand-off for a command
* whose commit_submit_async() continuation resolved COMMITTED. Per task
* 3.2's own contract, a COMMITTED outcome must NOT apply state or reply to