Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
89ced9f
Preserve fractional persisted replay clocks in redrive
rmcdaniel Nov 13, 2026
d8d57ff
Keep whole-second corpus coverage beside fractional clock controls
rmcdaniel Nov 13, 2026
3cdab09
Assert full persisted instants in native redrive controls
rmcdaniel Nov 13, 2026
a990a42
Format fractional clock regression controls
rmcdaniel Nov 13, 2026
d6a1962
Preserve scheduled timer deadlines on older MariaDB schemas
rmcdaniel Nov 13, 2026
48b8bfa
Format timer deadline migration controls
rmcdaniel Nov 13, 2026
12fca2a
Keep migration assertion formatting stable
rmcdaniel Nov 13, 2026
2560388
Fence timer migration to the exact legacy timestamp metadata
rmcdaniel Nov 13, 2026
f494118
Format exact timer metadata guards and controls
rmcdaniel Nov 13, 2026
8f895c4
Merge accepted entity-reference main into replay clock precision
rmcdaniel Nov 13, 2026
83c605c
Keep locking parent history reads on the existing run index
rmcdaniel Nov 13, 2026
8a06698
Merge accepted Laravel floor main into parent-history lock correction
rmcdaniel Nov 13, 2026
7315349
Compose reviewed timer and parent-history correctness fixes
rmcdaniel Nov 14, 2026
e4e5f5a
Compose reviewed fractional replay clocks with native backend fixes
rmcdaniel Nov 14, 2026
12467c2
Isolate frozen-clock watchdog recovery from live queue workers
rmcdaniel Nov 13, 2026
fdfbb30
Fix MariaDB schema refusal and lock test exception types
rmcdaniel Nov 14, 2026
70b2d93
Merge accepted child-start main into Core fixes
rmcdaniel Nov 14, 2026
34a631f
Preserve MariaDB service dispatch timestamps and cover migration guards
rmcdaniel Nov 14, 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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## Unreleased

