Module 2 · Hands-on · about 30 min

Baseline Pattern: Single-Node Service + Workers

Run the smallest service-and-workers pattern inside one allocation, with readiness before workers and verification before success.

Tested

What this means for you
Tested
  • The example workflow ran start to finish and every result was correct. A second run was made to fail on purpose; it stopped at the check that the coordinator can serve (the readiness gate), before any worker started.
You need
  • Access to one compute node through Slurm. The test used one node on a shared partition.
  • Apptainer on the compute nodes.
  • A private folder for the job that you can write to (job-scoped scratch).
Depends on your center
  • Your account and partition names. You supply them when you submit the job.
  • How Apptainer is made available: on the default command path, or through a module your center provides.
  • Which scratch location the job uses. Centers provide it in different ways.
  • The coordinator listens only on loopback (127.0.0.1); reaching it from beyond the node needs access your center approves.
  • Whether each worker can run as its own job step (a program the scheduler launches and tracks inside your allocation) on a shared node.
  • Whether you can rebuild the container image yourself. On the newer Apptainer used for the latest test, the release’s image build script could not run without extra privileges, so the tested image is the earlier build with the same fingerprint (digest).

What hasn’t been tested yet

In this module

Prerequisites

Required resources

Your Compose stack, before

The baseline is a three-part Compose stack, sketched in Scheduler as Orchestrator: a coordinator that hands out work, workers that do it, and a one-shot check of the results. In Compose, each part is a service, and depends_on orders them.

Scroll horizontally to view all table columns.

Compose service What it does On the cluster
coordinator Accepts tasks and hands out work a job step that listens on loopback
worker (two replicas) Takes tasks and sends back results one job step per worker, started only after the readiness gate passes
verify Checks every result once the workers finish a check that runs inside the same job, last

The same stack in one allocation

One batch script asks the scheduler for one node. Inside that allocation it does what Compose did on your laptop: it makes a private job-scoped scratch folder, starts the coordinator, waits at the readiness gate, runs the workers, verifies the results, and cleans up.

The workflow source is the code that script and its helpers run. It is the complete, release-pinned package at workflows/baseline-slurm-apptainer in tag v0.1.0:

It serves two purposes. It is a demonstrator: it shows the pattern running end to end under a real scheduler, and the evidence badge on this page links to the test record of that run. It is also a template: you edit it to run your own application, using the list in “Adapt it”. This page shows only teaching excerpts, annotated fragments chosen for explanation. Read them in the annotated implementation reference, and open the package for the runnable code. The coordinator and worker process inert JSON tasks on purpose. They are placeholders that exercise the orchestration; they are not an application.

How the baseline works

The script moves through six steps. The table says what each step does and where it sits in baseline.sbatch (line numbers are from tag v0.1.0).

One allocation, start to finish
  1. Step 1 Get an allocation The scheduler grants one node for a set time. The script checks its inputs and makes a private job folder (job-scoped scratch).
  2. Step 2 Start coordinator The coordinator starts as its own tracked program (a job step), on local-only access (loopback), and writes its address to a file.
  3. Step 3 Readiness gate Proves the coordinator can serve: the exact expected answer within the time limit (30 seconds by default), or the job stops before any worker starts.
  4. Step 4 Run workers Only now do workers start, each as a job step. The script waits for every worker to finish.
  5. Step 5 Verify results With the coordinator still running, a separate check confirms every result; only a full pass writes the success file.
  6. Step 6 Clean up Cleanup, set up at the start (an EXIT trap), stops every step still running when the script exits, whether it passed or failed.

Temporary job storage (job-scoped scratch) may remain until your center clears it. Copy out what you need before the job ends.

Gate
Is the coordinator answering yet? This belongs to the pattern: keep it, and change only what “ready” means for your service.
Verify
Are the results right? Only your science can say: replace this check with your own.

Scroll horizontally to view all table columns.

Step What the script does Lines
1. Get an allocation Requests one node for ten minutes, checks the image and the input, and makes a private job folder that must not already exist. 2–7, 123–198
2. Start coordinator Launches the coordinator as its own job step. It listens on loopback and writes its address to a file. 200–206
3. Readiness gate Waits until /health gives the exact expected answer, or stops with a named error when the time limit passes. 208–225
4. Run workers Launches each worker as its own job step, then waits for the workers, never for the coordinator. 227–242
5. Verify results With the coordinator still running, checks every result. Only a full pass writes result.json. 243–251
6. Clean up On any exit, stops every step politely. A failed run leaves no success file. The forced stop after the grace period, and the time-limit path, are Designed, not run. trap set at 120, cleanup at 82–114

