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
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ on:
description: Run the reviewed published SDK/native timer-redrive tuple
type: boolean
default: false
activity_retry_cap:
description: Run the reviewed installed scalar local retry-cap case
type: boolean
default: false
retry_cap_artifact_source:
description: Select the approved build candidate or its identical registry wheel
type: choice
options: [candidate, registry]
default: candidate

permissions:
contents: read
Expand All @@ -36,6 +45,58 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
activity-retry-cap:
if: github.event_name == 'workflow_dispatch' && inputs.activity_retry_cap
needs: [action-policy, lint]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
actions: read
env:
RETRY_CAP_ARTIFACT_SOURCE: ${{ inputs.retry_cap_artifact_source }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.12.16"
- name: Resolve the exact approved build candidate
if: inputs.retry_cap_artifact_source == 'candidate'
id: retry-candidate
env:
GH_TOKEN: ${{ github.token }}
run: python .github/scripts/promote_release.py resolve --version 2.18.0
- name: Download the approved candidate handoff
if: inputs.retry_cap_artifact_source == 'candidate'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ steps.retry-candidate.outputs.artifact-id }}
run-id: ${{ steps.retry-candidate.outputs.run-id }}
github-token: ${{ github.token }}
repository: ${{ github.repository }}
path: .release-candidate
archive: false
- name: Verify candidate archive and distribution bytes
if: inputs.retry_cap_artifact_source == 'candidate'
run: python .github/scripts/promote_release.py extract --version 2.18.0
- name: Bootstrap patched pip trust
uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust
with:
clients: pip,python
- name: Run the approved scalar local retry case
run: >-
python scripts/ci/published-activity-acceptance.py
--scenario retry-cap --artifact-source "$RETRY_CAP_ARTIFACT_SOURCE"
- name: Retain exact package and retry evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: activity-retry-cap-${{ github.sha }}
path: published-activity-results/
retention-days: 7
published-timer-redrive:
if: github.event_name == 'workflow_dispatch' && inputs.published_timer_redrive
needs: [action-policy, lint]
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.18.0] - 2026-11-14

### Added

