Diese Seite wurde maschinell aus dem Englischen übersetzt und kann Fehler enthalten. Englisches Original

Jobsystem

axqua.jobs gibt einer Simulation eine eigene Lebenszeit. Ein Job ist ein Ordner auf der Festplatte, den ein Prozess erstellt und ein anderer Prozess ausführt. Die Module model, ids und paths verwenden nur die Python-Standardbibliothek.

UML Klassendiagramm des Jobsystems

Klassen eines Jobs. JobSpec ist die Beschreibung, die eingereicht wird, mit einer Klasse von Optionen pro Art von Job. JobStatus ist der Zustand, den der laufende Job meldet, mit einer Klasse von Fortschritten pro Art von Job. Die vier Launcher erfüllen die Schnittstelle JobLauncher und lösen einen Job unter Linux, unter Windows und im Windows-Subsystem für Linux ab.

Stellenbeschreibung und Speicherung

The job model: states, kinds, options, progress, and the two records on disk.

Two files describe a job, and the split is the whole point (plan §3):

job.json

The immutable submission record. What was asked for, resolved and frozen at submit time - including the entire case configuration, not a reference to it. If the user edits case-config.yml afterwards, a running job must not change underneath itself, and a finished job must stay reproducible from its own directory alone. Written once, then made read-only.

status.json

The mutable state. Written only by the runner, only by atomic replace. Readers never lock; they read the last complete file.

Progress is nested and typed by job kind, not flattened into the top level. Most jobs are not optimizations: a steady run reports simulated time against duration, a mesh-convergence study reports levels completed, a BAL run reports iterations and a best objective. Hoisting iteration and best_objective to the top level would force every other kind to publish nulls and would invite the dashboard to render meaningless zeros.

Standard library only. The CLI touches this module on every job verb, and tests/test_capabilities.py asserts from a subprocess that nothing heavy is pulled in.

axqua.jobs.model.utc_now()[Quellcode]

One timestamp format everywhere: ISO-8601, seconds, with an offset.

Rückgabetyp:

str

class axqua.jobs.model.JobState(*values)[Quellcode]

Bases: str, Enum

Where a job is. str-valued so it serializes without a converter.

QUEUED = 'QUEUED'
STARTING = 'STARTING'
RUNNING = 'RUNNING'
POSTPROCESSING = 'POSTPROCESSING'
COMPLETED = 'COMPLETED'
FAILED = 'FAILED'
CANCEL_REQUESTED = 'CANCEL_REQUESTED'
CANCELLED = 'CANCELLED'
property is_terminal: bool

A terminal job never changes again - which is what lets the dashboard stop polling it (plan §18).

property is_active: bool
can_go_to(other)[Quellcode]
Parameter:

other (JobState)

Rückgabetyp:

bool

axqua.jobs.model.TRANSITIONS: dict[JobState, frozenset[JobState]] = {JobState.CANCELLED: frozenset({}), JobState.CANCEL_REQUESTED: frozenset({JobState.CANCELLED, JobState.COMPLETED, JobState.FAILED}), JobState.COMPLETED: frozenset({}), JobState.FAILED: frozenset({}), JobState.POSTPROCESSING: frozenset({JobState.CANCEL_REQUESTED, JobState.COMPLETED, JobState.FAILED}), JobState.QUEUED: frozenset({JobState.CANCELLED, JobState.CANCEL_REQUESTED, JobState.FAILED, JobState.STARTING}), JobState.RUNNING: frozenset({JobState.CANCEL_REQUESTED, JobState.COMPLETED, JobState.FAILED, JobState.POSTPROCESSING}), JobState.STARTING: frozenset({JobState.CANCELLED, JobState.CANCEL_REQUESTED, JobState.FAILED, JobState.RUNNING})}

The legal edges. Two are worth spelling out because they look wrong at a glance:

  • QUEUED -> CANCELLED directly - the job was cancelled before it ever launched, so there is nothing to tear down and no reason to pass through CANCEL_REQUESTED.

  • CANCEL_REQUESTED -> COMPLETED - a cancel is a request; a solver a few seconds from finishing may well finish first, and reporting that as CANCELLED would be a lie.

exception axqua.jobs.model.IllegalTransition(message, *, subject=None, remedy=None, **details)[Quellcode]

Bases: ConfigError

Raised rather than coerced.

Silently clamping an impossible transition is how a job ends up reporting RUNNING forever, or COMPLETED after a crash. The state machine is small enough that any violation is a bug in axqua, and it should be loud.

Parameter:
  • message (str)

  • subject (str | None)

  • remedy (str | None)

  • details (Any)

Rückgabetyp:

None

code = 'axqua.job.transition'
axqua.jobs.model.check_transition(current, target)[Quellcode]
Parameter:
Rückgabetyp:

None

class axqua.jobs.model.JobKind(*values)[Quellcode]

Bases: str, Enum

One kind per case-template script.

The taxonomy is not invented here - cases/case-template/ already contains it, one script per unit of long-running work. Keeping the two in step means a user who knows the scripts knows the job kinds, and the scripts stay the supported standalone path (plan §29).

PREPROCESSING = 'preprocessing'
STEADY_RUN = 'steady'
MESH_CONVERGENCE = 'mesh-convergence'
BUILD_3D = 'build-3d'
STEADY_RUN_3D = 'steady-3d'
VERTICAL_CONVERGENCE = 'vertical-convergence'
UNSTEADY_RUN = 'unsteady'
OPENFOAM_BUILD = 'openfoam-build'
OPENFOAM_RUN = 'openfoam-run'
OPENFOAM_SEDIMENT_BUILD = 'openfoam-sediment-build'
OPENFOAM_SEDIMENT_RUN = 'openfoam-sediment'
OPENFOAM_MESH_CONVERGENCE = 'openfoam-mesh-convergence'
CALIBRATION = 'calibration'
CALIBRATION_MULTIFLOW = 'calibration-multiflow'
VALIDATION = 'validation'
axqua.jobs.model.parse_kind(value)[Quellcode]

Accept the enum value, the enum name, or a few obvious aliases.

Parameter:

value (str)

Rückgabetyp:

JobKind

class axqua.jobs.model.Options[Quellcode]

Bases: object

Base for the per-kind option records.

A dataclass in its own right, with no fields, so that it also serves as the „no options“ case: JobSpec defaults to one, and as_dict/as_kwargs then return an empty mapping instead of raising.

from_dict refuses unknown keys. Plan §5 is explicit: never silently ignore an unknown field, because that is how a job that looks submitted quietly runs something else.

classmethod from_dict(data, *, where='options')[Quellcode]
Parameter:
Rückgabetyp:

Options

as_dict()[Quellcode]
Rückgabetyp:

dict[str, Any]

as_kwargs()[Quellcode]

What the executor splats into the backend verb.

Rückgabetyp:

dict[str, Any]

class axqua.jobs.model.PreprocessingOptions(discharge=None, validate_env=True, dry_run=False)[Quellcode]

Bases: Options

cases/case-template/preprocessing.py.

Parameter:
discharge: float | None = None
validate_env: bool = True
dry_run: bool = False
class axqua.jobs.model.SteadyRunOptions(ncsize=None, cas_file=None, hotstart_tolerance=None, report_wetting=True, report_sections=False)[Quellcode]

Bases: Options

initial_run.py - its NCSIZE and the hotstart tolerance.

Parameter:
  • ncsize (int | None)

  • cas_file (str | None)

  • hotstart_tolerance (float | None)

  • report_wetting (bool)

  • report_sections (bool)

ncsize: int | None = None
cas_file: str | None = None
hotstart_tolerance: float | None = None
report_wetting: bool = True
report_sections: bool = False
class axqua.jobs.model.MeshConvergenceOptions(discharge=None, refinement_ratio=1.3, tolerance=0.05, n_coarser=1, n_finer=2, max_extra_levels=3, auto_extend=False, initial_depth=0.5, ncsize=None, answers=<factory>)[Quellcode]

Bases: Options

mesh_convergence_study.py.

answers is what replaces the study’s stdin prompt in a detached run; see axqua.jobs.interaction.

Parameter:
discharge: float | None = None
refinement_ratio: float = 1.3
tolerance: float = 0.05
n_coarser: int = 1
n_finer: int = 2
max_extra_levels: int = 3
auto_extend: bool = False
initial_depth: float | None = 0.5
ncsize: int | None = None
answers: dict[str, bool]
class axqua.jobs.model.OpenFoamMeshConvergenceOptions(discharge=None, refinement_ratio=1.3, tolerance=0.05, n_coarser=1, n_finer=2, max_extra_levels=3, auto_extend=False, n_processors=None, pre_run=None, hotstart=None, answers=<factory>)[Quellcode]

Bases: Options

The mesh convergence study of the OpenFOAM free-surface model.

pre_run and hotstart say where the 2D result every level starts from comes from, as for openfoam-build.

Parameter:
discharge: float | None = None
refinement_ratio: float = 1.3
tolerance: float = 0.05
n_coarser: int = 1
n_finer: int = 2
max_extra_levels: int = 3
auto_extend: bool = False
n_processors: int | None = None
pre_run: bool | None = None
hotstart: str | None = None
answers: dict[str, bool]
class axqua.jobs.model.Build3DOptions(hydrostatic_steps=None, n_levels=None)[Quellcode]

Bases: Options

add3d.py (build half).

Parameter:
  • hydrostatic_steps (int | None)

  • n_levels (int | None)

hydrostatic_steps: int | None = None
n_levels: int | None = None
class axqua.jobs.model.Steady3DOptions(variant='hydrostatic', ncsize=None)[Quellcode]

Bases: Options

add3d.py --run.

Parameter:
  • variant (str)

  • ncsize (int | None)

variant: str = 'hydrostatic'
ncsize: int | None = None
class axqua.jobs.model.VerticalConvergenceOptions(layer_counts=None, tolerance=0.05, ncsize=None)[Quellcode]

Bases: Options

vertical_convergence_3d.py.

Parameter:
layer_counts: list[int] | None = None
tolerance: float = 0.05
ncsize: int | None = None
class axqua.jobs.model.UnsteadyOptions(mode_3d=False, control_sections=True, gaia_enabled=False, gaia_bedload=True, gaia_suspended=False, run=True, ncsize=None)[Quellcode]

Bases: Options

unsteady_run.py and its in-script toggles.

