diff --git a/CHANGELOG.md b/CHANGELOG.md index 850571f6..b25f73b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,25 @@ project follows [Semantic Versioning](https://semver.org/). and group APIs are unchanged, and `childWorkflow()` refuses an `await` option. +### Changed + +- New condition waits use versioned `dwphp2:sha256:` predicate fingerprints + (#293). Callable tokens exclude whitespace, comments and neighboring call-site + arguments, while preserving literals and operator boundaries. A predicate + change still fails replay. Indistinguishable same-line closures share a + combined fingerprint; put predicates on separate lines before starting runs + when they need independent identities. Unsupported source shapes or a PHP + build without the tokenizer retain the existing v1 computation. + Replaying v2 waits requires the tokenizer in every worker. +- Replay accepts a recorded v1 fingerprint only when its original source-line + hash matches exactly. Physical reopens preserve their first recorded + fingerprint, including its absence. V2 formatting tolerance therefore applies + to waits first opened with v2, not retroactively to legacy runs. +- Upgrade the queue's workers together before opening v2 waits. Older workers + reject v2 fingerprints, so rolling back below this SDK after v2 waits exist + can fail replay. Deploy sources atomically and restart workers, including + workers using OPcache. + ## [3.9.4] - 2026-11-04 ### Fixed diff --git a/docs/portal/build/workflows-activities.md b/docs/portal/build/workflows-activities.md index 45ac49db..5fd73ffb 100644 --- a/docs/portal/build/workflows-activities.md +++ b/docs/portal/build/workflows-activities.md @@ -405,7 +405,28 @@ A later deployment can raise the maximum while keeping the recorded versions in For a boolean rollout, use `patched('require-reservation-review')`. It returns `false` for legacy histories and `true` for new histories. After the legacy branch is no longer supported, replace the conditional call with `deprecatePatch()` for the same ID before eventually removing the marker. Compatible numeric and patch helpers share the same frozen version for a change ID. Every call must support that version, and `patched()` returns whether it equals `1`. Do not reuse a change ID for an unrelated rollout. -A `waitCondition()` predicate is part of replay identity too. The worker fingerprints the source lines the closure occupies, so editing those lines while open runs have recorded that wait (even one already satisfied) raises `NonDeterministicWorkflow`. That includes re-indenting or editing an inner comment. Any other code on the predicate's lines, such as the variable it assigns, is hashed too. Moving the file or editing code above or below the predicate does not change the fingerprint. The key and timeout are part of replay identity in their own right; change them only behind a version marker, as with a changed predicate. [Issue #293](https://github.com/durable-workflow/sdk-php/issues/293) tracks a whitespace-insensitive fingerprint. +A `waitCondition()` predicate is part of replay identity too. New waits use a +`dwphp2:sha256:` fingerprint of the callable's tokens. Whitespace and comments +outside literals, file locations and neighboring call-site arguments are +excluded. Changing the predicate's tokens still raises +`NonDeterministicWorkflow`, even for a wait that has already been satisfied. +The key and timeout remain separate replay checks; change them behind a version +marker when an open run can reach the call. + +Reflection can leave several same-line closures indistinguishable. Those +closures share a combined candidate fingerprint, so changing either can affect +both. Formatting that resolves that ambiguity can also change the candidate +set. Put predicates on separate lines before starting runs when they need +independent identities. If the callable cannot be isolated, or PHP lacks the +tokenizer, the worker retains the v1 source-line fingerprint. +Every worker that replays v2 waits needs PHP's tokenizer enabled. + +Waits first opened by older SDKs retain their exact v1 source-line check. +Re-indenting those lines, changing a comment or editing neighboring code on +the same lines can still fail legacy replay. A physical reopen echoes the +first recorded fingerprint, so upgrading does not change an existing wait's +identity. Upgrade the queue's workers together: older workers reject v2 +fingerprints, and cannot safely take over runs that already contain them. Deploy workflow code atomically and restart workers. The fingerprint reads the deployed source file. If files are replaced in place under any long-running worker that still has the old code loaded, the loaded code and the file on disk can disagree, and replay reports a mismatch until the worker restarts. `opcache.validate_timestamps=0` widens this window. Ship the PHP sources with the worker image; the fingerprint cannot be computed without them. diff --git a/regression-corpus-policy.json b/regression-corpus-policy.json index 0fef9a49..9bbabfba 100644 --- a/regression-corpus-policy.json +++ b/regression-corpus-policy.json @@ -38,6 +38,12 @@ { "glob": "src/Worker/Replayer.php" }, + { + "glob": "src/Worker/ConditionWaitDefinition.php" + }, + { + "glob": "src/Worker/ConditionPredicateSource.php" + }, { "glob": "src/Worker/WorkflowEntryArguments.php" }, diff --git a/src/Worker/ConditionPredicateSource.php b/src/Worker/ConditionPredicateSource.php new file mode 100644 index 00000000..7e45c061 --- /dev/null +++ b/src/Worker/ConditionPredicateSource.php @@ -0,0 +1,233 @@ +, starts: array>}> */ + private static array $files = []; + + public static function canonical(Closure $predicate): ?string + { + if (!class_exists(PhpToken::class, false)) { + return null; + } + try { + $reflection = new ReflectionFunction($predicate); + $file = $reflection->getFileName(); + if (!is_string($file) || !is_file($file)) { + return null; + } + $source = self::fileTokens($file); + $tokens = $source['tokens']; + $candidates = []; + foreach ($source['starts'][$reflection->getStartLine()] ?? [] as $index) { + $candidate = self::candidate($tokens, $index, $reflection); + if ($candidate !== null) { + $candidates[] = $candidate; + } + } + if ($candidates === []) { + return null; + } + // Reflection cannot always distinguish same-line closures. Preserve + // every possible candidate rather than guess or invoke user code. + $candidates = array_values(array_unique($candidates)); + sort($candidates, SORT_STRING); + + return self::frame($candidates); + } catch (Throwable) { + return null; + } + } + + /** @return array{hash: string, tokens: list, starts: array>} */ + private static function fileTokens(string $file): array + { + $source = @file_get_contents($file); + if (!is_string($source)) { + return ['hash' => '', 'tokens' => [], 'starts' => []]; + } + $hash = hash('sha256', $source); + if ((self::$files[$file]['hash'] ?? null) === $hash) { + return self::$files[$file]; + } + $tokens = array_values(array_filter(PhpToken::tokenize($source), + static fn (PhpToken $token): bool => !$token->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT]))); + $starts = []; + foreach ($tokens as $index => $token) { + if ($token->is([T_FUNCTION, T_FN])) { + $starts[$token->line][] = $index; + } + } + // Bound process memory and replace a file's entry when its bytes change. + // File paths and content hashes are cache keys, never fingerprint input. + if (count(self::$files) >= 8 && !isset(self::$files[$file])) { + array_shift(self::$files); + } + self::$files[$file] = ['hash' => $hash, 'tokens' => $tokens, 'starts' => $starts]; + + return self::$files[$file]; + } + + /** @param list $tokens */ + private static function candidate(array $tokens, int $index, ReflectionFunction $reflection): ?string + { + $cursor = $index + 1; + if (($tokens[$cursor]->text ?? null) === '&') { + ++$cursor; + } + $name = null; + if (($tokens[$cursor] ?? null)?->is(T_STRING)) { + $name = $tokens[$cursor++]->text; + } + // isAnonymous() starts at PHP 8.2. Anonymous reflection names contain + // this marker on 8.1 too; the rest of the name never enters the hash. + $anonymous = str_contains($reflection->getName(), '{closure'); + if (($anonymous && $name !== null) + || (!$anonymous && $name !== $reflection->getShortName()) + || ($tokens[$cursor]->text ?? null) !== '(') { + return null; + } + $parametersEnd = self::balancedEnd($tokens, $cursor); + if ($parametersEnd === null || !self::parametersMatch($tokens, $cursor, $parametersEnd, $reflection)) { + return null; + } + $start = self::declarationStart($tokens, $index); + $static = false; + foreach (array_slice($tokens, $start, $index - $start) as $modifier) { + $static = $static || $modifier->is(T_STATIC); + } + if ($static !== $reflection->isStatic()) { + return null; + } + $end = self::bodyEnd($tokens, $parametersEnd + 1, $tokens[$index]->is(T_FN)); + if ($end === null || self::endLine($tokens[$end]) !== $reflection->getEndLine()) { + return null; + } + + return self::frame(array_map(static fn (PhpToken $token): string => $token->text, + array_slice($tokens, $start, $end - $start + 1))); + } + + /** @param list $tokens */ + private static function declarationStart(array $tokens, int $index): int + { + while ($index > 0 && $tokens[$index - 1]->is([T_STATIC, T_PUBLIC, T_PROTECTED, T_PRIVATE, T_FINAL, T_ABSTRACT])) { + --$index; + } + + return $index; + } + + /** @param list $tokens */ + private static function parametersMatch(array $tokens, int $start, int $end, ReflectionFunction $reflection): bool + { + $names = []; + for ($index = $start + 1; $index < $end; ++$index) { + $token = $tokens[$index]; + if (self::opening($token) !== null) { + $index = self::balancedEnd($tokens, $index) ?? $end; + } elseif ($token->is(T_VARIABLE)) { + $names[] = substr($token->text, 1); + } + } + $parameters = array_map(static fn (\ReflectionParameter $parameter): string => $parameter->getName(), $reflection->getParameters()); + + return $names === $parameters; + } + + /** @param list $tokens */ + private static function bodyEnd(array $tokens, int $start, bool $arrow): ?int + { + for ($index = $start, $count = count($tokens); $index < $count; ++$index) { + if ($arrow && $tokens[$index]->is(T_DOUBLE_ARROW)) { + return self::expressionEnd($tokens, $index + 1); + } + if (!$arrow && $tokens[$index]->text === '{') { + return self::balancedEnd($tokens, $index); + } + if (self::opening($tokens[$index]) !== null) { + $index = self::balancedEnd($tokens, $index) ?? $count; + } + } + + return null; + } + + /** @param list $tokens */ + private static function expressionEnd(array $tokens, int $start): ?int + { + $ternaries = 0; + for ($index = $start, $count = count($tokens); $index < $count; ++$index) { + $text = $tokens[$index]->text; + if (in_array($text, [',', ';', ')', ']', '}'], true) || ($text === ':' && $ternaries === 0)) { + return $index > $start ? $index - 1 : null; + } + if (self::opening($tokens[$index]) !== null) { + $end = self::balancedEnd($tokens, $index); + if ($end === null) { + return null; + } + $index = $end; + } elseif ($text === '?') { + ++$ternaries; + } elseif ($text === ':') { + --$ternaries; + } + } + + return $index > $start ? $index - 1 : null; + } + + /** @param list $tokens */ + private static function balancedEnd(array $tokens, int $start): ?int + { + $stack = []; + for ($index = $start, $count = count($tokens); $index < $count; ++$index) { + $opening = self::opening($tokens[$index]); + if ($opening !== null) { + $stack[] = $opening; + } elseif (in_array($tokens[$index]->text, [')', ']', '}'], true)) { + if (array_pop($stack) !== $tokens[$index]->text) { + return null; + } + if ($stack === []) { + return $index; + } + } + } + + return null; + } + + private static function opening(PhpToken $token): ?string + { + return match ($token->text) { + '(' => ')', + '[', '#[' => ']', + '{', '${' => '}', + default => null, + }; + } + + private static function endLine(PhpToken $token): int + { + return $token->line + substr_count($token->text, "\n"); + } + + /** @param list $parts */ + private static function frame(array $parts): string + { + // Length framing preserves token boundaries, including + + versus ++. + return implode('', array_map(static fn (string $part): string => strlen($part).':'.$part, $parts)); + } +} diff --git a/src/Worker/ConditionWaitDefinition.php b/src/Worker/ConditionWaitDefinition.php index ba9cf474..f0bb132a 100644 --- a/src/Worker/ConditionWaitDefinition.php +++ b/src/Worker/ConditionWaitDefinition.php @@ -12,6 +12,16 @@ final class ConditionWaitDefinition { public static function fingerprint(Closure $predicate): ?string + { + $tokens = ConditionPredicateSource::canonical($predicate); + + return $tokens === null + ? self::legacyFingerprint($predicate) + : 'dwphp2:sha256:'.hash('sha256', $tokens); + } + + /** The published v1 computation remains exact for histories opened by older workers. */ + public static function legacyFingerprint(Closure $predicate): ?string { $source = self::source($predicate); diff --git a/src/Worker/Replayer.php b/src/Worker/Replayer.php index c9629fdd..63d6bc12 100644 --- a/src/Worker/Replayer.php +++ b/src/Worker/Replayer.php @@ -420,6 +420,7 @@ public function replay( $this->assertParallelPathMatches($step, $path); if ($command->type === 'open_condition_wait') { $this->assertConditionWaitCompatible($step, $command); + $command = $command->withRecordedConditionDefinition($step['condition_definition_fingerprint']); } $nextSequence = max($nextSequence, $step['sequence'] + 1); @@ -1318,6 +1319,7 @@ private function bindConditionReopens(array $steps): array */ private function conditionReopen(WorkflowCommand $command, array $step, array $history): WorkflowCommand { + $command = $command->withRecordedConditionDefinition($step['condition_definition_fingerprint']); $timeout = $step['timeout_seconds'] ?? null; $openedAt = $step['opened_at'] ?? null; if (!is_int($timeout) || !$openedAt instanceof \DateTimeImmutable) { @@ -3494,7 +3496,7 @@ private function assertConditionWaitCompatible(array $step, WorkflowCommand $com ); } if ($step['condition_definition_fingerprint'] !== null - && $step['condition_definition_fingerprint'] !== $currentFingerprint) { + && !$command->conditionDefinitionMatches($step['condition_definition_fingerprint'])) { throw new NonDeterministicWorkflow( 'Condition wait predicate fingerprint changed during replay.', $step['sequence'], diff --git a/src/Worker/WorkflowCommand.php b/src/Worker/WorkflowCommand.php index 485e19d0..aadc0f0b 100644 --- a/src/Worker/WorkflowCommand.php +++ b/src/Worker/WorkflowCommand.php @@ -217,6 +217,27 @@ public function conditionSatisfied(): bool return ($this->conditionPredicate)() === true; } + /** @internal V1 remains strict even when a new command would write v2. */ + public function conditionDefinitionMatches(string $recorded): bool + { + return $recorded === ($this->attributes['condition_definition_fingerprint'] ?? null) + || ($this->conditionPredicate !== null + && preg_match('/\Asha256:[a-f0-9]{64}\z/', $recorded) === 1 + && ConditionWaitDefinition::legacyFingerprint($this->conditionPredicate) === $recorded); + } + + /** @internal A physical reopen keeps its logical wait's first fingerprint, including absence. */ + public function withRecordedConditionDefinition(?string $fingerprint): self + { + $attributes = $this->attributes; + unset($attributes['condition_definition_fingerprint']); + if ($fingerprint !== null) { + $attributes['condition_definition_fingerprint'] = $fingerprint; + } + + return $this->copyWithAttributes($attributes); + } + /** * @param list $arguments * @param array $options @@ -700,11 +721,17 @@ public static function workflowStream(array $directive): self /** @param array $attributes */ public function withAttributes(array $attributes): self + { + return $this->copyWithAttributes(array_merge($this->attributes, $attributes)); + } + + /** @param array $attributes */ + private function copyWithAttributes(array $attributes): self { return new self( $this->type, $this->historyShape, - array_merge($this->attributes, $attributes), + $attributes, $this->localResult, $this->sideEffect, $this->conditionPredicate, diff --git a/tests/ConditionWaitFingerprintTest.php b/tests/ConditionWaitFingerprintTest.php new file mode 100644 index 00000000..e7a3dc6d --- /dev/null +++ b/tests/ConditionWaitFingerprintTest.php @@ -0,0 +1,229 @@ + */ + private array $files = []; + + protected function tearDown(): void + { + foreach ($this->files as $file) { + if (is_file($file)) { + unlink($file); + } + } + } + + public function testWhitespaceCommentsAndLocationsDoNotChangeV2(): void + { + $first = $this->load('return static function (): bool { return false; };'); + $second = $this->load("\n\nreturn static function ( ) : bool {\n // Approval is pending.\n return /* explanation */ false ;\n};"); + + self::assertStringStartsWith('dwphp2:sha256:', ConditionWaitDefinition::fingerprint($first)); + self::assertSame(ConditionWaitDefinition::fingerprint($first), ConditionWaitDefinition::fingerprint($second)); + self::assertNotSame(ConditionWaitDefinition::legacyFingerprint($first), ConditionWaitDefinition::legacyFingerprint($second)); + } + + public function testNeighboringCallArgumentsAreOutsideTheFingerprint(): void + { + $first = $this->load("return (static fn (\$predicate, \$key, \$timeout) => \$predicate)(static fn (): bool => false, 'approval', 30);"); + $second = $this->load("return (static fn (\$predicate, \$key, \$timeout) => \$predicate)(static fn (): bool => false, 'other', 90);"); + + self::assertSame(ConditionWaitDefinition::fingerprint($first), ConditionWaitDefinition::fingerprint($second)); + } + + #[DataProvider('changedPredicates')] + public function testRealTokenChangesKeepDifferentIdentities(string $left, string $right): void + { + self::assertNotSame(ConditionWaitDefinition::fingerprint($this->load($left)), ConditionWaitDefinition::fingerprint($this->load($right))); + } + + public static function changedPredicates(): iterable + { + yield 'result' => ['return static fn (): bool => false;', 'return static fn (): bool => true;']; + yield 'operator boundaries' => ['$value = 1; return static fn () => $value + +$value;', '$value = 1; return static fn () => $value++ +$value;']; + yield 'literal whitespace' => ["return static fn () => 'a b';", "return static fn () => 'ab';"]; + yield 'nested closure body' => ['return static fn () => (static fn () => false)();', 'return static fn () => (static fn () => true)();']; + } + + public function testIndistinguishableSameLineClosuresShareTheWholeCandidateIdentity(): void + { + [$first, $second] = $this->load('return [static fn (): bool => false, static fn (): bool => true];'); + [$changed] = $this->load('return [static fn (): bool => false, static fn (): bool => !false];'); + + self::assertSame(ConditionWaitDefinition::fingerprint($first), ConditionWaitDefinition::fingerprint($second)); + self::assertNotSame(ConditionWaitDefinition::fingerprint($first), ConditionWaitDefinition::fingerprint($changed)); + } + + public function testParameterNamesDistinguishSameLineClosures(): void + { + [$first, $second] = $this->load('return [static fn ($left) => $left, static fn ($right) => $right];'); + [$unchanged] = $this->load('return [static fn ($left) => $left, static fn ($right) => !$right];'); + + self::assertNotSame(ConditionWaitDefinition::fingerprint($first), ConditionWaitDefinition::fingerprint($second)); + self::assertSame(ConditionWaitDefinition::fingerprint($first), ConditionWaitDefinition::fingerprint($unchanged)); + } + + public function testNamedAndInvokableCallablesCanBeReadWithoutCallingThem(): void + { + $object = $this->load('return new class { public function ready(): bool { throw new \LogicException("Not evaluated"); } public function __invoke(): bool { return false; } };'); + + self::assertStringStartsWith('dwphp2:sha256:', ConditionWaitDefinition::fingerprint(Closure::fromCallable([$object, 'ready']))); + self::assertStringStartsWith('dwphp2:sha256:', ConditionWaitDefinition::fingerprint(Closure::fromCallable($object))); + } + + public function testBalancedBodiesTernariesAndCrLfRemainPortable(): void + { + $source = 'return static fn (): bool => (true ? (static fn (bool $unused = false): bool => false)() : true);'; + $first = $this->load($source); + $second = $this->load("\r\n".str_replace(' => ', "\r\n => ", $source)); + + self::assertSame(ConditionWaitDefinition::fingerprint($first), ConditionWaitDefinition::fingerprint($second)); + } + + public function testUnavailableSourceDoesNotReuseCachedTokens(): void + { + $predicate = $this->load('return static fn (): bool => false;'); + self::assertNotNull(ConditionWaitDefinition::fingerprint($predicate)); + unlink($this->files[array_key_last($this->files)]); + + self::assertNull(ConditionWaitDefinition::fingerprint($predicate)); + self::assertNull(ConditionWaitDefinition::fingerprint(Closure::fromCallable('strlen'))); + } + + public function testGoldenIdentityMatchesInFreshProcesses(): void + { + $predicate = $this->load('return static fn (): bool => false;'); + $file = $this->files[array_key_last($this->files)]; + $expected = 'dwphp2:sha256:02a95ef3eef2f4aec3dbd83735628c87719b16370c73743a0bd4fc7b91abc15c'; + self::assertSame($expected, ConditionWaitDefinition::fingerprint($predicate)); + if (!function_exists('proc_open')) { + self::markTestSkipped('Fresh-process evidence requires proc_open.'); + } + for ($attempt = 0; $attempt < 2; ++$attempt) { + $process = proc_open([PHP_BINARY, '-r', + 'require $argv[1]; echo DurableWorkflow\\Worker\\ConditionWaitDefinition::fingerprint(require $argv[2]);', + dirname(__DIR__).'/vendor/autoload.php', $file, + ], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process); + $output = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(0, proc_close($process), $errors); + self::assertSame($expected, $output); + } + } + + public function testLegacyWaitsKeepTheirFirstFingerprintOnReopen(): void + { + $predicate = $this->load('return static fn (): bool => false;'); + $legacy = ConditionWaitDefinition::legacyFingerprint($predicate); + $commands = $this->replay($predicate, $legacy); + + self::assertSame($legacy, $commands[0]['condition_definition_fingerprint']); + self::assertSame('php:condition-wait:0', $commands[0]['condition_wait_occurrence_id']); + } + + public function testAWaitWithoutARecordedFingerprintKeepsItsAbsence(): void + { + $commands = $this->replay($this->load('return static fn (): bool => false;'), null); + + self::assertArrayNotHasKey('condition_definition_fingerprint', $commands[0]); + } + + public function testV2HistoryAcceptsFormattingChangesAndKeepsItsIdentity(): void + { + $first = $this->load('return static fn (): bool => false;'); + $formatted = $this->load('return static fn ( ): bool => /* pending */ false ;'); + $fingerprint = ConditionWaitDefinition::fingerprint($first); + + self::assertSame($fingerprint, $this->replay($formatted, $fingerprint)[0]['condition_definition_fingerprint']); + } + + public function testLegacyHistoryDoesNotGainFormattingToleranceRetroactively(): void + { + $first = $this->load('return static fn (): bool => false;'); + $formatted = $this->load('return static fn ( ): bool => /* pending */ false ;'); + $legacy = ConditionWaitDefinition::legacyFingerprint($first); + + $this->expectException(NonDeterministicWorkflow::class); + + $this->replay($formatted, $legacy); + } + + public function testALaterAuthoredWaitWritesV2AfterAnEarlierLegacyWait(): void + { + $predicate = $this->load('return static fn (): bool => false;'); + $history = [ + ['event_type' => 'ConditionWaitOpened', 'payload' => ['sequence' => 1, 'condition_wait_id' => 'first', + 'condition_key' => 'first', 'timeout_seconds' => 60, + 'condition_definition_fingerprint' => ConditionWaitDefinition::legacyFingerprint($predicate)]], + ['event_type' => 'ConditionWaitTimedOut', 'payload' => ['sequence' => 1, 'condition_wait_id' => 'first']], + ]; + $workflow = static function (WorkflowContext $context) use ($predicate): bool { + $context->waitCondition($predicate, 'first', 60); + + return $context->waitCondition($predicate, 'second', 60); + }; + $commands = (new Replayer(new AvroPayloadCodec()))->replay($workflow, $history, [], 'php-workers')->commands; + + self::assertSame('second', $commands[0]['condition_key']); + self::assertSame('php:condition-wait:1', $commands[0]['condition_wait_occurrence_id']); + self::assertSame(ConditionWaitDefinition::fingerprint($predicate), $commands[0]['condition_definition_fingerprint']); + } + + #[DataProvider('mismatchedFingerprints')] + public function testLegacyV2AndExplicitMismatchesRemainStrict(string $recorded): void + { + $this->expectException(NonDeterministicWorkflow::class); + + $this->replay($this->load('return static fn (): bool => false;'), $recorded); + } + + public static function mismatchedFingerprints(): iterable + { + yield 'legacy' => ['sha256:'.str_repeat('a', 64)]; + yield 'v2' => ['dwphp2:sha256:'.str_repeat('a', 64)]; + yield 'explicit' => ['sha256:different']; + yield 'unknown version' => ['dwphp3:sha256:'.str_repeat('a', 64)]; + } + + /** @return list> */ + private function replay(Closure $predicate, ?string $recorded): array + { + $payload = ['sequence' => 1, 'condition_wait_id' => 'wait-1', 'timeout_seconds' => 60, + 'condition_wait_occurrence_id' => 'php:condition-wait:0']; + if ($recorded !== null) { + $payload['condition_definition_fingerprint'] = $recorded; + } + + return (new Replayer(new AvroPayloadCodec()))->replay( + static fn (WorkflowContext $context): bool => $context->waitCondition($predicate, timeout: 60), + [['event_type' => 'ConditionWaitOpened', 'payload' => $payload]], [], 'php-workers', + )->commands; + } + + private function load(string $source): mixed + { + $file = tempnam(sys_get_temp_dir(), 'condition-fingerprint-'); + self::assertIsString($file); + $this->files[] = $file; + file_put_contents($file, "commands[0]['condition_key']); self::assertSame(61, $result->commands[0]['timeout_seconds']); self::assertMatchesRegularExpression( - '/\Asha256:[0-9a-f]{64}\z/', + '/\Adwphp2:sha256:[0-9a-f]{64}\z/', $result->commands[0]['condition_definition_fingerprint'], ); } diff --git a/tests/fixtures/replay-regressions/condition-wait-token-fingerprint-v2.json b/tests/fixtures/replay-regressions/condition-wait-token-fingerprint-v2.json new file mode 100644 index 00000000..a0b17c2a --- /dev/null +++ b/tests/fixtures/replay-regressions/condition-wait-token-fingerprint-v2.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://raw.githubusercontent.com/durable-workflow/.github/main/regression-corpus/evidence-schema.json", + "fixture_schema": "durable-workflow.replay-regression/v1", + "id": "php-condition-wait-token-fingerprint-v2", + "protocol_version": "1.20", + "bindings": [ + "php" + ], + "workflow": { + "type": "golden.signal", + "input": [ + "condition:pending" + ] + }, + "history": [ + { + "event_type": "ConditionWaitOpened", + "payload": { + "sequence": 1, + "condition_wait_id": "pending-1", + "condition_key": "approval-pending", + "condition_definition_fingerprint": "dwphp2:sha256:02a95ef3eef2f4aec3dbd83735628c87719b16370c73743a0bd4fc7b91abc15c", + "timeout_seconds": 60 + } + } + ], + "expected": { + "type": "open_condition_wait", + "condition_key": "approval-pending", + "condition_definition_fingerprint": "dwphp2:sha256:02a95ef3eef2f4aec3dbd83735628c87719b16370c73743a0bd4fc7b91abc15c", + "timeout_seconds": 60 + } +}