The readiness gate proves the coordinator can serve. By default it polls for up to 30 seconds and passes only on the exact expected answer. It does not prove that any result is right. That is verification, and it runs inside the job, as step 5 of the same script (baseline.sbatch lines 245–251), not as a separate command afterward. A running process is not yet a successful result.

Implementation reference

The baseline.sbatch excerpts below are lines from the script itself, in order; a # … line marks lines left out. The Python excerpts are shortened. Each excerpt links to its release-pinned line range in the real v0.1.0 source on GitHub. Use the complete package when adapting and submitting the workflow.

Open the complete workflow package (external)

Read the package README (external)

Scheduler submission and the resources it claimsworkflows/baseline-slurm-apptainer/slurm/baseline.sbatch (Lines 1–28) (external)

One allocation claims one node and a bounded amount of CPU and time. The settings below the directives have defaults that you can override when you submit; a “# …” line marks lines left out.

#!/usr/bin/env bash
#SBATCH --job-name=bssw-baseline
#SBATCH --nodes=1
#SBATCH --ntasks=1
#SBATCH --cpus-per-task=4
#SBATCH --time=00:10:00
#SBATCH --output=slurm-%j.out

set -Eeuo pipefail
# …
WORKER_COUNT=${WORKER_COUNT:-2}
TASK_COUNT=${TASK_COUNT:-4}
READINESS_TIMEOUT=${READINESS_TIMEOUT:-30}
CLEANUP_TIMEOUT=${CLEANUP_TIMEOUT:-10}
INPUT_PATH=${INPUT_PATH:-${WORKFLOW_ROOT}/inputs/tasks.json}
EXPECTED_PATH=${EXPECTED_PATH:-${WORKFLOW_ROOT}/expected/results.json}
IMAGE_PATH=${IMAGE_PATH:-${WORKFLOW_ROOT}/container/baseline.sif}
INPUT_SHA256_FILE=${INPUT_SHA256_FILE:-${WORKFLOW_ROOT}/inputs/tasks.json.sha256}
IMAGE_SHA256_FILE=${IMAGE_SHA256_FILE:-${IMAGE_PATH}.sha256}
Start the coordinator, then wait until it is readyworkflows/baseline-slurm-apptainer/slurm/baseline.sbatch (Lines 200–225) (external)

The coordinator starts as its own job step, in the background. Nothing else starts until its endpoint file appears within the timeout and the readiness probe confirms it can actually serve, not just that the process exists. The script defines step_exec (run a command as a job step) and container_exec (run it in the container) earlier.

step_exec "$APPTAINER_CMD" exec --cleanenv --bind "${RUNTIME_DIR}:/work" "$IMAGE_PATH" \
  python3 "$APP_ROOT/coordinator.py" --input /work/input.json \
  --results /work/results.json --endpoint-file /work/endpoint.json \
  > "$RUNTIME_DIR/logs/coordinator.log" 2>&1 &
COORDINATOR_PID=$!
CHILDREN+=("$COORDINATOR_PID")
emit_event "coordinator-start"

ENDPOINT_DEADLINE=$((SECONDS + READINESS_TIMEOUT))
while [[ ! -s "$RUNTIME_DIR/endpoint.json" ]]; do
  "$KILL_CMD" -0 "$COORDINATOR_PID" 2>/dev/null || \
    fail "BSSW-COORDINATOR-EXIT" 74 "the coordinator exited before publishing its endpoint"
  (( SECONDS < ENDPOINT_DEADLINE )) || \
    fail "BSSW-READY-TIMEOUT" 75 "the coordinator did not become semantically ready before the deadline"
  sleep 0.05
done
ENDPOINT=$(container_exec python3 -c \
  'import json; print(json.load(open("/work/endpoint.json", encoding="utf-8"))["endpoint"])') || \
  fail "BSSW-COORDINATOR-EXIT" 74 "the coordinator endpoint record is invalid"
container_exec python3 "$APP_ROOT/readiness.py" --endpoint "$ENDPOINT" \
  --task-count "$TASK_COUNT" --timeout "$READINESS_TIMEOUT" \
  > "$RUNTIME_DIR/logs/readiness.log" 2>&1 || \
  fail "BSSW-READY-TIMEOUT" 75 "the coordinator did not become semantically ready before the deadline"
"$KILL_CMD" -0 "$COORDINATOR_PID" 2>/dev/null || \
  fail "BSSW-COORDINATOR-EXIT" 74 "the coordinator exited immediately after readiness"
emit_event "coordinator-ready"
Run workers, then verify results before claiming successworkflows/baseline-slurm-apptainer/slurm/baseline.sbatch (Lines 227–252) (external)

Each worker is its own job step, started in the background. The script waits for each worker, never for the coordinator, and stops with a named error if one fails. Only after every worker finishes does the verifier check the results: a completed process is not yet a successful result.

