-
Notifications
You must be signed in to change notification settings - Fork 2
fix(lock): implement Redlock single-instance pattern in LockManagerService #537
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -264,6 +264,7 @@ private function buildPrePaidSaga(Member $owner, Summit $summit, array $payload) | |
| $this->member_repository, | ||
| $this->attendee_repository, | ||
| $this->ticket_type_repository, | ||
| $this->promo_code_repository, | ||
| $this->tx_service, | ||
| $this->lock_service | ||
| )); | ||
|
|
@@ -827,7 +828,7 @@ public function run(array $formerState): array | |
|
|
||
| $this->lock_service->lock('promocode.' . $promo_code->getId() . '.usage.lock', function () use ($promo_code, $qty, $owner_email) { | ||
| $promo_code->addUsage($owner_email, $qty); | ||
| }); | ||
| }, 30); | ||
|
|
||
| }); | ||
| // mark a done | ||
|
|
@@ -865,7 +866,7 @@ public function undo() | |
|
|
||
| $this->lock_service->lock('promocode.' . $promo_code->getId() . '.usage.lock', function () use ($promo_code, $info, $owner_email) { | ||
| $promo_code->removeUsage(intval($info['qty']), $owner_email); | ||
| }); | ||
| }, 30); | ||
|
|
||
| }); | ||
| } | ||
|
|
@@ -950,7 +951,7 @@ public function run(array $formerState): array | |
|
|
||
| $this->lock_service->lock('ticket_type.' . $ticket_type->getId() . '.sell.lock', function () use ($ticket_type, $reservations) { | ||
| $ticket_type->sell($reservations[$ticket_type->getId()]); | ||
| }); | ||
| }, 30); | ||
|
|
||
| } | ||
| }); | ||
|
|
@@ -967,7 +968,7 @@ public function undo() | |
| if (is_null($ticket_type)) return; | ||
| $this->lock_service->lock('ticket_type.' . $ticket_type->getId() . '.sell.lock', function () use ($ticket_type, $qty) { | ||
| $ticket_type->restore($qty); | ||
| }); | ||
| }, 30); | ||
| }); | ||
| } | ||
| } | ||
|
|
@@ -1474,6 +1475,11 @@ final class AutoAssignPrePaidTicketTask extends AbstractTask | |
| */ | ||
| private $ticket_type_repository; | ||
|
|
||
| /** | ||
| * @var ISummitRegistrationPromoCodeRepository | ||
| */ | ||
| private $promo_code_repository; | ||
|
|
||
| /** | ||
| * @var ILockManagerService | ||
| */ | ||
|
|
@@ -1487,19 +1493,21 @@ final class AutoAssignPrePaidTicketTask extends AbstractTask | |
| * @param IMemberRepository $member_repository | ||
| * @param ISummitAttendeeRepository $attendee_repository | ||
| * @param ISummitTicketTypeRepository $ticket_type_repository | ||
| * @param ISummitRegistrationPromoCodeRepository $promo_code_repository | ||
| * @param ITransactionService $tx_service | ||
| * @param ILockManagerService $lock_service | ||
| */ | ||
| public function __construct | ||
| ( | ||
| ?Member $owner, | ||
| Summit $summit, | ||
| array $payload, | ||
| IMemberRepository $member_repository, | ||
| ISummitAttendeeRepository $attendee_repository, | ||
| ISummitTicketTypeRepository $ticket_type_repository, | ||
| ITransactionService $tx_service, | ||
| ILockManagerService $lock_service | ||
| ?Member $owner, | ||
| Summit $summit, | ||
| array $payload, | ||
| IMemberRepository $member_repository, | ||
| ISummitAttendeeRepository $attendee_repository, | ||
| ISummitTicketTypeRepository $ticket_type_repository, | ||
| ISummitRegistrationPromoCodeRepository $promo_code_repository, | ||
| ITransactionService $tx_service, | ||
| ILockManagerService $lock_service | ||
| ) | ||
| { | ||
| $this->tx_service = $tx_service; | ||
|
|
@@ -1510,6 +1518,7 @@ public function __construct | |
| $this->member_repository = $member_repository; | ||
| $this->attendee_repository = $attendee_repository; | ||
| $this->ticket_type_repository = $ticket_type_repository; | ||
| $this->promo_code_repository = $promo_code_repository; | ||
| } | ||
|
|
||
| public function run(array $formerState): array | ||
|
|
@@ -1536,7 +1545,7 @@ public function run(array $formerState): array | |
| if (empty($promo_code_val)) throw new ValidationException("Promo code is required."); | ||
|
|
||
| $type_id = $ticket_dto['type_id']; | ||
| $order = $this->lock_service->lock('ticket_type.' . $type_id . 'promo_code.' . $promo_code_val . '.sell.lock', | ||
| $order = $this->lock_service->lock('ticket_type.' . $type_id . '.promo_code.' . $promo_code_val . '.sell.lock', | ||
| function () use ($promo_code_val, $type_id) { | ||
|
|
||
| $attendee_email = $this->owner->getEmail(); | ||
|
|
@@ -1555,7 +1564,7 @@ function () use ($promo_code_val, $type_id) { | |
| if (empty($attendee_last_name)) | ||
| $attendee_last_name = $this->payload['owner_last_name'] ?? $this->owner->getLastName(); | ||
|
|
||
| $promo_code = $this->summit->getPromoCodeByCode($promo_code_val); | ||
| $promo_code = $this->promo_code_repository->getByValueExclusiveLock($this->summit, $promo_code_val); | ||
| if (!PromoCodesUtils::isPrePaidPromoCode($promo_code)) | ||
| throw new EntityNotFoundException("Promo code is not found."); | ||
|
|
||
|
|
@@ -1658,7 +1667,7 @@ function () use ($promo_code_val, $type_id) { | |
|
|
||
|
|
||
| return $order; | ||
| }); | ||
| }, 30); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @romanetar This 30 s lifetime makes TTL expiry a silent loss of mutual exclusion, and this call site is the only one of the four where the Redis lock is the sole guard of the invariant — the fix should be a DB pessimistic lock, matching the sibling tasks. The other three sites are already backed by row locks — Suggested fix — same pattern as Independently,
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Verified both points against the code and applied fixes for both (will push shortly). DB pessimistic lock for
Ran |
||
| return ['order' => $order]; | ||
| }); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@romanetar The key fix is correct, but note it splits the lock namespace between fleets during a rolling deploy: old pods lock
ticket_type.7promo_code.X.sell.lockwhile new pods lockticket_type.7.promo_code.X.sell.lock, so the two fleets don't mutually exclude on this path for the duration of the rollout. Compounding it, old pods still run the unconditional-DEL-on-failed-acquire behavior this PR fixes, so they can delete new pods' token locks on any key. Since this call site is the one place where the Redis lock is the sole concurrency guard (no DB row lock — see my other comment), the deploy window is exactly where a race could materialize.No code change needed — suggest a line in the release notes: deploy while prepaid-assignment traffic is idle, or drain old workers before starting the new fleet.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good flag, and confirmed against
main'sLockManagerService—releaseLock()there is an unconditionaldelete($name)andlock()'sfinallycalls it even afteracquireLockthrowsUnacquiredLockException, so an old pod that loses contention on any key will delete it out from under whoever holds it, no ownership check. Combined with the key-namespace split you describe (missing dot on old pods vs. present on new), the two fleets neither mutually exclude on this path nor leave each other's locks alone during the rollout — exactly the window you're calling out.No code change from this thread, but worth noting: the DB pessimistic lock added for your other comment on this same call site (
getByValueExclusiveLockon the promo code,PESSIMISTIC_WRITE+HINT_REFRESH, held through commit) closes the concrete failure mode here — old and new pods share the same MySQL row lock regardless of what happens to the Redis key naming or unconditional deletes on either side. So the double-assignment scenario this thread worried about is now covered even mid-rollout; Redis staying inconsistent across fleets during the deploy window would at most cause spurious contention/retries, not an actual double-booked prepaid ticket.Will add the release-note line about draining old workers / deploying during idle prepaid-assignment traffic anyway, since the underlying old-pod unconditional-release bug can still stomp on other locks in the system during the transition, not just this one.