Parameter:
mode_3d: bool = False
control_sections: bool = True
gaia_enabled: bool = False
gaia_bedload: bool = True
gaia_suspended: bool = False
run: bool = True
ncsize: int | None = None
class axqua.jobs.model.OpenFoamBuildOptions(pre_run=None, hotstart=None, cell_size=None, layers=None)[Quellcode]

Bases: Options

openfoam_preprocessing.py.

Parameter:
  • pre_run (bool | None)

  • hotstart (str | None)

  • cell_size (float | None)

  • layers (int | None)

pre_run: bool | None = None
hotstart: str | None = None
cell_size: float | None = None
layers: int | None = None
class axqua.jobs.model.OpenFoamRunOptions(n_processors=None, reconstruct=True, to_vtk=None, check_mesh=True)[Quellcode]

Bases: Options

openfoam_run.py.

Parameter:
  • n_processors (int | None)

  • reconstruct (bool)

  • to_vtk (bool | None)

  • check_mesh (bool)

n_processors: int | None = None
reconstruct: bool = True
to_vtk: bool | None = None
check_mesh: bool = True
class axqua.jobs.model.OpenFoamSedimentBuildOptions(pre_run=None, hotstart=None)[Quellcode]

Bases: Options

The case of the fine sediment simulation.

pre_run and hotstart say where the water surface comes from, as for the build of the free-surface case. The cell size is that of the block openfoam.

Parameter:
  • pre_run (bool | None)

  • hotstart (str | None)

pre_run: bool | None = None
hotstart: str | None = None
class axqua.jobs.model.OpenFoamSedimentRunOptions(iterations=None, check_mesh=True)[Quellcode]

Bases: Options

The fine sediment simulation. iterations replaces fine_sediment.iterations for this run.

Parameter:
  • iterations (int | None)

  • check_mesh (bool)

iterations: int | None = None
check_mesh: bool = True
class axqua.jobs.model.CalibrationOptions(prepare_only=False, calibration_quantities=None, extraction_quantities=None, vel_err_floor=None, depth_err_floor=None)[Quellcode]

Bases: Options

run_Bayes_cal.py.

Parameter:
  • prepare_only (bool)

  • calibration_quantities (list[str] | None)

  • extraction_quantities (list[str] | None)

  • vel_err_floor (float | None)

  • depth_err_floor (float | None)

prepare_only: bool = False
calibration_quantities: list[str] | None = None
extraction_quantities: list[str] | None = None
vel_err_floor: float | None = None
depth_err_floor: float | None = None
class axqua.jobs.model.ValidationOptions(situation='', compare_only=False, ncsize=None, validate=True)[Quellcode]

Bases: Options

The calibrated model, run for a validation situation and compared with it.

situation names an entry of calibration.validation in the case file and may stay empty when the case has only one. compare_only repeats the comparison for a run that exists, after the measurements were corrected for example.

Parameter:
  • situation (str)

  • compare_only (bool)

  • ncsize (int | None)

  • validate (bool)

situation: str = ''
compare_only: bool = False
ncsize: int | None = None
validate: bool = True

What tells the study of the calibration capability that this is a validation.

class axqua.jobs.model.MultiflowCalibrationOptions(flows=<factory>, launch_mode='run', force=False)[Quellcode]

Bases: Options

run_Bayes_cal_multiflow.py.

flows carries the FlowSpec fields as plain dicts; the backend rebuilds the dataclasses. Keeping them dicts here is what lets job.json stay JSON.

Parameter:
flows: list[dict[str, Any]]
launch_mode: str = 'run'
force: bool = False
class axqua.jobs.model.Progress(kind='none')[Quellcode]

Bases: object

Base for the kind-typed progress payloads.

The kind discriminator is written into the JSON so a reader can dispatch, and from_dict() keeps an unknown kind as a raw dict rather than discarding it - a plugin built against an older axqua must degrade to showing nothing useful, not to losing the field.

Parameter:

kind (str)

kind: str = 'none'
as_dict()[Quellcode]
Rückgabetyp:

dict[str, Any]

merged(**updates)[Quellcode]
Parameter:

updates (Any)

Rückgabetyp:

Progress

static from_dict(data)[Quellcode]
Parameter:

data (Any)

Rückgabetyp:

Progress | dict[str, Any] | None

class axqua.jobs.model.PreprocessingProgress(kind: 'str' = 'preprocessing', step: 'int' = 0, n_steps: 'int' = 5, step_name: 'str' = '')[Quellcode]

Bases: Progress

Parameter:
kind: str = 'preprocessing'
step: int = 0
n_steps: int = 5
step_name: str = ''
class axqua.jobs.model.SolverRunProgress(kind='solver_run', simulated_time=None, duration=None, iteration=None, time_step=None, courant=None, flux_imbalance=None)[Quellcode]

Bases: Progress

A TELEMAC or OpenFOAM march.

Progress is measured against simulated time, not step count: the CFL-adaptive time step makes the number of steps unknown a priori, which is exactly why axqua.progress.SolverProgress already works this way.

Parameter:
  • kind (str)

  • simulated_time (float | None)

  • duration (float | None)

  • iteration (int | None)

  • time_step (float | None)

  • courant (float | None)

  • flux_imbalance (float | None)

kind: str = 'solver_run'
simulated_time: float | None = None
duration: float | None = None
iteration: int | None = None
time_step: float | None = None
courant: float | None = None
flux_imbalance: float | None = None
property fraction: float | None
class axqua.jobs.model.LadderProgress(kind='ladder', levels_done=0, levels_total=None, current_label='', converged=None, gci=None, pending_question=None)[Quellcode]

Bases: Progress

A convergence study: mesh levels or vertical layer counts.

Parameter:
  • kind (str)

  • levels_done (int)

  • levels_total (int | None)

  • current_label (str)

  • converged (bool | None)

  • gci (float | None)

  • pending_question (str | None)

kind: str = 'ladder'
levels_done: int = 0
levels_total: int | None = None
current_label: str = ''
converged: bool | None = None
gci: float | None = None
pending_question: str | None = None
class axqua.jobs.model.CalibrationProgress(kind: 'str' = 'calibration', iteration: 'int | None' = None, max_iterations: 'int | None' = None, best_objective: 'float | None' = None, flow: 'str | None' = None, n_flows: 'int | None' = None)[Quellcode]

Bases: Progress

Parameter:
  • kind (str)

  • iteration (int | None)

  • max_iterations (int | None)

  • best_objective (float | None)

  • flow (str | None)

  • n_flows (int | None)

kind: str = 'calibration'
iteration: int | None = None
max_iterations: int | None = None
best_objective: float | None = None
flow: str | None = None
n_flows: int | None = None
class axqua.jobs.model.OpenFoamBuildProgress(kind: 'str' = 'openfoam_build', cells: 'int | None' = None, stage: 'str' = '', mesh_usable: 'bool | None' = None)[Quellcode]

Bases: Progress

Parameter:
  • kind (str)

  • cells (int | None)

  • stage (str)

  • mesh_usable (bool | None)

kind: str = 'openfoam_build'
cells: int | None = None
stage: str = ''
mesh_usable: bool | None = None
class axqua.jobs.model.KindMeta(kind, slug, solver, title, capability, verb, options, progress, workspace_default, needs_environment)[Quellcode]

Bases: object

Everything the system needs to know about a job kind, as data.

verb is the SolverBackend method the executor calls. Three verbs rather than one per kind: a convergence study is genuinely neither a build nor a run, but adding a verb per kind would push the taxonomy into the protocol and force every backend to grow with it.

