Skip to content

Commit 9a98ecf

Browse files
kumare3claude
andauthored
feat: triggers without automation + flyte.run(trigger) to fire on demand (#1535)
Triggers no longer require an automation. A trigger without one is a named launch configuration (inputs, env vars, queue, notifications) that only fires on demand. `flyte.run(trigger, ...)` fires any deployed trigger from Python, as the trigger, so the run carries its registered config and is recorded as trigger-fired. ```python import flyte import flyte.notify import flyte.remote from flyte.models import ActionPhase env = flyte.TaskEnvironment(name="reports") # No automation: nothing schedules this, it is fired on demand. full_report = flyte.Trigger( name="full-report", inputs={"region": "all", "days": 30}, env_vars={"REPORT_VERBOSE": "1"}, notifications=flyte.notify.Email(on_phase=ActionPhase.SUCCEEDED, recipients=("me@example.com",)), ) @env.task(triggers=(full_report,)) async def report(region: str = "all", days: int = 7) -> str: return f"report for {region!r} over {days} day(s)" # Fire it programmatically, with the trigger's inputs / env vars / notifications. trigger = flyte.remote.Trigger.get(name="full-report", task_name=report.name) run = flyte.run(trigger) # region="all", days=30 run = flyte.run(trigger, days=3) # override one input, keep the rest ``` Also: `flyte create trigger` no longer requires `--schedule`. Examples in `examples/triggers/manual.py` and `examples/triggers/programmatic.py`. Signed-off-by: Ketan Umare <kumare3@users.noreply.github.com> Co-authored-by: Ketan Umare <kumare3@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 2fdef2c commit 9a98ecf

10 files changed

Lines changed: 740 additions & 39 deletions

File tree

examples/triggers/manual.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Triggers without automation: a named, pre-bound launch configuration.
2+
3+
A `Trigger` does not have to be scheduled. Leave `automation` unset and the
4+
trigger becomes a saved launch configuration for the task -- default inputs,
5+
queue, env vars, notifications -- that nothing fires on its own. It is fired
6+
on demand only (from the UI, or via the API), which makes it a convenient way
7+
to publish a handful of "blessed" ways to run a task without re-typing inputs.
8+
9+
Try it (see the env vars below for where notifications are delivered):
10+
11+
SLACK_WEBHOOK_URL=... NOTIFICATION_EMAIL=... flyte deploy examples/triggers/manual.py env
12+
flyte get trigger
13+
14+
`report_on_demand` deploys with two such triggers, `quick-report` and
15+
`full-report`, alongside a regular scheduled one. Fire either manual trigger
16+
from the task's Triggers tab in the UI, or from Python with `flyte.run` (see
17+
`programmatic.py`). The run starts with the trigger's bound inputs, env vars
18+
and notifications.
19+
"""
20+
21+
import os
22+
from datetime import datetime
23+
24+
import flyte
25+
import flyte.notify
26+
from flyte.models import ActionPhase
27+
28+
env = flyte.TaskEnvironment(name="manual_trigger_example")
29+
30+
# Where notifications go. Read from the deploying shell so no credentials land
31+
# in the example:
32+
#
33+
# SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... \
34+
# NOTIFICATION_EMAIL=you@example.com \
35+
# flyte deploy examples/triggers/manual.py env
36+
#
37+
# webhook.site is handy for seeing the Slack payload if you have no webhook yet.
38+
SLACK_WEBHOOK = os.environ.get("SLACK_WEBHOOK_URL", "https://webhook.site/")
39+
REPORT_RECIPIENTS = (os.environ.get("NOTIFICATION_EMAIL", "<EMAIL>"),)
40+
41+
# No `automation=`: nothing schedules these. Each is just a named set of
42+
# inputs plus what to do when the run ends.
43+
#
44+
# Trigger inputs override the task's own defaults (`region="all"`, `days=7`
45+
# below) for every run fired through the trigger. Inputs the trigger does not
46+
# mention keep the task default, so `quick-report` still gets `as_of=None`.
47+
quick_report = flyte.Trigger(
48+
name="quick-report",
49+
inputs={"region": "us-east", "days": 1},
50+
description="Yesterday only, for a fast sanity check",
51+
# A quick check only needs to shout when something goes wrong.
52+
notifications=flyte.notify.Slack(
53+
on_phase=(ActionPhase.FAILED, ActionPhase.TIMED_OUT),
54+
webhook_url=SLACK_WEBHOOK,
55+
message=":x: quick-report {{.Run.Name}} ended in {{.Phase}}: {{.Error}}",
56+
),
57+
)
58+
59+
full_report = flyte.Trigger(
60+
name="full-report",
61+
inputs={"region": "all", "days": 30},
62+
description="The full monthly report",
63+
env_vars={"REPORT_VERBOSE": "1"},
64+
# The monthly report is worth an email on success and a Slack ping on failure.
65+
notifications=(
66+
flyte.notify.Email(
67+
on_phase=ActionPhase.SUCCEEDED,
68+
recipients=REPORT_RECIPIENTS,
69+
subject="Monthly report {{.Run.Name}} is ready",
70+
body="The full report finished.\nRun: {{.Run.Name}}\nProject/Domain: {{.Run.Project}}/{{.Run.Domain}}",
71+
),
72+
flyte.notify.Slack(
73+
on_phase=ActionPhase.FAILED,
74+
webhook_url=SLACK_WEBHOOK,
75+
message=":rotating_light: full-report {{.Run.Name}} failed: {{.Error}}",
76+
),
77+
),
78+
)
79+
80+
# A scheduled trigger can sit next to the manual ones on the same task. Only a
81+
# schedule can bind `flyte.TriggerTime`, since a manual trigger has no fire time.
82+
nightly = flyte.Trigger(
83+
name="nightly",
84+
automation=flyte.Cron("0 2 * * *"),
85+
inputs={"as_of": flyte.TriggerTime, "region": "all", "days": 1},
86+
notifications=flyte.notify.Slack(
87+
on_phase=ActionPhase.FAILED,
88+
webhook_url=SLACK_WEBHOOK,
89+
message=":rotating_light: nightly report {{.Run.Name}} failed: {{.Error}}",
90+
),
91+
)
92+
93+
94+
@env.task(triggers=(quick_report, full_report, nightly))
95+
async def report_on_demand(region: str = "all", days: int = 7, as_of: datetime | None = None) -> str:
96+
as_of = as_of or datetime.now()
97+
msg = f"report for region={region!r} over the last {days} day(s), as of {as_of.isoformat()}"
98+
print(msg)
99+
return msg
100+
101+
102+
if __name__ == "__main__":
103+
flyte.init_from_config()
104+
flyte.deploy(env)

examples/triggers/programmatic.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Fire a deployed trigger from Python with `flyte.run`.
2+
3+
A trigger is a saved launch configuration for a task: inputs, env vars, queue,
4+
notifications. Scheduled and artifact triggers fire on their own; a trigger
5+
with no automation (see `manual.py`) only fires when something asks for it.
6+
Either kind can be fired on demand by fetching it and passing it to
7+
`flyte.run`, exactly like a task:
8+
9+
trigger = flyte.remote.Trigger.get(name="full-report", task_name=...)
10+
run = flyte.run(trigger) # everything the trigger was deployed with
11+
run = flyte.run(trigger, days=3) # override one input, keep the rest
12+
13+
The run is created *as* the trigger: the platform records the trigger as its
14+
origin (same as a scheduled fire), and the run carries the trigger's inputs,
15+
env vars, queue and notification rules. Keyword arguments override individual
16+
inputs; anything left out keeps the value the trigger was deployed with.
17+
`flyte.with_runcontext(...)` layers further overrides (env vars, queue, run
18+
name, ...) on top of the trigger's run spec.
19+
20+
Try it, after deploying `manual.py`:
21+
22+
flyte deploy examples/triggers/manual.py env
23+
python examples/triggers/programmatic.py
24+
"""
25+
26+
import flyte
27+
import flyte.remote
28+
29+
TASK_NAME = "manual_trigger_example.report_on_demand"
30+
31+
32+
def fire_as_deployed() -> flyte.remote.Run:
33+
"""Fire `full-report` with exactly what it was deployed with (region="all", days=30)."""
34+
trigger = flyte.remote.Trigger.get(name="full-report", task_name=TASK_NAME)
35+
return flyte.run(trigger)
36+
37+
38+
def fire_with_overrides() -> flyte.remote.Run:
39+
"""Override one input; `region` keeps the trigger's value, `days` becomes 3."""
40+
trigger = flyte.remote.Trigger.get(name="full-report", task_name=TASK_NAME)
41+
return flyte.run(trigger, days=3)
42+
43+
44+
def fire_with_runcontext() -> flyte.remote.Run:
45+
"""Layer run-level overrides on top of the trigger's run spec.
46+
47+
The trigger's own env vars and notification rules are kept; `EXTRA_FLAG` is added and
48+
the run gets a fixed name. Anything `with_runcontext` sets wins over the trigger's value.
49+
"""
50+
trigger = flyte.remote.Trigger.get(name="quick-report", task_name=TASK_NAME)
51+
return flyte.with_runcontext(env_vars={"EXTRA_FLAG": "1"}, name="quick-report-from-python").run(trigger)
52+
53+
54+
def fire_every_trigger_on_task() -> list[flyte.remote.Run]:
55+
"""Triggers from `listall()` can be fired too; their details are fetched on demand.
56+
57+
Scheduled triggers (`nightly` here) fire just fine off-schedule: the platform stamps the
58+
run start time, and any `flyte.TriggerTime` input is filled from it.
59+
"""
60+
runs = []
61+
for trigger in flyte.remote.Trigger.listall(task_name=TASK_NAME):
62+
runs.append(flyte.run(trigger))
63+
return runs
64+
65+
66+
if __name__ == "__main__":
67+
flyte.init_from_config()
68+
69+
run = fire_with_overrides()
70+
print(f"fired full-report with days=3: {run.url}")
71+
run.wait()
72+
print(f"phase={run.phase} inputs={run.inputs()} outputs={run.outputs()}")
73+
74+
run = fire_with_runcontext()
75+
print(f"fired quick-report with run-context overrides: {run.url}")

src/flyte/_internal/runtime/trigger_serde.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,13 @@ async def to_task_trigger(
183183
if kickoff_arg_name is not None:
184184
context_kvs.append(literals_pb2.KeyValuePair(key=KICKOFF_TIME_INPUT_ARG_CONTEXT_KEY, value=kickoff_arg_name))
185185

186-
if isinstance(t.automation, OnArtifact):
186+
if t.automation is None:
187+
# No automation: the trigger is a named launch configuration that is only fired on
188+
# demand (UI / API). Nothing schedules it, so there is no kickoff-time or artifact binding.
189+
automation_spec = common_pb2.TriggerAutomationSpec(
190+
type=common_pb2.TriggerAutomationSpecType.TYPE_NONE,
191+
)
192+
elif isinstance(t.automation, OnArtifact):
187193
# Note the contrast with the schedule branch below, which stashes the kickoff-time input
188194
# arg name under KICKOFF_TIME_INPUT_ARG_CONTEXT_KEY (see convert.py): a scheduled trigger
189195
# has to, because the offloaded inputs blob is written once and cannot carry the per-fire

0 commit comments

Comments
 (0)