You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Feasibility assessment for migrating the U19-pipeline orchestration layer (the cron-driven automation) to Apache Airflow, across bothU19-pipeline-python and U19-pipeline-matlab. This covers what maps cleanly, the real challenges, a recommended target architecture, and a phased path. No code changes — this is a planning/discussion issue. Scope is the scheduling/automation layer, not the DataJoint schema definitions or the make()/.populate() logic itself (those stay as-is, in whichever language they live).
1. What runs today (the thing being migrated)
The orchestration is a hand-rolled state machine driven by a 30-minute cron job, not a workflow engine.
Alert system (3 AM daily): alert_system/cronjob_alert.py → main_alert_system.py (live-session monitor, schedule check, water-weigh, tech alert, locked-tables, log cleanup, rig maintenance) + a Streamlit water-weigh GUI.
MATLAB nightly (call_u19_night_cronjob.sh): pulls ViRMEn + U19-pipeline-matlab, then matlab -batch "startup_virtual_machine.m; populate_tables.m". See the dedicated MATLAB section below — this is an ordered ~20-step batch, not one opaque job.
Plus DB backup, pupillometry, and missing-syncbehavior crons.
The migrate-to-airflow branch exists but has no DAGs yet — greenfield.
MATLAB workflow scope (in scope, confirmed by exploring U19-pipeline-matlab)
This migration covers both the Python and MATLAB orchestration — but as scheduling, not a logic rewrite. The MATLAB .populate()/make() logic stays in MATLAB; Airflow just replaces its cron.
populate_tables.m is an ordered batch with real internal dependencies, not a single task. Sequence: reset_reweight_subjects → populate_schedule_for_tomorrow / populate_technician_schedule → protocol-level/training-profile updates → behavior populates (Session, SessionBlock, TowersSession, TowersBlock, SpatialTimeBlobs) → optogenetics ingest + OptogeneticSession → subtasks → pupillometry (PupillometrySession, …SyncBehavior, model ingest) → posture-tracking → imaging_pipeline.SyncImagingBehavior → psychometrics (TowersSessionPsych, TowersSubjectCumulativePsych, …). Psychometrics/sync depend on the behavior tables populating first → worth modeling as a small DAG with explicit task ordering, not a BashOperator blob.
Three-repo + lib dependency:startup_virtual_machine.m requires ViRMEn, U19-pipeline-matlab, and datajoint_matlab_libs (mym, datajoint-matlab, GHToolbox) on the MATLAB path. The Airflow MATLAB host (likely SSH'd to) must provision all of these. Each step also does its own git pull today.
MATLAB is a second writer to the shared job-status tables.resubmit_failed_jobs.m writes recording_process.Processing.status_processing_id and recording_process.LogStatus — the same tables the Python state machine owns. So MATLAB is not an independent silo; it shares the orchestration state store. This sharpens the dual-state challenge (Python schemas/docker setup/readme. #2): whatever owns status_processing_id must stay consistent across Python, MATLAB, and the GUI.
Each modality has mirrored Python/MATLAB table defs (README), so the schema is shared; only the populate/orchestration entry points differ.
Unifying principle: orchestrate the shared DataJoint state, not the language
Both Python and MATLAB interact with the same MariaDB (11.4) behind the same DataJoint interface — the schema and the recording_process.Processing / status tables are the real shared substrate. The goal is one DAG set, manageable from a single Airflow instance, whose task dependencies are expressed against that shared DataJoint state regardless of whether a task runs Python or matlab -batch. Language is an implementation detail of each task; DataJoint tables are the dependency graph.
Python ↔ MATLAB overlap audit (requested)
Result: mostly disjoint by domain, with one real shared seam at imaging ingest — and the two languages are already interleaved within a single workflow, not merely parallel.
Domain / tables
MATLAB nightly
Python real-time
Verdict
imaging_pipeline.AcquiredTiff
populate() in populate_tables.m
populate() in modality_preingestionand invokes MATLAB populate_Imaging_AcquiredTiff() via ingest_scaninfo_shell.sh (matlab -r)
ephys element (EphysRecording, BehaviorSync, PreCluster, LFP, Clustering, Curation, CuratedClustering)
—
Python only
Python-owned
imaging element (Preprocess, Processing, MotionCorrection, Segmentation, Fluorescence, Activity)
—
Python only
Python-owned
Key takeaways for the DAG:
AcquiredTiff is populated from both sides — the unified DAG must pick a single owner for that node (or make it idempotent + guarded) so the nightly MATLAB run and the real-time Python flow don't race.
There is already a cross-language dependency edge: Python's imaging modality_preingestion calls a MATLAB script synchronously (ingest_scaninfo_shell.sh → populate_Imaging_AcquiredTiff). In Airflow this becomes an explicit edge: a MATLAB task (TiffSplit/AcquiredTiff ingest) that the downstream Python ScanInfo/element tasks depend on. This is the concrete proof that a unified DAG is the right model, not two siloed ones.
Everything else is cleanly owned by one language, so the unified DAG is mostly "two domain subgraphs sharing a DataJoint state store + one shared imaging seam."
Subgraphs (traced edge-by-edge from DataJoint foreign keys)
Edges below are real FK declarations (-> table in each table's DataJoint header), so they are the populate ordering. [M] = MATLAB-owned task, [P] = Python-owned, [shared] = the cross-language node.
Correction after reading the make() bodies (FK headers were misleading here — see "Implicit dependencies" below): the four Towers…Psych* tables do not all share one prerequisite.
TowersSessionPsych and TowersSubjectCumulativePsych truly read only TowersSession/Session → can run once TowersSession is done.
TowersSessionPsychTask actually reads TowersBlockandTowersBlockTrial inside makeTuples → must run after TowersBlock, despite its FK saying only -> TowersSession.
TowersSubjectCumulativePsych / …Level are subject-cumulative: makeTuples queries all prior sessions (session_start_time <= … over Session & TowersSession). They are not row-local — they must run after the whole nightly TowersSession batch, not be mapped per-session in isolation, or cumulative curves will be computed on partial history.
D. Scheduling / housekeeping [M] (no FK into the above; run independently each night): reset_reweight_subjects, populate_schedule_for_tomorrow, populate_technician_schedule, updateProtocolLevelTable, updateTrainingProfileProtocol, ingest_subtasks (behavior_subtask).
E. The imaging seam (cross-language) [shared] — the only place the two graphs touch:
u19_recording.recording [P, real-time state machine produces this]
└─ imaging_pipeline.ImagingPipelineSession [P populate]
└─ imaging_pipeline.AcquiredTiff [shared] ← Python AcquiredTiff.make() shells out to MATLAB
│ populate_Imaging_AcquiredTiff() via ingest_scaninfo_shell.sh;
│ also populated directly by the MATLAB nightly
└─ imaging_pipeline.TiffSplit [P writes it inside AcquiredTiff.make, allow_direct_insert]
├─→ imaging_pipeline.scan_element.ScanInfo → imaging element chain [P]
│ (Preprocess → Processing → MotionCorrection → Segmentation → Fluorescence → Activity)
└─→ imaging_pipeline.SyncImagingBehavior [M nightly; FK -> TiffSplit, but make ALSO needs
recording.RecordingBehaviorSession + behavior file on disk]
So AcquiredTiff is the single contended node (and both the Python and MATLAB AcquiredTiff.make write TiffSplit/TiffSplitFile — pick one owner per key). Downstream forks into a Python branch (element processing) and a MATLAB branch (SyncImagingBehavior) — but SyncImagingBehavior is not a clean TiffSplit-only consumer; it also depends on the Python recording chain and the raw behavior file (see Implicit deps #4).
Implicit dependencies found by reading the make() bodies (not in the FK headers)
The declared FKs govern most ordering, but the make bodies contain reads/writes that the FK graph does not show. These are the edges most likely to cause a unified DAG to mis-fire:
TowersSessionPsychTask → TowersBlock + TowersBlockTrial (implicit). FK says -> TowersSession only; the make reads block/trial tables. Treat TowersBlock as a hard upstream.
TowersBlock.makewritesTowersBlockTrial as a side-effect (no separate populate). So TowersBlockTrial rows appear when TowersBlock runs — downstream consumers (add first two schemas (lightsheet and microscope) #1) depend on TowersBlock, not a separate task.
TowersSubjectCumulativePsych / …Level are subject-cumulative — read all prior sessions for the subject. Must run after the full TowersSession batch (a "barrier" task), not per-session.
SyncImagingBehavior is cross-modality + cross-language + reads disk. FK says -> TiffSplit only; the make/keySource also require recording.RecordingBehaviorSession, recording.Recording, acquisition.SessionStarted (behavior file path), TiffSplitFile, and it reads the raw behavior file from the mounted FS. Real upstreams: the Python recording chain andTiffSplitand the behavior file on disk.
PupillometrySyncBehavior / PostureTrackingSyncBehavior read acquisition.SessionVideo + behavior file + the video file on disk (FK says -> …Session only). Implicit upstream: SessionVideo ingested and the video present on the mount.
MATLAB AcquiredTiff.make also writes TiffSplit/TiffSplitFile (lines ~621–704) — identical product to Python's AcquiredTiff.make. Confirms the contended node: whichever owner runs, it must produce TiffSplit the same way. Don't let both run for the same key.
Imported behavior makes read files off disk and self-mutate quality flags.acquisition.Session, behavior.TowersSession, behavior.TowersBlock each fetch1(SessionStarted,'new_remote_path_behavior_file'), read the VirMEn behavior file, and on failure update(... 'is_bad_session'/'invalid_session', 1). → These tasks have an external-data precondition (file must exist on the mounted FS) that no FK captures — model as an Airflow sensor/precondition, and note they write back upstream quality flags (so a "populate" task is not side-effect-free).
Net: the FK graph is correct as a floor on ordering, but the unified DAG needs ~4 extra edges (#1, #4, #5) and two structural notes (#3 barrier, #7 external-file sensors). None of these break the unified design — they make it more accurate.
F. Ephys subgraph [P] (real-time, no MATLAB edges):
(Heavy step = Clustering/Kilosort on SLURM; this is the chain that flows through the integer state machine + transfers.)
The state machine (recording_handler.py, recording_process_handler.py, params_config.py):
Each job carries an integer status_*_id. Every cron tick advances each active job by exactly one status step.
The recording-process chain (params_config.py lines 86–186) is a linear DAG: RAW_FILE_TRANSFER_REQUEST → transfer-check → JOB_QUEUE (sbatch) → JOB_FINISHED (poll sacct) → PROC_FILE_TRANSFER_REQUEST → transfer-check → populate-element → delete-cluster-data.
State/history/errors live in recording_process.Processing + recording_process.LogStatus (slurm_id, task_copy_id_*, error message/exception). Notifications via Slack webhooks (lab.SlackWebhooks).
External execution:
SLURM on spock (spockmk2-loginvm.pni.princeton.edu) / della (della-gpu.princeton.edu): slurm_creator.py generates a .slurm, SSHes via paramiko (or runs locally if is_this_spock()), sbatch, polls sacct.
Globus + SCP transfers PNI↔cluster; auth via fair-research-login.
Heavy jobs: Kilosort2 (GPU, hours), Suite2p, CatGT. Results ingested into element-array-ephys / element-calcium-imaging via *_element_populate.py.
Config/deploy: DataJoint config (dj_local_conf.json / ~/.datajoint_config.json, custom block) for DB creds + paths; Slack webhooks in lab.SlackWebhooks. No .env secrets framework, no CI. Python 3.12+, uv-managed. Prod host [email protected]; deploy = git pull on cron tick.
2. What maps cleanly to Airflow (easy wins)
The integer status chain → an explicit Airflow DAG. The DAG is currently encoded as an integer; each ProcessFunction becomes a task. Biggest structural win.
Polling steps → Sensors / deferrable operators.transfer_check (Globus) and slurm_job_check (sacct) are textbook deferrable-sensor cases — no more "check once per 30 min."
Retries / backoff / error capture → built in (replacing the exception_handler + manual LogStatus writes; on_failure_callback for Slack).
Idempotency already exists — DataJoint .populate() is safe to re-run.
Nightly .populate() + alert crons → scheduled DAGs (even just BashOperator initially) — the lowest-risk first target.
3. The real challenges
Dynamic, data-driven job set. The unit of work is "every active recording_process row," discovered at runtime. Needs dynamic task mapping or a controller DAG + TriggerDagRunOperator. Central design decision.
Dual state / source of truth — and MATLAB is a second writer. DB status columns (recording_process.Processing.status_processing_id, LogStatus) are read by the RecordingProcessJobGUI + Slack and written by both the Python state machine and MATLAB (resubmit_failed_jobs.m). Airflow wants to own state → must dual-write the DB status initially, and the MATLAB resubmit path must be reconciled with Airflow (e.g. become an Airflow "clear/retry" trigger) so two systems don't fight over the same rows.
Long-running SLURM jobs (Kilosort hours-long). Must use deferrable operators to avoid starving worker slots; define ownership of sbatch lifecycle on failure/cancel.
Cross-cluster auth & networking. SSH keys to spock/della, Globus refresh tokens, PNI mounts; is_this_spock() special-casing must move into Connections/Hooks. PNI infra constraints (where a daemon may run, firewall, GPU queue policy) are a gating factor.
MATLAB runtime + 3-repo provisioning. The nightly populate needs a licensed MATLAB plus ViRMEn, U19-pipeline-matlab, and datajoint_matlab_libs on the path (startup_virtual_machine.m). Decide whether MATLAB stays on a dedicated host invoked via SSH/matlab -batch, and where the per-run git pull of all three repos happens (in the DAG vs. baked into the host).
Alert subsystem is a second migration (water-weigh, schedule, locked-tables, rig maintenance + Streamlit GUI) — easy but doubles surface area; scope explicitly.
Secrets/config migration from DJ config + lab.SlackWebhooks → Airflow Connections/Variables (or keep reading DJ config inside tasks).
Operational lift at PNI — Airflow scheduler/webserver/metadata-DB/executor is a real service to run, monitor, back up. And no CI exists to lean on during cutover.
Concurrency/idempotency. Today flock serializes everything; Airflow runs concurrently → guard against double-submitting SLURM for the same job_id (pools, max_active_tis_per_dag, DB guards).
4. Recommended target architecture
One Airflow instance orchestrating a unified, DataJoint-state-centric DAG set for both Python and MATLAB. Keep the SSH + Globus execution model; replace cron + the status state-machine with Airflow running as a standalone daemon on the existing prod host (or a dedicated PNI VM).
Single management plane: all orchestration — Python real-time state machine, MATLAB nightly, and alerts — lives as DAGs in one Airflow instance/UI. The project is managed from that one place, which is the stated goal.
DataJoint state is the dependency graph. Tasks express dependencies via shared DataJoint tables (recording_process.Processing, modality element tables), not via language. A task is "run this populate / this make / this transfer," implemented in whichever language owns that node. Python tasks call DJ-Python; MATLAB tasks SSH to a MATLAB host and run matlab -batch.
Model the cross-language imaging seam explicitly: the MATLAB AcquiredTiff/TiffSplit ingest is an upstream task; the Python ScanInfo/imaging-element tasks depend on it (this edge exists today as a synchronous shell-out — Airflow makes it a first-class dependency). Pick a single owner for AcquiredTiff to avoid the nightly-vs-realtime race.
Executor:LocalExecutor (→ CeleryExecutor later) — not Kubernetes. PNI has no obvious K8s; KubernetesPodOperator is the biggest lift for the least near-term payoff. Revisit containers after the logic is proven.
DAG shape: lightweight controller DAG (replaces the 30-min cron) that queries active jobs and fans out via dynamic task mapping (or TriggerDagRunOperator) one task-group per active recording_process: request-transfer → (sensor) → submit-slurm → (deferrable sensor) → transfer-back → (sensor) → populate-element.
Long jobs: deferrable SLURM sensors.
State:dual-write DB status_* + LogStatus so the GUI/Slack/MATLAB consumers keep working; DB status stays the cross-system contract.
Reuse, don't rewrite: wrap existing slurm_creator, clusters_paths_and_transfers, *_element_populate in PythonOperators first; refactor into Hooks later.
Secrets: SSH/Globus → Airflow Connections; keep DJ config for the DB.
First target: the nightly .populate() + alert DAGs (lowest risk).
5. Suggested phased path
Stand up Airflow on the prod host (LocalExecutor + own metadata DB), no DAGs.
Phase 1 — nightly populates + alerts (covers MATLAB): port the Python nightly, the alert system, and the MATLAB populate_tables.m to scheduled DAGs. The MATLAB one becomes a small DAG that SSHes to a MATLAB host and runs the populate steps in dependency order (behavior → sync/psychometrics), rather than one BashOperator. Validates infra/logging/Slack. No state-machine risk.
Phase 2 — ephys state machine: model the electrophysiology recording_process chain with dynamic mapping + deferrable SLURM sensor, dual-writing DB status. Run in shadow alongside the existing cron for one modality.
Phase 4 — cleanup: decide whether Airflow becomes canonical state (requires GUI changes) or DB status stays the contract.
6. Open questions / decisions needed
Where can a long-lived Airflow daemon run at PNI, and what are the SSH/Globus/GPU-queue policy constraints? (gates executor choice)
Should the DB status_* remain the permanent cross-system contract, or do we eventually port the RecordingProcessJobGUI to read Airflow state?
Is the alert subsystem in scope for this migration or tracked separately?
Is there a MATLAB host Airflow can SSH to with all three repos + DataJoint libs provisioned, or does that host need to be created?
Should resubmit_failed_jobs.m be reimplemented as an Airflow clear/retry trigger so MATLAB stops writing status_processing_id directly?
Who owns imaging_pipeline.AcquiredTiff in the unified DAG (it's populated by both the MATLAB nightly and the Python real-time imaging flow)? Pick one owner or make it idempotent + guarded.
Decisions already made
MariaDB is 11.4 (confirmed; the earlier 10.2 in Infrastructure.md is stale).
Scope = both repos, one unified DataJoint-state-centric DAG set, single Airflow instance — the project is to be managed from one place.
🤖 AI text below 🤖
Summary
Feasibility assessment for migrating the U19-pipeline orchestration layer (the cron-driven automation) to Apache Airflow, across both
U19-pipeline-pythonandU19-pipeline-matlab. This covers what maps cleanly, the real challenges, a recommended target architecture, and a phased path. No code changes — this is a planning/discussion issue. Scope is the scheduling/automation layer, not the DataJoint schema definitions or themake()/.populate()logic itself (those stay as-is, in whichever language they live).1. What runs today (the thing being migrated)
The orchestration is a hand-rolled state machine driven by a 30-minute cron job, not a workflow engine.
Cron entry points (three independent subsystems):
*/30 * * * *,flock):call_cronjob_automatic_job.sh(conda +git pull --autostash) →cronjob_automatic_job.py→RecordingHandler.pipeline_handler_main()→RecProcessHandler.pipeline_handler_main().alert_system/cronjob_alert.py→main_alert_system.py(live-session monitor, schedule check, water-weigh, tech alert, locked-tables, log cleanup, rig maintenance) + a Streamlit water-weigh GUI.call_u19_night_cronjob.sh): pulls ViRMEn +U19-pipeline-matlab, thenmatlab -batch "startup_virtual_machine.m; populate_tables.m". See the dedicated MATLAB section below — this is an ordered ~20-step batch, not one opaque job.The
migrate-to-airflowbranch exists but has no DAGs yet — greenfield.MATLAB workflow scope (in scope, confirmed by exploring
U19-pipeline-matlab)This migration covers both the Python and MATLAB orchestration — but as scheduling, not a logic rewrite. The MATLAB
.populate()/make()logic stays in MATLAB; Airflow just replaces its cron.populate_tables.mis an ordered batch with real internal dependencies, not a single task. Sequence:reset_reweight_subjects→populate_schedule_for_tomorrow/populate_technician_schedule→ protocol-level/training-profile updates → behavior populates (Session,SessionBlock,TowersSession,TowersBlock,SpatialTimeBlobs) → optogenetics ingest +OptogeneticSession→ subtasks → pupillometry (PupillometrySession,…SyncBehavior, model ingest) → posture-tracking →imaging_pipeline.SyncImagingBehavior→ psychometrics (TowersSessionPsych,TowersSubjectCumulativePsych, …). Psychometrics/sync depend on the behavior tables populating first → worth modeling as a small DAG with explicit task ordering, not a BashOperator blob.startup_virtual_machine.mrequiresViRMEn,U19-pipeline-matlab, anddatajoint_matlab_libs(mym, datajoint-matlab, GHToolbox) on the MATLAB path. The Airflow MATLAB host (likely SSH'd to) must provision all of these. Each step also does its owngit pulltoday.resubmit_failed_jobs.mwritesrecording_process.Processing.status_processing_idandrecording_process.LogStatus— the same tables the Python state machine owns. So MATLAB is not an independent silo; it shares the orchestration state store. This sharpens the dual-state challenge (Python schemas/docker setup/readme. #2): whatever ownsstatus_processing_idmust stay consistent across Python, MATLAB, and the GUI.Unifying principle: orchestrate the shared DataJoint state, not the language
Both Python and MATLAB interact with the same MariaDB (11.4) behind the same DataJoint interface — the schema and the
recording_process.Processing/ status tables are the real shared substrate. The goal is one DAG set, manageable from a single Airflow instance, whose task dependencies are expressed against that shared DataJoint state regardless of whether a task runs Python ormatlab -batch. Language is an implementation detail of each task; DataJoint tables are the dependency graph.Python ↔ MATLAB overlap audit (requested)
Result: mostly disjoint by domain, with one real shared seam at imaging ingest — and the two languages are already interleaved within a single workflow, not merely parallel.
imaging_pipeline.AcquiredTiffpopulate()inpopulate_tables.mpopulate()inmodality_preingestionand invokes MATLABpopulate_Imaging_AcquiredTiff()viaingest_scaninfo_shell.sh(matlab -r)imaging_pipeline.SyncImagingBehaviorpopulate()in nightlyTowersSession/TowersBlock/SpatialTimeBlobs/psychometrics), optogenetics, pupillometry, posture-tracking, scheduling, reweight, protocol/training-profile updatesEphysRecording,BehaviorSync,PreCluster,LFP,Clustering,Curation,CuratedClustering)Preprocess,Processing,MotionCorrection,Segmentation,Fluorescence,Activity)Key takeaways for the DAG:
AcquiredTiffis populated from both sides — the unified DAG must pick a single owner for that node (or make it idempotent + guarded) so the nightly MATLAB run and the real-time Python flow don't race.modality_preingestioncalls a MATLAB script synchronously (ingest_scaninfo_shell.sh→populate_Imaging_AcquiredTiff). In Airflow this becomes an explicit edge: a MATLAB task (TiffSplit/AcquiredTiff ingest) that the downstream PythonScanInfo/element tasks depend on. This is the concrete proof that a unified DAG is the right model, not two siloed ones.Subgraphs (traced edge-by-edge from DataJoint foreign keys)
Edges below are real FK declarations (
-> tablein each table's DataJoint header), so they are the populate ordering.[M]= MATLAB-owned task,[P]= Python-owned,[shared]= the cross-language node.Roots (shared, ingested upstream):
subject.Subject,task.Task→acquisition.SessionStarted→acquisition.Session(also-> task.TaskLevelParameterSet).A. Behavior / psychometrics subgraph
[M](nightly, all rooted onacquisition.Session):Correction after reading the
make()bodies (FK headers were misleading here — see "Implicit dependencies" below): the fourTowers…Psych*tables do not all share one prerequisite.TowersSessionPsychandTowersSubjectCumulativePsychtruly read onlyTowersSession/Session→ can run onceTowersSessionis done.TowersSessionPsychTaskactually readsTowersBlockandTowersBlockTrialinsidemakeTuples→ must run afterTowersBlock, despite its FK saying only-> TowersSession.TowersSubjectCumulativePsych/…Levelare subject-cumulative:makeTuplesqueries all prior sessions (session_start_time <= …overSession & TowersSession). They are not row-local — they must run after the whole nightlyTowersSessionbatch, not be mapped per-session in isolation, or cumulative curves will be computed on partial history.B. Optogenetics subgraph
[M]:C. Pupillometry & posture subgraphs
[M](independent, parallel):D. Scheduling / housekeeping
[M](no FK into the above; run independently each night):reset_reweight_subjects,populate_schedule_for_tomorrow,populate_technician_schedule,updateProtocolLevelTable,updateTrainingProfileProtocol,ingest_subtasks(behavior_subtask).E. The imaging seam (cross-language)
[shared]— the only place the two graphs touch:So
AcquiredTiffis the single contended node (and both the Python and MATLABAcquiredTiff.makewriteTiffSplit/TiffSplitFile— pick one owner per key). Downstream forks into a Python branch (element processing) and a MATLAB branch (SyncImagingBehavior) — butSyncImagingBehavioris not a cleanTiffSplit-only consumer; it also depends on the Pythonrecordingchain and the raw behavior file (see Implicit deps #4).Implicit dependencies found by reading the
make()bodies (not in the FK headers)The declared FKs govern most ordering, but the make bodies contain reads/writes that the FK graph does not show. These are the edges most likely to cause a unified DAG to mis-fire:
TowersSessionPsychTask→TowersBlock+TowersBlockTrial(implicit). FK says-> TowersSessiononly; the make reads block/trial tables. TreatTowersBlockas a hard upstream.TowersBlock.makewritesTowersBlockTrialas a side-effect (no separate populate). SoTowersBlockTrialrows appear whenTowersBlockruns — downstream consumers (add first two schemas (lightsheet and microscope) #1) depend onTowersBlock, not a separate task.TowersSubjectCumulativePsych/…Levelare subject-cumulative — read all prior sessions for the subject. Must run after the fullTowersSessionbatch (a "barrier" task), not per-session.SyncImagingBehavioris cross-modality + cross-language + reads disk. FK says-> TiffSplitonly; the make/keySource also requirerecording.RecordingBehaviorSession,recording.Recording,acquisition.SessionStarted(behavior file path),TiffSplitFile, and it reads the raw behavior file from the mounted FS. Real upstreams: the Pythonrecordingchain andTiffSplitand the behavior file on disk.PupillometrySyncBehavior/PostureTrackingSyncBehaviorreadacquisition.SessionVideo+ behavior file + the video file on disk (FK says-> …Sessiononly). Implicit upstream:SessionVideoingested and the video present on the mount.AcquiredTiff.makealso writesTiffSplit/TiffSplitFile(lines ~621–704) — identical product to Python'sAcquiredTiff.make. Confirms the contended node: whichever owner runs, it must produceTiffSplitthe same way. Don't let both run for the same key.acquisition.Session,behavior.TowersSession,behavior.TowersBlockeachfetch1(SessionStarted,'new_remote_path_behavior_file'), read the VirMEn behavior file, and on failureupdate(... 'is_bad_session'/'invalid_session', 1). → These tasks have an external-data precondition (file must exist on the mounted FS) that no FK captures — model as an Airflow sensor/precondition, and note they write back upstream quality flags (so a "populate" task is not side-effect-free).Net: the FK graph is correct as a floor on ordering, but the unified DAG needs ~4 extra edges (#1, #4, #5) and two structural notes (#3 barrier, #7 external-file sensors). None of these break the unified design — they make it more accurate.
F. Ephys subgraph
[P](real-time, no MATLAB edges):(Heavy step = Clustering/Kilosort on SLURM; this is the chain that flows through the integer state machine + transfers.)
The state machine (
recording_handler.py,recording_process_handler.py,params_config.py):status_*_id. Every cron tick advances each active job by exactly one status step.params_config.pylines 86–186) is a linear DAG:RAW_FILE_TRANSFER_REQUEST → transfer-check → JOB_QUEUE (sbatch) → JOB_FINISHED (poll sacct) → PROC_FILE_TRANSFER_REQUEST → transfer-check → populate-element → delete-cluster-data.recording_process.Processing+recording_process.LogStatus(slurm_id,task_copy_id_*, error message/exception). Notifications via Slack webhooks (lab.SlackWebhooks).External execution:
spock(spockmk2-loginvm.pni.princeton.edu) /della(della-gpu.princeton.edu):slurm_creator.pygenerates a.slurm, SSHes via paramiko (or runs locally ifis_this_spock()),sbatch, pollssacct.fair-research-login.element-array-ephys/element-calcium-imagingvia*_element_populate.py.Config/deploy: DataJoint config (
dj_local_conf.json/~/.datajoint_config.json,customblock) for DB creds + paths; Slack webhooks inlab.SlackWebhooks. No.envsecrets framework, no CI. Python 3.12+,uv-managed. Prod host[email protected]; deploy =git pullon cron tick.2. What maps cleanly to Airflow (easy wins)
ProcessFunctionbecomes a task. Biggest structural win.transfer_check(Globus) andslurm_job_check(sacct) are textbook deferrable-sensor cases — no more "check once per 30 min."exception_handler+ manualLogStatuswrites;on_failure_callbackfor Slack)..populate()is safe to re-run..populate()+ alert crons → scheduled DAGs (even just BashOperator initially) — the lowest-risk first target.3. The real challenges
TriggerDagRunOperator. Central design decision.recording_process.Processing.status_processing_id,LogStatus) are read by the RecordingProcessJobGUI + Slack and written by both the Python state machine and MATLAB (resubmit_failed_jobs.m). Airflow wants to own state → must dual-write the DB status initially, and the MATLAB resubmit path must be reconciled with Airflow (e.g. become an Airflow "clear/retry" trigger) so two systems don't fight over the same rows.is_this_spock()special-casing must move into Connections/Hooks. PNI infra constraints (where a daemon may run, firewall, GPU queue policy) are a gating factor.ViRMEn,U19-pipeline-matlab, anddatajoint_matlab_libson the path (startup_virtual_machine.m). Decide whether MATLAB stays on a dedicated host invoked via SSH/matlab -batch, and where the per-rungit pullof all three repos happens (in the DAG vs. baked into the host).lab.SlackWebhooks→ Airflow Connections/Variables (or keep reading DJ config inside tasks).flockserializes everything; Airflow runs concurrently → guard against double-submitting SLURM for the samejob_id(pools,max_active_tis_per_dag, DB guards).4. Recommended target architecture
One Airflow instance orchestrating a unified, DataJoint-state-centric DAG set for both Python and MATLAB. Keep the SSH + Globus execution model; replace cron + the status state-machine with Airflow running as a standalone daemon on the existing prod host (or a dedicated PNI VM).
recording_process.Processing, modality element tables), not via language. A task is "run this populate / this make / this transfer," implemented in whichever language owns that node. Python tasks call DJ-Python; MATLAB tasks SSH to a MATLAB host and runmatlab -batch.AcquiredTiff/TiffSplit ingest is an upstream task; the PythonScanInfo/imaging-element tasks depend on it (this edge exists today as a synchronous shell-out — Airflow makes it a first-class dependency). Pick a single owner forAcquiredTiffto avoid the nightly-vs-realtime race.LocalExecutor(→CeleryExecutorlater) — not Kubernetes. PNI has no obvious K8s; KubernetesPodOperator is the biggest lift for the least near-term payoff. Revisit containers after the logic is proven.TriggerDagRunOperator) one task-group per active recording_process: request-transfer → (sensor) → submit-slurm → (deferrable sensor) → transfer-back → (sensor) → populate-element.status_*+LogStatusso the GUI/Slack/MATLAB consumers keep working; DB status stays the cross-system contract.slurm_creator,clusters_paths_and_transfers,*_element_populatein PythonOperators first; refactor into Hooks later..populate()+ alert DAGs (lowest risk).5. Suggested phased path
populate_tables.mto scheduled DAGs. The MATLAB one becomes a small DAG that SSHes to a MATLAB host and runs the populate steps in dependency order (behavior → sync/psychometrics), rather than one BashOperator. Validates infra/logging/Slack. No state-machine risk.6. Open questions / decisions needed
status_*remain the permanent cross-system contract, or do we eventually port the RecordingProcessJobGUI to read Airflow state?resubmit_failed_jobs.mbe reimplemented as an Airflow clear/retry trigger so MATLAB stops writingstatus_processing_iddirectly?imaging_pipeline.AcquiredTiffin the unified DAG (it's populated by both the MATLAB nightly and the Python real-time imaging flow)? Pick one owner or make it idempotent + guarded.Decisions already made
Infrastructure.mdis stale).Key files
Python (
U19-pipeline-python):u19_pipeline/automatic_job/:recording_handler.py,recording_process_handler.py,params_config.py(status DAG, lines 86–186),cronjob_automatic_job.py,call_cronjob_automatic_job.sh,crontab_example,slurm_creator.py,clusters_paths_and_transfers.py,ephys_element_populate.py,imaging_element_populate.py·u19_pipeline/recording.py,recording_process.py·u19_pipeline/alert_system/·scripts/conf_file_finding.pyMATLAB (
U19-pipeline-matlab):scripts/call_u19_night_cronjob.sh,scripts/startup_virtual_machine.m,scripts/populate_tables.m,scripts/populate_schedule_for_tomorrow.m,scripts/populate_technician_schedule.m,scripts/resubmit_failed_jobs.m(shared-state writer) ·Infrastructure.md(DB/host details)