8 Operaciones CRUD avanzadas

En este post, vamos a examinar algunos patrones más “avanzados” que puedes querer usar para los puntos finales CRUD que proporciona tu API.

Aprenderás:

  • Cómo admitir actualizaciones parciales de un recurso (para que el cliente solo necesite enviar los datos que desea cambiar).
  • Cómo utilizar el control de concurrencia(concurrency control) optimista para evitar condiciones de carrera (race conditions) cuando dos clientes intentan actualizar el mismo recurso al mismo tiempo.
  • Cómo utilizar los plazos de tiempo del contexto para finalizar consultas de base de datos que se ejecutan durante mucho tiempo y prevenir el uso innecesario de recursos.

8.1 Manejando updates parciales

En este capítulo vamos a cambiar el comportamiento del updateMovieHandler para que admita actualizaciones parciales de los registros de películas. Conceptualmente, esto es un poco más complicado que hacer una sustitución completa, por eso establecimos las bases con ese enfoque primero. Como ejemplo, supongamos que notamos que el año de lanzamiento de The Breakfast Club está mal en nuestra base de datos (debería ser 1985 en lugar de 1986). Sería conveniente si pudiéramos enviar una solicitud JSON que contenga solo el cambio que debe aplicarse, en lugar de todos los datos de la película, así:

{"year": 1985}

Echemos un vistazo rápido a lo que sucede si intentamos enviar esta solicitud en este momento:

$ curl -X PUT -d '{"year": 1985}' localhost:4000/v1/movies/4
{
    "error": {
        "genres": "must be provided",
        "runtime": "must be provided",
        "title": "must be provided"
    }
}

Como mencionamos anteriormente en el libro, al decodificar el cuerpo de la solicitud, cualquier campo en nuestra estructura de entrada que no tenga un par clave/valor JSON correspondiente conservará su valor cero. Resulta que verificamos estos valores cero durante la validación y devolvemos los mensajes de error que ves arriba.

En el contexto de la actualización parcial, esto causa un problema. ¿Cómo diferenciamos entre:

  • Un cliente que proporciona un par clave/valor que tiene un valor de valor cero, como {"title": ""}, en cuyo caso queremos devolver un error de validación.
  • Un cliente que no proporciona un par clave/valor en su JSON en absoluto, en cuyo caso queremos ‘omitir’ la actualización del campo pero no enviar un error de validación.

Para ayudar a responder esto, recordemos rápidamente cuáles son los valores por defecto (zero-values) para los diferentes tipos en Go.

Go typeZero-value
int*, uint*, float*, complex0
string""
boolfalse
func, array, slice, map, chan and pointersnil

Lo fundamental aquí es notar que los punteros tienen el valor por defecto nil.

Entonces, en teoría, podríamos cambiar los campos en nuestra estructura de entrada (input struct) para que sean punteros. Luego, para ver si un cliente ha proporcionado un par clave/valor específico en el JSON, simplemente podemos verificar si el campo correspondiente en la estructura de entrada es igual a nil o no.

var input struct {
		Title   *string       `json:"title"`   // This will be nil if there is no corresponding key in the JSON.
		Year    *int32        `json:"year"`    // Likewise...
		Runtime *data.Runtime `json:"runtime"` // Likewise...
		Genres  []string      `json:"genres"`  // We don't need to change this because slices already have the zero-value nil.
	}

8.1 Realizando el update parcial

Pongamoslo en practica y editemos updateMovieHandler para que soporte los update parciales:

