Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Question] Does Bun support optimistic locking? #1128

Open
takaaa220 opened this issue Feb 14, 2025 · 7 comments
Open

[Question] Does Bun support optimistic locking? #1128

takaaa220 opened this issue Feb 14, 2025 · 7 comments
Labels
enhancement New feature or request help wanted Extra attention is needed

Comments

@takaaa220
Copy link
Contributor

Hi, maintainers!

I’m considering replacement into Bun in my project and want to check if it supports optimistic locking.

If not, is there any plan to support it in the future?
If there is no plan to support it, I’m thinking of implementing it using model query hooks(BeforeInsert, BeforeUpdate and BeforeDelete), but I’d love to hear if there are better approaches.

Thanks!

@j2gg0s
Copy link
Collaborator

j2gg0s commented Feb 17, 2025

What is optimistic locking?
A field like version?

UPDATE users 
SET balance = balance - 100, version = version + 1 
WHERE id = 1 AND version = 3;

If I understand correctly, we dont cureently support it.
But I think supporting this feature is a good idea.
The challenge is how to design the interface for users. Do you have any ideas or existing examples?

@j2gg0s j2gg0s added the enhancement New feature or request label Feb 17, 2025
@takaaa220
Copy link
Contributor Author

@j2gg0s

What is optimistic locking?
A field like version?

Yes, you understand correctly.

If I understand correctly, we dont cureently support it.

Thank your for reply, i got it.

The challenge is how to design the interface for users. Do you have any ideas or existing examples?

I think the following interface changes would be necessary to support optimistic locking:

  • Add an interface to specify whether a model should use optimistic locking and which column to use (e.g., bun:",optimisticlock" for versioned field).
  • Modify UPDATE and DELETE queries to include the version field in the condition when updating and deleting rows of tables that use optimistic locking. And then, return an error indicating a conflict if the number of affected rows does not match the expected count.
  • Increment version when updating a record.
  • (Optional) Set an initial version value when inserting a record.

An alternative approach could be providing a plugin that utilizes Model Query Hooks.
I created a PoC, but I found that RowsAffected is not accessible in AfterUpdateHook. To work around this, I had to perform an additional SELECT inside AfterUpdate.

Poc (Maybe this works only in limited usecase)
package optimisticlocking

import (
	"context"
	"fmt"

	"github.com/uptrace/bun"
)

// OptimisticLockingModel is a model that supports optimistic locking.
//
// How to implement OptimisticLockingModel is as follows:
//
//	type User struct {
//		ID      int64 `bun:",pk,autoincrement"`
//		Name    string
//		Version int
//	}
//
//	var _ optimisticlocking.OptimisticLockingModel = (*User)(nil)
//
//	func (u *User) VersionColumn() string {
//		return "version"
//	}
//
//	func (u *User) GetVersion() int {
//		return u.Version
//	}
//
//	func (u *User) IncrementVersion() {
//		u.Version++
//	}
//
//	func (u *User) BeforeAppendModel(ctx context.Context, query bun.Query) error {
//		return optimisticlocking.BeforeAppendModel(ctx, query)
//	}
//
//	func (u *User) BeforeUpdate(ctx context.Context, query *bun.UpdateQuery) error {
//		return optimisticlocking.BeforeUpdate(ctx, query)
//	}
//
//	func (u *User) BeforeDelete(ctx context.Context, query *bun.DeleteQuery) error {
//		return optimisticlocking.BeforeDelete(ctx, query)
//	}
//
//	func (u *User) AfterUpdate(ctx context.Context, query *bun.UpdateQuery) error {
//		return optimisticlocking.AfterUpdate(ctx, query)
//	}
//
//	func (u *User) AfterDelete(ctx context.Context, query *bun.DeleteQuery) error {
//		return optimisticlocking.AfterDelete(ctx, query)
//	}
type OptimisticLockingModel interface {
	VersionColumn() string
	GetVersion() int
	IncrementVersion()

	bun.BeforeAppendModelHook
	bun.BeforeUpdateHook
	bun.BeforeDeleteHook
	bun.AfterUpdateHook
	bun.AfterDeleteHook
}

// BeforeAppendModel is a hook that is called before the model is appended to the query.
// It is used to set the version column to the current version.
func BeforeAppendModel(ctx context.Context, query bun.Query) error {
	model := getOptimisticLockingModelFromQuery(query)
	if model == nil {
		return nil
	}

	switch query.(type) {
	case *bun.InsertQuery:
		if model.GetVersion() == 0 {
			model.IncrementVersion()
		}
	case *bun.UpdateQuery:
		model.IncrementVersion()
	}
	return nil
}

// BeforeUpdate is a hook that is called before the model is updated.
// It is used to check if the version column is out of date.
func BeforeUpdate(ctx context.Context, query *bun.UpdateQuery) error {
	model := getOptimisticLockingModelFromQuery(query)
	if model == nil {
		return nil
	}

	query.Where("? = ?", query.FQN(model.VersionColumn()), model.GetVersion())

	return nil
}

// BeforeDelete is a hook that is called before the model is deleted.
// It is used to check if the version column is out of date.
func BeforeDelete(ctx context.Context, query *bun.DeleteQuery) error {
	model := getOptimisticLockingModelFromQuery(query)
	if model == nil {
		return nil
	}

	query.Where("? = ?", bun.Ident(model.VersionColumn()), model.GetVersion())

	return nil
}

