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.
112 lines
3.3 KiB
Go
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)
|
|
})
|
|
}
|