- **Run migrations on MariaDB.** Timer status and service-dispatch metadata
updates preserve their scheduled timestamps on older MariaDB schemas. The
forward migrations remove implicit timestamp updates without rewriting
retained rows. Condition occurrence
reopens use the original scheduled history deadline, including when an older
cancelled timer row has already drifted (#1139).

- Concurrent child completion keeps parent-history row locks on the existing
per-run index on MySQL and MariaDB, avoiding an unrelated sibling history
row in the parent barrier lock cycle. No new index or retry is introduced.

- Experimental embedded child-start milestone: `Workflow::startChild()` returns
a versioned `StartedChildWorkflow` receipt after native child admission, and
`$child->await()` observes that same child later. Cold replay reuses the
Expand All @@ -15,6 +26,9 @@
- Require patched Laravel 12.72 or 13.41 minimums for the current Composer
security advisories. Keep existing application audits enabled and update
application lockfiles before upgrading Workflow (#1133).
- Failed-run redrive preserves fractional seconds in replay clocks for the
original run start and reused steps. Repeated redrive keeps previously
recorded clock strings unchanged.

- Experimental domain entity references for top-level arguments of embedded
queued and local activities (#996). Register an entity class, deterministic
Expand Down
38 changes: 38 additions & 0 deletions src/Support/WorkflowMigration.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@

use Illuminate\Database\Connection;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\MySqlConnection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use UnexpectedValueException;

abstract class WorkflowMigration extends Migration
{
Expand All @@ -16,6 +18,42 @@ public function getConnection(): ?string
return config('workflows.storage.connection') ?? $this->connection;
}

protected function preserveMariaDbScheduledTimestamp(string $table, string $column, string $refusal): void
{
$connection = DB::connection($this->getConnection());
if (! $connection instanceof MySqlConnection || ! $connection->isMaria()) {
return;
}

// Older MariaDB implicitly rewrites its first non-null TIMESTAMP.
// Remove only that behavior, preserving the rest of its definition.
$metadata = $connection->selectOne(
'SELECT COLUMN_TYPE AS column_type, IS_NULLABLE AS is_nullable, COLUMN_DEFAULT AS default_value, '
. 'DATETIME_PRECISION AS datetime_precision, EXTRA AS extra FROM information_schema.columns '
. 'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
[$connection->getTablePrefix() . $table, $column],
);
if ($metadata === null || ! str_contains(strtolower((string) $metadata->extra), 'on update')) {
return;
}
if (strtolower((string) $metadata->column_type) !== 'timestamp(6)'
|| $metadata->is_nullable !== 'NO'
|| strtolower((string) $metadata->default_value) !== 'current_timestamp(6)'
|| (int) $metadata->datetime_precision !== 6
|| strtolower((string) $metadata->extra) !== 'on update current_timestamp(6)') {
throw new UnexpectedValueException($refusal);
}

$grammar = $connection->getQueryGrammar();
$wrappedTable = $grammar->wrapTable($table);
$wrappedColumn = $grammar->wrap($column);
$this->withSchemaLockBudget(static function () use ($connection, $wrappedTable, $wrappedColumn): void {
$connection->statement(
"ALTER TABLE {$wrappedTable} MODIFY {$wrappedColumn} TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), ALGORITHM=INPLACE, LOCK=NONE",
);
});
}

/**
* Run a short schema change with a one-second lock wait.
*
Expand Down
6 changes: 4 additions & 2 deletions src/V2/Support/ConditionOccurrenceDeadline.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,14 @@ public static function resolve(WorkflowRun $run, array $command): array
->whereKey($first['timer_id'])
->first();
if (! $timer instanceof WorkflowTimer || (int) $timer->sequence !== $first['sequence']
|| ! $timer->fire_at instanceof CarbonInterface) {
|| ! $first['deadline_at'] instanceof CarbonInterface) {
throw new ConditionOccurrenceDeadlineConflict('condition_wait_anchor_unavailable');
}

return [
'deadline_at' => $timer->fire_at,
// The scheduled history retains the first deadline even if an
// older MariaDB schema rewrote the cancelled timer row's fire_at.
'deadline_at' => $first['deadline_at'],
'keep_open' => $keepOpen,
];
}
Expand Down
2 changes: 1 addition & 1 deletion src/V2/Support/DefaultWorkflowControlPlane.php
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ public function redrive(string $instanceId, string $failedRunId, array $options
]),
'replayed_started_at' => is_string($startedPayload['replayed_started_at'] ?? null)
? $startedPayload['replayed_started_at']
: $source->started_at?->toIso8601String(),
: RedriveReplayClock::formatRecordedAt($source->started_at),
'workflow_link_id' => $link->id,
],
), null, $command);
Expand Down
12 changes: 6 additions & 6 deletions src/V2/Support/FailedRunRedrivePlan.php
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ public static function forRun(WorkflowRun $run): array
$reusedSteps[] = [
'event_type' => $marker->event_type,
'payload' => $markerPayload,
'recorded_at' => $marker->recorded_at?->toIso8601String(),
'recorded_at' => RedriveReplayClock::formatRecordedAt($marker->recorded_at),
];

continue;
Expand Down Expand Up @@ -462,7 +462,7 @@ public static function forRun(WorkflowRun $run): array
$reusedSteps[] = [
'event_type' => HistoryEventType::ActivityCompleted,
'payload' => $payload,
'recorded_at' => $completion->recorded_at?->toIso8601String(),
'recorded_at' => RedriveReplayClock::formatRecordedAt($completion->recorded_at),
];
}

Expand Down Expand Up @@ -914,7 +914,7 @@ private static function updateBundles(
$bundleEvents[] = [
'event_type' => $event->event_type,
'payload' => $eventPayload,
'recorded_at' => $event->recorded_at?->toIso8601String(),
'recorded_at' => RedriveReplayClock::formatRecordedAt($event->recorded_at),
];
}

Expand Down Expand Up @@ -1031,7 +1031,7 @@ private static function firedTimerStep(array $events): ?array
return [
'event_type' => HistoryEventType::TimerFired,
'payload' => $firedPayload,
'recorded_at' => $fired->recorded_at?->toIso8601String(),
'recorded_at' => RedriveReplayClock::formatRecordedAt($fired->recorded_at),
'scheduled_payload' => $scheduledPayload,
];
}
Expand Down Expand Up @@ -1182,7 +1182,7 @@ private static function signalSteps(array $events, WorkflowRun $run, array $prov
$signalEvents[] = [
'event_type' => $event->event_type,
'payload' => $eventPayload,
'recorded_at' => $event->recorded_at?->toIso8601String(),
'recorded_at' => RedriveReplayClock::formatRecordedAt($event->recorded_at),
];
}

Expand Down Expand Up @@ -1325,7 +1325,7 @@ private static function completedChildStep(array $events, WorkflowRun $run, arra
$childEvents[] = [
'event_type' => $event->event_type,
'payload' => $eventPayload,
'recorded_at' => $event->recorded_at?->toIso8601String(),
'recorded_at' => RedriveReplayClock::formatRecordedAt($event->recorded_at),
];
}

Expand Down
31 changes: 27 additions & 4 deletions src/V2/Support/ParallelChildGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Workflow\V2\Support;

use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Schema\Blueprint;
use Workflow\V2\Enums\ActivityStatus;
use Workflow\V2\Enums\HistoryEventType;
use Workflow\V2\Enums\RunStatus;
Expand Down Expand Up @@ -732,10 +734,10 @@ private static function persistSelectionWinner(
$events = $parentRun->historyEvents();

if ($lockHistoryForUpdate) {
$events->lockForUpdate();
$parentRun->setRelation('historyEvents', self::lockedParentHistory($parentRun));
} else {
$parentRun->setRelation('historyEvents', $events->get());
}

$parentRun->setRelation('historyEvents', $events->get());
$groupId = $metadata['parallel_group_id'];
$memberBase = self::intValue($metadata['selection_member_base_sequence'] ?? null)
?? ($metadata['parallel_group_base_sequence'] + $metadata['parallel_group_index']);
Expand Down Expand Up @@ -800,7 +802,7 @@ private static function groupCompletedSuccessfully(
if ($lockHistoryForUpdate) {
// Observe the resolution event committed by the previous holder of
// the parent lock even when this transaction has an older snapshot.
$parentRun->setRelation('historyEvents', $parentRun->historyEvents() ->lockForUpdate() ->get());
$parentRun->setRelation('historyEvents', self::lockedParentHistory($parentRun));
}

$activitiesBySequence = collect(RunActivityView::activitiesForRun($parentRun, decodePayloads: false))
Expand Down Expand Up @@ -876,6 +878,27 @@ private static function groupCompletedSuccessfully(
return true;
}

/**
* @return Collection<int, WorkflowHistoryEvent>
*/
private static function lockedParentHistory(WorkflowRun $parentRun): Collection
{
$events = $parentRun->historyEvents();
$model = $events->getRelated();
$connection = $model->getConnection();
if (in_array($connection->getDriverName(), ['mysql', 'mariadb'], true)) {
// A table scan can lock an uncommitted sibling event while that
// sibling waits for our parent-run lock. Use the migration's
// per-run index, including Laravel's configured table/index prefix.
$connection->getSchemaBuilder();
$index = (new Blueprint($connection, $model->getTable()))
->unique(['workflow_run_id', 'sequence'])->index;
$events->forceIndex($index);
}
return $events->lockForUpdate()
->get();
}

/**
* @param array<string, mixed> $payload
* @return array{
Expand Down
10 changes: 10 additions & 0 deletions src/V2/Support/RedriveReplayClock.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@

final class RedriveReplayClock
{
public static function formatRecordedAt(?CarbonInterface $time): ?string
{
if ($time === null) {
return null;
}
return $time->format('u') === '000000'
? $time->toIso8601String()
: $time->format('Y-m-d\TH:i:s.uP');
}

public static function startedAt(WorkflowRun $run): ?CarbonInterface
{
if ($run->run_number <= 1) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

use Workflow\Support\WorkflowMigration;

return new class() extends WorkflowMigration {
public $withinTransaction = false;

public function up(): void
{
$this->preserveMariaDbScheduledTimestamp(
'workflow_run_timers',
'fire_at',
'workflow_timer_fire_at_legacy_shape_unavailable',
);
}

public function down(): void
{
// Older runtime code can use the corrected column. Reinstating an
// automatic deadline rewrite would corrupt retained timer evidence.
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

use Workflow\Support\WorkflowMigration;

return new class() extends WorkflowMigration {
public $withinTransaction = false;

public function up(): void
{
$this->preserveMariaDbScheduledTimestamp(
'workflow_service_call_dispatches',
'available_at',
'workflow_service_dispatch_available_at_legacy_shape_unavailable',
);
}

public function down(): void
{
// Preserve scheduling evidence rather than restore automatic rewrites.
}
};
7 changes: 5 additions & 2 deletions tests/Feature/V2/V2ActivityLeaseRepairLockTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Tests\Feature\V2;

use Illuminate\Database\DeadlockException;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
Expand Down Expand Up @@ -69,11 +70,13 @@ public function testLeaseRepairDoesNotWaitForAnAttemptWhileHoldingItsRun(): void
$run = WorkflowRun::query()->lockForUpdate()->findOrFail($task->workflow_run_id);
TaskRepair::recoverExistingTask($task->fresh(), $run);
});
} catch (QueryException $error) {
$failure = $error;
} catch (QueryException|DeadlockException $error) {
// Laravel wraps MariaDB NOWAIT contention in nested transactions.
$failure = $error instanceof DeadlockException ? $error->getPrevious() : $error;
}
$elapsed = (hrtime(true) - $started) / 1e9;
$this->assertInstanceOf(QueryException::class, $failure);
$this->assertStringContainsString('for update nowait', $failure->getSql());
$this->assertLessThan(
2.0,
$elapsed,
Expand Down
Loading
Loading