Tutorial del esquema
Tutorial del esquemaLección 28: Actualizando grandes conjuntos de datos

Lección 28: Actualizando grandes conjuntos de datos

A veces necesitamos actualizar miles de recursos en una sola acción, como se expresa en el siguiente comentario (publicado en un grupo de comunidad sobre WordPress):

Encuentro que para muchos clientes estoy trabajando con grandes conjuntos de datos (10.000+ variaciones de producto para 1 producto, o 13.000+ archivos multimedia)... inevitablemente los clientes quieren poder editar en masa muchas cosas a la vez, como etiquetar 2000 archivos multimedia con la misma etiqueta.

En esta lección del tutorial exploraremos formas de abordar esta tarea.

Nested Mutations

Para que esta consulta GraphQL funcione, la Creando una configuración del esquema aplicada al endpoint necesita tener las Usar mutaciones anidadas habilitadas

Gracias a las Usar mutaciones anidadas, podemos recuperar y actualizar miles de recursos desde la BD mediante una sola consulta GraphQL:

mutation ReplaceOldWithNewDomainInPosts {
  posts(pagination: { limit: 3000 }) {
    id
    rawContent
    adaptedRawContent: _strReplace(
      search: "https://my-old-domain.com"
      replaceWith: "https://my-new-domain.com"
      in: $__rawContent
    )
    update(input: {
      contentAs: { html: $__adaptedRawContent }
    }) {
      status
      errors {
        __typename
        ...on ErrorPayload {
          message
        }
      }
    }
  }
}

Dependiendo de la resiliencia del sistema, sin embargo, esta única ejecución GraphQL podría poner demasiada carga sobre la BD, incluso haciendo que se caiga.

Paginando la ejecución de la consulta GraphQL

Si actualizar miles de recursos a la vez hace que el sistema se caiga, la solución es simple: En lugar de ejecutar la GraphQL solo una vez para miles de recursos, podemos ejecutarla cientos de veces para docenas de recursos cada vez.

Los siguientes scripts bash primero averiguan el número total de comentarios mediante commentCount, luego calculan los segmentos considerando la variable de entorno $ENTRIES_TO_PROCESS, y calculan los parámetros de paginación y llaman a la consulta GraphQL para cada segmento (simplemente recuperando los comentarios de ese segmento):

# Get the number of comments in the site
GRAPHQL_RESPONSE=$(curl
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"query": "{\n  commentCount\n}"}' \
  https://mysite.com/graphql/)
 
# Extract the number of comments into a variable
COMMENT_COUNT=$(echo $GRAPHQL_RESPONSE \
  | grep -E -o '"commentCount\":([0-9]+)' \
  | cut -d':' -f2-)
 
echo "Number of comments: $COMMENT_COUNT"
 
# How many entries will be processed on each query
ENTRIES_TO_PROCESS=10
 
# Calculate how many requests must be triggered
PAGINATION_COUNT=$(($(($COMMENT_COUNT / $ENTRIES_TO_PROCESS)) + $(($(($COMMENT_COUNT % $ENTRIES_TO_PROCESS)) ? 1 : 0))))
 
echo "Number of requests to process (at $ENTRIES_TO_PROCESS entries per request): $PAGINATION_COUNT"
 
# Execute the requests, at one per second
for PAGINATION_NUMBER in $(seq 0 $(($PAGINATION_COUNT - 1))); do sleep 1 && echo "\n\nPagination number: $PAGINATION_NUMBER\n" && curl -X POST -H "Content-Type: application/json" -d "{\"query\": \"{ comments(pagination: { limit: $ENTRIES_TO_PROCESS, offset: $(($PAGINATION_NUMBER * $ENTRIES_TO_PROCESS)) }) { id date content } }\"}" https://mysite.com/graphql/ ; done

Ejecutando la consulta GraphQL de forma recursiva

Como la solución de arriba involucra scripting bash, debe ejecutarse mediante la CLI (o algún panel de admin o herramienta), limitando su uso.

