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
205 changes: 205 additions & 0 deletions tests/RedriveSuccessorStrictnessTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,211 @@ public static function priorCallResolutions(): iterable
}
}

/** @param array<string, mixed> $opening */
#[DataProvider('terminalLedgerCases')]
public function testPriorCallTerminalLedgerScansItsWholeInterval(
array $opening,
string $firstType,
string $secondType,
string $scenario,
): void {
$fixture = self::priorCallFixture($opening);
$first = self::terminal($fixture, $firstType);
$second = self::terminal($fixture, $secondType);
$foreign = $second;
$kind = $opening['event_type'];
$foreign['payload'][self::identity($kind)] = 'unrelated-resource';
if ($scenario === 'later-other-step') {
$foreign = $second;
$foreign['payload']['sequence'] = 5;
}
if ($scenario !== 'foreign-only' && $scenario !== 'later-only') {
if ($scenario === 'foreign-before') {
$fixture = self::insert($fixture, self::deliveryIndex($fixture), $foreign);
}
$fixture = self::insert($fixture, self::deliveryIndex($fixture), $first);
self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE]),
'Each multiplicity probe first proves its valid one-terminal control.');
}
if (in_array($scenario, ['foreign-only', 'foreign-after', 'duplicate-after-foreign'], true)) {
$fixture = self::insert($fixture, self::deliveryIndex($fixture), $foreign);
}
if (in_array($scenario, ['duplicate', 'duplicate-after-foreign'], true)) {
$fixture = self::insert($fixture, self::deliveryIndex($fixture), $second);
} elseif (in_array($scenario, ['later-duplicate', 'later-only'], true)) {
$fixture = self::insert($fixture, self::deliveryIndex($fixture) + 1, $second);
} elseif (in_array($scenario, ['later-foreign', 'later-other-step', 'later-duplicate-after-foreign'], true)) {
$fixture = self::insert($fixture, self::deliveryIndex($fixture) + 1, $foreign);
if ($scenario === 'later-duplicate-after-foreign') {
$fixture = self::insert($fixture, self::deliveryIndex($fixture) + 2, $second);
}
}
$violation = RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE]);

if (in_array($scenario, ['duplicate', 'duplicate-after-foreign', 'later-duplicate', 'later-duplicate-after-foreign'], true)) {
self::assertNotNull($violation);
self::assertStringContainsString('more than one terminal', $violation);
} elseif (in_array($scenario, ['foreign-only', 'later-only'], true)) {
self::assertNotNull($violation);
self::assertStringContainsString('an earlier call is unresolved', $violation);
} else {
self::assertNull($violation);
}
}

/** @return iterable<string, array{array<string, mixed>, string, string, string}> */
public static function terminalLedgerCases(): iterable
{
$families = self::priorCallFamilies();
$pairs = [
['activity', 'ActivityFailed', 'ActivityCompleted'],
['activity', 'ActivityCompleted', 'ActivityCompleted'],
['local activity', 'ActivityFailed', 'ActivityCompleted'],
['local activity', 'ActivityCompleted', 'ActivityCompleted'],
['timer', 'TimerCancelled', 'TimerFired'],
['timer', 'TimerFired', 'TimerFired'],
['signal', 'SignalApplied', 'SignalApplied'],
];
foreach ($pairs as [$name, $first, $second]) {
foreach (['duplicate', 'foreign-before', 'foreign-after', 'duplicate-after-foreign',
'foreign-only', 'later-duplicate', 'later-only', 'later-foreign', 'later-other-step',
'later-duplicate-after-foreign'] as $scenario) {
yield $name.' / '.$first.' / '.$second.' / '.$scenario => [$families[$name][0], $first, $second, $scenario];
}
}
}

/** @param array<string, mixed> $opening */
#[DataProvider('wholeHistoryTerminalCases')]
public function testWholeSuccessorRejectsASecondPrimaryTerminal(
array $opening,
string $firstType,
string $secondType,
string $boundary,
): void {
$fixture = self::priorCallFixture($opening);
if ($boundary === 'recorded delivery') {
$fixture['history'][6]['payload']['sequence'] = 3;
$fixture['history'][6]['payload']['call_kind'] = match ($opening['event_type']) {
'TimerScheduled' => 'timer',
'SignalWaitOpened' => 'signal',
default => ($opening['payload']['local_activity'] ?? false) ? 'local_activity' : 'activity',
};
} elseif ($boundary === 'no cancellation') {
$fixture['history'] = array_values(array_filter($fixture['history'], static fn (array $event): bool => !in_array(
$event['event_type'], ['CooperativeCancellationRequested', 'CooperativeCancellationDelivered'], true,
)));
}
$first = self::terminal($fixture, $firstType);
$position = $boundary === 'no cancellation' ? count($fixture['history']) : self::deliveryIndex($fixture);
$fixture = self::insert($fixture, $position, $first);
self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE]),
'One terminal remains valid at each claimed history boundary.');
$fixture = self::insert($fixture, count($fixture['history']), self::terminal($fixture, $secondType));
$violation = RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE]);