// AfterUpdate is a hook that is called after the model is updated.
// It is used to check if the version column is out of date.
func AfterUpdate(ctx context.Context, query *bun.UpdateQuery) error {
	model := getOptimisticLockingModelFromQuery(query)
	if model == nil {
		return nil
	}

	// Since cannot access the update result in AfterUpdateHook,
	// detect version conflicts by checking if a record with the updated version exists.
	exists, err := bun.NewSelectQuery(query.DB()).
		Model(model).
		ColumnExpr("1").
		WherePK().
		Where("? = ?", query.FQN(model.VersionColumn()), model.GetVersion()).
		Exists(ctx)
	if err != nil {
		return err
	}
	if !exists {
		return &OptimisticLockingError{
			TableName:  query.GetTableName(),
			OldVersion: model.GetVersion() - 1,
		}
	}

	return nil
}

func AfterDelete(ctx context.Context, query *bun.DeleteQuery) error {
	model := getOptimisticLockingModelFromQuery(query)
	if model == nil {
		return nil
	}

	// Since cannot access the delete result in AfterDeleteHook,
	// detect version conflicts by checking if a record with the updated version exists.
	exists, err := bun.NewSelectQuery(query.DB()).
		Model(model).
		ColumnExpr("1").
		WherePK().
		Where("? = ?", model.VersionColumn(), model.GetVersion()).
		Exists(ctx)
	if err != nil {
		return err
	}
	if !exists {
		return &OptimisticLockingError{
			TableName:  query.GetTableName(),
			OldVersion: model.GetVersion(),
		}
	}

	return nil
}

func getOptimisticLockingModelFromQuery(query bun.Query) OptimisticLockingModel {
	model := query.GetModel()
	optimisticLockingModel, ok := model.Value().(OptimisticLockingModel)
	if !ok {
		return nil
	}
	return optimisticLockingModel
}

type OptimisticLockingError struct {
	TableName  string
	OldVersion int
}

func (e *OptimisticLockingError) Error() string {
	return fmt.Sprintf("bun: version %d is out of version on table %s", e.OldVersion, e.TableName)
}

usage:

Do you have any thoughts on these approaches? I’m open to discussing potential improvements or alternative designs.

@takaaa220
Copy link
Contributor Author

existing examples?

Here are some examples of ORMs that implement optimistic locking:

Gorm provides optimistic locking as a plugin. It offers a custom type for the version column, which is used for optimistic locking. The column type is an integer.

Gorp allows specifying the version column by calling SetVersionCol("COLUMN_NAME"). The column type used for versioning is an integer.

In ActiveRecord, optimistic locking is automatically enabled if the table contains a lock_version column. The column name used for optimistic locking can be customized. The column type is an integer.

@opstoken
Copy link

dbdriver uses duckdb, dialect with pgdialect, and fix some field type errors, my app runs ok

@emoss08
Copy link
Contributor

emoss08 commented Feb 19, 2025

Here is an example of how I do optimistic locking in my application, may not be the best apporach ,but it works for my use case.

func (cr *commodityRepository) Update(ctx context.Context, com *commodity.Commodity) (*commodity.Commodity, error) {
	dba, err := cr.db.DB(ctx)
	if err != nil {
		return nil, eris.Wrap(err, "get database connection")
	}

	log := cr.l.With().
		Str("operation", "Update").
		Str("id", com.GetID()).
		Int64("version", com.Version).
		Logger()

	err = dba.RunInTx(ctx, nil, func(c context.Context, tx bun.Tx) error {
		ov := com.Version

		com.Version++

		results, rErr := tx.NewUpdate().
			Model(com).
			WherePK().
			Where("com.version = ?", ov).
			Returning("*").
			Exec(c)
		if rErr != nil {
			log.Error().
				Err(rErr).
				Interface("commodity", com).
				Msg("failed to update commodity")
			return eris.Wrap(rErr, "update commodity")
		}

		rows, roErr := results.RowsAffected()
		if roErr != nil {
			log.Error().
				Err(roErr).
				Interface("commodity", com).
				Msg("failed to get rows affected")
			return eris.Wrap(roErr, "get rows affected")
		}

		if rows == 0 {
			return errors.NewValidationError(
				"version",
				errors.ErrVersionMismatch,
				fmt.Sprintf("Version mismatch. The Commodity (%s) has either been updated or deleted since the last request.", com.GetID()),
			)
		}

		return nil
	})
	if err != nil {
		log.Error().Err(err).Msg("failed to update commodity")
		return nil, eris.Wrap(err, "update commodity")
	}

	return com, nil
}

@j2gg0s j2gg0s added the help wanted Extra attention is needed label Feb 20, 2025
@Tiscs
Copy link
Collaborator

Tiscs commented Feb 20, 2025

What is optimistic locking? A field like version?

UPDATE users
SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 3;
If I understand correctly, we dont cureently support it. But I think supporting this feature is a good idea. The challenge is how to design the interface for users. Do you have any ideas or existing examples?

I implemented optimistic locking in a similar way and recommend that developers on my project team fully understand the implications of database operations. It’s also important to minimize implicit logic, even if this results in some repetitive boilerplate code. I make an effort to place as little data processing logic as possible into the hooks.

This makes Bun both concise and easy to understand, which is why I appreciate it so much.

@takaaa220
Copy link
Contributor Author

@Tiscs
I also prefer minimizing implicit behavior, and I believe avoiding implicit behavior aligns well with Bun's design philosophy.
(If I didn't have to consider migrating a project that heavily relies on the optimistic locking feature of an ORM to Bun, I could fully agree with your opinion... 😄 )

That said, I think optimistic locking is a widely needed feature. The fact that various ORMs support this feature suggests its demand.
Therefore, would you consider providing a solution that avoids implicit behavior while still making optimistic locking available to some extent?
It doesn’t necessarily have to be provided by Bun itself—a third-party plugin or extension could also be a viable option. (I will also try to think about possible approaches.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request help wanted Extra attention is needed
Projects
None yet
Development

No branches or pull requests

5 participants