Skip to content

Commit 987d05f

Browse files
committed
Enhance concurrency handling in number generation with database transactions and atomic operations
1 parent 667640e commit 987d05f

File tree

2 files changed

+128
-9
lines changed

2 files changed

+128
-9
lines changed

src/Generator.php

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use CleaniqueCoders\RunningNumber\Contracts\Generator as GeneratorContract;
66
use CleaniqueCoders\RunningNumber\Contracts\Presenter;
77
use CleaniqueCoders\RunningNumber\Exceptions\InvalidRunningNumberTypeException;
8+
use Illuminate\Support\Facades\DB;
89

910
class Generator implements GeneratorContract
1011
{
@@ -53,14 +54,21 @@ public function generate(): string
5354
throw new InvalidRunningNumberTypeException('Unsupported '.$this->type);
5455
}
5556

56-
$this->createRunningNumberTypeIfNotExists();
57+
return DB::transaction(function () {
58+
$this->createRunningNumberTypeIfNotExists();
5759

58-
$running_number = config('running-number.model')::where('type', $this->getType())->first();
59-
$running_number->increment('number');
60-
$running_number->save();
61-
$running_number->refresh();
60+
// Use lockForUpdate() to prevent race conditions
61+
// This locks the row until the transaction is committed
62+
$running_number = config('running-number.model')::where('type', $this->getType())
63+
->lockForUpdate()
64+
->first();
6265

63-
return $this->presenter->format($this->getType(), $running_number->number);
66+
// Increment and save atomically within the transaction
67+
$running_number->increment('number');
68+
$running_number->refresh();
69+
70+
return $this->presenter->format($this->getType(), $running_number->number);
71+
});
6472
}
6573

6674
private function getType()
@@ -70,8 +78,11 @@ private function getType()
7078

7179
private function createRunningNumberTypeIfNotExists()
7280
{
73-
if (! config('running-number.model')::where('type', $this->getType())->exists()) {
74-
config('running-number.model')::create(['type' => $this->getType()]);
75-
}
81+
// Use firstOrCreate() which is atomic and prevents race conditions
82+
// Multiple concurrent requests will not create duplicate types
83+
config('running-number.model')::firstOrCreate(
84+
['type' => $this->getType()],
85+
['number' => 0]
86+
);
7687
}
7788
}

tests/RunningNumberTest.php

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,111 @@ public function up()
111111
->and($secondRecord->number)->toBe(2)
112112
->and(config('running-number.model')::where('type', 'SECTION')->count())->toBe(1);
113113
});
114+
115+
// Race Condition & Rollback Tests
116+
it('prevents duplicate numbers when generating concurrently', function () {
117+
// Generate multiple numbers in sequence
118+
$numbers = [];
119+
for ($i = 0; $i < 5; $i++) {
120+
$numbers[] = RunningNumberGenerator::make()
121+
->type(Organization::ORGANIZATION->value)
122+
->generate();
123+
}
124+
125+
// All numbers should be unique
126+
expect(count($numbers))->toBe(5)
127+
->and(count(array_unique($numbers)))->toBe(5)
128+
->and($numbers)->toMatchArray([
129+
'ORGANIZATION001',
130+
'ORGANIZATION002',
131+
'ORGANIZATION003',
132+
'ORGANIZATION004',
133+
'ORGANIZATION005',
134+
]);
135+
});
136+
137+
it('handles type creation race condition gracefully', function () {
138+
// First creation should succeed
139+
RunningNumberGenerator::make()->type(Organization::DIVISION->value)->generate();
140+
141+
// Verify only one record was created
142+
$count = config('running-number.model')::where('type', 'DIVISION')->count();
143+
144+
expect($count)->toBe(1);
145+
});
146+
147+
it('maintains sequential integrity after multiple operations', function () {
148+
$type = Organization::UNIT->value;
149+
150+
// Generate 10 numbers
151+
for ($i = 1; $i <= 10; $i++) {
152+
RunningNumberGenerator::make()->type($type)->generate();
153+
}
154+
155+
// Verify the final number is 10
156+
$record = config('running-number.model')::where('type', 'UNIT')->first();
157+
158+
expect($record->number)->toBe(10);
159+
160+
// Generate 5 more
161+
for ($i = 1; $i <= 5; $i++) {
162+
RunningNumberGenerator::make()->type($type)->generate();
163+
}
164+
165+
// Verify the final number is now 15
166+
$record->refresh();
167+
expect($record->number)->toBe(15);
168+
});
169+
170+
it('uses database transactions for generation', function () {
171+
// This test verifies that transactions are being used
172+
// by checking that the number is properly incremented
173+
$type = Organization::SECTION->value;
174+
175+
// Generate first number
176+
$first = RunningNumberGenerator::make()->type($type)->generate();
177+
expect($first)->toBe('SECTION001');
178+
179+
// Generate second number - should be sequential
180+
$second = RunningNumberGenerator::make()->type($type)->generate();
181+
expect($second)->toBe('SECTION002');
182+
183+
// Verify database state
184+
$record = config('running-number.model')::where('type', 'SECTION')->first();
185+
expect($record->number)->toBe(2);
186+
});
187+
188+
it('does not lose numbers on successful generation', function () {
189+
$type = Organization::PROFILE->value;
190+
$generated = [];
191+
192+
// Generate 20 numbers
193+
for ($i = 1; $i <= 20; $i++) {
194+
$generated[] = RunningNumberGenerator::make()->type($type)->generate();
195+
}
196+
197+
// Extract the numeric part from each generated number
198+
$numbers = array_map(function ($num) {
199+
return (int) preg_replace('/[^0-9]/', '', $num);
200+
}, $generated);
201+
202+
// Verify no numbers are skipped (sequential from 1 to 20)
203+
expect($numbers)->toEqual(range(1, 20));
204+
});
205+
206+
it('ensures atomic operations with firstOrCreate', function () {
207+
// Create a new type
208+
RunningNumberGenerator::make()->type(Organization::DIVISION->value)->generate();
209+
210+
// Try to generate again - should not create duplicate type record
211+
RunningNumberGenerator::make()->type(Organization::DIVISION->value)->generate();
212+
RunningNumberGenerator::make()->type(Organization::DIVISION->value)->generate();
213+
214+
// Should only have ONE record for this type
215+
$count = config('running-number.model')::where('type', 'DIVISION')->count();
216+
expect($count)->toBe(1);
217+
218+
// And the number should be 3
219+
$record = config('running-number.model')::where('type', 'DIVISION')->first();
220+
expect($record->number)->toBe(3);
221+
});

0 commit comments

Comments
 (0)