Skip to content

Persist-first client reads: previews carry persisted ids, refreshes wait for the in-flight persist#122

Closed
ThbPS wants to merge 8 commits into
fix/serialize-opc-ajax-per-cartfrom
feat/persist-first-address-reads
Closed

Persist-first client reads: previews carry persisted ids, refreshes wait for the in-flight persist#122
ThbPS wants to merge 8 commits into
fix/serialize-opc-ajax-per-cartfrom
feat/persist-first-address-reads

Conversation

@ThbPS

@ThbPS ThbPS commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator
Questions Answers
Description? Minimal client-side fix: the guest inline flow kept sending raw address fields even once the autosave had persisted them, making the server mount-and-delete temp address rows on routine rounds (row churn, mid-flight pricing against unconfirmed state). The client now resolves the persisted state first — previews carry the persisted ids when the form is clean, and refreshes wait for the in-flight persist. The server takes its battle-tested id-branch; no server change — the temp branches simply become cold fallbacks. Stacked on #121.
Type? bug fix
BC breaks? one deliberate contract change: a still-valid carrier selection now survives an address round-trip (re-validated server-side) instead of being cookie-cleared
Deprecations? no
Fixed ticket? Follow-up of #121 (same nightly race family: routine temp churn / mid-persist pricing).
How to test? Red-first specs below; full lot green; CI dispatch.

The three fixes (one commit, +111/−7, client only)

  1. Previews carry the persisted ids when the form is cleanbuildSelectAddressPayload resolves the autosave-persisted hidden id unless an edit is pending (opcDraftPending dataset dirty-flag, set on input, cleared per address type by the persist response; a use_same=1 save only MIRRORS the delivery id onto the invoice pointer and must not lift the billing hold).
  2. Previews triggered on a dirty form are held and re-run on opcAddressPersisted. Inconclusive autosaves (incomplete address mid-typing) keep the hold armed — the eventual successful persist re-runs the refresh; section readiness owns the retract meanwhile.
  3. A carriers fetch racing the FIRST persist (core updatedCart, retry click) holds the loader and re-fetches with the persisted id once the autosave confirms — same inconclusive-keeps-the-defer rule.

TDD evidence

Red-first e2e specs (PrestaShopCorp/opc.e2e.tests branch test/persist-first-address-reads, not in CI lots — same convention as local-race-repro):

  • C1 red: toggle previews left with id_address_delivery=0 instead of the persisted id → green.
  • C2 red: no carriers response carrying the persisted id ever follows a fetch racing the first persist → green.
  • C3 red: previews left with raw billing fields while an edit was unpersisted (2 payloads) → green. The mirror-save trap and the inconclusive-drop starvation were both caught by the loop-validation and fixed within the cycle.

Validation: full opc-payment-eligibility lot green locally; A/B baseline confirmed the residual ~1/6 local flake on two billing specs pre-exists on #121's content (not introduced here).

The one observable contract change (deliberate)

Routing the carrier click through the id-branch moves the selection onto the CART
(instead of the temp-keyed cookie): it is re-validated against every address round —
dropped when unavailable (unchanged protection, still asserted by the no-carrier leg
of spe42) and restored when still valid (was: silently lost). spe42's cookie-clear
pin is migrated accordingly on the paired e2e branch.

Merge pairing: merge together with PrestaShopCorp/opc.e2e.tests branch
test/persist-first-address-reads (C1-C3 specs + the spe42 contract migration).

Deliberately out of scope

The temp-address machinery itself (OpcTempAddress, the two cookie storages, the guard mount) stays untouched as the fallback for third-party raw-fields integrations and degraded windows. Its retirement is a separate refactor PR once this has soaked — the full TDD'd retirement (server awaiting contracts + deletions, −651 lines) is already prepared and validated (10/11 lots CI, the single red being a cookie-contract e2e pin) if/when wanted.

🤖 Generated with Claude Code

ThbPS and others added 7 commits July 15, 2026 10:46
…sory lock

Concurrent OPC ajax requests (autosave, carriers/payment refreshes,
selectaddress) all mutate the same cart row, several through full-row
Cart::save() calls built from each request's own snapshot, so
interleavings lose writes: duplicated/orphaned inline addresses and an
invoice pointer reverted to a stale value (nightly
inline-billing-no-duplicate: addressCount 6-9 instead of 2, and the
15s pollInvoiceIsSeparate timeout).

GET_LOCK('opc_cart_<id>', 10) around handleOpcRequest serializes them:
sequential flows only ever see an uncontended lock (~sub-ms), and on
timeout — or any error acquiring the lock — the request proceeds
unlocked, i.e. degrades to today's behavior, never worse. The lock
auto-releases if PHP dies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OpcTempAddress::cleanup() restored the cart pointers captured at
request start (in-memory cart) with a blind full-row Cart::save(): a
concurrent use_same re-check or savedraft persist committing between
capture and cleanup was silently reverted, leaving the cart pointing
at a stale — possibly deleted — address (nightly 15s
pollInvoiceIsSeparate timeout).

