Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci-scripts-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
paths:
- "scripts/ci/**"
- "design/dedicated-integrations/**"
- "design/dedicated-integrations-triggers/**"
- ".github/workflows/ci-scripts-test.yml"
- "pyproject.toml"
- "src/bundles/*/pyproject.toml"
Expand Down
189 changes: 189 additions & 0 deletions design/dedicated-integrations-triggers/README.md

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions design/dedicated-integrations-triggers/decisions/TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# <Decision title>

Status: draft
Decision ID: <process-model | self-managed-ingress | delivery-semantics | ...>
Applies to: <event-transport matrix file(s), tracks, or provider mechanisms this governs>
Owners (sign-off roles): <platform owner>, <lfx owner>, <langflow-base owner>, <Enterprise owner>, <frontend owner>
Last verified: YYYY-MM-DD

<!--
Same parse rules as design/dedicated-integrations/decisions/TEMPLATE.md so the INT-1 checker can validate this
directory once it accepts a --design-root: Status is one of draft | proposed | accepted | superseded; the
"## Decision" heading is required; every role on the Owners line needs a row in the "## Sign-off" table below and in
the README sign-off table, which must list this file. `Status: accepted` records the release owner's decision; the
other roles sign off in PR review.
-->

## Context

Why this decision is on the critical path and which TRG tickets block on it.

## Facts (with citations)

| # | Fact | Source URL | Verified on | Confidence |
|---|------|------------|-------------|------------|
| 1 | | | | |

Every fact used in Options or Decision appears here. Reuse the matrix `sources` ids in parentheses where one exists.

## Options

### Option A: <name>

Pros, cons, and what it costs (engineer-weeks, calendar time, recurring obligations).

### Option B: <name>

## Decision

One paragraph, imperative.

## Consequences

Matrix rows that change; contract, process, or frontend surfaces affected; estimate delta.

## Re-open trigger

Concrete observable events (for example "Microsoft Graph documents a pull-based delivery for change notifications",
"Slack withdraws Socket Mode", "a customer commits to a trigger-driven flow for a dated release"). Include a
re-verify-by date.

## Sign-off

| Role | Name | Date | PR |
|------|------|------|----|
| platform owner | | | |
| lfx owner | | | |
| langflow-base owner | | | |
| Enterprise owner | | | |
| frontend owner | | | |
128 changes: 128 additions & 0 deletions design/dedicated-integrations-triggers/decisions/delivery-semantics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Delivery semantics for triggered runs

Status: accepted
Decision ID: delivery-semantics
Applies to: the `trigger_event` ledger and the dispatcher (TRG-2); every `delivery`, `replay` and `dedupe_key` block in `matrices/*-events.json`
Owners (sign-off roles): platform owner, langflow-base owner, release owner
Last verified: 2026-09-05

## Context

TRG-1 exit criterion 5. Every wave-1 mechanism is at-least-once at the provider (Slack retries three times, Graph
retries for about four hours and may duplicate and reorder, Pub/Sub is at-least-once and unordered, and every Track B
recovery path replays a window). A flow run is not idempotent: it posts messages, writes files, and bills tokens. The
gate therefore has to decide once, for every provider, where duplicates are collapsed, how long an event stays
replayable, what happens to an event that never succeeds, whether ordering is promised, and what backpressure the run
path applies - before TRG-2 writes the ledger and before TRG-4 and TRG-5 write their ack paths.

## Facts (with citations)

| # | Fact | Source URL | Verified on | Confidence |
|---|------|------------|-------------|------------|
| 1 | Slack retries a failed or slow event delivery three times (immediately, 1 min, 5 min) and disables delivery after sustained failure; the deadline is three seconds | https://docs.slack.dev/apis/events-api/ | 2026-09-05 | high |
| 2 | Slack `event_id` is stable across retries and across a Socket Mode redelivery; `envelope_id` is per delivery | https://docs.slack.dev/apis/socket-mode/ | 2026-09-05 | high |
| 3 | Microsoft Graph notifications may arrive out of order and may be duplicated, and carry no provider event id | https://learn.microsoft.com/en-us/graph/change-notifications-overview | 2026-09-05 | high |
| 4 | Cloud Pub/Sub delivery is at-least-once and unordered unless ordering is enabled; an unacknowledged message is redelivered after the ack deadline | https://cloud.google.com/pubsub/docs/pull | 2026-09-05 | high |
| 5 | Google push channels send an initial `X-Goog-Resource-State: sync` message that carries no change | https://developers.google.com/workspace/calendar/api/guides/push | 2026-09-05 | high |
| 6 | `Job.dedupe_key` has no database unique index: `create_job` counts and then inserts, so background-execution idempotency is racy across replicas | `src/backend/base/langflow/services/jobs/service.py` create_job | 2026-09-05 | high |
| 7 | A guarded-`UPDATE` claim (`FOR UPDATE SKIP LOCKED` on PostgreSQL, `UPDATE ... WHERE state = 'pending'` on SQLite) is already used for job claiming in this repo | `src/backend/base/langflow/services/jobs/service.py` | 2026-09-05 | high |
| 8 | The default API worker count is greater than one, so even SQLite deployments run several processes against one file | `src/backend/base/langflow/__main__.py` | 2026-09-05 | high |

