Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
d93bd66
Reproduce missing runtime-only activity lease transport
rmcdaniel Oct 21, 2026
db16019
Describe the existing leased completion upload environment setting
rmcdaniel Oct 21, 2026
cf465b0
Expose fenced runtime-only activity lease renewal
rmcdaniel Oct 21, 2026
98dd633
Reproduce missing workflow deadline fences on activity delivery
rmcdaniel Oct 21, 2026
b003cd8
Cover expiry during renewal and external activity result resolution
rmcdaniel Oct 21, 2026
3867885
Project original workflow deadlines and refuse expired activity work
rmcdaniel Oct 21, 2026
9cf82f6
refactor: make activity lease renewal fences explicit
rmcdaniel Oct 21, 2026
5375963
test: reproduce activity leases retained after worker deregistration
rmcdaniel Oct 21, 2026
c7765d2
fix: revoke activity claims at worker deregistration
rmcdaniel Oct 21, 2026
d665687
Merge remote-tracking branch 'origin/main' into prep/878-worker-depar…
rmcdaniel Oct 21, 2026
48e0a16
Merge qualified Server tracing into activity lease preparation
rmcdaniel Oct 21, 2026
1d88a79
Merge remote-tracking branch 'origin/main' into prep/878-published-core
rmcdaniel Oct 21, 2026
18cf8f4
prep: adopt published Core 2.10.1 for guarded activity lease maintenance
rmcdaniel Oct 21, 2026
fbfaec8
Isolate deregistration migrations from transactional feature suites
rmcdaniel Oct 21, 2026
7a28201
Document and validate runtime-only activity lease renewal
rmcdaniel Oct 21, 2026
3507b97
Merge remote-tracking branch 'origin/main' into prep/878-published-core
rmcdaniel Oct 21, 2026
8a04f91
chore: prepare Server 2.11 and Helm 0.3 lease release
rmcdaniel Oct 21, 2026
7a73dd2
docs: explain previously ignored activity lease overrides
rmcdaniel Oct 21, 2026
cf51b36
docs: acknowledge the historical chart version exception
rmcdaniel Oct 21, 2026
e416342
Merge commit '9ed594784f5a568f47fb97d7b9cfe2bc0037ef2a' into prep/878…
rmcdaniel Oct 21, 2026
3c26df1
test: preserve installed PHP activity lease acceptance inputs
rmcdaniel Oct 21, 2026
d9aac03
Merge current main and official PHP base for lease qualification
rmcdaniel Oct 21, 2026
d6ca57b
test: exercise application log streams in actual Server roles
rmcdaniel Oct 21, 2026
52bc320
Default container application logs to stderr and verify published roles
rmcdaniel Oct 21, 2026
7ac8d2b
Keep historical immutable release recovery compatible with logging ro…
rmcdaniel Oct 21, 2026
2dc5017
Merge remote-tracking branch 'origin/main' into fix/618-container-log…
rmcdaniel Oct 21, 2026
ee079e7
fix(release): version logging chart defaults as 0.2.4
rmcdaniel Oct 22, 2026
17f15b1
fix(ci): scope first-run cleanup to unconditional job steps
rmcdaniel Oct 22, 2026
b0e4251
test(release): require successful complete recovery verification
rmcdaniel Oct 22, 2026
cc4dba7
Merge remote-tracking branch 'origin/main' into fix/618-container-log…
rmcdaniel Oct 22, 2026
b0be021
Merge remote-tracking branch 'origin/main' into prep/878-published-core
rmcdaniel Oct 22, 2026
aefb869
Merge qualified container logging into Server 2.11 candidate
rmcdaniel Oct 22, 2026
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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ APP_DEBUG=true
APP_URL=http://localhost:8080
# APP_VERSION defaults to the checked-in source release identity.

LOG_CHANNEL=stack
LOG_CHANNEL=stderr
LOG_LEVEL=debug

DB_CONNECTION=mysql
Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/published-release-recovery.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,30 @@ jobs:
TLS_TEST_ROOT: ${{ runner.temp }}/dw-server-tls-release-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
run: bash scripts/ci/qualify-database-tls.sh

- name: Verify existing image first-run readiness and container logging
env:
DW_SERVER_IMAGE: ghcr.io/durable-workflow/server@${{ steps.release_verify.outputs.image_digest }}
DW_FIRST_RUN_SCOPE: recovery-first-run-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
RELEASE_SOURCE_COMMIT: ${{ inputs.release_commit }}
run: |
if git rev-parse --verify "${RELEASE_SOURCE_COMMIT}:scripts/ci/verify-container-logging.mjs" >/dev/null 2>&1; then
scripts/smoke-published-first-run.sh
else
echo "This immutable release predates container logging acceptance; historical verification remains unchanged."
fi