Parameter:
kind: JobKind
slug: str
solver: str
title: str
capability: Capability | None
verb: str
options: type
progress: type
workspace_default: str
needs_environment: bool
axqua.jobs.model.KIND_META: dict[JobKind, KindMeta] = {JobKind.BUILD_3D: KindMeta(kind=<JobKind.BUILD_3D: 'build-3d'>, slug='build3d', solver='telemac', title='Write the TELEMAC-3D steering files', capability=<Capability.STEADY3D: 'steady3d'>, verb='build', options=<class 'axqua.jobs.model.Build3DOptions'>, progress=<class 'axqua.jobs.model.PreprocessingProgress'>, workspace_default='case', needs_environment=False), JobKind.CALIBRATION: KindMeta(kind=<JobKind.CALIBRATION: 'calibration'>, slug='bal', solver='telemac', title='HydroBayesCal calibration', capability=<Capability.CALIBRATION: 'calibration'>, verb='study', options=<class 'axqua.jobs.model.CalibrationOptions'>, progress=<class 'axqua.jobs.model.CalibrationProgress'>, workspace_default='case', needs_environment=True), JobKind.CALIBRATION_MULTIFLOW: KindMeta(kind=<JobKind.CALIBRATION_MULTIFLOW: 'calibration-multiflow'>, slug='balmf', solver='telemac', title='HydroBayesCal multi-flow calibration', capability=<Capability.CALIBRATION: 'calibration'>, verb='study', options=<class 'axqua.jobs.model.MultiflowCalibrationOptions'>, progress=<class 'axqua.jobs.model.CalibrationProgress'>, workspace_default='case', needs_environment=True), JobKind.MESH_CONVERGENCE: KindMeta(kind=<JobKind.MESH_CONVERGENCE: 'mesh-convergence'>, slug='meshconv', solver='telemac', title='Mesh-convergence study', capability=<Capability.MESH_CONVERGENCE: 'mesh_convergence'>, verb='study', options=<class 'axqua.jobs.model.MeshConvergenceOptions'>, progress=<class 'axqua.jobs.model.LadderProgress'>, workspace_default='case', needs_environment=True), JobKind.OPENFOAM_BUILD: KindMeta(kind=<JobKind.OPENFOAM_BUILD: 'openfoam-build'>, slug='ofbuild', solver='openfoam', title='Build the OpenFOAM case', capability=<Capability.FREE_SURFACE_3D: 'free_surface_3d'>, verb='build', options=<class 'axqua.jobs.model.OpenFoamBuildOptions'>, progress=<class 'axqua.jobs.model.OpenFoamBuildProgress'>, workspace_default='case', needs_environment=False), JobKind.OPENFOAM_MESH_CONVERGENCE: KindMeta(kind=<JobKind.OPENFOAM_MESH_CONVERGENCE: 'openfoam-mesh-convergence'>, slug='ofmeshconv', solver='openfoam', title='Mesh-convergence study of the OpenFOAM model', capability=<Capability.MESH_CONVERGENCE: 'mesh_convergence'>, verb='study', options=<class 'axqua.jobs.model.OpenFoamMeshConvergenceOptions'>, progress=<class 'axqua.jobs.model.LadderProgress'>, workspace_default='case', needs_environment=True), JobKind.OPENFOAM_RUN: KindMeta(kind=<JobKind.OPENFOAM_RUN: 'openfoam-run'>, slug='ofrun', solver='openfoam', title='OpenFOAM interFoam run', capability=<Capability.FREE_SURFACE_3D: 'free_surface_3d'>, verb='run', options=<class 'axqua.jobs.model.OpenFoamRunOptions'>, progress=<class 'axqua.jobs.model.SolverRunProgress'>, workspace_default='case', needs_environment=True), JobKind.OPENFOAM_SEDIMENT_RUN: KindMeta(kind=<JobKind.OPENFOAM_SEDIMENT_RUN: 'openfoam-sediment'>, slug='ofsed', solver='openfoam', title='Fine sediment simulation (sediDriftFoam2)', capability=<Capability.MORPHODYNAMICS: 'morphodynamics'>, verb='run', options=<class 'axqua.jobs.model.OpenFoamSedimentRunOptions'>, progress=<class 'axqua.jobs.model.SolverRunProgress'>, workspace_default='case', needs_environment=True), JobKind.OPENFOAM_SEDIMENT_BUILD: KindMeta(kind=<JobKind.OPENFOAM_SEDIMENT_BUILD: 'openfoam-sediment-build'>, slug='ofsedbuild', solver='openfoam', title='Build the fine sediment case', capability=<Capability.MORPHODYNAMICS: 'morphodynamics'>, verb='build', options=<class 'axqua.jobs.model.OpenFoamSedimentBuildOptions'>, progress=<class 'axqua.jobs.model.OpenFoamBuildProgress'>, workspace_default='case', needs_environment=False), JobKind.PREPROCESSING: KindMeta(kind=<JobKind.PREPROCESSING: 'preprocessing'>, slug='preproc', solver='telemac', title='Build the TELEMAC case', capability=<Capability.STEADY2D: 'steady2d'>, verb='build', options=<class 'axqua.jobs.model.PreprocessingOptions'>, progress=<class 'axqua.jobs.model.PreprocessingProgress'>, workspace_default='case', needs_environment=False), JobKind.STEADY_RUN: KindMeta(kind=<JobKind.STEADY_RUN: 'steady'>, slug='steady', solver='telemac', title='Steady 2D run', capability=<Capability.STEADY2D: 'steady2d'>, verb='run', options=<class 'axqua.jobs.model.SteadyRunOptions'>, progress=<class 'axqua.jobs.model.SolverRunProgress'>, workspace_default='case', needs_environment=True), JobKind.STEADY_RUN_3D: KindMeta(kind=<JobKind.STEADY_RUN_3D: 'steady-3d'>, slug='steady3d', solver='telemac', title='Steady 3D run', capability=<Capability.STEADY3D: 'steady3d'>, verb='run', options=<class 'axqua.jobs.model.Steady3DOptions'>, progress=<class 'axqua.jobs.model.SolverRunProgress'>, workspace_default='case', needs_environment=True), JobKind.UNSTEADY_RUN: KindMeta(kind=<JobKind.UNSTEADY_RUN: 'unsteady'>, slug='unsteady', solver='telemac', title='Unsteady (hydrograph) run', capability=<Capability.UNSTEADY2D: 'unsteady2d'>, verb='run', options=<class 'axqua.jobs.model.UnsteadyOptions'>, progress=<class 'axqua.jobs.model.SolverRunProgress'>, workspace_default='case', needs_environment=True), JobKind.VALIDATION: KindMeta(kind=<JobKind.VALIDATION: 'validation'>, slug='val', solver='telemac', title='Validation with data of another flow situation', capability=<Capability.CALIBRATION: 'calibration'>, verb='study', options=<class 'axqua.jobs.model.ValidationOptions'>, progress=<class 'axqua.jobs.model.SolverRunProgress'>, workspace_default='case', needs_environment=True), JobKind.VERTICAL_CONVERGENCE: KindMeta(kind=<JobKind.VERTICAL_CONVERGENCE: 'vertical-convergence'>, slug='vertconv', solver='telemac', title='Vertical-layer convergence study', capability=<Capability.VERTICAL_CONVERGENCE: 'vertical_convergence'>, verb='study', options=<class 'axqua.jobs.model.VerticalConvergenceOptions'>, progress=<class 'axqua.jobs.model.LadderProgress'>, workspace_default='case', needs_environment=True)}

they hotstart from a result that already sits in the case’s model_dir, and copying it would violate the large-data rule (plan §26). The build did not use to - it defaulted to job, a self-contained directory - and that made the default chain unusable: submit --kind preprocessing followed by submit --kind steady failed with „no built case“, because the build sat in a job folder the run never looked in, and the case status kept reporting the case as unbuilt. That is the two buttons of the plugin’s first tab. --workspace job still gives the self-contained build, and --from-job is how a run is pointed at it.

Type:

Every kind defaults to workspace: case. Run kinds must

axqua.jobs.model.kinds_for(solver)[Quellcode]
Parameter:

solver (str)

Rückgabetyp:

list[JobKind]

class axqua.jobs.model.Resources(mpi_processes: 'int' = 1, working_root: 'str | None' = None, time_limit_s: 'float | None' = None, min_free_bytes: 'int' = 5368709120)[Quellcode]

Bases: object

Parameter:
  • mpi_processes (int)

  • working_root (str | None)

  • time_limit_s (float | None)

  • min_free_bytes (int)

mpi_processes: int = 1
working_root: str | None = None
time_limit_s: float | None = None
min_free_bytes: int = 5368709120

Refuse to start below this much free space (plan §24). 5 GiB is a floor, not a forecast - a real OpenFOAM run wants far more, but this catches the full volume.

classmethod from_dict(data)[Quellcode]
Parameter:

data (dict[str, Any] | None)

Rückgabetyp:

Resources

as_dict()[Quellcode]
Rückgabetyp:

dict[str, Any]

class axqua.jobs.model.Workspace(mode='case', source_job=None)[Quellcode]

Bases: object

Where the job’s working directories point.

job

Rebase the case’s four phase directories into <job>/input and <job>/results. Right for a build, which produces its own inputs.

case

Leave them alone. Right for a run that hotstarts from an r2d.slf already sitting in the case’s model_dir - and the only mode under which the standalone scripts and the job system operate on the same files.

link

Point at a previous job’s build. Copies nothing (plan §26).

Parameter:
  • mode (str)

  • source_job (str | None)

mode: str = 'case'
source_job: str | None = None
classmethod from_dict(data)[Quellcode]
Parameter:

data (dict[str, Any] | None)

Rückgabetyp:

Workspace

as_dict()[Quellcode]
Rückgabetyp:

dict[str, Any]

class axqua.jobs.model.JobSpec(job_id, kind, solver, schema_version=1, solver_profile=None, created=<factory>, created_by=<factory>, case=<factory>, environment=<factory>, resources=<factory>, workspace=<factory>, options=<factory>, launcher=<factory>)[Quellcode]

Bases: object

job.json: what was asked for, frozen.

Parameter:
job_id: str
kind: JobKind
solver: str
schema_version: int = 1
solver_profile: str | None = None
created: str
created_by: dict[str, Any]
case: dict[str, Any]
environment: dict[str, Any]
resources: Resources
workspace: Workspace
options: Options
launcher: dict[str, Any]
property meta: KindMeta
property case_name: str
property source_config: str | None
as_dict()[Quellcode]
Rückgabetyp:

dict[str, Any]

classmethod from_dict(data)[Quellcode]
Parameter:

data (dict[str, Any])

Rückgabetyp:

JobSpec

class axqua.jobs.model.JobStatus(job_id, state=JobState.QUEUED, kind=None, solver='', schema_version=1, phase='', progress=None, launcher=None, started=None, updated=<factory>, finished=None, exit_code=None, error=None, artifacts=<factory>, history=<factory>)[Quellcode]

Bases: object

status.json: where the job is now.

Only a process holding job.lock writes this. That is the rule that makes plan §3’s „exactly one writer per file“ testable rather than aspirational.

Parameter:
job_id: str
state: JobState = 'QUEUED'
kind: JobKind | None = None
solver: str = ''
schema_version: int = 1
phase: str = ''
progress: Any = None
launcher: dict[str, Any] | None = None
started: str | None = None
updated: str
finished: str | None = None
exit_code: int | None = None
error: dict[str, Any] | None = None
artifacts: dict[str, str]
history: list[dict[str, str]]
transition(target, *, when=None)[Quellcode]

Move to target, recording it. Raises on an illegal edge.

Parameter:
Rückgabetyp:

JobStatus

as_dict()[Quellcode]
Rückgabetyp:

dict[str, Any]

classmethod from_dict(data)[Quellcode]
Parameter:

data (dict[str, Any])

Rückgabetyp:

JobStatus

classmethod initial(spec)[Quellcode]
Parameter:

spec (JobSpec)

Rückgabetyp:

JobStatus

Job identifiers: readable, sortable, collision-safe.

A bare UUID was rejected deliberately. The identifier is a directory name and a dashboard column, and f835ac7d-3e91-... is neither greppable nor meaningful six months later - the dashboard sketch in the plan truncates it to f835ac7, which is the problem showing through. So:

<date>-<case-slug>-<kind-slug>-<6 hex>
2026-08-14-isar-2025-steady-a3f19c

The date sorts chronologically under a plain ls, the case and kind say what the job is without opening anything, and the random tail keeps it collision-safe. The tail is short on purpose: uniqueness is claimed by creating the directory with mkdir(exist_ok=False) (see axqua.jobs.paths), not asserted by entropy, so six hex digits plus a redraw loop is ample.

Standard library only - this module is imported by the CLI on every job verb.

axqua.jobs.ids.JOB_ID_RE = re.compile('^(?P<date>\\d{4}-\\d{2}-\\d{2})-(?P<slug>[a-z0-9][a-z0-9-]{0,60}?)-(?P<tail>[0-9a-f]{6})$')

The one grammar. Anything that does not match is not a job id, which is what lets axqua status <arg> tell a job id from a case-config path without guessing.

axqua.jobs.ids.MAX_REDRAWS = 8

How many times make_job_id() redraws the random tail before giving up. The caller claims the directory; a clash means it lost that race, which at six hex digits needs a deliberate effort to arrange.

axqua.jobs.ids.slug(text, *, limit=32)[Quellcode]

Reduce text to a lowercase [a-z0-9-] token fit for a directory name.

