Parallel execution plan¶
Once you have registered many feature-engineering processes, you rarely want
to run them one after another. tdfs4ds can read the
persisted lineage graph, work out which
processes are independent, and schedule them into waves — every process
in a wave can run in parallel, and waves run in dependency order.
You seed the planner with the things you actually care about — some datasets, features, or processes — and it expands to the upstream closure: every process needed to (re)build those seeds, and nothing else. That closure is also a natural migration unit (it is self-contained).
Build a plan¶
import tdfs4ds
# Seed with datasets / features / processes (mix freely). None = whole store.
plan = tdfs4ds.analyze_parallel_plan(seeds=["customer_360", "churn_score"])
print("waves:", plan["waves"]) # [[pid, pid], [pid], ...] each list = one parallel wave
print("processes:", plan["n_processes"])
print("unresolved seeds:", plan["unresolved_seeds"])
Each wave is a list of process ids that can run concurrently because none of them depends on another in the same wave. Wave 0 holds the leaf-most upstream processes; your seeds land in the last wave(s).
Existing processes from older versions — rebuild lineage first
The planner derives wave order from the persisted lineage graph
(FS_LINEAGE_GRAPH). A process that isn't in the graph yet is flagged
lineage_unknown and placed in wave 0 — assumed independent. That is
only safe if it genuinely has no upstream process; if it does, dependent
processes would be scheduled in parallel instead of in order. Processes
registered by older tdfs4ds versions may not have their edges persisted, so
before scheduling a legacy store run this once:
It (re)derives and persists the dependencies of every registered process and
dataset. Then analyze_parallel_plan(...) orders them into correct waves.
The plan also reports:
wave_groups— per wave, processes grouped by their FilterManager / TimeManager.unify_candidates— distinct filter/time-manager objects that hold equivalent content (candidates for unification).serialize_together— processes in the same wave that write to the same storage table and therefore must not run concurrently (aMERGElocks its targetFS_T_*table). Feature-store tables are keyed by(data_domain, entity, data type), so processes on different entities or different data types write different tables and can run in parallel. When a process's features are not yet registered (a first-time parallel build, so the exact target table can't be resolved from the feature catalog), the planner falls back to a conservative(data_domain, entity)conflict key — reported asentity:DOMAIN::ENTITY— so same-entity processes still serialize instead of racing the same table.lineage_unknown— processes not yet inFS_LINEAGE_GRAPH(runtdfs4ds.rebuild_lineage_graph()to fill the gap; they are placed in wave 0).
Tip
A cycle in the process graph is reported (plan["cycle"]), never
silently ignored — resolve the circular dependency before scheduling.
Generate an execution script¶
Turn a plan into a standalone, runnable Python script:
path = tdfs4ds.write_execution_script(plan, force_compute=False)
print("wrote", path) # under tdfs4ds.SCHEDULING_SCRIPT_DIR
The generated script runs waves sequentially and, within each wave, runs the
independent processes in parallel using separate OS processes
(ProcessPoolExecutor). Each worker opens its own Teradata context from the
TDFS4DS_TD_* environment variables, so the module-level configuration globals
that run() mutates never collide between workers. Processes flagged
serialize_together run one after another inside a single worker.
Processes on one TimeManager run in lockstep (0.3.1.240). Within a wave,
the processes that share a TimeManager form a time group: the script moves the
manager's cursor one date at a time, runs the group's processes in parallel at
that date with run(pid, time_manager=tm) (which reads the cursor and never
moves it), and starts the next date when they are all done. Earlier versions
ran a roll_out per worker, which moved one shared cursor from several workers
at once, so a process could compute at another's date. A wave's other
processes run first; time groups then run one after another. To let processes
run on different dates, give them their own cursor with
split_time_manager.
First runs are serialized. A process whose features are not all registered
yet registers them on its first run, and a new FEATURE_ID is allocated from
the catalog's current maximum: two first runs in parallel would get the same
ids. Such processes share the conflict key feature registration (first run)
and run one after another inside one unit; once their features exist, the
next plan runs them in parallel.
Prefer a string you can inspect first? Use
tdfs4ds.generate_execution_script(plan) (returns the source without writing).
Pass mode="sequential" for a plain, single-process debugging run.
Cap the parallelism¶
Each parallel unit is a full OS process running a heavy Teradata ingestion, so a
wide wave can overwhelm the system. Cap how many run at once per wave with
max_workers:
When a wave has more units than the cap, the extra units queue in a rolling
ProcessPoolExecutor — at most max_workers run at once and each queued unit
starts the instant a worker frees (continuous, not fixed batches; the only
barrier is between waves, for dependency order). A write-conflict
(serialize_together) group counts as one unit — its processes run
back-to-back inside a single worker — so the cap bounds concurrent units, never
letting two processes MERGE the same table at once. max_workers=None (the
default) falls back to
the global tdfs4ds.SCHEDULING_MAX_WORKERS (or TDFS4DS_SCHEDULING_MAX_WORKERS
/ JSON scheduling_max_workers); when that is also None the script is
unbounded — one worker per parallel unit (the historical behaviour). An
explicit max_workers= argument always overrides the global for that call.
What happens when a process fails¶
Added in v0.3.1.210.
A failure in one process does not invalidate the whole plan -- only what depends
on it. wave_on_failure decides how much of the plan a failure takes with it.
It is named apart from the per-process on_failure on run()/roll_out()
deliberately: the two take different value sets, and 'raise' is valid in both
while meaning something different, so a single name would make an invalid value
look like a documentation error.
| Policy | Behaviour |
|---|---|
'skip_descendants' (default) |
Skips only the failed process's downstream closure; independent branches still run. |
'continue' |
Runs every wave, then reports the failures. |
'stop_after_wave' |
Finishes the current wave, then stops before the next one. |
'raise' |
Same, but ends with a RuntimeError. |
The downstream closure is computed inside the generated script from the plan's
own dependency edges, so the script stays self-contained -- it does not import
networkx.
Both runners finish the current wave before honouring 'stop_after_wave' or
'raise': the parallel path cannot abort units already in flight, and the two
must not disagree about what a wave is.
The generated worker calls run(..., on_failure='raise'), because a wave policy
can only react to failures it is told about -- a per-filter failure is otherwise
absorbed by the process. Edit that line in the generated script if you
deliberately want a process to tolerate failed filters.
The script exits non-zero when anything failed, listing the failed processes and those skipped because of them. Each failure is also recorded in the follow-up table -- see Failure handling.
Unify managers¶
When unify_candidates shows that several parallel processes use equivalent
but distinct FilterManagers or TimeManagers, you can repoint them onto a single
canonical manager so one driver drives the whole wave. The mutators are
opt-in and default to a dry run:
group = plan["unify_candidates"]["filter"][0] # {managers:[...], processes:[...]}
preview = tdfs4ds.unify_filter_managers(group["processes"]) # dry_run=True: returns SQL
tdfs4ds.unify_filter_managers(group["processes"], dry_run=False) # actually repoint
Repointing rewrites each process view's manager reference (REPLACE VIEW),
updates the FS_FILTER_MANAGER / FS_TIME_MANAGER catalog row and refreshes
the view's lineage. Use unify_time_managers(...) for time drivers, and
split_time_manager(...) for the inverse (an own cursor per process).
In the admin UI¶
The API server admin shell has an Execution plan panel: pick datasets/processes as seeds, click Build workflow to see an interactive wave-layered graph (columns = execution order; nodes in one column run in parallel; red border = write conflict, orange = unifiable manager), set an optional Max parallel workers cap, and use ⬇ Generate .py script to download the runnable script.
The FS_TIME_MANAGER catalog¶
To make "which TimeManager does this process use" discoverable (so it can be
grouped/unified), each process's TimeManager is recorded in a new
FS_TIME_MANAGER catalog (mirrored on FS_V_PROCESS_CATALOG as
TIME_DATABASE_NAME / TIME_VIEW_NAME / TIME_TABLE_NAME).
The association is recorded at registration with
upload_features(..., time_manager=tm) (0.3.1.240), by roll_out, by
run(..., time_manager=tm), and by link_time_managers for processes
registered without it. run(pid) then uses the recorded manager's current
date. A manager the process view does not read is checked against the lineage
graph (TIME_MANAGER_CHECK, see
TimeManager).
Upgrading from an older version¶
The catalog is fully backward compatible:
connect()creates the (empty) table automatically on a pre-existing store — non-destructively, before it refreshes the catalog view. Nothing is required for your store to keep working; old processes simply readNULLin theTIME_*columns, which the planner treats as "no time manager" and still schedules into waves.- There is no backfill from history — the per-process TimeManager was never
stored before this version. Instead the catalog is self-populating: the
next time you
roll_outyour existing processes, their associations are recorded. (For a one-off,unify_time_managers(...)also writes the rows.)