Skip to content

[Promotion] Add version locking to promotions and coupons#18921

Merged
TheMilek merged 1 commit intoSylius:2.3from
NoResponseMate:SY-405/add-locks-on-promotion-usage
Apr 7, 2026
Merged

[Promotion] Add version locking to promotions and coupons#18921
TheMilek merged 1 commit intoSylius:2.3from
NoResponseMate:SY-405/add-locks-on-promotion-usage

Conversation

@NoResponseMate
Copy link
Copy Markdown
Contributor

@NoResponseMate NoResponseMate commented Mar 18, 2026

Q A
Branch? 2.3
Bug fix? kinda
New feature? kinda
BC breaks? no
Deprecations? no
Related tickets -
License MIT

Summary by CodeRabbit

  • New Features

    • Optimistic locking added for promotions and promotion coupons to reduce concurrent update conflicts.
  • Refactor

    • Promotion usage handling rewritten to use a locked-refresh-and-delegate approach for safer, atomic updates.
  • Database

    • Migrations and model version fields introduced to support optimistic locking.
  • Tests

    • Added tests covering locking and delegation behavior for promotion usage updates.

@NoResponseMate NoResponseMate requested review from a team as code owners March 18, 2026 15:38
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 18, 2026

📝 Walkthrough

Walkthrough

Adds optimistic locking to Promotion and PromotionCoupon (version fields, ORM mappings, migrations), updates interfaces to be Versioned, and refactors AtomicOrderPromotionsUsageModifier to obtain entity locks via the EntityManager and delegate increment/decrement to a decorated modifier.

Changes

Cohort / File(s) Summary
Core Entity Models
src/Sylius/Component/Core/Model/Promotion.php, src/Sylius/Component/Core/Model/PromotionCoupon.php
Added protected version property (default 1) with getVersion(): ?int and setVersion(?int): void accessors.
Entity Interfaces
src/Sylius/Component/Core/Model/PromotionInterface.php, src/Sylius/Component/Core/Model/PromotionCouponInterface.php
Now extend VersionedInterface, adding versioning to the public contract.
Doctrine ORM Configuration
src/Sylius/Bundle/CoreBundle/Resources/config/doctrine/model/Promotion.orm.xml, src/Sylius/Bundle/CoreBundle/Resources/config/doctrine/model/PromotionCoupon.orm.xml
Added integer version field mappings with version="true" to enable optimistic locking.
Database Migrations
src/Sylius/Bundle/CoreBundle/Migrations/Version20260216120000.php, src/Sylius/Bundle/CoreBundle/Migrations/Version20260216120001.php
New migrations adding version INT NOT NULL DEFAULT 1 columns to sylius_promotion and sylius_promotion_coupon (standard and PostgreSQL variants).
Modifier Refactor
src/Sylius/Bundle/CoreBundle/Doctrine/ORM/Promotion/Modifier/AtomicOrderPromotionsUsageModifier.php
Reworked to accept optional Connection, a decorated OrderPromotionsUsageModifierInterface, and EntityManagerInterface; removed direct SQL updates and now refreshes and locks relevant entities (optimistic locking) before delegating increment/decrement to the decorated modifier; constructor usage of Connection is deprecated.
Service Configuration
src/Sylius/Bundle/CoreBundle/Resources/config/services/promotion.php
Updated service args for sylius.modifier.promotion.order_usage.atomic to pass null, the decorated service (.inner), and doctrine.orm.entity_manager; removed constructor arg from non-atomic modifier.
Tests
src/Sylius/Bundle/CoreBundle/tests/Doctrine/ORM/Promotion/Modifier/AtomicOrderPromotionsUsageModifierTest.php, src/Sylius/Component/Core/tests/Model/PromotionCouponTest.php
Added tests verifying EntityManager refresh/lock interactions and delegation to the decorated modifier; added test confirming PromotionCoupon implements VersionedInterface and default version equals 1.

Sequence Diagram

sequenceDiagram
    participant Client as Client Code
    participant AOUM as AtomicOrderPromotionsUsageModifier
    participant EM as EntityManager
    participant DM as Decorated Modifier
    participant DB as Database

    Client->>AOUM: increment(order, promotions, coupon?)
    Note over AOUM: Refresh & optimistic lock phase
    AOUM->>EM: refresh(promotion)
    EM->>DB: SELECT promotion
    DB-->>EM: promotion row (version=N)
    AOUM->>EM: lock(promotion, OPTIMISTIC, N)
    EM-->>AOUM: locked (version checked)

    alt coupon present
        AOUM->>EM: refresh(coupon)
        EM->>DB: SELECT coupon
        DB-->>EM: coupon row (version=M)
        AOUM->>EM: lock(coupon, OPTIMISTIC, M)
        EM-->>AOUM: locked (version checked)
    end

    Note over AOUM: Delegate actual usage change
    AOUM->>DM: increment(order, promotions, coupon?)
    DM->>DB: apply updates (via repositories/ORM)
    DB-->>DM: updates persisted
    DM-->>AOUM: done
    AOUM-->>Client: complete
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I nudged the versions up to one,
Locks in place before the fun,
I delegate with careful hops,
No rogue SQL, no sudden drops,
Hooray — concurrency now runs!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding version locking to promotions and coupons. It is concise, clear, and directly related to the primary objective of the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 18, 2026