Empty or wholly non-alphanumeric input becomes "case" rather than an empty segment, because an empty segment would make the id ambiguous to parse.

Parameter:
Rückgabetyp:

str

axqua.jobs.ids.make_job_id(case_name, kind_slug='', *, when=None, tail=None)[Quellcode]

Build one job id. tail is for tests; production draws it randomly.

Parameter:
  • case_name (str)

  • kind_slug (str)

  • when (date | None)

  • tail (str | None)

Rückgabetyp:

str

axqua.jobs.ids.parse_job_id(job_id)[Quellcode]

Split job_id into (date, slug, tail).

The slug is returned whole - the case and kind halves are not separable after the fact, since a case name may itself contain dashes. Nothing needs them apart: the authoritative kind lives in job.json.

Parameter:

job_id (str)

Rückgabetyp:

tuple[date, str, str]

axqua.jobs.ids.is_job_id(value)[Quellcode]

True when value is spelled like a job id. Never raises.

Parameter:

value (object)

Rückgabetyp:

bool

Where axqua keeps configuration, job state and scratch - platform-aware.

Four separate roles, kept apart because they have different backup, size and portability characteristics:

config_dir

Hand-edited settings that belong to the user: profiles.yml. Small, worth backing up, never machine-generated in bulk.

data_dir

Derived state axqua owns: jobs.sqlite. Reconstructible (see axqua.jobs.index), so losing it costs a rescan and nothing else.

state_dir / cache_dir

Runtime scraps and throwaway intermediates.

The job root is separate from all four and deliberately overridable: CFD output runs to tens of gigabytes and belongs on a scratch volume, not under ~.

No platformdirs dependency. It would be ~40 lines saved against a new runtime requirement on a package whose dependency policy (plan §28) is to stay light, and the QGIS plugin never imports axqua, so there is no second consumer to share it with.

axqua.jobs.paths.ENV_CONFIG = 'AXQUA_CONFIG_DIR'

Environment overrides, checked before any platform convention. These exist so a test suite (and a sysadmin) can relocate everything without patching code; the autouse fixture in tests/conftest.py sets all of them into tmp_path.

axqua.jobs.paths.LEGACY_APP_NAME = 'hydromate'

The pre-rename application name. Its directories are still read when the aXqua ones do not exist yet, so an existing job root, index and profiles.yml survive the rename without anyone having to move anything.

axqua.jobs.paths.config_dir()[Quellcode]

User configuration: profiles.yml lives here.

Rückgabetyp:

Path

axqua.jobs.paths.data_dir()[Quellcode]

Derived, reconstructible state: the job index.

Rückgabetyp:

Path

axqua.jobs.paths.state_dir()[Quellcode]
Rückgabetyp:

Path

axqua.jobs.paths.cache_dir()[Quellcode]
Rückgabetyp:

Path

axqua.jobs.paths.job_root(*, explicit=None, profile_root=None, config_root=None)[Quellcode]

Resolve the job root, first hit wins.

--job-root beats $AXQUA_JOB_ROOT beats the solver profile’s working_root beats jobs.root of the profile of this computer beats the case config’s project.job_root beats data_dir()/jobs. The ordering runs most-specific to least: an explicit flag is about this submission, a profile is about this machine, and the config is about this case.

The profile of this computer is consulted here and not only by submit, because list, status, logs and cancel resolve the root through this function too. When only submit knew the folder a profile names, a job was created there and then not found by any of the commands that follow it.

Parameter:
Rückgabetyp:

Path

axqua.jobs.paths.index_path()[Quellcode]

The SQLite job index. Derived - deleting it is safe, list --rebuild restores.

Rückgabetyp:

Path

axqua.jobs.paths.profiles_path()[Quellcode]

The solver-profile file. Machine-local by design: it names install paths.

Rückgabetyp:

Path

class axqua.jobs.paths.JobDir(root)[Quellcode]

Bases: object

Every path inside one job directory, in one place.

The per-job directory is authoritative (plan §5); the SQLite index is derived from it. So this class is the schema of a job on disk, and anything that needs a path asks here rather than joining strings.

Parameter:

root (str | os.PathLike)

property job_id: str
property spec: Path

job.json - the immutable submission record.

property status: Path

status.json - mutable, replaced atomically, runner-only.

property lock: Path
property cancel_request: Path

Its own file, so the one-writer-per-file rule survives cancellation.

property runner_log: Path

axqua’s narration. The solver’s own listing is a separate artifact.

property launch_env: Path

The captured solver environment, as systemd’s EnvironmentFile.

property answers: Path

Answers to questions a study asked while detached.

property input: Path
property frozen_config: Path
property preprocessing: Path
property simulation: Path
property solver_logs: Path
property calibration: Path
property results: Path
property results_json: Path
property qgis: Path

Where SolverBackend.export_qgis_results() writes. Never PyQGIS output.

exists()[Quellcode]
Rückgabetyp:

bool

create()[Quellcode]

Claim the directory. Raises FileExistsError if the id is taken.

The claim is the collision check - mkdir(exist_ok=False) is atomic, so two concurrent submits cannot both win the same id no matter how the tails were drawn.

Rückgabetyp:

JobDir

Reading and writing the two job files, safely.

Two properties matter more than anything else here, because QGIS may be polling status.json at 1 Hz while the runner rewrites it:

Writes are atomic. Temporary file, fsync, os.replace. Not os.rename - that fails on Windows when the destination exists, and a „write then rename“ that silently falls back to „unlink then rename“ has a window where the file does not exist at all. os.replace is atomic on both platforms.

Reads tolerate a torn moment. Even with an atomic replace, a reader on a network filesystem can observe an incomplete file, and the correct response is to look again rather than to report the job as broken. So the reader retries a few times over a few tens of milliseconds before giving up.

Schema versioning follows plan §5 exactly: older migrates in memory and is never rewritten on read (a read must not mutate a record the user may still want to inspect); equal proceeds; newer is refused with a message naming both versions, because running a job description you do not fully understand is how a job that looks submitted quietly runs something else.

axqua.jobs.store.read_json_tolerant(path, *, retries=4, delay=0.05)[Quellcode]

Read a JSON file, retrying through a concurrent replace.

Returns None when the file is absent - an absent status.json is a normal state (the job directory exists but nothing has run yet), not an error.

Parameter:
Rückgabetyp:

dict[str, Any] | None

axqua.jobs.store.read_spec(job_dir)[Quellcode]
Parameter:

job_dir (JobDir | str | PathLike)

Rückgabetyp:

JobSpec

axqua.jobs.store.read_status(job_dir)[Quellcode]

The last complete status, or None if nothing has been written yet.

Parameter:

job_dir (JobDir | str | PathLike)

Rückgabetyp:

JobStatus | None

axqua.jobs.store.write_json_atomic(path, payload, *, fsync=True)[Quellcode]

Replace path with payload atomically. Returns the path.

Parameter:
Rückgabetyp:

Path

axqua.jobs.store.write_spec(job_dir, spec, *, freeze=True)[Quellcode]

Write job.json once, then make it read-only.

The read-only bit is not security - it is a statement. job.json is the record of what was asked for; anything that wants to change it wants a new job.

Parameter:
Rückgabetyp:

Path

axqua.jobs.store.write_status(job_dir, status)[Quellcode]

Replace status.json.

Callers must hold job.lock; that is enforced by convention and by the executor’s structure rather than by a check here, because reaper.reconcile legitimately writes after proving the previous owner is gone.

Parameter:
Rückgabetyp:

Path

job.lock: one writer at a time, and a way back from a crash.

Two QGIS windows, or a CLI and QGIS, can act on the same job at once (plan §5). The lock gives status.json exactly one writer. What makes it more than a file is what happens when the holder dies: a lock nobody can break is worse than no lock, because a job that crashed at 3 a.m. could then never be resubmitted.

So the record carries enough to judge staleness without guessing, and the checks run in order of confidence:

  1. A different host is never broken automatically. On a shared job root the PID means nothing here, so the honest answer is „cannot tell“, and --force is a human decision.

  2. A different boot id is stale: the machine restarted, so every PID it recorded is meaningless. This is also the wsl --shutdown case.

  3. A dead PID is stale.

  4. A live PID with a different start time is stale - PID reuse. Without this check a long-running job’s recycled PID makes an unrelated process look like the lock holder, and the lock is never broken.

Readers never lock (plan §5); they read the last complete status.json.

class axqua.jobs.lock.JobLock(job_dir)[Quellcode]

Bases: object

An advisory lock on one job directory.

Parameter:

job_dir (JobDir | str | os.PathLike)

owner()[Quellcode]
Rückgabetyp:

LockRecord | None

is_stale()[Quellcode]

(stale, reason). A missing lock is not stale - it is absent.

Rückgabetyp:

tuple[bool, str]

acquire(*, purpose='execute', break_stale=True, force=False)[Quellcode]
Parameter:
Rückgabetyp:

JobLock

release()[Quellcode]
Rückgabetyp:

None

property held: bool
holding(*, purpose='execute', break_stale=True, force=False)[Quellcode]
Parameter:
Rückgabetyp:

Iterator[JobLock]

class axqua.jobs.lock.LockRecord(pid: 'int', started: 'str', purpose: 'str' = 'execute', host: 'str' = '', boot_id: 'str | None' = None, proc_started: 'float | None' = None, extra: 'dict[str, Any]'=<factory>)[Quellcode]

Bases: object

Parameter:
pid: int
started: str
purpose: str = 'execute'
host: str = ''
boot_id: str | None = None
proc_started: float | None = None
extra: dict[str, Any]
as_dict()[Quellcode]
Rückgabetyp:

dict[str, Any]

classmethod from_dict(data)[Quellcode]
Parameter:

data (dict[str, Any])

Rückgabetyp:

LockRecord

classmethod for_self(purpose)[Quellcode]
Parameter:

purpose (str)

Rückgabetyp:

LockRecord

exception axqua.jobs.lock.LockedError(message, *, subject=None, remedy=None, **details)[Quellcode]

Bases: ConfigError

Someone else holds the lock, and it is not stale.

Parameter:
  • message (str)

  • subject (str | None)

  • remedy (str | None)

  • details (Any)

Rückgabetyp:

None

code = 'axqua.job.locked'

A SQLite index over the job root - derived, never authoritative.

