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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ coverage.xml
.phpunit.result.cache
.php_cs.cache
.php-cs-fixer.cache
.vs
.vs
/.regression-corpus-consumer-*/
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@

## Unreleased

- Redrive a failed run whose workflow applied an update before the failure.
The successor records the update's acceptance, application and completion
with `reused_from_run_id` and `reused_recorded_at`, ahead of the step it
ran before, and replay runs the update handler again from its recorded
arguments so the workflow state matches. The update is not delivered again.
A rejected update, and one the failure closed before it ran, are not reused.
Only three update shapes are accepted, in history order: a rejection, an
acceptance closed by the failure, or an acceptance, application and
successful completion with identical arguments. Every event in each shape
must name the same update, command and handler and belong to the source run.
An acceptance closed by the failure must record a terminal reason and a
failure, with no result and no step. An update the source
accepted but never resolved is refused with `unsupported_pending_update`,
any other shape or binding with `mismatched_update_history` or
`incomplete_update_step`, an externally stored update payload with
`external_update_payload_not_supported`, and an inline payload that can't be
decoded with `unavailable_update_payload`. A run a service worker started
that applied an update is refused with `service_redrive_history_not_qualified`
(#864).
- Redrive a successor that itself reused signal waits or updates. A reused
payload keeps the run that first recorded it, and `reused_from_run_id` names
the immediate source, so a chain of redrives reuses each step once.
`reused_from_run_id` and `reused_recorded_at` come as a pair on every reused
update and signal event. A reused event keeps its original time, and a
history where one key is missing, blank or names another source is refused.
Any `external_storage` key on a signal payload counts as external, even a
null one. Before,
redriving such a successor was refused with `mismatched_signal_receipt`
(#864).

## 2.19.0 - 2026-11-03

- Rollback: releases before 2.19.0 ignore `reused_recorded_at` on the signal,
Expand Down
6 changes: 3 additions & 3 deletions docs/api-stability.md
Original file line number Diff line number Diff line change
Expand Up @@ -754,10 +754,10 @@ class, source file path, or stack trace to replay or handle a failure.
| `SignalRejected` | `workflow_command_id`, `signal_id`, `signal_name`, `signal_wait_id`, `command_sequence`, `rejection_reason`, `terminal_reason` | `PendingSignalCloser`, `RunSignalView`, `HistoryTimeline` |
| `SignalWaitOpened` | `signal_name`, `signal_wait_id`, `sequence`, `timeout_seconds`, `cancellation_scope_id` (candidate 1.20), `reused_from_run_id`, `reused_recorded_at` | `WorkflowStepHistory`, `SignalWaits`, `RunSignalView`, `HistoryTimeline` |
| `SignalWaitCancelled` (candidate 1.20) | `signal_name`, `signal_wait_id`, `sequence`, `timeout_seconds`, `timer_id`, `cancelled_at`, `cancellation_scope` | `ScopedWaitCancellation`, `SignalWaits`, `HistoryTimeline`, scope delivery and worker history consumers |
| `UpdateAccepted` | `workflow_command_id`, `update_id`, `workflow_instance_id`, `workflow_run_id`, `update_name`, `arguments`, `ordering_state`, `queued_behind_command_id`, `queued_behind_command_sequence`, `queued_behind_command_type` | `RunUpdateView`, worker history payload consumers |
| `UpdateAccepted` | `workflow_command_id`, `update_id`, `workflow_instance_id`, `workflow_run_id`, `update_name`, `arguments`, `ordering_state`, `queued_behind_command_id`, `queued_behind_command_sequence`, `queued_behind_command_type`, `reused_from_run_id`, `reused_recorded_at` | `RunUpdateView`, worker history payload consumers |
| `UpdateRejected` | `workflow_command_id`, `update_id`, `workflow_instance_id`, `workflow_run_id`, `update_name`, `arguments`, `validation_errors` | `RunUpdateView`, `HistoryTimeline`, command-contract projections |
| `UpdateApplied` | `workflow_command_id`, `update_id`, `workflow_instance_id`, `workflow_run_id`, `update_name`, `arguments`, `sequence` | `QueryStateReplayer`, `RunUpdateView`, worker history payload consumers |
| `UpdateCompleted` | `workflow_command_id`, `update_id`, `workflow_instance_id`, `workflow_run_id`, `update_name`, `sequence`, `result`, `failure_id`, `failure_category`, `non_retryable`, `exception_type`, `exception_class`, `message`, `code`, `exception`, `terminal_reason` | `RunUpdateView`, worker history payload consumers |
| `UpdateApplied` | `workflow_command_id`, `update_id`, `workflow_instance_id`, `workflow_run_id`, `update_name`, `arguments`, `sequence`, `reused_from_run_id`, `reused_recorded_at` | `QueryStateReplayer`, `RunUpdateView`, worker history payload consumers |
| `UpdateCompleted` | `workflow_command_id`, `update_id`, `workflow_instance_id`, `workflow_run_id`, `update_name`, `sequence`, `result`, `failure_id`, `failure_category`, `non_retryable`, `exception_type`, `exception_class`, `message`, `code`, `exception`, `terminal_reason`, `reused_from_run_id`, `reused_recorded_at` | `RunUpdateView`, worker history payload consumers |
| `ConditionWaitOpened` | `condition_wait_id`, `condition_key`, `condition_definition_fingerprint`, `sequence`, `timeout_seconds`, `cancellation_scope_id` (candidate 1.20) | `WorkflowStepHistory`, `ConditionWaits`, worker history payload consumers |
| `ConditionWaitSatisfied` | `condition_wait_id`, `condition_key`, `condition_definition_fingerprint`, `sequence`, `timer_id`, `timeout_seconds`, `workflow_signal_id`, `signal_name`, `signal_wait_id` | `WorkflowStepHistory`, `ConditionWaits`, `QueryStateReplayer`, `HistoryTimeline` |
| `ConditionWaitTimedOut` | `condition_wait_id`, `condition_key`, `condition_definition_fingerprint`, `sequence`, `timer_id`, `timeout_seconds` | `WorkflowStepHistory`, `ConditionWaits`, `QueryStateReplayer`, `HistoryTimeline` |
Expand Down
58 changes: 58 additions & 0 deletions scripts/ci/test-regression-corpus-policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -2254,5 +2254,63 @@ def test_content_heuristics_can_be_corrected_without_removing_core_guards(self)
self.assertIn("replay implementation changed but its corpus did not grow", result.stderr)



def load_validator() -> Any:
import importlib.util

# Loading the validator must not leave bytecode in the checkout either.
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location("validate_regression_corpus", VALIDATOR)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module


class ConsumerScratchTreeTest(unittest.TestCase):
"""The official consumer tree must never be committable source."""

def test_a_consumer_tree_under_the_repository_root_is_ignored(self) -> None:
validator = load_validator()
probe = "src/ConsumerScratchProbe.php"
root = str(REPOSITORY_ROOT)

# The real consumer run: it materializes the tree under the root, then
# lists untracked and ignored files from inside it.
result = validator._consumer_result(
REPOSITORY_ROOT,
{probe: b"<?php\n"},
(
"sh",
"-c",
'git -C "$0" ls-files --others --exclude-standard; '
'echo "--- ignored"; '
'git -C "$0" ls-files --others --ignored --exclude-standard --directory',
root,
),
)

self.assertEqual(0, result.returncode, result.stderr)
untracked, _, ignored = result.stdout.partition("--- ignored")
prefix = validator.CONSUMER_TREE_PREFIX
# Positive control: the tree really was under the root.
self.assertIn(prefix, ignored)
self.assertNotIn(prefix, untracked)
self.assertFalse(any(path.name.startswith(prefix) for path in REPOSITORY_ROOT.iterdir()))

def test_the_consumer_tree_prefix_is_ignored_by_git(self) -> None:
prefix = load_validator().CONSUMER_TREE_PREFIX
result = run(
"git",
"check-ignore",
"--no-index",
"--quiet",
f"{prefix}abc123/src/V2/Support/Probe.php",
cwd=REPOSITORY_ROOT,
)
self.assertEqual(0, result.returncode, "consumer scratch tree is not ignored by .gitignore")


if __name__ == "__main__":
unittest.main()
6 changes: 5 additions & 1 deletion scripts/ci/validate-regression-corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,10 @@
PHP_CODEC_ALIASES: dict[str, str] = {}
PHP_REPLAY_PAYLOAD_DECODER = Path(__file__).with_name("decode-replay-payload.php")
RUNTIME_DEPENDENCY_PATHS = ("vendor",)
# The consumer tree lives under the repository root so it can hard-link the
# runtime dependencies. .gitignore excludes it, so a run that is killed before
# cleanup cannot leave committable source behind.
CONSUMER_TREE_PREFIX = ".regression-corpus-consumer-"
ZERO_COMMIT = re.compile(r"^0+$")
LEGACY_MALFORMED_WIRE_REPAIRS = {
"%%%": "JSUl",
Expand Down Expand Up @@ -1820,7 +1824,7 @@ def _consumer_result(
) -> subprocess.CompletedProcess[str]:
started = time.monotonic()
with tempfile.TemporaryDirectory(
prefix=".regression-corpus-consumer-",
prefix=CONSUMER_TREE_PREFIX,
dir=source_root,
) as temporary:
consumer_root = Path(temporary)
Expand Down
8 changes: 4 additions & 4 deletions src/V2/Support/FailedRunRedriveEligibility.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,12 @@ public static function evaluate(
}

// Service-mode SDK workers are qualified to replay reused activities,
// side effects, version markers and timers. Reused signal, memo and
// search-attribute steps are not qualified yet, so a run a service
// worker started refuses them before anything is written.
// side effects, version markers and timers. Reused signal, memo,
// search-attribute and update steps are not qualified yet, so a run a
// service worker started refuses them before anything is written.
if (
($startedPayload['workflow_definition_fingerprint_source'] ?? null) === 'worker'
&& self::reusesServiceUnqualifiedSteps($plan['reused_steps'])
&& (self::reusesServiceUnqualifiedSteps($plan['reused_steps']) || ($plan['update_bundles'] ?? []) !== [])
) {
return self::refuse('service_redrive_history_not_qualified');
}
Expand Down
Loading
Loading