|
8 | 8 | use Workflow\V2\Enums\HistoryEventType; |
9 | 9 | use Workflow\V2\Models\WorkflowHistoryEvent; |
10 | 10 | use Workflow\V2\Models\WorkflowRun; |
| 11 | +use Workflow\V2\Workflow; |
11 | 12 |
|
12 | 13 | final class WorkflowDefinitionFingerprint |
13 | 14 | { |
@@ -41,6 +42,78 @@ public static function matchesCurrent(WorkflowRun $run): ?bool |
41 | 42 | return hash_equals($recorded, $current); |
42 | 43 | } |
43 | 44 |
|
| 45 | + /** |
| 46 | + * Resolve the workflow class to use for a given in-flight run, preferring |
| 47 | + * the class that matches the `workflow_definition_fingerprint` recorded |
| 48 | + * in the run's `WorkflowStarted` history event. |
| 49 | + * |
| 50 | + * This keeps a run pinned to the definition it started under when a |
| 51 | + * deploy has promoted a new class under the same `workflow_type` while |
| 52 | + * the run is parked on a signal/timer. Without pinning the engine picks |
| 53 | + * the new class from `workflow_runs.workflow_class` and runs the wrong |
| 54 | + * code path against the existing history. |
| 55 | + * |
| 56 | + * Resolution order: |
| 57 | + * 1. Fast path — if no fingerprint was recorded (legacy run), or the |
| 58 | + * recorded fingerprint equals the current class's fingerprint, or |
| 59 | + * pinning is disabled via config, fall back to |
| 60 | + * {@see TypeRegistry::resolveWorkflowClass()}. |
| 61 | + * 2. Reverse-lookup — ask the definition registry for the class whose |
| 62 | + * source fingerprint matches the recorded hash. Requires the class |
| 63 | + * to have been seen by {@see WorkflowDefinition::fingerprint()} in |
| 64 | + * the current process. |
| 65 | + * 3. Fall back — if the registry has no match for the recorded |
| 66 | + * fingerprint, resolve via {@see TypeRegistry::resolveWorkflowClass()} |
| 67 | + * so the run still makes progress rather than failing hard. The |
| 68 | + * `RunDetailView` fingerprint-drift signal still surfaces the |
| 69 | + * mismatch to operators. |
| 70 | + * |
| 71 | + * Controlled by `workflows.v2.compatibility.pin_to_recorded_fingerprint` |
| 72 | + * (default `true`). Set to `false` to restore hot-swap behavior. |
| 73 | + * |
| 74 | + * @return class-string<Workflow> |
| 75 | + */ |
| 76 | + public static function resolveClassForRun(WorkflowRun $run): string |
| 77 | + { |
| 78 | + $fallbackClass = TypeRegistry::resolveWorkflowClass($run->workflow_class, $run->workflow_type); |
| 79 | + |
| 80 | + if (! self::pinningEnabled()) { |
| 81 | + return $fallbackClass; |
| 82 | + } |
| 83 | + |
| 84 | + $recorded = self::recordedForRun($run); |
| 85 | + |
| 86 | + if ($recorded === null) { |
| 87 | + return $fallbackClass; |
| 88 | + } |
| 89 | + |
| 90 | + // Warm the reverse index for the fallback class so repeated calls |
| 91 | + // for a run whose fingerprint matches the current class stay on the |
| 92 | + // fast path (O(1) hash-equals) instead of running a registry lookup. |
| 93 | + $currentFingerprint = WorkflowDefinition::fingerprint($fallbackClass); |
| 94 | + |
| 95 | + if ($currentFingerprint !== null && hash_equals($recorded, $currentFingerprint)) { |
| 96 | + return $fallbackClass; |
| 97 | + } |
| 98 | + |
| 99 | + $pinnedClass = WorkflowDefinition::findClassByFingerprint($recorded); |
| 100 | + |
| 101 | + if ($pinnedClass !== null && is_subclass_of($pinnedClass, Workflow::class)) { |
| 102 | + return $pinnedClass; |
| 103 | + } |
| 104 | + |
| 105 | + return $fallbackClass; |
| 106 | + } |
| 107 | + |
| 108 | + private static function pinningEnabled(): bool |
| 109 | + { |
| 110 | + $configured = function_exists('config') |
| 111 | + ? config('workflows.v2.compatibility.pin_to_recorded_fingerprint', true) |
| 112 | + : true; |
| 113 | + |
| 114 | + return (bool) $configured; |
| 115 | + } |
| 116 | + |
44 | 117 | private static function workflowStartedEvent(WorkflowRun $run): ?WorkflowHistoryEvent |
45 | 118 | { |
46 | 119 | if ($run->relationLoaded('historyEvents')) { |
|
0 commit comments