Native vs external orchestration: a decision framework
Most teams do not choose an orchestration boundary — they drift across it. A single Flux task that started life as a clean hourly downsample slowly accretes a conditional filter here, a second to() there, a call out to a billing API smuggled through a webhook, until it is quietly running a cross-system business process inside an engine built for periodic queries. The native InfluxDB task engine is excellent at what it was designed for and structurally unable to do several things an external engine does natively, and the failure mode when you cross that line is rarely loud. This page sits under External Orchestration & Workflow Engines and gives you a repeatable way to decide, per workflow, whether it should stay on the native engine or move to Airflow, Prefect, or Dagster — scored against concrete criteria rather than argued from taste, and re-audited on a schedule so the boundary stops drifting.
The failure scenario this solves
A renewables operator ran a fleet of 4,200 solar inverters across 180 sites, each reporting AC power, DC string voltage, and internal temperature at 10-second resolution into a bucket named inverter_raw. The nightly job was supposed to do four things in order: roll the raw telemetry into per-site hourly energy totals, reconcile those totals against the revenue meter, branch on the site’s contract type, and then call an external settlement service that pays each site owner for the energy exported to the grid. It was all implemented as one native Flux task, because “it already had the data.”
Branching was the first thing that did not fit. Flux has no clean conditional control flow across pipeline stages, so the author faked it: a chain of filter() calls that kept only rows where a contract_type tag matched, one branch per contract, each ending in its own to() and an HTTP POST to the settlement endpoint. The task looked like this in outline:
// Anti-pattern: branching business logic smuggled into a Flux task
settled = from(bucket: "inverter_hourly")
|> range(start: -1d)
|> filter(fn: (r) => r._measurement == "energy_exported_kwh")
settled
|> filter(fn: (r) => r.contract_type == "ppa_fixed") // "branch" 1
|> map(fn: (r) => ({r with rate: 0.11}))
|> to(bucket: "settlement_queue")
settled
|> filter(fn: (r) => r.contract_type == "spot") // "branch" 2
|> map(fn: (r) => ({r with rate: 0.0})) // rate filled later, elsewhere
|> to(bucket: "settlement_queue")
For eleven weeks it appeared to work. Then finance found that 37 sites on spot contracts had been paid nothing for a full quarter. The spot branch depended on a price lookup that a different task wrote a few minutes later; on nights when the downsample ran slow, the spot rows were written with a zero rate and the settlement call went out anyway — a silent partial failure. The native engine reported the task as successful every single night, because from its point of view the query returned and the writes landed. There was no step-level status, no failed-task signal, no way to say “stage three succeeded but stage four settled the wrong number.” The fix took three weeks of reconciliation and a migration of the settlement stages onto Prefect, where each step had an explicit state and a failed settlement halted the run instead of silently completing it.
The root cause was not Flux. It was that a cross-system, branching, financially consequential workflow was placed on an engine that offers no branching, no cross-step state, and no partial-failure semantics — and nobody had a rule for when that placement is wrong. This page is that rule.
Prerequisites
Core concept: place the workflow, not the tool
There is no engine that is correct for every workflow, so the framework does not rank engines — it scores workflows and lets the score select the engine. The native InfluxDB task engine is a query scheduler: it runs Flux on a cadence, against data it already holds, with a single linear pipeline per task and success defined as “the query completed.” That is exactly right for downsampling, retention-aligned rollups, and single-system transforms. An external engine is a general workflow runtime: it models a workflow as a graph of independent steps with per-step state, conditional edges, cross-system operators, and backfill as a first-class operation. The moment a workflow needs any of the second list, it is mispriced on the native engine — and the mispricing shows up as silent failure, not as an error.
Six criteria separate the two cleanly. Each is scored for a specific workflow from 0 (a natural fit for the native engine) through 2 (only an external engine handles it well), then multiplied by a weight that reflects how expensive that dimension is to get wrong:
$$S = \sum_{i=1}^{n} w_i, c_i, \qquad \text{move to an external engine when } S \ge \theta$$
with each $c_i \in {0, 1, 2}$ and a default threshold $\theta = 12$ on the weighting below. The weights are deliberately front-loaded onto cross-system reach and failure isolation, because those are the two dimensions where a wrong placement fails silently rather than loudly. A workflow that touches only InfluxDB and has no branching will score near zero no matter how large it is; a workflow that settles money across a system boundary will cross the threshold even if it is small.
Step-by-step implementation
1. Inventory the workflow and its cross-system reach
Before scoring, write down what the workflow actually does end to end — every system it reads from or writes to, not just the InfluxDB parts. The single most common mistake is scoring the task (a downsample) instead of the workflow (a downsample that gates a payment). Start by enumerating what is running natively today.
# Enumerate native tasks and dump their Flux so you can read the real reach.
influx task list --org solar-ops --json > tasks.json
# For each task, pull its script and grep for the tells of cross-system reach:
# HTTP calls, secrets, and writes to buckets a *different* stage owns.
for id in $(jq -r '.[].id' tasks.json); do
influx task retrieve --id "$id" --json \
| jq -r '.flux' \
| grep -nE 'http\.post|secrets\.get|to\(bucket' \
&& echo " ^ task $id touches external or downstream systems"
done
A workflow whose Flux imports http or secrets, or writes into a bucket that another job treats as an input queue, is already reaching across a system boundary — that is your first and most heavily weighted criterion. Map the full sequence of stages, including the ones that live in other tasks, the way you would when mapping dependencies and constructing a DAG; the settlement failure above was invisible precisely because two stages lived in two tasks with no modelled edge between them.
2. Score each criterion from native to external
Encode the rubric as data so the decision is reproducible and reviewable rather than a hallway argument. Each criterion gets a 0/1/2 anchor description so two engineers scoring the same workflow land on the same number.
# rubric.yaml — anchors make scores reproducible across reviewers.
threshold: 12
criteria:
cross_system_reach:
weight: 3
0: "Reads and writes InfluxDB only."
1: "Writes one downstream bucket another job consumes."
2: "Calls or depends on a non-Influx system (API, warehouse, payment)."
failure_isolation:
weight: 3
0: "A failed run is safely idempotent to re-run whole."
1: "Partial progress is recoverable but not automatic."
2: "A partial failure has external side effects (money, alerts, emails)."
branching_logic:
weight: 2
0: "One linear pipeline, no conditionals."
1: "A single boolean gate on whether to run."
2: "Multiple branches with different downstream actions."
backfill_retry:
weight: 2
0: "Never backfilled; forward-only cadence."
1: "Occasional manual re-run of a recent window."
2: "Routine parametrized backfill over arbitrary historical ranges."
fan_out_parallelism:
weight: 1
0: "Single query, single writer."
1: "A handful of parallel windows or buckets."
2: "Dynamic fan-out whose width depends on runtime data."
observability_lineage:
weight: 1
0: "Success = query completed is sufficient."
1: "Need per-run duration and success history."
2: "Need per-step state, lineage, and alert-on-step semantics."
3. Compute the weighted decision score
Feed a workflow’s scores through the rubric and let the arithmetic make the call. The script below is deliberately dumb — it only multiplies and sums — which is the point: the judgement lives in the anchored scores, and the output is auditable.
import sys
import yaml
def decide(rubric_path: str, scores: dict[str, int]) -> dict:
rubric = yaml.safe_load(open(rubric_path))
total = 0
breakdown = {}
for name, spec in rubric["criteria"].items():
c = scores.get(name, 0)
if c not in (0, 1, 2):
raise ValueError(f"{name} must be 0, 1, or 2, got {c}")
contribution = spec["weight"] * c
breakdown[name] = contribution
total += contribution
verdict = "external" if total >= rubric["threshold"] else "native"
return {"score": total, "verdict": verdict, "breakdown": breakdown}
if __name__ == "__main__":
# The nightly solar settlement workflow from the failure scenario:
solar_settlement = {
"cross_system_reach": 2, # calls the settlement service -> 6
"failure_isolation": 2, # partial failure pays wrong sums -> 6
"branching_logic": 2, # per-contract branches -> 4
"backfill_retry": 1, # occasional re-run of a night -> 2
"fan_out_parallelism":1, # per-site windows -> 1
"observability_lineage":2, # needs step-level state -> 2
}
print(decide("rubric.yaml", solar_settlement))
# -> {'score': 21, 'verdict': 'external', ...}
The solar settlement workflow scores 21 against a threshold of 12 — decisively external, and the breakdown shows exactly why: the two 3-weight dimensions alone contribute 12. Contrast that with the plain hourly downsample that feeds it, which reads and writes InfluxDB only, never branches, and is idempotent to re-run: it scores 0 and stays native. Two stages of the same nightly pipeline land on opposite sides of the boundary, which is the correct and common outcome — the split runs through a workflow, not between workflows.
4. Route the workflow and record the decision
A decision you cannot find later will drift back. Persist the verdict as a small annotation the audit in step 5 can re-read, and for anything that scored external, move the boundary-crossing stages out while leaving the pure-InfluxDB stages native. The native downsample keeps running as a task; the external engine reads its output and owns branching, settlement, and failure handling.
# For "external" verdicts, the branching and settlement stages become
# a graph in the external engine. Sketch in Prefect-style tasks; the same
# shape maps onto an Airflow DAG or Dagster job.
from prefect import flow, task
@task(retries=3, retry_delay_seconds=120)
def read_hourly_energy(site_id: str) -> dict: ...
@task
def resolve_rate(site_id: str, contract: str) -> float:
# Explicit branch — a real conditional, not a filter() trick.
if contract == "spot":
return lookup_spot_price(site_id) # fails loudly if price is missing
return FIXED_PPA_RATES[contract]
@task
def settle(site_id: str, kwh: float, rate: float) -> None:
if rate <= 0:
raise ValueError(f"refusing to settle {site_id} at rate {rate}")
settlement_api.pay(site_id, amount=kwh * rate)
@flow(name="solar-settlement")
def settle_fleet(site_ids: list[str]) -> None:
for site_id in site_ids:
energy = read_hourly_energy(site_id)
rate = resolve_rate(site_id, energy["contract"])
settle(site_id, energy["kwh"], rate) # a raised error HALTS this site
The critical difference from the Flux anti-pattern is the raise in settle: a missing or zero rate now fails the step loudly and stops that site’s settlement, instead of quietly paying zero and reporting success. The retry and branching semantics that make this safe are engine-specific — how Prefect and Dagster differ on exactly this point is the subject of Prefect vs Dagster for InfluxDB orchestration.
5. Re-audit placements on a schedule
Placement rots. A native task that scored 0 last year acquires an HTTP call this year and silently becomes a mis-placed external workflow. Run the scoring in CI against the live task inventory so a task that grows across the boundary trips a review instead of a finance incident.
import os, re
from influxdb_client import InfluxDBClient
client = InfluxDBClient(url=os.environ["INFLUX_URL"],
token=os.environ["INFLUX_TOKEN"],
org=os.environ["INFLUX_ORG"])
CROSS = re.compile(r"http\.post|secrets\.get")
for t in client.tasks_api().find_tasks():
flux = t.flux or ""
reach = 2 if CROSS.search(flux) else 0
branches = flux.count("filter(fn:")
if reach == 2 or branches >= 4:
print(f"[REVIEW] task '{t.name}' shows external-engine tells "
f"(reach={reach}, filter-branches={branches}) — re-score it")
client.close()
This is intentionally a smoke detector, not the full rubric: any task that calls out over HTTP, or stacks up enough filter() calls to look like smuggled branching, is flagged for a human to re-score against rubric.yaml. Wire it into the same review that governs cron & interval scheduling logic so placement is reviewed whenever cadence is.
Configuration reference
| Criterion | Native fits when | External fits when | Signal to watch |
|---|---|---|---|
| Cross-system reach | Reads and writes InfluxDB only | Touches any non-Influx system — API, warehouse, payment, email | http.post / secrets.get appearing in a task’s Flux |
| Failure isolation | A failed run is idempotent and safe to re-run whole | A partial failure has external side effects that cannot be undone | Task reported “success” while a downstream figure is wrong |
| Branching logic | One linear pipeline, no conditionals | Multiple branches taking different downstream actions | A growing stack of filter() calls standing in for if |
| Backfill & retry | Forward-only cadence, rare manual re-runs | Routine parametrized backfill over arbitrary ranges | Engineers hand-editing range(start:) to reprocess history |
| Fan-out parallelism | Single query and writer, or a fixed handful | Dynamic fan-out whose width depends on runtime data | Copy-pasted near-identical tasks, one per site or shard |
| Observability & lineage | “Query completed” is a sufficient success signal | You need per-step state, lineage, and alert-on-step | Debugging failures by reading raw data instead of run state |
Scores are 0/1/2 per criterion against these anchors; multiply by the weights in rubric.yaml (3, 3, 2, 2, 1, 1) and compare the sum to $\theta = 12$. Tune $\theta$ to your risk appetite — lower it toward 9 for financially regulated workflows where a silent partial failure is unacceptable, raise it toward 15 for internal analytics where the native engine’s operational simplicity is worth more than step-level state.
Common failure modes and fixes
1. Branching logic smuggled into Flux via conditional filters.
Symptom: a task grows a fan of filter() blocks, each ending in its own to() or HTTP call, one per “case.” Root cause: Flux has no cross-stage conditional control flow, so branches are faked with row filters that share no state and cannot signal each other. Remediation: score the branching criterion honestly (a filter fan is a 2), move the branches to an external engine where a conditional is a real edge, and leave only the linear transform native.
# Real branch in an external engine — one path executes, the other does not.
rate = resolve_rate(site_id, contract) if contract else DEFAULT_RATE
2. Silent partial failure with no step-level status. Symptom: the native task is green every night, yet a downstream number (a payment, an alert count) is periodically wrong. Root cause: the native engine defines success as “the query returned,” so a stage that completes with bad inputs is indistinguishable from one that completes correctly. Remediation: give the consequential stages an engine with per-step state and make the step raise on a bad invariant, as in step 4, so failure stops the run instead of completing it.
3. External engine adopted for a job the native engine handled fine.
Symptom: a plain hourly downsample is running as an Airflow DAG with a PythonOperator that shells out to the InfluxDB client, adding a scheduler, a worker pool, and a database to maintain. Root cause: an over-correction — treating “external is more powerful” as “external is always better” and paying real operational cost for capability the workflow never uses. Remediation: score it; a single-system, non-branching, idempotent rollup scores 0 and belongs on the native engine, where it costs nothing extra to run. The concrete trade-offs for the time-series case are worked through in InfluxDB tasks vs Airflow for time-series pipelines.
4. Backfill implemented as a hand-edited re-run.
Symptom: reprocessing last month means an engineer edits range(start:) in the task, runs it once, and edits it back — and sometimes forgets the last step. Root cause: the native engine has no parametrized backfill; a task runs a cadence, not an arbitrary window on demand. Remediation: if backfill is routine, that criterion scores 2; move the workflow to an engine where a date range is a run parameter and history is reprocessed without mutating the production definition.
5. Cross-system credentials pinned inside a task.
Symptom: a settlement token or warehouse password lives in a task’s secrets.get, and rotating it means editing every task that calls out. Root cause: the boundary was crossed inside an engine with no shared connection or secret abstraction across steps. Remediation: once a workflow is external, centralize the credential in the engine’s connection store; the presence of secrets.get for a third-party system is itself a strong signal (criterion 1 scoring 2) that the workflow already left native territory.
Verification and testing
The whole point of the framework is that mis-placement should be detectable, not discovered by finance a quarter late. Verify two things continuously: that no native task has quietly grown across the boundary, and that every external-side effect actually happened when the pipeline claimed success.
First, confirm the downsample stage that feeds settlement is still landing data — a stalled upstream is what let zero-rate rows through in the first place. A monitor.deadman check pages an operator when the hourly energy bucket goes quiet, before the settlement stage can act on stale or missing input:
import "influxdata/influxdb/monitor"
import "experimental"
from(bucket: "inverter_hourly")
|> range(start: -2h)
|> filter(fn: (r) => r._measurement == "energy_exported_kwh")
|> monitor.deadman(t: experimental.subDuration(from: now(), d: 90m))
|> filter(fn: (r) => r.dead == true)
Second, close the loop that the native engine could not: assert that the count of settlement records written back equals the count of sites the pipeline processed. A mismatch is the exact silent partial failure from the scenario, now caught the next morning instead of the next quarter:
sites = from(bucket: "inverter_hourly")
|> range(start: -1d)
|> filter(fn: (r) => r._measurement == "energy_exported_kwh")
|> group() |> distinct(column: "site_id") |> count()
settled = from(bucket: "settlement_audit")
|> range(start: -1d)
|> filter(fn: (r) => r._measurement == "settlement_paid")
|> group() |> distinct(column: "site_id") |> count()
union(tables: [sites, settled])
|> difference() // non-zero row => a site was processed but not settled
Finally, audit placement itself on a cadence by running the step-5 smoke detector in CI and failing the build when a task that should be pure InfluxDB starts calling out over HTTP. Together these give you a native-side liveness check, an external-side reconciliation, and a drift alarm on the boundary between them.
Integration points
This framework is the entry point to a set of more specific decisions. Once a workflow scores native, everything about running it well lives under automated task scheduling & orchestration — cadence, offsets, retries, and the Flux discipline that keeps a single-system pipeline honest. Once it scores external, the first fork is usually Airflow versus the native engine for time-series specifically, which InfluxDB tasks vs Airflow for time-series pipelines treats in depth, followed by the choice between modern external engines in Prefect vs Dagster for InfluxDB orchestration. Whichever side a workflow lands on, the moment it spans more than one stage you are constructing a dependency graph, and the modelling techniques in dependency mapping & DAG construction apply equally to native task chains and external workflows — the settlement incident was a graph with an unmodelled edge. For the wiring itself once a workflow moves out, the connection and scheduling mechanics belong to Airflow + InfluxDB integration, and the lighter-weight container path is covered in Kubernetes CronJob orchestration.
FAQ
Can I run the framework on a workflow that is only partly built?
Yes, and you should — score it from the design, not the implementation. The whole value is catching a cross-system, branching workflow before it is jammed into a native task. Score the intended end-to-end behaviour against the six criteria; if the design already reaches across a boundary and branches, it scores external no matter how tempting it is to start with “just a Flux task because the data is already there.”
What score means I definitely should not use the native engine?
Any single 3-weight criterion at 2 — cross-system reach or failure isolation — is a strong standalone signal, because those are the dimensions that fail silently. A workflow that calls a payment API (reach = 2, contributing 6) or whose partial failure moves money (isolation = 2, contributing 6) is halfway to the threshold on one criterion alone. Treat either of those as a near-automatic external verdict and let the remaining criteria only confirm it.
Does moving to an external engine mean abandoning native tasks entirely?
No, and that is the most common misreading. The boundary runs through a pipeline: the pure single-system stages — downsampling raw telemetry into hourly energy totals — stay as native tasks because they score 0, while only the branching, cross-system, consequential stages move out. The external engine reads the native task’s output. You almost never want the external engine doing the high-frequency query work the native engine does for free.
How often should placements be re-audited?
Re-run the scoring whenever a task’s Flux changes and on a fixed cadence regardless — monthly is a reasonable default for most fleets. Placement drifts because tasks accrete capability quietly; a task that scored 0 last quarter can acquire an http.post in a routine change and silently become a mis-placed external workflow. The CI smoke detector in step 5 makes the common drift cheap to catch between full audits.
Is a high score an argument for Airflow specifically over Prefect or Dagster?
No — the framework decides native versus external, not which external engine. A high score tells you the workflow needs branching, per-step state, or cross-system reach; it says nothing about which runtime provides those best for your team. That second decision turns on your language, deployment model, and lineage needs, and is exactly what the two more specific pages under this one exist to answer.
Related
- InfluxDB tasks vs Airflow for time-series pipelines — the concrete trade-offs once a time-series workflow scores external.
- Prefect vs Dagster for InfluxDB orchestration — choosing between modern external engines after the native/external call is made.
- Automated Task Scheduling & Orchestration — how to run a workflow well once it scores native.
- Dependency Mapping & DAG Construction — modelling the stage graph so no edge goes unmodelled, native or external.
- External Orchestration & Workflow Engines — the broader treatment of engines this framework sits within.
Up one level: External Orchestration & Workflow Engines