Stop a Yugabyte read restart from silently killing a transactional send

FixEmailService
Shipped
September 4, 2026 at 11:55 PM UTC
Author
Kamo
Commit
0220d6a

EmailTemplateService.sendToUser is the endpoint behind every service-to-service transactional email on the platform, and it was @Transactional over three reads: findById(org), the template lookup, then the lazy initialisation of the org's domain rows inside resolvePrimaryDomain. The **************** gflag is off on this cluster, so YSQL maps every isolation level onto snapshot isolation, and Yugabyte can absorb a read restart transparently ONLY when the read is the first statement of its transaction. That domain read is the third, so a restart came out as 40001 Restart read required (query layer retry isn't possible because this is not the first command in the transaction) and POST /api/email/templates/send answered 500 — intermittently, with no write anywhere in the method, and for no reason visible to the service that asked for the mail. It was reported as "users land on /verification in kamo-register and get no email until they press Resend", which is four hops away. SecurityService sends the address-verification mail from **************** whose @Transactional covers the token row AND the outbound call, and the register endpoint only logged the failure — so a restart here rolled the token back while the register site still redirected to /verification, a screen that tells the registrant an email is on its way when none was sent and no code exists. Resend ran the same work in a fresh transaction and got through, which is what made it read as intermittent. Password reset, welcome, lead and document notifications sit on the same call and were failing the same way. The send now holds no transaction, so each read is its own implicit single-statement transaction — the one shape Yugabyte restarts for free. The two writes move to EmailTemplateWrites, a SEPARATE bean because a self-invocation never reaches the proxy and @Transactional on a self-invoked method is inert. incrementUsage runs after SMTP with the catch outside its own boundary and is never rethrown: a failed counter reporting 500 for a message that has already gone out is the exact inversion this path suffered when incrementUsage last ran without a transaction. open-in-view is pinned true rather than left to the Boot default, because the lazy domain load now depends on it. Not holding a transaction across the logo fetches and the SMTP send is worth having on its own — the pool is ten connections wide. TransactionalSendRatchetTest fails if the annotation comes back. EmailTemplateServiceTest was already red before this (a stale five-argument verify against a six-argument send) and is green again.

All changes

Like what you see shipping?

Every one of these updates lands in your workspace automatically. Start free and watch it grow week after week.

Start Free ForeverView Pricing