func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Request) {
	// Extract the movie ID from the URL.
	id, err := app.readIDParam(r)
	if err != nil {
		app.notFoundResponse(w, r)
		return
	}

	// Retrieve the movie record as normal.
	movie, err := app.models.Movies.Get(id)
	if err != nil {
		switch {
		case errors.Is(err, data.ErrRecordNotFound):
			app.notFoundResponse(w, r)
		default:
			app.serverErrorResponse(w, r, err)
		}
		return
	}
	// Use pointers for the Title, Year and Runtime fields.
	var input struct {
		Title   *string       `json:"title"`
		Year    *int32        `json:"year"`
		Runtime *data.Runtime `json:"runtime"`
		Genres  []string      `json:"genres"`
	}

	// Decode the JSON as normal.
	err = app.readJSON(w, r, &input)
	if err != nil {
		app.badRequestResponse(w, r, err)
		return
	}
	// If the input.Title value is nil then we know that no corresponding "title" key/
	// value pair was provided in the JSON request body. So we move on and leave the
	// movie record unchanged. Otherwise, we update the movie record with the new title
	// value. Importantly, because input.Title is a now a pointer to a string, we need
	// to dereference the pointer using the * operator to get the underlying value
	// before assigning it to our movie record.
	if input.Title != nil {
		movie.Title = *input.Title
	}
	// We also do the same for the other fields in the input struct.
	if input.Year != nil {
		movie.Year = *input.Year
	}
	if input.Runtime != nil {
		movie.Runtime = *input.Runtime
	}
	if input.Genres != nil {
		movie.Genres = input.Genres // Note that we don't need to dereference a slice.
	}

	v := validator.New()
	if data.ValidateMovie(v, movie); !v.Valid() {
		app.failedValidationResponse(w, r, v.Errors)
		return
	}

	err = app.models.Movies.Update(movie)
	if err != nil {
		app.serverErrorResponse(w, r, err)
		return
	}

	err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil)
	if err != nil {
		app.serverErrorResponse(w, r, err)
	}
}

Resumiendo: hemos cambiado nuestra estructura de entrada para que todos los campos tengan el valor cero nil. Después de analizar la solicitud JSON, recorremos los campos de la estructura de entrada y solo actualizamos el registro de la película si el nuevo valor no es nil.

Además de esto, para los puntos finales de la API que realizan actualizaciones parciales en un recurso, es apropiado utilizar el método HTTP PATCH en lugar de PUT (que está destinado a reemplazar un recurso por completo).

Así que, antes de probar nuestro nuevo código, actualicemos rápidamente nuestro archivo cmd/api/routes.go para que nuestro updateMovieHandler solo se use para las solicitudes PATCH.

func (app *application) routes() http.Handler {
	router := httprouter.New()

	router.NotFound = http.HandlerFunc(app.notFoundResponse)

	router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)

	router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)
	router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler)
	router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler)
	// Require a PATCH request, rather than PUT.
	router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.updateMovieHandler)
	router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.deleteMovieHandler)

	return app.recoverPanic(router)
}

8.1 Demostracion

Con eso configurado, verifiquemos que esta funcionalidad de actualización parcial funcione corrigiendo el año de lanzamiento de The Breakfast Club a 1985. De esta manera:

$ curl -X PATCH -d '{"year": 1985}' localhost:4000/v1/movies/4
{
    "movie": {
    "id": 4,
    "title": "The Breakfast Club",
    "year": 1985,
    "runtime": "96 mins",
    "genres": [
        "drama"
    ],
    "version": 2
    }
}

De nuevo, eso se ve bien. Podemos ver que el valor del año se ha actualizado correctamente y el número de versión se ha incrementado, pero ninguno de los otros campos de datos ha cambiado.

También intentemos rápidamente la misma solicitud pero incluyendo un valor de título vacío. En este caso, la actualización se bloqueará y deberías recibir un error de validación, así:

$ curl -X PATCH -d '{"year": 1985, "title": ""}' localhost:4000/v1/movies/4
{
    "error": {
        "title": "must be provided"
    }
}

8.1 Informacion adicional

8.1 Null values en JSON

Un caso especial a tener en cuenta es cuando el cliente suministra explícitamente un campo en la solicitud JSON con el valor null . En este caso, nuestro controlador ignorará el campo y lo tratará como si no se hubiera suministrado. Por ejemplo, la siguiente solicitud no realizaría cambios en el registro de películas (aparte de incrementar el número de versión):

$ curl -X PATCH -d '{"title": null, "year": null}' localhost:4000/v1/movies/4
{
    "movie": {
    "id": 4,
    "title": "The Breakfast Club",
    "year": 1985,
    "runtime": "96 mins",
    "genres": [
        "drama"
        ],
    "version": 3
    }
}

En un mundo ideal, este tipo de solicitud devolvería algún tipo de error de validación. Pero, a menos que escribas tu propio analizador JSON personalizado, no hay forma de determinar la diferencia entre el cliente que no suministra un par clave/valor en el JSON o lo suministra con el valor null. En la mayoría de los casos, probablemente será suficiente explicar este comportamiento especial en la documentación del cliente para el endpoint y decir algo como “Los elementos JSON con valores nulos se ignorarán y permanecerán sin cambios”.

