Triggers
A trigger is what invokes a function: an HTTP request, a schedule, a message on a queue, a webhook from another service, an event on a topic, an event of RunR itself. A project has a list of triggers — any combination of the types below — and every event reaches the same deployed function.
| Type | What invokes the function | Runs on | Needs |
|---|---|---|---|
http |
Requests to the project's URL (subdomain, gateway route, custom domains) | the Knative Service (public) | — |
schedule |
A cron schedule in a time zone (the organization's unless it names one, below) | the CronJob when it is the only trigger, the event gateway otherwise | — |
queue |
Messages of a Redis Stream, one consumer group per project | the event gateway | Redis on the region |
topic |
Events of an organization topic; every subscribed function gets each event | the event gateway | Redis on the region |
webhook |
Requests to a stable public inbox URL, signature-checked, stored, replayable | the event gateway (via the inbox) | — |
platform_event |
RunR events: deployments, alerts, runtime patches, cost limits, promotions between environments | the event gateway (via the inbox) | — |
workflow |
Durable workflows (the engine) | the Knative Service (public) | its only trigger |
kafka, nats, rabbitmq, mqtt |
Messages of a broker (below) | the event gateway | the broker's address and credentials |
cdc |
Row changes of Postgres tables (logical replication) | the event gateway | a role with REPLICATION |
s3 |
Objects created, changed or deleted in a bucket of any S3 API | the event gateway | the bucket's keys |
email |
Messages arriving in an IMAP mailbox | the event gateway | the mailbox password |
A change of the trigger list reaches the cluster with the project's next deploy.
A global project may have any trigger list: HTTP and workflow triggers answer in every region, every other trigger runs in its primary region only (the CronJob and the event gateway of the other regions wait on standby) and moves with the primary on a switchover or failover.
The trigger list#
Every trigger has a name (unique in the project, [a-z0-9-], it names consumer groups, gateway entries and metrics), a type, its config, write-only secrets and an enabled switch. The order matters once: the first enabled trigger is the primary one. The single-trigger fields of the project config — config.trigger_type, config.schedule, config.queue_stream, what API clients, the Terraform provider and runr.yaml's trigger: used before trigger lists — mirror the primary trigger, in both directions:
- setting
config.trigger_type(andschedule/queue_stream) through the project API changes the primary trigger's type and settings; the other triggers stay; - changing the list writes its primary trigger back into those fields.
Every existing project got one trigger from its config with the upgrade (tenant migration 0077_create_trigger_tables).
Limits: 20 triggers per project; one http trigger; workflow only alone; a project keeps at least one trigger (disable it instead of removing it).
What a project runs#
App\Triggers\TriggerPlan decides it from the enabled triggers:
| Enabled triggers | Workload |
|---|---|
a schedule alone |
CronJob <slug>-trigger, as schedule projects always ran (the handler runs in schedule mode) |
anything with http or workflow |
public Knative Service <slug>; every other trigger through the event gateway |
| only gateway triggers (queue, topic, webhook, platform events, several schedules, broker sources …) | cluster-local Knative Service; the function runs in queue mode (RUNR_TRIGGER=queue) |
| none (all disabled) | cluster-local Knative Service nothing calls |
Schedules next to other triggers. A schedule that is not the project's only trigger does not start a CronJob pod: the event gateway delivers each tick as the CloudEvent dev.runr.schedule.tick (ce-id <trigger>-<unix time of the tick>, data {"schedule": "<cron>", "timezone": "<zone>", "scheduled_time": "<RFC 3339, UTC>", "local_time": "<RFC 3339 in the zone>"}) to the same service as every other event — one warm workload, no per-tick pod, no image pull. A tick that comes while the previous one is still being delivered is skipped. A project whose only trigger is a schedule keeps its CronJob.
The runtime must support what the list needs (runtime.yaml triggers, runtimes.md): http next to an HTTP trigger (gateway events then arrive as HTTP requests of the public service), queue for an event-only project, schedule for the CronJob.
Schedules and time zones#
A schedule runs in a time zone: config.timezone, an IANA name (Europe/Berlin, America/New_York, UTC; the backward-compatible names PHP knows, such as Europe/Kiev, too). Without one it runs in the organization's time zone — the zone of the timed warmer (Administration → Regions & settings, inside an organization Regions & domain → Time zone; PATCH /api/settings {"timezone": …}; Europe/Istanbul by default, in a multi-tenant install the platform's zone for an organization without its own) — and follows it.
- Where it applies. A schedule alone renders its CronJob with
spec.timeZone; a schedule next to other triggers gives the gateway's schedule source the zone (the gateway image carries the zone database). Both use the cron parser of the CronJob controller, so both fire at the same wall-clock times, daylight saving changes included. - Following the organization. The deploy renders the organization's zone of the moment and marks what follows it (the CronJob's annotation
runr.dev/timezone-from: organization, the gateway entry'ssource.timezone_from). When the organization's zone changes, RunR patches those CronJobs and the schedule entries of the deployed gateway configs at once (the gateway pod restarts on its new config) — nothing else of a deployed trigger list changes there. A schedule with a zone of its own keeps it. - The API returns
config.timezoneas set and, on every schedule trigger,effective_timezone(the zone it runs in) andtimezone_source(triggerororganization).runr triggers lsshows the zone next to the cron, the console's trigger editor has a time zone field (empty: the organization's). - The single-trigger fields of the project API (
POST/PUT /api/projects, Terraform'srunr_project.trigger, the MCP toolsproject_create/project_update) takeconfig.schedule_timezone— the zone of the project's schedule trigger,nullor""for the organization's — and return it withconfig.schedule_effective_timezone(terraform.md). - Upgrade. Schedules from before time zones ran in UTC and keep running there: the upgrade sets
timezone: UTCon them (migration2026_10_22_200001, tenant migration0090). New schedules without a zone run in the organization's.
The event gateway#
The project's event gateway is the Deployment <slug>-qc (image ghcr.io/brf-tech/runr/queue-consumer, runtime/queue-consumer; the name is the queue consumer's of earlier versions). It runs one source per gateway trigger and delivers every event the same way:
- CloudEvents over HTTP (binary mode) to the function's cluster-local URL:
ce-specversion,ce-id,ce-type,ce-source,ce-time,ce-subject, onece-<extension>per extension,Content-Type= the event's data content type,X-RunR-Delivery-Attempt,X-RunR-Trigger; the body is the event data. - 2xx = delivered. A redirect is not followed and is not an acknowledgement.
- Retries with exponential backoff (
retry_backoff_secondsdoubled per attempt, capped atretry_backoff_max_seconds), at mostmax_retriesretries. - Dead letter after the last retry: queue and topic triggers copy the message to a dead-letter stream (
<stream>-dlq; topic subscriptions<topic stream>.<group>-dlq) withrunr.dlq.*metadata; webhook and platform events becomefailed(and can be replayed). - Per-trigger concurrency: at most
concurrencydeliveries of a trigger at once (a slot is held during the request, not during a backoff). - At least once: a source acknowledges an event only once it is delivered or dead-lettered; a gateway that stops mid-delivery leaves it to be delivered again (Redis pending entries, inbox leases). Functions should treat
ce-idas an idempotency key. - Metrics on port 8081:
GET /metrics(Prometheus text:runr_gateway_deliveries_total,runr_gateway_failed_attempts_total,runr_gateway_retries_total,runr_gateway_in_flight,runr_gateway_delivery_seconds,runr_gateway_source_restarts_total, pertriggerandtype),GET /stats(JSON),GET /healthz.
Delivery settings, per gateway trigger (config.delivery; defaults in config/triggers.php):
| Key | Default | Range |
|---|---|---|
concurrency |
4 | 1–32 |
max_retries |
5 | 0–20 |
retry_backoff_seconds |
2 | 1–300 |
retry_backoff_max_seconds |
300 | 1–3600 |
The delivery timeout is the project's timeout_seconds.
Configuration and secrets. A project whose only gateway trigger is one queue trigger with the default settings keeps the queue consumer's env-only form of earlier versions (STREAM, GROUP, SINK_URL, Redis credentials from the Secret <slug>-qc-redis) — unchanged manifests for queue projects (legacy env mode). Every other gateway reads gateway.json from the Secret <slug>-qc-config (mounted read-only at /etc/runr/gateway): Redis credentials, the inbox token, broker passwords are there and never in the Deployment spec (security.md). The Service <slug>-qc (port 8081) lets the API nudge the gateway when its inbox has new events; without a nudge an inbox source asks the API every 15 seconds (TRIGGERS_INBOX_POLL_SECONDS).
Capacity. The gateway pod (50m CPU, 64 MiB) counts in the project's reservation like the queue consumer always did (billing.md). The region's topic publisher runr-topics (50m CPU, 64 MiB) is a region component, not a project's: like gateway-auth and the internal-network authz it counts as a platform pod — the region status check subtracts its requests from the region capacity (architecture.md). It tolerates dedicated workers and prefers the other nodes.
Legacy env mode#
The env-only form is the gateway image without GATEWAY_CONFIG: the queue consumer's environment (REDIS_ADDRESS, REDIS_PASSWORD, STREAM, GROUP, CONSUMER, SINK_URL, DLQ_STREAM, DLQ_MAXLEN, MAX_RETRIES, RETRY_BACKOFF, RETRY_BACKOFF_MAX, DELIVERY_TIMEOUT, BATCH_SIZE, CONCURRENCY, CLAIM_MIN_IDLE) becomes one redis-stream trigger. What the queue consumer guaranteed holds:
- an entry is acknowledged only once delivered (2xx) or dead-lettered;
- the dead-letter write and the acknowledgement are one
MULTI/EXEC(XADDto<STREAM>-dlqwith therunr.dlq.*fields,XACK) — an entry is never copied to the dead letter twice by a gateway that stopped in between, and never acknowledged without its record (the same holds for queue triggers and topic subscriptions ingateway.json, whose dead letter is on the same Redis); - without
SINK_URLit delivers tohttp://localhost:8080, the queue consumer's default (and logs it).
What differs from the queue consumer, by design:
- retries run in the gateway with the same backoff; Redis' delivery count still counts them across a restart (an entry that used up its retries before a restart is dead-lettered at once);
- another consumer's pending entry (a pod that was restarted or evicted mid-delivery) is claimed once idle for
CLAIM_MIN_IDLE(default 30s), where the queue consumer claimed it once idle for its next backoff; the gateway keeps the entries it is delivering fresh, so a long retry is not claimed away; CONCURRENCY(default 1) delivers several entries at once;GET /statskeeps the queue consumer's keys and adds/metrics.
Internal calls. The function is a Knative Service on the region's internal network: the gateway calls it as the project itself. Both forms read the project's RUNR_INTERNAL_TOKEN from its Secret <slug>-secret-env and send it in X-Runr-Internal-Token with every delivery attempt; without the token (a gateway of an earlier release) the region's authz knows the gateway by its pod's address, whose labels name the project (runr/project-id, runr.dev/internal-name).
Webhook inbox#
A webhook trigger has a stable public URL:
https://<console>/api/hooks/<context>/<key>
(<context>: the organization; the base is TRIGGERS_WEBHOOK_BASE_URL, else the git webhook base, else the console URL). The URL stays the same while the trigger exists — changing its settings does not change it.
On POST (or PUT) RunR:
- checks the body size (
max_body_kb, at mostTRIGGERS_WEBHOOK_MAX_BODY_KB, 1 MiB) →413; - verifies the signature with the trigger's preset →
401; - drops a delivery id it already stored →
200 {"status": "duplicate"}(nothing is delivered twice); - stores the request (headers without
Authorization/Cookie, the raw body) →200 {"id": "…", "status": "stored"}. The sender is done here.
The gateway then delivers the stored event as dev.runr.webhook.received (ce-source /webhooks/<trigger>, ce-subject the sender's event — GitHub event, Stripe / Slack event type, ce-runrdelivery the delivery id, ce-runrheaders the sender's headers as JSON, Content-Type and body as sent). An unknown or disabled inbox answers 404, a stopped project 409.
config.verify |
Signature | Delivery id (dedupe) |
|---|---|---|
github |
X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the body> |
X-GitHub-Delivery |
stripe |
Stripe-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<body>">, within tolerance_seconds (300) |
the event's id |
slack |
X-Slack-Signature: v0=<hex HMAC-SHA256 of "v0:<X-Slack-Request-Timestamp>:<body>">, within tolerance_seconds; the url_verification handshake is answered |
the event's event_id |
hmac |
config.hmac.header (default X-Signature): HMAC of the body with algorithm sha256 / sha1 / sha512, encoding hex / base64, after an optional prefix (e.g. sha256=) |
config.delivery_id_header |
none |
no check — keep the URL secret | config.delivery_id_header |
The signing secret is the write-only secrets.signing_secret (required unless none). config.delivery_id_header names the delivery id header for any preset.
Retention and replay. Stored events stay for retention_days (default 7, TRIGGERS_EVENT_RETENTION_DAYS; at most TRIGGERS_EVENT_RETENTION_MAX_DAYS, 30) and are pruned daily (triggers:prune). The console's inbox viewer, the API, the CLI (runr triggers events, runr triggers replay) and the MCP tools list them, show headers and body, and replay one event or every failed one: the event is pending again and delivered with fresh retries (ce-runrreplay counts the replays). Viewing bodies and replaying need replay.manage.
Topics#
A topic fans an event out to every subscribed function. Topics belong to the organization and live on a region's Redis as the stream runr.topic.<name> (with the organization suffix of queue streams in a multi-tenant install). A queue is one consumer group on one stream; a topic gives every subscribing trigger a consumer group of its own (sub.<project>.<trigger>), so each gets every event.
- Create topics in the console (Topics), with
POST /api/topicsorrunr topics create(topics.manage); a region needs Redis. A subscribed topic is not deleted (409). - Subscribe a project with a
topictrigger:config.topic,config.start_fromlatest(default: events published from now on) orearliest(what the stream still holds). The project must be in the topic's region. - Each topic keeps its last
TRIGGERS_TOPIC_MAX_LEN(10 000) events; an event is at mostTRIGGERS_TOPIC_MAX_BYTES(256 KiB).
Subscribers get ce-type as published (default dev.runr.topic.message), ce-source /topics/<name>, ce-runrpublisher the publishing project (/projects/<id>, /runr for the API).
Publishing#
From a function. In a region where its organization has a topic, a function gets
RUNR_TOPICS_URL(the region's publisher,runr-topics) andRUNR_TOPICS_TOKEN(its publish token, from the project's secret env Secret). The SDK helpers wrap the call:from runr_workflows import publish publish("orders.created", {"order": 17}, type="shop.order.created")import { publish } from '@runr/workflows' await publish('orders.created', { order: 17 }, { type: 'shop.order.created' })or plain HTTP:
POST $RUNR_TOPICS_URL/v1/topics/<name>/eventswithAuthorization: Bearer $RUNR_TOPICS_TOKEN, the data as body, itsContent-Type, optionalce-type/ce-subject/ce-id→202 {"id", "stream_id"}. A token only reaches its organization's topics. A function deployed before its region's first topic gets the variables with its next deploy.From outside.
POST /api/topics/<topic>/publish{"data": …, "type"?, "subject"?, "id"?, "content_type"?}(events.send),runr topics publish <topic> --data '{"order":17}', the MCP tooltopic_publish, or the console's test publish.
The region's publisher (Deployment + Service runr-topics, the gateway image in publisher mode) is applied with the first topic of the region and by deploys that hand out its URL.
Platform events#
A platform_event trigger sends RunR's own events to the function:
| Event | When | CloudEvent type |
|---|---|---|
deployment.finished |
a deployment became active | dev.runr.deployment.finished |
deployment.failed |
a deployment failed | dev.runr.deployment.failed |
alert.fired |
an alert rule fired (and each reminder after its cooldown) | dev.runr.alert.fired |
alert.resolved |
an alert resolved | dev.runr.alert.resolved |
runtime.patch_available |
a patch of a project's runtime was published | dev.runr.runtime.patch_available |
cost.limit_reached |
a project or the organization reached its cost limit | dev.runr.cost.limit_reached |
environment.promotion.pending_approval |
a promotion between environments waits for an approver (Environments) | dev.runr.environment.promotion.pending_approval |
environment.promotion.approved |
a promotion was approved | dev.runr.environment.promotion.approved |
environment.promotion.rejected |
a promotion was rejected | dev.runr.environment.promotion.rejected |
environment.promotion.deployed |
a promotion's deployment went live in the target environment | dev.runr.environment.promotion.deployed |
environment.promotion.failed |
a promotion failed (its deployment failed or was refused) | dev.runr.environment.promotion.failed |
config.events lists the events (["*"] for all), config.projects optionally limits them to events about those projects (default: every project of the organization). The data is JSON: {"event", "project_id", "occurred_at", "data": {…}}; ce-subject is the project id. A promotion's data is {promotion_id, project_name, version_id, version, source_environment, target_environment, status, message, requested_by, decided_by, decision_comment, deployment_id, status_message} — names, never a configuration value; forward it to a chat or an incident tool from the function. Each occurrence is delivered once (ce-runrdelivery), stored like a webhook event and replayable. Features raise events with app(App\Triggers\PlatformEvents::class)->dispatch($event, $data, $projectId, $occurrenceKey).
Broker and external sources#
The event gateway also reads systems outside RunR. Each source keeps its credentials in the trigger's write-only secrets; they reach only the gateway's config Secret. Every source acknowledges (commits, acks) an event only once it is delivered or dead-lettered — a gateway that stops before leaves it to the broker to redeliver — and gives each event a stable ce-id where the system has one. The console renders their settings from the type's fields; the CLI sets them with runr triggers add --type <type> --set key=value --secret key=value.
| Type | Reads | Order and guarantee | Secrets |
|---|---|---|---|
kafka |
a topic through a consumer group (group_id, default runr.<project>.<trigger>) |
in order per partition (one group member per concurrency), offset committed after delivery; at least once |
sasl_password |
nats |
subjects; with stream a JetStream durable pull consumer (durable, default runr-<project>-<trigger>), else a core NATS queue group |
JetStream: ack after delivery, "in progress" while retries run, at least once; core NATS: at most once | password, token, nkey_seed or creds (auth.mode) |
rabbitmq |
a queue (declare: true declares it durable with bindings) |
manual acks, requeued when the gateway stops first; prefetch default 2 × concurrency; at least once |
url (amqp[s]://user:password@host/vhost) |
mqtt |
a topic filter, QoS 1 by default, persistent session (clean_session: false), optional $share/<shared_group>/… |
acks after delivery (QoS 1 / 2: at least once; QoS 0: at most once); MQTT has no stable message id, so a redelivery gets a new ce-id |
password |
cdc |
Postgres logical replication (pgoutput) of a publication through a slot (slot, default runr_<project>_<trigger>; create_publication with tables, create_slot) |
strictly in commit order, one delivery at a time; the slot advances once every change of a transaction is delivered | dsn (a role with REPLICATION) |
s3 |
a bucket prefix on any S3 API (the region's object store, MinIO, AWS S3 …), listed every poll_interval (30s); a bucket of the region's object store is reached once its binding is attached to the project (the data namespace admits the pods of attached projects only, Bindings) |
created / updated (etag changed) / deleted (opt-in) per change; a key's state advances once its event is delivered; start_from: latest takes the first listing as baseline, checkpoint_key keeps the listing in the bucket across restarts |
access_key, secret_key |
email |
an IMAP mailbox (host, port 993, tls implicit / starttls / none, mailbox INBOX), unseen messages every poll_interval |
the message is marked seen, moved (move_to) or deleted (after) once delivered |
password |
Events:
| Type | ce-type |
ce-source |
ce-subject |
data |
|---|---|---|---|---|
kafka |
dev.runr.kafka.message |
/kafka/<topic> |
the key | the value as sent (ce-kafkapartition, ce-kafkaoffset, ce-kafkaheaders) |
nats |
dev.runr.nats.message |
/nats/<subject> |
the subject | the payload (ce-natsheaders) |
rabbitmq |
dev.runr.rabbitmq.message |
/rabbitmq/<queue> |
the routing key | the body with its content type (ce-rabbitexchange, ce-rabbitheaders) |
mqtt |
dev.runr.mqtt.message |
/mqtt/<topic> |
the topic | the payload (ce-mqttqos, ce-mqttretained) |
cdc |
dev.runr.cdc.insert / update / delete |
/cdc/<database>/<schema>.<table> |
<schema>.<table> |
JSON {op, schema, table, lsn, commit_time, xid, new, old} (values as Postgres text) |
s3 |
dev.runr.s3.object.created / updated / deleted |
/s3/<bucket> |
the key | JSON {bucket, key, etag, size, last_modified, event} |
email |
dev.runr.email.received |
/email/<user>/<mailbox> |
the Subject | JSON {message_id, from, to, cc, reply_to, subject, date, text, html, headers, attachments, truncated} (raw_base64 with include_raw) |
Notes:
tls(enabled,ca_pem,insecure_skip_verify) configures the Kafka, NATS, RabbitMQ (amqps://) and MQTT (ssl://,wss://) clients; IMAP uses the system roots.- When a
cdctrigger goes, RunR drops its replication slot and the publication it created (CDC cleanup). cdcforcesconcurrency: 1; Kafka keeps the order per partition with one group member per concurrency step.- The gateway pod connects out to the systems: network policies of the project namespace and the systems' own allow lists must let it.
CDC cleanup#
Postgres keeps WAL for a replication slot until it is read, so a slot nobody reads fills the database's disk. When a cdc trigger goes, RunR drops what it left on the database:
| When | What is dropped |
|---|---|
the trigger is removed (DELETE, a PUT / runr.yaml list without it, the single-trigger fields retyping it) |
its slots and the publication it created |
it is renamed, or its slot, database (dsn) or created publication changes |
what it no longer reads |
| its project is deleted | every cdc trigger's slots and publications |
| an environment is deleted, the project renamed | the default slots of the old workload name |
The slots are the trigger's own slot, or the default runr_<workload>_<trigger> of the project's workload and of each environment's. A publication is dropped only when the trigger had create_publication and the publication carries RunR's mark (the gateway comments a publication it creates: Created by RunR …); one that existed before stays.
- Not while it is read. A trigger change reaches the gateway with the next deploy, so RunR waits while a deployed gateway config still lists the slot (it looks every 10 minutes, for up to 7 days), and never drops a slot another cdc trigger of the organization reads.
- From the region. The drop runs where the gateway ran: a one-shot pod of the gateway image (
GATEWAY_MODE=cdc-cleanup) in the project namespace, labelled as the project's pod, with the DSN in a short-lived Secret. A slot still in use by another connection is waited for up to a minute; an unreachable database or a busy slot is tried three times. - The outcome. The API's answer to the change lists what RunR drops (
cleanup: trigger, slots, publication, message), andrunr triggers rm/update/runr deployand the console print it. When it is done the user who made the change (else the project's owner) gets a notification — what was dropped, or what is left with the SQL to run on the database, e.g.SELECT pg_drop_replication_slot('runr_shop_orders'); DROP PUBLICATION IF EXISTS "runr_orders";.
API#
| Method and path | Permission | |
|---|---|---|
GET /api/trigger-types |
signed in | the registered types, platform events, webhook presets |
GET /api/projects/{project}/triggers |
projects.view |
the list and what it deploys (plan) |
POST /api/projects/{project}/triggers |
projects.update |
{type, name?, enabled?, position?, config?, secrets?} |
PUT /api/projects/{project}/triggers |
projects.update |
{triggers: [...]} replaces the list; a trigger with the same name and type keeps its secrets, inbox URL and events |
PATCH /api/projects/{project}/triggers/{trigger} |
projects.update |
{name?, enabled?, position?, config?, secrets?}; position: 0 makes it primary; secrets are merged ("" removes one) |
DELETE /api/projects/{project}/triggers/{trigger} |
projects.update |
|
GET /api/projects/{project}/triggers/{trigger}/events |
replay.manage |
?status=&per_page= |
GET /api/projects/{project}/triggers/{trigger}/events/{event} |
replay.manage |
headers and body |
POST /api/projects/{project}/triggers/{trigger}/events/{event}/replay |
replay.manage |
|
POST /api/projects/{project}/triggers/{trigger}/events/replay |
replay.manage |
{status: failed|delivered|all, ids?} |
GET /api/topics, GET /api/topics/{topic} |
projects.view (any project) |
|
POST /api/topics, PATCH, DELETE /api/topics/{topic} |
topics.manage |
|
POST /api/topics/{topic}/publish |
events.send |
{trigger} is the trigger's id or name, {topic} the topic's id or name. The project resource (GET /api/projects/{project}) carries triggers too; secrets are never returned, only which are set. A schedule trigger carries effective_timezone and timezone_source (time zones). The answer of a PUT, PATCH or DELETE that left a cdc trigger's slots behind carries cleanup: [{trigger, type, slots, publication, status, message}] (CDC cleanup).
runr.yaml#
triggers:
- type: http
- type: schedule
name: nightly
cron: "0 3 * * *" # or schedule:
timezone: Europe/Berlin # default: the organization's time zone
- type: queue
stream: orders # or queue:
delivery: { concurrency: 8, max_retries: 3 }
- type: webhook
name: github
verify: github # the signing secret is set in RunR, not here
- type: topic
topic: orders.created
- type: platform_event
events: [deployment.failed, alert.fired]
- type: kafka
brokers: [kafka-1:9092, kafka-2:9092]
topic: orders
sasl: { mechanism: scram-sha-512, username: orders-fn } # sasl_password: set in RunR
- type: s3
endpoint: s3.example.com
bucket: uploads
prefix: incoming/ # access_key / secret_key: set in RunR
triggers: replaces the whole list on push and runr deploy and wins over the single trigger: block; type, name, enabled are trigger fields, every other key goes into the trigger's config. A trigger with the same name and type as an existing one keeps its secrets and inbox URL. See cli.md.
The CLI checks the entries with the API's own rules before it sends them: php artisan triggers:schema derives a JSON Schema of a triggers: entry from the trigger types' validation rules (api/resources/triggers/triggers.schema.json; a test fails when the file and the rules differ), and the CLI embeds the same file (a test fails when the copies differ). What the schema cannot express — rule objects other than cron and time zone, rules that depend on other fields or on install settings — is left to the API, so the CLI is never stricter than the API. What the API ignores is a warning, not an error: trigger: next to triggers: (triggers: is used), a secrets key (not sent), keys a type does not have.
Adding a trigger type#
A new type is two pieces (the broker sources were added this way); run php artisan triggers:schema afterwards and copy the file to cli/internal/projectfile/triggers.schema.json:
API: a subclass of
App\Triggers\TriggerType(orTypes\GatewayTriggerTypefor the common delivery settings) —key(),rules()/secretRules()(validation ofconfig.*/secrets.*),normalize(),validate(),requiresRedis(),gatewayEntry()(the Go source kind and its settings, rendered intogateway.json),fields()(the console's generic form for the type; labels inlang/<locale>/triggers.phpfields.<type>.<key>),present(),entryPoint(),deleted()/changed()(what a removed or changed trigger leaves outside RunR, as the cdc type's slots) — registered inApp\Providers\TriggerServiceProvider(or any provider:app(TriggerTypes::class)->register(...)). Credentials go intosecretsand from there only into the gateway's config Secret.Gateway: a Go package under
runtime/queue-consumer/sources/<kind>implementingtype Source interface { Name() string Run(ctx context.Context, emit func(context.Context, source.Event) error) error }registered in
init()withsource.Register("<kind>", factory)and blank-imported inmain.go. A source reads its broker and callsemitper event;emitblocks until the event is delivered or dead-lettered (source.Settled(err)→ acknowledge / commit), so retries, backoff, dead letters, concurrency, signing and metrics stay in the core.