## Options

### Option A: exactly-once end to end

Pros: the semantics a user assumes.
Cons: undeliverable. Fact 3 alone (duplicated, unordered, id-less notifications) means the provider cannot supply the
identity an exactly-once contract needs, and fact 6 means the run layer cannot supply it either.
Cost: unbounded.

### Option B: at-least-once with per-provider dedupe inside each source adapter

Pros: each adapter can use the sharpest key it knows.
Cons: five adapters each own a correctness-critical invariant, with no single place to test it; a resync path and a
push path in the same provider can disagree and re-run flows.
Cost: repeated per provider, and repeated again for every future provider.

### Option C: at-least-once at the edge, collapsed once in the ledger by a database unique index (selected)

Pros: one invariant, one index, one test; adapters only have to *derive* a key, and the recorded-payload contract
tests can pin that push and poll derive the same one. Replay, dead-letter, and backpressure all become properties of
one table.
Cons: the key derivation is still per provider, and a bad derivation degrades to duplicate runs rather than to an
error, so it has to be tested rather than reviewed.
Cost: one migration and one dispatcher, both already in TRG-2.

## Decision

Option C.

**At-least-once, collapsed once.** Ingress and listeners never execute a flow; they write one `trigger_event` row and
return. `trigger_event` carries a `UNIQUE (trigger_id, dedupe_key)` index, and an insert that violates it is an
idempotent success, not an error - Slack's three retries (fact 1), Graph's duplicates (fact 3), and Pub/Sub's
redeliveries (fact 4) all collapse to one row and therefore one run. The ledger's index is the *only* database-level
dedupe guarantee in the system; the dispatcher does not rely on `Job.dedupe_key` (fact 6).

**Dedupe keys are per mechanism and recorded in the matrices.** Where the provider supplies a stable identity it is
used verbatim (Slack `event_id`, fact 2). Where it does not, the key is derived from the changed item's identity and
version - Graph from `subscriptionId`/`resource`/`resourceData.id`/`changeType`/etag, Google Calendar from calendar
id, event id and `updated`, Drive from `fileId` and `modifiedTime`, Gmail from mailbox and history record id - and
the derivation must be reachable from both the push payload and the poll item, because every Track A recovery path is
a Track B read. A `sync` message (fact 5) is dropped before the ledger write and never becomes a row.

**Ack ordering.** A listener acknowledges the provider only after the ledger write has committed (fact 4's
redelivery is the safety net). An ingress route answers within the provider's deadline and, if the write cannot
complete in time, answers non-2xx so the provider retries rather than answering 2xx and losing the event.

**Replay window: 7 days, purge at 30 days.** Ledger rows stay replayable for 7 days from receipt; rows older than 30
days are purged by a leased job. Replay writes a *new* row linked by `replay_of_event_id` rather than mutating the
original, so lineage survives and the unique index is not fought. Catch-up for a missed schedule tick coalesces
within the replay window: many missed ticks produce one run, not a storm.

**Retries and dead-letter.** A claimed event is leased; an expired lease returns it to `pending` with `attempt + 1`.
Attempts are capped per trigger (`max_attempts`, default 5) with exponential backoff and jitter, and an event that
exhausts them moves to `dead` with the last error retained. Dead rows never dispatch again on their own; an operator
replays them explicitly. A trigger whose events die repeatedly moves to `error` so the failure is visible on the
trigger rather than only in the ledger.

**Ordering is not promised.** No mechanism guarantees it (facts 3, 4) and the ledger does not add one. Per-trigger
run concurrency is capped (`concurrency_limit`, default 1) so events for one trigger execute one at a time in claim
order, which is the closest useful approximation and is what a conversation-correlated flow actually needs.
Cross-trigger ordering is undefined and documented as such.

