Tasks & LAW¶
DSProd is built on LAW (the Luigi Analysis Workflow). You declare
what output you want by running a task; LAW resolves the dependency graph and runs only the
steps that are not already done. Every production task takes the same --setup <path> parameter
(the production setup YAML) and a --workflow
backend.
The tasks live in dsprod/tasks.py.
Task reference¶
InstallCMSSW¶
Installs, once on the shared AFS area, the CMSSW releases an era's production needs (all
production steps plus every requested NanoAOD version). It is a local workflow with one
branch per selected era, and is idempotent — env.sh guards each release with a .installed flag. The
batch tasks require it so the releases exist before jobs run.
ImportGridpack and MakeGridpack¶
Both provide the same product — <output>/gridpacks/<gridpack-name>/gridpack.tar.xz, relative to
fs_default — and both branch over distinct gridpacks, not points, since several points can
share one (the 2B2JLNu, 2B2L2Nu and 2B2Tau final states of a mass point use the same
gridpack, and one branch per point would make them race on the same output). Which of the two does
the work depends only on where the gridpack already is:
Two setups do not share the copy, even for the same gridpack: the product path above starts
at the setup's output:, so XHHbbWW/gridpacks/... and XHHbbtautau/gridpacks/... are different
targets and each production imports its own. What is shared is the content and the store it
comes from — for the 80 gridpacks of the X_HH grid that is 2.3 GB written once per setup.
| state | what happens |
|---|---|
already on fs_default |
nothing — both tasks are complete |
| in the DSProdGridpacks store | ImportGridpack copies it to fs_default |
| nowhere | MakeGridpack generates it, and stages the result to fs_default |
The setup never names a gridpack: its location in the store is derived from the process's
gridpack_rel_path, which mirrors the model's own layout level for level
(<process>/<generator>/<comEnergy>/<production_mode>/<gridpack-name>/), and presence there alone
decides import vs. generate. The gridpack name carries the production mode
(GluGlutoRadiontoHH_M-800), because a production stores its gridpacks flat under
<output>/gridpacks/.
ImportGridpack is always local, and MakeGridpack requires it, so the import has happened
by the time MakeGridpack decides what to submit. That matters: reading the store needs the git
checkout and the Git-LFS server, which a grid worker has neither of — without this split, a batch
job would regenerate a gridpack the repository already holds. Under the sparse checkout the
tarball is not even in the working tree, so ImportGridpack streams the object from the LFS
server straight to fs_default and verifies it against the pointer's size and sha256.
MakeGridpack renders the point's cards and runs
genproductions_scripts/bin/<generator>/gridpack_generation.sh. It is the only one of the two
that is worth a batch backend (--workflow htcondor|crab).
Clean environment for generation
gridpack_generation.sh sets up its own CMSSW and aborts if one is already active. DSProd
strips the CMSSW_*/SCRAM/PYTHON* variables from the generation subprocess, so gridpack
generation works even on a CRAB worker (where env.sh sets up a cvmfs CMSSW for LAW itself).
Never generated inside a production job
RunProd requires MakeGridpack, so a worker that finds the gridpack missing — often only
because it cannot reach fs_default — would schedule it and spend the production slot on
MadGraph, about 1.5 h, before failing to upload the result from that same worker.
A MakeGridpack branch running on a batch node therefore checks what was submitted: if this
job was launched for MakeGridpack itself, it generates normally — producing gridpacks on the
grid is a supported thing to do, law run MakeGridpack --workflow htcondor|crab. If the job
was launched for something else, it refuses and says so at once. That error means either the
gridpack really is missing, in which case submit MakeGridpack for it, or fs_default is
unreachable from the worker.
PremixFileList¶
Resolves an era's premix pileup dataset to a plain file list on fs_default
(<output>/premix/<era>.txt), once, and RunProd passes it to cmsDriver as
--pileup_input filelist:....
Without it, --pileup_input dbs:<dataset> makes every job resolve that dataset — ~38 000 files
— with its own DAS query. At a few thousand concurrent jobs the queries start returning nothing,
cmsDriver then writes a config with no secondary input, and cmsRun dies on
after the job has already produced its GEN-SIM. A 5000-job production lost 10 % of its jobs that
way. The list is identical to what a successful DAS query returns, so nothing about the physics
changes; it is stored in the production area (not <output>_test) because it depends only on the
era, and a batch node refuses to build one — that query belongs on the submitting machine.
Seeing premix/ while listing the production directory only means that directory exists.
It is not a listing of the files inside it: a job that has just looked for its own, not
yet written, nano record must still find <era>.txt there. Treating every name that
listing did not mention as absent made every CRAB job try to rebuild the list and die
on the guard above.
RunProd¶
The core production task: a fused GEN→…→MiniAOD→NanoAOD chain for one (era, point, seed), run
via cmsDriver steps (dsprod/run_step.py). Branches are enumerated by
runprod_branches(eras, points) — the single source of truth for branch numbering, shared with
NanoMergeTask. For each requested NanoAOD version it stages one file and records that it did:
<output>/staging/nanoAOD_<version>/<era>/<point>/nano_<version>_<seed>.root
<output>/produced/nanoAOD_<version>/<era>/<point>/nano_<version>_<seed>.json
The record, not the staged file, is what RunProd declares as its output. NanoMergeTask
deletes each staged file once it has merged it, so the file's presence cannot say whether a seed
ran: a resumed workflow would find the outputs of every already-merged seed missing and regenerate
the whole era (law marks such jobs initially missing task outputs). Nothing deletes the records,
so completeness survives the merge. To redo a seed deliberately, delete its record along with its
nano file.
RunProd carries its own failure budget (retries: 9, tolerance: 0.05, acceptance: 1.0)
rather than law's, so one dead branch cannot end a multi-day production while a short sample still
fails the workflow.
The failure budget¶
A production is not allowed to die because one of its branches did.
law counts attempts per job and failed jobs per workflow, and its defaults are made for short
workflows: retries: 5 and tolerance: 0.0, i.e. the first branch to run out of attempts
raises tolerance exceeded and ends the run — 4799 finished jobs and all. RunProd therefore
sets its own budget:
| value | meaning | |
|---|---|---|
retries |
9 | 10 attempts per branch: law submits a job once, then resubmits it retries times |
tolerance |
0.05 | up to 5 % of the branches may run out of attempts without ending the run |
acceptance |
1.0 | law's default, kept: every branch must finish for the workflow to succeed |
Ten attempts is what it takes to survive a run of broken sites, which is how a budget is really
spent: of the 301 branches of the 2026-09 Run3_2023BPix production that failed more than once, 295
failed at two or more different sites, ~30 % of all attempts failed, and three sites failed 93–98 %
of everything sent to them. A budget of four made it a coin flip whether a healthy branch escaped
them before running out — 34 of 16 000 branches were written off with nothing wrong with any of
them. A larger budget costs a successful branch nothing; only a branch that is genuinely dead pays,
in wall clock nobody is waiting for, since tolerance keeps the production running past it and the
quarantine holds the site that spent the budget out for
longer each time. (law offers a branch that has just spent its last attempt to
the submission step once more and then ignores whatever that job reports, so the number of CRAB
jobs a hopeless branch produces is one higher than its budget. Only the budgeted attempts decide
when it counts as failed.)
tolerance is a fraction of the branches, as law reads any value at or below 1, so a
production of fewer than 20 branches — a single point, or a --test run — has a budget below one
job and still ends on its first dead branch. law reads a value above 1 as an absolute number of
jobs instead, so --RunProd-tolerance 2 is how a small production gets the same slack.
It is acceptance, not tolerance, that forbids a silently short sample: the run carries on past
a failed branch, but once nothing is left to finish, law reports
and the workflow fails. law keeps its retry counts in memory only, so running the same command
again hands each failed branch a fresh budget and resubmits it after one polling iteration. In
other words: tolerance decides how much of a production may fail before law stops driving the
rest, and a rerun is what finishes it.
That last point cuts both ways: a branch that is broken for good — a bad gridpack, a seed that crashes the generator — makes every run end the same way, and every rerun hands it a fresh budget, so repeated reruns retry it forever. It shows up as runs of the same length failing on the same branch numbers; the fix is to fix or drop the branch, not to raise the budget.
Both are ordinary law parameters, so a run can override them — --RunProd-retries 3,
--RunProd-tolerance 0.1. Address RunProd by name: like --max-runtime and --n-cpus, neither
is passed on to or taken from the tasks around it, which is what keeps NanoMergeTask from handing
its RunProd requirement law's tolerance: 0.0 back.
Every step asserts that its output holds exactly the -n events it was asked for, counted in the
release that wrote it. -n is only a request: a step that returns fewer produces a valid file, each
later step carries the shortfall forward, and the merge only ever compared a group with the sum of
its own inputs — so of the 166 merged Run3_2023 v12 files, 40 were delivered holding 49 998 or
49 999 events while advertising 50 000. Checking per step is what says where the events went;
entering the release to count costs seconds against a step measured in hours. A generator that
filtered events would legitimately return fewer, and no DSProd process does that today.
Those 40 files were a generator defect rather than a framework one, which is also what the
assertion is for: a fragment that lets Pythia sample an off-shell W/Z below the mass at which it
can be decayed loses the events it cannot decay — about 5 in 10^6, so one job in two hundred — and
an LHE record, readable only once, cannot be replaced, so the job ends one event short. The X_HH
fragments keep mMin above that floor, so the count a job is asked for is one it can deliver.
A shortfall like that is not reproducible per seed, which is why a failing branch is worth retrying: the seed fixes the LHE file, but the resonance masses are drawn by Pythia, and with concurrent hadronizer streams the draw a given record sees depends on which stream took it and what that stream did before. So one attempt in a hundred failing is bad luck and the retry succeeds; a branch that comes up short on every attempt is a generator setting to fix.
RunProd requires the VOMS proxy, InstallCMSSW (for its era), and MakeGridpack (for its
point). Its steps run cmsDriver with --nThreads <n_cpus> (4 by default, and a setup's
resources: may set another), so
the job's core allocation is what cmsRun actually uses; a nThreads in the conditions overrides it
per step. The proxy requirement is satisfied by the batch-delegated proxy
inside a job (see Grid proxy). The number of seeds per point and era follows from
events_total[era] / events_per_job — events_total is per era, so one setup covers all of them.
Never generated inside a merge job
NanoMergeTask requires single RunProd branches, so a merge branch whose seed has no
record would make luigi run this 7 h chain inside the merge job — a 3 h slot with one core,
which it loses on walltime. RunProd therefore makes the same check MakeGridpack does: on
a batch node, a job launched for anything other than RunProd refuses to generate and names
the seed it was asked for. Either that seed really is missing — produce it first — or
fs_default is unreachable from the worker, which is the other way a record looks missing.
NanoMergeTask¶
Merges a group of per-seed nanos (files_per_merge per group) into one output with haddnano,
verifies that the merged event count equals the sum of the inputs, and — only then — removes the
staged per-seed inputs. Those counts are taken in one invocation of the nano release for the
whole group: entering it (scram runtime, in a container when the worker OS differs) costs far
more than the counting, so a 50-input group counted file by file spent about 10 minutes of its 3 h
slot before anything had been verified. The counts come back in a JSON file keyed by path — stdout
is where ROOT prints its own warnings, so nothing may be read off it by position.
Before merging, the group's job size is checked against the events_requested field of the seeds'
produced/ records, so a group mixing seeds produced at different sizes is refused rather than
delivered as a file of an unadvertised size; afterwards the merged file is checked against the size
those seeds were produced for, which — since every step delivers its -n exactly — can only fail
if the merge itself lost events. It takes the input paths from the grouping itself, not from what RunProd
declares (that is the record), and refuses to run if a staged file of its group is gone although
its seed is recorded as produced. Output is the final, FLAF-facing file:
A group waits for its own seeds only, not for the generation stage as a whole: the workflow
requires exactly the RunProd branches of the groups it is running, taken from its branch map
after --branches has been applied. A group whose 50 seeds are on storage can therefore be
merged while the other 4750 of the era are still being produced. The requirement used to be the
entire RunProd workflow, and that is how the Run3_2023BPix production reached 169 of its 192
groups complete with not one merged.
Three consequences to know about:
- Asking for the whole merge (no
--branches) requires everyRunProdbranch, which law collapses back to "all branches" — so a production driven throughNanoMergeTaskpolls the same job data aslaw run RunProditself,data/RunProd/<store>/<backend>_jobs_0To<n>.json, and merges only once the last seed of the selection is done. A narrowed merge run instead gets its own job-data file, named after the branch ranges it requires (crab_jobs_0To51_100To251.json). It lands in the samedata/RunProd/<store>/directory only when it is given the same--eras/--points/--testas the driver:store_parts()appends a slug and hash of those, so a merge run narrowed by--eraswhile the production is driven without it keeps a separate set of job ids for the same seeds. Either way, two processes that can submitRunProdmust not run in one area — they would submit the same seeds twice under two sets of job ids. The product paths themselves do not depend on any selection. --brancheson the merge selects merge groups and asks for the seeds behind them. It used to hand the merge's own branch numbers toRunProdas if they were seeds, so--branches 5waited onRunProdbranch 5 rather than on the 50 seeds of group 5.- A seed selection stops at
RunProd. Its own requirements branch over gridpacks and eras, so law copyingbranchesone level further meant--branches 10:20askedMakeGridpackfor gridpacks 10–19 — while seed 10 needs gridpack 0, whose absence the requirement then never noticed — and dropped the premix list of every era outside the range.
ProductionTask¶
One command for a whole production: it produces, repairs what the storage lost, and merges.
It yields RunProd, then runs the PruneProducedRecords check, then yields NanoMergeTask — the
three steps an operator otherwise runs by hand, in the order that makes the middle one worth having.
A produced/ record whose staged nano file has been lost is invisible until the merge of its group
runs, and then that group fails on every attempt; checking before the merge turns each one into a
seed that is simply produced again. If the check deletes anything, the pruned seeds go back through
RunProd (only those, since only those are now incomplete) before the merge is submitted.
--backend (crab by default, also htcondor or local) reaches both stages; the usual
--eras, --points, --nano-versions and --test narrow the run as everywhere else. Completeness
is the merged files themselves, so a finished production is a no-op and an interrupted one resumes.
The repair here runs in delete mode, unlike the standalone task, which reports by default —
there is no operator to read a report mid-run. --max-repairs (2) bounds it: each round produces
the pruned seeds again, so a storage that keeps losing them would otherwise be answered with
submission after submission, and the run stops with a message naming the count instead.
--max-repairs 0 skips the repair entirely. The count lives on the driver task, which luigi keeps
as one instance only while it runs the task in the same process — so while a repair is allowed,
--workers above one and --worker-timeout are refused with a message rather than left to
degrade the bound silently. On crab and htcondor that costs nothing, since the batch system is
the parallelism. On --backend local it does: there --workers is what runs branches in parallel
(backends), so a large local production wants --max-repairs 0 --workers n
and a law run PruneProducedRecords of its own before and after.
When the bound does stop a run, the records that sweep deleted are already gone, so those seeds are
simply unproduced: fix the storage and run the command again. And because the sweep follows the
production stage immediately, a staged file that is written but not yet visible — remote writes can
lag by seconds — would be read as lost and its seed produced once more. RunProd writes the staged
file before its record, which makes that unlikely, and the cost if it happens is one job.
Why the repair runs before the merge, not after a failure
luigi never re-runs a task whose dynamic dependency failed, so there is no point at which "merge, and prune if it failed" could do the pruning — the generator simply never regains control. A file lost after this has swept therefore still fails its merge branch; running the same command again repairs it and finishes the production. The driver prints the payload's own error for a failed job, so a merge that fails for some other reason says so directly.
BackfillProducedRecords¶
A migration for productions that ran before the produced/ records existed, whose staged files
have already been merged away: it reconstructs the records from what is on storage — a merged file
accounts for the whole group of seeds behind it, a surviving staged file for its own seed — and
leaves existing records alone, so it is safe to re-run (delete its backfill.done flag to make it
list storage again).
One branch per (era, point, nano version). Each branch reads the storage with three directory
listings rather than a stat per seed, and uploads its records --upload-threads at a time
(16 by default): a full era is 16000-28000 seeds per nano version, and at one remote round trip per seed
the migration takes hours instead of minutes. Raise --workers and --upload-threads on a slow
endpoint — the work is all latency, not CPU.
PruneProducedRecords¶
The repair for the opposite drift: a produced/ record whose staged nano file is gone and
unaccounted for. A record is what the merge trusts — it is why a merge may delete the files it
consumed without the next run reading a finished era as unproduced — so a staged file that
disappears anyway (storage lost it, or a hand removed it) leaves a record no one can honour, and
the only symptom is a merge group that fails on every attempt with N of 50 staged nano files of
this merge group are gone. Three groups of the Run3_2022EE production sat like that on
2026-09-15, each missing exactly one file of fifty.
law run PruneProducedRecords --setup <setup> --eras Run3_2022EE
law run PruneProducedRecords --setup <setup> --eras Run3_2022EE --prune
Without --prune it only reports, because the cost of a wrong deletion is a seed produced again
from scratch; with it, the stale records are deleted and those seeds are produced again on the next
RunProd. A record is stale only when nothing accounts for its seed:
- a merged file covering the seed's group accounts for it — the normal state after a merge, when every record of the group has no staged file and all of them are healthy;
- a staged nano file accounts for its own seed.
Two rails, because the blast radius of a mistake here is an era. A directory listing that fails
stops the prune instead of reading as "nothing is staged", which would condemn every record of the
point — listdir() and not exists(), because exists() answers an unknown file from a cached
listing of its directory and so can answer from evidence older than the question (a failed listing
itself is no longer a "no" anywhere: gfal_ls_checked raises). Absence is
established instead from the first successful listing of an ancestor that does not carry the
branch below it, climbing when it has to: before a production's first merge there is no merged
tree at all, so the point's directory and the era's above it are both missing and the answer comes
from higher up. And a point whose stale share exceeds
--max-stale-fraction (0.5) is reported as a storage or configuration fault rather than pruned.
The task never reports itself complete — records can go stale again tomorrow — and it is a plain
task rather than a workflow for that reason. A law local workflow yields its branches as dynamic
dependencies and luigi re-runs the workflow once they finish, re-checking each branch, so a branch
that is never complete is scheduled again on every pass and the run never ends; a live production
hit exactly that. A plain task is run once and marked done whatever its completeness says. The work
is remote listings, so the (era, point, version) units are checked --threads at a time (8) instead
of by luigi workers, and every unit is reported before an unreadable one stops the run.
Not while merges of the same era are in flight
A merge uploads its merged file and only then deletes the staged inputs it consumed, and this
task reads the staging tree before the merged one, so a group that merges underneath it is
still accounted for. What that does not cover is storage that has not yet made the new merged
file visible — FNAL EOS has shown read-after-write lag — which would read as "neither staged
nor merged". Run it when the era's merges are not running, and read the report before passing
--prune.
CollectGridpacks¶
The way back: it collects the gridpacks a setup produced into the local DSProdGridpacks checkout, so they can be committed and reused (and so the next production imports them instead of regenerating them).
For each distinct gridpack of the setup it reports one of three states — already in store,
collected (it was on fs_default, so it is downloaded into the store checkout), or
not produced yet — and writes the same list to data/CollectGridpacks/<setup>/collected.json.
Every collected gridpack gets a README.md next to it recording how it was made: the setup, the
cards it came from, and the exact commits of DSProd, DSProdModels and genproductions_scripts —
the provenance the store requires.
It commits nothing. Adding ~30 MB of Git-LFS content per gridpack stays a deliberate act, so the task ends by printing the commands to run:
law run CollectGridpacks --setup <setup>
# ... then, as printed:
git -C gridpacks add --sparse '<process>/<generator>/<comEnergy>/<production-mode>/<gridpack-name>'
git -C gridpacks commit -m "add ..."
git -C gridpacks push
--sparse is required: the store is checked out sparsely,
and a plain git add skips a *.tar.xz path with only a hint.
The task has no output to be "done": it re-runs every time it is asked for.
MakeManifest (planned)¶
A future task that writes the dataset manifest FLAF uses to enumerate the produced samples. Not yet implemented.
Useful LAW options¶
--print-status -1— show what LAW considers done vs. pending for the full graph, without running anything.--print-deps -1— print the dependency tree (with the backend each task would use).--eras '<glob>'/--points '<glob>'— produce only the matching eras / points of the setup (see production setups).--test <n>— produce<n>events per point and era in one job, into a separate<output>_testarea.--branch <n>/--branches <a,b>— run only selected branches of a workflow.--workers <n>— run several branches in parallel locally.--<TaskName>-<param> <value>— override a parameter of an upstream task (e.g.--MakeGridpack-workflow localwhileRunProdruns on HTCondor).