feat(18.1-08): detached admin bulk actions run outside the framework transaction

- pact.AdminBulkAction.Detached (json:"-"): opt-in; records loaded through the list scope without row locks, no write transaction in the context
- cabana BulkAction: detached branch with the same empty, partial-selection, failure and message rules; lockScoped is loadScoped with lock, the locked path unchanged
- docs: admin-controllers bulk route list, pact and cabana READMEs
This commit is contained in:
Jakub Zych
2026-10-16 12:47:46 +02:00
parent f217c73ef8
commit fc2a397d9b
5 changed files with 69 additions and 10 deletions

View File

@@ -345,7 +345,9 @@ func (s CRUDService) BulkDelete(ctx context.Context, cc *CompiledController, in
// matches no scoped row is a successful no-op with Affected 0 and the action
// does not run. A mixed present/absent selection conflicts and rolls back. A
// name the list does not declare, or the controller does not register, is not
// found.
// found. A Detached action opens no transaction: its records are loaded
// through the same scope and rules without row locks, and Run gets a context
// without a write transaction.
func (s CRUDService) BulkAction(ctx context.Context, cc *CompiledController, name string, in BulkDeleteInput) (BulkActionResult, error) {
if s.DB == nil {
return BulkActionResult{}, errors.New("cabana: database is not configured")
@@ -365,6 +367,10 @@ func (s CRUDService) BulkAction(ctx context.Context, cc *CompiledController, nam
return BulkActionResult{}, err
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr))
if action.Detached {
result, err := s.detachedBulkAction(ctx, cc, name, action, ids)
return result, localizeForbidden(ctx, s.tr, err)
}
var result BulkActionResult
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
@@ -398,6 +404,36 @@ func (s CRUDService) BulkAction(ctx context.Context, cc *CompiledController, nam
return result, nil
}
// detachedBulkAction is the branch of BulkAction for a Detached action: no
// transaction is opened, the rows are loaded through the list scope without
// row locks, and Run gets the locale context without a write transaction.
// The empty, partial-selection, failure and message rules are those of the
// locked path.
func (s CRUDService) detachedBulkAction(ctx context.Context, cc *CompiledController, name string, action pact.AdminBulkAction, ids []uint) (BulkActionResult, error) {
if err := ctx.Err(); err != nil {
return BulkActionResult{}, lifecycleFailure(cc, err)
}
proto, err := newWritableModel(cc)
if err != nil {
return BulkActionResult{}, err
}
rows, err := loadScoped(ctx, s.DB, cc, proto, ids, false)
if err != nil {
return BulkActionResult{}, err
}
if len(rows) == 0 {
return BulkActionResult{}, nil
}
if len(rows) != len(ids) {
return BulkActionResult{}, partialSelection{}
}
out, err := action.Run(ctx, pact.AdminBulkActionInput{Records: rows})
if err != nil {
return BulkActionResult{}, actionFailure(cc, "bulk action", name, err)
}
return BulkActionResult{Message: translateKey(ctx, s.tr, out.Message), Affected: out.Affected}, nil
}
// actionFailure classifies the error of a bulk or record action's Run like a
// lifecycle hook's: the errors a plugin may answer with pass through, anything
// else is logged with the controller and action and becomes the opaque
@@ -936,13 +972,22 @@ func normalizeIDs(raw []any) ([]uint, error) {
return ids, nil
}
// lockScoped loads and row-locks the rows of ids through the list scope on
// the write transaction tx (loadScoped with lock).
func lockScoped(ctx context.Context, tx *gorm.DB, cc *CompiledController, proto any, ids []uint) ([]any, error) {
return loadScoped(ctx, tx, cc, proto, ids, true)
}
// loadScoped loads the rows of ids through the controller's ListExtendQuery
// scope on db, ordered by primary key, with FOR UPDATE row locks when lock
// is set (lockScoped) and without them for a detached bulk action.
func loadScoped(ctx context.Context, db *gorm.DB, cc *CompiledController, proto any, ids []uint, lock bool) ([]any, error) {
mt := reflect.TypeOf(proto)
if mt == nil || mt.Kind() != reflect.Pointer || mt.Elem().Kind() != reflect.Struct {
return nil, &CapabilityError{ControllerID: controllerID(cc)}
}
holder := reflect.New(reflect.SliceOf(mt.Elem()))
q := tx.WithContext(ctx).Model(proto)
q := db.WithContext(ctx).Model(proto)
if cc != nil {
if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil {
if next := ext.ListExtendQuery(ctx, q); next != nil {
@@ -955,8 +1000,10 @@ func lockScoped(ctx context.Context, tx *gorm.DB, cc *CompiledController, proto
for i, id := range ids {
vals[i] = castPK(proto, id)
}
err := q.Clauses(clause.Locking{Strength: "UPDATE"}).
Where(clause.IN{Column: clause.Column{Name: col}, Values: vals}).
if lock {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
err := q.Where(clause.IN{Column: clause.Column{Name: col}, Values: vals}).
Order(clause.OrderByColumn{Column: clause.Column{Name: col}, Desc: false}).
Find(holder.Interface()).Error
if err != nil {