feat(odoo-mail): sync partner upsert before firing async mails
Closes the register-then-immediately-forgot race for good. Two parallel calls to /velxio/api/send-welcome and /velxio/api/send-password-reset were hitting Odoo's REPEATABLE-READ snapshot timing: both workers took their snapshot BEFORE either had committed the partner row, so each ran its own INSERT and the second blew up on the velxio_user_id unique constraint — costing one of the two emails. Add a new sync_partner() helper that POSTs to a new Odoo route /velxio/api/upsert-partner (lives in velxio_subscription) which only upserts the partner — no mail, no subscription, fast. Register and forgot-password await this BEFORE firing the async welcome / reset tasks. Each user's flow is sequential at the Velxio HTTP layer, so the snapshot race disappears. sync_partner() reuses the existing _post() error-swallowing pattern. If Odoo is down, sync_partner returns None and the await is a no-op — the user still registers / gets the generic 200, the welcome / reset endpoints retain their defensive upsert as a fallback path.
This commit is contained in:
parent
60d570cc5a
commit
4df7abc624
|
|
@ -91,9 +91,20 @@ async def register(
|
|||
token = create_access_token({"sub": user.id})
|
||||
_set_auth_cookie(response, token)
|
||||
|
||||
# Fire-and-forget welcome mail through Odoo. Registration MUST NOT
|
||||
# block on this — if Odoo is down the user still gets in. We snapshot
|
||||
# the fields we need so the task isn't tied to the SQLAlchemy session.
|
||||
# Synchronously upsert the Odoo partner BEFORE firing the async
|
||||
# welcome. This serializes partner creation at the Velxio HTTP
|
||||
# layer so a subsequent forgot-password (or any other mail) lands
|
||||
# on a partner whose existence is already committed — sidestepping
|
||||
# the REPEATABLE-READ snapshot race two parallel mail workers
|
||||
# would otherwise hit. `sync_partner` swallows its own errors and
|
||||
# returns None if Odoo is down, so the user is never blocked.
|
||||
await odoo_mail.sync_partner(
|
||||
velxio_user_id=user.id,
|
||||
email=user.email,
|
||||
name=user.username,
|
||||
country_code=user.signup_country or None,
|
||||
)
|
||||
|
||||
asyncio.create_task(
|
||||
odoo_mail.send_welcome(
|
||||
velxio_user_id=user.id,
|
||||
|
|
@ -210,6 +221,16 @@ async def forgot_password(
|
|||
reset_url = (
|
||||
f"{settings.FRONTEND_URL.rstrip('/')}/reset-password?token={plaintext}"
|
||||
)
|
||||
# Same trick as register: ensure the Odoo partner exists synchronously
|
||||
# before firing the async reset mail. Cheap (~50ms) and removes the
|
||||
# REPEATABLE-READ snapshot race entirely.
|
||||
await odoo_mail.sync_partner(
|
||||
velxio_user_id=user.id,
|
||||
email=user.email,
|
||||
name=user.username,
|
||||
country_code=user.signup_country or None,
|
||||
)
|
||||
|
||||
asyncio.create_task(
|
||||
odoo_mail.send_password_reset(
|
||||
email=user.email,
|
||||
|
|
|
|||
|
|
@ -2,20 +2,33 @@
|
|||
|
||||
Velxio leans on Odoo's outgoing-mail relay (SPF/DKIM already warmed up,
|
||||
templates editable from the admin UI) instead of running its own SMTP.
|
||||
The Odoo-side endpoints live in `velxio_transactional_mail` addon:
|
||||
The Odoo-side endpoints live in `velxio_transactional_mail` (mail) and
|
||||
`velxio_subscription` (partner upsert) addons:
|
||||
|
||||
POST <ODOO_URL>/velxio/api/send-welcome
|
||||
POST <ODOO_URL>/velxio/api/send-password-reset
|
||||
POST <ODOO_URL>/velxio/api/upsert-partner (sync, fast)
|
||||
POST <ODOO_URL>/velxio/api/send-welcome (async, slow — SMTP)
|
||||
POST <ODOO_URL>/velxio/api/send-password-reset (async, slow — SMTP)
|
||||
|
||||
Both expect a JSON-RPC payload (Odoo's `type='json'` controllers expect
|
||||
All expect a JSON-RPC payload (Odoo's `type='jsonrpc'` controllers expect
|
||||
`{"jsonrpc": "2.0", "params": {...}}`) and authenticate via the
|
||||
`X-Velxio-API-Key` header.
|
||||
|
||||
These helpers are designed to be called from `asyncio.create_task(...)`
|
||||
so they NEVER raise into the request lifecycle. Any failure is logged at
|
||||
WARNING level and the function returns False. The result is otherwise
|
||||
ignored — registration / forgot-password succeed even when Odoo is down,
|
||||
the user just doesn't get the email until ops re-runs the cron.
|
||||
`sync_partner` is meant to be awaited synchronously. The mail helpers
|
||||
are designed to be called from `asyncio.create_task(...)` so they NEVER
|
||||
raise into the request lifecycle. Any failure is logged at WARNING level
|
||||
and the function returns False/None. Registration / forgot-password
|
||||
succeed even when Odoo is down — the user just doesn't get the email
|
||||
until ops re-runs the cron.
|
||||
|
||||
Why the partner upsert is split out: Odoo's transaction isolation is
|
||||
REPEATABLE READ, so two parallel mail workers for the same brand-new
|
||||
user (register followed by an immediate forgot-password) would both
|
||||
take a snapshot before either committed the partner. Both would then
|
||||
INSERT, hitting the unique constraint on velxio_user_id and rolling
|
||||
back one of the two mails. Funnelling the partner upsert through a
|
||||
single synchronous call BEFORE the async mail tasks fire serializes
|
||||
the create at the Velxio HTTP layer and dodges the snapshot race
|
||||
entirely.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -74,6 +87,35 @@ async def _post(endpoint: str, params: dict) -> Optional[dict]:
|
|||
return None
|
||||
|
||||
|
||||
async def sync_partner(
|
||||
*,
|
||||
velxio_user_id: str,
|
||||
email: str,
|
||||
name: Optional[str] = None,
|
||||
country_code: Optional[str] = None,
|
||||
) -> Optional[int]:
|
||||
"""Synchronously ensure the Odoo partner for this user exists.
|
||||
|
||||
Caller awaits this BEFORE firing any async mail tasks so the
|
||||
welcome / reset endpoints land on a partner the upsert already
|
||||
committed. Returns the partner_id on success, None on any failure
|
||||
(Odoo down, network error, etc.). The caller should ignore the
|
||||
result and proceed — the user is registered/recovered regardless.
|
||||
"""
|
||||
params: dict = {
|
||||
"velxio_user_id": velxio_user_id,
|
||||
"email": email,
|
||||
}
|
||||
if name:
|
||||
params["name"] = name
|
||||
if country_code:
|
||||
params["country_code"] = country_code
|
||||
result = await _post("/velxio/api/upsert-partner", params)
|
||||
if result and "partner_id" in result:
|
||||
return int(result["partner_id"])
|
||||
return None
|
||||
|
||||
|
||||
async def send_welcome(
|
||||
*,
|
||||
velxio_user_id: str,
|
||||
|
|
|
|||
Loading…
Reference in New Issue