WORKER_PIDS=()
for ((index=1; index<=10#$WORKER_COUNT; index++)); do
  printf -v worker_id 'worker-%02d' "$index"
  step_exec "$APPTAINER_CMD" exec --cleanenv --bind "${RUNTIME_DIR}:/work" "$IMAGE_PATH" \
    python3 "$APP_ROOT/worker.py" --endpoint "$ENDPOINT" --worker-id "$worker_id" \
    --max-wait "$READINESS_TIMEOUT" > "$RUNTIME_DIR/logs/${worker_id}.log" 2>&1 &
  worker_pid=$!
  CHILDREN+=("$worker_pid")
  WORKER_PIDS+=("$worker_pid")
  emit_event "worker-start"
done
for worker_pid in "${WORKER_PIDS[@]}"; do
  if ! wait "$worker_pid"; then
    fail "BSSW-WORKER-EXIT" 76 "at least one exclusive worker step failed"
  fi
done
"$KILL_CMD" -0 "$COORDINATOR_PID" 2>/dev/null || \
  fail "BSSW-COORDINATOR-EXIT" 74 "the coordinator exited before result verification"
container_exec python3 "$APP_ROOT/verify.py" --input /work/input.json \
  --results /work/results.json --expected /work/expected.json --output /work/result.json \
  > "$RUNTIME_DIR/logs/verification.log" 2>&1 || \
  fail "BSSW-VERIFY-RESULT" 77 "result schema, count, uniqueness, or expected content verification failed"
[[ -s "$RESULT_PATH" ]] || \
  fail "BSSW-VERIFY-RESULT" 77 "the verifier did not atomically create the success result"
emit_event "verification-success"
printf 'BSSW workflow completed: result is in the job-scoped runtime directory.\n'
Readiness is a bounded health contract, not a sleepworkflows/baseline-slurm-apptainer/bin/readiness.py (Lines 17–38) (external)

The probe polls the coordinator /health endpoint on loopback with a hard deadline. It passes only when the coordinator reports the expected schema and task count—so workers never start against a half-initialized service.

deadline = time.monotonic() + args.timeout
while time.monotonic() < deadline:
    try:
        with urllib.request.urlopen(f"{args.endpoint}/health") as response:
            health = json.load(response)
        if response.status == 200 and health == {
            "status": "ready",
            "schemaVersion": 1,
            "taskCount": args.task_count,
        }:
            raise SystemExit(0)   # ready
    except (OSError, ValueError, urllib.error.URLError):
        pass
    time.sleep(min(args.interval, deadline - time.monotonic()))
raise SystemExit(24)   # timed out
What the coordinator reports as “ready”workflows/baseline-slurm-apptainer/bin/coordinator.py (Lines 24–37) (external)

The /health response is the contract the readiness probe checks. Reporting taskCount means the coordinator has parsed its input and has work to hand out before any worker asks.

def health(self) -> dict:
    return {
        "status": "ready",
        "schemaVersion": 1,
        "taskCount": len(self.tasks),
    }

def claim(self) -> dict:
    # A worker asks for work; the coordinator hands out one task
    # or reports that all tasks are done (or that more may arrive).
    if self.pending:
        task_id = self.pending.popleft()
        return {"status": "task", "task": self.tasks[task_id]}
    status = "done" if len(self.results) == len(self.tasks) else "wait"
    return {"status": status}
The verifier is the only thing that can write “success”workflows/baseline-slurm-apptainer/bin/verify.py (Lines 22–70) (external)

The verifier checks that every task produced exactly one result, that results are unique, and that they match the expected outputs. Only then does it atomically write the success marker—the single artifact a completed run is judged by.

# Load tasks, actual results, and the expected results.
tasks = load_tasks(args.input)
actual = json.load(open(args.results))["results"]
expected = json.load(open(args.expected))["results"]

seen = set()
for result in actual:
    if result["taskId"] in seen:
        raise ValueError("duplicate task id")
    if result["taskId"] not in {e["taskId"] for e in expected}:
        raise ValueError("unexpected task id")
    seen.add(result["taskId"])

# Every task must be covered exactly once.
if seen != {task["id"] for task in tasks}:
    raise ValueError("results do not cover each task exactly once")

# Atomically write the success marker.
success = {
    "schemaVersion": 1,
    "status": "success",
    "taskCount": len(tasks),
    "results": sorted(actual, key=lambda r: r["taskId"]),
}
atomic_json(args.output, success)

Files the workflow reads and writes

Every JSON file here is machine-parsed: it is written for programs to read. People write two of them (inputs/tasks.json, expected/results.json), and you open two to confirm success (result.json, events.jsonl); nothing else needs reading.

Scroll horizontally to view all table columns.

File Written by Read by Notes
inputs/tasks.json you the coordinator and the launcher The work to do, as inert data.
expected/results.json you the verifier The answers the demonstration verifier compares against. Replace or delete it.
container/image-lock.json the package container/acquire.sh Pins the base image.
diagnostics.json the package tests and tooling Lists the failure IDs the package’s tests check, a subset of the IDs in Troubleshooting.
endpoint.json the coordinator the launcher, the readiness probe, the workers Where the coordinator is listening.
results.json the coordinator the verifier Intermediate worker results. Not a success claim.
result.json the verifier you and CI The only success marker: "status": "success".
events.jsonl the launcher audit tooling and you JSON Lines, one event per line. Ordered events; proves readiness came before every worker start.

Warning: results.json and result.json differ by one letter and mean different things. The first is intermediate worker output. The second is the success marker.

The job writes its run files (input.json, expected.json, endpoint.json, results.json, result.json, events.jsonl, versions.txt, and a logs/ folder) into a private bssw-<job id> folder under the scratch root you choose.

Procedure

  1. Get the code, check the prerequisites listed at the start of this module, then record your local values.
  2. Get the container image and check the input.
  3. Set the adapter values that differ at your center: account, partition, and scratch root.
  4. Submit the job once, with the exact command on that page. Keep account names, private hosts, and sensitive paths out of logs.
  5. Check that it worked.

Expected result

A successful run:

A zero exit alone is not success. Check result.json. The test record linked from this page’s evidence badge shows what a real run produced.

What varies by center

Check each of these with your center before you run or adapt the workflow:

Failure modes

Every failure prints a stable ID that begins BSSW- and exits nonzero. Match the ID in your job output to its entry in Troubleshooting:

A readiness timeout stops the job at the gate: no worker starts and no result.json is written. That is the gate doing its job. Troubleshooting lists the remaining IDs.

Adapt it

Two checks guard a run, and they adapt differently:

Scroll horizontally to view all table columns.

Part Keep or replace Why
#SBATCH header and adapter variables adapt the values same mechanism; your account, partition, and limits
Storage setup (private job folder, binds) keep private job-scoped state does not depend on the application
Coordinator launch on loopback replace with your service your service is the point
Readiness probe (bin/readiness.py) keep the loop; edit what it waits for the exact /health answer is written into bin/readiness.py (lines 29–31) and returned by bin/coordinator.py (lines 27–28)
Shared task format (bin/common.py) edit if your tasks differ every program loads tasks through it; it fixes the task shape (id plus a payload with message and repeat) and its limits
Worker launch loop (srun steps) replace with your workers your parallelism and payloads
bin/verify.py replace the documented seam for result checks
expected/ replace or delete only the demonstration verifier reads it
container/Apptainer.def and container/image-lock.json replace with your image pinned provenance for your base image

To run your own application, expect to edit 12 files and to set up to 14 adapter variables. The files are slurm/baseline.sbatch (directive values and launch lines), inputs/tasks.json, inputs/tasks.json.sha256, expected/results.json (or delete it), bin/coordinator.py, bin/worker.py, bin/verify.py, bin/validate_input.py, bin/readiness.py, bin/common.py, container/Apptainer.def, and container/image-lock.json. These stay as they are: container/acquire.sh, diagnostics.json, and README.md. The 14 adapter variables are the ones in the package README’s adapter table: WORKER_COUNT, TASK_COUNT, READINESS_TIMEOUT, CLEANUP_TIMEOUT, IMAGE_PATH, IMAGE_SHA256_FILE, INPUT_PATH, INPUT_SHA256_FILE, EXPECTED_PATH, BSSW_SCRATCH_ROOT, BSSW_APPTAINER_CMD, BSSW_SRUN_CMD, BSSW_SLURM_VERSION_CMD, and BSSW_SHA256_CMD.

For a step-by-step translation of your own Compose file, use Compose-to-HPC translation.

Safety and scope limitations

The baseline covers one node, loopback communication, bounded inert inputs, and ephemeral allocation-scoped state. It does not establish multi-node networking, durable service operation, cross-center portability, performance scaling, or production support. GPUs, more than one node, and PBS-family schedulers are Designed, not run. The full list is at what hasn’t been tested yet.

Key points

Check completion

Complete only when the job exits zero and the verifier reports the documented task count, unique results, and a success result artifact.

Sources and scope

Review source roles and citations
  • SOW commitment: Publish a baseline Slurm and Apptainer service-plus-workers pattern. Fellowship SOW, Milestone 1
  • Project decision: Limit the baseline to one node and require result-based verification. Milestone 1 validation boundary

Project-adopted practices and project decisions are not BSSw Fellowship Program requirements.