- name: Clean up existing image first-run resources
if: always()
env:
DW_FIRST_RUN_SCOPE: recovery-first-run-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
LOGGING_TEST_PROJECT: recovery-first-run-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-logs
run: |
docker ps -aq --filter "label=com.docker.compose.project=$LOGGING_TEST_PROJECT" | xargs -r docker rm -f >/dev/null 2>&1 || true
docker network ls -q --filter "label=com.docker.compose.project=$LOGGING_TEST_PROJECT" | xargs -r docker network rm >/dev/null 2>&1 || true
docker volume ls -q --filter "label=com.docker.compose.project=$LOGGING_TEST_PROJECT" | xargs -r docker volume rm -f >/dev/null 2>&1 || true
docker rm -f "${DW_FIRST_RUN_SCOPE}-server" "${DW_FIRST_RUN_SCOPE}-bootstrap" "${DW_FIRST_RUN_SCOPE}-unbootstrapped" >/dev/null 2>&1 || true
docker volume rm "${DW_FIRST_RUN_SCOPE}-database" >/dev/null 2>&1 || true

- name: Clean up published image database TLS fixtures
if: always()
env:
Expand Down Expand Up @@ -93,6 +117,7 @@ jobs:
needs: verify
if: >-
always() &&
needs.verify.result == 'success' &&
needs.verify.outputs.release_verification_outcome == 'success' &&
needs.verify.outputs.published_database_tls_outcome == 'success' &&
github.repository == 'durable-workflow/server' &&
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -296,9 +296,14 @@ jobs:
if: always()
env:
DW_FIRST_RUN_SCOPE: release-first-run-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
LOGGING_TEST_PROJECT: release-first-run-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-logs
run: |
docker ps -aq --filter "label=com.docker.compose.project=$LOGGING_TEST_PROJECT" | xargs -r docker rm -f >/dev/null 2>&1 || true
docker network ls -q --filter "label=com.docker.compose.project=$LOGGING_TEST_PROJECT" | xargs -r docker network rm >/dev/null 2>&1 || true
docker volume ls -q --filter "label=com.docker.compose.project=$LOGGING_TEST_PROJECT" | xargs -r docker volume rm -f >/dev/null 2>&1 || true
docker rm -f \
"${DW_FIRST_RUN_SCOPE}-server" \
"${DW_FIRST_RUN_SCOPE}-unbootstrapped" \
"${DW_FIRST_RUN_SCOPE}-bootstrap" >/dev/null 2>&1 || true
docker volume rm -f \
"${DW_FIRST_RUN_SCOPE}-database" >/dev/null 2>&1 || true
Expand Down
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ ENV DW_WORKER_LONG_POLL_MAX_CONCURRENT=2 \
DB_CONNECTION=sqlite \
DB_DATABASE=/app/database/database.sqlite \
QUEUE_CONNECTION=database \
CACHE_STORE=file
CACHE_STORE=file \
LOG_CHANNEL=stderr

ENTRYPOINT ["server-entrypoint"]