Listing hundreds of jobs by opening hundreds of status.json files is slow enough to be felt in a dashboard, so there is an index. But the index is a cache of the directories, and the directories win every disagreement (plan §5). Two consequences, both deliberate:

  • rebuild() reconstructs the whole thing by scanning, and it is a tested path rather than a repair tool nobody exercises. That covers the first crash mid-write, a restored backup, and a job directory the user moved by hand.

  • Every index failure is swallowed. A job must never fail because its bookkeeping could not be written; that is what makes plan §24’s „a solver failure must not corrupt the job registry“ true by construction rather than by care. Callers that cannot open the database fall back to a live directory scan.

WAL journalling, so a QGIS reader polling the list never blocks the runner writing it.

class axqua.jobs.index.JobRow(job_id, root, kind=None, solver=None, case_name=None, source_config=None, state=None, created=None, updated=None, finished=None, schema_version=None, indexed=None)[Quellcode]

Bases: object

One row, flattened for a table view. Never carries solver field data (plan §26).

Parameter:
  • job_id (str)

  • root (str)

  • kind (str | None)

  • solver (str | None)

  • case_name (str | None)

  • source_config (str | None)

  • state (str | None)

  • created (str | None)

  • updated (str | None)

  • finished (str | None)

  • schema_version (int | None)

  • indexed (str | None)

job_id: str
root: str
kind: str | None = None
solver: str | None = None
case_name: str | None = None
source_config: str | None = None
state: str | None = None
created: str | None = None
updated: str | None = None
finished: str | None = None
schema_version: int | None = None
indexed: str | None = None
property job_dir: JobDir
as_dict()[Quellcode]
Rückgabetyp:

dict[str, Any]

class axqua.jobs.index.JobIndex(db_path=None)[Quellcode]

Bases: object

The index. Constructing one never raises; using one never raises.

Parameter:

db_path (str | os.PathLike | None)

property usable: bool

False when the database could not be opened - callers then scan instead.

upsert(row)[Quellcode]
Parameter:

row (JobRow)

Rückgabetyp:

None

remove(job_id)[Quellcode]
Parameter:

job_id (str)

Rückgabetyp:

None

get(job_id)[Quellcode]
Parameter:

job_id (str)

Rückgabetyp:

JobRow | None

list(*, state=None, kind=None, solver=None, case=None, since=None, limit=None)[Quellcode]
Parameter:
  • state (str | None)

  • kind (str | None)

  • solver (str | None)

  • case (str | None)

  • since (str | None)

  • limit (int | None)

Rückgabetyp:

list[JobRow]

rebuild(job_root=None, *, reconcile=True)[Quellcode]

Rebuild from a directory scan. Returns how many jobs were found.

This is the authority restoring the cache, so it clears first: an index row for a directory the user deleted is exactly the kind of disagreement the scan exists to settle.

Parameter:
Rückgabetyp:

int

axqua.jobs.index.row_for(job_dir, *, reconcile=False)[Quellcode]

Read one job directory into a row, or None if it is not a job.

Never raises: a half-written or hand-mangled directory should drop out of a listing, not take the listing down with it.

Parameter:
Rückgabetyp:

JobRow | None

axqua.jobs.index.scan(job_root=None, *, reconcile=False)[Quellcode]

Yield every job directory under job_root, newest first.

The directory name must look like a job id, so unrelated folders under a shared scratch root are skipped rather than misread.

Parameter:
Rückgabetyp:

Iterator[JobRow]

axqua.jobs.index.list_jobs(*, job_root=None, index=None, rebuild=False, **filters)[Quellcode]

List jobs, preferring the index and falling back to a scan.

The fallback is not a corner case: a fresh checkout, a read-only home, or a corrupt database all land here, and in every one of them a slower correct answer beats an error.

Parameter:
Rückgabetyp:

list[JobRow]

results/results.json: what a job produced, as small structured metadata.

The rule this file exists to enforce is plan §26: the runner and the plugin communicate through job metadata, status metadata, file paths and small structured results - never through solver field arrays. A TELEMAC result is hundreds of megabytes and a reconstructed OpenFOAM case is gigabytes; serializing any of that into JSON, or copying it so the plugin can find it, would be the single easiest way to make the system unusable.

So a result entry is a path plus a hint. The plugin reads the manifest, decides what to add to the layer tree, and lets QGIS/MDAL open the file itself.

The manifest is written by SolverBackend.export_qgis_results(), which is also why that method takes no PyQGIS: it produces files and describes them, and the plugin does the loading (plan §14).

class axqua.jobs.results.ResultEntry(name, path, kind='file', style='', variable='', description='', extra=<factory>)[Quellcode]

Bases: object

One loadable artifact.

Parameter:
name: str
path: str
kind: str = 'file'
style: str = ''
variable: str = ''
description: str = ''
extra: dict[str, Any]
as_dict()[Quellcode]
Rückgabetyp:

dict[str, Any]

classmethod from_dict(data)[Quellcode]
Parameter:

data (dict[str, Any])

Rückgabetyp:

ResultEntry

class axqua.jobs.results.ResultManifest(job_id='', solver='', kind='', generated=<factory>, schema_version=1, objective=None, entries=<factory>, summary=<factory>, crs_epsg=None)[Quellcode]

Bases: object

Everything one job produced that is worth opening.

Parameter:
job_id: str = ''
solver: str = ''
kind: str = ''
generated: str
schema_version: int = 1
objective: float | None = None
entries: list[ResultEntry]
summary: dict[str, Any]
crs_epsg: int | None = None

The coordinate reference system of the case. A SELAFIN file carries none, so a result opened in QGIS had no CRS at all and did not line up with a base map unless the project happened to use the same one.

add(name, path, *, kind='file', style='', variable='', description='', base=None, **extra)[Quellcode]

Record one artifact, skipping it if the file is not there.

Silently skipping is right here: backends list everything they might have produced, and a manifest entry pointing at a missing file would make the plugin show a broken layer rather than simply not showing one.

Parameter:
Rückgabetyp:

ResultManifest

as_dict()[Quellcode]
Rückgabetyp:

dict[str, Any]

classmethod from_dict(data)[Quellcode]
Parameter:

data (dict[str, Any])

Rückgabetyp:

ResultManifest

axqua.jobs.results.read_manifest(job_dir)[Quellcode]
Parameter:

job_dir (JobDir | str | PathLike)

Rückgabetyp:

ResultManifest | None

axqua.jobs.results.write_manifest(job_dir, manifest)[Quellcode]
Parameter:
Rückgabetyp:

Path

Prozesse und Staatenabstimmung

Is that process still there? - the primitives, without psutil.

A detached job records a PID. Some time later, possibly after a reboot, something has to decide whether that PID still means what it meant. Three facts are needed and none of them is a plain os.kill(pid, 0):

  • PID reuse. On Linux, PIDs wrap; on a busy box a multi-day job’s PID can be reassigned to something unrelated. os.kill(pid, 0) then says „alive“ about a process that is not the job. So the start time is recorded alongside the PID and compared - the pair is what identifies a process, not the number alone.

  • Reboot. After a restart every recorded PID is meaningless. The boot id makes that a single comparison rather than a heuristic, and it is also the wsl --shutdown case: the distro’s VM restarted and every job in it is gone.

  • Another machine. A job root on a shared filesystem may hold jobs launched elsewhere. Their PIDs say nothing here, so the host is recorded and a foreign job is reported rather than judged.

psutil would supply all three, but the dependency policy (plan §28) is to stay light, and this is ~100 lines of standard library.

axqua.jobs.procs.boot_id()[Quellcode]

An identifier that changes on every boot, or None if unobtainable.

Linux exposes one directly. Elsewhere it is derived from the boot instant, rounded hard: the point is only to differ across restarts, and monotonic-clock jitter must not make two readings on the same boot disagree.

Rückgabetyp:

str | None

axqua.jobs.procs.host_id()[Quellcode]

A stable-enough machine name. Used only to refuse to judge a foreign job.

Rückgabetyp:

str

axqua.jobs.procs.pid_alive(pid)[Quellcode]

True when pid names a live process. Never raises.

A zombie counts as dead, and getting this wrong is not academic. os.kill(pid, 0) succeeds against a zombie, because the process table entry still exists until the parent reaps it. So a job whose runner has exited but whose parent has not yet called wait reads as RUNNING - which makes cancel report that it failed to stop something that already stopped, and leaves the dashboard waiting forever. In production the launcher’s parent exits and init reaps the child, so the window is short; in any long-lived parent (a test runner, a plugin process that submitted) it is not.

Parameter:

pid (int | None)

Rückgabetyp:

bool

axqua.jobs.procs.process_start_time(pid)[Quellcode]

When pid started, in whatever unit the platform offers.

The value is only ever compared with another reading from the same platform, so the unit does not need to be portable - only stable. None means „cannot tell“, which callers must treat as „do not conclude anything“, not as „changed“.

Parameter:

pid (int | None)

Rückgabetyp:

float | None

axqua.jobs.procs.self_start_time()[Quellcode]
Rückgabetyp:

float | None

Reconciling a job’s recorded state with reality.

A detached job can die in ways it cannot report: the machine reboots, wsl --shutdown takes the distro with every job in it, an OOM killer removes the runner, someone runs kill -9. In all of those the last thing written to status.json says RUNNING, and it will keep saying so forever.

A job stuck in RUNNING is worse than a job marked FAILED: the dashboard keeps polling it, the user keeps waiting for it, and nothing ever prompts a resubmission. So every read path that shows state to a human goes through here first, and one implementation covers the POSIX fallback, systemd, WSL and Windows because they all reduce to the same question - is the recorded process still the process we launched?

The one thing this must not do is guess about another machine. On a shared job root a PID from another host says nothing here, so such a job is reported unchanged.

axqua.jobs.reaper.reconcile(job_dir, *, write=True)[Quellcode]

Return the job’s true state, correcting status.json when it is wrong.

write exists for read-only callers (a listing over a mounted archive) that still want the corrected answer in memory.

Parameter:
Rückgabetyp:

JobStatus | None

SolverProfile

Solver profiles: how to reach TELEMAC or OpenFOAM on this machine.

A profile is deliberately machine-local and lives in the user’s config directory, never in the project file. It names install paths, core counts and scratch volumes - exactly the things that differ between the workstation, the cluster and a colleague’s laptop. Putting them in a shared .axqua-prj would destroy its portability the first time it was committed (plan §3, §6).

The schema is the plan’s, verbatim, so the example in §6 parses unchanged:

