Skip to content
This project is part of pgEdge Labs and is under active development. APIs and features may change without notice.

Tasks and async operations

Most writes in this CLI are asynchronous: the API accepts the request, spawns a background task, and answers immediately. The command exits when the request is accepted, not when the work finishes, so an accepted request is not finished work. Without a wait flag, a create that later fails to provision still exits 0, and anything scripted should wait.

The wait flags

Four flags appear on every asynchronous command in all three modules:

  • --wait blocks until the operation's task reaches a terminal state, printing one status line per poll to stderr. In byoc and managed it then reads the resource until it reaches the write's end status, except on byoc ingress writes, where the task is all it waits for.
  • --follow blocks the same way but streams the task's step messages instead of bare status lines, one line per step transition.
  • --wait-timeout bounds the wait, in seconds.
  • --wait-interval sets the polling interval, in seconds.

The defaults and the bounds each module applies:

Module --wait-timeout --wait-interval Effect on --follow
byoc 600 5 Both flags apply, as they do to --wait
managed 600 5 Both flags apply, as they do to --wait
controlplane 600 3 Neither flag applies. See below

The Control Plane's --follow ignores both flags. It has no overall bound at all, and it polls on a fixed two-second cycle rather than on --wait-interval. Each individual log poll gets its own fixed 30 seconds, independent of --timeout, so --timeout 0 still stops a poll at 30 seconds and only a --timeout below 30 seconds shortens one. Both flags do reach --wait on those same commands, so read the pair as --wait's alone.

--timeout is a different bound again: it caps a single HTTP request in every module and defaults to 30 seconds. A wait's own polls are capped by the shorter of --timeout and the remaining wait deadline, so a hung request cannot outlive --wait-timeout and does not spend all of it either.

Exit codes when waiting

--wait and --follow share one contract: exit 0 when the task succeeds, 1 when it fails, and 3 when the bound expires. Read stderr for which bound fired, and the exit codes guide for what those three numbers mean everywhere else.

The failure path differs in two ways. --wait prints the task's own error message alongside the failure, while a Control Plane --follow reports only that the task failed and leaves the reason to task get. And on the Control Plane a task can also end canceled, a third terminal outcome that exits 1 like a failure everywhere except task cancel itself, where reaching it is the point.

On managed, a wait does not stop at the task. When the task succeeds, --wait and --follow read the resource until it reaches its end status, within the same --wait-timeout. A database must be available, a deleted database or branch must answer not found or read status deleted, and a new branch must be available. A resource that settles on another status, such as degraded, ends the wait at exit status 1, naming that status. database wait <database-id> applies the same rule without a write.

Exit 3 does not mean the command is safe to repeat. The write already reached the server, and the task is probably still running. Read the resource, or the task, before retrying anything.

Under -o json, a byoc or managed mutating command writes either nothing at all or the resource as it stood when the request was accepted. The writes whose API answer carries no body produce nothing, in every output format: every delete, plus byoc database restore and rotate-password and managed database resize and rotate-password. The rest write the accepted resource and never refresh it, so a failed create still shows the status it had on acceptance, carries no error, and prints stdout that is byte-identical whether the task succeeds or fails. Either way, gate on the exit code, or on a separate task get, and not on that object. Control Plane mutating commands write their accepted-response object in every wait mode, which is the same caution in a different shape. The output formats and paging guide covers which successes print nothing more generally.

Which writes are asynchronous

Every command below spawns a task and accepts --wait and --follow. Every read is synchronous, and so are the byoc metadata resources (ssh-key, cloud-account and cluster share) and both Starfleet modules' metadata-only database update. None of those carries either flag. byoc cluster update is asynchronous while byoc database update is not, because the first changes infrastructure and the second only changes a record.

The asynchronous commands in each module:

Module Asynchronous commands
byoc cluster create/update/delete, database create/delete/restore/rotate-password, database mcp/rag/postgrest deploy and update, database service remove, ingress create/delete, backup-store create/delete
managed database create/delete/resize/rotate-password, backup create/restore, database branch create/delete, database allowlist add/remove/set/open/clear, database mcp/rag/postgrest deploy and update, database mcp/rag apply, database service remove
controlplane database create/update/delete/restore/upgrade, database node backup/switchover/failover, database instance start/stop/restart, host remove, task cancel