self::assertNotNull($violation);
self::assertStringContainsString('more than one terminal', $violation);
}

/** @return iterable<string, array{array<string, mixed>, string, string, string}> */
public static function wholeHistoryTerminalCases(): iterable
{
foreach (self::priorCallFamilies() as $family => [$opening, , $terminals]) {
if (!in_array($family, ['activity', 'local activity', 'timer', 'signal'], true)) {
continue;
}
foreach ($terminals as $first) {
foreach ($terminals as $second) {
foreach (['next-step delivery', 'recorded delivery', 'no cancellation'] as $boundary) {
yield $family.' / '.$first.' / '.$second.' / '.$boundary => [$opening, $first, $second, $boundary];
}
}
}
}
}

#[DataProvider('pairedWaitTerminals')]
public function testCanonicalWaitAndBackingTimerRowsAreNotTwoLogicalTerminals(
string $kind,
string $layout,
string $waitTerminal,
string $timerTerminal,
bool $timerAfterDelivery,
): void {
$fixture = self::waitTimeoutFixture($kind, $layout);
$fixture = self::insert($fixture, self::deliveryIndex($fixture), [
'event_type' => $waitTerminal,
'payload' => ['sequence' => 3, $kind.'_wait_id' => $kind,
'workflow_command_id' => 'signal-command', 'signal_id' => 'signal-event'],
]);
$fixture = self::insert($fixture, self::deliveryIndex($fixture) + (int) $timerAfterDelivery,
self::waitTimeoutTerminal($kind, $timerTerminal));

self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE]));
}

/** @return iterable<string, array{string, string, string, string, bool}> */
public static function pairedWaitTerminals(): iterable
{
foreach (['wait', 'timer', 'both'] as $layout) {
foreach ([false, true] as $later) {
yield 'condition satisfied / '.$layout.' / '.(int) $later => ['condition', $layout, 'ConditionWaitSatisfied', 'TimerCancelled', $later];
yield 'condition timed out / '.$layout.' / '.(int) $later => ['condition', $layout, 'ConditionWaitTimedOut', 'TimerFired', $later];
yield 'signal applied / '.$layout.' / '.(int) $later => ['signal', $layout, 'SignalApplied', 'TimerCancelled', $later];
}
}
}

/**
* @param array<string, mixed> $opening
* @param array<string, mixed> $foreignFields
*/
#[DataProvider('terminalModeAndKindCases')]
public function testForeignTerminalModeAndTimerKindCannotChangeTheClosedState(
array $opening,
string $type,
array $foreignFields,
string $scenario,
): void {
$fixture = self::priorCallFixture($opening);
$valid = self::terminal($fixture, $type);
$foreign = $valid;
$foreign['payload'] = [...$foreign['payload'], ...$foreignFields];
$fixture = self::insert($fixture, self::deliveryIndex($fixture), $valid);
self::assertNull(RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE]));
$later = str_starts_with($scenario, 'later-');
$fixture = self::insert($fixture, $scenario === 'before' ? 6 : self::deliveryIndex($fixture) + (int) $later, $foreign);
if (str_ends_with($scenario, 'duplicate-after')) {
$fixture = self::insert($fixture, self::deliveryIndex($fixture) + ($later ? 2 : 0), $valid);
}
$violation = RedriveSuccessorHistory::violation($fixture, RedriveFixtureRegistry::SUCCESSORS[self::FIXTURE]);

if (str_ends_with($scenario, 'duplicate-after')) {
self::assertNotNull($violation);
self::assertStringContainsString('more than one terminal', $violation);
} else {
self::assertNull($violation);
}
}

/** @return iterable<string, array{array<string, mixed>, string, array<string, mixed>, string}> */
public static function terminalModeAndKindCases(): iterable
{
$families = self::priorCallFamilies();
$cases = [
['activity', 'ActivityCompleted', ['local_activity' => true]],
['local activity', 'ActivityCompleted', ['local_activity' => false]],
['timer', 'TimerFired', ['timer_kind' => 'condition_timeout', 'condition_wait_id' => 'condition']],
['timer', 'TimerFired', ['timer_kind' => 'signal_timeout', 'signal_wait_id' => 'signal']],
];
foreach ($cases as $index => [$family, $type, $fields]) {
foreach (['before', 'after', 'duplicate-after', 'later-after', 'later-duplicate-after'] as $scenario) {
yield $family.' / '.$index.' / '.$scenario => [$families[$family][0], $type, $fields, $scenario];
}
}
}

