Schedule a project on Dagster¶
dlt-ops ships no Dagster adapter, and for the recommended path it does not need one. This guide gives you two recipes: a CLI-driven asset per source that keeps every dlt-ops guarantee — lead with this — and native @dlt_assets for per-table lineage when you are willing to trade that operational layer away. Both enumerate your project through the public discover_sources() API.
Prerequisites
- A dlt-ops project whose sources carry
scheduletags — this page uses the repository'sexamples/basic_project(one@hourlysource, one@daily). dlt-opsinstalled and onPATHin the environment Dagster runs in — the recommended recipe shells out to the CLI.- A current Dagster release with Declarative Automation (
pip install dagster), pluspip install dagster-dlt(never the deprecateddagster-embedded-elt) for the advanced native recipe only.
Choose a recipe
| Recipe | Unit in Dagster | Keeps dlt-ops ledger, assertions, checkpoints | Per-table lineage | API surface it needs |
|---|---|---|---|---|
| CLI-driven asset (below) | one asset per source | Yes — the run goes through dlt-ops pipeline run |
No | Public and stable — discover_sources() plus the CLI |
Native @dlt_assets (advanced) |
one asset per dlt table | No — Dagster owns the run | Yes | Internal, no-stability helpers |
Note
Dagster is not installed in the environment these docs are verified in. The Dagster and dagster-dlt snippets are shown against their current APIs and carry no pasted Dagster run; every dlt-ops command they wrap was executed and its output pasted.
Start from discovery¶
Both recipes begin by enumerating the project through the public discover_sources() API, which returns each source keyed by name with its schedule tag and its live @dlt.source callable. Run it from the project root:
from pathlib import Path
from dlt_ops import discover_sources
for name, info in discover_sources(Path(".")).items():
print(name, info.config.schedule.value)
That mapping is the input to everything below: the source names become asset keys, and the schedule tags become cron expressions.
One asset per source, subprocessing the CLI¶
The recommended recipe builds one Dagster asset per source, each subprocessing dlt-ops pipeline run -s <name> -y. Because the run goes through the CLI, it goes through the same dlt-ops runner as everything else — the runs ledger, pre-load assertions, and checkpoint resume all stay in force. A per-source AutomationCondition.on_cron gives each asset its own cadence, mapped from the schedule tag:
Backfill is the exception, and it is a different command. pipeline run takes no window flags; a bounded, chunked, resumable window is dlt-ops pipeline backfill --from --to --chunk, so a Dagster partition that must re-ingest a specific window subprocesses that verb instead. See the backfill guide.
# at (or beside) your dlt-ops project root
import subprocess
from pathlib import Path
import dagster as dg
from dlt_ops import discover_sources
PROJECT_ROOT = Path(__file__).resolve().parent
# You own the preset -> cron mapping; dlt-ops keeps schedule tags coarse.
TAG_TO_CRON = {
"@hourly": "0 * * * *",
"@2hourly": "0 */2 * * *",
"@daily": "0 5 * * *",
"@weekly": "0 0 * * 1",
"@monthly": "0 0 1 * *",
}
def source_asset(name: str, cron: str) -> dg.AssetsDefinition:
@dg.asset(
name=name,
group_name="dlt_ops",
automation_condition=dg.AutomationCondition.on_cron(cron),
)
def _asset(context: dg.AssetExecutionContext) -> None:
# Non-zero exit -> CalledProcessError -> failed materialization.
subprocess.run(
["dlt-ops", "--root", str(PROJECT_ROOT), "pipeline", "run", "-s", name, "-y"],
check=True,
)
return _asset
assets = [
source_asset(name, TAG_TO_CRON[info.config.schedule.value])
for name, info in discover_sources(PROJECT_ROOT).items()
if info.config and info.config.schedule.value != "@manual"
]
Wire the assets — and the sensor that makes on_cron fire — into Definitions:
defs = dg.Definitions(
assets=assets,
sensors=[
# Declarative Automation only runs assets when this sensor is on. Ship
# it enabled so `on_cron` works on deploy without a UI toggle.
dg.AutomationConditionSensorDefinition(
name="dlt_ops_automation",
target=dg.AssetSelection.all(),
default_status=dg.DefaultSensorStatus.RUNNING,
),
],
)
Each asset shells out to the CLI you already run by hand; here is that command against the example source — exit 0, with every dlt-ops guarantee applied:
Pipeline Configuration
----------------------------------------
Source: github_events_api
Function: github_events_api_source
Resources: all (2 total)
Destination: duckdb
Dataset: github_events_raw (from .dlt/config.toml)
Capabilities: full
...
events: 20 | Time: 0.09s | Rate: 212.93/s
actors: 5 | Time: 0.09s | Rate: 54.15/s
...
1 load package(s) were loaded to destination duckdb and into dataset github_events_raw
Two things this recipe gets right by construction:
- Discovery imports your import-safe source modules once at code-location load — never per sensor tick — and the run itself is subprocessed, so heavy work stays out of the Dagster daemon and the dlt-ops runner keeps ownership of the pipeline.
@manualsources are filtered out of the cron list; expose them as un-scheduled assets to materialize on demand. - Non-zero exit fails the materialization.
subprocess.run(..., check=True)raisesCalledProcessErroron exit1, which Dagster records as a failed materialization — the same exit-code contract cron and CI rely on (deployment).
Advanced: native @dlt_assets for per-table lineage¶
Native @dlt_assets turns one source into one Dagster asset per dlt table, with column-level schema metadata in the asset graph — the reason dagster-dlt exists. It is the right choice only in a narrow case, because it makes two trades you must accept up front:
- It needs internal, no-stability helpers. Building the matching
dlt.pipeline(...)needs the effective destination, dataset, and the runtime<source>_pipelinename — none of them public:SourceInfo.configcarries only per-source overrides (Nonewhen the source uses project defaults), andSourceInfo.pipeline_nameis the pipeline directory name, not the runtime pipeline name. The recipe importsload_project_config,resolve_destination,resolve_dataset, andpipeline_name_for_source, none of which are indlt_ops.__all__— so they carry no stability promise across minors. - Dagster owns the run, so the dlt-ops operational layer is bypassed.
@dlt_assetscallsdlt.runitself; it never goes throughdlt-ops pipeline run. That means no runs-ledger row, no pre-loadfail/warnassertions, and no checkpoint resume — everything that lives in the dlt-ops runner. You gain per-table lineage and give up the operational guarantees the CLI-driven asset keeps.
Choose native @dlt_assets only when per-table lineage matters more than the dlt-ops operational layer. The internal helpers do resolve to real values — here against the example source:
from pathlib import Path
from dlt_ops import discover_sources
from dlt_ops.config import load_project_config, resolve_destination, resolve_dataset
from dlt_ops.runs.writer import pipeline_name_for_source
root = Path(".")
project = load_project_config(root)
info = discover_sources(root)["github_events_api"]
print(pipeline_name_for_source(info.name),
resolve_destination(info.config, project),
resolve_dataset(info.config, project))
Feed those three values into dlt.pipeline(...), and the live source from info.source_fn() into @dlt_assets:
from pathlib import Path
import dlt
import dagster as dg
from dagster_dlt import DagsterDltResource, dlt_assets
from dlt_ops import discover_sources
# Internal — not in dlt_ops.__all__, no stability promise across minors:
from dlt_ops.config import load_project_config, resolve_destination, resolve_dataset
from dlt_ops.runs.writer import pipeline_name_for_source
PROJECT_ROOT = Path(__file__).resolve().parent
project = load_project_config(PROJECT_ROOT)
info = discover_sources(PROJECT_ROOT)["github_events_api"]
@dlt_assets(
dlt_source=info.source_fn(), # the live @dlt.source — this half is public
dlt_pipeline=dlt.pipeline(
pipeline_name=pipeline_name_for_source(info.name), # github_events_api_pipeline
destination=resolve_destination(info.config, project), # duckdb
dataset_name=resolve_dataset(info.config, project), # github_events_raw
),
name=info.name,
group_name="dlt_ops",
)
def github_events_api_assets(context: dg.AssetExecutionContext, dlt: DagsterDltResource):
yield from dlt.run(context=context)
defs = dg.Definitions(
assets=[github_events_api_assets],
resources={"dlt": DagsterDltResource()},
)
Schedule these exactly like the CLI-driven assets — attach an AutomationCondition.on_cron through a DagsterDltTranslator, or add the same automation-condition sensor to Definitions.
Troubleshooting: assets never materialize on schedule¶
If assets with on_cron never run, the automation-condition sensor is off. Declarative Automation is evaluated by a sensor, not by the scheduler directly: Dagster auto-creates a default_automation_condition_sensor for any code location that has automation conditions, but it starts stopped and must be toggled on under the Automation tab. The Definitions above ship an AutomationConditionSensorDefinition with default_status=RUNNING so this is handled at deploy time; if you rely on the default sensor instead, enable it in the UI.
Where next¶
- Deployment — where Dagster sits on the ladder, and the cron / GitHub Actions / GitLab CI recipes that share the same command
- Scheduling and orchestration — the schedule contract and the orchestrator-neutral core interface
- API reference —
discover_sources()and theSourceInfofields these recipes read