profiles:
  telemac_linux:
    solver: telemac
    environment: posix
    setup_script: /opt/telemac/v9.1/configs/pysource.sh
    mpi_launcher: mpirun
    mpi_processes: 16
    working_root: /scratch/axqua

  openfoam_windows:            # Foundation OpenFOAM: WSL is the route
    solver: openfoam
    environment: wsl
    distro: Ubuntu-22.04
    setup_script: /opt/openfoam9/etc/bashrc     # a path INSIDE the distro
    working_root: /scratch/axqua            # inside the distro

validate() is the pre-submission check plan §6 asks for, and it is SolverEnvironment.validate() unchanged - including its provenance warning about sentinels that came from the ambient shell rather than the setup script. That warning is the „works in my terminal, fails in the runner“ trap, and a detached job is precisely where it bites.

class axqua.jobs.profiles.Profile(name, solver='telemac', environment='posix', setup_script=None, shell=None, distro=None, config_name=None, mpi_launcher=None, mpi_processes=1, working_root=None, launcher='auto', postprocess=<factory>, overrides=<factory>)[Quellcode]

Bases: object

One way of reaching one solver.

Parameter:
  • name (str)

  • solver (str)

  • environment (str)

  • setup_script (Path | None)

  • shell (str | None)

  • distro (str | None)

  • config_name (str | None)

  • mpi_launcher (str | None)

  • mpi_processes (int)

  • working_root (Path | None)

  • launcher (str)

  • postprocess (list[str])

  • overrides (dict[str, str])

name: str
solver: str = 'telemac'
environment: str = 'posix'
setup_script: Path | None = None
shell: str | None = None
distro: str | None = None
config_name: str | None = None
mpi_launcher: str | None = None
mpi_processes: int = 1
working_root: Path | None = None
launcher: str = 'auto'
postprocess: list[str]
overrides: dict[str, str]
classmethod from_dict(name, data, *, base=None)[Quellcode]
Parameter:
Rückgabetyp:

Profile

as_dict()[Quellcode]
Rückgabetyp:

dict[str, Any]

solver_environment()[Quellcode]
Rückgabetyp:

SolverEnvironment

validate(*, timeout=120.0)[Quellcode]

Can this profile actually reach its solver? Never raises.

Parameter:

timeout (float)

Rückgabetyp:

EnvStatus

describe()[Quellcode]
Rückgabetyp:

str

axqua.jobs.profiles.from_plugin_profile(plugin, solver='telemac', cfg=None)[Quellcode]

The job-system view of one binding of an *.axq-profile.

None when the profile does not bind solver, so the caller can fall back. The case’s own process count fills in where the binding names none: the profile says what the computer has, and a binding without a number has no opinion.

Parameter:
Rückgabetyp:

Profile | None

axqua.jobs.profiles.implicit(cfg, solver='telemac')[Quellcode]

The profile a job uses when none is named.

The active *.axq-profile where it binds solver, otherwise whatever the case config resolved to (from_config()). The second half is what keeps a computer without a profile working exactly as before.

The environment variable of the code and a solvers.local.yml beside the case outrank the profile here as they do when the case is loaded (axqua.core.machine.resolve()); a job would otherwise compute with another installation than the one its case was built with. The profile then still says how this computer enters an environment and how many processes it runs. The script is the one that outranks it, and what the profile holds for its own installation (the systel configuration, the variables) is not applied to another one.

Parameter:
Rückgabetyp:

Profile

axqua.jobs.profiles.load_profiles(path=None)[Quellcode]

Read profiles.yml. A missing file is an empty mapping, not an error.

A fresh install has no profiles and must still be able to run axqua profiles to be told so.

Parameter:

path (str | PathLike | None)

Rückgabetyp:

dict[str, Profile]

axqua.jobs.profiles.resolve(name, *, solver=None, cfg=None, path=None)[Quellcode]

Find the profile to use, or synthesize one from the case config.

The fallback matters: every existing case already carries telemac.pysource (or openfoam.bashrc), so a user who has never written a profiles.yml can still submit a job. The profile system is an addition, not a new prerequisite.

Parameter:
Rückgabetyp:

Profile

axqua.jobs.profiles.save_profiles(profiles, path=None)[Quellcode]

Write profiles.yml, keeping the profiles: wrapper.

Parameter:
Rückgabetyp:

Path

Fortschrittsereignisse und Protokolle

Turning a stream of solver lines into job state.

axqua’s existing progress reporting is console-shaped: on_line callbacks that draw a bar on a TTY. A detached job has no TTY and needs the same information as structured state in status.json, so that QGIS never has to parse a human-readable log (plan §11, explicitly).

The wrong fix would be a second parser. SolverProgress already extracts the iteration and the simulated time from TELEMAC’s listing header, and OpenFoamProgress already extracts the Courant number and the time step from interFoam’s - both hard-won against real output. So this module adds a sink those classes also emit to, and everything composes:

  • ConsoleSink is today’s behavior, unchanged, for terminal runs;

  • StatusFileSink writes status.json;

  • TeeSink does both, which is what axqua execute wants - a debugging run should look exactly like the old scripts and leave a machine-readable trail.

StatusFileSink is throttled. Without it a TELEMAC listing at a few headers a second produces several fsync-ed rewrites a second on a scratch volume, which is both wasteful and a reliable way to make a network filesystem unhappy. State and step changes are never throttled - they are what the dashboard is waiting for.

class axqua.jobs.events.ConsoleSink(*, echo=True)[Quellcode]

Bases: NullSink

Print. This is what a terminal run has always done.

Parameter:

echo (bool)

line(text)[Quellcode]
Parameter:

text (str)

Rückgabetyp:

None

step(name, *, done=False, seconds=None, failed=False)[Quellcode]
Parameter:
Rückgabetyp:

None

class axqua.jobs.events.EventSink(*args, **kwargs)[Quellcode]

Bases: Protocol

What a long-running operation reports.

Five verbs, because five different things happen and collapsing them into one log(str) is exactly what forces a GUI to parse prose.

line(text)[Quellcode]

One raw line of solver output.

Parameter:

text (str)

Rückgabetyp:

None

progress(**fields)[Quellcode]

Typed progress fields for the current job kind.

Parameter:

fields (Any)

Rückgabetyp:

None

step(name, *, done=False, seconds=None, failed=False)[Quellcode]

A named phase started, finished or failed.

Parameter:
Rückgabetyp:

None

artifact(kind, path)[Quellcode]

A file worth recording in the job’s artifact map.

Parameter:
Rückgabetyp:

None

decision(question, answer, source)[Quellcode]

A question the job answered on the user’s behalf, and why.

Parameter:
Rückgabetyp:

None

class axqua.jobs.events.NullSink[Quellcode]

Bases: object

Discards everything. The default, so nothing has to check for None.

line(text)[Quellcode]
Parameter:

text (str)

Rückgabetyp:

None

progress(**fields)[Quellcode]
Parameter:

fields (Any)

Rückgabetyp:

None

step(name, *, done=False, seconds=None, failed=False)[Quellcode]
Parameter:
Rückgabetyp:

None

artifact(kind, path)[Quellcode]
Parameter:
Rückgabetyp:

None

decision(question, answer, source)[Quellcode]
Parameter:
Rückgabetyp:

None

class axqua.jobs.events.StatusFileSink(job_dir, status, *, min_interval=2.0)[Quellcode]

Bases: NullSink

Fold events into status.json.

Holds the JobStatus in memory and replaces the file; the executor owns the lock for the duration of the run, so there is exactly one writer.

Parameter:
progress(**fields)[Quellcode]
Parameter:

fields (Any)

Rückgabetyp:

None

step(name, *, done=False, seconds=None, failed=False)[Quellcode]
Parameter:
Rückgabetyp:

None

artifact(kind, path)[Quellcode]
Parameter:
Rückgabetyp:

None

decision(question, answer, source)[Quellcode]
Parameter:
Rückgabetyp:

None

pending(question)[Quellcode]

Record a question the job is about to answer, so the dashboard can show it.

Parameter:

question (str | None)

Rückgabetyp:

None

state_changed()[Quellcode]

Write the status now, because its state was just changed.

The executor assigns a transition on the status object itself, so this sink still considers itself clean and flush() skips the write. The new state then reached the file only when a progress event happened to follow. A steady run reports progress within seconds; a calibration reports none, and stood in the dashboard as STARTING for as long as it ran.

Rückgabetyp:

None

flush()[Quellcode]
Rückgabetyp:

None

close()[Quellcode]
Rückgabetyp:

None

class axqua.jobs.events.TeeSink(*sinks)[Quellcode]

Bases: NullSink

Fan out to several sinks. A failing sink never stops the others.

Parameter:

sinks (Any)

line(text)[Quellcode]
Parameter:

text (str)

Rückgabetyp:

None

progress(**fields)[Quellcode]
Parameter:

fields (Any)

Rückgabetyp:

None

step(name, *, done=False, seconds=None, failed=False)[Quellcode]
Parameter:
Rückgabetyp:

None

artifact(kind, path)[Quellcode]
Parameter:
Rückgabetyp:

None

decision(question, answer, source)[Quellcode]
Parameter:
Rückgabetyp:

None

flush()[Quellcode]
Rückgabetyp:

None

close()[Quellcode]
Rückgabetyp:

None

class axqua.jobs.events.TextLogSink(writer)[Quellcode]

Bases: NullSink

Append raw lines to a rotating text file - the solver’s own narration.

Deliberately separate from runner.log (plan §23): axqua’s narration is state transitions and exit codes, while this is the solver talking. Mixing them makes both harder to read and makes the rotation policy wrong for at least one of them.

Parameter:

writer (Any)

line(text)[Quellcode]
Parameter:

text (str)

Rückgabetyp:

None

step(name, *, done=False, seconds=None, failed=False)[Quellcode]
Parameter:
Rückgabetyp:

None

axqua.jobs.events.current_sink: ContextVar = <ContextVar name='axqua_event_sink' default=<axqua.jobs.events.NullSink object>>

The ambient sink, so log_step can report phases without every one of its ~40 call sites growing a parameter. A ContextVar rather than a module global because a convergence study runs nested steps and must not leak its sink to a sibling.

axqua.jobs.events.using_sink(sink)[Quellcode]
Parameter:

sink (Any)

Rückgabetyp:

Iterator[Any]

Job logging: bounded, and split by who is talking.

Two separate streams, because they answer different questions and want different policies (plan §23):

runner.log

axqua’s own narration - state transitions, the commands it issued, exit codes, log_step timings. Small, and the thing to read first when a job fails.

solver/<name>.log

