Skip to content

Commit 88abe0d

Browse files
committed
feat: enhance position calculation with error handling and jitter mechanism
1 parent 533e88d commit 88abe0d

10 files changed

+1222
-61
lines changed

src/Concerns/HasCardSchema.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public function getCardSchema(Model $record): ?Schema
3232
}
3333

3434
$livewire = $this->getLivewire();
35+
/** @phpstan-ignore argument.type (Filament Schema expects HasSchemas&Livewire\Component but getLivewire returns HasBoard) */
3536
$schema = Schema::make($livewire)->record($record);
3637

3738
return $this->evaluate($this->cardSchemaBuilder, ['schema' => $schema]);
@@ -42,6 +43,7 @@ public function getCardSchema(Model $record): ?Schema
4243
*/
4344
protected function resolveDefaultClosureDependencyForEvaluationByName(string $parameterName): array
4445
{
46+
/** @phpstan-ignore argument.type (Filament Schema expects HasSchemas&Livewire\Component but getLivewire returns HasBoard) */
4547
return match ($parameterName) {
4648
'schema' => [Schema::make($this->getLivewire())],
4749
default => parent::resolveDefaultClosureDependencyForEvaluationByName($parameterName),

src/Concerns/InteractsWithBoard.php

Lines changed: 1 addition & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ protected function calculateAndUpdatePositionWithRetry(
259259

260260
// Log the conflict for monitoring
261261
Log::info('Position conflict detected, retrying', [
262-
'card_id' => $card->id,
262+
'card_id' => $card->getKey(),
263263
'target_column' => $targetColumnId,
264264
'attempt' => $attempt,
265265
'max_attempts' => $maxAttempts,
@@ -302,54 +302,6 @@ protected function isDuplicatePositionError(QueryException $e): bool
302302
str_contains($e->getMessage(), 'UNIQUE constraint failed');
303303
}
304304

305-
/**
306-
* Execute position update with retry mechanism for race conditions.
307-
* Handles cases where rapid card movements cause stale data issues.
308-
*
309-
* @template T
310-
*
311-
* @param callable(): T $callback
312-
* @return T
313-
*/
314-
protected function withPositionRetry(callable $callback, string $cardId, string $targetColumnId, int $maxAttempts = 3): mixed
315-
{
316-
$baseDelay = 50; // milliseconds
317-
$lastException = null;
318-
319-
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
320-
try {
321-
return $callback();
322-
} catch (QueryException $e) {
323-
if (! $this->isDuplicatePositionError($e)) {
324-
throw $e;
325-
}
326-
327-
$lastException = $e;
328-
329-
Log::info('Position conflict detected, retrying', [
330-
'card_id' => $cardId,
331-
'target_column' => $targetColumnId,
332-
'attempt' => $attempt,
333-
'max_attempts' => $maxAttempts,
334-
]);
335-
336-
if ($attempt >= $maxAttempts) {
337-
throw new MaxRetriesExceededException(
338-
"Failed to move card after {$maxAttempts} attempts due to position conflicts",
339-
previous: $e
340-
);
341-
}
342-
343-
$delay = $baseDelay * pow(2, $attempt - 1);
344-
usleep($delay * 1000);
345-
346-
continue;
347-
}
348-
}
349-
350-
throw $lastException ?? new \RuntimeException('Unexpected retry loop exit');
351-
}
352-
353305
public function loadMoreItems(string $columnId, ?int $count = null): void
354306
{
355307
$count = $count ?? $this->getBoard()->getCardsPerColumn();

src/Services/DecimalPosition.php

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
namespace Relaticle\Flowforge\Services;
66

7+
use InvalidArgumentException;
8+
79
/**
810
* Decimal-based position calculation using BCMath for precision.
911
* Uses DECIMAL(20,10) storage - 10 integer digits + 10 decimal places.
@@ -15,7 +17,7 @@
1517
* a unique position to prevent collisions when multiple users move cards
1618
* to the same position simultaneously.
1719
*/
18-
final class DecimalPosition
20+
final readonly class DecimalPosition
1921
{
2022
/**
2123
* Default gap between positions (65,535).
@@ -76,10 +78,18 @@ public static function before(string $position): string
7678
*
7779
* @param string $after Lower bound position (card above)
7880
* @param string $before Upper bound position (card below)
79-
* @return string Position between bounds with jitter applied
81+
* @return string Position between bounds with jitter applied
82+
*
83+
* @throws InvalidArgumentException When after >= before (invalid bounds)
8084
*/
8185
public static function between(string $after, string $before): string
8286
{
87+
if (bccomp($after, $before, self::SCALE) >= 0) {
88+
throw new InvalidArgumentException(
89+
"Invalid bounds: after ({$after}) must be less than before ({$before})"
90+
);
91+
}
92+
8393
// Calculate the exact midpoint
8494
$sum = bcadd($after, $before, self::SCALE);
8595
$midpoint = bcdiv($sum, '2', self::SCALE);
@@ -106,10 +116,18 @@ public static function between(string $after, string $before): string
106116
*
107117
* @param string $after Lower bound position
108118
* @param string $before Upper bound position
109-
* @return string Exact midpoint between bounds
119+
* @return string Exact midpoint between bounds
120+
*
121+
* @throws InvalidArgumentException When after >= before (invalid bounds)
110122
*/
111123
public static function betweenExact(string $after, string $before): string
112124
{
125+
if (bccomp($after, $before, self::SCALE) >= 0) {
126+
throw new InvalidArgumentException(
127+
"Invalid bounds: after ({$after}) must be less than before ({$before})"
128+
);
129+
}
130+
113131
$sum = bcadd($after, $before, self::SCALE);
114132

115133
return bcdiv($sum, '2', self::SCALE);
@@ -172,7 +190,7 @@ public static function generateSequence(int $count): array
172190
* Normalize a position string to ensure consistent format.
173191
* Converts numeric values to properly scaled decimal strings.
174192
*/
175-
public static function normalize(string|int|float $position): string
193+
public static function normalize(string | int | float $position): string
176194
{
177195
return bcadd((string) $position, '0', self::SCALE);
178196
}
@@ -220,7 +238,7 @@ public static function gap(string $lower, string $upper): string
220238
* @param string $after Lower bound position
221239
* @param string $before Upper bound position
222240
* @param int $count Number of positions to generate
223-
* @return array<int, string> Array of unique positions
241+
* @return array<int, string> Array of unique positions
224242
*/
225243
public static function generateBetween(string $after, string $before, int $count): array
226244
{
@@ -252,7 +270,7 @@ public static function generateBetween(string $after, string $before, int $count
252270
* desired range using BCMath for precision.
253271
*
254272
* @param string $maxOffset Maximum absolute offset (positive number)
255-
* @return string Random value in [-maxOffset, +maxOffset]
273+
* @return string Random value in [-maxOffset, +maxOffset]
256274
*/
257275
private static function generateJitter(string $maxOffset): string
258276
{
@@ -261,14 +279,19 @@ private static function generateJitter(string $maxOffset): string
261279
return '0.0000000000';
262280
}
263281

264-
// Get 8 random bytes and convert to unsigned 64-bit integer
282+
// Get 8 random bytes and convert to unsigned 64-bit string
283+
// PHP's unpack('P') returns signed int for values >= 2^63,
284+
// so we manually convert bytes to an unsigned decimal string
265285
$bytes = random_bytes(8);
266-
$randomInt = unpack('P', $bytes)[1]; // Unsigned 64-bit little-endian
286+
$randomUnsigned = '0';
287+
for ($i = 7; $i >= 0; $i--) {
288+
$randomUnsigned = bcmul($randomUnsigned, '256', 0);
289+
$randomUnsigned = bcadd($randomUnsigned, (string) ord($bytes[$i]), 0);
290+
}
267291

268-
// Normalize to [0, 1] range
269-
// PHP_INT_MAX is the max for signed, but we have unsigned so use 2^64
292+
// Normalize to [0, 1] range using 2^64 - 1 as max
270293
$maxUint64 = '18446744073709551615'; // 2^64 - 1
271-
$normalized = bcdiv((string) $randomInt, $maxUint64, self::SCALE);
294+
$normalized = bcdiv($randomUnsigned, $maxUint64, self::SCALE);
272295

273296
// Scale to [-1, 1] range
274297
$scaled = bcsub(bcmul($normalized, '2', self::SCALE), '1', self::SCALE);

src/Services/PositionRebalancer.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* This service redistributes positions evenly using the default gap, ensuring
1717
* consistent spacing and preventing precision exhaustion after many insertions.
1818
*/
19-
final class PositionRebalancer
19+
final readonly class PositionRebalancer
2020
{
2121
/**
2222
* Rebalance all positions in a column.

0 commit comments

Comments
 (0)