- `ActivityRetryPolicy.exponential(...)` creates generated activity retry schedules with a finite 60-second default maximum. It rejects `None` or nonfinite maxima and retains the existing wire shape and interval rounding. The legacy constructor, explicit backoff lists and policies already recorded in history keep their original values. (durable-workflow/.github#219)
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "durable-workflow"
version = "2.17.2"
version = "2.18.0"
description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
readme = "README.md"
requires-python = ">=3.10"
Expand Down Expand Up @@ -79,8 +79,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"

[tool.durable-workflow]
product-train = "2.17.2"
registry-version = "2.17.2"
product-train = "2.18.0"
registry-version = "2.18.0"
supported-server-versions = "2.8.0"
supported-server-release-baselines = { "2" = "2.8.0", "3" = "3.0.0" }
worker-protocol-version = "1.20"
Expand Down
46 changes: 46 additions & 0 deletions scripts/ci/activity-retry-cap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
The retry-cap case qualifies the exact reviewed 2.18.0 wheel on the published
Server/Python tuple in `tests/fixtures/published-retry-cap.json`. It uses the
existing internal two-container profile, complete hash lock, captured no-site
entry, before/after installed inventory and independent ID/label cleanup.
It adds no host mount, port, capability or alternate local-activity carrier.

The release approval record supplies the SDK hash. Candidate mode reuses the
protected publication resolver and archive extractor to bind the successful
same-repository build, source/tree, handoff artifact and both distributions.
The controller captures that record from Git, reads the wheel through a held
descriptor and checks its approved hash again. Only the 14 already locked
dependencies are downloaded from PyPI. Registry mode downloads all 15 locked
wheels and requires the identical approved SDK hash. The final private installer
always receives the complete 15-package hash lock and has no network fallback.

After source, artifact, runner and accepted-main gates are complete, select one
bounded candidate invocation through the ordinary workflow:

```bash
gh workflow run ci.yml --ref main -f activity_retry_cap=true -f retry_cap_artifact_source=candidate
```

After protected publication, select `retry_cap_artifact_source=registry` for the
normal-registry repeat. Neither invocation runs in ordinary push/PR CI.

Three real scalar local executions exercise the factory's default cap, a short
explicit list and an empty list. The generated policy has two total attempts,
an initial interval of 61 seconds and the factory's omitted maximum, so its
single retry must wait at least 60 seconds. Five attempts with `[1, 2]` select
`1, 2, 2, 2`; the empty list selects zero waits. Monotonic callback observations
independently reject shortened nonzero waits, with a 10-millisecond clock
tolerance. No upper worker-start guarantee is inferred from scheduling time.

The case binds complete ordered attempt/callback counts, reported backoffs,
accepted command kinds and actual retained public activity snapshots/retry
events to the selected policies. Each committed history replays with a callback
that refuses execution. Public exports bind the requested workflow/run and
unredacted retained state. Every source history is re-read unchanged.

Each Worker has a 100-second or 30-second SDK budget. The complete selected
pytest case is bounded at 180 seconds within the existing 240-second consumer
exchange and six-minute controller workload. Independent cleanup retains its
per-call bounds. The seven-day artifact contains the wheel set, complete
effective lock, source/tuple hashes, command/history/timing observations,
installed inventory and full-ID/label absence. Failures remain NONPASS; source
or virtual-sleep controls do not qualify an installed tuple.
10 changes: 8 additions & 2 deletions scripts/ci/activity_acceptance_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
CASE_FILES = {
"activity-identity": {"test_activity_identity.py"},
"timer-redrive": {"test_native_timer_redrive.py", "redrive_timer_clock_workflow.py"},
"retry-cap": {"test_activity_retry_cap.py"},
}


Expand Down Expand Up @@ -247,7 +248,10 @@ def entry_source(mode, case_name, case_sha, installed, worker_entry=""):

def execute_case(verifier, python, packet, before, environment, admission, installed):
redrive = packet["scenario"] == "timer-redrive"
test_name = "test_native_timer_redrive.py" if redrive else "test_activity_identity.py"
test_name = {
"timer-redrive": "test_native_timer_redrive.py", "retry-cap": "test_activity_retry_cap.py",
"activity-identity": "test_activity_identity.py",
}[packet["scenario"]]
environment.update(
{
"DURABLE_WORKFLOW_SERVER_URL": admission["server_url"],
Expand All @@ -258,6 +262,8 @@ def execute_case(verifier, python, packet, before, environment, admission, insta
)
if redrive:
environment["DURABLE_WORKFLOW_REDRIVE_CELL"] = "1"
if packet["scenario"] == "retry-cap":
environment["DURABLE_WORKFLOW_RETRY_CAP_CELL"] = "1"
worker_entry = entry_source("worker", test_name, packet["files"][test_name]["sha256"], installed)
parent_entry = entry_source("pytest", test_name, packet["files"][test_name]["sha256"], installed, worker_entry)
result = subprocess.run(
Expand All @@ -273,7 +279,7 @@ def execute_case(verifier, python, packet, before, environment, admission, insta
input=json.dumps({"inventory": installed}),
capture_output=True,
text=True,
timeout=180 if redrive else 90,
timeout=90 if packet["scenario"] == "activity-identity" else 180,
)
after = inventory(verifier, python, packet)
if after != before:
Expand Down
34 changes: 34 additions & 0 deletions scripts/ci/activity_acceptance_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Bind the retry case to the independently reviewed SDK release bytes."""

from __future__ import annotations

import hashlib
import re
from pathlib import Path

from scripts.ci.activity_acceptance_entry import captured_file

VERSION = "2.18.0"
WHEEL = f"durable_workflow-{VERSION}-py3-none-any.whl"
SDIST = f"durable_workflow-{VERSION}.tar.gz"


def lock(dependencies: bytes, approval: dict) -> tuple[bytes, str]:
if approval["version"] != VERSION or set(approval["files"]) != {WHEEL, SDIST}:
raise ValueError("retry case requires its exact reviewed release")
digest = approval["files"][WHEEL]
if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None:
raise ValueError("retry case requires the approved wheel digest")
if re.search(rb"(?im)^[ \t]*durable[-_]workflow\b", dependencies):
raise ValueError("retry dependency lock already contains an SDK selection")
result = dependencies.rstrip() + f"\ndurable-workflow=={VERSION} --hash=sha256:{digest}\n".encode()
return result, digest


def candidate_wheel(root: Path, digest: str) -> bytes:
# The protected download/extract steps validate the same-repository build
# and complete handoff archive. This read binds the consumed bytes again.
data = captured_file(root / "dist" / WHEEL)
if hashlib.sha256(data).hexdigest() != digest:
raise ValueError("candidate wheel differs from the approved release")
return data
49 changes: 42 additions & 7 deletions scripts/ci/published-activity-acceptance.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Run one fixed published SDK acceptance case inside an owned internal network."""
"""Run one fixed approved SDK acceptance case inside an owned internal network."""

from __future__ import annotations

Expand All @@ -19,6 +19,7 @@

try:
from scripts.ci import activity_acceptance_output as output_files
from scripts.ci import activity_acceptance_release as release
from scripts.ci import activity_acceptance_runtime as runtime
from scripts.ci.activity_acceptance_buildx import BuildxState
from scripts.ci.activity_acceptance_session import Session
Expand All @@ -27,6 +28,7 @@
raise
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from scripts.ci import activity_acceptance_output as output_files
from scripts.ci import activity_acceptance_release as release
from scripts.ci import activity_acceptance_runtime as runtime
from scripts.ci.activity_acceptance_buildx import BuildxState
from scripts.ci.activity_acceptance_session import Session
Expand All @@ -40,13 +42,17 @@
ID_FORMAT = "{{.ID}}"
DIAGNOSTIC_LIMIT = 64 * 1024
SCENARIO = "activity-identity"
ARTIFACT_SOURCE = "registry"
SCENARIOS = {
"activity-identity": (
"test_activity_identity.py", "test_retry_preserves_received_execution_id_and_changes_attempt_id",
),
"timer-redrive": (
"test_native_timer_redrive.py", "test_cold_workers_preserve_fractional_timer_clock_across_repeated_redrive",
),
"retry-cap": (
"test_activity_retry_cap.py", "test_installed_local_retries_preserve_cap_tail_and_committed_replay",
),
}
# No inherited context, registry credentials, proxy, SSH or remote Docker endpoint.
# This kernel directory is private to each CLI process and contains no config.json.
Expand Down Expand Up @@ -154,18 +160,25 @@ def capture_source(report):
if re.fullmatch(r"[0-9a-f]{40}", commit) is None or os.environ.get("GITHUB_SHA", commit) != commit:
raise ValueError("checkout differs from the selected Actions commit")
paths = {
REQUIREMENTS: "scripts/ci/published-activity-requirements.txt",
REQUIREMENTS: "scripts/ci/" + (
"retry-cap-dependencies.txt" if SCENARIO == "retry-cap" else "published-activity-requirements.txt"
),
VERIFIER_FILENAME: "scripts/ci/verify_activity_install.py",
SCENARIOS[SCENARIO][0]: "tests/integration/" + SCENARIOS[SCENARIO][0],
"conftest.py": "tests/integration/conftest.py",
"bootstrap.py": "scripts/ci/activity_acceptance_consumer.py",
"entry.py": "scripts/ci/activity_acceptance_entry.py",
"tuple.json": "tests/fixtures/" + (
"published-timer-redrive.json" if SCENARIO == "timer-redrive" else "published-activity-acceptance.json"
{"timer-redrive": "published-timer-redrive.json", "retry-cap": "published-retry-cap.json"}.get(
SCENARIO, "published-activity-acceptance.json"
)
),
}
if SCENARIO == "timer-redrive":
paths["redrive_timer_clock_workflow.py"] = "tests/integration/redrive_timer_clock_workflow.py"
if SCENARIO == "retry-cap":
paths["release.json"] = f".github/release-candidates/{release.VERSION}.json"
paths["release-loader.py"] = "scripts/ci/activity_acceptance_release.py"
captured = {
name: call("git", "show", commit + ":" + path, cwd=ROOT).stdout.encode() for name, path in paths.items()
}
Expand All @@ -175,10 +188,21 @@ def capture_source(report):


def download_wheels(selected, captured, files):
if ARTIFACT_SOURCE == "candidate" and SCENARIO != "retry-cap":
raise ValueError("candidate artifacts require the fixed retry-cap case")
approval_path = ".github/release-candidates/" + selected["sdk_version"] + ".json"
approval = json.loads(call("git", "show", "HEAD:" + approval_path, cwd=ROOT).stdout)
wheel_name = next(name for name in approval["files"] if name.endswith(".whl"))
dependency_lock = captured[REQUIREMENTS]
if SCENARIO == "retry-cap":
if selected["sdk_version"] != release.VERSION or approval != json.loads(captured["release.json"]):
raise ValueError("retry release selection differs from captured authority")
captured[REQUIREMENTS], _digest = release.lock(dependency_lock, approval)
files.write(REQUIREMENTS, captured[REQUIREMENTS])
download_requirements = REQUIREMENTS
if ARTIFACT_SOURCE == "candidate":
download_requirements = "retry-dependencies.txt"
files.write(download_requirements, dependency_lock)
files.check()
wheelhouse = OUTPUT / "wheels"
wheelhouse.mkdir(mode=0o700)
Expand All @@ -201,11 +225,16 @@ def download_wheels(selected, captured, files):
"--dest",
str(wheelhouse),
"-r",
str(OUTPUT / REQUIREMENTS),
str(OUTPUT / download_requirements),
env=environment,
)
files.write("download.log", result.stdout + result.stderr)
files.check()
if ARTIFACT_SOURCE == "candidate":
data = release.candidate_wheel(ROOT, approval["files"][wheel_name])
descriptor = os.open(wheelhouse / wheel_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
with os.fdopen(descriptor, "wb") as output:
output.write(data)
wheels = {}
for path in sorted(wheelhouse.iterdir()):
if path.is_symlink() or not path.is_file() or path.suffix != ".whl":
Expand Down Expand Up @@ -489,7 +518,7 @@ def run_case(owned, selected, images, packet, bootstrap, report):
if ready["sdk_inventory"] != result["sdk_inventory"]:
raise ValueError("installed SDK bytes changed during the case")
owned.files.write("sdk-inventory.json", json.dumps(result["sdk_inventory"], indent=2))
name = "timer-redrive" if SCENARIO == "timer-redrive" else "identity"
name = "identity" if SCENARIO == "activity-identity" else SCENARIO
owned.files.write(name + ".log", result["case_output"])
owned.files.write(name + ".xml", result["junit"])
report["consumer_ready"] = {key: value for key, value in ready.items() if key != "sdk_inventory"}
Expand All @@ -504,7 +533,8 @@ def run_case(owned, selected, images, packet, bootstrap, report):

def main():
files = output_files.Output(OUTPUT)
report = {"complete": False, "scenario": SCENARIO, "started_at": datetime.now(timezone.utc).isoformat()}
report = {"complete": False, "scenario": SCENARIO, "artifact_source": ARTIFACT_SOURCE,
"started_at": datetime.now(timezone.utc).isoformat()}
owned = OwnedDocker(uuid4().hex, files, report)
signal.signal(signal.SIGALRM, deadline)
signal.alarm(360)
Expand All @@ -514,6 +544,7 @@ def main():
report["tuple"] = selected
report["engine"] = engine()
packet = download_wheels(selected, captured, files)
report["requirements_sha256"] = packet["files"][REQUIREMENTS]["sha256"]
report["wheel_sha256"] = packet["wheel_sha256"]
report["wheel_set"] = {name: item["sha256"] for name, item in packet["wheels"].items()}
images = {role: verify_published_image(selected, role, files) for role in ("runtime", "consumer")}
Expand All @@ -537,5 +568,9 @@ def main():
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--scenario", choices=tuple(SCENARIOS), default=SCENARIO)
SCENARIO = parser.parse_args().scenario
parser.add_argument("--artifact-source", choices=("registry", "candidate"), default=ARTIFACT_SOURCE)
arguments = parser.parse_args()
SCENARIO, ARTIFACT_SOURCE = arguments.scenario, arguments.artifact_source
if ARTIFACT_SOURCE == "candidate" and SCENARIO != "retry-cap":
parser.error("candidate artifacts are accepted only by the fixed retry-cap case")
main()
Loading
Loading