Expand Down
134 changes: 120 additions & 14 deletions app/Http/Controllers/Api/ActivityTaskController.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Workflow\V2\Contracts\ActivityLeaseRenewal;
use Workflow\V2\Contracts\ActivityTaskBridge as ActivityTaskBridgeContract;
use Workflow\V2\Enums\HistoryEventType;
use Workflow\V2\Exceptions\ExternalPayloadIntegrityException;
Expand Down Expand Up @@ -219,6 +220,7 @@ public function poll(Request $request): JsonResponse
'external_executor' => $externalExecutor,
...WorkflowObservabilityPoll::forActivityClaim((string) $namespace, $claim),
], static fn (mixed $v): bool => $v !== null)),
'server_time' => now()->toJSON(),
'poll_status' => is_string($poll['poll_status'] ?? null)
? $poll['poll_status']
: ($claim === null ? 'empty' : 'leased'),
Expand Down Expand Up @@ -494,12 +496,10 @@ public function status(Request $request, string $taskId): JsonResponse
$status['can_continue'] = false;
$status['reason'] = 'lease_expired';
}
foreach (['heartbeat', 'start_to_close', 'schedule_to_close'] as $kind) {
if ($status['can_continue'] === true && isset($deadlines[$kind])
&& ! CarbonImmutable::parse($deadlines[$kind])->isAfter(now())) {
$status['can_continue'] = false;
$status['reason'] = $kind.'_timeout';
}
$expiredReason = $this->expiredDeadlineReason($deadlines);
if ($status['can_continue'] === true && $expiredReason !== null) {
$status['can_continue'] = false;
$status['reason'] = $expiredReason;
}
if ($status['can_continue'] === true && $workerSession !== null) {
$sessionLease = $workerSession['lease_expires_at'] ?? null;
Expand Down Expand Up @@ -694,6 +694,103 @@ private function activityCancellationReceipt(
];
}

public function renewLease(Request $request, string $taskId): JsonResponse
{
if ($response = WorkerProtocol::rejectUnsupported($request)) {
return $response;
}

$bridge = app(ActivityTaskBridgeContract::class);
if (! $bridge instanceof ActivityLeaseRenewal) {
return WorkerProtocol::json([
'lease_renewed' => false,
'can_continue' => false,
'reason' => 'activity_lease_renewal_unavailable',
'error' => 'The configured activity bridge does not support runtime lease renewal.',
], 422);
}

try {
$response = $this->renewOwnedActivityLease($request, $taskId, $bridge);
} catch (\Throwable $exception) {
if (! BackendLockPressure::is($exception)) {
throw $exception;
}

$response = BackendLockPressure::workerOperationResponse($request, false);
}

return $response;
}

private function renewOwnedActivityLease(Request $request, string $taskId, ActivityLeaseRenewal $bridge): JsonResponse
{
$validated = $request->validate([
'activity_attempt_id' => ['required', 'string'],
'lease_owner' => ['required', 'string'],
]);
$namespace = $request->attributes->get('namespace');
$observed = null;
if ($response = $this->leaseRenewalRefusal($namespace, $taskId, $validated, $observed)) {
return $response;
}

$workerSession = $this->storageMutations->run(
fn (): ?array => $this->workerSessions->heartbeatForAttempt(
$namespace,
$validated['activity_attempt_id'],
$validated['lease_owner'],
),
);
if ($workerSession !== null && ($workerSession['admitted'] ?? false) !== true) {
return WorkerProtocol::json([
...$observed,
'lease_renewed' => false,
'can_continue' => false,
'reason' => $workerSession['reason'] ?? 'worker_session_renewal_failed',
'worker_session' => $this->workerSessionSnapshotFromRenewal($workerSession),
'worker_session_renewal' => $this->workerSessionRenewalStatusPayload($workerSession),
], $this->workerSessionRenewalHttpStatus($workerSession));
}

$status = $this->storageMutations->run(
static fn (): array => $bridge->renewLease($validated['activity_attempt_id'], $validated['lease_owner']),
);

return WorkerProtocol::json([
...$status,
'task_id' => $taskId,
'worker_session' => $workerSession['session'] ?? null,
], $this->outcomeStatus($status['reason'] ?? null));
}

private function leaseRenewalRefusal(string $namespace, string $taskId, array $validated, ?array &$observed): ?JsonResponse
{
if ($response = $this->guardAttemptOwnership(
$namespace,
$taskId,
$validated['activity_attempt_id'],
$validated['lease_owner'],
$observed,
)) {
return $response;
}

if (($observed['can_continue'] ?? false) !== true) {
return WorkerProtocol::json([
...$observed, 'lease_renewed' => false,
], $this->outcomeStatus($observed['reason'] ?? null));
}

$expiredReason = $this->expiredDeadlineReason(
$this->executionDeadlines($observed['activity_execution_id'] ?? null) ?? [],
);

return $expiredReason === null ? null : WorkerProtocol::json([
...$observed, 'lease_renewed' => false, 'can_continue' => false, 'reason' => $expiredReason,
], 409);
}

/**
* Heartbeat an in-progress activity task.
*
Expand Down Expand Up @@ -1031,18 +1128,13 @@ private function guardOutcomeAuthority(string $taskId, string $attemptId, array
$reason = 'lease_expired';
} else {
$deadlines = $this->executionDeadlines($status['activity_execution_id'] ?? null) ?? [];
foreach (['heartbeat', 'start_to_close', 'schedule_to_close'] as $kind) {
if (isset($deadlines[$kind]) && ! CarbonImmutable::parse($deadlines[$kind])->isAfter(now())) {
$reason = $kind.'_timeout';
break;
}
}
$reason = $this->expiredDeadlineReason($deadlines);
}
}

// Closed, cancelled and reclaimed attempts retain their existing handling,
// including the committed completion receipt and cancellation diagnostics.
if (! in_array($reason, ['lease_expired', 'heartbeat_timeout', 'start_to_close_timeout', 'schedule_to_close_timeout'], true)) {
if (! in_array($reason, ['lease_expired', 'heartbeat_timeout', 'start_to_close_timeout', 'schedule_to_close_timeout', 'run_timeout', 'execution_timeout'], true)) {
return null;
}

Expand Down Expand Up @@ -1184,7 +1276,7 @@ private function executionDeadlines(?string $executionId): ?array
}

/** @var ActivityExecution|null $execution */
$execution = ActivityExecution::query()->find($executionId);
$execution = ActivityExecution::query()->with('run')->find($executionId);

if (! $execution) {
return null;
Expand All @@ -1195,11 +1287,25 @@ private function executionDeadlines(?string $executionId): ?array
'start_to_close' => $this->deadlineTimestamp($execution->close_deadline_at),
'schedule_to_close' => $this->deadlineTimestamp($execution->schedule_to_close_deadline_at),
'heartbeat' => $this->deadlineTimestamp($execution->heartbeat_deadline_at),
'run' => $this->deadlineTimestamp($execution->run?->run_deadline_at),
'execution' => $this->deadlineTimestamp($execution->run?->execution_deadline_at),
], static fn (mixed $v): bool => $v !== null);