Podemos replicar la misma lógica dentro de la consulta GraphQL en sí, permitiéndonos ejecutarla ya dentro de WordPress (incluso ya almacenándola como una Persisted Query de GraphQL).

La consulta GraphQL de abajo se ejecuta a sí misma de forma recursiva. Al ser invocada por primera vez:

  • Divide el número total de recursos a actualizar en segmentos (calculados usando la variable $limit proporcionada)
  • Se ejecuta a sí misma mediante una nueva petición HTTP para cada uno de los segmentos (pasando el correspondiente $offset como variable), actualizando así solo un subconjunto de todos los recursos en un momento dado

La consulta GraphQL es recursiva al tener las peticiones HTTP apuntando a la misma URL que la actual (más añadir la variable $offset para ese segmento), para lo cual recuperamos la URL (y también el body, método y cabeceras) desde la petición HTTP actual (mediante la extensión HTTP Request via Schema).

El argumento $async pasado a _sendHTTPRequests se ha establecido a false, para que las peticiones HTTP se ejecuten una después de la otra. Adicionalmente, la variable opcional $delay permite indicar cuántos milisegundos retrasar antes de enviar cada petición.

Una vez que todos los recursos han sido actualizados, la ejecución de la consulta GraphQL alcanza el final y termina:

# When first invoked, we do not pass variable `$offset`
# Then `$offset` is `null`, and dynamic variable `$executeQuery` will be `true`
query ExportExecute(
  $offset: Int
) {
  executeQuery: _notNull(value: $offset)
    @export(as: "executeQuery")
    @remove # Comment this directive to visualize output during development
}
 
# Only calculate the segments on the first invocation of the GraphQL query
query CalculateVars($limit: Int! = 10)
  @depends(on: "ExportExecute")
  @skip(if: $executeQuery)
{
  # Calculate the number of HTTP requests to be sent
  commentCount
  fractionalNumberExecutions: _floatDivide(number: $__commentCount, by: $limit)
    @remove # Comment this directive to visualize output during development
  numberExecutions: _floatCeil(number: $__fractionalNumberExecutions)
  
  # Generate a list of the offset
  arrayOffsets: _arrayPad(array: [], length: $__numberExecutions, value: null)
    @underEachArrayItem(
      passIndexOnwardsAs: "position"
    )
      @applyField(
        name: "_intMultiply"
        arguments: {
          multiply: $position
          with: $limit
        }
        setResultInResponse: true
      )
    @export(as: "offsets")
 
  # Vars needed to generate a list of the HTTP Request inputs,
  # with many of them retrieved from the current HTTP request data
  url: _httpRequestFullURL
    @export(as: "url")
    @remove # Comment this directive to visualize output during development
  method: _httpRequestMethod
    @export(as: "method")
    @remove # Comment this directive to visualize output during development
  headers: _httpRequestHeaders
    @remove # Comment this directive to visualize output during development
  headersInputList: _objectConvertToNameValueEntryList(
    object: $__headers
  )
    @export(as: "headersInputList")
    @remove # Comment this directive to visualize output during development
  body: _httpRequestBody
    @remove # Comment this directive to visualize output during development
  bodyJSONObject: _strDecodeJSONObject(string: $__body)
    @export(as: "bodyJSONObject")
    @remove # Comment this directive to visualize output during development
  bodyHasVariables: _propertyIsSetInJSONObject(
    object: $__bodyJSONObject,
    by: { key: "variables" }
  )
    @export(as: "bodyHasVariables")
    @remove # Comment this directive to visualize output during development
}
 
query GenerateVars
  @depends(on: ["ExportExecute", "CalculateVars"])
  @skip(if: $executeQuery)
{
  bodyJSON: _echo(value: $bodyJSONObject)
    @unless(condition: $bodyHasVariables)
      @objectAddEntry(
        key: "variables"
        value: {}
      )
    @export(as: "bodyJSON")
    @remove # Comment this directive to visualize output during development
}
 
