R3-9: unify consumeBiometricChallenge on READ COMMITTED + GetForUpdate

consumeBiometricChallenge was the odd one out: it used SERIALIZABLE
while the two other row-local CAS sites (consumeSessionForSign and
tryApproveOperation) used READ COMMITTED + GetForUpdate. For row-
local CAS the FOR UPDATE row lock already gives us the atomicity we
need; SERIALIZABLE adds cost and forces waiters to abort with 40001
instead of blocking. One pattern, one way.

Aligns with LLM.md's R2-2 note (READ COMMITTED + FOR UPDATE is the
canonical row-local CAS pattern for this repo).
This commit is contained in:
2025-12-28 11:09:25 -08:00
parent 14d42b3425
commit d67948b00a
+9 -3
View File
@@ -747,13 +747,19 @@ func (s *Server) handleBiometricEnroll(w http.ResponseWriter, r *http.Request) {
// WebAuthn challenge as used. Returns (chalBytes, true, nil) iff the challenge
// exists, belongs to the user, is pending, and transitions to consumed.
// Prevents replay of the same challenge (R2-1).
//
// R3-9: uses READ COMMITTED + GetForUpdate, matching consumeSessionForSign
// and tryApproveOperation. For row-local CAS the FOR UPDATE row lock
// provides the atomicity we need; SERIALIZABLE would abort concurrent
// waiters with 40001 rather than let them block, costing us a retry.
// One pattern, one way.
func (s *Server) consumeBiometricChallenge(ctx context.Context, orgID, userID, credentialID string) ([]byte, bool, error) {
var out []byte
err := s.db.ORM.RunInTransactionWith(ctx, &orm.TxOptions{
Isolation: orm.IsolationSerializable,
MaxAttempts: 5,
Isolation: orm.IsolationReadCommitted,
MaxAttempts: 3,
}, func(tx orm.DB) error {
cred, gerr := orm.Get[db.WebAuthnCredential](tx, credentialID)
cred, gerr := orm.GetForUpdate[db.WebAuthnCredential](tx, credentialID)
if gerr != nil {
return gerr
}