Adding concurrency control in our updateMovieHandler and documenting it.
All checks were successful
Deploy Greenlight API / deploy (push) Successful in 57s

This commit is contained in:
Maxime Delporte
2025-11-08 10:41:44 +01:00
parent b76496e096
commit f8f78c3eec
5 changed files with 36 additions and 4 deletions

View File

@@ -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 :

View File

@@ -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)
}

View File

@@ -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 {
switch {
case errors.Is(err, data.ErrEditConflict):
app.editConflictResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}

View File

@@ -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.

View File

@@ -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