Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docker-compose.published.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ name: durable-workflow-server
# release whose default names another image). Set DW_SERVER_IMAGE to run
# any other native image, by tag or digest; pin a digest for production
# change control.
x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:3.8.0}
x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:3.9.0}

services:
server:
Expand Down
192 changes: 192 additions & 0 deletions docs/releases/3.9.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
# Durable Workflow Server 3.9.0 (native)

Server 3.9.0 makes a signal or update `request_id` a durable command key, has
a run's own close settle the activities and timers it left open, and honors
storage admission. Two of these change behavior a client can observe; see
[Behavior changes](#behavior-changes). Its exact images are
`durableworkflow/server:3.9.0` and `ghcr.io/durable-workflow/server:3.9.0`;
the published Compose default selects 3.9.0. Rolling aliases move separately
after published-image qualification.

## Upgrade

**Schema upgrade required on every backend.** This release adds one
native-only table, `dw_command_intents`, which is created empty:

| Backend | Schema |
|------------|---------|
| SQLite | 12 → 13 |
| PostgreSQL | 13 → 14 |
| MariaDB | 13 → 14 |
| MySQL | 14 → 15 |

The step only creates the table; it rewrites no rows and takes seconds. A
server never upgrades its schema at startup. Started on the earlier schema,
3.9.0 refuses with `native_schema_upgrade_required`.

For the single-node Compose deployment:

1. Stop the Server and leave MySQL running:

```bash
docker compose --env-file durable-workflow.prod.env \
-f docker-compose.published.yml stop server
```
2. Back up MySQL and `server_storage`, and record the current image
reference. This backup is the only way back to 3.8.x.
3. Change `DW_SERVER_IMAGE` to the 3.9.0 image, pull it and run
`schema-upgrade` once:

```bash
docker compose --env-file durable-workflow.prod.env \
-f docker-compose.published.yml pull server
docker compose --env-file durable-workflow.prod.env \
-f docker-compose.published.yml run --rm --no-deps server schema-upgrade
```

From 3.8.x on MySQL it reports
`"from_schema_version":14,"schema_version":15,"status":"upgraded"`. Run
again, it reports `"status":"current"`.
4. Start the Server with
`docker compose --env-file durable-workflow.prod.env -f docker-compose.published.yml up -d --wait`,
then confirm `/api/ready`, `/api/cluster/info` (`version` is `3.9.0`) and
worker registration.

With several Servers on one database, stop every one of them before step 3
and start them all on 3.9.0 afterwards. On MySQL and MariaDB, an interrupted
upgrade resumes when you run `schema-upgrade` again; SQLite and PostgreSQL
upgrade in one transaction.

**Rolling back to 3.8.x requires restoring the backup.** 3.8.0 refuses the
upgraded database with `native_schema_newer_than_binary`.

**From earlier releases,** the 3.9.0 image's `schema-upgrade` applies every
earlier step in the same run. From 3.7.x that includes the
[3.8.0 step](3.8.0.md#upgrade). On MySQL from 3.6.x or earlier (schema 12 or
below), it includes the [3.7.0 JSON rebuild](3.7.0.md#upgrade), with its
downtime of about 2 minutes per million history rows.

On Kubernetes, scale the Server to zero, back up, run `helm upgrade` with
`native.schemaUpgrade.enabled=true` so the 3.9.0 image runs `schema-upgrade`
once, then scale back up. Chart 1.7.0, which defaults to Server 3.9.0, adds
its notes to the chart's
[upgrade notes](../../k8s/helm/durable-workflow/docs/UPGRADING.md).

The worker protocol stays at `1.20` and the control plane at version `2`.

## Behavior changes

### A signal or update `request_id` is a durable command key

A signal or update `request_id` now names one command on its workflow
instance ([#934](https://github.com/durable-workflow/server/issues/934)).
Cluster info advertises `capabilities.command_request_keys: true`.

- A retry with the same command intent returns the original receipt,
including its `command_id`, `run_id` and, for an update, `update_id`, and
writes nothing. The receipt is found before any run gate, so a retry after
the run closed or continued as new still gets it. The intent is Core's
CommandIntent v1 digest over the command type, name, typed arguments and
explicit run target, so `1`, `1.0` and `"1"` differ and map key order does
not.
- The same key with a different name, arguments or run target is refused with
`409`, `reason: "request_id_conflict"` and
`outcome: "rejected_request_id_conflict"`. The response names the differing
part in `conflict` and the original command in `retained_command_id`.
Nothing is written. **Do not retry this 409 unchanged:** it means the key
was reused for different input. Send the original input to recover the
receipt, or use a new key.
- The key is stored hashed and the intent as its digest, never the arguments.
Both live as long as the command; retention removes them with the run's
details.

**This differs from PHP 2.x,** which recorded a second signal for a repeated
signal `request_id` and returned the original update receipt for a retry with
different arguments. Clients that reuse one `request_id` for different input
get the 409 on native. sdk-php 3.17.0 surfaces it as a typed conflict
(`SignalFailed::conflict()` / `UpdateFailed::conflict()`), and the coming
sdk-rust 4.15.0 reports the key it sent; none of the SDKs resend on 409. See
[Behavior that differs from PHP 2.x](../rust-server-upgrade-takeover.md#behavior-that-differs-from-php-2x).

### A run's own close settles its open activities and timers

When a run's own code closes it with `complete_workflow`, `fail_workflow` or
`continue_as_new`, the close now cancels the activities and timers it still
has open, in the same transaction and before the terminal event, as Workflow
2.28.0 does ([#1193](https://github.com/durable-workflow/server/issues/1193),
Core workflow#1117).

- Each open activity, such as a `select()` loser the workflow never awaited,
gets `ActivityCancelled` with `cancellation_reason: "run_closed"`. Each
pending timer, a wait's timeout timer included, gets `TimerCancelled`.
Nothing is appended after the terminal event.
- A late heartbeat or lease renewal for such an activity answers
`reason: "run_closed"` with `can_continue: false`. A late `complete` or
`fail` gets `409` with `outcome: "ignored"`, `recorded: false`,
`reason: "run_closed"` and `activity_status: "cancelled"`. Workers treat it
as final.
- Replay ignores an `ActivityCancelled` whose `cancellation_reason` is
`run_closed`; the workflow never observed it. sdk-php 3.17.0 does this, as do
sdk-python 2.17.0 and sdk-rust 4.15.0 (in release).
- Native refuses the `abandon` cancellation policy, so it never keeps an
activity running past its run's close. Children are unchanged: they follow
their parent-close policy.

PHP 2.x left this work open, so the activity still ran and its history was
appended after the terminal event. **A workflow that needs a loser's effect
must await it before returning.**

## Storage admission

Native now honors the operator storage-pressure observation that PHP Server
documents ([#951](https://github.com/durable-workflow/server/issues/951)).
It is off unless `DW_STORAGE_ADMISSION_FILE`, `DW_STORAGE_ADMISSION_SOURCE` and
`DW_STORAGE_ADMISSION_MAX_AGE_SECONDS` (1 to 300) are set.

- `normal` admits everything. `draining` refuses starts, signals, updates,
queries, registrations, uploads, polls and other new work with `503`,
`Retry-After: 5` and `storage_pressure`, while existing leases still
complete, fail, heartbeat and renew. `fenced`, and any missing, malformed,
stale, wrong-source or untrusted observation, keeps only reads.
- A long poll that is already waiting answers the poll refusal at its next
probe. The maintenance loop starts no pass under pressure.
- `/api/ready` reports pressure as not ready; `/api/health` keeps its
database meaning. Refusals never expose the file path or source.
- The file is opened without following symlinks and checked on the same
descriptor. Native does not yet serve the draining completion-upload
allowance, so an upload is refused like other new work; a worker keeps its
lease and uploads after pressure clears.

See [Storage Admission](../storage-admission.md#native-server).

## Other changes

- Worker command names longer than their database columns are refused with
the typed `422 validation_failed` naming `commands.N.<field>`, before any
write ([#607](https://github.com/durable-workflow/server/issues/607)):
`activity_type`, `signal_name`, `condition_key` and
`condition_definition_fingerprint` at 191 characters; `queue`,
`connection` and `workflow_type` at 255; a child's `workflow_type` and
`queue` at 191. Before, MySQL and PostgreSQL failed such a completion with a
database error and the task retried, while **SQLite stored the over-long
name**; on SQLite such a completion is now refused.
- `DW_MAX_HISTORY_EVENTS` has no effect, and the reference now says so
([#504](https://github.com/durable-workflow/server/issues/504)). The Server
does not send `invocable_http` activity requests, and the reference now says
so too ([#527](https://github.com/durable-workflow/server/issues/527)).
- Native GitHub Releases carry the tagged release notes after the identity
table ([#1111](https://github.com/durable-workflow/server/issues/1111)).
- Tests now cover numeric-string names, selected-build routing and bounded
observation for declared worker command contracts
([#436](https://github.com/durable-workflow/server/issues/436)).
- The two unproved APT paths in conformance and performance images run with
APT retries off (durable-workflow/.github#295).

## SDKs

- sdk-php 3.17.0 is out: typed `request_id` conflicts, replay that ignores
`run_closed` cancellations, and a one-time structural-limit failure for an
oversized completion.
- sdk-python 2.17.0 and sdk-rust 4.15.0 (in release) add the same handling:
`run_closed` replay, the structural-limit failure, and, in sdk-rust, the
key it sent on a `request_id` conflict.
Loading