Skip to content

[Bug] ActivityMocker is incompatible with time-skipping — virtual clock skips through start-to-close timeout before mocked response arrives #745

@ilyazastrognov

Description

@ilyazastrognov

What are you really trying to do?

Testing a payment-timeout workflow that mixes a long timer and several activities. The workflow waits 30 minutes via
Workflow::awaitWithTimeout, then calls an activity to check the gateway, then dispatches one of three completion activities
depending on the result. We want the test to complete in seconds (not 30+ wall-clock minutes) by relying on the time-skipping test
server, while mocking the activity results with Temporal\Testing\ActivityMocker.

Describe the bug

ActivityMocker serves activity results through the RoadRunner KV cache (RoadRunnerActivityInvocationCache) — it short-circuits
the worker's normal activity-execution lifecycle. The test server therefore never sees "an activity is running on a worker" and
keeps fast-forwarding virtual time even though the activity is logically in flight.

withStartToCloseTimeout(N) is denominated in virtual seconds, so the timeout elapses in microseconds of wall-clock — faster
than the mocked response can come back through KV. The workflow fails with:

WorkflowFailedException
└── ActivityFailure
    └── TimeoutFailure: timeoutWorkflowType='TIMEOUT_TYPE_START_TO_CLOSE'                                                           

This means timer-driven workflows with ActivityMocker are effectively untestable under time-skipping. We confirmed it with the
following matrix:

Setup Result
Timer + signal-only workflow (no activities) works — workflow exits on signal before timer matters
Timer + ActivityMocker-served activity, default Time Locking Counter = 1 hangs on the timer (no fast-forward — see related
issue about lock counter)
Same, with explicit unlockTimeSkipping() around getResult() TIMEOUT_TYPE_START_TO_CLOSE on activity
Same, with withStartToCloseTimeout(300) (10x larger) same — virtual time elapses 300s in microseconds, mock still doesn't beat
it
Same, with smaller workflow input timeouts (e.g. awaitWithTimeout(1, ...)) same — activity timeout still virtual

The root cause is that ActivityMocker sits outside the worker's "activity is running" signal. The TS / Java / Go SDKs don't have
this problem because their test mocks register as real activity implementations on the worker (e.g.
worker.registerActivitiesImplementations(mockImpl) in Java), so the test server correctly halts the virtual clock while a mock is
responding.

Minimal Reproduction

#[WorkflowInterface]
final class TimerActivityWorkflow {                                                                                                 
    #[WorkflowMethod(name: 'TimerActivity')]
    public function execute(string $id): \Generator {                                                                               
        $activity = Workflow::newActivityStub(            
            CheckActivity::class,                                                                                                   
            ActivityOptions::new()->withStartToCloseTimeout(30),
        );                                                                                                                          
                                                          
        yield Workflow::timer(1800);                  // 30 min — should fast-forward                                               
        $result = yield $activity->check($id);        // mocked via ActivityMocker
        return $result;                                                                                                             
    }                                                     
}

final class TimerActivityWorkflowTest extends WorkflowTestCase {                                                                    
    private ActivityMocker $mocks;
                                                                                                                                    
    protected function setUp(): void {                    
        parent::setUp();
        $this->mocks = new ActivityMocker();
    }                                                                                                                               
 
    protected function tearDown(): void {                                                                                           
        $this->mocks->clear();                            
        parent::tearDown();
    }

    public function testHits(): void {                                                                                              
        $this->mocks->expectCompletion('CheckActivity.check', 'paid');
                                                                                                                                    
        $stub = $this->workflowClient->newWorkflowStub(   
            TimerActivityWorkflow::class,
            WorkflowOptions::new()                                                                                                  
                ->withTaskQueue('default')
                ->withWorkflowExecutionTimeout('PT2H'),                                                                             
        );                                                

        // Fails with TIMEOUT_TYPE_START_TO_CLOSE on the mocked activity.                                                           
        $result = $this->workflowClient->start($stub, 'p1')->getResult('string');
                                                                                                                                    
        self::assertSame('paid', $result);                
    }                                                                                                                               
}                                                         

Environment/Versions

  • OS and processor: Linux x86_64 (Fedora 43, kernel 6.19), running inside an Alpine-based Docker container.
  • Temporal Version: temporal/sdk 2.17, temporal-test-server (downloaded by Temporal\Testing\Downloader from official
    releases — version whichever SystemInfo::detect() resolves at the time of running).
  • Are you using Docker or Kubernetes or building Temporal from source? Docker (Alpine + RoadRunner 2024.x). Issue is independent
    of RR — same shape against a vanilla worker.
  • PHP: 8.4

Additional context

Suggested fixes, in order of preference:

  1. Make ActivityMocker participate in the test server's "activity is running" signal. Have it open and hold an activity
    heartbeat (or equivalent gRPC call) for the duration of the mocked response, so the server halts the virtual clock the same way it
    does for real activities. Without this, time-skipping is unusable for any workflow that mixes timers and activities — which is most
    non-trivial workflows.

  2. Document the limitation prominently. The current testing-suite docs (PHP
    Testing
    ) suggest ActivityMocker as the standard way to mock activities in
    workflow tests, with no warning that it's incompatible with timer-driven workflows under time-skipping. At minimum, a "Known
    limitations" section pointing at this issue would have saved us hours of debugging.

  3. Provide a worker-registered mock alternative. TS / Java / Go register mock activity implementations on the worker, which
    integrates with the activity-running detection. A PHP equivalent — e.g. a MockActivityRegistry you register on the worker before
    WorkerFactory::run() — would let users opt out of ActivityMocker for the cases where it can't work.

Workaround we're using: registering real PHP stub-classes that implement the activity interface on the test worker, then using
PHPUnit mocks/spies inside those stubs to assert/control behavior. Works, but requires writing per-test stub classes — much heavier
than ActivityMocker::expectCompletion.

Related issue: the test server also starts with Time Locking Counter = 1 (skipping locked) and PHP SDK never auto-unlocks around
getResult() / execute(), the way TS/Java/Go SDKs do. That's filed separately — but it compounds with this one: until both are
fixed, timer-driven PHP workflows with mocked activities can't be tested via the standard tools.

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions