Skip to content

Commit 74ade2b

Browse files
authored
Merge pull request #891 from durable-workflow/fix/878-embedded-activity-maintenance
Maintain embedded activity leases without moving callbacks
2 parents cafcd20 + ccf88d3 commit 74ade2b

16 files changed

Lines changed: 1342 additions & 0 deletions

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ Server or Cloud does not reinterpret existing embedded history.
130130
- [Embedded installation](https://durable-workflow.com/docs/2.0/installation/)
131131
- [Embedded feature guides](https://durable-workflow.com/docs/2.0/category/embedded/)
132132
- [Embedded live execution](docs/workflow/embedded-live-execution.md)
133+
- [Embedded activity leases](docs/workflow/embedded-activity-leases.md)
133134
- [Configuration reference](https://durable-workflow.com/docs/2.0/configuration/options/)
134135
- [Deployment modes](https://durable-workflow.com/docs/2.0/polyglot/deployment-modes/)
135136
- [Monitoring with Waterline](https://durable-workflow.com/docs/2.0/monitoring/)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Embedded activity leases
2+
3+
A lease records which attempt owns an activity. It is separate from the activity's
4+
start-to-close, schedule-to-close and heartbeat timeouts, and from workflow run
5+
and execution timeouts.
6+
7+
For committed activities on persistent storage, the runtime starts an independent
8+
PHP metadata process before invoking the activity. The callback stays in its
9+
original PHP process, with its Laravel services, captured state and connections.
10+
The helper renews only that original attempt and stops when its owner exits.
11+
It is joined before the callback outcome is recorded.
12+
13+
The helper uses the original database connection settings, including its TLS and
14+
authentication configuration. Automatic maintenance needs `proc_open` and an
15+
available PHP CLI executable. Laravel's existing Symfony executable finder locates the CLI binary
16+
when the activity is invoked from another PHP SAPI. No activity arguments,
17+
results or exception payloads are sent to the helper.
18+
19+
SQLite maintenance uses the database's current journal mode. Opening the helper
20+
does not replay a journal-mode setting that the running application has changed.
21+
22+
If process creation or a PHP CLI binary is unavailable, the callback keeps its
23+
original captured lease and deadline. Short activities retain their previous
24+
behavior, but automatic renewal is unavailable and grants no additional time.
25+
26+
Runtime renewal records no heartbeat event and supplies no application progress.
27+
Activities with a heartbeat timeout must still call `heartbeat()` with genuine
28+
progress. Renewal cannot extend a recorded business deadline, revive an expired
29+
grant, transfer the attempt to another worker, or authorize a late result.
30+
Brief database contention retries only inside the last confirmed grant. Loss of
31+
authority fences the outcome; it cannot interrupt arbitrary blocking PHP code or
32+
undo an external side effect.
33+
34+
`DW_V2_ACTIVITY_TASK_LEASE_SECONDS` configures the ownership lease, with a default
35+
of 300 seconds and valid values from 1 through 86400. Increasing it also increases
36+
how long recovery may wait after an unannounced worker death. It is not an
37+
activity execution timeout. Keep the usual worker, repair and timeout processing
38+
running so expired attempts receive a durable recovery outcome.
39+
40+
In-memory databases, uncommitted outer transactions, mocked activities and a
41+
frozen application test clock have no independently observable live grant. They
42+
retain the captured lease without an independent helper. Use persistent storage
43+
and a real clock when qualifying long-running queue workers. Custom database
44+
connectors must be available to the fresh metadata process; application-specific
45+
connector bootstraps are not qualified by the built-in driver tests.
46+
47+
Legacy activity jobs that start after their workflow has completed or failed
48+
retain the original captured lease and outcome handling. They receive no new
49+
renewal authority. An activity retained by the runtime's explicit abandon policy
50+
can renew only within that policy's original recorded deadlines.

‎src/V2/Jobs/RunActivityTask.php‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Workflow\V2\Support\ActivityRetryPolicy;
1717
use Workflow\V2\Support\ActivityTaskClaim;
1818
use Workflow\V2\Support\ActivityTaskClaimer;
19+
use Workflow\V2\Support\EmbeddedActivityLease;
1920
use Workflow\V2\Support\EntryMethod;
2021
use Workflow\V2\Support\ExecutionTracing;
2122
use Workflow\V2\Support\RuntimeObjectFactory;
@@ -100,6 +101,16 @@ public function handle(): void
100101
}
101102

102103
private function executeClaim(ActivityTaskClaim $claim): void
104+
{
105+
$maintenance = EmbeddedActivityLease::start($claim);
106+
try {
107+
$this->executeMaintainedClaim($claim, $maintenance);
108+
} finally {
109+
$maintenance?->stop();
110+
}
111+
}
112+
113+
private function executeMaintainedClaim(ActivityTaskClaim $claim, ?EmbeddedActivityLease $maintenance): void
103114
{
104115
$attemptId = $claim->attemptId();
105116
$attemptCount = $claim->attemptNumber();
@@ -150,6 +161,8 @@ private function executeClaim(ActivityTaskClaim $claim): void
150161
}
151162
$backoffSeconds = ActivityRetryPolicy::backoffSeconds($execution, $activity, $attemptCount);
152163

164+
$maintenance?->confirm();
165+
153166
$outcome = ActivityOutcomeRecorder::record(
154167
$this->taskId,
155168
$attemptId,

‎src/V2/Support/ActivityTaskClaim.php‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ final class ActivityTaskClaim
3838

3939
private ?int $lastRenewalRequestStartedAt = null;
4040

41+
private ?int $maintainedAdmissionDeadline = null;
42+
4143
public function __construct(
4244
public readonly WorkflowTask $task,
4345
public readonly WorkflowRun $run,
@@ -91,6 +93,11 @@ public function __construct(
9193
*/
9294
public function outcomeAdmissionDeadline(): int
9395
{
96+
if ($this->maintainedAdmissionDeadline !== null) {
97+
return $this->businessAdmissionDeadline === null
98+
? $this->maintainedAdmissionDeadline
99+
: min($this->maintainedAdmissionDeadline, $this->businessAdmissionDeadline);
100+
}
94101
$deadline = $this->businessAdmissionDeadline === null
95102
? $this->leaseAdmissionDeadline
96103
: min($this->leaseAdmissionDeadline, $this->businessAdmissionDeadline);
@@ -99,6 +106,45 @@ public function outcomeAdmissionDeadline(): int
99106
? $deadline : min($deadline, $this->heartbeatAdmissionDeadline);
100107
}
101108

109+
/**
110+
* @internal The independent maintainer validates every intermediate original grant.
111+
*/
112+
public function maintenanceRequestDeadline(): int
113+
{
114+
$deadline = hrtime(true) + 5_000_000_000;
115+
116+
return $this->businessAdmissionDeadline === null ? $deadline : min($deadline, $this->businessAdmissionDeadline);
117+
}
118+
119+
/**
120+
* @return array<string, mixed>
121+
*/
122+
public function identity(): array
123+
{
124+
return $this->identity;
125+
}
126+
127+
/**
128+
* @internal A fresh proof from this claim's private metadata process, never a remote Worker response.
129+
* @param array<string, mixed> $reply
130+
*/
131+
public function acknowledgeMaintenance(array $reply, int $started): bool
132+
{
133+
$received = hrtime(true);
134+
if ($started < $this->claimedAt || $started > $received
135+
|| ($reply['can_continue'] ?? null) !== true
136+
|| ($reply['identity'] ?? null) !== $this->identity
137+
|| ! is_int($reply['monotonic'] ?? null) || $reply['monotonic'] < $started
138+
|| $reply['monotonic'] > $received || ! is_int($reply['until'] ?? null)
139+
|| $reply['until'] <= $received || $reply['until'] - $received > 86_400_000_000_000
140+
|| ($this->businessAdmissionDeadline !== null && $received >= $this->businessAdmissionDeadline)) {
141+
return false;
142+
}
143+
$this->maintainedAdmissionDeadline = $reply['until'];
144+
145+
return true;
146+
}
147+
102148
/**
103149
* A successful transport response may extend only its original live claim.
104150
* Capture the request clock before renewal begins so its latency consumes budget.

0 commit comments

Comments
 (0)