8.2 Control de concurrencia optimista

Los observadores agudos pueden haber notado un pequeño problema en nuestro updateMovieHandler: hay una condición de carrera si dos clientes intentan actualizar el mismo registro de película exactamente al mismo tiempo.

Para ilustrar esto, imaginemos que tenemos dos clientes que usan nuestra API: Alice y Bob. Alice quiere corregir el valor de duración de The Breakfast Club a 97 minutos, y Bob quiere agregar el género ‘comedia’ a la misma película. Ahora imagina que Alice y Bob envían estas dos solicitudes de actualización exactamente al mismo tiempo. Como explicamos en Let’s Go, el http.Server de Go maneja cada solicitud HTTP en su propia goroutine, por lo que cuando esto sucede, el código en nuestro updateMovieHandler se ejecutará concurrentemente en dos goroutines diferentes.

Sigamos lo que podría pasar en este escenario:

  1. La goroutine de Alice llama a app.models.Movies.Get() para recuperar una copia del registro de la película (que tiene el número de versión N ).
  2. La goroutine de Bob llama a app.models.Movies.Get() para recuperar una copia del registro de la película (que todavía tiene el número de versión N ).
  3. La goroutine de Alice cambia la duración a 97 minutos en su copia del registro de la película.
  4. La goroutine de Bob actualiza los géneros para incluir ‘comedia’ en su copia del registro de la película.
  5. La goroutine de Alice llama a app.models.Movies.Update() con su copia del registro de la película. El registro de la película se escribe en la base de datos y el número de versión se incrementa a N+1 .
  6. La goroutine de Bob llama a app.models.Movies.Update() con su copia del registro de la película. El registro de la película se escribe en la base de datos y el número de versión se incrementa a N+2 .

A pesar de realizar dos actualizaciones separadas, solo la actualización de Bob se reflejará en la base de datos al final, porque las dos goroutines estaban compitiendo para realizar el cambio. La actualización de Alice a la duración de la película se perderá cuando la actualización de Bob la sobrescriba con el antiguo valor de la duración. Y esto sucede de manera silenciosa, no hay nada que informe a Alice o a Bob sobre el problema.

Nota: Este tipo específico de condición de carrera se conoce como una “data race” (carrera de datos). Las data races pueden ocurrir cuando dos o más goroutines intentan usar un conjunto de datos compartido (en este ejemplo, el registro de la película) al mismo tiempo, pero el resultado de sus operaciones depende del orden exacto en que el planificador ejecuta sus instrucciones.

8.2 Previniendo el data rece

Ahora que entendemos que existe una carrera de datos y por qué está sucediendo, ¿cómo podemos evitarlo? Hay un par de opciones, pero el enfoque más simple y limpio en este caso es utilizar una forma de bloqueo optimista basado en el número de versión en nuestro registro de película.

La solución funciona así:

  1. Las goroutines de Alice y Bob llaman a app.models.Movies.Get() para recuperar una copia del registro de la película. Ambos registros tienen el número de versión N.
  2. Las goroutines de Alice y Bob realizan sus respectivos cambios en el registro de la película.
  3. Las goroutines de Alice y Bob llaman a app.models.Movies.Update() con sus copias del registro de la película. Pero la actualización solo se ejecuta si el número de versión en la base de datos sigue siendo N. Si ha cambiado, entonces no ejecutamos la actualización y enviamos al cliente un mensaje de error en su lugar.

Esto significa que la primera solicitud de actualización que llega a nuestra base de datos tendrá éxito y quien esté realizando la segunda actualización recibirá un mensaje de error en lugar de que se aplique su cambio.

Para que esto funcione, necesitamos cambiar la declaración SQL para actualizar una película de modo que se vea así:

UPDATE movies
SET title = $1, year = $2, runtime = $3, genres = $4, version = version + 1
WHERE id = $5 AND version = $6
RETURNING version

Observe que en la cláusula WHERE ahora estamos buscando un registro con un ID y un número de versión específicos. Si no se puede encontrar un registro coincidente, esta consulta generará un error sql.ErrNoRows y sabemos que el número de versión ha cambiado (o el registro se ha eliminado por completo). De cualquier manera, es una forma de conflicto de edición y podemos usar esto como un desencadenante para enviar al cliente una respuesta de error adecuada.

8.2 Implementación de bloqueo optimista

De acuerdo, eso es suficiente teoría… ¡pongámoslo en práctica! Comenzaremos creando un error personalizado ErrEditConflict que podemos devolver desde nuestros modelos de base de datos en caso de un conflicto. Lo usaremos más adelante al trabajar con registros de usuario también, por lo que tiene sentido definirlo en el archivo internal/data/models.go de la siguiente manera:

package data

import (
   "database/sql"
   "errors"
)

var (
   ErrRecordNotFound = errors.New("record not found")
   ErrEditConflict   = errors.New("edit conflict")
)

A continuación, actualicemos el método Update() de nuestro modelo de base de datos para ejecutar la nueva consulta SQL y gestionar la situación en la que no se pudo encontrar un registro coincidente.

func (m MovieModel) Update(movie *Movie) error {
   // Add the 'AND version = $6' clause to the SQL query.
   query := `
   UPDATE movies
   SET title = $1, year = $2, runtime = $3, genres = $4, version = version + 1
   WHERE id = $5 AND version = $6
   RETURNING version`
   // Create an args slice containing the values for the placeholder parameters.
   args := []any{
   	movie.Title,
   	movie.Year,
   	movie.Runtime,
   	pq.Array(movie.Genres),
   	movie.ID,
   	movie.Version, // Add the expected 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
}

A continuación, dirígete a tu archivo cmd/api/errors.go y crea un nuevo ayudante editConflictResponse(). Queremos que esto envíe una respuesta 409 Conflict, junto con un mensaje de error en inglés claro que explique el problema al cliente.

package main

...

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

Y como último paso, necesitamos cambiar nuestro updateMovieHandler para que verifique si hay un error ErrEditConflict y llame al ayudante editConflictResponse() si es necesario. Así:

func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Request) {

...

	// Intercept any ErrEditConflict error and call the new editConflictResponse()
	// helper.
	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
	}

	err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil)
	if err != nil {
		app.serverErrorResponse(w, r, err)
	}
}

En este punto, nuestro updateMovieHandler debería estar a salvo de la condición de carrera de la que hemos estado hablando. Si dos goroutines están ejecutando el código al mismo tiempo, la primera actualización tendrá éxito y la segunda fallará porque el número de versión en la base de datos ya no coincide con el valor esperado. Probemos esto utilizando el comando xargs para enviar un montón de solicitudes concurrentes a nuestro endpoint. Suponiendo que tu computadora ejecute las solicitudes lo suficientemente cerca en el tiempo, deberías encontrar que algunas solicitudes tienen éxito pero las demás ahora fallan con un código de estado 409 Conflict. Algo así:

$ xargs -I % -P8 curl -X PATCH -d '{"runtime": "97 mins"}' "localhost:4000/v1/movies/4" < <(printf '%s\n' {1..8})
{
"movie": {
    "id": 4,
    "title": "Breakfast Club",
    "year": 1985,
    "runtime": "97 mins",
    "genres": [
    "drama"
    ],
    "version": 4
}
}
{
    "error": "unable to update the record due to an edit conflict, please try again"
}
{
    "error": "unable to update the record due to an edit conflict, please try again"
}
{
    "error": "unable to update the record due to an edit conflict, please try again"
}
{
    "error": "unable to update the record due to an edit conflict, please try again"
}
{
    "error": "unable to update the record due to an edit conflict, please try again"
}
{
    "error": "unable to update the record due to an edit conflict, please try again"
}
{
    "movie": {
    "id": 4,
    "title": "Breakfast Club",
    "year": 1985,
    "runtime": "97 mins",
    "genres": [
        "drama"
        ],
    "version": 5
    }
}

Para concluir, la condición de carrera que hemos estado demostrando en este capítulo es bastante inofensiva. Pero en otras aplicaciones, esta misma clase de condición de carrera puede tener consecuencias mucho más serias, como cuando se actualiza el nivel de stock de un producto en una tienda en línea o el saldo de una cuenta.