**Backpressure.** The dispatcher claims a bounded batch and never claims more than the per-trigger concurrency cap
allows, so a burst grows the ledger rather than the run queue. When the run path rejects a submission the event is
rescheduled with backoff, not dropped. The ledger is the buffer; that is why the purge job, not the ingress, bounds
its size.

## Consequences

- TRG-2 owns the `UNIQUE (trigger_id, dedupe_key)` index, the claim/lease/retry/dead-letter state machine, the replay
and purge jobs, and the `replay_of_event_id` self-reference; all of it is in one migration.
- TRG-4's ingress and TRG-5's Socket Mode adapter both write-then-ack; TRG-4's ingress performs no outbound HTTP and
no execution inside the request, which is what makes the three-second Slack deadline and the ten-second Graph
handshake reachable.
- TRG-6's recorded-payload contract tests must assert that push and poll produce byte-identical dedupe keys for the
same change; without that assertion a Graph resync or a Google full list re-runs flows.
- TRG-7 shows attempts, dedupe key, state, and replay lineage per event, and the operator replay action is explicit
rather than automatic.
- TRG-8's soak measures exactly these numbers: zero lost events, zero duplicate runs, dead-letter only after
`max_attempts`.
- Fact 8 means even a single-container SQLite deployment needs the lease rows; "single process" is never assumed.

## Re-open trigger

- A provider ships an ordered, exactly-once delivery Langflow can honour end to end, or
- the 7-day replay window proves wrong in the soak (either too short for a real recovery or too expensive to retain),
or
- `Job.dedupe_key` gains a database unique index, which would let the dispatcher lean on it for the submit step.

Re-verify by: the 1.14 planning gate.

## Sign-off

| Role | Name | Date | PR |
|------|------|------|----|
| platform owner | | | |
| langflow-base owner | | | |
| release owner | Eric Hare | 2026-09-05 | #14911 |
114 changes: 114 additions & 0 deletions design/dedicated-integrations-triggers/decisions/process-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Process model for triggers

Status: accepted
Decision ID: process-model
Applies to: every Track B mechanism in `matrices/*-events.json`; the TRG-2 dispatcher; TRG-3 packaging
Owners (sign-off roles): platform owner, langflow-base owner, Enterprise owner, release owner
Last verified: 2026-09-05

## Context

TRG-1 exit criterion 3. Track B mechanisms (`slack.socket_mode`, `microsoft.graph_delta_poll`,
`google.calendar_sync_poll`, `google.drive_changes_poll`, `google.gmail_watch_pubsub_pull`) hold or repeatedly open an
outbound connection. Both prior attempts hosted that loop inside the API process, and the platform owner's position
recorded in the README is that a listener needs a separate service or a supervised subprocess, one instance per
connection, so API replicas, restarts, and autoscaling neither duplicate nor drop connections. This record decides
the shape, the lease, and the behaviour in each deployment context, because TRG-2 cannot start a dispatcher and TRG-3
cannot pick a packaging without it.

Two loops are in play and they are not the same thing. The **dispatcher** drains the `trigger_event` ledger and
submits runs; it is short-lived work that fits the API process. The **listeners** hold provider connections; they do
not.

## Facts (with citations)

| # | Fact | Source URL | Verified on | Confidence |
|---|------|------------|-------------|------------|
| 1 | The API process defaults to more than one uvicorn worker: `(cpu_count() * 2) + 1` | `src/backend/base/langflow/__main__.py` worker default | 2026-09-05 | high |
| 2 | Enterprise lifespan hooks exist and are the only in-process place to start and stop background work | `src/backend/base/langflow/main.py:87` `_enterprise_lifespan_hooks`, run at `:653` and `:697` | 2026-09-05 | high |
| 3 | Nothing in the repo supervises a long-lived process; Celery exists (`core/celery_app.py`) but is not wired to flow execution in OSS | README "Runtime seams" | 2026-09-02 | high |
| 4 | A guarded-`UPDATE` lease idiom already exists and is proven on SQLite and PostgreSQL | `src/backend/base/langflow/services/jobs/service.py` claim/renew helpers | 2026-09-05 | high |
| 5 | `lfx run` and `lfx serve` do not host listeners; the headless entry points build and serve a graph only | `src/lfx/src/lfx/cli/serve_app.py`, `serve_durable.py`, `serve_workflow.py` | 2026-09-02 | high |
| 6 | Slack counts a stale Socket Mode connection against the ten-per-app cap until it times out, so two replicas holding one app's connection can lock the app out | https://docs.slack.dev/apis/socket-mode/ | 2026-09-05 | high |
| 7 | `origin/mock-orchestra` ran one Discord gateway client per bot from the API lifespan; `origin/feat-native-triggers-v2` ran one asyncio worker per uvicorn worker from the API lifespan | README "Precedents" | 2026-09-02 | high |