byoc backup create looks asynchronous and is not served by a wait flag. It spawns no task the API exposes: the work runs through the Control Plane, the response carries no body, and there is nothing to poll from this CLI.

managed backup create waits on the backup record, not on a task. The write does spawn a task, but that task reaches its terminal state seconds before the backup record leaves pending. Its --wait therefore reads the record until it is completed, exit status 0, or failed, exit status 1.

Picking the right signal

A task finishing and a resource being usable are different facts, and the gap between them differs by command. The signal that terminates honestly for each managed write:

Write Signal to trust
database create --wait, which ends at exit status 0 when the database is available.
database delete --wait, which ends at exit status 0 when database get answers not found or reads status deleted.
database resize --wait. The status moves to modifying, and a degraded end is exit status 1.
database rotate-password --wait, with the timeout caveat below.
Any services write --wait, then ask the service itself, because state is not readiness (see below).
backup create --wait, which reads the backup record, not the task.
backup restore --wait, --follow or task get.

A succeeded task does not say which status resulted, so a byoc or managed --wait reads the status after the task. It ends at exit status 1 unless that status is the end status the write aims for.

A restore is the case where the task carries the answer. The API answers by putting the database into modifying before the work starts, and the mandatory pre-restore backup can refuse the whole restore after that point. That refusal arrives on the task, not as an error from the command, so a script sees it only through --wait, --follow or task get.

What --wait reports first on a services write is that write's task, found by subject the way every byoc and managed wait finds one. It then waits for the database to be available, but that status is the wrong question on its own. A failure that touched only the service leaves the database available, which is the same status a clean write produces. On managed a service that failed to roll out then reads state: failed. On BYOC a failed deploy records no state at all, so the entry keeps whatever an earlier write stored, or none. A failure that reached wider leaves the database degraded. So available after a services write does not mean the services came up. Take the task from --wait, then read the service's own state.

A service's state field is not a readiness signal in either module. It reads running as soon as the deploy completes, while the server itself is still answering 503. On BYOC the platform records the value when a write succeeds and does not refresh it afterward, so it reports on the last successful write rather than on the service now. Its one useful value is failed, and a running one proves nothing. To know a deployed service is ready, ask the service, and retry until it answers.

backup create on managed never moves the database's status, so polling the database's status after one proves nothing at all: it reads available immediately and always. Its --wait reads the backup record instead.

byoc's --wait ends on the resource's status as managed's does: a create, update, restore or rotation at available, a delete when the resource answers not found, and any other settled status at exit status 1 naming it. Its ingress create and delete are the exception, where the task is the signal. The one byoc write with no task at all is backup create, above.

The Control Plane is simpler. Every asynchronous command takes --wait and --follow, and the task is the signal for all of them.

How a wait finds its task

The Control Plane hands the task back. Its mutating commands answer with an accepted response carrying the task, print <command> task <id> accepted (<status>). to stderr, and poll exactly that task, so there is nothing to discover. Two commands word that line differently: host remove prints Host removal task <id> accepted (<status>). and task cancel prints Cancellation of task <id> requested.

No byoc or managed response carries a task identifier at all. Waiting there discovers the task by subject instead: before sending the write, the CLI reads the subject's newest task, and then tracks the first task that is not that one. That capture is also why --wait on the mutating command beats running task wait afterward: it is tracking the task from before the write, where task wait needs an ID that may not have appeared yet.

The two modules differ in what happens when that pre-mutation read fails:

  • byoc refuses the write. The read failed, so the write is not sent.
  • managed sends the write anyway, because you asked to write and not to read a task list, and falls back to an age floor: only a task created at or after the moment of the failed read, less a one-minute margin, is accepted as this write's.

That margin covers the API's one-second timestamp resolution and any clock skew between your machine and the server. If the real task never appears, the wait times out at exit 3 with a message naming the floor and saying the pre-mutation read failed. That message is also what a clock skewed by more than a minute looks like, so check the clock if you see it after a write that plainly succeeded.

Inspecting a task afterward

Every module has a task group, and tasks are scoped per product: a managed task is not visible under pgedge starfleet byoc task, and neither is visible from controlplane. Reach for one of these groups when no wait flag was passed, when the operation was someone else's, or when a wait ended in a timeout.

In byoc and managed the group is list, get and wait:

pgedge starfleet managed task list --subject-id <database-id>
pgedge starfleet managed task get <task-id>
pgedge starfleet byoc task wait <task-id> --follow