Como mencioné brevemente en Let’s Go, es bueno adquirir el hábito de pensar en las condiciones de carrera cada vez que escribes código y estructurar tus aplicaciones para gestionarlas o evitarlas por completo, sin importar cuán inofensivas puedan parecer en el momento del desarrollo.

8.2 Informacion adicional

8.2 Bloqueo de ida y vuelta

El patrón de bloqueo optimista que hemos utilizado aquí tiene la ventaja de que se puede extender para que el cliente pase el número de versión que esperan en un encabezado If-Not-Match o X-Expected-Version.

En ciertas aplicaciones, esto puede ser útil para ayudar al cliente a asegurarse de que no están enviando su solicitud de actualización basada en información obsoleta. De manera muy general, podrías implementar esto agregando una verificación en tu updateMovieHandler de la siguiente manera:

func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Request) {
	id, err := app.readIDParam(r)
	if err != nil {
		app.notFoundResponse(w, r)
		return
	}
	movie, err := app.models.Movies.Get(id)
	if err != nil {
		switch {
		case errors.Is(err, data.ErrRecordNotFound):
			app.notFoundResponse(w, r)
		default:
			app.serverErrorResponse(w, r, err)
		}
		return
	}

	// If the request contains a X-Expected-Version header, verify that the movie
	// version in the database matches the expected version specified in the header.
	if r.Header.Get("X-Expected-Version") != "" {
		if strconv.FormatInt(int64(movie.Version), 32) != r.Header.Get("X-Expected-Version") {
			app.editConflictResponse(w, r)
			return
	}
	}
	...
}

8.2 Bloqueo en otros campos o tipos

Usar un número de versión entero incrementable como base para un bloqueo optimista es seguro y computacionalmente económico. Recomiendo utilizar este enfoque a menos que haya una razón específica para no hacerlo.

Como alternativa, podrías usar una marca de tiempo (timestamp) de última actualización como base para el bloqueo. Sin embargo, esto es menos seguro, ya que existe la posibilidad teórica de que dos clientes actualicen un registro exactamente al mismo tiempo, y el uso de una marca de tiempo también introduce el riesgo de problemas adicionales si el reloj de tu servidor está incorrecto o se vuelve incorrecto con el tiempo.

Si es importante para ti que el identificador de versión no sea adivinable, una buena opción es utilizar una cadena aleatoria de alta entropía, como un UUID, en el campo de versión. PostgreSQL tiene un tipo UUID y la extensión uuid-ossp que podrías utilizar para este propósito de la siguiente manera:

UPDATE movies
SET title = $1, year = $2, runtime = $3, genres = $4, version = uuid_generate_v4()
WHERE id = $5 AND version = $6
RETURNING version

8.3 Manejando los timeouts en las consultas SQL

Hasta ahora en nuestro código, hemos estado utilizando los métodos Exec() y QueryRow() de Go para ejecutar nuestras consultas SQL. Pero Go también proporciona variantes conscientes del contexto de estos dos métodos: ExecContext() y QueryRowContext(). Estas variantes aceptan una instancia de context.Context como primer parámetro que puedes aprovechar para terminar las consultas de la base de datos en ejecución.

Esta característica puede ser útil cuando tienes una consulta SQL que está tardando más de lo esperado en ejecutarse. Cuando esto sucede, sugiere un problema, ya sea con esa consulta en particular o con tu base de datos o aplicación en general. Probablemente quieras cancelar la consulta (para liberar recursos), registrar un error para una investigación adicional y devolver una respuesta 500 Internal Server Error al cliente. En este capítulo, actualizaremos nuestra aplicación para hacer exactamente eso.

8.3 Imitando una consulta de larga duración

Para ayudar a demostrar cómo funciona todo esto, comencemos adaptando el método Get() de nuestro modelo de base de datos para que simule una consulta de larga duración. Específicamente, actualizaremos nuestra consulta SQL para devolver un valor pg_sleep(10), lo que hará que PostgreSQL duerma durante 10 segundos antes de devolver su resultado.

package data

// ...

