Repository navigation
Conversation
…transaction Inside an active PDO transaction getNextPosition() now reads the last active row of the scope with SELECT ... ORDER BY order DESC LIMIT 1 FOR UPDATE, so it returns the latest committed state instead of the REPEATABLE READ snapshot. Outside a transaction behavior is unchanged. Refs #31 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1c8c556 to
3f890db
Compare
…other The documented serialization between a locking getNextPosition() and moveWithinScope() was inferred from InnoDB semantics. Cover both directions against real MySQL so the wording is backed by tests. Refs #31 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Architectural review requestThis PR fixes #31 but changes a documented behavior of the v1.4.0 contract, so it needs an explicit architectural decision before merge. It is not merged and no tag or release is implied. The change in the contract
Compatibility impact
Performance impact (measured, MySQL 8.0.46 and 8.4.11, 1000-row scopes)
Locks are held for the caller's whole transaction, so hot scopes and long transactions amplify contention. What the solution does NOT guarantee
Decisions requested
EvidenceCI is green on HEAD |
… lock-free Per architectural review, getNextPosition() keeps its published v1.4.0 behavior (plain read, no lock). The stale-snapshot fix moves to a new additive getNextPositionForUpdate(), which requires an active PDO transaction, never begins one, and reads the scope's last active row with SELECT ... ORDER BY order DESC LIMIT 1 FOR UPDATE. Refs #31 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Rework per the architectural review is pushed (HEAD d2b1907): |
…f-contained - CHANGELOG: getNextPositionForUpdate() avoids stale-snapshot allocation when used instead of getNextPosition(); it does not change getNextPosition(). - README: import PdoTransactionRunner and the ordering classes in the example. - InvalidOrderingOperationException docblock lists the missing-transaction case. Refs #31 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ion case The exception covers invalid runtime arguments and an unmet operation precondition (getNextPositionForUpdate() without an active PDO transaction). Update the class docblock, the exception table, and the triggering conditions in the package reference. No API or runtime change. Refs #31 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…tionException Refs #31 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Read the original session innodb_lock_wait_timeout before lowering it and restore that exact value instead of DEFAULT. Begin transactions inside the protected scope and release each connection in its own nested finally, checking inTransaction() first, so a failing cleanup step cannot skip the remaining ones. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Fresh Full Acceptance Review — PR #34Verdict: NOT READY TO MERGEReviewed candidate
I reviewed the cumulative code, tests, README, package reference, changelog, exception contract, PR description and previous architecture discussion against the repository instructions and pinned applicable standards. Green CI does not prove the concurrency interleaving identified below. English flowchart — API described by this PRflowchart TD
A[Caller starts PDO transaction] --> B[Call getNextPositionForUpdate]
B --> C{Scope arguments valid?}
C -- No --> X[Throw InvalidOrderingOperationException]
C -- Yes --> D{Transaction active on this PDO?}
D -- No --> X
D -- Yes --> E[Build scope and soft-delete predicates]
E --> F[SELECT tail ORDER BY order DESC, id DESC LIMIT 1 FOR UPDATE]
F --> G{Did the locking read wait?}
G -- No --> H{Active tail found?}
G -- Yes --> I[Lock on the selected row is granted]
I --> J[Current implementation does not re-read the tail]
J --> H
H -- Yes --> K[Return selected order + 1]
H -- No --> L[Return 1]
K --> M[Caller inserts on the same PDO and transaction]
L --> N[Caller must serialize first insert into an empty scope]
M --> O[Caller commits or rolls back; locks are released]
The wait-and-resume branch is the gap the current tests do not prove safe. Blocker — a waiting allocator can use a tail that is no longer the maximumThe current locking query reads and locks one tail row, then immediately returns that row's order plus one. The integration tests prove that a contending query can time out; they do not cover a waiting query that resumes successfully after another transaction inserts and commits a higher tail. This is a correctness risk under the explicitly documented MySQL documentation: InnoDB Transaction Isolation Levels and Locking Reads. Required before merge
Minor finding — public docs omit the SQL tie-breakerThe runtime SQL orders by both the configured order column and the configured ID column descending; the package reference mentions both, but the README and method PHPDoc summarize the query as ordering only by the order column. Align those descriptions with the actual query so maintainers understand which row is selected and locked. Other reviewed areas
Final decision: NOT READY TO MERGE. Resolve the concurrency finding and the documentation mismatch, push the changes to this PR, and request a fresh full acceptance review against the updated remote base and HEAD. No merge or release is authorized by this review. |
…rUpdate waited A locking read that waits for the lock on the previous tail could resume and return that stale tail + 1 after another transaction committed a higher tail (reproduced under READ COMMITTED, yielding a duplicate position). Repeat the locking read until two consecutive reads return the same tail. Add real-MySQL tests that queue a second allocator on its own connection behind the tail lock, insert and commit a higher tail meanwhile, and check the resumed result under READ COMMITTED and REPEATABLE READ (deadlock is the accepted outcome there; a reused position never is). Align README, package reference and PHPDoc with the actual query, which orders by the order column and the id column descending. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The lowest-dependencies job runs an older PHPStan that does not narrow the by-reference pipes array filled by proc_open. Validate the process and both pipes explicitly so the helper type-checks on every supported version. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Declare the pipes array filled by proc_open with an explicit @var so the helper type-checks under the lowest (2.1.0) and latest PHPStan releases; the previous is_array() guard satisfied only the older one. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Fresh Full Acceptance Review — PR #34Verdict: NOT READY TO MERGEReviewed candidate
I reviewed the full accumulated code and documentation change (not only the latest commit), the new subprocess helper, the integration/regression tests, the PR description and prior review, plus English flowchart — current API pathflowchart TD
A[Caller starts a PDO transaction] --> B[Call getNextPositionForUpdate]
B --> C{Scope arguments valid?}
C -- No --> X[Throw InvalidOrderingOperationException]
C -- Yes --> D{Transaction active on this PDO?}
D -- No --> X
D -- Yes --> E[Build scope and soft-delete predicates]
E --> F[Execute SELECT tail ORDER BY order DESC, id DESC LIMIT 1 FOR UPDATE]
F --> G{Did this read return the same tail order as the previous read?}
G -- No --> H[Remember the current tail order]
H --> F
G -- Yes --> I[Return tail order + 1]
I --> J[Caller inserts on the same PDO and transaction]
J --> K[Caller commits or rolls back; locks are released]
F -. Lock timeout or deadlock .-> L[Propagate PDOException; caller owns retry policy]
D -. Scope is empty .-> M[Return 1 after two stable empty reads]
M --> N[Caller must serialize the first insert]
The newly added Blocker 1 — the REPEATABLE READ regression test relies on InnoDB choosing one specific deadlock victimIn That makes the test dependent on which transaction InnoDB chooses as the deadlock victim. If the parent transaction is rolled back instead, the test can throw directly from the parent-side insert/commit, or the waiter can resume against the unchanged maximum and report MySQL 8.4 documents that InnoDB rolls back one transaction to resolve a deadlock and attempts to choose a smaller transaction, with size based on rows inserted/updated/deleted; it does not guarantee that the waiter is always the victim: Deadlock Detection and Deadlocks in InnoDB. Required before merge: make this test deterministic with respect to either victim outcome. Handle the parent-side deadlock explicitly, then verify the committed database state and returned position for the outcome that actually occurred. Do not hide arbitrary PDOExceptions or merely broaden the accepted output; the test must still prove that no duplicate position is allocated when the higher tail commits and that a rolled-back insert was not falsely treated as committed. Repeat the test enough times to verify that it no longer depends on victim-selection luck. Finding 2 — documented “same row twice” is not what the implementation comparesThe PHPDoc and README say the locking read repeats until two consecutive reads return the same row. The implementation selects only the configured order value and compares the two integer order values ( Required: either have the query return the configured order and ID and compare the selected row identity, or change the documentation to state precisely that it repeats until two consecutive reads return the same tail order, explaining why holding the tail selected by the final locking read is sufficient for the advertised guarantee. Keep PHPDoc, README, package reference, and changelog consistent. Finding 3 — the revised locking footprint is not fully documentedThe repeated reads can lock an earlier tail and then a newer tail in the same caller-owned transaction. InnoDB retains acquired row locks until the transaction ends, so the resulting lock footprint may include a prior tail as well as the current tail. The current lock-footprint notes describe the individual scan and soft-deleted rows, but do not explain this additional lock retention caused by the new retry loop. Required: document this consequence in the PHPDoc and public runtime behavior guidance, or otherwise demonstrate that the implementation releases obsolete tail locks without breaking the concurrency guarantee. Do not describe the behavior as locking only the final tail if multiple selected tail rows remain locked. Minor repository-facing accuracy issueThe PR description still says Other areas reviewed
Final decision: NOT READY TO MERGE. Resolve Blocker 1, align the same-row/same-order contract, document the repeat-read lock footprint, refresh the stale PR description, push the result to this PR, and request another Fresh Full Acceptance Review against the new remote HEAD. No merge or release is authorized by this review. |
…test victim-independent The locking read in getNextPositionForUpdate() compares the order value of consecutive reads, not row identity, so the PHPDoc, README, package reference and changelog now say "same order value". They also state the cost the repeat implies: at least two locking reads per call (also for an empty scope), repeating only while the tail keeps changing, each repeat able to wait or fail with a lock wait timeout or deadlock. The REPEATABLE READ concurrency test no longer assumes which transaction InnoDB picks as the deadlock victim. If the inserting transaction is the victim the queued allocator must report the unchanged next position; if it commits, the queued allocator must report the new next position or fail with a deadlock. It never reuses the inserted position. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…onForUpdate() Mark getNextPosition() @deprecated by owner decision. Behavior is unchanged and the method stays supported throughout 1.x; cleanup is left to the next major release. No runtime deprecation notice is emitted. Update the class and method PHPDoc, README (the quick start now uses the locking variant inside PdoTransactionRunner), package reference and changelog, and add a regression assertion for the deprecation marker. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…when to use each Withdraw the @deprecated marker on getNextPosition() by owner decision: the lock-free read has legitimate long-term uses (previews, reads outside a transaction or under READ COMMITTED, callers that serialize allocation another way), so the two reads are intentionally supported side by side. Document the choice in the method PHPDoc, README and package reference, drop the Deprecated changelog entry, and replace the regression assertion with one that guards that neither method carries a deprecation marker. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…fe, with tests
The plain getNextPosition() read uses the transaction snapshot. Under
REPEATABLE READ that snapshot is created by the first non-locking read and
is not refreshed by locking reads, so the previous guidance ("appropriate
locking", "parent-row lock or UNIQUE with retry") was incomplete: locking
after the snapshot exists does not make the read current, and a retry in
the same transaction returns the same stale value.
Replace that guidance in the PHPDoc, README, package reference and
changelog with the precise rule: the plain read is current only when no
older snapshot exists (outside a transaction, under READ COMMITTED, or when
the serialization lock is taken before the first non-locking read).
Add two real-MySQL tests that back both sides of the rule.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
PHPStan 2.3 memoizes repeated calls on the readonly manager and reports the second identical assertSame() as always true. Assert each retry attempt inside a loop so the check stays meaningful and the analysis passes on the latest, lowest and CI-resolved dependency sets. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… document lock accumulation The REPEATABLE READ concurrency test only checked the waiting allocator's report. It now also reads the committed rows on another connection and checks that they match the outcome (the inserted row exists only if its transaction committed, no position occupied twice) and that a reported position is exactly the next free one. A "heavy" waiter variant biases InnoDB towards picking the inserting transaction as the deadlock victim, so both victim outcomes are exercised on every run. Add a test that a repeated locking read keeps both the earlier and the new tail locked until the transaction ends, and document that accumulated lock footprint in the PHPDoc, README and package reference. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Fresh Full Acceptance Review — PR #34الحكم: جاهزة للدمج هندسيًا — لا يوجد blocker (مع إصلاح توثيقي صغير موصى به قبل الدمج)المرشح الذي رُوجع
راجعت الحالة المتراكمة كاملة، لا آخر commit فقط: الكود، والاختبارات، وسكربت الـ subprocess، وREADME، و Flowchart — مسار الـ API الحاليflowchart TD
A[Caller starts a PDO transaction] --> B[Call getNextPositionForUpdate]
B --> C{Scope arguments valid?}
C -- No --> X[Throw InvalidOrderingOperationException]
C -- Yes --> D{Transaction active on this PDO?}
D -- No --> X
D -- Yes --> E[Build scope and soft-delete WHERE]
E --> F[SELECT order ... ORDER BY order DESC, id DESC LIMIT 1 FOR UPDATE]
F --> G{Same order value as previous read?}
G -- No --> H[Remember tail order]
H --> F
G -- Yes --> I[Return tail + 1, or 1 for empty scope]
I --> J[Caller inserts on the same PDO and transaction]
J --> K[Caller commits or rolls back; all tail locks released]
F -. Lock wait timeout or deadlock .-> L[PDOException propagates; caller owns retry]
التحقق من الـ remediations السابقة
مراجعة التصميم والعقد
الملاحظات1. (Minor — توثيق، موصى بإصلاحه في هذه الـ PR) عبارة متبقية تكرر الافتراض الذي أسقطه #31في
تحت 2. (Nit — خارج النطاق عمدًا)
|
Closes #31
الملخص
بعد المراجعة المعمارية، تحتفظ
getNextPosition()بسلوكها المنشور في v1.4.0 (قراءةMAX()عادية، بلا قفل، بلا معاملة). وهذا يُبقي سطرPACKAGE_BUILDING_STANDARD.mdالذي ينص على أنها «لا تبدأ معاملة ولا تأخذ قفلًا» صحيحًا. إصلاح الـ snapshot القديم دالة جديدة إضافية، ويبقى المساران مدعومين جنبًا إلى جنب (لا@deprecatedعلىgetNextPosition()، بقرار المالك: للقراءة بلا قفل استخدامات مشروعة طويلة الأمد). وثّقت PHPDoc وREADME والمرجع متى تُستخدم كل دالة، وبقاعدة الـ snapshot الدقيقة للقراءة العادية: تحتREPEATABLE READلا تُحدِّث قراءات القفل الـ snapshot، فلا يجعل القفل بعد وجوده القراءة العادية حديثة، وإعادة المحاولة في المعاملة نفسها (مثل بعد خطأ مفتاح مكرر) تُرجع القيمة القديمة نفسها:ScopedOrderingManager::getNextPositionForUpdate(PDO, ScopedOrderingConfig, int|string|null $scopeValue = null): intInvalidOrderingOperationExceptionالموجود (موثّق الآن بأنه يغطي الوسائط غير الصالحة أو شرطًا مسبقًا غير مستوفى). لا تبدأ معاملة ولا تنهيها؛ يبقى القفل حتى ينهي المستدعي معاملته، لذا يجب الإدراج على الاتصال والمعاملة نفسيهما. تتركّب معPdoTransactionRunner/PdoSavepointTransactionRunnerدون Runner جديد.SELECT <order> ... ORDER BY <order> DESC, <id> DESC LIMIT 1 FOR UPDATE. قراءة القفل تعيد آخر حالة مُثبَّتة بدل snapshot الـREPEATABLE READ، فلا يضيع صف ثبّته اتصال آخر بعد إنشاء الـ snapshot (سبب تكرارdisplay_orderفي getNextPosition() reads a stale MAX inside a caller transaction with an older snapshot, producing duplicate positions #31).READ COMMITTED(نتيجة3بدل4). التكرار لا يستمر إلا ما دام الذيل يتغيّر، وكل قراءة مكررة قد تنتظر قفلًا أو تفشل بمهلة قفل أو deadlock.NULLالخاصة بـgetNextPosition()(مُنشئ WHERE مشترك؛ SQL الدالة القديمة لم يتغير).لم يتغير أي توقيع أو استثناء أو ملكية معاملات قائمة. التغيير العام الوحيد هو إضافة الدالة (إضافة بمستوى minor؛ تصنيف الإصدار قرار المالك). تحقق من ذلك بمقارنة الواجهة العامة لـ v1.4.0 بالفرع بالـ reflection (الفرق الوحيد دالة مضافة) وبتشغيل اختبارات v1.4.0 نفسها على
src/الخاص بالفرع.لماذا هذا الاستعلام
قياس على MySQL 8.0.46 و8.4.11 حقيقيين بنطاقات من 1000 صف (
performance_schema.data_locksوEXPLAIN):MAX() ... FOR UPDATEمع فهرس(scope, order)ORDER BY DESC LIMIT 1 FOR UPDATEمع فهرس(scope, order)وذيل حيREPEATABLE READ)(scope, order)صالح (جدول الاختبار العام لا يملكه)READ COMMITTEDرُفضت
FOR SHAREلأن مستدعيَين يمسكان قفلًا مشتركًا ثم يتعارضان عند الإدراج.الضمان والحدود (موثقة في الكود وREADME والمرجع)
المضمون: النتيجة تعكس آخر حالة مُثبَّتة؛ وعلى نطاق غير فارغ يتسلسل المستدعون الذين يستخدمون قراءات قفل أيضًا (
getNextPositionForUpdate()وmoveWithinScope()) على آخر صف حي. أُثبت ذلك باختبارات MySQL حقيقية في الاتجاهين.غير مضمون: النطاق الفارغ (لا صف لقفله، فعلى المستدعي قفل صف أب أو استخدام قيد
UNIQUE)؛ الكاتبون بلا قراءات قفل (إلا فجوةREPEATABLE READ)؛ حجم الأقفال يعتمد على الفهرس والصفوف المحذوفة منطقيًا؛ قد تلمس الفجوة حدود نطاق آخر؛ تنتشر استثناءاتPDOExceptionلمهلة القفل والـ deadlock كما هي وإعادة المحاولة مسؤولية المستدعي (انظر #32، لم يُلمس). تحتREPEATABLE READيفشل عادةً استدعاء ينتظر خلف معاملة تُدرج بعدها فوق الصف المقفول بـ deadlock بدل أن يستأنف.الاختبارات
ScopedOrderingManagerStaleSnapshotTest(25 اختبارًا على MySQL حقيقي):NULL، والنطاق الفارغ، والصفوف المحذوفة منطقيًا، واستقلال النطاقات، والإدراج ثمmoveWithinScope()بمواضع فريدة متصلة.moveWithinScope()للآخر، وعدم حجب صفوف النطاقات الأخرى، وحد النطاق الفارغ.READ COMMITTED: يجب أن يرى المنتظر الذيل الجديد ويُرجع4والحالة المثبّتة[1, 2, 3].REPEATABLE READ(حالتان: منتظر خفيف ومنتظر ثقيل يرجّح أن تكون المعاملة المُدرِجة ضحية الـ deadlock): يُقرأ الحالة المثبّتة من اتصال آخر ويُتحقق أنها تطابق النتيجة (الصف المُدرَج موجود فقط إن ثُبّتت معاملته، ولا موضع مشغول مرتين)، وأن الموضع الذي يُبلِغ عنه المنتظر هو أول موضع حر تمامًا، أو أنه فشل بـ deadlock 1213 والإدراج مثبّت.getNextPosition()القديمة لا تأخذ قفلًا حتى داخل معاملة ولا ينتظرها قفل اتصال آخر.OrderingPublicApiRegressionTestيثبّت التوقيع الجديد وأن أيًّا منgetNextPosition()وgetNextPositionForUpdate()لا يحمل وسم@deprecated. واجهةtests/ConsumerتستدعيgetNextPosition()لإثبات استمرار دعمها.تحقق بتجارب معدّلة مؤقتة: عند إزالة حلقة إعادة القراءة يسقط اختبار
READ COMMITTEDواختبار القفل المتراكم؛ وعند جعل الدالة القديمة تقفل يسقط حارس عدم القفل؛ وفي 8 تشغيلات متتالية للحالتين الخفيفة والثقيلة كان المنتظر ضحية الـ deadlock دائمًا في الأولى والمُدرِج دائمًا في الثانية، فيُمارَس الفرعان في كل تشغيل.التحقق
مرتبط بالـ HEAD
defdfabf87473e264f276ea8a218913233bd93e2.GitHub Actions (التشغيل 38951382078،
success، الفحوص العشرة ناجحة): CI Gate، وQuality (PHP 8.2)، وUnit/Regression (PHP 8.2 و8.3 و8.4 و8.5)، وIntegration (PHP 8.2 و8.5 مع MySQL 8.4.10)، وLowest dependencies (PHP 8.2 مع MySQL 8.4.10)، وWorkflow lint.محليًا (على
f7c4c28، وأضافdefdfabبعده فقرة توثيقية واحدة فيPERSISTENCE_PACKAGE_REFERENCE.mdفقط؛ PHP 8.4.4 وMySQL 8.4.10 حقيقي، ولا يغني عن CI):composer validate --strictوdump-autoload --optimize --strict-psrوcheck-platform-reqsوaudit --abandoned=failوPHPStan بأعلى مستوى وunit 260 وregression 42 وintegration 119 وconsumer harness وphp-cs-fixer وgit diff --check. وأُعيدت PHPStan وphp-cs-fixer وunit وregression وintegration على حلّين جديدين للاعتماديات: الأحدث (PHPStan 2.3.1) والحد الأدنى (PHPStan 2.1.0 وphp-cs-fixer 3.94.0 وPHPUnit 11.5.0). لم يُشغَّلactionlintمحليًا (Workflow lint في CI). المستودع كان يفشل في PHPStan 2.3.1 قبل هذا العمل بسبب أخطاء لا تخصه، وعولج ذلك في #35 (مدمج).خارج النطاق
moveWithinScope()تقيّدnewOrderبقيمةMAX()قديمة تحت الشرط نفسه: سُجّلت منفصلة في #33 ولم تُعدَّل هنا. #32 لم يُلمس. لا Tag ولا Release.🤖 Generated with Claude Code