The solver talking. TELEMAC prints a block per time step and interFoam at ~1e-3 s steps over hours produces hundreds of thousands of lines; a multi-day run will otherwise fill the scratch volume and take itself down.

Both rotate, and rotation keeps the last parts. That is worth stating because the naive alternative - stop writing at N bytes - keeps the first, and when a run fails after two days the beginning is precisely the part nobody needs. logging.handlers.RotatingFileHandler already behaves correctly here: the live file is always the newest and .1 the most recent rollover.

The disk guard is here too. „Insufficient disk space where detectable“ (plan §24) is six lines and turns a mid-run death with a corrupt half-written result into a refusal at submit time carrying a remedy.

class axqua.jobs.logs.RotatingTextSink(path, *, max_bytes=134217728, backups=3)[Quellcode]

Bases: object

Append raw solver output to a size-bounded file.

Built on RotatingFileHandler rather than hand-rolled, so the rollover semantics are the standard library’s and the last parts survive. Line-buffered writes: a job that is killed must still have its most recent output on disk.

Parameter:
write(line)[Quellcode]
Parameter:

line (str)

Rückgabetyp:

None

close()[Quellcode]
Rückgabetyp:

None

axqua.jobs.logs.free_bytes(path)[Quellcode]

Free space on the volume holding path, or None if it cannot be told.

Parameter:

path (str | PathLike)

Rückgabetyp:

int | None

axqua.jobs.logs.require_free_space(path, minimum=5368709120)[Quellcode]

Refuse to start when the volume is already too full to finish.

Parameter:
Rückgabetyp:

None

axqua.jobs.logs.runner_log(path, *, level=10, console=False, max_bytes=33554432, backups=5)[Quellcode]

Route the axqua logger into a rotating runner.log for the duration.

Scoped rather than global because a job runs inside a process that may also be doing something else (axqua execute in a terminal), and the handler must come off cleanly afterwards.

Parameter:
Rückgabetyp:

Iterator[Path]

Answering, in a detached job, the questions an interactive study asks.

convergence.run_mesh_convergence prompts twice: whether to reuse completed levels on a re-run, and whether to refine one level further when the study has not converged. Its default handler reads stdin with a timeout and, off a TTY, returns the default - so a detached job does not hang, which is the important part.

But „does not hang“ is not the same as „does the right thing“: the default for extend is no, so a study submitted specifically to march finer would quietly stop at its first ladder. The fix is not to make the prompt smarter; it is to make the answer part of the submission. PolicyAsk answers from job.json, never blocks, and records every question and answer in runner.log so the decision is auditable rather than invisible.

An answer file is also honored, which is what lets a future axqua answer JOB_ID --yes reach a running study without restarting it. The file format is fixed here even though the CLI verb is not built yet, so adding it later needs no migration.

class axqua.jobs.interaction.PolicyAsk(answers=None, *, sink=None, answer_file=None, default=False)[Quellcode]

Bases: object

A drop-in replacement for the study’s ask callable.

Signature-compatible with convergence._default_ask (prompt, keyword default, and a tolerated timeout), so it substitutes without the study knowing.

Parameter:
  • answers (dict[str, bool] | None)

  • sink (Any)

  • answer_file (Path | str | None)

  • default (bool)

axqua.jobs.interaction.classify(prompt)[Quellcode]

Map a prompt to a policy key, or "other".

Parameter:

prompt (str)

Rückgabetyp:

str

Einreichung und Ausführung

Turning a case configuration into a job, and starting it detached.

Submission does three things, in an order chosen so that a failure never leaves a half-started job behind:

  1. Freeze. The resolved configuration is written into the job directory, both as the record in job.json and as a loadable input/case-config.yml. From that moment the job is reproducible from its own directory and immune to later edits of the case - which is the whole reason job.json stores the configuration rather than a path to it (plan §3).

  2. Validate. The solver environment is probed before anything is launched, because „the setup script is wrong“ discovered at submit time is a message, while the same fact discovered inside a detached process is an archaeology exercise.

  3. Launch, and only then record the handle.

The environment is captured here, once, and handed to the launcher as a dict - the first real use of SolverEnvironment.capture(). That is what keeps a shell out of the job’s process tree.

axqua.jobs.submit.build_spec(cfg, kind, *, profile=None, job_id=None, options=None, np=None, workspace=None, source_job=None, launcher='auto', source_config=None)[Quellcode]

Assemble the immutable submission record. Writes nothing.

Parameter:
Rückgabetyp:

JobSpec

axqua.jobs.submit.create_job(cfg, spec, *, job_root=None)[Quellcode]

Claim the directory and freeze the inputs. Nothing is launched.

Parameter:
Rückgabetyp:

JobDir

axqua.jobs.submit.submit_job(cfg, kind, *, profile=None, options=None, job_root=None, np=None, workspace=None, source_job=None, launcher='auto', source_config=None, validate_env=True, dry_run=False)[Quellcode]

Freeze, validate, launch. Returns the JobDir, or the spec on a dry run.

Parameter:
Rückgabetyp:

JobSpec | JobDir

Running one job: dispatch, state, and turning any failure into a record.

This is the only module in axqua.jobs that touches a solver, and it touches it through exactly one seam - registry.get(solver).load(). There is no if solver == "telemac" here and there must never be (plan §12): the kind table says which verb to call, the backend decides what that verb means, and adding a third solver is a pip install, not an edit to this file.

The shape of a run:

lock -> runner.log -> load the frozen config -> rebase the workspace
     -> disk check -> STARTING -> RUNNING -> backend.<verb>(...)
     -> POSTPROCESSING -> backend.postprocess / export_qgis_results
     -> results.json -> COMPLETED

Every failure becomes a ErrorRecord in status.json and an ERROR line in runner.log. Plan §24 requires both, and the reason is practical: the record is what a GUI can act on, the log is what a human can read.

Cancellation is cooperative first. cancel writes a request file; the token below is checked between steps and inside the streaming callback, so the job tears itself down and writes CANCELLED on its own. The launcher’s tree-kill is the fallback for a job that is too busy or too stuck to notice.

class axqua.jobs.executor.CancelToken(job_dir, *, interval=1.0)[Quellcode]

Bases: object

Cooperative cancellation, driven by a file.

A file rather than a signal because the request comes from a different process (possibly a different QGIS session, possibly days later), and because it preserves the one-writer-per-file rule: cancel never touches status.json.

Parameter:
property requested: bool
check()[Quellcode]
Rückgabetyp:

None

class axqua.jobs.executor.ExecutionContext(job_dir, spec, cfg, sink=<factory>, cancel=None, manifest=None, log=<Logger axqua.jobs.executor (WARNING)>, _ask=None, _open_sinks=<factory>)[Quellcode]

Bases: object

Everything a backend needs that is not the case configuration.

Passed as ctx= rather than as loose keyword arguments so a backend can grow a new need without every caller changing, and so the standalone case scripts - which pass no context at all - keep working against the same backend methods.

Parameter:
job_dir: JobDir
spec: JobSpec
cfg: Any
sink: Any
cancel: CancelToken | None = None
manifest: ResultManifest | None = None
log: Logger = <Logger axqua.jobs.executor (WARNING)>
property capability: Capability | None
property options: Any
should_stop()[Quellcode]
Rückgabetyp:

bool

check_cancelled()[Quellcode]
Rückgabetyp:

None

ask(prompt, *, default=False, timeout=None)[Quellcode]

Answer a study’s question from the job record. Never reads stdin.

Parameter:
Rückgabetyp:

bool

progress(**fields)[Quellcode]
Parameter:

fields (Any)

Rückgabetyp:

None

solver_sink(name)[Quellcode]

A rotating text sink for one solver invocation’s raw output.

Parameter:

name (str)

Rückgabetyp:

Any

record(name, path, **kwargs)[Quellcode]

Add an artifact to the result manifest.

Parameter:
Rückgabetyp:

None

close()[Quellcode]
Rückgabetyp:

None

exception axqua.jobs.executor.JobCancelled[Quellcode]

Bases: Exception

Raised inside the job when a cancellation request is noticed.

axqua.jobs.executor.execute(job_dir, *, sink=None, console=False, break_stale_lock=True)[Quellcode]

Run the job in job_dir to completion. Returns a process exit code.

Synchronous by design - this is what axqua execute calls, and what every detached launcher ultimately starts. Keeping the detached path and the debugging path identical means a failure reproduces under a terminal.

Parameter:
Rückgabetyp:

int

Abgestellte Trägerraketen

Starting a job that outlives whatever started it.

This is the interface plan §8 asks for, and the reason it is an interface rather than one implementation is that „detached“ means four genuinely different things:

  • systemd user services on Linux, where the kernel already provides the thing this module wants - a named unit whose whole cgroup can be queried, killed and logged as a unit. MPI ranks belong to it automatically.

  • A new POSIX session everywhere else, where the process group is the unit.

  • A Windows Job Object, because Windows has no process groups in the POSIX sense and without a Job Object there is no reliable tree-kill at all.

  • WSL, where the job must be detached inside the distro and the Windows side acts on it only by running commands through wsl.exe.

Two rules cut across all four:

The environment goes on the process, not into a wrapper shell. Every launcher takes an already-captured environment dict (SolverEnvironment.capture()) and sets it directly. A bash -lc 'source ...; axqua execute' in the middle would add a layer between the unit and the ranks, and each such layer is somewhere a signal can fail to propagate.

A handle must survive the launching process. Everything needed to query or cancel the job later - unit name, pid, pgid, job-object name, the recording host and boot id - is serialized into status.json. A fresh axqua cancel days later reconstructs the launcher from that alone.

class axqua.jobs.launcher.JobLauncher(*args, **kwargs)[Quellcode]

Bases: Protocol

name: str
classmethod available(env=None)[Quellcode]

Can this launcher be used on this machine, right now?

Parameter:

env (SolverEnvironment | None)

Rückgabetyp:

bool

submit(job_dir, argv, *, env, cwd)[Quellcode]
Parameter:
Rückgabetyp:

LaunchHandle

status(handle)[Quellcode]
Parameter:

handle (LaunchHandle)

Rückgabetyp:

LaunchState

cancel(handle, *, grace=20.0)[Quellcode]
Parameter:
Rückgabetyp:

bool

logs(handle)[Quellcode]
Parameter:

handle (LaunchHandle)

Rückgabetyp:

Path | None