func (m MovieModel) Get(id int64) (*Movie, error) {
    if id < 1 {
        return nil, ErrRecordNotFound
    }

    // Update the query to return pg_sleep(10) as the first value.
    query := `
        SELECT pg_sleep(10), id, created_at, title, year, runtime, genres, version
        FROM movies
        WHERE id = $1
    `
    
    var movie Movie

    // Importantly, update the Scan() parameters so that the pg_sleep(10) return value
    // is scanned into a []byte slice.
    err := m.DB.QueryRow(query, id).Scan(
        &[]byte{}, // Add this line.
        &movie.ID,
        &movie.CreatedAt,
        &movie.Title,
        &movie.Year,
        &movie.Runtime,
        pq.Array(&movie.Genres),
        &movie.Version,
    )

    if err != nil {
        switch {
        case errors.Is(err, sql.ErrNoRows):
            return nil, ErrRecordNotFound
        default:
            return nil, err
        }
    }

    return &movie, nil
}

Si reinicias la aplicación y realizas una solicitud al punto final GET /v1/movies/:id, deberías encontrar que la solicitud se queda en espera durante 10 segundos antes de que finalmente recibas una respuesta exitosa que muestra la información de la película. Similar a esto:

$ curl -w '\nTime: %{time_total}s \n' localhost:4000/v1/movies/1
{
  "movie": {
    "id": 1,
    "title": "Moana",
    "year": 2015,
    "runtime": "107 mins",
    "genres": [
      "animation",
      "adventure"
    ],
    "version": 1
  }
}
Time: 10.013534s

Nota: En el comando curl anterior, estamos utilizando la bandera -w para anotar la respuesta HTTP con el tiempo total que tomó completar el comando. Para obtener más detalles sobre la información de temporización disponible en curl, consulta este excelente artículo en el blog.

8.3 Agregar un tiempo de espera de consulta

Ahora que tenemos código que simula una consulta que tarda mucho, impongamos un límite de tiempo para que la consulta SQL se cancele automáticamente si no se completa en 3 segundos. Para hacer esto, necesitamos:

  1. Utilizar la función context.WithTimeout() para crear una instancia de context.Context con un plazo de tiempo límite de 3 segundos.
  2. Ejecutar la consulta SQL utilizando el método QueryRowContext(), pasando la instancia de context.Context como parámetro.

Lo demostraré:

package data

import (
	"context" // New import
	"database/sql"
	"errors"
	"time"
	"greenlight.alexedwards.net/internal/validator"
	"github.com/lib/pq"
)

// ...

func (m MovieModel) Get(id int64) (*Movie, error) {
	if id < 1 {
		return nil, ErrRecordNotFound
	}
	
	query := `
		SELECT pg_sleep(10), id, created_at, title, year, runtime, genres, version
		FROM movies
		WHERE id = $1`
	
	var movie Movie

	// Use the context.WithTimeout() function to create a context.Context which carries a
	// 3-second timeout deadline. Note that we're using the empty context.Background()
	// as the 'parent' context.
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)

	// Importantly, use defer to make sure that we cancel the context before the Get()
	// method returns.
	defer cancel()

	// Use the QueryRowContext() method to execute the query, passing in the context
	// with the deadline as the first argument.
	err := m.DB.QueryRowContext(ctx, query, id).Scan(
		&[]byte{},
		&movie.ID,
		&movie.CreatedAt,
		&movie.Title,
		&movie.Year,
		&movie.Runtime,
		pq.Array(&movie.Genres),
		&movie.Version,
	)

	if err != nil {
		switch {
		case errors.Is(err, sql.ErrNoRows):
			return nil, ErrRecordNotFound
		default:
			return nil, err
		}
	}

	return &movie, nil
}

// ...

Hay un par de cosas en el código anterior que me gustaría enfatizar y explicar:

  • La línea defer cancel() es necesaria porque asegura que los recursos asociados con nuestro contexto siempre se liberarán antes de que el método Get() regrese, evitando así una fuga de memoria. Sin ella, los recursos no se liberarán hasta que se alcance el tiempo de espera de 3 segundos o se cancele el contexto principal (que en este ejemplo específico es context.Background()).

  • La cuenta regresiva del tiempo de espera comienza desde el momento en que se crea el contexto con context.WithTimeout(). Cualquier tiempo empleado ejecutando código entre la creación del contexto y la llamada a QueryRowContext() se contará hacia el tiempo de espera.

Bien, ahora probemos esto.

