From f8f78c3eec6ab67647e44f75ec1f95614e96961a Mon Sep 17 00:00:00 2001 From: Maxime Delporte Date: Sat, 8 Nov 2025 10:41:44 +0100 Subject: [PATCH] Adding concurrency control in our updateMovieHandler and documenting it. --- README.md | 8 ++++++++ cmd/api/errors.go | 5 +++++ cmd/api/movies.go | 7 ++++++- internal/data/models.go | 2 ++ internal/data/movies.go | 18 +++++++++++++++--- 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1b6c816..32cd03e 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,14 @@ There are two comon approaches to doing this : From an HTTP semantics point of view, using headers to convey the API version is the 'purer' approach. But from a user-experience point of view, using a URL prefix is arguably better. It makes it possible for developers to see which version of the API is being used at a glance, and it also means that the API can still be explored using a regular web browser (which is harder if custom headers are required). +## Concurrent request to test data race + +In our 'updateMovieHandler' we prevent data race. To test if everything is going as we want, we can try multiple request at the same time with this command : + +```bash +xargs -I % -P8 curl -X PATCH -d '{"runtime": "97 mins"}' "localhost:4000/v1/movies/4" < <(printf '%s\n' {1..8}) +``` + ## SQL Migrations The first thing we need to do is generate a pair of _migration files_ using the **migrate create** command : diff --git a/cmd/api/errors.go b/cmd/api/errors.go index 8273418..e553361 100644 --- a/cmd/api/errors.go +++ b/cmd/api/errors.go @@ -56,3 +56,8 @@ func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Reques func (app *application) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) { app.errorResponse(w, r, http.StatusUnprocessableEntity, errors) } + +func (app *application) editConflictResponse(w http.ResponseWriter, r *http.Request) { + message := "unable to update the record due to an edit conflict, please try again" + app.errorResponse(w, r, http.StatusConflict, message) +} diff --git a/cmd/api/movies.go b/cmd/api/movies.go index 195f75e..cf74688 100644 --- a/cmd/api/movies.go +++ b/cmd/api/movies.go @@ -151,7 +151,12 @@ func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Reques // Pass the updated movie record to our new Update() method err = app.models.Movies.Update(movie) if err != nil { - app.serverErrorResponse(w, r, err) + switch { + case errors.Is(err, data.ErrEditConflict): + app.editConflictResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } return } diff --git a/internal/data/models.go b/internal/data/models.go index e3826c3..eb8d236 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -6,8 +6,10 @@ import ( ) // ErrRecordNotFound : Define a custom ErrRecordNotFound error. We'll return this from our Get() method when looking up a movie that doesn't exist in our database. +// ErrEditConflict : This error is used when a data race occurs. var ( ErrRecordNotFound = errors.New("record not found") + ErrEditConflict = errors.New("edit conflict") ) // Models : Wraps the MovieModel. We'll add other models to this, like a UserModel and PermissionModel, as our build progresses. diff --git a/internal/data/movies.go b/internal/data/movies.go index 1a43c8c..9d3a6b2 100644 --- a/internal/data/movies.go +++ b/internal/data/movies.go @@ -110,10 +110,11 @@ func (m MovieModel) Get(id int64) (*Movie, error) { // Update : Updating a specific record in the movies table func (m MovieModel) Update(movie *Movie) error { // Declare the SQL query for updating the record and returning the new version number. + // Add the 'AND version = $6' clause to prevent data race query := ` UPDATE movies SET title = $1, year = $2, runtime = $3, genres = $4, version = version +1 - WHERE id = $5 + WHERE id = $5 AND version = $6 RETURNING version` // Create an args slice containing the values for the placeholder parameters. @@ -123,10 +124,21 @@ func (m MovieModel) Update(movie *Movie) error { movie.Runtime, pq.Array(movie.Genres), movie.ID, + movie.Version, } - // Use the QueryRow() method to execute the query, passing in the args slice as a variadic parameter and scanning the new version value into the movie struct. - return m.DB.QueryRow(query, args...).Scan(&movie.Version) + // Execute the SQL query. If no matching row could be found, we know the movie version has changed (or the record has been deleted) and we return our custom ErrEditConflict error. + err := m.DB.QueryRow(query, args...).Scan(&movie.Version) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return ErrEditConflict + default: + return err + } + } + + return nil } // Delete : Deleting a specific record from the movies table