Skip to content

Commit a996cf7

Browse files
author
mezzeddine
committed
jobs/tests/docs: Add heartbeat command tests and lifecycle documentation
Add router-level tests covering JobCommand lifecycle: - Kill command created when status → KILLED or DELETED - Delivered exactly once via heartbeat - No duplicate delivery on subsequent heartbeats - Multiple jobs handled independently - Non-terminal transitions do not create commands Add developer documentation describing one-shot delivery semantics. Ignore local virtualenv (.venv).
1 parent 70bf3c7 commit a996cf7

4 files changed

Lines changed: 223 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,5 +101,8 @@ pixi.lock
101101
*.egg-info
102102
docs/templates/_builtin_markdown.jinja
103103

104+
# virtualenv
105+
.venv
106+
104107
# docs site
105108
site
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime, timezone
4+
from time import sleep
5+
6+
import pytest
7+
from fastapi.testclient import TestClient
8+
9+
from diracx.core.models.job import JobStatus
10+
11+
pytestmark = pytest.mark.enabled_dependencies(
12+
[
13+
"AuthSettings",
14+
"JobDB",
15+
"JobLoggingDB",
16+
"ConfigSource",
17+
"TaskQueueDB",
18+
"SandboxMetadataDB",
19+
"WMSAccessPolicy",
20+
"DevelopmentSettings",
21+
"JobParametersDB",
22+
]
23+
)
24+
25+
26+
def test_multiple_jobs_receive_independent_kill_commands(
27+
normal_user_client: TestClient,
28+
valid_job_ids: list[int],
29+
):
30+
"""Verify that multiple jobs each receive exactly one Kill command."""
31+
r = normal_user_client.patch(
32+
"/api/jobs/status",
33+
json={
34+
job_id: {
35+
datetime.now(timezone.utc).isoformat(): {
36+
"Status": JobStatus.KILLED,
37+
"MinorStatus": "Marked for termination",
38+
}
39+
}
40+
for job_id in valid_job_ids
41+
},
42+
)
43+
r.raise_for_status()
44+
45+
sleep(1)
46+
47+
r = normal_user_client.patch(
48+
"/api/jobs/heartbeat",
49+
json={job_id: {"Vsize": 2000} for job_id in valid_job_ids},
50+
)
51+
r.raise_for_status()
52+
53+
commands = r.json()
54+
assert len(commands) == len(valid_job_ids)
55+
assert {cmd["job_id"] for cmd in commands} == set(valid_job_ids)
56+
assert {cmd["command"] for cmd in commands} == {"Kill"}
57+
58+
sleep(1)
59+
60+
r = normal_user_client.patch(
61+
"/api/jobs/heartbeat",
62+
json={job_id: {"Vsize": 2001} for job_id in valid_job_ids},
63+
)
64+
r.raise_for_status()
65+
66+
assert r.json() == []
67+
68+
69+
def test_non_killed_status_does_not_create_command(
70+
normal_user_client: TestClient,
71+
valid_job_id: int,
72+
):
73+
"""Verify statuses different from KILLED do not enqueue job commands."""
74+
r = normal_user_client.patch(
75+
"/api/jobs/status",
76+
json={
77+
valid_job_id: {
78+
datetime.now(timezone.utc).isoformat(): {
79+
"Status": JobStatus.RUNNING,
80+
"MinorStatus": "Normal transition",
81+
}
82+
}
83+
},
84+
)
85+
r.raise_for_status()
86+
87+
sleep(1)
88+
89+
r = normal_user_client.patch(
90+
"/api/jobs/heartbeat",
91+
json={valid_job_id: {"Vsize": 500}},
92+
)
93+
r.raise_for_status()
94+
95+
assert r.json() == []
96+
97+
98+
def test_deleted_creates_kill_command(
99+
normal_user_client: TestClient,
100+
valid_job_id: int,
101+
):
102+
"""Verify DELETED follows the same command path as KILLED."""
103+
r = normal_user_client.patch(
104+
"/api/jobs/status",
105+
json={
106+
valid_job_id: {
107+
datetime.now(timezone.utc).isoformat(): {
108+
"Status": JobStatus.DELETED,
109+
"MinorStatus": "User removed job",
110+
}
111+
}
112+
},
113+
)
114+
r.raise_for_status()
115+
116+
sleep(1)
117+
118+
r = normal_user_client.patch(
119+
"/api/jobs/heartbeat",
120+
json={valid_job_id: {"Vsize": 123}},
121+
)
122+
r.raise_for_status()
123+
124+
commands = r.json()
125+
assert len(commands) == 1
126+
assert commands[0]["job_id"] == valid_job_id
127+
assert commands[0]["command"] == "Kill"
128+
129+
sleep(1)
130+
131+
r = normal_user_client.patch(
132+
"/api/jobs/heartbeat",
133+
json={valid_job_id: {"Vsize": 124}},
134+
)
135+
r.raise_for_status()
136+
137+
assert r.json() == []
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
## What Are Job Commands?
2+
3+
Job commands implement a **per-job control queue** between the WMS and the pilot (JobAgent).
4+
5+
They allow the WMS to send control instructions to running jobs via the heartbeat mechanism.
6+
7+
Currently used for:
8+
9+
- Remote job termination (`Kill` command)
10+
11+
The mechanism is generic and could support additional commands in the future (e.g. debug actions, core dump requests, etc.).
12+
13+
______________________________________________________________________
14+
15+
## Behaviour Summary
16+
17+
### Command Creation
18+
19+
When a job transitions to a terminal state (`KILLED` or `DELETED`),
20+
`set_job_statuses()` enqueues a `Kill` command in the JobDB.
21+
22+
### Command Delivery
23+
24+
Commands are delivered during:
25+
26+
```text
27+
PATCH /api/jobs/heartbeat
28+
```
29+
30+
Flow:
31+
32+
1. Heartbeat updates job state.
33+
2. `get_job_commands()` retrieves pending commands.
34+
3. Commands are marked as sent.
35+
4. Commands are returned to the pilot.
36+
37+
This guarantees **one-shot delivery semantics**:
38+
39+
- A command is delivered exactly once.
40+
- It is not re-delivered on subsequent heartbeats.
41+
42+
______________________________________________________________________
43+
44+
## Sequence Diagram (Kill Command Lifecycle)
45+
46+
```mermaid
47+
sequenceDiagram
48+
autonumber
49+
participant User
50+
participant Router
51+
participant JobDB
52+
participant WatchDog
53+
54+
User->>Router: PATCH /api/jobs/status (Killed)
55+
Router->>JobDB: update status → KILLED
56+
Router->>JobDB: enqueue "Kill"
57+
Router-->>User: 200 OK
58+
59+
WatchDog->>Router: PATCH /api/jobs/heartbeat
60+
Router->>JobDB: fetch + mark sent
61+
Router-->>WatchDog: [Kill]
62+
63+
WatchDog->>Router: PATCH /api/jobs/heartbeat
64+
Router-->>WatchDog: []
65+
```
66+
67+
______________________________________________________________________
68+
69+
## Activity View
70+
71+
```mermaid
72+
flowchart TD
73+
A[Status change → KILLED] --> B[Enqueue Kill command]
74+
B --> C[Stored in JobDB]
75+
76+
D[Heartbeat] --> E[Fetch commands]
77+
E --> F[Mark as sent]
78+
F --> G[Return to pilot]
79+
G --> D
80+
```
81+
82+
______________________________________________________________________

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ nav:
171171
- Components:
172172
- Databases: dev/explanations/components/db.md
173173
- Routes: dev/explanations/components/routes.md
174+
- Job commands: dev/explanations/job_commands.md
174175
- Testing: dev/explanations/testing.md
175176
- Extensions: dev/explanations/extensions.md
176177
- Designing functionality: dev/explanations/designing-functionality.md

0 commit comments

Comments
 (0)