Si reinicias la aplicación y haces otra solicitud al endpoint GET /v1/movies/:id, ahora deberías recibir una respuesta de error similar a esta después de un retraso de 3 segundos:

$ curl -w '\nTime: %{time_total}s \n' localhost:4000/v1/movies/1
{
  "error": "the server encountered a problem and could not process your request"
}
Time: 3.025179s

Si vuelves a la ventana de la terminal donde se está ejecutando la aplicación, también deberías ver una línea de registro con el mensaje de error “pq: canceling statement due to user request”. Algo así:

$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
time=2023-09-10T10:59:16.722+02:00 level=ERROR msg="pq: canceling statement due to user request"

Sí, la redacción de este mensaje de error puede parecer extraña al principio… hasta que te das cuenta de que el mensaje “canceling statement due to user request” proviene de PostgreSQL. En ese contexto, tiene sentido: nuestra aplicación es el usuario y estamos cancelando intencionalmente la consulta después de 3 segundos.

Sí, esto es realmente bueno y las cosas están funcionando como esperaríamos. Después de 3 segundos, se alcanza el tiempo de espera del contexto y nuestro controlador de base de datos pq envía una señal de cancelación a PostgreSQL†. PostgreSQL luego termina la consulta en ejecución, se liberan los recursos correspondientes y devuelve el mensaje de error que vemos arriba. Luego, se envía al cliente una respuesta 500 Internal Server Error, y el error se registra para que sepamos que algo salió mal.

† Más precisamente, nuestro contexto (el que tiene un tiempo de espera de 3 segundos) tiene un canal Done, y cuando se alcanza el tiempo de espera, el canal Done se cerrará. Mientras se ejecuta la consulta SQL, nuestro controlador de base de datos pq también ejecuta una goroutine en segundo plano que escucha en este canal Done. Si el canal se cierra, pq envía una señal de cancelación a PostgreSQL. PostgreSQL termina la consulta y luego envía el mensaje de error que vemos arriba como respuesta a la goroutine pq original. Ese mensaje de error se devuelve luego al método Get() de nuestro modelo de base de datos.

8.3 Tiempos de espera fuera de PostgreSQL

Hay otra cosa importante que señalar aquí: es posible que la fecha límite de tiempo de espera se alcance antes de que la consulta PostgreSQL incluso comience. Puede recordar que anteriormente configuramos nuestro grupo de conexiones sql.DB para permitir un máximo de 25 conexiones abiertas. Si todas esas conexiones están en uso, entonces cualquier consulta adicional será “encolada” por sql.DB hasta que haya una conexión disponible. En este escenario, o cualquier otro que cause un retraso, es posible que la fecha límite de tiempo de espera se alcance antes de que siquiera esté disponible una conexión de base de datos libre. Si esto sucede, entonces QueryRowContext() devolverá un error context.DeadlineExceeded.

De hecho, podemos demostrar esto en nuestra aplicación estableciendo el número máximo de conexiones abiertas en 1 y realizando dos solicitudes concurrentes a nuestro endpoint. Adelante, reinicie la API usando la bandera -db-max-open-conns=1, así:

$ go run ./cmd/api -db-max-open-conns=1
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development

Luego, en otra ventana de la terminal, realiza dos solicitudes al endpoint GET /v1/movies/:id al mismo tiempo. En el momento en que se alcanza el tiempo de espera de 3 segundos, deberíamos tener una consulta SQL en ejecución y la otra aún “en espera” en el grupo de conexiones sql.DB. Deberías recibir dos respuestas de error que se ven así:

$ curl localhost:4000/v1/movies/1 & curl localhost:4000/v1/movies/1 &
[1] 33221
[2] 33222
$ {
"error": "the server encountered a problem and could not process your request"
}
{
"error": "the server encountered a problem and could not process your request"
}

Cuando regreses a tu terminal original, deberías ver dos mensajes de error diferentes:

$ go run ./cmd/api -db-max-open-conns=1
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
time=2023-09-10T10:59:27.722+02:00 level=ERROR msg="context deadline exceeded"
time=2023-09-10T10:59:27.722+02:00 level=ERROR msg="pq: canceling statement due to user request" addr=:4000 env=development

