|
8 | 8 | use Filament\Actions\ActionGroup; |
9 | 9 | use Illuminate\Database\Eloquent\Builder; |
10 | 10 | use Illuminate\Database\Eloquent\Model; |
| 11 | +use Illuminate\Database\QueryException; |
11 | 12 | use Illuminate\Support\Facades\DB; |
| 13 | +use Illuminate\Support\Facades\Log; |
12 | 14 | use InvalidArgumentException; |
13 | 15 | use Relaticle\Flowforge\Board; |
| 16 | +use Relaticle\Flowforge\Exceptions\MaxRetriesExceededException; |
14 | 17 | use Relaticle\Flowforge\Services\Rank; |
15 | 18 | use Throwable; |
16 | 19 |
|
@@ -95,27 +98,168 @@ public function moveCard( |
95 | 98 | throw new InvalidArgumentException("Card not found: {$cardId}"); |
96 | 99 | } |
97 | 100 |
|
98 | | - // Calculate new position using Rank service |
99 | | - $newPosition = $this->calculatePositionBetweenCards($afterCardId, $beforeCardId, $targetColumnId); |
| 101 | + // Calculate and update position with automatic retry on conflicts |
| 102 | + $newPosition = $this->calculateAndUpdatePositionWithRetry($card, $targetColumnId, $afterCardId, $beforeCardId); |
100 | 103 |
|
101 | | - // Use transaction for data consistency |
102 | | - DB::transaction(function () use ($card, $board, $targetColumnId, $newPosition) { |
| 104 | + // Emit success event after successful transaction |
| 105 | + $this->dispatch('kanban-card-moved', [ |
| 106 | + 'cardId' => $cardId, |
| 107 | + 'columnId' => $targetColumnId, |
| 108 | + 'position' => $newPosition, |
| 109 | + ]); |
| 110 | + } |
| 111 | + |
| 112 | + /** |
| 113 | + * Calculate position and update card within transaction with pessimistic locking. |
| 114 | + * This prevents race conditions when multiple users drag cards simultaneously. |
| 115 | + */ |
| 116 | + protected function calculateAndUpdatePosition( |
| 117 | + Model $card, |
| 118 | + string $targetColumnId, |
| 119 | + ?string $afterCardId, |
| 120 | + ?string $beforeCardId |
| 121 | + ): string { |
| 122 | + $newPosition = null; |
| 123 | + |
| 124 | + DB::transaction(function () use ($card, $targetColumnId, $afterCardId, $beforeCardId, &$newPosition) { |
| 125 | + $board = $this->getBoard(); |
| 126 | + $query = $board->getQuery(); |
| 127 | + $positionField = $board->getPositionIdentifierAttribute(); |
| 128 | + |
| 129 | + // LOCK reference cards for reading to prevent stale data |
| 130 | + $afterCard = $afterCardId |
| 131 | + ? (clone $query)->where('id', $afterCardId)->lockForUpdate()->first() |
| 132 | + : null; |
| 133 | + |
| 134 | + $beforeCard = $beforeCardId |
| 135 | + ? (clone $query)->where('id', $beforeCardId)->lockForUpdate()->first() |
| 136 | + : null; |
| 137 | + |
| 138 | + // Calculate position INSIDE transaction with locked data |
| 139 | + $newPosition = $this->calculatePositionBetweenLockedCards( |
| 140 | + $afterCard, |
| 141 | + $beforeCard, |
| 142 | + $targetColumnId |
| 143 | + ); |
| 144 | + |
| 145 | + // Update card position |
103 | 146 | $columnIdentifier = $board->getColumnIdentifierAttribute(); |
104 | 147 | $columnValue = $this->resolveStatusValue($card, $columnIdentifier, $targetColumnId); |
105 | | - $positionIdentifier = $board->getPositionIdentifierAttribute(); |
106 | 148 |
|
107 | 149 | $card->update([ |
108 | 150 | $columnIdentifier => $columnValue, |
109 | | - $positionIdentifier => $newPosition, |
| 151 | + $positionField => $newPosition, |
110 | 152 | ]); |
111 | 153 | }); |
112 | 154 |
|
113 | | - // Emit success event after successful transaction |
114 | | - $this->dispatch('kanban-card-moved', [ |
115 | | - 'cardId' => $cardId, |
116 | | - 'columnId' => $targetColumnId, |
117 | | - 'position' => $newPosition, |
118 | | - ]); |
| 155 | + return $newPosition; |
| 156 | + } |
| 157 | + |
| 158 | + /** |
| 159 | + * Calculate position between locked cards (used within transaction). |
| 160 | + */ |
| 161 | + protected function calculatePositionBetweenLockedCards( |
| 162 | + ?Model $afterCard, |
| 163 | + ?Model $beforeCard, |
| 164 | + string $columnId |
| 165 | + ): string { |
| 166 | + if (! $afterCard && ! $beforeCard) { |
| 167 | + return $this->getBoardPositionInColumn($columnId, 'bottom'); |
| 168 | + } |
| 169 | + |
| 170 | + $positionField = $this->getBoard()->getPositionIdentifierAttribute(); |
| 171 | + |
| 172 | + $beforePos = $beforeCard?->getAttribute($positionField); |
| 173 | + $afterPos = $afterCard?->getAttribute($positionField); |
| 174 | + |
| 175 | + if ($beforePos && $afterPos && is_string($beforePos) && is_string($afterPos)) { |
| 176 | + return Rank::betweenRanks(Rank::fromString($afterPos), Rank::fromString($beforePos))->get(); |
| 177 | + } |
| 178 | + |
| 179 | + if ($beforePos && is_string($beforePos)) { |
| 180 | + return Rank::before(Rank::fromString($beforePos))->get(); |
| 181 | + } |
| 182 | + |
| 183 | + if ($afterPos && is_string($afterPos)) { |
| 184 | + return Rank::after(Rank::fromString($afterPos))->get(); |
| 185 | + } |
| 186 | + |
| 187 | + return Rank::forEmptySequence()->get(); |
| 188 | + } |
| 189 | + |
| 190 | + /** |
| 191 | + * Calculate and update position with automatic retry on conflicts. |
| 192 | + * Wraps calculateAndUpdatePosition() with retry logic to handle rare duplicate position conflicts. |
| 193 | + */ |
| 194 | + protected function calculateAndUpdatePositionWithRetry( |
| 195 | + Model $card, |
| 196 | + string $targetColumnId, |
| 197 | + ?string $afterCardId, |
| 198 | + ?string $beforeCardId, |
| 199 | + int $maxAttempts = 3 |
| 200 | + ): string { |
| 201 | + $baseDelay = 50; // milliseconds |
| 202 | + $lastException = null; |
| 203 | + |
| 204 | + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { |
| 205 | + try { |
| 206 | + return $this->calculateAndUpdatePosition( |
| 207 | + $card, |
| 208 | + $targetColumnId, |
| 209 | + $afterCardId, |
| 210 | + $beforeCardId |
| 211 | + ); |
| 212 | + } catch (QueryException $e) { |
| 213 | + // Check if this is a unique constraint violation |
| 214 | + if (! $this->isDuplicatePositionError($e)) { |
| 215 | + throw $e; // Not a duplicate, rethrow |
| 216 | + } |
| 217 | + |
| 218 | + $lastException = $e; |
| 219 | + |
| 220 | + // Log the conflict for monitoring |
| 221 | + Log::info('Position conflict detected, retrying', [ |
| 222 | + 'card_id' => $card->id, |
| 223 | + 'target_column' => $targetColumnId, |
| 224 | + 'attempt' => $attempt, |
| 225 | + 'max_attempts' => $maxAttempts, |
| 226 | + ]); |
| 227 | + |
| 228 | + // Max retries reached? |
| 229 | + if ($attempt >= $maxAttempts) { |
| 230 | + throw new MaxRetriesExceededException( |
| 231 | + "Failed to move card after {$maxAttempts} attempts due to position conflicts", |
| 232 | + previous: $e |
| 233 | + ); |
| 234 | + } |
| 235 | + |
| 236 | + // Exponential backoff: 50ms, 100ms, 200ms |
| 237 | + $delay = $baseDelay * pow(2, $attempt - 1); |
| 238 | + usleep($delay * 1000); |
| 239 | + |
| 240 | + // Refresh reference cards before retry (they may have moved) |
| 241 | + continue; |
| 242 | + } |
| 243 | + } |
| 244 | + |
| 245 | + // Should never reach here |
| 246 | + throw $lastException ?? new \RuntimeException('Unexpected retry loop exit'); |
| 247 | + } |
| 248 | + |
| 249 | + /** |
| 250 | + * Check if a QueryException is due to unique constraint violation on positions. |
| 251 | + */ |
| 252 | + protected function isDuplicatePositionError(QueryException $e): bool |
| 253 | + { |
| 254 | + $errorCode = $e->errorInfo[1] ?? null; |
| 255 | + |
| 256 | + // SQLite: SQLITE_CONSTRAINT (19) |
| 257 | + // MySQL: ER_DUP_ENTRY (1062) |
| 258 | + // PostgreSQL: unique_violation (23505) |
| 259 | + |
| 260 | + return in_array($errorCode, [19, 1062, 23505]) || |
| 261 | + str_contains($e->getMessage(), 'unique_position_per_column') || |
| 262 | + str_contains($e->getMessage(), 'UNIQUE constraint failed'); |
119 | 263 | } |
120 | 264 |
|
121 | 265 | public function loadMoreItems(string $columnId, ?int $count = null): void |
|
0 commit comments