# Generate all the HTTPRequestInput objects to send each of the HTTP requests
query GenerateRequestInputs(
  $timeout: Float,
  $delay: Int
)
  @depends(on: ["ExportExecute", "GenerateVars"])
  @skip(if: $executeQuery)
{
  # Generate a list of the HTTP Request inputs (without the offset)
  requestInputs: _echo(value: $offsets)
    @underEachArrayItem(
      passValueOnwardsAs: "requestOffset"
      affectDirectivesUnderPos: [1, 2]
    )
      @applyField(
        name: "_objectAddEntry",
        arguments: {
          object: $bodyJSON
          underPath: "variables"
          key: "offset"
          value: $requestOffset
        },
        passOnwardsAs: "itemJSON"
      )
      @applyField(
        name: "_echo",
        arguments: {
          value: {
            url: $url
            method: $method
            options: {
              headers: $headersInputList
              json: $itemJSON
              timeout: $timeout
              delay: $delay
            }
          }
        },
        setResultInResponse: true
      )
    @export(as: "requestInputs")
    @remove # Comment this directive to visualize output during development
}
 
# Execute all the generated URLs, either asynchronously or not
query ExecuteURLs
  @depends(on: ["ExportExecute", "GenerateRequestInputs"])
  @skip(if: $executeQuery)
{
  _sendHTTPRequests(
    async: false
    inputs: $requestInputs
  ) {
    statusCode
    contentType
    body
      @remove
    bodyJSON: _strDecodeJSONObject(string: $__body)
  }
}
 
# This is the actual execution of the query.
# In this case, it simply prints the time when it was executed,
# the provided query variables, and the comment IDs for that segment
query ExecuteQuery(
  $offset: Int
  $limit: Int! = 10
)
  @depends(on: "ExportExecute")
  @include(if: $executeQuery)
{
  executionTime: _httpRequestRequestTime
  queryVariables: _sprintf(string: "[$limit: %s, $offset: %s]", values: [$limit, $offset])
  comments(
    pagination: { limit: $limit, offset: $offset }
    sort: { order: ASC, by: ID }
  ) {
    id
  }
}
 
query ExecuteAll
  @depends(on: ["ExecuteURLs", "ExecuteQuery"])
{
  id
    @remove
}

La respuesta es:

{
  "data": {
    "commentCount": 23,
    "numberExecutions": 3,
    "arrayOffsets": [
      0,
      10,
      20
    ],
    "_sendHTTPRequests": [
      {
        "statusCode": 200,
        "contentType": "application/json",
        "bodyJSON": {
          "data": {
            "executionTime": 1689814467,
            "queryVariables": "[$limit: 10, $offset: 0]",
            "comments": [
              { "id": 2 },
              { "id": 3 },
              { "id": 4 },
              { "id": 5 },
              { "id": 6 },
              { "id": 7 },
              { "id": 8 },
              { "id": 9 },
              { "id": 10 },
              { "id": 11 }
            ]
          }
        }
      },
      {
        "statusCode": 200,
        "contentType": "application/json",
        "bodyJSON": {
          "data": {
            "executionTime": 1689814468,
            "queryVariables": "[$limit: 10, $offset: 10]",
            "comments": [
              { "id": 12 },
              { "id": 13 },
              { "id": 16 },
              { "id": 17 },
              { "id": 18 },
              { "id": 19 },
              { "id": 20 },
              { "id": 21 },
              { "id": 22 },
              { "id": 23 }
            ]
          }
        }
      },
      {
        "statusCode": 200,
        "contentType": "application/json",
        "bodyJSON": {
          "data": {
            "executionTime": 1689814470,
            "queryVariables": "[$limit: 10, $offset: 20]",
            "comments": [
              { "id": 24 },
              { "id": 25 },
              { "id": 26 }
            ]
          }
        }
      }
    ]
  }
}