Aquí, el mensaje de error pq: canceling statement due to user request se relaciona con la terminación de la consulta SQL en ejecución, mientras que el mensaje context deadline exceeded se relaciona con la cancelación de la consulta SQL en cola antes de que se haya liberado una conexión de base de datos gratuita.

En una línea similar, también es posible que el plazo de tiempo de contexto se agote más tarde, cuando se esté procesando la data devuelta por la consulta con Scan(). Si esto sucede, Scan() también devolverá un error context.DeadlineExceeded.

8.3 Actualizando nuestro modelo de base de datos

Vamos a actualizar rápidamente nuestro modelo de base de datos para utilizar un plazo de tiempo límite de 3 segundos para todas nuestras operaciones. Mientras lo hacemos, también eliminaremos la cláusula pg_sleep(10) de nuestro método Get().

package data

import (
	"context"
	"database/sql"
	"errors"
	"time"

	"github.com/lib/pq"
)

func (m MovieModel) Insert(movie *Movie) error {
	query := `
		INSERT INTO movies (title, year, runtime, genres)
		VALUES ($1, $2, $3, $4)
		RETURNING id, created_at, version`
	args := []interface{}{movie.Title, movie.Year, movie.Runtime, pq.Array(movie.Genres)}

	// Create a context with a 3-second timeout.
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	// Use QueryRowContext() and pass the context as the first argument.
	return m.DB.QueryRowContext(ctx, query, args...).Scan(&movie.ID, &movie.CreatedAt, &movie.Version)
}

func (m MovieModel) Get(id int64) (*Movie, error) {
	if id < 1 {
		return nil, ErrRecordNotFound
	}

	// Remove the pg_sleep(10) clause.
	query := `
		SELECT id, created_at, title, year, runtime, genres, version
		FROM movies
		WHERE id = $1`
	var movie Movie
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	// Remove &[]byte{} from the first Scan() destination.
	err := m.DB.QueryRowContext(ctx, query, id).Scan(
		&movie.ID,
		&movie.CreatedAt,
		&movie.Title,
		&movie.Year,
		&movie.Runtime,
		pq.Array(&movie.Genres),
		&movie.Version,
	)
	if err != nil {
		switch {
		case errors.Is(err, sql.ErrNoRows):
			return nil, ErrRecordNotFound
		default:
			return nil, err
		}
	}
	return &movie, nil
}

func (m MovieModel) Update(movie *Movie) error {
	query := `
		UPDATE movies
		SET title = $1, year = $2, runtime = $3, genres = $4, version = version + 1
		WHERE id = $5 AND version = $6
		RETURNING version`
	args := []interface{}{
		movie.Title,
		movie.Year,
		movie.Runtime,
		pq.Array(movie.Genres),
		movie.ID,
		movie.Version,
	}

	// Create a context with a 3-second timeout.
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	// Use QueryRowContext() and pass the context as the first argument.
	err := m.DB.QueryRowContext(ctx, query, args...).Scan(&movie.Version)
	if err != nil {
		switch {
		case errors.Is(err, sql.ErrNoRows):
			return ErrEditConflict
		default:
			return err
		}
	}
	return nil
}

func (m MovieModel) Delete(id int64) error {
	if id < 1 {
		return ErrRecordNotFound
	}

	query := `
		DELETE FROM movies
		WHERE id = $1`

	// Create a context with a 3-second timeout.
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	// Use ExecContext() and pass the context as the first argument.
	result, err := m.DB.ExecContext(ctx, query, id)
	if err != nil {
		return err
	}

	rowsAffected, err := result.RowsAffected()
	if err != nil {
		return err
	}

	if rowsAffected == 0 {
		return ErrRecordNotFound
	}

	return nil
}

8.3 Informacion adicionales

8.3 Usando Peticiones de contexto

Como alternativa al patrón que hemos utilizado en el código anterior, podríamos crear un contexto con un tiempo de espera en nuestros handlers utilizando el contexto de la solicitud como padre y luego pasarlo a nuestro modelo de base de datos.

Pero, y es un gran pero, hacer esto introduce una gran complejidad de comportamiento, y para la mayoría de las aplicaciones, los beneficios no son lo suficientemente grandes como para que la compensación valga la pena. Los detalles detrás de esto son muy interesantes, pero también bastante intrincados y complejos. Por esa razón, lo he discutido más a fondo en este apéndice.

Post Relacionados