Capture the pre-swap pointers from a fresh DB read at mount time, swap
with pointer-only UPDATEs instead of full-row saves, and restore each
pointer only if it still holds OUR temp id (atomic UPDATE ... WHERE
id_address_x = tempId). A newer concurrent commit wins; with no
concurrency the behavior is unchanged. Belt-and-braces under the
per-cart lock: non-OPC writers (core-theme cart ajax) stay outside it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…me_address

The selectcarrier id-branch defaulted a MISSING use_same_address key to
'1', so a carrier selection carrying no billing intent at all silently
reset cart.id_address_invoice to the delivery address, clobbering a
separate billing the buyer had persisted. Align on the carriers
handler's guard (array_key_exists + === '1'): no key, no mirror.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…aborting

Aborting the superseded in-flight savedraft is client-only: the POST
keeps executing server-side and the address it inserts is never
adopted client-side (its id only lands with the response the abort
threw away). An interleaved use_same re-check then strands it on the
customer record — removeAbandonedInlineInvoiceAddress can only delete
the id the front knows. On slow shops this accumulates orphan
'Invoice address' rows (nightly counts of 6-9 for an expected 2).

Never keep more than one savedraft in flight and never abort: the edit
stays pending and ONE request — built from the form state at send
time, carrying the ids the in-flight response just delivered — goes
out when the current one settles. pagehide beacons are exempt (the
page is going away). views/public bundle rebuilt from sources.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET_LOCK's namespace is server-wide: two PrestaShop installs sharing
one MySQL server would contend on bare 'opc_cart_<id>' names —
spurious serialization, never corruption, but easy to rule out. The
discriminator is hashed to a fixed length because lock names are
capped at 64 characters and an over-long name errors out, which the
degrade path would turn into a silently never-locking controller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ess contracts

Two regression tests ported from the deterministic race fixtures that
proved the bugs on main:
- OpcTempAddress::cleanup() under a concurrent use_same re-check must
  yield to the newer pointer commit — never resurrect the pointer to
  the now-deleted billing address (the nightly 15s pollInvoiceIsSeparate
  timeout mechanism).
- A selectcarrier request carrying a delivery id but no
  use_same_address key must not mirror the invoice pointer onto the
  delivery address (was: missing key defaulted to '1' and clobbered a
  persisted separate billing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An environment where _DB_NAME_/_DB_PREFIX_ are undefined (the module's
own dockerized unit runner; exotic bootstraps) fataled on the lock-name
construction instead of degrading unlocked — the whole acquisition now
sits inside the same try/catch as the GET_LOCK call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-project-automation github-project-automation Bot moved this to Ready for review in PR Dashboard Jul 15, 2026
@ThbPS
ThbPS marked this pull request as draft July 15, 2026 13:35
…fetches wait for the in-flight persist

The guest inline flow made the server mount-and-delete temp address
rows on routine rounds because the client kept sending raw fields even
once the autosave had persisted them. The client now resolves the
persisted state first, so the server takes its battle-tested id-branch
and the temp branches become cold fallbacks (third-party raw-fields
integrations, degraded windows) — no server behavior change:

- selectaddress previews resolve the autosave-persisted hidden id when
  the form is untouched since its persist (dirty-tracking via an
  opcDraftPending dataset flag set on input, cleared per address type
  by the persist response — a use_same=1 save only MIRRORS the
  delivery id onto the invoice pointer and must not lift the billing
  hold);
- previews triggered while an edit awaits its autosave are held and
  re-run on opcAddressPersisted; inconclusive autosaves (incomplete
  address mid-typing) keep the hold armed — the eventual successful
  persist re-runs the refresh, readiness owns the retract meanwhile;
- a carriers fetch racing the FIRST persist (core updatedCart, retry
  click) holds the loader and re-fetches with the persisted id once
  the autosave confirms — same inconclusive-keeps-the-defer rule.

TDD: specs/local-persist-first/persist-first-address-reads.spec.ts
(opc.e2e.tests branch test/persist-first-address-reads) — C1/C2/C3 red
on the previous client, green here. views/public bundles rebuilt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ThbPS
ThbPS force-pushed the feat/persist-first-address-reads branch from bd0c1a1 to 7fb1f0f Compare July 15, 2026 13:39
@ThbPS ThbPS changed the title Persist-first address reads — retire the temp-address machinery Persist-first client reads: previews carry persisted ids, refreshes wait for the in-flight persist Jul 15, 2026
@ThbPS

ThbPS commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Consolidated into #121 per review decision: one train so everything is tested together. The final diff leaves OpcTempAddress strictly untouched (the persist-first client makes the temp branches cold; the per-cart lock serializes the OPC-vs-OPC interleavings). The full temp-machinery retirement stays a future refactor PR — already TDD-validated 11/11 in CI (run 29418233325), parked on wip/temp-retirement-full.

@ThbPS
ThbPS force-pushed the fix/serialize-opc-ajax-per-cart branch from 9687287 to f138d89 Compare July 15, 2026 13:56
@ThbPS ThbPS closed this Jul 15, 2026
@github-project-automation github-project-automation Bot moved this from Ready for review to Closed in PR Dashboard Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Closed

Development

Successfully merging this pull request may close these issues.

2 participants