## Options

### Option A: subprocess supervisor under the API lifespan only

Pros: one artifact to deploy; nothing new for the operator; Desktop works with no extra shape.
Cons: fact 1 means several API processes would each try to supervise; the supervisor dies with the API; scaling the
API scales the listeners; a listener leak takes the API with it. Repeats the precedent shape (fact 7).
Cost: low now, high later.

### Option B: separate service only

Pros: the clean shape - listeners scale, restart, and fail independently of the API; matches fact 6's requirement
that exactly one process holds each provider connection.
Cons: Desktop and single-container Docker have no second process to run, so those contexts lose Track B entirely,
and every developer running `langflow run` locally loses it too.
Cost: an operator Deployment, a Compose service, and docs.

### Option C: both, with one primary (selected)

The separate service is the supported shape; the lifespan subprocess is the single-replica convenience shape.
Cost: Option B's cost plus a subprocess supervisor and its single-worker guard.

## Decision

Option C. A `langflow listeners` process is the primary shape for Track B and is what the operator Deployment and the
Compose service run; `LANGFLOW_LISTENERS_MODE=subprocess` makes the API lifespan spawn exactly that process as a
child, and that mode is the Desktop and single-container shape. The boot path asserts that no FastAPI app is created
in the listener process, so the two never converge again by accident.

The dispatcher and the schedule tick are not listeners and stay in the API lifespan, gated on
`trigger_dispatcher_enabled` and held by a `trigger_lease` heartbeat singleton so that only one of the several API
workers runs them; the listener process may host the same loop when the API is not running it.

Lease semantics, one lease per provider connection in `trigger_listener_lease`: TTL 30 s, heartbeat 10 s, reconcile
poll 5 s, failover within two TTLs of an unclean death, claim and renew through the guarded-`UPDATE` idiom of fact 4
so SQLite and PostgreSQL behave the same. A replica that loses its lease cancels its adapter tasks before another
replica's claim can succeed in the common case, and fact 6 is the reason the TTL is short rather than generous.

Per context:

| Context | Dispatcher | Listeners | Shape |
|---|---|---|---|
| hosted / multi-replica Kubernetes | API lifespan, lease-elected | separate Deployment, `replicas: 1` per listener group | operator `spec.listeners` |
| self-managed Compose or single-container Docker | API lifespan, lease-elected | `LANGFLOW_LISTENERS_MODE=subprocess`, spawned from one worker | one container or a second service |
| Desktop | API lifespan | subprocess mode | no operator action |
| headless (`lfx serve`, `lfx run`) | none | none | triggers are not hosted here at all |

Health lives on `LANGFLOW_LISTENERS_HEALTH_PORT` (default 7861) with `/health` for liveness and `/healthz` for
readiness (database probe, leases held, no renew failure within the last TTL).

## Consequences

- TRG-3 owns `langflow listeners`, the lease table, the adapter protocol, the health endpoints, and four packaging
shapes; its estimate carries the operator Deployment and the Compose service.
- TRG-2 owns the dispatcher inside the API lifespan and the `trigger_lease` singleton; it does not start listeners.
- `headless` is `unavailable` for public ingress in all three matrices and hosts no Track B mechanism, which is this
gate's answer to the README's open question "Track A on `lfx serve`": no. `lfx serve` neither exposes a trigger
ingress route nor holds a listener.
- Enterprise needs no registration of its own: the listener process runs the same `lfx.toml` service discovery as
the API, so EE service overrides apply unchanged.
- Desktop Track B is real but single-user: the subprocess dies with the app, which is acceptable because Desktop
triggers only fire while Langflow is open. The frontend says so.

## Re-open trigger

- A customer needs Track B on `lfx serve`, or
- the subprocess mode proves unsafe under the default multi-worker API (fact 1) and has to become lease-elected too,
or
- Celery becomes a supported OSS execution backend, which would give listeners a supervisor that already exists.

Re-verify by: the 1.14 planning gate.

## Sign-off

| Role | Name | Date | PR |
|------|------|------|----|
| platform owner | | | |
| langflow-base owner | | | |
| Enterprise owner | | | |
| release owner | Eric Hare | 2026-09-05 | #14911 |
Loading
Loading