return $deadlines !== [] ? $deadlines : null;
}

/** @param array<string, string> $deadlines */
private function expiredDeadlineReason(array $deadlines): ?string
{
foreach (['heartbeat', 'start_to_close', 'schedule_to_close', 'run', 'execution'] as $kind) {
if (isset($deadlines[$kind]) && ! CarbonImmutable::parse($deadlines[$kind])->isAfter(now())) {
return $kind.'_timeout';
}
}

return null;
}

private function deadlineTimestamp(?CarbonInterface $deadline): ?string
{
return $deadline?->copy()
Expand Down
15 changes: 14 additions & 1 deletion app/Http/Controllers/Api/WorkerDeregistrationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,21 @@ public function destroy(Request $request, string $workerId): JsonResponse
return $response;
}

return $this->completeLegacyDeregistration($request, $workerId);
}

private function completeLegacyDeregistration(Request $request, string $workerId): JsonResponse
{
$namespace = (string) $request->attributes->get('namespace');
$recoveredWorkflowTaskCount = $this->registrar->deregister($request, $namespace, $workerId);
try {
$recoveredWorkflowTaskCount = $this->registrar->deregister($request, $namespace, $workerId);
} catch (\Throwable $exception) {
if (! BackendLockPressure::is($exception)) {
throw $exception;
}

return BackendLockPressure::workerOperationResponse($request, false);
}

if ($recoveredWorkflowTaskCount === null) {
return WorkerProtocol::json([
Expand Down
2 changes: 2 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use App\Observers\WorkflowHistoryEventObserver;
use App\Observers\WorkflowTaskObserver;
use App\Observers\WorkflowUpdateValidationObserver;
use App\Support\ActivityTaskLeaseConfiguration;
use App\Support\DispatchObservability;
use App\Support\ExternalWorkflowUpdateAdmission;
use App\Support\NamespaceExternalPayloadStorage;
Expand Down Expand Up @@ -202,6 +203,7 @@ public function boot(): void
WorkflowPackageApiFloor::assert();

WorkflowTaskLeaseConfiguration::apply();
ActivityTaskLeaseConfiguration::apply();
WorkflowMemoRollingCompatibility::register();

config([
Expand Down
30 changes: 30 additions & 0 deletions app/Support/ActivityTaskLeaseConfiguration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace App\Support;

use Workflow\V2\Support\ActivityLease;

final class ActivityTaskLeaseConfiguration
{
public static function apply(): int
{
$configured = config('server.lease.activity_task_timeout', 300);
$seconds = is_int($configured) || is_string($configured)
? filter_var($configured, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1, 'max_range' => 86_400],
]) : false;
if ($seconds === false) {
throw new ActivityTaskLeaseConfigurationException('DW_ACTIVITY_TASK_TIMEOUT must be an integer between 1 and 86400 seconds.');
}

config(['server.lease.activity_task_timeout' => $seconds]);
if (defined(ActivityLease::class.'::CONFIG_KEY') && method_exists(ActivityLease::class, 'seconds')) {
config([ActivityLease::CONFIG_KEY => $seconds]);
if (ActivityLease::seconds() !== $seconds) {
throw new ActivityTaskLeaseConfigurationException('The configured activity-task lease does not match the Workflow runtime.');
}
}

return $seconds;
}
}
7 changes: 7 additions & 0 deletions app/Support/ActivityTaskLeaseConfigurationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace App\Support;

use RuntimeException;

final class ActivityTaskLeaseConfigurationException extends RuntimeException {}
Loading
Loading