src/docs/xce.md gains a 'Job arrays' section: name[1-N] and name[1], numbering from 1, the API-server limit (default 1000, at most 32767), <array-id>[k] and <array-id>[a-b] selection (with the peek/restart caveat), XCE_JOBINDEX, the harness jobsub -a N / $jobid[k], and every LSF array feature this release does not support. The superseded statements in job-array-single-wal-record and xce-test-e2e-primitives are annotated (history kept), the controller-job-cleanup and job-dependency briefs describe 1-based numbering, and the roadmap gains an entry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
11 KiB
Brief: controller-job-cleanup
Problem
Operators of long-running XCE clusters have no way to get rid of finished jobs.
- The controller keeps every job it has ever accepted in memory, forever. So do
the scheduler (
core->jobs) and the apiserver store. - Memory use, full dataset pushes (scheduler join, apiserver connect), and
xcecli jobs listoutput all grow without bound. - A hidden, hard-coded one-hour rule drops finished jobs only from snapshots,
not from memory. Nodes therefore disagree about which jobs exist:
- a restarted node holds a different job set than one that never restarted;
- a standby that installs a snapshot keeps jobs the snapshot omits.
Current State
Codebase exploration, 2026-09-29.
Job table
- Jobs live in
c->jobmgr, anxkvmgrtable with integer keys (controller/cluster.c:1668-1685). It has no delete operation:.del_key = NULL(src/lib/kvmgr.c:289, bug-1443). - The underlying pieces exist:
kvtab_del(kv.c:288) andvclear(the rollback path atkvmgr.c:107-128). - Jobs are reference-counted RTTI objects, and
job_obj_killer(cluster.c:1591) does nothing.
WAL
- There is no "job removed" entry type (
include/wal_types.h:132-310). Numbers 13-19 are free. - A new type has to be handled in:
- both apply paths,
apply_wal_entry(persist.c:4195) andapply_committed_entry(persist.c:4607), with identical decoding (persistence.md:68); wal_classify()(persist.c:7395, where an unclassified type asserts under XCE_TESTING);- the replay verdict.
persist_apply_verdict(persist.c:6660-6690) treats a shrinking job table as DIVERGENT, andapply_expectation_t(persist.h:162) has to count removals.
- both apply paths,
- Fact append pattern to copy:
persist_submit_job_fact_ex(persist.c:8105) →wal_hook_append_classified(..., WAL_MODE_RETAIN, ...).
The existing snapshot-only retention rule
job_should_retain()(persist.c:8695-8714) keeps a terminal (EXIT|DONE|PERR) job untilfinish_time + finished_retain_shas passed.finished_retain_sdefaults toDEFAULT_FINISHED_RETAIN_S=3600(include/config.h:91) and cannot be configured.- Its only caller is
collect_job_frames(persist.c:2513), which runs at snapshot time, per node, using that node'stime(NULL). - The same rule also expires TRANSIENT jobs after
transient_job_timeout_s. load_snapshot_entities(persist.c:3827) only adds jobs and never clears the table.
Terminal states
JOB_IS_FINISH= EXIT || DONE (include/xce/types.h:425-429).- Two definitions of "terminal" disagree:
job.c:45says DONE|EXIT, andpersist.c:8690says EXIT|DONE|PERR. finish_timeis set live (job.c:2756), carried in the WAL_JOB_FINISH payload, and restored on apply and resync.
Periodic, primary-only work
- Pattern:
ha_start_primary_heartbeat(ha.c:10142) usesdelay_task_ms(..., -1, fn, c), and the callback does nothing unlessrole == CTRL_PRIMARY. - Offload pattern:
checkpoint_task_fn(persist.c:3545-3592) runs on the delaymgr thread and callsslowlane_submit(...)(slowlane.c:185). - delaymgr is trigger-only (
ha-async-io.md).
Cluster config
- Schema
ClusterConfig(schema/controller.capnp:850-869); the next free ordinal is@15. - C struct:
cluster_config_t(include/clusterconfig.h). - YAML and validation:
configs/xce.yamlandconfigs/schema/xce.schema.json(additionalProperties:false). - xceadmin:
_XCE_SCALAR_FIELDS(xceadmin/src/xcecommon/mapping.py:176). - Defaults:
myconf.c:65-76. - Live config upgrade swaps
myconf->cluster_config(config_repl.c:747), so read the value on every sweep. - Model to follow:
worker_resync_timeout_ms.
Downstream
- Scheduler: no job-removed hook detail exists (
include/xce/cluster_def.h:43-67), andcore->jobsnever deletes. - Apiserver:
OnJobDeleted(backend/notification.go:11,handler.go:83) exists but nothing calls it. Reconnect never prunes the store. - libxce: querying a missing jobid returns 0 jobs with status 0
(
opget.c:159-176,xceapi.c:787). Control operations return-ENOENT. - Worker resync ignores unknown jobids (
resync.c:638-647), which is safe. - Array jobs: there is no parent object. Each element is an independent job with
its own jobid. Elements are numbered 1..N per
job-array-one-based-index(2026-10-01): element k of array<array-id>is<array-id>[k], k = 1..N (arrays submitted before that change may still have elements 0..N-1). The earlier note here, jobid in[base, base+array_size), described numbering from 0. - There is no accounting or history file, so a removed job leaves no record.
Job dependencies
- None exist (no field in
job_submit_data_t). The user asked for a stub.
Desired Outcome
- Finished jobs (DONE or EXIT) are removed permanently once they have been finished longer than a configured retention period and no other job depends on them.
- Every controller node removes the same jobs at the same point in the log. Removal survives replay, restart and failover, and snapshots match memory.
- The scheduler and apiserver drop removed jobs too, so a removed job disappears
from
xcecliand from REST. - Both periods can be configured in the cluster configuration, and cleanup can be turned off.
Approach
Chosen: approach A, a replicated removal record (user decision, 2026-09-29).
-
Trigger (primary only). A delaymgr timer fires every
job_cleanup_period. The callback returns unless this node is PRIMARY, and otherwise hands the sweep to the slow lane. It never blocks. -
Sweep (slow lane). Walk
jobmgrunder a read lock and pick jobs that meet all of these:- state is DONE or EXIT, not TRANSIENT;
finish_time + job_retention < now;- the dependency stub (
job_has_dependents(job), which always returns false for now) lets it go.
Append the chosen jobids as new WAL job-removal facts (RETAIN), batched and bounded per sweep.
-
Apply (every node, both apply paths). Remove the job from
jobmgrthrough a newxkvmgrdelete operation that drops the table's reference; in-flight holders keep the job alive until they release it. Update the replay-verdict expectations so that removals are counted. -
Propagate. Tell the scheduler to drop the job from
core->jobs(a new hook detail or event), and tell the apiserver (wire up the existingOnJobDeleted). -
Retire the snapshot-only filter for finished jobs, so a snapshot contains exactly the jobs in memory. The requirements decide what happens to the TRANSIENT-timeout part of
job_should_retain(keep it separate or fold it in).
Rejected alternatives:
- B, each node cleans up locally without replication. Clock skew makes nodes disagree, and the replay verdict flags a shrinking table as DIVERGENT.
- C, extend the snapshot filter. Still decided per node, and makes the existing drift permanent.
Scope
- In:
- Two cluster configuration parameters, both configurable (names are
provisional):
job_retention: how long a finished job is kept. Default 3600 s, matching today's snapshot rule. 0 turns cleanup off, and jobs are kept forever.job_cleanup_period: the sweep interval. Default 300 s.- Both need schema, YAML, JSON-schema validation, xceadmin mapping, defaults and live-upgrade pickup.
- The primary-only periodic sweep.
- The dependency-check stub.
- The new WAL removal fact, handled on both apply paths and in classification, with replay-verdict accounting.
- The
xkvmgrdelete operation, safe with reference counts. - Removal on every node.
- Scheduler and apiserver propagation.
- Retiring the snapshot-only filter for finished jobs, so snapshot and memory agree.
- Tests: unit, persistence round-trip, and live HA.
- Two cluster configuration parameters, both configurable (names are
provisional):
- Out:
- A real job-dependency feature. Only the stub is in scope.
- Accounting or job-history archiving before removal.
- Manual per-job purge commands.
- Cleaning up PEND, RUN or other non-terminal jobs.
- The lost-and-found drain (
roadmap.md).
Boundary Candidates
- libxce
xkvmgrdelete primitive (reference-count lifetime). - Controller cleanup policy: config, sweep, dependency stub.
- Persistence: removal fact, both apply paths, classification, replay verdict, snapshot alignment.
- Propagation: scheduler removal event, apiserver
OnJobDeleted.
Out of Boundary
- The job-dependency model and its submit option.
- Job history or accounting storage.
- How jobs are displayed (
job-status-detail-display).
Upstream / Downstream
- Upstream:
- The WAL and replication machinery (
ha-wal-replication,raft-apply-decoupling'sapply_committed_entryseam). ha-entity-snapshots(wherejob_should_retainlives).- The config pipeline (
clusterconfig-schema-split,config-upgrade-snapshot,admin-config-tool).
- The WAL and replication machinery (
- Downstream:
job-dependency(2026-09-29) replaces the stubjob_has_dependents()with a real check against the controller's in-memory reverse index. Keep the stub's signature stable so that swap is drop-in. A job is never removed while any pending job depends on it.- A future accounting or history feature, which archives before removal.
job-status-detail-display(a removed job simply stops appearing).
Existing Spec Touchpoints
- Extends:
ha-entity-snapshots. Its finished-job retention filter is retired or replaced; note this in that spec's history. - Adjacent:
worker-rejoin-resync(unknown jobids on resync)job-status-detail-displayscheduler-core(its job table)
Constraints
- HA/persistence spec: use the full 3-phase workflow, not
/kiro-spec-quick. - Only the primary decides a removal, and every removal goes through the replicated WAL. No node removes a job on its own.
- The delaymgr callback is trigger-only. The sweep runs on the slow lane.
- Save/restore field-completeness (
persistence.md):- the removal fact decodes identically on both apply paths;
- snapshot and memory agree after removal;
- run
/xce-audit-invariants controller(dimension 7).
- Wire and schema changes are additive Cap'n Proto only. Old WAL and snapshots
must still load. The requirements must also cover what happens when a node
without this feature meets the new fact (
WAL_ENTRY_TYPE_MAXskip-with-WARN). - Removal must not free a job that another thread still references.
- Sweeps must be bounded, so a large backlog does not create one giant WAL entry or stall the slow lane.
- Known edge case: a very late resubmit retry of a removed job's jobid is
accepted as a new job, because dedup checks
jobmgrpresence. Record it as accepted or mitigated.