Core concepts
Six nouns run all of Setu: connections, datasets, pipelines, runs, agents, and secrets. Learn how they fit and you can read the rest of the docs fluently.
A migration in Setu reads like a sentence: a pipeline moves data from a source connection to a target connection; you pick which datasets to move; each execution is a run; and when a side lives in a private network, an agent runs that side using secrets that never leave the edge. Here is each noun in detail, grounded in the real types and SQL.
Connections & placement
A connection is a saved, testable handle to one system — a Postgres database, an S3 bucket, a local folder. It has a provider (the connector kind, renamed from kind in the dataset-centric migration) and a JSONB config. The config never stores plaintext credentials: it holds ${secret.NAME} references that are resolved at execution time. On the write path the config is persisted verbatim — the handler explicitly keeps secret refs intact and never seals plaintext into the row.
The field that makes Setu interesting is placement. Every connection is either cloud (Setu can reach it directly) or agent (it lives behind a bound agent, identified by agent_id). This one pair drives all routing — it is a pure function:
// stagePlacement says where one stage of a run executes: the cloud worker
// (Cloud=true) or a specific bound agent (Cloud=false, AgentID set).
type stagePlacement struct {
Cloud bool
AgentID uuid.UUID
}
// derivePlacement decides where extract and load run from the two connection
// placements. It is a pure function — no I/O, trivially unit-tested.
func derivePlacement(srcPlacement string, srcAgent uuid.UUID, dstPlacement string, dstAgent uuid.UUID) runPlacement {
return runPlacement{
Extract: stageFor(srcPlacement, srcAgent), // reads the source
Load: stageFor(dstPlacement, dstAgent), // writes the destination
}
}
func stageFor(placement string, agent uuid.UUID) stagePlacement {
if placement == "agent" {
return stagePlacement{Cloud: false, AgentID: agent}
}
return stagePlacement{Cloud: true}
}Because a pipeline’s extract and load are routed independently, the two placements combine into four topologies — all sharing the same blob seam:
| cloud → cloud | Both reachable. The worker does it all — direct in one pass for small deltas, or staged through blob for large ones. |
|---|---|
| agent → cloud | Extract runs on the agent and stages to blob· the cloud worker loads from that blob. The source’s credentials never leave the edge. |
| cloud → agent | The worker extracts and stages· a load job is handed to the destination’s agent, which loads from blob into a target the cloud can’t reach. |
| agent → agent | Both stages run on (possibly different) agents· the blob is the only shared medium between them. The cloud only orchestrates. |
A connection’s config is stored as-is. Here is an agent-placed Postgres config; the credential is a reference, meaningless without the agent’s local secret store:
{
"host": "10.0.4.12",
"port": 5432,
"database": "orders",
"user": "setu_ro",
"password": "${secret.ORDERS_DB_PASSWORD}",
"sslmode": "require"
}test agent_job and waits for the runtime to run it and report back. Discover and preview take the same round-trip: the cloud dispatches a discover/preview job and polls for the result.Datasets & the catalog
A dataset is one movable stream inside a source connection — a table, a view, a file, a collection, an API. You don’t create them by hand: discovery asks a source to list its streams, infers a schema for each, and registers them into the catalog. The catalog is the browsable inventory the mapper and pipeline builder read from. The row shape (migration 0006, plus primary_key in 0021):
| physical_name | The real name in the source (e.g. public.orders). UNIQUE (connection_id, physical_name). |
|---|---|
| type | The stream kind: table | view | file | collection | api. |
| schema | The inferred field list (name, type, nullability) stored as JSONB. |
| row_estimate | A cheap size hint used to plan and to preview. |
| primary_key | text[] — drives upsert/merge and gives a staged load its conflict target. |
| status | active | missing | archived — reconciliation flips a vanished stream to missing rather than deleting it. |
| tags · folder_id | User curation: GIN-indexed tags and a folder tree for organising the catalog. |
Discovery is reconciling, not additive: it upserts every stream it finds and sweeps away ones that vanished — all inside one transaction so the catalog is never left half-updated. There is a deliberate safety valve for a transient empty discovery:
for _, d := range discovered {
row, err := q.UpsertDataset(ctx, sqlc.UpsertDatasetParams{
ConnectionID: conn.ID, Provider: conn.Provider,
PhysicalName: d.PhysicalName, Type: d.Type,
Schema: d.Schema, RowEstimate: d.RowEstimate,
PrimaryKey: pk, WorkspaceID: workspaceID, // dataset inherits its connection's workspace
})
// ...
}
// Empty-discovery safety: never mass-flag the whole catalog missing on a
// transient empty discovery. Skip the sweep and report Missing=0.
if len(discovered) == 0 {
return Summary{Total: len(rows)}, rows, nil
}
missing, err := q.MarkDatasetsMissing(ctx, sqlc.MarkDatasetsMissingParams{
ConnectionID: conn.ID, Present: PresentNames(discovered),
})discover job. When the agent returns the streams, onAgentDiscoverResult runs the same catalog.Register reconciliation — identical upsert-plus-sweep, just fed from a connection the cloud never touched.Pipelines
A pipeline is the plan: a source, an ordered chain of operators (transform/validate), and a destination. Internally it’s stored as a canonical Document — “everything is an operator,” with the source and destination as terminal nodes wired by edges. The node/edge model is DAG-ready, but execution today is linear.
// Document is the JSON-serialised pipeline body stored in pipeline.spec.
type Document struct {
Version int `json:"version"`
Params map[string]any `json:"params,omitempty"`
Vars map[string]any `json:"vars,omitempty"`
Nodes []Node `json:"nodes"`
Edges []Edge `json:"edges,omitempty"`
}
// SyncConfig describes how a source reads across runs: a full re-read (default)
// or an incremental read bounded by a cursor column.
type SyncConfig struct {
Mode string `json:"mode,omitempty"` // "full" | "incremental"
CursorField string `json:"cursorField,omitempty"`
CursorByStream map[string]string `json:"cursorByStream,omitempty"` // per-stream override
}
type DestinationConfig struct {
ConnectionID string `json:"connectionId"`
Mode string `json:"mode,omitempty"` // append|overwrite|upsert|merge
SchemaPolicy string `json:"schemaPolicy,omitempty"` // create|strict
TargetPrefix string `json:"targetPrefix,omitempty"` // e.g. "staging_"
}Three settings on the terminal nodes govern semantics. Their allowed values come straight from the declarative DestinationSpec/SourceSpec the console renders forms from:
| write mode | append (add rows), overwrite (replace the target), upsert (insert-or-update on the primary key), or merge. |
|---|---|
| sync mode | full (re-read everything, the default) or incremental (read only rows past a cursor column, tracked per stream across runs via pipeline_state). |
| schema policy | create migrates the target schema to fit; strict requires it to already exist. |
Most pipelines are created from a source, a destination, and a mode — Setu builds the canonical straight-line Document for you with BuildLinear, then derives the denormalised source_connection_id/target_connection_id columns from the validated Document so the row and the spec can never disagree. A full Document may also be supplied directly:
{
"version": 1,
"nodes": [
{ "id": "source", "type": "source",
"config": { "connectionId": "…", "streams": ["public.orders"],
"sync": { "mode": "incremental", "cursorField": "updated_at" } } },
{ "id": "destination", "type": "destination",
"config": { "connectionId": "…", "mode": "upsert", "schemaPolicy": "create" } }
],
"edges": [ { "from": "source", "to": "destination" } ]
}Runs
A run is one execution of a pipeline. It carries a status lifecycle, a phase (where in the extract/load machine it is), a dry_run flag, live counters, an append-only event log, and — when data is staged — a run artifact pointing at the blob.
CREATE TYPE run_status AS ENUM ('pending','running','succeeded','failed','cancelled');
CREATE TABLE run (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
pipeline_id uuid NOT NULL REFERENCES pipeline(id) ON DELETE CASCADE,
status run_status NOT NULL DEFAULT 'pending',
phase text NOT NULL DEFAULT 'extract' -- 0019, widened by 0022
CHECK (phase IN ('extract','load','direct')),
rows_read bigint NOT NULL DEFAULT 0,
rows_written bigint NOT NULL DEFAULT 0,
rows_quarantined bigint NOT NULL DEFAULT 0, -- 0002
rows_total bigint NOT NULL DEFAULT 0, -- 0020: progress denominator
dry_run boolean NOT NULL DEFAULT false, -- 0002
cancel_requested boolean NOT NULL DEFAULT false, -- 0011: cooperative cancel
error text,
started_at timestamptz,
finished_at timestamptz
);status is the outer lifecycle; phase is the inner extract/load position. They are orthogonal — a run is running while its phase moves through extract then load (or sits at direct):
| phase | Progress through the E/L machine: extract → load, or direct. A resumed run reads its phase to pick up where it stopped — a run past extract loads purely from blob and never touches the source. |
|---|---|
| dry_run | Runs the full extract/transform but skips the write — for validating a pipeline safely. |
| run_event | The append-only live log (level, stream, message, running row counts) streamed to the console’s run theater over SSE. |
| run_artifact | A pointer to the staged blob prefix and its totals, plus an expires_at the GC sweeps, so a later stage (or a different host) can load it. |
| extract/load_checkpoint | Per-(run, stream) resume cursors: the last committed blob part and rows already loaded, so a retry doesn’t redo finished work. |
| cancel_requested | A cooperative cancel flag polled during the copy — a long load stops cleanly rather than being killed. |
The console watches a run by tailing its event log as Server-Sent Events until the run reaches a terminal status:
/api/v1/runs/{id}/eventssession · SSEStreams run_event rows as they append (700ms poll, ~10min cap), closing when status is succeeded, failed, or cancelled. Cancellation is a separate POST /runs/{id}/cancel that is idempotent — repeated clicks are harmless.
Staged vs direct execution
Setu has two ways to run a migration, and it auto-decides between them per run — you never configure it:
- Direct (
phase = direct) — stream rows straight from source through the operator chain to the destination in one pass, no blob. Chosen only when both sides are cloud-reachable and it’s an incremental sync whose delta is at or below a row threshold (small deltas don’t benefit from staging’s resume/replay, so the blob round-trip is pure overhead). - Staged (
phase = extract→load) — extract writes rows to the blob store as parts; a separate load reads them back into the destination. This is mandatory whenever the run crosses the cloud↔edge boundary (the blob is the only shared medium) and is used for large deltas even in-cloud, so a load can resume from its checkpoint without re-reading the source.
// directMaxRows: at/below this incremental delta, stream DIRECTLY (no blob).
// Default 50000 (~one blob part). Env: SETU_DIRECT_MAX_ROWS (0 disables direct).
func directMaxRows() int64 { /* env override, else 50000 */ }
// Only a fresh (still-extracting) run whose load is NOT pinned to an agent can go
// direct; a run resumed at the load phase must read from the staged blob.
if run.Phase == "extract" && !loadOnAgent {
forceDirect := !stagedEnabled() // SETU_STAGED_EXECUTION=off kill switch
autoDirect := false
if res.Incremental {
plans, todo, total, sized, err := w.directDelta(ctx, src, res, streams, run.PipelineID, ecs)
// ...
autoDirect = sized && total <= directMaxRows() // small delta → direct
}
if forceDirect || autoDirect {
return w.runDirect(runCtx, ctx, q, runID, src, dst, todo, res, run.DryRun, ...)
}
}
// Otherwise: extract the source into blob, set phase=load, then load from blob.The staged path also handles the cloud→edge hand-off. After the worker stages the extract, if the destination is agent-placed it dispatches a load job to that agent and returns a sentinel so the run isn’t marked succeeded — the run continues on the edge and completes when the agent reports its load result:
if run.Phase == "extract" {
// ...runExtract stages to blob...
q.SetRunPhase(ctx, sqlc.SetRunPhaseParams{ID: runID, Phase: "load"})
if loadOnAgent {
q.CreateAgentJob(ctx, sqlc.CreateAgentJobParams{
AgentID: uuid.UUID(dstConn.AgentID.Bytes), WorkspaceID: workspaceID,
Kind: "load", RunID: pgtype.UUID{Bytes: runID, Valid: true},
ConnectionID: pgtype.UUID{Bytes: dstID, Valid: true}, Payload: payload,
})
return runner.Stats{}, errHandedOffToAgent // run continues on the edge
}
}ws/<ws>/pl/<pipeline>/run/<run>/ so the reader finds exactly what the writer staged. It’s the same mechanism that makes runs resumable.Edge secrets
Credentials for agent-placed connections must never reach the cloud. Setu enforces this with ${secret.NAME} references: the control plane stores and ships the reference verbatim inside a job payload, and the agent resolves it locally at extract time from its own secret store.
// Secrets is the edge secret store: name→value. Connection configs pulled
// from the control plane carry ${secret.NAME} references (never plaintext);
// the agent resolves them from this map at extract time. Populated via
// `setu secret set <NAME> <VALUE>` and written 0600 under the user's config dir.
Secrets map[string]string `json:"secrets,omitempty"`The extract/load spec the agent fetches carries the config with references intact — the apiserver returns it verbatim and comments that it must never resolve secrets on the cloud side:
// Config is returned VERBATIM (secret refs intact). Never resolve here.
cfg := json.RawMessage(conn.Config)
writeJSON(w, http.StatusOK, extractSpecDTO{
RunID: runID,
BlobPrefix: runBlobPrefix(ws, run.PipelineID, runID),
Provider: conn.Provider,
Config: cfg, // {secret.X{'}'} still unresolved
Streams: streams,
Blob: engineblob.SpecFromEnv(), // same backend the cloud reads back from
})Cloud connections resolve their references the mirror-image way. The apiserver’s secrets.Resolver reads the workspace’s secret table — values sealed with AES-256-GCM (ciphertext + nonce, never plaintext) — and substitutes them just before building the connector. So ORDERS_DB_PASSWORD exists as plaintext only on the host that ran setu secret set; a cloud secret exists as plaintext only transiently inside the apiserver at build time.
Agents
An agent is a single static binary a customer runs inside their own network. It has no inbound ports: it dials out to the control plane, enrolls once, then long-polls for jobs and runs the same engine locally.
| enroll | Exchange a one-time enrollment token for an agent key. The token is consumed atomically; the returned key is shown once and only its SHA-256 is stored server-side. |
|---|---|
| run | Long-poll /agent/v1/jobs/next, lease a job, fetch its extract/load spec, execute via the engine, and post the result. |
| kinds | test, discover, preview (interactive, connection-scoped) and extract, load (run-scoped data movement). |
| liveness | Each authenticated call touches last_seen_at; the orchestrator treats an agent as reachable only if it checked in within ~90s, and fails a run fast (or skips a schedule) when its runtime is offline. |
Schedules
A pipeline can run automatically on a cron cadence. A pipeline_schedule is one row per pipeline (PK = pipeline_id) with a 5-field cron, an enabled flag, an optional dry_run, and a next_run_at the scheduler polls. A partial index makes the firing predicate cheap:
CREATE TABLE pipeline_schedule (
pipeline_id uuid PRIMARY KEY REFERENCES pipeline(id) ON DELETE CASCADE,
cron text NOT NULL, -- 5-field cron expression
enabled boolean NOT NULL DEFAULT true,
dry_run boolean NOT NULL DEFAULT false,
next_run_at timestamptz, -- NULL when disabled
last_run_at timestamptz
);
CREATE INDEX pipeline_schedule_due_idx
ON pipeline_schedule (next_run_at)
WHERE enabled AND next_run_at IS NOT NULL;Firing goes through the same placement-aware path as a manual run (FireSchedule → orchestrateRun). One important difference: if the pipeline’s bound runtime is offline, a scheduled occurrence does not create a run destined to fail — it notifies the workspace owners and skips, catching up on the next cadence once the agent is back. Because all scheduler state lives in Postgres, an external cron can also force a pass via POST /internal/scheduler/tick — so due schedules still fire even if a free-tier host slept through its in-process ticker.