Skip to content

test(temporal): heartbeats and their cancellation, on a Laravel- and a Magento-hosted activity worker (#518) - #545

Merged
gplanchat merged 4 commits into
mainfrom
test/temporal-heartbeat-on-every-host
Sep 25, 2026
Merged

gplanchat merged 4 commits into
mainfrom
test/temporal-heartbeat-on-every-host

Conversation

@gplanchat

@gplanchat gplanchat commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Closes #518. Refs #510, #544.

Done when

  • The Laravel-hosted activity worker runs in the temporal-integration job. That job already runs phpunit --testsuite integration,temporal --fail-on-skipped over tests/integration/Temporal/ with DURABLE_TEMPORAL_ADDRESS set, and the root vendor/ has illuminate/*. The new tests live there, so no .github/workflows change is needed, and there is no patch to apply.
  • The heartbeat-outlives-its-timeout test passes there, and fails with the no-op sender (red shown below).
  • The cancellation test passes there.
  • Magento: the same. The worker is RuntimeFactory::activityWorker(), the class the module's di.xml wires, built with the same SharedActivityHeartbeatSender. The factory is plain PHP and runs from the root vendor/, so the Mage-OS job is not needed to host it.

How it works

  • Hosts. worker.php gains two roles, laravel-activity and magento-activity, each in a script under tests/integration/Temporal/Hosts/:
    • Laravel: DurableServiceProvider on a bare container with backend: temporal. The activities are resolved from that container, so they inject the provider's sender. The loop is durable:temporal-worker --role=activity --max-time; illuminate/console is not a root dependency, so the command itself cannot run.
    • Magento: RuntimeFactory(activityHandlers: …, temporalDsn: …, heartbeat: $shared)->activityWorker().
  • Tests. TemporalServerTestCase::activityWorkerRole() lets a test pick the host. HeartbeatOnAHostTestCase holds the two scenarios, run by LaravelHostedHeartbeatTest and MagentoHostedHeartbeatTest.
    • Heartbeat: 30 s of heartbeats, one a second, under a 5 s heartbeat timeout and RetryLimit::once().
    • Cancellation: the workflow's 3 s deadline cancels the heartbeating activity. The activity's next heartbeat returns true, and it writes a marker file the test reads. The run then stays open for 10 s: once it closes, a heartbeat hears "not found" rather than "cancel requested".

The project lead's conditions

  • DURABLE_HEARTBEAT=noop lives in the two host scripts only. Nothing under src/ reads it.
  • Every wait is bounded by wall-clock time.
    • The run's status is read with DescribeWorkflowExecution, which does not block, until a deadline; the result is read only once the run has closed.
    • The marker file is polled for at most 30 s.
    • Each host loop exits after DURABLE_HOST_WORKER_MAX_TIME (180 s by default).
    • The workers are killed in tearDown() whether the test passed or not; that is the existing base behaviour.
  • Extra CI time: about 90 s in temporal-integration (the four tests took 1:27 locally).

Red once, then green

Private temporalio/temporal:1.9.1 server (Server 1.32), extension grpc.

DURABLE_HEARTBEAT=noop … --filter OutlivesItsHeartbeatTimeout
1) LaravelHostedHeartbeatTest::testAHeartbeatingActivityOutlivesItsHeartbeatTimeout
The HeartbeatsThroughItsTimeout run did not close within 90 s; its activities are hosted by the laravel-activity worker.
1) MagentoHostedHeartbeatTest::testAHeartbeatingActivityOutlivesItsHeartbeatTimeout
The HeartbeatsThroughItsTimeout run did not close within 90 s; its activities are hosted by the magento-activity worker.
… --filter HostedHeartbeatTest
OK (4 tests, 8 assertions)   Time: 01:27

The cancellation test goes red with the no-op sender as well: the run reaches its deadline, and no heartbeat reports the cancellation, so the marker file is never written.

DURABLE_HEARTBEAT=noop … --filter HostedHeartbeatTest::testACancellation
1) LaravelHostedHeartbeatTest::testACancellationRequestedByTheServerReachesSendHeartbeat
2) MagentoHostedHeartbeatTest::testACancellationRequestedByTheServerReachesSendHeartbeat
Tests: 2, Assertions: 2, Failures: 2.

Why the red run shows a hang rather than an activity failure. With the no-op sender, the server does time the activity out (ActivityTaskTimedOut in the history). But the Temporal bridge ignores that event, so the workflow never fails and stays Running. That is a separate bug, filed as #544 (milestone 1), where the fix belongs. Here the bounded wait turns it into a red test instead of a stuck job.

Checks

  • The rest of the Temporal suite, run against the same server, passes: 117 tests. The only 2 errors are SearchAttributesTest, because my container was started without CI's --search-attribute flags.
  • The unit suite runs 2074 tests OK. PHPStan, Psalm and php-cs-fixer are clean. phpstan.neon's existing bare-container exception now also covers tests/integration/Temporal/Hosts/*.

gplanchat and others added 3 commits September 25, 2026 23:08
…vel or the Magento factory (#518)

worker.php takes two more roles, laravel-activity and magento-activity:
the activity worker DurableServiceProvider builds on a bare container
with backend: temporal, and the one RuntimeFactory::activityWorker()
builds. The heartbeating activities inject the sender each host wires
(#510). TemporalServerTestCase lets a subclass pick the activity role.

Each host loop exits after a bounded time. DURABLE_HEARTBEAT=noop, read
by these two scripts only, hands the activities the no-op sender to show
the heartbeat test red; nothing under src/ knows it. phpstan.neon's
existing bare-container exception extends to the host scripts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eaches it, on Laravel and Magento (#518)

Two workflows on the suite's workflow worker, their activities on the
host's: thirty seconds of heartbeats under a five-second heartbeat
timeout and one attempt, which completes; and a deadline that cancels a
heartbeating activity, whose next heartbeat returns true. The run stays
open for a few heartbeats after the cancellation: once it closes, a
heartbeat hears "not found", not "cancel requested".

Every wait is bounded by wall-clock time: the status is read with a
describe, which does not block, and the marker file is polled with a
deadline. The workers die in tearDown whether the test passed or not.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
gplanchat added a commit that referenced this pull request Sep 25, 2026
#518)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@gplanchat gplanchat left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of a3824cc (antoine), run against my own temporalio/temporal:1.9.1 container. Verdict: OK, no finding.

Runs

  • --filter '(Laravel|Magento)HostedHeartbeatTest' --fail-on-skipped: OK (4 tests). That is the heartbeat and the cancellation, on each host.
  • DURABLE_HEARTBEAT=noop on LaravelHostedHeartbeatTest::testAHeartbeatingActivityOutlivesItsHeartbeatTimeout: fails after exactly 90 s with "The HeartbeatsThroughItsTimeout run did not close within 90 s; its activities are hosted by the laravel-activity worker". The bound holds and the run does not hang, even with #544's TIMED_OUT still ignored.

(1) Each host's real wiring

  • Laravel: DurableServiceProvider is registered on a bare container. The activities come from $app->make(), so they inject the provider's ActivityHeartbeatSenderInterface singleton, the one the worker binds tokens onto. The registration target, $app->make(RegistryActivityExecutor::class), is the provider's singleton, which ActivityExecutor also resolves to (DurableServiceProvider:410,414). So the activities reach the worker's processor.
  • Magento: RuntimeFactory is given heartbeat: $shared, so activityWorker() delegates the SharedActivityHeartbeatSender to its own Temporal sender, and the activity holds that same $shared. That is what di.xml's preference plus the factory argument give on a real install.
  • In noop mode, Laravel overrides the interface binding before the worker is built, so both sides get the no-op sender. Magento hands only the activity the no-op sender.

(2) Bounds and teardown. Every wait has a wall-clock bound:

  • runWithin() polls DescribeWorkflowExecution with a 5 s RPC timeout, up to 90 s or 60 s;
  • the marker wait is capped at 30 s;
  • the host loops stop at DURABLE_HOST_WORKER_MAX_TIME (180 s).

The parent tearDown() terminates the started workflows and SIGKILLs every worker, and it runs whether the test passed or failed.

(3) The noop switch is read only in tests/integration/Temporal/Hosts/*. grep DURABLE_HEARTBEAT src finds nothing.

(4) The cancellation fixture sleeps 10 s after DeadlineExceededException before closing (IntegrationWorkflows.php:190-192), so heartbeats hear "cancel requested" rather than "not found". The marker assertion passes on both hosts.

phpstan.neon: tests/integration/Temporal/Hosts/* joins one existing narrow ignore, argument.type with the "expects Illuminate Application" message, the documented bare-container case. It is not an exclusion.

Gates: PHPStan, Psalm and cs:check 0.

@gplanchat

Copy link
Copy Markdown
Owner Author

Second review (bob) of a3824cc, alongside antoine. Verdict: changes needed, 1 finding.

F1: ci-ok is red, from PHPStan on the PHP 8.5 legs

Where: CI run 36190152536. The jobs Symfony 8.* / PHP 8.5 and Laravel 13.* / PHP 8.5 fail at the PHPStan step with 6 errors: Variable $argv might not be defined. (variable.undefined), 3 in tests/integration/Temporal/Hosts/magento-activity.php:22 and 3 in laravel-activity.php. ci-ok fails because of them.

Cause: phpstan.neon already ignores exactly this error for tests/integration/Temporal/worker.php. It's the entry whose comment says "The PHP CLI always defines $argv … Matches on the 8.5 legs only", with reportUnmatched: false. The two host scripts are required from worker.php but analysed as files of their own, so that entry doesn't reach them. PR #545's phpstan.neon change extends a different entry, the bare-container one.

Fix:

  • Either add tests/integration/Temporal/Hosts/* to the $argv entry's paths.
  • Or have the host scripts take what they need from worker.php's variables ($connection, $role) instead of reading $argv again.

The second is the smaller surface. Locally, PHPStan runs on PHP 8.2, which is why the body reports it clean.

Checked and OK: the two things I was asked to try to break

No harness process can leak on the runner.

  • spawnWorker() passes an argv array to proc_open, so the worker is exec'd with no sh -c wrapper. The SIGKILL in tearDown() reaches the PHP process itself, not a shell whose child would survive.
  • tearDown() runs after a failed test too, and also terminates the started workflows.
  • The two host scripts start no child processes (no proc_open, exec or fork).
  • Each host loop stops after DURABLE_HOST_WORKER_MAX_TIME, 180 s by default. So even if PHPUnit itself dies before tearDown(), no host worker outlives the job by more than 3 minutes.
  • The cancellation markers live in sys_get_temp_dir() and are unlinked in HeartbeatOnAHostTestCase::tearDown().

The red-run evidence matches what the test asserts.

  • "The HeartbeatsThroughItsTimeout run did not close within 90 s; its activities are hosted by the laravel-activity worker" is exactly runWithin()'s self::fail() message, from the wall-clock bound the heartbeat test asserts through (assertSame(['beats' => 30], $this->runWithin(…, 90.0))).
  • The no-op sender is set only by the host scripts (getenv('DURABLE_HEARTBEAT')), and proc_open passes getenv() through to the worker. So the variable in the red command really reaches the activity.
  • CI: both Temporal integration jobs pass with 126 tests (122 before), so the four new tests run there and are green.

Notes, not findings:

  • The heartbeat test's docblock says that with the no-op sender "it fails on its heartbeat timeout". What the red run shows is that the run never closes, because the bridge ignores ActivityTaskTimedOut ([Task] On Temporal, an activity that times out for good leaves its workflow Running forever #544). It would help to say that in the docblock, as the PR body already does.
  • The body shows a red run only for the heartbeat test. Reading the code, the cancellation test is red with the no-op sender too: sendHeartbeat() never returns true, the marker would only be written as never-cancelled after 60 s, and the test stops waiting at about 43 s. Showing that run would complete the evidence.

…run without it (#518, review)

PHPStan on PHP 8.5 reported $argv as possibly undefined in the two host
scripts, which it analyses as their own files. They now read
$_SERVER['argv'] and exit with a usage line when an argument is missing.
The heartbeat test's docblock says what the no-op run shows: the server
times the activity out, and until #544 the run did not even fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@gplanchat
gplanchat merged commit 641571b into main Sep 25, 2026
38 checks passed
@gplanchat

gplanchat commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner Author

Re-check (bob) at 46f0a1a: OK, F1 is resolved.

  • Both host scripts now read $_SERVER['argv'] behind a guard that prints a usage line and exits 1. There's no new phpstan.neon ignore.
  • CI on this head: Symfony 8 / PHP 8.5 and Laravel 13 / PHP 8.5 (PHPStan), the Magento module on PHP 8.5, and both Temporal integration jobs all pass, and ci-ok is green.
  • Both notes are taken: the heartbeat docblock now names the [Task] On Temporal, an activity that times out for good leaves its workflow Running forever #544 hang, and the body adds the cancellation test's red run with the no-op sender.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Task] A Laravel-hosted activity worker in the Temporal integration job, and the heartbeat timeout test

1 participant