/** @param array<string, mixed> $opening */
#[DataProvider('priorCallRefusals')]
public function testPriorCallRequiresItsOwnLaterResolution(array $opening, string $identity, string $terminalType, string $fault): void
Expand Down
89 changes: 75 additions & 14 deletions tests/Support/RedriveSuccessorHistory.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
* Checks the supported replay-fixture shapes against Workflow 2.19 redrive.
* This is a test evidence model. Scoped, grouped, selection and service-operation
* deliveries remain outside its qualified single-call boundary.
* Terminal multiplicity is checked across the whole successor for remote/local
* activities, plain timers and SignalApplied markers. Prior-call reachability
* uses only rows before delivery. Backing-timer/wait outcome combinations and child
* closure semantics retain their existing presence/identity bounds; this is
* not a generalized logical-outcome interpreter for those shapes.
*
* Core's DefaultWorkflowControlPlane::redrive() records, in one transaction,
* a WorkflowStarted carrying the successor identity and the redrive envelope,
Expand Down Expand Up @@ -280,10 +285,13 @@ public static function violation(array $fixture, array $reusedEventTypes): ?stri
return 'resume_step_sequence must be the last reused workflow step + 1 ('.($lastReusedStep + 1).')';
}
if ($requests !== [] || $deliveries !== []) {
return self::cancellationViolation($history, $requests, $deliveries, $startedAt, $instance, $run);
$violation = self::cancellationViolation($history, $requests, $deliveries, $startedAt, $instance, $run);
if ($violation !== null) {
return $violation;
}
}

return null;
return self::primaryTerminalViolation($history);
}

/**
Expand Down Expand Up @@ -511,8 +519,9 @@ private static function priorCallViolation(array $beforeDelivery): ?string
if ($kind === null || $shape === null) {
continue;
}
if (!self::callIsResolved($kind, $payload, $shape, array_slice($beforeDelivery, $index + 1))) {
return 'the cancellation delivery targets an unreachable next step: an earlier call is unresolved';
$violation = self::callResolutionViolation($kind, $payload, $shape, array_slice($beforeDelivery, $index + 1));
if ($violation !== null) {
return $violation;
}
}

Expand All @@ -524,25 +533,77 @@ private static function priorCallViolation(array $beforeDelivery): ?string
* @param array{string, list<string>} $shape
* @param list<array<string, mixed>> $afterOpening
*/
private static function callIsResolved(string $kind, array $payload, array $shape, array $afterOpening): bool
private static function callResolutionViolation(string $kind, array $payload, array $shape, array $afterOpening): ?string
{
[$identity, $terminals] = $shape;
$unresolved = 'the cancellation delivery targets an unreachable next step: an earlier call is unresolved';
if (!self::nonBlank($payload[$identity] ?? null)
|| !is_int($payload['sequence'] ?? null) || $payload['sequence'] < 1) {
return false;
return $unresolved;
}
$resolved = false;
foreach ($afterOpening as $terminal) {
$terminalPayload = is_array($terminal['payload'] ?? null) ? $terminal['payload'] : [];
$type = $terminal['event_type'] ?? null;
if (in_array($type, $terminals, true)
&& self::terminalFamilyMatches($kind, $type, $terminalPayload)
&& ($terminalPayload['sequence'] ?? null) === $payload['sequence']
&& ($terminalPayload[$identity] ?? null) === $payload[$identity]) {
return true;
if (self::terminalMatchesCall($kind, $payload, [$identity, $terminals], $terminal)) {
$resolved = true;
}
}

return false;
return $resolved ? null : $unresolved;
}

/** @param list<array<string, mixed>> $history */
private static function primaryTerminalViolation(array $history): ?string
{
foreach ($history as $index => $event) {
$payload = is_array($event['payload'] ?? null) ? $event['payload'] : [];
$kind = self::callKind((string) ($event['event_type'] ?? ''), $payload);
if (!in_array($kind, ['activity', 'local_activity', 'timer', 'signal'], true)) {
continue;
}
$shape = self::CALL_RESOLUTIONS[$kind];
if (!self::nonBlank($payload[$shape[0]] ?? null)
|| !is_int($payload['sequence'] ?? null) || $payload['sequence'] < 1) {
return 'a modeled primary call needs a resource identity and positive workflow step';
}
$closed = false;
foreach (array_slice($history, $index + 1) as $terminal) {
if (!self::closesModeledCall($kind, $terminal['event_type'] ?? null)
|| !self::terminalMatchesCall($kind, $payload, $shape, $terminal)) {
continue;
}
if ($closed) {
return 'a modeled primary call has more than one terminal';
}
$closed = true;
}
}

return null;
}

/**
* @param array<mixed> $opening
* @param array{string, list<string>} $shape
* @param array<string, mixed> $terminal
*/
private static function terminalMatchesCall(string $kind, array $opening, array $shape, array $terminal): bool
{
[$identity, $terminals] = $shape;
$payload = is_array($terminal['payload'] ?? null) ? $terminal['payload'] : [];
$type = $terminal['event_type'] ?? null;

return in_array($type, $terminals, true)
&& self::terminalFamilyMatches($kind, $type, $payload)
&& ($payload['sequence'] ?? null) === $opening['sequence']
&& ($payload[$identity] ?? null) === $opening[$identity];
}

private static function closesModeledCall(string $kind, mixed $type): bool
{
// A canonical timeout timer and wait-specific event can describe one
// outcome. Do not count those physical rows as independent terminals.
return in_array($kind, ['activity', 'local_activity', 'timer'], true)
|| ($kind === 'signal' && $type === 'SignalApplied');
}

/** @param array<mixed> $payload */
Expand Down
Loading