Files
summercms/modules/cabana/singleton.go
Jakub Zych 6312929148 fix(17-06): singleton route retries a lost first-or-create race
Two first requests to GET .../singleton both missed the record; the
loser's insert hit the unique index and the route answered 500. A
unique violation from SingletonRecord now reruns the transaction once,
which finds the winner's record. TestSingleton fires six concurrent
first requests and requires 200 with the same id from each.
2026-10-12 19:31:01 +02:00

112 lines
3.3 KiB
Go

package cabana
import (
"errors"
"log/slog"
"net/http"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
)
// AdminSingletonResult is the data of the singleton route: the id of the one
// record a pact.AdminSingleton controller edits.
type AdminSingletonResult struct {
ID uint `json:"id"`
}
// singletonRefuses answers 404 for a list, create, bulk or delete request on
// a singleton controller (D-11) and reports whether it did.
func singletonRefuses(w http.ResponseWriter, r *http.Request, cc *CompiledController) bool {
if cc == nil || !cc.singleton {
return false
}
writeNotFound(w, r)
return true
}
// singletonRace is a SingletonRecord that lost a first-or-create race: a
// concurrent first request created the record between its read and its
// insert, so the insert hit a unique index.
type singletonRace struct{}
func (singletonRace) Error() string { return "cabana: singleton record created concurrently" }
// uniqueViolation reports a Postgres unique violation (SQLSTATE 23505).
func uniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
// singletonRoute serves GET {vendor}/{plugin}/{controller}/singleton: in one
// transaction it asks the controller's SingletonRecord for the record
// (created when absent) and answers its id, after checking that the form
// scope (FormExtendQuery) can load it. When two first requests race and the
// loser's insert hits the record's unique index, the transaction runs once
// more and finds the record the winner created. It is guarded like the form
// schema; a controller that is not a singleton answers 404.
func (s *service) singletonRoute(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
provider, ok := cc.Controller.(pact.AdminSingleton)
if !cc.singleton || !ok || provider == nil {
writeNotFound(w, r)
return
}
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
ctx := r.Context()
var result AdminSingletonResult
attempt := func(tx *gorm.DB) error {
txCtx := withTx(ctx, tx)
record, err := provider.SingletonRecord(txCtx, tx)
if err != nil {
if uniqueViolation(err) {
return singletonRace{}
}
return lifecycleFailure(cc, err)
}
var id uint
if record != nil {
id = pkUint(record)
}
if id == 0 {
return lifecycleFailure(cc, errors.New("cabana: SingletonRecord returned no record with an id"))
}
target, err := newWritableModel(cc)
if err != nil {
return err
}
pk, err := coercePK(target, id)
if err != nil {
return err
}
if err := loadRecord(txCtx, tx, cc, target, pk); err != nil {
return err
}
result.ID = id
return nil
}
err = db.WithContext(ctx).Transaction(attempt)
var race singletonRace
if errors.As(err, &race) {
err = db.WithContext(ctx).Transaction(attempt)
if errors.As(err, &race) {
err = lifecycleFailure(cc, err)
}
}
if err != nil {
var missing recordNotFound
if !errors.As(err, &missing) {
slog.Error("cabana: singleton record failed", "controller", controllerID(cc), "error", err)
}
writeCRUDError(w, err)
return
}
WriteData(w, http.StatusOK, result, nil)
})
}