❗ Preview Environment delete from Bunnyshell failed

Check https://github.com/Sylius/Sylius/actions/runs/24092157737 for details.

See: Environment Details

Available commands:

  • 🚀 /bns:deploy to redeploy the environment
  • /bns:delete to remove the environment

@NoResponseMate NoResponseMate force-pushed the SY-405/add-locks-on-promotion-usage branch from c3b79ea to 2379276 Compare March 18, 2026 15:47
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
src/Sylius/Bundle/CoreBundle/tests/Doctrine/ORM/Promotion/Modifier/AtomicOrderPromotionsUsageModifierTest.php (1)

46-221: Add one lock-failure test to pin non-delegation behavior.

Current tests are all happy-path. Please add a case where locking fails and assert the decorated modifier is not called.

Proposed test addition
+    public function testDoesNotDelegateWhenLockFailsDuringIncrement(): void
+    {
+        $order = $this->createMock(OrderInterface::class);
+        $promotion = $this->createMock(PromotionInterface::class);
+
+        $order->method('getPromotions')->willReturn(new ArrayCollection([$promotion]));
+        $order->method('getPromotionCoupon')->willReturn(null);
+        $promotion->method('getVersion')->willReturn(1);
+
+        $this->entityManager->expects($this->once())->method('refresh')->with($promotion);
+        $this->entityManager
+            ->expects($this->once())
+            ->method('lock')
+            ->with($promotion, LockMode::OPTIMISTIC, 1)
+            ->willThrowException(new \RuntimeException('Lock failed'))
+        ;
+
+        $this->decoratedModifier->expects($this->never())->method('increment');
+
+        $this->expectException(\RuntimeException::class);
+        $this->atomicModifier->increment($order);
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/Sylius/Bundle/CoreBundle/tests/Doctrine/ORM/Promotion/Modifier/AtomicOrderPromotionsUsageModifierTest.php`
around lines 46 - 221, Add a negative test in
AtomicOrderPromotionsUsageModifierTest that simulates EntityManager::lock
throwing an exception and asserts the decorated modifier is not called; create
an OrderInterface mock with a Promotion (and optionally a Coupon), have the
promotion return a version, mock
$this->entityManager->expects(...)->method('refresh')->with(...) as usual and
mock ->method('lock') to throw an exception (e.g. new ORMException), then assert
$this->decoratedModifier->expects($this->never())->method('increment') (or
'decrement' for the other case) and call
$this->atomicModifier->increment($order) inside an expectException or try/catch
to verify the exception is propagated (or handled) while ensuring decorated
modifier was not invoked.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@src/Sylius/Bundle/CoreBundle/Doctrine/ORM/Promotion/Modifier/AtomicOrderPromotionsUsageModifier.php`:
- Around line 57-58: The foreach in AtomicOrderPromotionsUsageModifier passes
Promotion objects from $order->getPromotions() (Component promotion interface)
into refreshAndLock(), which currently type-hints a Core PromotionInterface;
change the parameter type to the more general VersionedInterface used by
refreshAndLock() (since it only calls getVersion()), and add "use
Sylius\Resource\Model\VersionedInterface;" to the imports so
refreshAndLock(VersionedInterface $resource) accepts both promotion variants;
update any docblocks or type hints referencing
Sylius\Component\Core\Model\PromotionInterface accordingly.

In `@src/Sylius/Component/Core/Model/PromotionCoupon.php`:
- Around line 20-21: The protected property $version in class PromotionCoupon is
untyped; change its declaration to use a native int type (e.g., update property
$version to be typed as int) so it matches the accessor contract
(getVersion/setVersion) and avoids mixed runtime state; ensure the default value
remains 1 and adjust any code that previously relied on it being untyped if
necessary.

In `@src/Sylius/Component/Core/Model/PromotionCouponInterface.php`:
- Line 19: You've added VersionedInterface to PromotionCouponInterface which is
a breaking change for third-party implementers; revert that direct extension and
instead introduce a new interface (e.g., PromotionCouponVersionedInterface) that
extends PromotionCouponInterface and VersionedInterface, update internal
type-hints/usages to prefer the new PromotionCouponVersionedInterface where
versioning is required, and add a deprecation docblock/note on
PromotionCouponInterface (or a clear release note) indicating the planned
migration path so existing implementers are not forced to add version methods
immediately.

---

Nitpick comments:
In
`@src/Sylius/Bundle/CoreBundle/tests/Doctrine/ORM/Promotion/Modifier/AtomicOrderPromotionsUsageModifierTest.php`:
- Around line 46-221: Add a negative test in
AtomicOrderPromotionsUsageModifierTest that simulates EntityManager::lock
throwing an exception and asserts the decorated modifier is not called; create
an OrderInterface mock with a Promotion (and optionally a Coupon), have the
promotion return a version, mock
$this->entityManager->expects(...)->method('refresh')->with(...) as usual and
mock ->method('lock') to throw an exception (e.g. new ORMException), then assert
$this->decoratedModifier->expects($this->never())->method('increment') (or
'decrement' for the other case) and call
$this->atomicModifier->increment($order) inside an expectException or try/catch
to verify the exception is propagated (or handled) while ensuring decorated
modifier was not invoked.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: efa55b4b-ae22-40fc-84fe-db4e864db1a5

📥 Commits

Reviewing files that changed from the base of the PR and between 385a5e5 and c3b79ea.

📒 Files selected for processing (12)
  • src/Sylius/Bundle/CoreBundle/Doctrine/ORM/Promotion/Modifier/AtomicOrderPromotionsUsageModifier.php
  • src/Sylius/Bundle/CoreBundle/Migrations/Version20260216120000.php
  • src/Sylius/Bundle/CoreBundle/Migrations/Version20260216120001.php
  • src/Sylius/Bundle/CoreBundle/Resources/config/doctrine/model/Promotion.orm.xml
  • src/Sylius/Bundle/CoreBundle/Resources/config/doctrine/model/PromotionCoupon.orm.xml
  • src/Sylius/Bundle/CoreBundle/Resources/config/services/promotion.php
  • src/Sylius/Bundle/CoreBundle/tests/Doctrine/ORM/Promotion/Modifier/AtomicOrderPromotionsUsageModifierTest.php
  • src/Sylius/Component/Core/Model/Promotion.php
  • src/Sylius/Component/Core/Model/PromotionCoupon.php
  • src/Sylius/Component/Core/Model/PromotionCouponInterface.php
  • src/Sylius/Component/Core/Model/PromotionInterface.php
  • src/Sylius/Component/Core/tests/Model/PromotionCouponTest.php

Comment thread src/Sylius/Component/Core/Model/PromotionCoupon.php
Comment thread src/Sylius/Component/Core/Model/PromotionCouponInterface.php
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/Sylius/Component/Core/Model/PromotionCouponInterface.php (1)

17-19: ⚠️ Potential issue | 🟠 Major

BC break: extending VersionedInterface forces third-party implementers to add version methods.

Adding VersionedInterface to the interface contract is a breaking change for any external implementations of PromotionCouponInterface. The PR description states "No BC breaks," but this change requires all implementers to add getVersion() and setVersion() methods.

Consider:

  1. Documenting this as an explicit BC break in the changelog, or
  2. Introducing a new interface (e.g., VersionedPromotionCouponInterface) that extends both interfaces, allowing a deprecation path
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Sylius/Component/Core/Model/PromotionCouponInterface.php` around lines 17
- 19, The change made PromotionCouponInterface to extend VersionedInterface
which introduces a BC break; revert that direct extension and instead create a
new interface VersionedPromotionCouponInterface that extends
PromotionCouponInterface and VersionedInterface (so existing implementers are
unaffected), move any internal usages that require versioning to type-hint
VersionedPromotionCouponInterface, and ensure getVersion()/setVersion() remain
only on the new interface; alternatively, if you intend to keep the change,
document it as an explicit BC break in the changelog and note implementers must
add getVersion()/setVersion() to their implementations.
🧹 Nitpick comments (1)
src/Sylius/Bundle/CoreBundle/Doctrine/ORM/Promotion/Modifier/AtomicOrderPromotionsUsageModifier.php (1)

70-76: Runtime assertion guards type safety for optimistic locking.

The method signature accepts PromotionCouponInterface|PromotionInterface (where PromotionInterface is from the Component namespace), but getVersion() is only guaranteed by VersionedInterface. The runtime Assert::isInstanceOf check handles this gap.

This works correctly in practice because Sylius's actual entity implementations extend the Core interfaces that include VersionedInterface. However, if third-party code provides a pure Component-level implementation without versioning, this will fail at runtime rather than compile-time.

Consider documenting this expectation or using VersionedInterface directly in the type signature if only versioned entities are supported.

Alternative: Use VersionedInterface in the type signature
-    private function refreshAndLock(PromotionCouponInterface|PromotionInterface $entity): void
+    private function refreshAndLock(VersionedInterface $entity): void
     {
         $this->entityManager->refresh($entity);
 
-        Assert::isInstanceOf($entity, VersionedInterface::class);
         $this->entityManager->lock($entity, LockMode::OPTIMISTIC, $entity->getVersion());
     }

This would require updating lockEntities() to filter or assert types before calling refreshAndLock().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/Sylius/Bundle/CoreBundle/Doctrine/ORM/Promotion/Modifier/AtomicOrderPromotionsUsageModifier.php`
around lines 70 - 76, The method refreshAndLock currently accepts
PromotionCouponInterface|PromotionInterface but relies on
VersionedInterface::getVersion(); change refreshAndLock to require
VersionedInterface (i.e. signature accepts VersionedInterface) and update
lockEntities (the caller) to either filter the collection to only
VersionedInterface instances or assert/cast each entity before calling
refreshAndLock, so no runtime Assert is needed; alternatively add an inline
docblock on refreshAndLock stating the requirement if you prefer to keep the
broader signature.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/Sylius/Component/Core/Model/PromotionCouponInterface.php`:
- Around line 17-19: The change made PromotionCouponInterface to extend
VersionedInterface which introduces a BC break; revert that direct extension and
instead create a new interface VersionedPromotionCouponInterface that extends
PromotionCouponInterface and VersionedInterface (so existing implementers are
unaffected), move any internal usages that require versioning to type-hint
VersionedPromotionCouponInterface, and ensure getVersion()/setVersion() remain
only on the new interface; alternatively, if you intend to keep the change,
document it as an explicit BC break in the changelog and note implementers must
add getVersion()/setVersion() to their implementations.

---

Nitpick comments:
In
`@src/Sylius/Bundle/CoreBundle/Doctrine/ORM/Promotion/Modifier/AtomicOrderPromotionsUsageModifier.php`:
- Around line 70-76: The method refreshAndLock currently accepts
PromotionCouponInterface|PromotionInterface but relies on
VersionedInterface::getVersion(); change refreshAndLock to require
VersionedInterface (i.e. signature accepts VersionedInterface) and update
lockEntities (the caller) to either filter the collection to only
VersionedInterface instances or assert/cast each entity before calling
refreshAndLock, so no runtime Assert is needed; alternatively add an inline
docblock on refreshAndLock stating the requirement if you prefer to keep the
broader signature.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5d21e5ee-83dd-443e-bbde-37c040f12d50

📥 Commits

Reviewing files that changed from the base of the PR and between c3b79ea and 2379276.

📒 Files selected for processing (12)
  • src/Sylius/Bundle/CoreBundle/Doctrine/ORM/Promotion/Modifier/AtomicOrderPromotionsUsageModifier.php
  • src/Sylius/Bundle/CoreBundle/Migrations/Version20260216120000.php
  • src/Sylius/Bundle/CoreBundle/Migrations/Version20260216120001.php
  • src/Sylius/Bundle/CoreBundle/Resources/config/doctrine/model/Promotion.orm.xml
  • src/Sylius/Bundle/CoreBundle/Resources/config/doctrine/model/PromotionCoupon.orm.xml
  • src/Sylius/Bundle/CoreBundle/Resources/config/services/promotion.php
  • src/Sylius/Bundle/CoreBundle/tests/Doctrine/ORM/Promotion/Modifier/AtomicOrderPromotionsUsageModifierTest.php
  • src/Sylius/Component/Core/Model/Promotion.php
  • src/Sylius/Component/Core/Model/PromotionCoupon.php
  • src/Sylius/Component/Core/Model/PromotionCouponInterface.php
  • src/Sylius/Component/Core/Model/PromotionInterface.php
  • src/Sylius/Component/Core/tests/Model/PromotionCouponTest.php
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/Sylius/Component/Core/Model/PromotionInterface.php
  • src/Sylius/Component/Core/Model/Promotion.php
  • src/Sylius/Bundle/CoreBundle/tests/Doctrine/ORM/Promotion/Modifier/AtomicOrderPromotionsUsageModifierTest.php
  • src/Sylius/Component/Core/Model/PromotionCoupon.php
  • src/Sylius/Component/Core/tests/Model/PromotionCouponTest.php
  • src/Sylius/Bundle/CoreBundle/Resources/config/doctrine/model/Promotion.orm.xml
  • src/Sylius/Bundle/CoreBundle/Resources/config/doctrine/model/PromotionCoupon.orm.xml

@TheMilek TheMilek merged commit a6f75cb into Sylius:2.3 Apr 7, 2026
66 of 67 checks passed
@NoResponseMate NoResponseMate deleted the SY-405/add-locks-on-promotion-usage branch April 9, 2026 08:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants