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:
davidmonterocrespo24 2026-05-13 02:59:52 +02:00
parent 60d570cc5a
commit 4df7abc624
2 changed files with 75 additions and 12 deletions

View File

@ -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,

View File

@ -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,