task list takes --subject-id, --subject-kind, --name, --status, --limit and --offset, and returns tasks newest first. In both modules the SUBJECT column reads <kind>/<id> rather than a bare ID, and the CREATED column carries a date with no time, so two tasks on one subject minutes apart look alike in the list. Use task get for the full timestamps.

--name is a free-form filter, not a closed set. An unknown name is not refused, it simply matches nothing at exit 0, which makes a typo indistinguishable from no such task. Read the names rather than guessing them:

pgedge starfleet managed task list --subject-id <database-id> -o json

Two managed names run against the pattern. A resize is update-managed-size, an infix rather than the suffix the others use, and every services write is update-managed, so the filter cannot tell an MCP deploy from a RAG update.

task wait attaches to a task the CLI did not start. It exits 0 on success, 1 on failure, 3 on timeout and 4 when no task has that ID. Exit 4 is immediate rather than a wait for the task to appear, so a task read straight after a mutation may need a moment before it can be waited on. --wait on the mutating command absorbs that delay and this command cannot, which is why --wait is the better choice for work you start yourself.

In text mode neither task wait nor a mutating command's --wait writes anything to stdout, on success or on failure, so gate on the exit code. Under -o json the two differ: task wait writes the task, so its status and error describe the outcome, while the mutating command writes the resource as it stood on acceptance. When a script needs the outcome as data rather than as an exit code, read it from task get or task wait.

task get is also where a failed byoc or managed operation explains itself. Text output prints the summary row, the full timestamps, the newest step with its status and progress, and the failure reason on a line of its own. -o json carries the whole per-step trace, of which the text form shows only the last entry.

Control Plane tasks

The Control Plane's group adds logs and cancel, and its scoping rules differ:

pgedge controlplane task list --database my-db
pgedge controlplane task get --database my-db <task-id>
pgedge controlplane task logs --database my-db <task-id>

task list works without a scope and lists everything newest first. Narrow it with --scope and --entity-id, or list one resource's tasks with --database or --host, which are mutually exclusive with each other and with the --scope pair. --limit caps the count and has no ceiling of its own, though zero and negative values are refused at exit 2. There is no --offset. --all reads every page of one database's or one host's tasks. The output formats and paging guide has the paging contract for every module.

task get and task logs are stricter: one of --database or --host is required, naming the task's scope entity, which is the ENTITY column of task list. Omitting both is a usage error at exit 2, reported before any request is made.

A failed Control Plane database create explains itself only on the task. The Control Plane records no reason on any instance of a failed create. A spec that fails during planning never creates an instance at all, so database get shows a failed database with nothing underneath it, and a failure after the instances exist leaves the database at failed with its instance rows still present. database get <database-id> --failure prints the latest task's reason under an Error heading without a task ID, and task logs carries the step-by-step trace.

controlplane task cancel is the only cancel command in the CLI. It covers database tasks only, --host is rejected, and --database is required. It is disruptive, so it prompts unless --force, and it is itself asynchronous, so --wait and --follow track the cancellation. This endpoint also answers with a bare task object rather than the wrapping envelope every other controlplane command uses, so under -o json the task's own keys sit at the top level of the document and a jq path needs one level fewer here than anywhere else.

A rotation that times out

A managed rotate-password timeout means the outcome is unknown, so read the task before you rotate again. While a rotation is in progress, the database reports modifying, and another rotation is refused at exit status 1 without starting a task. The job hands the new credential to the cluster first and only then waits for confirmation, and the cluster applies it independently, so a worker that died in between leaves a stuck task over a rotation the database may already have applied.

task get <task-id> is the discriminator. Read its status first: succeeded means the rotation finished. A stuck task and a running one both report the same status, so for a task still queued or running, compare updated_at against the current time, not against created_at. A rotation completes within seconds, so a task whose updated_at is minutes old has stopped progressing. created_at is the wrong baseline in both directions: a task that finished inside the API's one-second timestamp resolution shows the two equal and is healthy, while one that advanced once and then died shows them differing and is not.

An unwaited rotation returns no task identifier, so reach its task by name:

pgedge starfleet managed task list --subject-id <database-id> \
    --name rotate-password-managed

Taking the newest task of any name is wrong here: a database that has had more than one operation carries tasks of several names, and two stamped in the same second are not unusual.

Next steps