Skip to content

Serialize concurrent Quickpay callback handling per payment - #128

Merged
loevgaard merged 3 commits into
2.xfrom
notify-idempotency
Aug 24, 2026
Merged

loevgaard merged 3 commits into
2.xfrom
notify-idempotency

Conversation

@loevgaard

@loevgaard loevgaard commented Aug 24, 2026 •

Copy link
Copy Markdown
Member

Closes #109.

What this does

Adds Payum/Extension/NotifyIdempotencyExtension, a Payum extension tagged payum.extension factory="quickpay", that takes a non-blocking Symfony lock keyed on quickpayPaymentId around notify handling. A callback arriving while another one for the same payment is still being processed is resolved to a no-op action, so the controller answers its normal 2xx — Quickpay stops retrying — and nothing is processed twice. Nothing is lost either: notify handling re-fetches the payment from Quickpay, so the processing that holds the lock sees the state the duplicate carried.

Scoping decisions

  • A Payum extension rather than a lock in Controller\NotifyAction (which the issue's task list predating rc.1 suggested): since payum-quickpay rc.1, most callbacks arrive on the per-payment Payum token endpoint, not the plugin's shared endpoint. The extension matches the post-rewrap Notify whose model is the payment details — the one shape both endpoints funnel through exactly once per dispatch (via Sylius' ExecuteSameRequestWithPaymentDetailsAction) — so one lock guards both entry paths, including a token-endpoint delivery racing a shared-endpoint one.
  • No persisted processing flag in the payment details (the Saferpay-style flag half): the lock's TTL self-heals after a crashed worker, while a persisted flag would wedge the payment — and 2.x details are deliberately scalar-only with re-fetching as the source of truth.
  • Fail open: a broken lock store logs a warning and the callback is handled unguarded, as before this change. A failed lock release is logged and never fails the notify.
  • The lock is a named framework.lock resource, setono_sylius_quickpay (default store: flock, per-server serialization), prepended by the plugin extension and injected as the generated lock.setono_sylius_quickpay.factory. Multi-server setups redefine the resource with a shared store DSN in one line of standard configuration — documented in the README's Callbacks section.
  • The remaining issue task — migrating the callback url to Payum notify tokens — was already delivered upstream by payum-quickpay rc.1 (QuickPay-Callback-Url on every operation); the shared endpoint stays for operations made outside the store.

Tests

  • Unit tests for the extension (lock/release lifecycle, duplicate → no-op, unrelated requests ignored, fail-open on a broken store, tolerant release).
  • The issue's concurrency test: a full notify funnel with a real Payum Gateway (extension + Sylius' rewrap action) where a second identical callback is delivered while the first is mid-processing — both answer 204, exactly one is processed. Plus sequential callbacks both processing (lock released).

Verified live against the real Quickpay API

Full end-to-end run with the test application served through a tunnel (the new documented dev/trusted_proxies.yaml makes this possible):

  1. Checkout with the approved test card → Quickpay payment created, authorize callback delivered on the per-payment token url, payment authorized with scalar-only details.
  2. 6 concurrent identical signed callbacks at the shared endpoint → all 204, four acknowledged without processing (A callback for this Quickpay payment is already being processed), the rest processed sequentially.
  3. 6 concurrent callbacks split across both endpoints (3 token url + 3 shared) → all 204, four skipped: both paths serialize on the same lock.
  4. Admin Complete → capture at Quickpay, operation callback on the token url, payment completed, balance: 1627, Quickpay state processed.

@codecov

codecov Bot commented Aug 24, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.86%. Comparing base (bb7371f) to head (fc515d1).

Additional details and impacted files
@@             Coverage Diff              @@
##                2.x     #128      +/-   ##
============================================
+ Coverage     92.35%   93.86%   +1.51%     
- Complexity      189      205      +16     
============================================
  Files            23       25       +2     
  Lines           719      766      +47     
============================================
+ Hits            664      719      +55     
+ Misses           55       47       -8     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Quickpay retries callbacks, and a retry can race the original delivery. A new
Payum extension (tagged payum.extension factory="quickpay") takes a non-blocking
Symfony lock keyed on the quickpayPaymentId around notify handling: it matches
the post-rewrap Notify whose model is the payment details, the one shape both
the per-payment token endpoint and the shared /payment/quickpay/notify endpoint
funnel through exactly once per dispatch. An in-flight duplicate is resolved to
a no-op action, so it is acknowledged with the controller's normal 2xx and
Quickpay stops retrying without anything being processed twice. A broken lock
store fails open, and the lock TTL self-heals after a crashed worker, which is
why no processing flag is persisted in the payment details.

The default FlockStore serializes per server; the README documents aliasing
setono_sylius_quickpay.lock.factory to the framework's lock.factory for
multi-server setups.

The test application now trusts local tunnel clients (documented in the new dev
trusted_proxies.yaml), which makes a full end-to-end run against the real
Quickpay API possible; one was performed for this change, covering checkout,
authorize callback, concurrent duplicate callbacks on both endpoints, and
admin-triggered capture.

Closes #109
Comment thread src/Payum/Extension/NotifyIdempotencyExtension.php Outdated
Comment thread src/Payum/Extension/NotifyIdempotencyExtension.php Outdated
Instead of hand-wiring a FlockStore and LockFactory as plugin services, the
extension now prepends a named framework.lock resource, setono_sylius_quickpay
(default store: flock), and injects the factory FrameworkBundle generates for
it (lock.setono_sylius_quickpay.factory). That is the Symfony framework way:
stores are configured as DSNs, env vars work, the lock log channel is wired,
and a multi-server shop overrides the store by redefining the resource in one
line of standard configuration - framework.lock resources replace rather than
merge, so the plugin default steps aside cleanly.
Comment thread src/DependencyInjection/SetonoSyliusQuickpayExtension.php Outdated
…abled flag

- resolveQuickpayPaymentId() now reads the id through the gateway library's
  Details helper (hasPaymentId/paymentId); a present but unusable id surfaces
  the library's own LogicException instead of being silently ignored
- The anonymous no-op action is extracted to AcknowledgeNotifyAction with a
  docblock explaining the pre-set-action mechanism
- The prepended framework.lock config drops 'enabled' => true - FrameworkBundle
  normalization adds it to any lock array that lacks it
@loevgaard
loevgaard merged commit e4313e0 into 2.x Aug 24, 2026
30 checks passed
@loevgaard
loevgaard deleted the notify-idempotency branch August 24, 2026 08:58
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.

Harden the notify flow: idempotency, callback races, and per-payment notify tokens

1 participant