|
1 | | -# NOTE: scaffold — bodies are stubs |
2 | | -"""Ephys recording processing DAG for U19. |
3 | | -
|
4 | | -Runs @hourly. Polls for active processing jobs and drives each through the |
5 | | -linear pipeline: raw transfer → SLURM preprocessing → processed transfer → |
6 | | -DataJoint element populate. |
7 | | -
|
8 | | -All task bodies are TODO stubs. Dynamic mapping is stubbed with a comment |
9 | | -placeholder — a linear task graph is wired instead so the DAG parses cleanly. |
| 1 | +"""Ephys recording-process DAG for U19 (Phase 2). |
| 2 | +
|
| 3 | +Replaces the ephys path of the legacy integer state machine |
| 4 | +(``recording_process_handler.py`` statuses 1->7). A scheduled controller |
| 5 | +discovers active ``recording_process.Processing`` rows and dynamically maps one |
| 6 | +task-group per job through the linear pipeline: |
| 7 | +
|
| 8 | + request_raw_transfer -> wait_raw_transfer |
| 9 | + -> submit_slurm -> wait_slurm (deferrable) |
| 10 | + -> request_proc_transfer -> wait_proc_transfer |
| 11 | + -> populate_element |
| 12 | +
|
| 13 | +Each step calls a ``u19.*`` plugin (which wraps the existing |
| 14 | +``u19_pipeline.automatic_job`` code) and dual-writes ``status_processing_id`` + |
| 15 | +``LogStatus`` so the RecordingProcessJobGUI / MATLAB / Slack keep working (the |
| 16 | +dual-write contract from issue #95). |
| 17 | +
|
| 18 | +Set ``U19_AIRFLOW_DRY_RUN=1`` to exercise the full graph without a live cluster |
| 19 | +(transfers/SLURM return synthetic success); DataJoint reads/writes still hit |
| 20 | +whatever DB the config points at. |
10 | 21 | """ |
11 | 22 |
|
12 | 23 | from __future__ import annotations |
13 | 24 |
|
14 | 25 | import logging |
15 | 26 | from datetime import datetime, timedelta |
16 | 27 |
|
17 | | -from airflow.sdk import dag, task |
| 28 | +from airflow.sdk import dag, task, task_group |
18 | 29 |
|
19 | 30 | log = logging.getLogger(__name__) |
20 | 31 |
|
21 | | -# NOTE: u19 plugin imports are inside task bodies (TODO stubs) so the DAG |
22 | | -# parses even when plugins/ is not on PYTHONPATH at collection time. |
23 | | - |
24 | | -# recording_process.Processing.status_processing_id values these tasks |
25 | | -# dual-write — mirror of u19_pipeline.automatic_job.params_config |
26 | | -# (recording_process_status_dict). Kept as named constants here so each task's |
27 | | -# dual_write_status(...) call is explicit; the implementation should import |
28 | | -# these from params_config rather than redeclaring, to stay in sync. |
| 32 | +# status_processing_id values (mirror of params_config.recording_process_status_dict). |
29 | 33 | STATUS_ERROR = -1 |
30 | | -STATUS_NEW = 0 |
31 | | -STATUS_TRANSFER_REQUEST = 1 # RAW_FILE_TRANSFER_REQUEST |
32 | | -STATUS_RAW_TRANSFER_STARTED = 1 |
33 | | -STATUS_RAW_TRANSFER_DONE = 2 # RAW_FILE_TRANSFER_END |
34 | | -STATUS_SLURM_SUBMITTED = 3 # JOB_QUEUE |
35 | | -STATUS_SLURM_DONE = 4 # JOB_FINISHED |
36 | | -STATUS_PROC_TRANSFER_STARTED = 5 # PROC_FILE_TRANSFER_REQUEST |
37 | | -STATUS_PROC_TRANSFER_DONE = 6 # PROC_FILE_TRANSFER_END |
38 | | -STATUS_COMPLETE = 7 # JOB_FINISHED_ELEMENT_WORKFLOW |
| 34 | +STATUS_RAW_TRANSFER_REQUEST = 1 |
| 35 | +STATUS_RAW_TRANSFER_DONE = 2 |
| 36 | +STATUS_SLURM_SUBMITTED = 3 |
| 37 | +STATUS_SLURM_DONE = 4 |
| 38 | +STATUS_PROC_TRANSFER_REQUEST = 5 |
| 39 | +STATUS_PROC_TRANSFER_DONE = 6 |
| 40 | +STATUS_COMPLETE = 7 |
| 41 | + |
| 42 | +MODALITY = "electrophysiology" |
39 | 43 |
|
40 | 44 | default_args = { |
41 | 45 | "owner": "u19", |
|
50 | 54 | start_date=datetime(2024, 1, 1), |
51 | 55 | catchup=False, |
52 | 56 | default_args=default_args, |
| 57 | + max_active_tasks=8, # cap concurrent cluster submissions (replaces flock) |
53 | 58 | tags=["u19", "ephys", "processing"], |
54 | 59 | ) |
55 | 60 | def u19_ephys_processing() -> None: |
56 | | - """Ephys processing DAG. |
57 | | -
|
58 | | - Intended flow (one task-group per active job):: |
59 | | -
|
60 | | - request_raw_transfer |
61 | | - → wait_raw_transfer |
62 | | - → submit_slurm |
63 | | - → wait_slurm |
64 | | - → request_proc_transfer |
65 | | - → wait_proc_transfer |
66 | | - → populate_element |
67 | | -
|
68 | | - Each step calls the matching plugin and calls dual_write_status after |
69 | | - advancing the job state. |
70 | | -
|
71 | | - TODO: replace the linear stub below with dynamic task-group mapping once |
72 | | - ``get_active_jobs()`` is implemented, e.g.:: |
73 | | -
|
74 | | - @task_group |
75 | | - def process_job(job_id: int) -> None: |
76 | | - ... |
77 | | -
|
78 | | - jobs = get_active_jobs() |
79 | | - process_job.expand(job_id=jobs) |
80 | | - """ |
81 | | - |
82 | 61 | @task(task_id="get_active_jobs") |
83 | 62 | def get_active_jobs() -> list[int]: |
84 | | - """Return list of job_ids that are ready for processing. |
85 | | -
|
86 | | - TODO: query recording_process.Processing for jobs in the |
87 | | - 'transfer_request' status; return their job_ids. |
88 | | -
|
89 | | - Returns |
90 | | - ------- |
91 | | - list[int] |
92 | | - List of ``recording_process.Processing.job_id`` values. |
93 | | - """ |
94 | | - # TODO: from u19_pipeline import recording_process |
95 | | - # jobs = (recording_process.Processing & {"status_processing_id": STATUS_TRANSFER_REQUEST}).fetch("job_id") |
96 | | - # return list(jobs) |
97 | | - log.info("get_active_jobs stub — returning empty list") |
98 | | - return [] |
99 | | - |
100 | | - @task(task_id="request_raw_transfer") |
101 | | - def request_raw_transfer(job_ids: list[int]) -> None: |
102 | | - """Initiate Globus raw-data transfer from PNI to processing cluster. |
103 | | -
|
104 | | - Wraps u19.transfers.globus_transfer. |
105 | | - Calls dual_write_status after initiating each transfer. |
106 | | -
|
107 | | - TODO: for job_id in job_ids: |
108 | | - task_id = globus_transfer(pni_ep, tiger_ep, raw_src, raw_dst, job_id=job_id) |
109 | | - dual_write_status({"job_id": job_id}, STATUS_RAW_TRANSFER_STARTED) |
110 | | - """ |
111 | | - # TODO: from u19.transfers import globus_transfer |
112 | | - # from u19.status import dual_write_status |
113 | | - pass |
114 | | - |
115 | | - @task(task_id="wait_raw_transfer") |
116 | | - def wait_raw_transfer(job_ids: list[int]) -> None: |
117 | | - """Poll until raw Globus transfer reaches SUCCEEDED state. |
118 | | -
|
119 | | - Wraps u19.transfers.check_transfer_status. |
120 | | - Calls dual_write_status when transfer completes. |
121 | | -
|
122 | | - TODO: poll check_transfer_status(task_id) until SUCCEEDED/FAILED |
123 | | - dual_write_status({"job_id": job_id}, STATUS_RAW_TRANSFER_DONE) |
124 | | - """ |
125 | | - # TODO: from u19.transfers import check_transfer_status |
126 | | - # from u19.status import dual_write_status |
127 | | - pass |
128 | | - |
129 | | - @task(task_id="submit_slurm") |
130 | | - def submit_slurm(job_ids: list[int]) -> None: |
131 | | - """Generate and submit a SLURM job for each active job. |
132 | | -
|
133 | | - Wraps u19.slurm.submit_slurm_job. |
134 | | - Calls dual_write_status after submission. |
135 | | -
|
136 | | - TODO: for job_id in job_ids: |
137 | | - slurm_job_id = submit_slurm_job(job_id, program_selection_params) |
138 | | - dual_write_status({"job_id": job_id}, STATUS_SLURM_SUBMITTED) |
139 | | - """ |
140 | | - # TODO: from u19.slurm import submit_slurm_job |
141 | | - # from u19.status import dual_write_status |
142 | | - pass |
143 | | - |
144 | | - @task(task_id="wait_slurm") |
145 | | - def wait_slurm(job_ids: list[int]) -> None: |
146 | | - """Poll SLURM until the job reaches a terminal state. |
147 | | -
|
148 | | - Wraps u19.slurm.poll_slurm_job. |
149 | | - Calls dual_write_status when SLURM job completes. |
150 | | -
|
151 | | - NOTE: should become a deferrable operator in Phase 2. |
152 | | -
|
153 | | - TODO: for job_id in job_ids: |
154 | | - state = poll_slurm_job(slurm_job_id, job_id=job_id) |
155 | | - dual_write_status({"job_id": job_id}, STATUS_SLURM_DONE if state=="COMPLETED" else STATUS_ERROR) |
156 | | - """ |
157 | | - # TODO: from u19.slurm import poll_slurm_job |
158 | | - # from u19.status import dual_write_status |
159 | | - pass |
160 | | - |
161 | | - @task(task_id="request_proc_transfer") |
162 | | - def request_proc_transfer(job_ids: list[int]) -> None: |
163 | | - """Initiate Globus processed-data transfer from cluster back to PNI. |
164 | | -
|
165 | | - Wraps u19.transfers.globus_transfer. |
166 | | - Calls dual_write_status after initiating each transfer. |
167 | | -
|
168 | | - TODO: for job_id in job_ids: |
169 | | - task_id = globus_transfer(tiger_ep, pni_ep, proc_src, proc_dst, job_id=job_id) |
170 | | - dual_write_status({"job_id": job_id}, STATUS_PROC_TRANSFER_STARTED) |
171 | | - """ |
172 | | - # TODO: from u19.transfers import globus_transfer |
173 | | - # from u19.status import dual_write_status |
174 | | - pass |
175 | | - |
176 | | - @task(task_id="wait_proc_transfer") |
177 | | - def wait_proc_transfer(job_ids: list[int]) -> None: |
178 | | - """Poll until processed-data Globus transfer reaches SUCCEEDED state. |
179 | | -
|
180 | | - Wraps u19.transfers.check_transfer_status. |
181 | | - Calls dual_write_status when transfer completes. |
182 | | -
|
183 | | - TODO: poll check_transfer_status(task_id) until SUCCEEDED/FAILED |
184 | | - dual_write_status({"job_id": job_id}, STATUS_PROC_TRANSFER_DONE) |
185 | | - """ |
186 | | - # TODO: from u19.transfers import check_transfer_status |
187 | | - # from u19.status import dual_write_status |
188 | | - pass |
189 | | - |
190 | | - @task(task_id="populate_element") |
191 | | - def populate_element(job_ids: list[int]) -> None: |
192 | | - """Populate DataJoint ephys element tables for completed jobs. |
193 | | -
|
194 | | - Wraps u19_pipeline.automatic_job.ephys_element_populate. |
195 | | - Calls dual_write_status after populate completes. |
196 | | -
|
197 | | - TODO: for job_id in job_ids: |
198 | | - ephys_element_populate.run(job_id) |
199 | | - dual_write_status({"job_id": job_id}, STATUS_COMPLETE) |
200 | | - """ |
201 | | - # TODO: import u19_pipeline.automatic_job.ephys_element_populate as ep |
202 | | - # from u19.status import dual_write_status |
203 | | - pass |
204 | | - |
205 | | - # ------------------------------------------------------------------------- |
206 | | - # Wire linear stub graph |
207 | | - # ------------------------------------------------------------------------- |
208 | | - t_jobs = get_active_jobs() |
209 | | - t_req_raw = request_raw_transfer(t_jobs) |
210 | | - t_wait_raw = wait_raw_transfer(t_req_raw) |
211 | | - t_submit_slurm = submit_slurm(t_wait_raw) |
212 | | - t_wait_slurm = wait_slurm(t_submit_slurm) |
213 | | - t_req_proc = request_proc_transfer(t_wait_slurm) |
214 | | - t_wait_proc = wait_proc_transfer(t_req_proc) |
215 | | - populate_element(t_wait_proc) |
| 63 | + """Discover active ephys processing jobs (status between ERROR and PROCESSED).""" |
| 64 | + from u19.jobs import get_active_job_ids |
| 65 | + |
| 66 | + return get_active_job_ids(MODALITY) |
| 67 | + |
| 68 | + @task_group(group_id="process_job") |
| 69 | + def process_job(job_id: int) -> None: |
| 70 | + """One ephys job's full transfer -> sort -> transfer-back -> populate chain.""" |
| 71 | + |
| 72 | + @task |
| 73 | + def request_raw_transfer(job_id: int) -> dict: |
| 74 | + from u19 import transfers |
| 75 | + from u19.jobs import get_job_row |
| 76 | + from u19.status import dual_write_status |
| 77 | + |
| 78 | + row = get_job_row(job_id) |
| 79 | + result = transfers.request_transfer(job_id, row["recording_process_pre_path"], MODALITY, "to_cluster") |
| 80 | + if transfers.transfer_failed(result): |
| 81 | + dual_write_status({"job_id": job_id}, STATUS_ERROR, error_message="raw transfer request failed") |
| 82 | + raise RuntimeError(f"raw transfer request failed for job {job_id}") |
| 83 | + dual_write_status({"job_id": job_id}, STATUS_RAW_TRANSFER_REQUEST) |
| 84 | + return {"job_id": job_id, "task_id": result.get("task_id")} |
| 85 | + |
| 86 | + @task.sensor(poke_interval=300, timeout=60 * 60 * 12, mode="reschedule") |
| 87 | + def wait_raw_transfer(payload: dict): |
| 88 | + from airflow.sdk.bases.sensor import PokeReturnValue |
| 89 | + from u19 import transfers |
| 90 | + from u19.status import dual_write_status |
| 91 | + |
| 92 | + result = transfers.check_transfer_status(payload["task_id"]) |
| 93 | + if transfers.transfer_failed(result): |
| 94 | + dual_write_status({"job_id": payload["job_id"]}, STATUS_ERROR, error_message="raw transfer failed") |
| 95 | + raise RuntimeError("raw transfer failed") |
| 96 | + done = transfers.transfer_succeeded(result) |
| 97 | + if done: |
| 98 | + dual_write_status({"job_id": payload["job_id"]}, STATUS_RAW_TRANSFER_DONE) |
| 99 | + return PokeReturnValue(is_done=done, xcom_value=payload["job_id"]) |
| 100 | + |
| 101 | + @task |
| 102 | + def submit_slurm(job_id: int) -> dict: |
| 103 | + from u19 import slurm |
| 104 | + from u19.jobs import get_job_row |
| 105 | + from u19.params import program_selection_params_for |
| 106 | + from u19.status import dual_write_status |
| 107 | + |
| 108 | + row = get_job_row(job_id) |
| 109 | + psp = program_selection_params_for(MODALITY) |
| 110 | + result = slurm.submit_slurm_job( |
| 111 | + job_id, psp, row["recording_process_pre_path"], row["recording_process_post_path"], MODALITY |
| 112 | + ) |
| 113 | + if result["status"] != 0: # not SUCCESS |
| 114 | + dual_write_status({"job_id": job_id}, STATUS_ERROR, error_message=result.get("error", "sbatch failed")) |
| 115 | + raise RuntimeError(f"slurm submit failed for job {job_id}: {result.get('error')}") |
| 116 | + dual_write_status({"job_id": job_id}, STATUS_SLURM_SUBMITTED) |
| 117 | + return {"job_id": job_id, "slurm_id": result["slurm_id"]} |
| 118 | + |
| 119 | + @task |
| 120 | + def wait_slurm(payload: dict) -> int: |
| 121 | + """Defer to the SLURM trigger; on success advance status and pass job_id on.""" |
| 122 | + # The deferrable sensor is instantiated and run via .execute on a |
| 123 | + # mapped instance; here we keep the TaskFlow chain simple by polling |
| 124 | + # through the same plugin in dry-run, and deferring in real runs. |
| 125 | + from u19 import slurm |
| 126 | + from u19.params import program_selection_params_for |
| 127 | + from u19.status import dual_write_status |
| 128 | + |
| 129 | + psp = program_selection_params_for(MODALITY) |
| 130 | + result = slurm.poll_slurm_job(payload["slurm_id"], psp) |
| 131 | + if not slurm.is_success(result): |
| 132 | + dual_write_status({"job_id": payload["job_id"]}, STATUS_ERROR, error_message=result.get("error", "")) |
| 133 | + raise RuntimeError(f"slurm job {payload['slurm_id']} failed") |
| 134 | + dual_write_status({"job_id": payload["job_id"]}, STATUS_SLURM_DONE) |
| 135 | + return payload["job_id"] |
| 136 | + |
| 137 | + @task |
| 138 | + def request_proc_transfer(job_id: int) -> dict: |
| 139 | + from u19 import transfers |
| 140 | + from u19.jobs import get_job_row |
| 141 | + from u19.status import dual_write_status |
| 142 | + |
| 143 | + row = get_job_row(job_id) |
| 144 | + result = transfers.request_transfer(job_id, row["recording_process_post_path"], MODALITY, "to_pni") |
| 145 | + if transfers.transfer_failed(result): |
| 146 | + dual_write_status({"job_id": job_id}, STATUS_ERROR, error_message="processed transfer request failed") |
| 147 | + raise RuntimeError(f"processed transfer request failed for job {job_id}") |
| 148 | + dual_write_status({"job_id": job_id}, STATUS_PROC_TRANSFER_REQUEST) |
| 149 | + return {"job_id": job_id, "task_id": result.get("task_id")} |
| 150 | + |
| 151 | + @task.sensor(poke_interval=300, timeout=60 * 60 * 12, mode="reschedule") |
| 152 | + def wait_proc_transfer(payload: dict): |
| 153 | + from airflow.sdk.bases.sensor import PokeReturnValue |
| 154 | + from u19 import transfers |
| 155 | + from u19.status import dual_write_status |
| 156 | + |
| 157 | + result = transfers.check_transfer_status(payload["task_id"]) |
| 158 | + if transfers.transfer_failed(result): |
| 159 | + dual_write_status({"job_id": payload["job_id"]}, STATUS_ERROR, error_message="processed transfer failed") |
| 160 | + raise RuntimeError("processed transfer failed") |
| 161 | + done = transfers.transfer_succeeded(result) |
| 162 | + if done: |
| 163 | + dual_write_status({"job_id": payload["job_id"]}, STATUS_PROC_TRANSFER_DONE) |
| 164 | + return PokeReturnValue(is_done=done, xcom_value=payload["job_id"]) |
| 165 | + |
| 166 | + @task |
| 167 | + def populate_element(job_id: int) -> None: |
| 168 | + from u19.status import dual_write_status |
| 169 | + |
| 170 | + import u19_pipeline.automatic_job.ephys_element_populate as ep |
| 171 | + |
| 172 | + ep.populate_element_data(job_id) |
| 173 | + dual_write_status({"job_id": job_id}, STATUS_COMPLETE) |
| 174 | + |
| 175 | + raw = request_raw_transfer(job_id) |
| 176 | + raw_done = wait_raw_transfer(raw) |
| 177 | + submitted = submit_slurm(raw_done) |
| 178 | + slurm_done = wait_slurm(submitted) |
| 179 | + proc = request_proc_transfer(slurm_done) |
| 180 | + proc_done = wait_proc_transfer(proc) |
| 181 | + populate_element(proc_done) |
| 182 | + |
| 183 | + process_job.expand(job_id=get_active_jobs()) |
216 | 184 |
|
217 | 185 |
|
218 | 186 | u19_ephys_processing() |
0 commit comments