class axqua.jobs.launcher.LaunchHandle(backend, pid=None, pgid=None, proc_started=None, unit=None, job_object=None, distro=None, host=<factory>, boot_id=<factory>, started=<factory>, log_path=None)[Quellcode]

Bases: object

Everything needed to find the job again from a different process.

Parameter:
  • backend (str)

  • pid (int | None)

  • pgid (int | None)

  • proc_started (float | None)

  • unit (str | None)

  • job_object (str | None)

  • distro (str | None)

  • host (str)

  • boot_id (str | None)

  • started (str)

  • log_path (str | None)

backend: str
pid: int | None = None
pgid: int | None = None
proc_started: float | None = None
unit: str | None = None
job_object: str | None = None
distro: str | None = None
host: str
boot_id: str | None
started: str
log_path: str | None = None
as_dict()[Quellcode]
Rückgabetyp:

dict[str, Any]

classmethod from_dict(data)[Quellcode]
Parameter:

data (dict[str, Any])

Rückgabetyp:

LaunchHandle

class axqua.jobs.launcher.LaunchState(*values)[Quellcode]

Bases: str, Enum

What the launcher can say about a handle.

UNKNOWN is a real answer and is treated as „still running“ by the reaper. Reporting a job dead because its supervisor was momentarily unreachable would be worse than saying nothing.

RUNNING = 'running'
GONE = 'gone'
UNKNOWN = 'unknown'
axqua.jobs.launcher.available_launchers(env=None)[Quellcode]

Which launchers this machine can actually use. For axqua profiles.

Parameter:

env (SolverEnvironment | None)

Rückgabetyp:

list[str]

axqua.jobs.launcher.launcher_for(handle)[Quellcode]

Reconstruct the launcher that started a recorded job.

By name, from the handle - not by re-running the selection. A job launched under systemd must be cancelled through systemd even if the user manager has since become unavailable and selection would now choose the POSIX fallback.

Parameter:

handle (LaunchHandle)

Rückgabetyp:

JobLauncher

axqua.jobs.launcher.select_launcher(env=None, *, prefer=None)[Quellcode]

Pick a launcher, first hit wins.

The WSL rule is a forced match rather than a preference: a captured Linux environment cannot start a Windows process, so a WSL solver has exactly one way to be launched and letting a preference override it would produce a job that cannot run.

Parameter:
Rückgabetyp:

JobLauncher

systemd user services: the preferred Linux launcher.

A transient user unit gives, for free, all four things plan §8 asks for: the solver survives the shell that started it, its state can be queried, its entire process tree can be terminated as a unit, and its logs are retrievable. MPI ranks belong to the unit’s cgroup automatically, which is what makes cancellation reliable rather than best-effort - a cgroup kill cannot miss a descendant the way signalling a process group can if a child deliberately changes its own group.

Three details are load-bearing:

--collect

The unit name is deterministic (axqua-<job_id>.service), which is what lets a later process find it. Without --collect a failed unit lingers in that name and the same job could never be resubmitted.

EnvironmentFile

Rather than N x --setenv. A captured OpenFOAM environment is large enough to exceed ARG_MAX, and the failure is an opaque „Argument list too long“ at submit time.

KillMode=control-group

The default already kills the cgroup, but stating it makes the guarantee explicit rather than inherited.

available() deliberately checks three things. A bare SSH session without lingering has systemd-run on PATH and no user manager at all, so testing for the binary alone is a false positive that produces a job which fails the moment it is submitted.

axqua.jobs.launchers.systemd.unit_name(job_id)[Quellcode]
Parameter:

job_id (str)

Rückgabetyp:

str

class axqua.jobs.launchers.systemd.SystemdUserLauncher[Quellcode]

Bases: object

systemd-run --user transient units.

name = 'systemd'
classmethod available(env=None)[Quellcode]
Parameter:

env (Any)

Rückgabetyp:

bool

submit(job_dir, argv, *, env, cwd)[Quellcode]
Parameter:
Rückgabetyp:

LaunchHandle

status(handle)[Quellcode]
Parameter:

handle (LaunchHandle)

Rückgabetyp:

LaunchState

cancel(handle, *, grace=20.0)[Quellcode]

Stop the unit - which stops the whole cgroup, MPI ranks included.

Parameter:
Rückgabetyp:

bool

logs(handle)[Quellcode]

The unit’s captured stdout.

A file rather than journalctl: the plugin needs a path it can tail, and the unit is configured to append to one, so the journal is the fallback for a human rather than the interface.

Parameter:

handle (LaunchHandle)

Rückgabetyp:

Path | None

The POSIX fallback: a new session, and a process group to kill.

Used wherever systemd’s user manager is not available - a container, a machine without lingering enabled, macOS. Less capable than a systemd unit (no supervision, no restart, no journal) but sufficient for the one thing that matters: the job survives the shell that started it, and it can still be terminated as a whole.

start_new_session=True is the entire trick. It calls setsid, which makes the child a session and process-group leader, so its own children - TELEMAC’s launcher, mpirun, the ranks - inherit that process group. killpg then reaches all of them. Without it the child joins the submitter’s process group, and cancelling would either miss the ranks or kill the terminal that launched them.

The parent never waits. It returns immediately, exits, and the child is reparented to init; that is what „detached“ means here (plan §8).

class axqua.jobs.launchers.posix.PosixDetachedLauncher[Quellcode]

Bases: object

Popen(..., start_new_session=True) plus a recorded process group.

name = 'posix'
classmethod available(env=None)[Quellcode]
Parameter:

env (Any)

Rückgabetyp:

bool

submit(job_dir, argv, *, env, cwd)[Quellcode]
Parameter:
Rückgabetyp:

LaunchHandle

status(handle)[Quellcode]
Parameter:

handle (LaunchHandle)

Rückgabetyp:

LaunchState

cancel(handle, *, grace=20.0)[Quellcode]

SIGTERM the group, then SIGKILL what is left.

The group, never the pid: the ranks are grandchildren, and signalling the leader alone leaves orphan mpirun processes running at full tilt (plan §9).

Parameter:
Rückgabetyp:

bool

logs(handle)[Quellcode]
Parameter:

handle (LaunchHandle)

Rückgabetyp:

Path | None

Windows: detached processes inside a named Job Object.

Windows has no process groups in the POSIX sense, so killpg has no equivalent and the naive proc.terminate() kills only the process you named - leaving every MPI rank it started running at full tilt. A Job Object is the actual equivalent: a kernel object that owns a set of processes and can terminate all of them at once.

Two flags decide whether this works, and the plan’s sketch gets one of them subtly wrong:

JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE must be cleared

Otherwise the job dies when the last handle to the object closes - i.e. when axqua submit exits, which is the opposite of detached.

The object must be named

An unnamed Job Object is reachable only through the handle that created it, and that handle dies with the submitting process. A later axqua cancel, in a different process, then has nothing to open - so cancellation would be impossible, which is exactly the failure the Job Object was introduced to prevent. Hence Local\axqua-<job_id> plus OpenJobObjectW at cancel time.

Never executed against a real Windows install. The argv and flag construction are unit-tested because they are host-independent; the kernel32 calls are not, and this module says so rather than implying a coverage it does not have.

axqua.jobs.launchers.windows.job_object_name(job_id)[Quellcode]

Session-local, so two users on one machine cannot collide.

Parameter:

job_id (str)

Rückgabetyp:

str

class axqua.jobs.launchers.windows.WindowsJobObjectLauncher[Quellcode]

Bases: object

Detached CreateProcess assigned to a named Job Object.

name = 'windows'
classmethod available(env=None)[Quellcode]
Parameter:

env (Any)

Rückgabetyp:

bool

submit(job_dir, argv, *, env, cwd)[Quellcode]
Parameter:
Rückgabetyp:

LaunchHandle

status(handle)[Quellcode]
Parameter:

handle (LaunchHandle)

Rückgabetyp:

LaunchState

cancel(handle, *, grace=20.0)[Quellcode]

TerminateJobObject on the named object, with taskkill /T as fallback.

Parameter:
Rückgabetyp:

bool

logs(handle)[Quellcode]
Parameter:

handle (LaunchHandle)

Rückgabetyp:

Path | None

WSL: detach inside the distro, and act on it only through wsl.exe.

Foundation OpenFOAM has no native Windows build, so on Windows it is reached through WSL (plan §6). That makes the launcher a composition rather than a new mechanism: the job is detached with setsid/nohup inside the Linux distro exactly as PosixDetachedLauncher would, and the Windows side records the Linux PID and runs every later query or signal back through wsl.exe.

Two caveats are documented rather than pretended away, because both will be met in practice:

Killing ``wsl.exe`` does not kill the Linux tree. It is a client that talks to the distro’s init, not the parent of what it started. Cancellation must therefore run inside the distro and act on the Linux process group - which is what cancel() does. A launcher that terminated the wsl.exe process would report success and leave the solver running.

The distro’s lifetime is not ours. wsl --shutdown, a Windows restart, or WSL2 idling the VM out will terminate running jobs regardless of anything axqua does. Such jobs must recover to FAILED rather than a stuck RUNNING - which they do, through the same stale-process detection the POSIX fallback uses, because the recorded PID simply stops existing. One implementation, not two.

Never executed against a real WSL install. Command construction is unit-tested; the rest is not.

class axqua.jobs.launchers.wsl.WslLauncher(distro=None)[Quellcode]

Bases: object

Detach inside the distro; monitor and cancel across the boundary.

Parameter:

distro (str | None)

name = 'wsl'
classmethod available(env=None)[Quellcode]
Parameter:

env (Any)

Rückgabetyp:

bool

launch_payload(argv, cwd, log_path)[Quellcode]

The shell one-liner that detaches the runner inside the distro.

setsid gives the job its own session (so it is not killed when the wsl.exe client goes away), nohup detaches it from any hangup, stdin comes from /dev/null, and the last echo returns the Linux PID - which is the only thing the Windows side can use afterwards.

Parameter:
Rückgabetyp:

str

submit(job_dir, argv, *, env, cwd)[Quellcode]
Parameter:
Rückgabetyp:

LaunchHandle

status(handle)[Quellcode]
Parameter:

handle (LaunchHandle)

Rückgabetyp:

LaunchState

cancel(handle, *, grace=20.0)[Quellcode]

Signal the Linux process group, from inside the distro.

The negative pid is the process group, which is what setsid made the runner the leader of - so this reaches mpirun and its ranks.

Parameter:
Rückgabetyp:

bool

logs(handle)[Quellcode]
Parameter:

handle (LaunchHandle)

Rückgabetyp:

Path | None